Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4464fd0
wip: a service verb vouches for the one file reference it created
claude Nov 6, 2026
59d7107
test(service-storage): a member attaches through attachFileAs while s…
claude Nov 6, 2026
98c5826
test(dogfood): a member who may attach files archives a print as thei…
claude Nov 6, 2026
78b1698
chore(changeset): the attach verb vouches for the one file it created
claude Nov 6, 2026
4e9e098
test(objectql, service-storage): type the engine options the new pins…
claude Nov 6, 2026
b018a55
Merge remote-tracking branch 'origin/main' into claude/sec-hardening-r16
claude Nov 6, 2026
b3e5ab4
Merge remote-tracking branch 'origin/main' into claude/sec-hardening-r17
claude Nov 6, 2026
b905481
wip: the upload-complete door declares an attachTo target
claude Nov 6, 2026
2d0bb75
wip: attachUploadAs commits a member's own pending upload and attache…
claude Nov 6, 2026
53eef2e
wip: the upload-complete door attaches the caller's own upload when t…
claude Nov 6, 2026
4f78f54
wip: the vouch is spent on the verb's own write, and attachUploadAs v…
claude Nov 6, 2026
e0b4c21
wip: a census gate pins the call sites that set the vouched-reference…
claude Nov 6, 2026
27c3561
wip: Test Core runs the vouched-reference census gate, red and green
claude Nov 6, 2026
64beb52
test(service-storage): attachUploadAs attaches a member's own pending…
claude Nov 6, 2026
6ff78ac
test(service-storage): the commit door hands attachTo to the attacher…
claude Nov 7, 2026
f646b60
fix(console): the attachments panel attaches through the upload's own…
claude Nov 7, 2026
d2a2cde
docs: the attachments panel attaches in the call that completes its u…
claude Nov 7, 2026
7730c2f
test(dogfood): a member with no sys_file read attaches through the pa…
claude Nov 7, 2026
ce03251
chore(changeset): the upload's own completion attaches a member's fil…
claude Nov 7, 2026
c418d5c
ci: the vouched-reference census runs beside the single authz resolve…
claude Nov 7, 2026
9fe4c83
Merge remote-tracking branch 'origin/main' into claude/sec-hardening-r17
claude Nov 7, 2026
a9adf17
chore: the vouched-reference census gate leaves this change for its own
claude Nov 7, 2026
ad3ea1c
chore(changeset): the upload-complete request gains attachTo, so the …
claude Nov 7, 2026
cb31937
test(console): the write census drops the attachments panel's create …
claude Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/sec-r17-attach-on-upload-complete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/spec': minor
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/service-storage': patch
'@object-ui/data-objectstack': patch
'@object-ui/app-shell': patch
---

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

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`).

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.

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`.

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.

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`.

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.
9 changes: 8 additions & 1 deletion content/docs/api/plugin-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ other adapter.
| Method | Endpoint | SDK | Description |
|:-------|:---------|:----|:------------|
| POST | `/storage/upload/presigned` | `storage.getPresignedUrl` | Step 1 — mint an upload target for a new `sys_file` row |
| POST | `/storage/upload/complete` | `storage.upload` | Step 3 — commit the uploaded bytes |
| POST | `/storage/upload/complete` | `storage.upload` | Step 3 — commit the uploaded bytes; with `attachTo`, also attach the file to a record as the caller |
| POST | `/storage/upload/chunked` | `storage.initChunkedUpload` | Open a chunked / resumable session |
| PUT | `/storage/upload/chunked/:uploadId/chunk/:chunkIndex` | `storage.uploadPart` | Send one chunk |
| POST | `/storage/upload/chunked/:uploadId/complete` | `storage.completeChunkedUpload` | Assemble the parts |
Expand All @@ -167,6 +167,13 @@ other adapter.
so for a file whose type the browser does not know (`file.type === ''`) the
helper sends `application/octet-stream`; a typed file keeps its own type.

To attach the upload to a record's Attachments, send `attachTo: { parentObject,
parentId }` with step 3 (and start the upload with `scope: 'attachments'`): the
commit and the `sys_attachment` row are one unit, written as the signed-in
caller, and the answer carries the `attachmentId`
([attachment access](/docs/permissions/attachments-access)). A refused attach
leaves the upload pending.

A file field takes the `fileId` only after step 3. A signed-in record write
that names a file whose upload was never completed is refused with `400
VALIDATION_FAILED`, one `invalid_value` entry naming the field and the file,
Expand Down
34 changes: 34 additions & 0 deletions content/docs/permissions/attachments-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ drops a non-system caller's `uploaded_by` and reports it in `droppedFields`,
and the rest of the write lands. System-context writes keep the value they
supply.

### Attaching an upload from the browser

The Attachments panel attaches a file in the call that completes its upload:
the presigned upload's `POST /api/v1/storage/upload/complete` carries
`attachTo: { parentObject, parentId }`, and the server commits the file and
writes its `sys_attachment` row as one unit, **as the signed-in user**. Every
check above judges that user: the permission to create an attachment, edit
access to the record, the record object's `enable.files`, and the file
reference itself. The answer carries the new `attachmentId`. A refused attach
leaves the upload `pending` and attached to nothing, and the pending-upload
retention reclaims it.

The file must be the caller's own upload, still `pending`, in the
`attachments` scope, attached to nothing and owned by no record field, with its
bytes stored. Any other file id — another user's upload, an id that names no
file — is refused before anything is written:

| Code | Status | When |
| --- | --- | --- |
| `PERMISSION_DENIED` | 403 | The file is another user's upload (the commit door's uploader rule) |
| `FILE_NOT_FOUND` | 404 | The id names no file |
| `RESOURCE_CONFLICT` | 409 | The upload is already completed, already attached or owned by a field, or its bytes are not stored |
| `INVALID_REQUEST` | 400 | `attachTo` is malformed, or the upload was not started in the `attachments` scope |
| `NOT_IMPLEMENTED` | 501 | The deployment composes no auth service or no data engine, so there is no caller to attach as |

This is how a member who may attach files but holds no read on `sys_file`
attaches one: a `sys_attachment` create through the generic data API
(`POST /api/v1/data/sys_attachment`) names its `file_id` as any other
`lookup` value, which the caller must be able to read, and a file they cannot
read is refused `400 VALIDATION_FAILED` (`reference_not_found` on `file_id`).
The attach on completion vouches for that one file, in that one call, and
grants no read of `sys_file` or of anything else.

| Code | Status | When |
| --- | --- | --- |
| `RECORD_NOT_ACCESSIBLE` | 403 | The session names no signed-in user to record as the uploader |
Expand Down Expand Up @@ -224,6 +257,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
| Operation | Requirement | Deny code |
| --- | --- | --- |
| Attach (create) | parent object opts in (`enable.files`) | `FILES_DISABLED` (403) |
| 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 |
| 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 |
| List / read | inherits parent read visibility | *(filtered out)* |
| 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 |
Expand Down
32 changes: 16 additions & 16 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
changed, only whether this instrument could see them.

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

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

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

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **265** |
| 175 write call sites | quoted in the merged changeset | **266** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **188 of 265** decidable, **77** undecidable |
| 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 |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **189 of 266** decidable, **77** undecidable |
| 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 |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

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

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

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

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

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

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 203 |
| receiver erased, placed by the object NAME | 52 |
| receiver erased, placed by the object NAME | 53 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

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

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

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
9 changes: 9 additions & 0 deletions content/docs/references/api/storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ const result = CompleteChunkedUploadRequestSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **fileId** | `string` | ✅ | File ID returned from presigned request |
| **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. |
| **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. |

### Nested Shape: `CompleteUploadRequest.attachTo`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **parentObject** | `string` | ✅ | Object of the record the file is attached to (enable.files: true) |
| **parentId** | `string` | ✅ | Id of the record the file is attached to |


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


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 500 |
| `api/` | 501 |
14 changes: 7 additions & 7 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 265 |
| Object name statically decidable | 188 |
| Write call sites | 266 |
| Object name statically decidable | 189 |
| Object name chosen at run time | 77 |
| Against a tenancy-enabled object | 182 |
| Against a tenancy-enabled object | 183 |
| Against an object declaring tenancy off | 6 |
| Threading a tenant context | 202 |
| Threading a tenant context | 203 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 55 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 148 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 109 |
| Threading a context of undecidable elevation | 110 |

## Subtractions the census could NOT defend — enforced

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

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

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down Expand Up @@ -270,7 +270,7 @@ Measured on 2026-11-06 at `4addb47fd`.
| `packages/services/service-storage/src/backfill-sys-file-organizations.ts` | `update` | `sys_file` | enabled | context, elevation undecidable | 1 |
| `packages/services/service-storage/src/backfill-sys-file-public-scope.ts` | `update` | `sys_file` | enabled | context, elevation undecidable | 1 |
| `packages/services/service-storage/src/file-attachment-service.ts` | `delete` | `sys_attachment` | enabled | context, elevation undecidable | 1 |
| `packages/services/service-storage/src/file-attachment-service.ts` | `insert` | `sys_attachment` | enabled | context, elevation undecidable | 1 |
| `packages/services/service-storage/src/file-attachment-service.ts` | `insert` | `sys_attachment` | enabled | context, elevation undecidable | 2 |
| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `insert` | `sys_file` | enabled | context, elevation undecidable | 1 |
| `packages/services/service-storage/src/file-reference-lifecycle.ts` | `update` | `sys_file` | enabled | elevated | 2 |
| `packages/services/service-storage/src/metadata-store.ts` | `delete` | `sys_file` | enabled | options unreadable | 1 |
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@ export {
VOUCHED_REFERENCES,
vouchForReference,
isVouchedReference,
withoutVouchedReferences,
type VouchedReference,
} from './vouched-reference.js';

Expand Down
Loading
Loading