Skip to content

Commit d6b88f1

Browse files
fix(service-storage): a member attaches a file from the browser again — the upload's own completion attaches it as them, after #31031 (#31072)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc Clause-②: yes (widening) Metadata-protocol: added Skills-docs: updated here — `content/docs/permissions/attachments-access.mdx` and `content/docs/api/plugin-endpoints.mdx` state the `attachTo` key of the upload-complete request and its refusals; no file under `skills/**` teaches the upload-complete request (a `git grep` of `skills/` finds no `upload/complete` and no `CompleteUploadRequest`). ## 维护者速读 - 改了什么:记录附件面板在"完成上传"的同一次调用里,以成员本人身份把文件挂到记录上(`attachTo`),不再走第二步通用数据接口。 - 为什么改:#31031 之后,没有 `sys_file` 读权限的成员从浏览器挂附件,第二步 `POST /data/sys_attachment` 被拒(400 `reference_not_found`)。 - 风险与回滚:担保只覆盖调用者本人上传、仍为 pending、尚未被任何记录持有的那一个文件,并且先过挂载授权;通用数据接口照旧拒绝;担保在本次写入后即失效,钩子拿不到。回滚 = revert 本 PR,面板回到被拒状态。 - 先看:`file-attachment-service.ts` 的 `attachUploadAs`(担保前的各道检查)、`storage-routes.ts` 完成上传门的 `attachTo` 分支、`engine-defaults-and-autonumbers.ts` 的 `buildHookApi`(剥除担保)。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/attachments-browser-attach.dogfood.test.ts`(先构建依赖闭包),预期 8 passed。 - 需要你做的:无,席位落地。 ## What changes **The rule.** The upload-complete door (`POST /api/v1/storage/upload/complete`) takes an optional `attachTo: { parentObject, parentId }`. With it, the door first runs every gate it already ran: the session, a malformed `attachTo` (400), an id that names no file (404), the upload ownership rule (403), the starting organization, and the held bytes (409). It then hands the upload to the file-attachments service's new `attachUploadAs`, running as the caller. The answer is the committed file plus its new `attachmentId`. `attachUploadAs` vouches for one `sys_file` and nothing else. Every condition below must hold, in this order, before the vouch is spent: - The file is the caller's own upload. Another user's id and an id that names no file get the same answer. - It is `pending`. - It is in the `attachments` scope. - It is within the caller's write reach. - No record holds it yet (`findFileHolder`: no `sys_attachment` row and no `ref_*` column). - Its bytes are held. - The caller may attach to the parent: the `sys_attachment` create permission, the parent-edit attach rule, and `enable.files`. - The parent is in the file's organization. The verb then writes the `sys_attachment` row (vouching for that `file_id`) and commits the file, as one unit. If the write fails, the upload stays `pending`. **One-shot vouch.** The vouch is spent on the verb's own write. `buildHookApi` builds every hook's `ctx.api`, plus `withRunAs('user')` and `sudo` derived from it, from a context with the mark stripped (`withoutVouchedReferences`, `@objectstack/core`). A hook running under the vouched insert that names the same file is judged through the caller's read door. **Protocol.** The spec's `CompleteUploadRequestSchema` gains the optional `attachTo` key, and `FileUploadResponseSchema` gains an optional `attachmentId`. So `Metadata-protocol:` is `added`, and `@objectstack/spec` is graded `minor`. **Unchanged.** - No caller gains a read of `sys_file`. - `POST /api/v1/data/sys_attachment` naming a `sys_file` the caller cannot read is still refused `reference_not_found`. - A completion without `attachTo` commits exactly as before. - A mount with no file-attachments service refuses `attachTo` with 501. **Console.** - The Attachments panel completes its upload with `attachTo` and makes no second call. It keeps its refusal messages. - The upload adapter gains `attachTo` and returns the `attachmentId`. **Why the complete door, not a new route.** - `pending` is the proof that the file came from this flow and was never attached. - The commit and the attach are one unit, so no committed-but-unattached file is left behind. - The panel already calls this door, so its flow loses a step instead of gaining one. **Other paths checked.** - The SDK's `storage.upload` has no attach step. - MCP has no attach tool. - No other console path creates `sys_attachment`. None of them needed a change. **Docs.** - `content/docs/permissions/attachments-access.mdx` - `content/docs/api/plugin-endpoints.mdx` - the service-storage README - the generated storage API reference **Recounted.** The tenant audit census counts the verb's new `sys_attachment` insert (265 to 266 write call sites), and the strictness ledger counts the new `attachTo` object. ## Tests - `file-attachment-service.test.ts`, 13 new `attachUploadAs` cases. - Green leg: a member without `sys_file` read attaches. The file is committed, `uploaded_by` is the member, and `sys_file` stays unreadable to them. - Refused: - another user's id and a nonexistent id (same answer), - a committed upload, an already held upload, - the `user` scope, missing bytes, - a sharing deny, an explain deny, - a parent without `enable.files`, an unreadable parent, - a system context. - After the verb, the member's own direct insert naming the same file is still refused `reference_not_found`. - `storage-routes.complete-attach.test.ts` (new, 15 cases). - The door's own gates run first, and a refused request never reaches the attacher: 401, 403, 404, 409, and 400 for five malformed `attachTo` shapes. - The attacher's declared refusals are relayed with their code. A validation refusal becomes `VALIDATION_FAILED`. An undeclared fault is a generic 500. - A mount with no attacher answers 501. - A completion without `attachTo` is unchanged. - `engine-unreadable-reference-target.test.ts`, 3 new cases. A hook writing through `ctx.api`, or through `ctx.api.withRunAs('user')`, under the vouched insert, naming the same unreadable target, is refused. The control: under a system write, the same hook's write lands. - `attachments-browser-attach.dogfood.test.ts` (new, 8 cases), at the HTTP level on the showcase boot, as a member holding `showcase_contributor` plus `sys_attachment` read/create and nothing on `sys_file`. - Green leg: presign, then PUT, then complete with `attachTo` gives 200. One committed file, one attachment in the member's name. The panel's list read and the download URL work, and `sys_file/:id` stays 403. - Controls: - the generic two-step is refused 400 `reference_not_found`; - another user's upload id is refused 403 and stays pending; - a nonexistent id gets 404; - another user's invoice is refused and the upload stays pending; - re-attaching gets 409; - a `'*'` reader works through both flows. - Console: - `RecordAttachmentsPanel.test.tsx` uses the real adapter with a stubbed fetch. The complete body carries `attachTo`, a refusal maps to friendly copy, and a missing `attachmentId` is an error. - The `affordanceGrant-12047` control asserts the adapter gets `attachTo` and that `dataSource.create` is never called. - The write census in `affordanceGrantMap-12082.test.tsx` drops the panel's `handleFiles :: create` site, since the panel no longer calls `create`. Its `attachmentUpload` row is still read where the upload is offered, and its delete site is unchanged. ## Acceptance notes - The order's controls are all pinned in the dogfood: - another user's id, - a nonexistent id, - no attach right on the parent, - the direct generic POST still refused, - the `'*'` reader working. - The vouch premise is pinned in the unit tests: pending, the caller's own upload, through this flow, not yet attached. - One-shot: pinned in objectql; red under the ablation below. - The door answers 404 for an id that names no file and 403 for another user's id. That distinction predates this change: the door's existing `requireUploader` path already makes it on completion without `attachTo`. The verb's own answer is identical for both. **Follow-up.** The call-site census gate (`check:vouch-for-reference-callers`) has moved out of this PR. It pins the code that sets the vouched-reference mark to an allowlist: `file-attachment-service.ts`, 2 calls. Its parts: - the script and its root alias, - a core `repo` vitest pin, - the Test Core input wiring, - a `lint.yml` step. It lands in its own PR after this one merges. That PR is Tier H, through the maintainer, because of the workflow step. This PR touches no governed surface. ## Verification <details><summary>Ablation: the vouch in attachUploadAs</summary> The verb's insert context `vouchForReference(unitContext as ExecutionContext, …)` was replaced with a plain `unitContext` through `scripts/ablation-replace.mjs`. service-storage was rebuilt, and `ablation-dist-preflight` confirmed the marker is present in 2 built files. - Unit (`-t attachUploadAs`): **2 failed, 11 passed**. Red: the member attach, and the case that first attaches through the verb and then tries a direct insert. Its setup attach now fails. - Dogfood: **2 failed, 6 passed**. Red: the member end-to-end attach, and the re-attach control, whose first completion must succeed. Green: the generic door refusal, another user's id, the nonexistent id, another user's invoice, and the `'*'` reader through both flows. - Restore: blob == HEAD (`24e738f31e20`), source clean. Rebuilt, and preflight `--absent` confirmed the marker is absent from all 6 built files. </details> <details><summary>Ablation: the strip in buildHookApi</summary> `withoutVouchedReferences(...)` was removed from `buildHookApi`. Result: `engine-unreadable-reference-target.test.ts` **2 failed, 122 passed**. Red: the `ctx.api` and `withRunAs('user')` hook cases. Green: the system-write control. Restore: blob == HEAD. </details> <details><summary>Suites at head cb31937 (main merged at 5210acf), under the verify lock</summary> - `pnpm install --frozen-lockfile && pnpm build`: 119 of 119 tasks. - `@objectstack/core`: test 3143 passed; `test:repo` 94 passed. - `@objectstack/objectql`: `engine-unreadable-reference-target.test.ts` 124 passed. The whole package (11372 passed) was run before the census gate was split out; that commit does not touch objectql. - `@objectstack/service-storage`: test 1388 passed; typecheck clean. - Dogfood: - files: `attachments-browser-attach`, `attachments-permission-matrix`, `print-service-archive`. - result: 46 passed, 1 skipped. - the skip is the existing `skipIf(!organizationsAvailable)` block. - UI: - `affordanceGrantMap-12082.test.tsx`: 55 passed. - `plugin-form`, `app-shell` and `data-objectstack` whole packages: 23858 passed, 10 skipped. - `turbo run type-check` for `@object-ui/app-shell` and `@object-ui/data-objectstack`: 68 of 68 tasks. - `@objectstack/spec` `check:generated`: all 14 artifacts current. - The full dogfood suite was not run: the title has no `!` and Clause-② carries no `(narrowing)` arm. </details> <details><summary>Gates</summary> - `dispatch-gates --commands` at cb31937 derives 117 commands, and all 117 were run and exited 0. `--ran` reconciles them: 117 derived, 117 run, 0 not measured, 0 unrun. - The three that need the verify lock ran under it, exactly as printed: `pnpm --dir ui exec vitest run scripts/`, `spec check:generated` and `check:type-check-debt`. - `check:self-test-wired`: green. - `check-governed-merges.mjs --pr 31072`: NOT governed. 0 of 26 paths hit the register, and `git diff origin/main...HEAD -- .github/` is empty. - The changeset gates were run against the merge base 5210acf: `check-changeset-no-major`, `check-adr-0087-registration` (non-breaking) and `check-empty-changeset`, all green. `check-changeset-no-major` was also fed a `pull_request` payload carrying this body: its level axis passes, reading clause-② `yes (widening)`, metadata-protocol `added`, and `@objectstack/spec: minor`. </details> https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cb8f520 commit d6b88f1

