Skip to content

Commit a0413fc

Browse files
fix(service-storage): sys_file's storage-owned columns (key, mime_type, size, status, etag) are not writable outside the storage service (#27765)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27399 domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F (card thread session_01L6bhgWddm8zjbee2kUdmmA) Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` and no hand-written `content/docs` page teaches writing `sys_file`'s `key`, `mime_type`, `size`, `status` or `etag` through the data door (`git grep` for those columns beside `sys_file` outside `content/docs/references/` and the release notes: no such sentence); every taught upload path goes through the storage upload doors, which this change keeps. Claim: 6508175120 (seat `domain:server#2`, branch `claude/project-thread-36yup5`, the card thread's harness-pinned branch). Triage direction: 6489236866. Ruling: none. ## 维护者速读 - **改了什么**:`sys_file` 里由存储服务记账的五列(存储键 `key`、`mime_type`、`size`、`status`、`etag`)对数据入口变成只读:非系统的更新(按 id 或按条件)不再能改它们,其余字段照常落地;非系统的新建会丢掉调用方给这五列的值,并以 `pending` 起步。存储服务自己的写入(预签名、上传完成、分片上传、文件字段认领/释放/复制、附件墓碑)全部走系统上下文,不受影响。 - **为什么改**:文件字段靠 `status` 拒绝没传完的文件,靠 `size` / `mime_type` 判 `accept` / `maxSize`。之前管理员可以 `PATCH /api/v1/data/sys_file/ID` 把一个从没收到字节的上传改成 `committed`,再被记录的文件字段认领;或事后改大小和类型骗过字段约束。 - **风险与回滚**:收窄(narrowing),但不新增拒绝:更新照样回 200,只是这五列保持存储服务写的值(引擎按惯例记一条 warn);新建照样成功,只是以 `pending` 起步、五列为空。只影响直接改 `sys_file` 这几列的管理脚本。回滚 = revert 本 PR,无数据迁移。 - **先看**:① `packages/services/service-storage/src/sys-file-storage-owned-columns.ts`(新建侧守卫,`isSystem` 豁免那一行决定存储服务自己的写入不被拦);② `packages/services/service-storage/src/objects/system-file.object.ts`(五列 `readonly` 与列清单);③ `packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts`(#11076 管理员对照钉子改为「请求到达、`name` 落地、两列保持原值」)。 - **两分钟验证**:`pnpm --filter @objectstack/service-storage exec vitest run src/sys-file-storage-owned-columns.integration.test.ts`,预期 12 个用例全绿(卡片复现、五列逐一更新、条件更新、单条与批量新建、上传→完成→认领全链路、系统新建保值)。 - **需要你做的**:无,席位落地(合入队列前由席位安排契约复核)。 ## What changes - **The column list.** `SYS_FILE_STORAGE_OWNED_COLUMNS` in `system-file.object.ts` names the five columns the storage service owns: `key` (where the bytes live), `mime_type`, `size` and `etag` (what the backend was told and answered) and `status` (how far the upload got). `key` and `etag` are in the same class as the three the card names: a forged `key` points a row at bytes it never uploaded, and `etag` is the backend's own answer at `/upload/complete`. The ownership columns (`ref_*`, `released_ref_*`), `name`, `acl`, `scope`, `owner_id` and `metadata` are not in it and stay as they were. The constant is module-internal (not re-exported from `src/index.ts`), so the published surface does not move. - **Update.** Each of the five is declared `readonly: true`. Measured: the engine's static readonly strip runs on an UPDATE of every object, `sys_` included, for every context that is not `isSystem` (`staticReadonlyStripRuns = !opCtx.context?.isSystem`), by id and by predicate. It STRIPS, it does not refuse: the caller's value is taken off, the rest of the payload lands, the write answers as before and `this.logger` gets the readonly warn. Only a caller passing `strictReadonlyWrites` is refused (`ERR_READONLY_FIELD_REJECTED`), as for every readonly column; the data doors do not pass it. - **Create.** Measured: on an INSERT, `staticReadonlyInsertSubject` (`@objectstack/objectql`) returns `null` for every `sys_` object, leaving its columns to the object's own guard, so `readonly` alone does not touch a non-system create. The new `installSysFileStorageOwnedColumnsGuard` (`sys-file-storage-owned-columns.ts`) is that guard: a `beforeInsert` hook on `sys_file`, installed by `StorageServicePlugin` at `kernel:ready` beside the attachment lifecycle hooks. For a write whose context is not `isSystem` (context-less included, as the update strip judges), it deletes any of the five the payload carries, sets `status: 'pending'` and logs one `warn` naming the dropped columns. It does not refuse: the row is written with the caller's other columns, a file field refuses it (it is `pending`), and the lifecycle's `pending` retention collects it. ⛔ `packages/objectql`'s `sys_` insert exemption is not touched. - **The system-context census.** The guard's `isSystem` return is one elevation read, so `content/docs/permissions/system-context.mdx` gains row 47g for it (`installSysFileStorageOwnedColumnsGuard`) and its generated site register is rewritten by `check-system-context-census.mjs --write`. - **Every writer of these columns is already system context** (measured, nothing changed): `StorageMetadataStore.createFile` / `updateFile` / `deleteFile` (`systemInsertOptionsFor`, `{ isSystem: true, tenantId? }`), which the presigned, PUT, `/upload/complete`, chunked upload (`storage-routes.ts`), `field-file-service.ts` and `file-attachment-service.ts` doors all go through; `file-reference-lifecycle.ts` (claim, release, copy insert: `SYSTEM_CTX`); `attachment-lifecycle.ts` (tombstone, re-attach, reap guard: `SYSTEM_CTX`); `backfill-file-references.ts` (`SYSTEM_CTX`). The two backfills (`backfill-sys-file-organizations.ts`, `backfill-sys-file-public-scope.ts`) write only `organization_id` / `scope`. No `sys_file` write outside `service-storage` (`git grep` for an insert / update of `'sys_file'` in `packages/**` source). ## Tests - New `service-storage/src/sys-file-storage-owned-columns.integration.test.ts`, the #26479 rig (real `ObjectQL` over sqlite `:memory:`, `StorageServicePlugin` on the local adapter, the upload doors called as routes, data writes as a signed-in caller with no system context), 12 cases: - the list is exactly the five, each `readonly` on `SystemFile`; - the card's steps 1 to 5: presign, PATCH `{ status: 'committed', size: 999 }`, the row keeps every storage-owned value (still `pending`), and a file field still refuses the file with the #26479 envelope (`400 VALIDATION_FAILED`, `invalid_value` on `poster`); - an update by id cannot change each column (`it.each` over the five), and `name` in the same payload lands; - a predicate update cannot change any of them, and `acl` in the same payload lands; - a create cannot set any of them: the row is `pending` with all five null, `name` kept, and a file field refuses it; a batch create the same on every row; - the service's own presign → PUT → `/upload/complete` → claim path still writes every column (`mime_type`, `size`, `status: committed`, `etag` from complete, a `key` holding the file id) and a record's file field claims it; - a system create keeps every value it writes. - Moved pin, dogfood: `sys-file-metadata-write-refusal.dogfood.test.ts` (the #11076 RBAC refusal pin). Its non-admin cases (403 `PERMISSION_DENIED`, columns unchanged) are untouched. Its platform-admin contrast control asserted the admin's PATCH of `mime_type` / `size` LANDS, the exact behaviour this card reverses (the full battery's one red on the first run). The control now sends `name` in the same payload and asserts 200, that `name` landed (so the request still reaches the row) and that `mime_type` / `size` keep the seeded values. The header's sentences that said the columns carry no `readonly` are updated; the file always said it was written to survive this posture call. - Moved pin: `storage-service-plugin.test.ts`'s wiring case asserted every scoped hook names `sys_attachment`; it now counts the guard's one `beforeInsert` on `sys_file` separately and pins it. ## Acceptance notes - **Ablation, three legs, each red then restored** (`scripts/ablation-replace.mjs`, fix committed first; restore blob == HEAD and `git diff HEAD` empty on each). The suite imports `service-storage`'s `src/` directly, so no `dist/` rebuild is in any leg. - Leg 1, every `readonly: true,` in `system-file.object.ts` to `false` (6 hits, `id` included): 10 failed / 2 passed. Red: the list pin, the card's steps, all five update-by-id cases, the predicate update, both creates. Green: the end-to-end path and the system create. - Leg 2, delete the `installSysFileStorageOwnedColumnsGuard(...)` call in the plugin: 2 failed / 10 passed (the single and the batch create). - Leg 3, delete the guard's `isSystem` exemption: 8 failed / 4 passed. Red: the end-to-end path and the system create (the presign's own insert lost its `key` and `size`), and the update cases whose committed fixture is built by that path. - A first run of leg 1 left the status-by-id case green because the forged value equalled the committed one; the fixture now forges `pending` over a committed file, and the leg above is the rerun. - Dispatch-order conflicts, stated: the order asked for `pnpm lint`; `.claude/agents/os-dev.md` makes the repo-wide sweep CI's run, so eslint ran on the changed package (and `Lint & Repo Gates` runs the sweep). The order asked for "no new refusal": none is added on either verb. - Not handled, noted: the create guard does not read `strictReadonlyWrites` (the engine's insert strip would refuse under it on a non-`sys_` object); no data door passes that option. - Out of scope, noted (code reading, no door measured): the ownership columns `ref_object` / `ref_id` / `ref_field` and `released_ref_*` are also plain writable fields to a caller allowed to write `sys_file`; whether that is a defect is a separate question from this card's lifecycle columns. - Branch name is the card thread's harness-pinned `claude/project-thread-36yup5`, not the `claude/issue-N-slug` convention; the claim above is its identity record. ## Verification All on head `aebcc2cd` (fix and tests `886ec313`..`f886aad7`, merged with `origin/main` at `6f79d563`, then the ADR-0087 marker, the dogfood control and the census row). <details><summary>Package suite, typecheck, eslint</summary> - `pnpm --filter @objectstack/service-storage test && … typecheck` under `os-verify-lock.sh` on `aebcc2cd`: `VERDICT command-exit 0`, Test Files 71 passed (71), Tests 1299 passed (1299); the test-layer typecheck reports 0 errors. - `eslint --no-inline-config packages/services/service-storage packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts`: exit 0. The repo-wide `pnpm lint` sweep is left to `Lint & Repo Gates`. - The new suite alone: 12 passed (12). </details> <details><summary>Dogfood battery</summary> - First run (head `6f79d563`, closure built with `turbo run build --filter='@objectstack/dogfood^...'`): 1 failed / 353 passed / 3 skipped files, Tests 1 failed / 2857 passed / 20 skipped: `sys-file-metadata-write-refusal.dogfood.test.ts` › the platform-admin contrast control (received the seeded `application/octet-stream` / 11, expected the admin's values). Moved as described under Tests; that file alone then 6 passed (6). - Final run on `aebcc2cd`, closure rebuilt then `pnpm --filter @objectstack/dogfood test` under `os-verify-lock.sh`, no shard and no path: `VERDICT command-exit 0`, Test Files 354 passed / 3 skipped (357), Tests 2858 passed / 20 skipped (2878). </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 94 families derived at `aebcc2cd`; all 94 run on that head, each `exit 0`. - Along the way: `check:adr-0087-registration` first red (no disposition marker; `no-migration-prescription` added); `check:system-context-census` first red (the guard's `isSystem` read had no row; row 47g added); `check:plugin-teardown-shape --self-test` first exit 3 (shallow clone; its pinned fixture commit fetched); `check:dual-build-cjs-loads` first exit 3 (7 dists missing; built). - `--ran` reconcile: `94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED (a DERIVED zero — all 94 recorded an exit code and none of them is 3)`. - No open PR edits these files (name-only diffs of the 17 open PRs' heads against `origin/main`), so no `merge-tree` pair was owed. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 64599ec commit a0413fc

8 files changed

Lines changed: 485 additions & 23 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-storage': patch
3+
---
4+
5+
fix(service-storage): `sys_file`'s storage-owned columns (`key`, `mime_type`, `size`, `status`, `etag`) can no longer be set or changed by a write that does not come from the storage service
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A data-door write to five storage-owned sys_file columns is no longer applied: no key of any metadata schema is removed, renamed or re-shaped, no export, type or error code is removed or renamed, and no write is refused (an update keeps the stored values, a create starts pending). There is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
A file field refuses a file whose upload never completed by reading `sys_file.status`, and judges its `accept` / `maxSize` by `mime_type` / `size`. Those columns were plain writable fields, so a caller allowed to write `sys_file` rows (an administrator, on a stock boot) could `PATCH /api/v1/data/sys_file/ID` a presigned upload that never received its bytes to `committed`, and a record's file field then claimed it; or rewrite `size` / `mime_type` after commit so the field's constraints judged values that do not describe the stored bytes.
13+
14+
- **Update.** The five columns are now `readonly` on `sys_file`. A non-system update, by id or by predicate, keeps the values the storage service wrote and lands the rest of the payload; the write answers as before and the engine logs the dropped column at `warn`, as it does for every read-only column. A caller that passes `strictReadonlyWrites` is refused with `ERR_READONLY_FIELD_REJECTED`, as for every read-only column.
15+
- **Create.** A non-system create of a `sys_file` row keeps the caller's other columns, drops any value it gives these five, and starts the row `pending`, with one `warn` naming the dropped columns. A file field refuses such a file, and the lifecycle's `pending` retention collects it. The create is not refused.
16+
- **Unchanged.** The storage service's own writes all run under the system context and still move every column: presign, PUT and `/upload/complete`, the chunked upload, a file field's claim, release and copy, and the attachment tombstone. Every other `sys_file` column (`name`, `acl`, `scope`, ownership) is written through the data door as before.
17+
18+
To change what a file is, upload it again through the storage upload doors (`POST /api/v1/storage/upload/presigned`, then `/upload/complete`).

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ One of the largest consumers of the flag;
184184
| 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 | `refuseUnlessHumanCaller` |
185185
| 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 | `requireUserContext` |
186186
| 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 | `referencingCallerOf` |
187+
| 47g | The `sys_file` storage-owned columns guard returns early on create: a system insert keeps the `key`, `mime_type`, `size`, `status` and `etag` it writes | service-storage | Get: the storage service's own inserts (the upload doors, the field-file copy and backfill) write those five columns. Lose: nothing a user held — a non-system create has them dropped and starts `pending`, and on update the same columns are `readonly`, so the UPDATE `readonly` strip keeps them for every non-system caller | `installSysFileStorageOwnedColumnsGuard` |
187188

188189
### 5. Actions, metadata plane, provenance, the organization wall
189190

@@ -659,6 +660,7 @@ and CI refuses one that differs from what the tree produces.
659660
| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentReadVisibility` | 1 | 44 |
660661
| `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` | 1 | 47e |
661662
| `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` | 1 | 47f |
663+
| `packages/services/service-storage/src/sys-file-storage-owned-columns.ts#installSysFileStorageOwnedColumnsGuard` | 1 | 47g |
662664

663665
{/* END GENERATED: system-context-census */}
664666

‎packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// [#11076] REFUSAL PIN — a non-admin cannot write `sys_file.mime_type` / `size`
4-
// through the data API; a platform admin still can.
4+
// through the data API; a platform admin's write reaches the row, and (since
5+
// #27399) the two columns still keep what the storage service wrote.
56
//
67
// ── What this file is, and what it deliberately is NOT ──────────────────────
78
//
@@ -18,25 +19,24 @@
1819
// explicit-allow platform baseline `member_default` (#5491) NAMES no `sys_file`
1920
// grant, and object access comes from OWDs plus profile / permission-set
2021
// declarations only — so the refusal is produced by the baseline's SILENCE
21-
// about this object. `sys_file` reinforces that from the schema side: it
22-
// declares no ADR-0103 `managedBy` bucket at all (seven sibling platform
23-
// objects in `platform-objects/src/audit` do — `sys_attachment` names
24-
// `platform` outright, the rest name `engine-owned` / `append-only` / `config`),
25-
// and neither `mime_type` nor `size` carries `readonly`, though `id` on the same
26-
// object does. So nothing on the object itself is holding this line.
22+
// about this object. `sys_file` declares no ADR-0103 `managedBy` bucket at all
23+
// (seven sibling platform objects in `platform-objects/src/audit` do —
24+
// `sys_attachment` names `platform` outright, the rest name `engine-owned` /
25+
// `append-only` / `config`). [#27399] Since then `mime_type` and `size` (with
26+
// `key`, `status` and `etag`) are `readonly` storage-owned columns, so a
27+
// non-system UPDATE keeps the stored values whoever sends it; the 403 below is
28+
// still the RBAC answer, which comes before any column is judged.
2729
//
2830
// An absence has no gate. A baseline change that hands members a `sys_file`
2931
// grant — the shape a "while we are here" widening naturally takes — would
3032
// today move a declared security property (the storage service's accept /
3133
// maxSize re-check reads these columns) with every suite still green. This file
3234
// is what reddens instead.
3335
//
34-
// ⛔ It does NOT decide whether `sys_file` SHOULD be engine-owned, or whether
35-
// these columns should become readonly / system-managed. That is an un-ruled
36-
// platform posture call — it removes an affordance from app authors — and this
37-
// pin is written to survive every outcome of it: if the posture tightens the
38-
// refusal stays a refusal, and if it never does, this file is what tells you
39-
// when the absence stops holding.
36+
// ⛔ It does NOT decide whether `sys_file` SHOULD be engine-owned. The columns'
37+
// own posture was decided on #27399 (triage: read-only to the data door), and
38+
// this pin survived it as it was written to: the refusal stays a refusal, and
39+
// only the contrast control's read-back moved.
4040
//
4141
// ── Three assertions, and why each one is load-bearing ──────────────────────
4242
//
@@ -45,7 +45,9 @@
4545
// server rejects for everyone.
4646
//
4747
// The PLATFORM-ADMIN CONTRAST CONTROL, on the SAME row and the SAME two
48-
// columns, is part of the pin and not optional. Without it the refusal cases
48+
// columns, is part of the pin and not optional: the admin's write answers 200
49+
// and LANDS (a writable column in the same payload changes), so the request
50+
// reaches the row. Without it the refusal cases
4951
// pass just as well when the fixture never reaches the endpoint at all —
5052
// which is how a refusal pin rots into a tautology. This lane has the scar:
5153
// a `get-session` pin that answered `200` for anonymous (#10954).
@@ -232,16 +234,21 @@ describe('[#11076] `sys_file.mime_type` / `size` are not writable by a non-admin
232234
// row and the SAME two columns they did. Without it, all four cases above pass
233235
// unchanged if the fixture stops reaching the endpoint — a request that never
234236
// arrives is refused by nothing and looks exactly like a gate holding.
235-
it('a platform admin still succeeds on the same row and the same columns', async () => {
237+
it('a platform admin\'s write on the same row and the same columns reaches it, and the storage-owned columns keep their values', async () => {
236238
const ADMIN_WRITE = { mime_type: 'text/x-admin-11076', size: 777 } as const;
237-
expect(ADMIN_WRITE, 'the control must actually move the columns').not.toEqual({ ...SEEDED });
239+
expect(ADMIN_WRITE, 'the control must actually try to move the columns').not.toEqual({ ...SEEDED });
240+
const ADMIN_NAME = 'pin-admin-11076.bin';
238241

239-
const answer = await write(adminTok, { ...ADMIN_WRITE });
242+
const answer = await write(adminTok, { ...ADMIN_WRITE, name: ADMIN_NAME });
240243
expect(answer.status, `admin status — body: ${answer.body}`).toBe(200);
241244

242-
// Persisted, read back the same way the refusals were: the control proves a
243-
// write of these columns REACHES and LANDS, so the 403s above are a verdict
244-
// about the caller and not about the request.
245-
expect(await stored()).toEqual({ ...ADMIN_WRITE });
245+
// Read back the same way the refusals were. The writable column in the
246+
// same payload LANDED, so the request reached the row and the 403s above
247+
// are a verdict about the caller, not about the request. [#27399] The two
248+
// storage-owned columns keep what the storage service wrote: the engine's
249+
// readonly strip takes a non-system caller's value off, an admin's too.
250+
const row = await ql.findOne(OBJECT, { where: { id: ROW_ID }, context: SYS });
251+
expect(row?.name, 'the admin write reached and landed').toBe(ADMIN_NAME);
252+
expect(await stored()).toEqual({ ...SEEDED });
246253
});
247254
});

‎packages/services/service-storage/src/objects/system-file.object.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@ const UPLOAD_SCOPE_LABELS: Record<UploadScope, string> = {
2424
attachments: 'Attachments',
2525
};
2626

27+
/**
28+
* The `sys_file` columns the storage service owns: where the bytes live
29+
* (`key`), what the backend was told they are (`mime_type`, `size`, `etag`) and
30+
* how far the upload got (`status`). A file field refuses a file whose upload
31+
* never completed by reading `status`, and judges `accept` / `maxSize` by
32+
* reading `mime_type` / `size`, so a caller who could write them could make a
33+
* file with no bytes read as uploaded, or one the field forbids read as
34+
* allowed.
35+
*
36+
* Each is declared `readonly` below, so on an UPDATE the engine's static
37+
* readonly strip takes a non-system caller's value back out (the PATCH answers
38+
* as before and the column keeps what the service wrote). On an INSERT the
39+
* engine leaves a `sys_` object's columns to the object's own guard, and that
40+
* guard reads this list: `installSysFileStorageOwnedColumnsGuard`
41+
* (`sys-file-storage-owned-columns.ts`). Only the service's own writes, all
42+
* under the system context, move these columns: the upload doors through
43+
* `StorageMetadataStore`, the claim, release and copy of a file field, and the
44+
* attachment tombstone.
45+
*/
46+
export const SYS_FILE_STORAGE_OWNED_COLUMNS = ['key', 'mime_type', 'size', 'status', 'etag'] as const;
47+
2748
/**
2849
* System File Object
2950
*
@@ -57,10 +78,12 @@ export const SystemFile = ObjectSchema.create({
5778
readonly: true,
5879
}),
5980

81+
// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
6082
key: Field.text({
6183
label: 'Storage Key',
6284
required: true,
6385
searchable: true,
86+
readonly: true,
6487
}),
6588

6689
name: Field.text({
@@ -69,12 +92,16 @@ export const SystemFile = ObjectSchema.create({
6992
searchable: true,
7093
}),
7194

95+
// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
7296
mime_type: Field.text({
7397
label: 'MIME Type',
98+
readonly: true,
7499
}),
75100

101+
// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
76102
size: Field.number({
77103
label: 'Size (bytes)',
104+
readonly: true,
78105
}),
79106

80107
// A logical key prefix and a lifecycle discriminator — never an access
@@ -104,18 +131,22 @@ export const SystemFile = ObjectSchema.create({
104131
],
105132
}),
106133

134+
// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
107135
status: Field.select({
108136
label: 'Status',
109137
required: true,
138+
readonly: true,
110139
options: [
111140
{ label: 'Pending Upload', value: 'pending' },
112141
{ label: 'Committed', value: 'committed' },
113142
{ label: 'Deleted', value: 'deleted' },
114143
],
115144
}),
116145

146+
// Storage-owned (SYS_FILE_STORAGE_OWNED_COLUMNS).
117147
etag: Field.text({
118148
label: 'ETag',
149+
readonly: true,
119150
}),
120151

121152
owner_id: Field.text({

‎packages/services/service-storage/src/storage-service-plugin.test.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,16 +432,22 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>
432432

433433
const hookEvents: string[] = [];
434434
const globalHookEvents: string[] = [];
435+
const sysFileHookEvents: string[] = [];
435436
const middlewares: Array<{ object?: string }> = [];
436437
ctx.registerService('objectql', {
437438
registerHook: (event: string, _fn: unknown, opts: any) => {
438439
// File-reference ownership (ADR-0104 D3 wave 2) registers GLOBALLY —
439-
// any object may declare a file-class field. Everything else is
440-
// scoped to sys_attachment.
440+
// any object may declare a file-class field. The create-side guard of
441+
// sys_file's storage-owned columns is scoped to sys_file. Everything
442+
// else is scoped to sys_attachment.
441443
if (opts?.object === undefined) {
442444
globalHookEvents.push(event);
443445
return;
444446
}
447+
if (opts?.object === 'sys_file') {
448+
sysFileHookEvents.push(event);
449+
return;
450+
}
445451
expect(opts?.object).toBe('sys_attachment');
446452
hookEvents.push(event);
447453
},
@@ -488,6 +494,9 @@ describe('StorageServicePlugin: sys_file orphan lifecycle wiring (#2755)', () =>
488494
'beforeUpdate',
489495
'beforeUpdate',
490496
]);
497+
// The storage-owned columns' create-side guard
498+
// (sys-file-storage-owned-columns.ts): one `beforeInsert`, nothing else.
499+
expect(sysFileHookEvents).toEqual(['beforeInsert']);
491500
// Field-reference ownership: claim/copy on write, release on delete
492501
// (file-reference-lifecycle.ts). Registered without an object filter.
493502
expect(globalHookEvents.sort()).toEqual([

‎packages/services/service-storage/src/storage-service-plugin.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { registerStorageFamily } from './storage-routes.js';
6363
import type { FileReadVerdict, StorageGatedSessionReader, StorageUploadSession } from './storage-routes.js';
6464
import { installAttachmentLifecycleHooks, createSysFileReapGuard, createUploadSessionReapGuard, findFileHolder, findHeldFiles } from './attachment-lifecycle.js';
6565
import { installFileReferenceHooks, type FileReferenceEngine } from './file-reference-lifecycle.js';
66+
import { installSysFileStorageOwnedColumnsGuard } from './sys-file-storage-owned-columns.js';
6667
import type { FileReferenceReadAuthorizer } from './referenceable-file.js';
6768
import {
6869
installAttachmentAccessHooks,
@@ -488,6 +489,10 @@ export class StorageServicePlugin implements Plugin {
488489
// lifecycle sweep. Both degrade silently on bare kernels.
489490
if (engine && typeof (engine as any).registerHook === 'function') {
490491
installAttachmentLifecycleHooks(engine as any, ctx.logger);
492+
// The create-side guard of sys_file's storage-owned columns: the engine
493+
// strips them off a non-system UPDATE (they are `readonly`) but leaves a
494+
// `sys_` INSERT to the object's own guard.
495+
installSysFileStorageOwnedColumnsGuard(engine as any, ctx.logger);
491496
// [#25692] The download door's verdict on one file for one caller,
492497
// bound once from the live registry and handed to BOTH write doors
493498
// that name a `sys_file` id — a file-class field and a

0 commit comments

Comments
 (0)