You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 3761710
Browse filesBrowse the repository at this point in the historyBrowse files
fix(storage)!: a record write claims or copies only a file its caller may read (#25951)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DM7SePpQnQZ7NUSKUCvUrek)_Fixes#25692
Clause-②: no (narrowing)
Metadata-protocol: none
## What changes
ADR-0104 D3 (wave 2) gives each `sys_file` at most one owning field
slot. A record write that references a file id either claims an
unclaimed file for the writing slot, or, when another slot owns the
file, copies its bytes into a fresh `sys_file` the writing slot owns
(copy-on-claim). Neither step asked whether the write's caller may read
the file. The claim path now asks the download door's own verdict, under
the write's caller context, and claims or copies only on an allow.
- **One judgement, two callers.** The door's verdict body moves out of
`buildFileReadAuthorizer` into `judgeFileRead`: the uploader, a reader
of the record whose field owns the file (`readableFieldOwners`,
honouring `fileAccessDelegate`), a member of the organization for a file
no record owns (#25693), a reader of any `sys_attachment` parent for an
attachments-scope file. The download door calls it with no change in
behaviour. The claim path calls it through
`buildFileReferenceReadAuthorizer`, which also admits a `public_read`
file and asks nothing on a kernel with no `auth` service, where the door
binds no gate either. One file has one access rule, decided in one
place.
- **The quiet answer, after #25682.** A file the caller may not read is
answered exactly as the organization-scoped lookup answers a file it
cannot see: the id is kept as written, and nothing is claimed or copied.
This follows the precedent #25682 set for the cross-organization half of
the same lifecycle. There is no new error code or status, and the write
cannot tell "may not read" from "does not exist".
- **Ordering.** Constraint verdicts no longer run ahead of the read
check.
- **A write's own copy.** A copy the write itself minted is claimed by
that same write without a second question. The mint is remembered per
write (keyed on the write's dispatch scope). When that record is
missing, the write fails safe: the copy stays unclaimed and is never
claimed for the wrong caller.
- **Outage.** An authorization-store outage
(`AuthzStoreUnavailableError`) fails the write with its declared
envelope rather than reading as either answer. Any other throw from the
judgement is a deny.
- **Unchanged.** System writes and context-less programmatic writes
carry no caller to ask, and reference files as before.
`callerContext` (attachment-access hooks) is exported so the lifecycle
reads a write's caller the same way, with operation-private keys
dropped. The system-context page
(`content/docs/permissions/system-context.mdx`) gains row 47f for the
claim path's system-write exemption, which `check-system-context-census`
requires.
## BREAKING, shipped as `patch`
The changeset carries Before / Now / Fix and the ADR-0087 marker
`not-required (no-migration-prescription)`: no spec key, stored row or
exported declaration moves. A write that referenced a file its caller
may not read now keeps the id unowned. To fix, reference the file as a
caller who may read it, or upload it again. A server-side process that
copies files between records on purpose runs as a system write.
## Tests
- `file-reference-lifecycle.test.ts`: 15 new cases against a stand-in
verdict. They cover refusal on insert and update for owned files (no
copy) and unclaimed files (no claim, a tombstone not revived), per-id
judging of a multiple-value field, the constraint ordering with its
control, the verdict being asked with the caller context
(operation-private keys dropped), outage propagation (the record is not
written), and system, context-less and unbound writes unchanged.
Controls show an admitted caller still copies and claims.
- `file-reference-read-verdict.integration.test.ts` (new): 11 cases on a
real ObjectQL and SqlDriver with `StorageServicePlugin` booted. For each
file, the download door's answer to the writer and the claim path's
outcome for the same writer agree. A refused file gets 403 and is kept;
a served file gets 200 and is copied or claimed. The cases span the
field-owned, delegate, attachments, uploader, `public_read` and
unclaimed arms, plus outage and other-fault pins and a system write.
- Package suite: 65 files, 1179 tests pass. `tsc -p tsconfig.test.json`
compiles both files.
## Ablations
Every negative pin was put back to the refused behaviour and shown red,
then restored. Each leg ran through `scripts/ablation-replace.mjs`: the
anchor hit 1 → 0, the blob changed, the restored blob equals HEAD, and
`git diff HEAD` was empty.
| Leg | Mutation | Red |
| --- | --- | --- |
| A | the lookup ignores the verdict | 15: every unit refusal, ordering,
caller-context and outage pin, and every integration refusal and fault
pin. Every integration allow control stays green. |
| B | the lifecycle swallows a verdict throw | 2: the unit and
integration outage pins |
| C | the plugin's binding admits everyone | 5: the integration refusal
and fault pins |
| D | the plugin's binding stops re-raising an outage | 1: the
integration outage envelope pin |
| E | a write's own copy is asked again | 1: the unit control "claimed
without a second question" |
| F | a system write is asked | 1: the unit system-write pin |
## Gates
All on head `4f3c21e5`. The service-storage closure was built first,
then the whole repo (`pnpm build`, 119/119 tasks).
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 102 gates. All 102 exited 0, and
`--ran` reconciles them: 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN.
- `pnpm lint` (repo-wide `eslint . --no-inline-config`): exit 0.
- `check:commit-card-trailers --range origin/main..HEAD`: all 6 commits
carry the model-free trailer pair.
- Dogfood battery (`pnpm --filter @objectstack/dogfood test`, no shard,
no path, under the verify lock): 295 of 299 files passed and 3 were
skipped; 2522 of 2541 tests passed and 18 were skipped. The one failure
was `packaged-flow-write-door-parity.dogfood.test.ts`, "a flow no
package ships is still created, updated and removed through the same
door", which hit its 5 s timeout while the 102 gates ran on the same 4
cores. Re-run alone under the lock: 5/5 passed. The test touches no file
field.
## Acceptance notes
- #25694 (queued after this card, same file) should be re-read against
this head before work on it starts.
- The ADR-0104 D3 text is amended in a separate docs PR, so this code PR
carries no Tier H surface.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016DTRymn12to8YSGMcTzS7h)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(storage)!: a record write claims or copies only a file its caller may read
6
+
7
+
The file-reference claim path (ADR-0104 D3: a file field holds a `sys_file` id, at most one record's field owns a file, and writing an id another record owns copies the bytes into a fresh file the writing record owns) now asks, before it copies or claims a referenced file, whether the write's caller may read that file. The question is the download door's own verdict, asked under the write's caller context: the uploader, a reader of the one record whose field owns the file (its object's `fileAccessDelegate` where one is declared), a member of the organization for a file no record owns, a reader of a parent record for an attachments-scope file, anyone for a `public_read` file. The door and the claim path now call one judgement, so one file has one access rule.
8
+
9
+
A file the caller may not read is answered as the organization-scoped lookup answers a file it cannot see: the value is kept as written, nothing is claimed or copied, and constraint verdicts no longer run ahead of the read check. A copy this write made is claimed by the same write without a second question. System writes and context-less programmatic calls have no caller to ask and reference files as before; so does a kernel with no `auth` service, where the download door binds no gate. An authorization-store outage fails the write with its declared envelope instead of reading as either answer.
10
+
11
+
Clause-②: no (narrowing) — a record write reaches fewer files: a file its caller may not read is no longer copied or claimed for it. Nothing that was refused becomes accepted.
12
+
13
+
Metadata-protocol: none (a write path's file bookkeeping; nothing a metadata author writes changes)
14
+
15
+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which files the file-reference claim path copies or claims for a record write (those its caller may read). The remedy is a change of who writes the reference (a caller who may read the file), never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
16
+
17
+
**BREAKING** for a write that referenced a file its caller may not read, shipped as `patch` because the metadata protocol does not move.
18
+
19
+
-**Before.** The claim path copied or claimed a referenced file without asking whether the write's caller may read it.
20
+
-**Now.** Both happen only when the caller may read the file under the download door's rule. Otherwise the record keeps the id as written and owns nothing: the file's readers are unchanged, and the written record's readers gain no access to it through that record.
21
+
-**Fix.** Reference a file as a caller who may read it (its uploader, or a reader of the record that owns it), or upload the file again. A server-side process that copies files between records on purpose runs as a system write.
Copy file name to clipboardExpand all lines: content/docs/permissions/system-context.mdx
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -178,6 +178,7 @@ One of the largest consumers of the flag;
178
178
| 47c | The inbound-mail IMAP sync: `syncMailbox` and `syncAll` admit the system context ALONE | service-mailbox | Get: the sync job reads every mailbox row (its organization, address and IMAP cursor), writes the cursor back, and hands each fetched message to `ingest` under the system context. Lose: nothing a user held — every other caller is refused (`PERMISSION_DENIED`), because a sync stores a mailbox's mail and moves its cursor |`packages/services/service-mailbox/src/sync/mailbox-sync-service.ts#syncMailbox`, `#syncAll`|
179
179
| 47d | The print service REFUSES the system context: `printRecordToFile` admits a human user acting for themselves alone | service-print | Get: nothing — a system caller is refused (`PERMISSION_DENIED`) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it |`packages/services/service-print/src/print-service.ts#refuseUnlessHumanCaller`|
180
180
| 47e | The caller-scoped attach verbs REFUSE the system context: `attachFileAs` and `assertMayAttach` admit a signed-in user alone | service-storage | Get: nothing — a system caller is refused (`PERMISSION_DENIED`); the system attaches through `attachFile`, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them |`packages/services/service-storage/src/file-attachment-service.ts#requireUserContext`|
181
+
| 47f | The file-reference claim path asks no read verdict of a system write: a record write claims or copies a referenced file only as its caller may read it (the download door's verdict), and a system write carries no caller to ask | service-storage | Get: a system write claims or copies any file the organization-scoped lookup reaches, as before #25692; a deliberate server-side copy between records runs this way. Lose: nothing a user held — a user write is judged by the download door's verdict, and a context-less programmatic write is not asked either |`packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf`|
181
182
182
183
### 5. Actions, metadata plane, provenance, the organization wall
0 commit comments