26 files changed

Lines changed: 1449 additions & 119 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/service-storage': patch
6+
'@object-ui/data-objectstack': patch
7+
'@object-ui/app-shell': patch
8+
---
9+
10+
fix(service-storage): a member attaches a file from the browser again — the upload's own completion attaches it as them, restoring the Attachments panel after #31031
11+
12+
Clause-②: yes (widening)
13+
Metadata-protocol: added
14+
Skills-docs: updated here — `content/docs/permissions/attachments-access.mdx` and `content/docs/api/plugin-endpoints.mdx` state the `attachTo` key of the upload-complete request and its refusals; no file under `skills/**` teaches the upload-complete request (a `git grep` of `skills/` finds no `upload/complete` and no `CompleteUploadRequest`).
15+
16+
A signed-in member who may attach files, and holds no read of `sys_file`, can attach a file from a record's Attachments panel again. Since the write-time reference check began judging a `lookup` value through the caller's own read door, the panel's second step — `POST /api/v1/data/sys_attachment` naming the file it had just uploaded — was refused `400 VALIDATION_FAILED` (`file_id`, `reference_not_found`) for such a member.
17+
18+
The rule now: the upload-complete door (`POST /api/v1/storage/upload/complete`) takes an optional `attachTo: { parentObject, parentId }`. With it, the door runs every gate it already asked — the session, the upload ownership rule, the starting organization, the held bytes — and then hands the upload to the file-attachments service's `attachUploadAs`, which runs as the caller. That verb vouches for one `sys_file` only: a `pending` upload in the `attachments` scope that the caller uploaded, that no record holds yet, in the caller's write reach and in the parent's organization. It asks the attach authorization first (the permission to create an attachment, the parent-edit attach rule, `enable.files`), then writes the `sys_attachment` row and commits the file as one unit. The answer is the committed file with its new `attachmentId`. Another user's upload id, an id that names no file, a committed or already attached upload, and a record the caller may not attach to are refused, and the upload stays `pending`.
19+
20+
The vouch is spent on that one write: a hook that runs under it — as the user or as the system — writes without the mark (`withoutVouchedReferences`, `@objectstack/core`), so a hook naming the same `file_id` is judged through the caller's read door.
21+
22+
Unchanged: no caller gains a read of `sys_file` or of anything else. The generic data door keeps its rule — a `sys_attachment` create through `POST /api/v1/data/sys_attachment` naming a `sys_file` the caller cannot read is still refused `reference_not_found`. A completion without `attachTo` commits exactly as before. A mount with no file-attachments service bound refuses `attachTo` with `501 NOT_IMPLEMENTED`.
23+
24+
The console's Attachments panel (`@object-ui/app-shell`) now completes its upload with `attachTo` and makes no second call, keeping its refusal messages; the upload adapter (`@object-ui/data-objectstack`) gains an `attachTo` option and returns the `attachmentId` it receives.

‎content/docs/api/plugin-endpoints.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ other adapter.
154154
| Method | Endpoint | SDK | Description |
155155
|:-------|:---------|:----|:------------|
156156
| POST | `/storage/upload/presigned` | `storage.getPresignedUrl` | Step 1 — mint an upload target for a new `sys_file` row |
157-
| POST | `/storage/upload/complete` | `storage.upload` | Step 3 — commit the uploaded bytes |
157+
| POST | `/storage/upload/complete` | `storage.upload` | Step 3 — commit the uploaded bytes; with `attachTo`, also attach the file to a record as the caller |
158158
| POST | `/storage/upload/chunked` | `storage.initChunkedUpload` | Open a chunked / resumable session |
159159
| PUT | `/storage/upload/chunked/:uploadId/chunk/:chunkIndex` | `storage.uploadPart` | Send one chunk |
160160
| POST | `/storage/upload/chunked/:uploadId/complete` | `storage.completeChunkedUpload` | Assemble the parts |
@@ -167,6 +167,13 @@ other adapter.
167167
so for a file whose type the browser does not know (`file.type === ''`) the
168168
helper sends `application/octet-stream`; a typed file keeps its own type.
169169

170+
To attach the upload to a record's Attachments, send `attachTo: { parentObject,
171+
parentId }` with step 3 (and start the upload with `scope: 'attachments'`): the
172+
commit and the `sys_attachment` row are one unit, written as the signed-in
173+
caller, and the answer carries the `attachmentId`
174+
([attachment access](/docs/permissions/attachments-access)). A refused attach
175+
leaves the upload pending.
176+
170177
A file field takes the `fileId` only after step 3. A signed-in record write
171178
that names a file whose upload was never completed is refused with `400
172179
VALIDATION_FAILED`, one `invalid_value` entry naming the field and the file,

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,39 @@ drops a non-system caller's `uploaded_by` and reports it in `droppedFields`,
7272
and the rest of the write lands. System-context writes keep the value they
7373
supply.
7474

75+
### Attaching an upload from the browser
76+
77+
The Attachments panel attaches a file in the call that completes its upload:
78+
the presigned upload's `POST /api/v1/storage/upload/complete` carries
79+
`attachTo: { parentObject, parentId }`, and the server commits the file and
80+
writes its `sys_attachment` row as one unit, **as the signed-in user**. Every
81+
check above judges that user: the permission to create an attachment, edit
82+
access to the record, the record object's `enable.files`, and the file
83+
reference itself. The answer carries the new `attachmentId`. A refused attach
84+
leaves the upload `pending` and attached to nothing, and the pending-upload
85+
retention reclaims it.
86+
87+
The file must be the caller's own upload, still `pending`, in the
88+
`attachments` scope, attached to nothing and owned by no record field, with its
89+
bytes stored. Any other file id — another user's upload, an id that names no
90+
file — is refused before anything is written:
91+
92+
| Code | Status | When |
93+
| --- | --- | --- |
94+
| `PERMISSION_DENIED` | 403 | The file is another user's upload (the commit door's uploader rule) |
95+
| `FILE_NOT_FOUND` | 404 | The id names no file |
96+
| `RESOURCE_CONFLICT` | 409 | The upload is already completed, already attached or owned by a field, or its bytes are not stored |
97+
| `INVALID_REQUEST` | 400 | `attachTo` is malformed, or the upload was not started in the `attachments` scope |
98+
| `NOT_IMPLEMENTED` | 501 | The deployment composes no auth service or no data engine, so there is no caller to attach as |
99+
100+
This is how a member who may attach files but holds no read on `sys_file`
101+
attaches one: a `sys_attachment` create through the generic data API
102+
(`POST /api/v1/data/sys_attachment`) names its `file_id` as any other
103+
`lookup` value, which the caller must be able to read, and a file they cannot
104+
read is refused `400 VALIDATION_FAILED` (`reference_not_found` on `file_id`).
105+
The attach on completion vouches for that one file, in that one call, and
106+
grants no read of `sys_file` or of anything else.
107+
75108
| Code | Status | When |
76109
| --- | --- | --- |
77110
| `RECORD_NOT_ACCESSIBLE` | 403 | The session names no signed-in user to record as the uploader |
@@ -224,6 +257,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
224257
| Operation | Requirement | Deny code |
225258
| --- | --- | --- |
226259
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
260+
| Attach on upload completion (`attachTo`) | the caller's own pending, unattached `attachments`-scope upload, then every create check | `PERMISSION_DENIED` (403) / `RESOURCE_CONFLICT` (409), then the create codes |
227261
| Attach (create), re-point | a by-id update of the parent record by the caller would be admitted | `ATTACHMENT_PARENT_ACCESS` (403); `OBJECT_NOT_FOUND` (404) when the parent object is not registered |
228262
| List / read | inherits parent read visibility | *(filtered out)* |
229263
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
9393
changed, only whether this instrument could see them.
9494

9595
**The expensive failure direction is a keyword.** Sites whose receiver the author
96-
typed `any` have no type to read, and there are 62 of them — just over a fifth
96+
typed `any` have no type to read, and there are 63 of them — just over a fifth
9797
of the population, concentrated in exactly the seed and bootstrap paths this
9898
control exists for. Scoring an unreadable receiver as "not an engine" would have
9999
dropped every one of them silently, with a clean exit and a smaller number that
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **55 of the 265 sites are spelled that way**. A
135+
forwarding shim cannot, and **55 of the 266 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **265** |
200+
| 175 write call sites | quoted in the merged changeset | **266** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **188 of 265** decidable, **77** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 148 decidably elevated, 0 decidably not, 109 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **189 of 266** decidable, **77** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 148 decidably elevated, 0 decidably not, 110 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -210,18 +210,18 @@ be stated is what this instrument counts, which is written above and re-runnable
210210
at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213-
one, and both are counted in the generated tables below: the 62 sites reached
213+
one, and both are counted in the generated tables below: the 63 sites reached
214214
through an erased (`any`) receiver, and the 69 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
148 of 265 (56%) as decidably elevated, with 109 more whose elevation is a
220+
148 of 266 (56%) as decidably elevated, with 110 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 265`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 266`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,28 +233,28 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **265** |
237-
| …whose object name is statically decidable | 188 |
236+
| write call sites on the application surface | **266** |
237+
| …whose object name is statically decidable | 189 |
238238
| …whose object name is chosen at run time | 77 |
239-
| …against an object with tenancy ENABLED | 182 |
239+
| …against an object with tenancy ENABLED | 183 |
240240
| …against an object that declares tenancy off | 6 |
241-
| threading a tenant context | 202 |
241+
| threading a tenant context | 203 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 55 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246246
| threading a decidably ELEVATED (`isSystem`) context | 148 |
247247
| threading a context that is decidably NOT elevated | 0 |
248-
| threading a context whose elevation is a run-time fact | 109 |
248+
| threading a context whose elevation is a run-time fact | 110 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252252
| receiver carried a readable engine type | 203 |
253-
| receiver erased, placed by the object NAME | 52 |
253+
| receiver erased, placed by the object NAME | 53 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 119 |
257+
| object name spelled inline | 120 |
258258
| object name spelled through a `const` | 69 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 70 |
@@ -307,7 +307,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-06 at `4addb47fd`.
310+
Measured on 2026-11-07 at `ce0325138`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |

‎content/docs/references/api/storage.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ const result = CompleteChunkedUploadRequestSchema.parse(data);
103103
| :--- | :--- | :--- | :--- |
104104
| **fileId** | `string` | ✅ | File ID returned from presigned request |
105105
| **eTag** | `string` | optional | ETag the client reports for the uploaded object. Stored on the file record as sent and not compared with anything: no storage adapter reports a stored object's ETag. Before committing, the door checks that the storage backend holds bytes under the file's key and, when the upload declared a size, that the held size equals it. |
106+
| **attachTo** | `{ parentObject: string; parentId: string }` | optional | Attach the uploaded file to this record in the same call, as the signed-in caller: the commit and the attachment row are one unit, and the answer carries the new attachmentId. Only the uploader's own pending upload in the attachments scope can be attached this way. The attachment is judged as the caller's own create: the permission to create an attachment, edit access to the record, and the record object's enable.files opt-in. A refused attach leaves the upload pending and uncommitted. |
107+
108+
### Nested Shape: `CompleteUploadRequest.attachTo`
109+
110+
| Property | Type | Required | Description |
111+
| :--- | :--- | :--- | :--- |
112+
| **parentObject** | `string` | ✅ | Object of the record the file is attached to (enable.files: true) |
113+
| **parentId** | `string` | ✅ | Id of the record the file is attached to |
106114

107115

108116
---
@@ -211,6 +219,7 @@ const result = CompleteChunkedUploadRequestSchema.parse(data);
211219
| **created** | `string` | ✅ | Creation timestamp |
212220
| **etag** | `string` | optional | Entity tag |
213221
| **fileId** | `string` | optional | Opaque sys_file id (ADR-0104 D3 file-as-reference) |
222+
| **attachmentId** | `string` | optional | Id of the sys_attachment row the commit created, when the request carried attachTo |
214223

215224

216225
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 500 |
22+
| `api/` | 501 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 265 |
37-
| Object name statically decidable | 188 |
36+
| Write call sites | 266 |
37+
| Object name statically decidable | 189 |
3838
| Object name chosen at run time | 77 |
39-
| Against a tenancy-enabled object | 182 |
39+
| Against a tenancy-enabled object | 183 |
4040
| Against an object declaring tenancy off | 6 |
41-
| Threading a tenant context | 202 |
41+
| Threading a tenant context | 203 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 55 |
4545
| …and decidably tenancy-enabled | 29 |
4646
| Threading a decidably elevated context | 148 |
4747
| Threading a decidably non-elevated context | 0 |
48-
| Threading a context of undecidable elevation | 109 |
48+
| Threading a context of undecidable elevation | 110 |
4949

5050
## Subtractions the census could NOT defend — enforced
5151

@@ -90,7 +90,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-11-06 at `4addb47fd`.
93+
Measured on 2026-11-07 at `ce0325138`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
@@ -270,7 +270,7 @@ Measured on 2026-11-06 at `4addb47fd`.
270270
| `packages/services/service-storage/src/backfill-sys-file-organizations.ts` | `update` | `sys_file` | enabled | context, elevation undecidable | 1 |
271271
| `packages/services/service-storage/src/backfill-sys-file-public-scope.ts` | `update` | `sys_file` | enabled | context, elevation undecidable | 1 |
272272
| `packages/services/service-storage/src/file-attachment-service.ts` | `delete` | `sys_attachment` | enabled | context, elevation undecidable | 1 |
273-
| `packages/services/service-storage/src/file-attachment-service.ts` | `insert` | `sys_attachment` | enabled | context, elevation undecidable | 1 |
273+
| `packages/services/service-storage/src/file-attachment-service.ts` | `insert` | `sys_attachment` | enabled | context, elevation undecidable | 2 |
274274
| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `insert` | `sys_file` | enabled | context, elevation undecidable | 1 |
275275
| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 2 |
276276
| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_file` | enabled | options unreadable | 1 |

‎packages/core/src/security/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ export {
544544
VOUCHED_REFERENCES,
545545
vouchForReference,
546546
isVouchedReference,
547+
withoutVouchedReferences,
547548
type VouchedReference,
548549
} from './vouched-reference.js';
549550

0 commit comments

Comments
 (0)