Skip to content

fix(service-storage): sys_file's storage-owned columns (key, mime_type, size, status, etag) are not writable outside the storage service - #27765

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-36yup5
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-36yup5

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Requested by Sales · project thread

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(Pin the measured sys_file metadata refusal, so a baseline change that grants members sys_file writes reddens a gate instead of shipping quietly #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 storage: a file field accepts a presigned upload that was never completed (sys_file status pending) and claims it #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 storage: a file field accepts a presigned upload that was never completed (sys_file status pending) and claims it #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 Pin the measured sys_file metadata refusal, so a baseline change that grants members sys_file writes reddens a gate instead of shipping quietly #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).

Package suite, typecheck, eslint
  • 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).
Dogfood battery
  • 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).
Derived gates
  • 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.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA

… the storage service

Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA
Co-authored-by: Claude <noreply@anthropic.com>
…owned columns as kept

Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA
Co-authored-by: Claude <noreply@anthropic.com>
…in the system-context census

Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 13 documentable anchor(s).

31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d7625ea4500aa244c792d55b5d7668c80dbc10fe.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d7625ea4500aa244c792d55b5d7668c80dbc10fe → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a0cd071178a0d69b01159f1224727076b0fba0af — the merge of head aebcc2cd577f372576a95bd5c7892b80e2fb31d2 into base d7625ea4500aa244c792d55b5d7668c80dbc10fe, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0cd071178a0d69b01159f1224727076b0fba0af && git checkout a0cd071178a0d69b01159f1224727076b0fba0af
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d7625ea4500aa244c792d55b5d7668c80dbc10fe aebcc2cd577f372576a95bd5c7892b80e2fb31d2 && git checkout -B drift-repro d7625ea4500aa244c792d55b5d7668c80dbc10fe && git merge --no-ff aebcc2cd577f372576a95bd5c7892b80e2fb31d2

node scripts/docs-audit/affected-docs.mjs --json d7625ea4500aa244c792d55b5d7668c80dbc10fe

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d7625ea4500aa244c792d55b5d7668c80dbc10fe → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head aebcc2c · 2026-10-29T22:39Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: aebcc2cd577f372576a95bd5c7892b80e2fb31d2
Local-runs: none
Scope: packages/services/service-storage/src/objects/system-file.object.ts, packages/services/service-storage/src/sys-file-storage-owned-columns.ts, packages/services/service-storage/src/storage-service-plugin.ts, packages/services/service-storage/src/sys-file-storage-owned-columns.integration.test.ts, packages/services/service-storage/src/storage-service-plugin.test.ts, packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts, content/docs/permissions/system-context.mdx, .changeset/27399-sys-file-storage-owned-columns.md + packages/objectql/src/validation/rule-validator.ts (staticReadonlyInsertSubject), packages/objectql/src/engine.ts (staticReadonlyStripRuns, staticReadonlyStripRunsMulti, updateStaticReadonlySubject, insertStaticReadonlySubject, the per-row beforeInsert dispatch, triggerHooks), packages/objectql/src/validation/record-validator.ts (validateRecord, insert walk), packages/objectql/src/engine-execution-plumbing.ts (buildSession), packages/objectql/src/hook-write-provenance.ts (recordHookPayloadWrites), packages/services/service-storage/src/metadata-store.ts, packages/services/service-storage/src/file-reference-lifecycle.ts, packages/services/service-storage/src/attachment-lifecycle.ts, packages/services/service-storage/src/backfill-file-references.ts, packages/services/service-storage/src/backfill-sys-file-organizations.ts, packages/services/service-storage/src/backfill-sys-file-public-scope.ts, packages/services/service-storage/src/field-file-service.ts, packages/services/service-storage/src/file-attachment-service.ts, packages/services/service-storage/src/storage-routes.ts, packages/services/service-storage/src/index.ts, packages/metadata-protocol/src/protocol.ts (createData, cloneData), packages/rest/src/batch-routes.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/import-prepare.ts (importControlOptOutRefusal), packages/mcp/src/mcp-http-tools.ts, packages/mcp/src/stdio-data-bridge.ts, ui/apps/console/src/components/FormPage.tsx, .claude/agents/os-dev.md, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs

① Derived judgments

Rendered 2026-10-29T22:55Z. Inputs: card #27399 body; comments 6489236866 (triage, posted 2026-10-29T05:59Z), 6508175120 (claim), 6510110320 (os-dev-report, incl. its dogfood battery line); PR #27765 body, file list (8 files, +485/−23), the net diff origin/main...pr27765 (8 commits on the branch); the head's check-runs. Not read as inputs: the dispatch order, the seat's conclusions.

  • Completeness — every non-system write path keeps the five columns.
    • UPDATE, by id and by predicate: engine.update strips over updateStaticReadonlySubject (the whole registered schema — the update strip applies neither insert exclusion), gated staticReadonlyStripRuns = !opCtx.context?.isSystem and staticReadonlyStripRunsMulti (engine.ts). Strip plus warn, no refusal unless strictReadonlyWrites, which no door passes. Covers REST PATCH (the dogfood pin drives it), batch update (ql.update with trxCtx, batch-routes.ts) and in-process updates.
    • INSERT: staticReadonlyInsertSubject returns null for every sys_ name (rule-validator.ts), so readonly covers no create and the guard is the create side. The engine dispatches beforeInsert once per row of a batch (dispatch.mode: 'per-row') with session: buildSession(opCtx.context); isSystem is propagated only when set and a context-less write has no session, so the guard's predicate equals the update strip's. delete on ctx.input.data goes through the recording proxy's deleteProperty trap and the re-set of status is recorded as a hook write; nothing is handed back after the hooks because withholdInsertReadonlyFromHooks reads insertStaticReadonlySubject, null for sys_. Doors covered: REST create (createData → engine.insert with the request context), batch create (p.createData with trxCtx), clone (cloneData → engine.insert with the caller's context), import (rows written under the caller's context; the runAutomations: false opt-out is refused to everyone but the system principal by importControlOptOutRefusal, and skipAutomations skips metadata-bound hooks only — code-registered hooks always run in triggerHooks), insertMany (the partial-row mode of the same insert). MCP: sys_ objects are not exposed unless allowSystem (isSystemObject, mcp-http-tools.ts) and no write in packages/mcp/src elevates to isSystem. No sys_file writer outside service-storage exists in packages/** source.
    • Gap at a door: none found. The one way around both halves is a composition whose data engine has no registerHook (the plugin's kernel:ready branch degrades silently, as the sibling attachment hooks already do) — not a door; see ④.
  • Legitimate callers, each cited as system context. StorageMetadataStore.createFile / updateFile / deleteFile take systemInsertOptionsFor / systemByIdWriteOptionsFor ({ isSystem: true, tenantId? }, metadata-store.ts); behind them the presigned, /upload/complete, chunked and chunk-complete doors (storage-routes.ts) and field-file-service.ts (store create / delete / update). file-attachment-service.ts: the sys_file row goes through the store; the caller's own context reaches only the sys_attachment insert and the parent read. file-reference-lifecycle.ts claim, release and copy insert: SYSTEM_CTX. attachment-lifecycle.ts tombstone, re-attach un-tombstone and reap guard: SYSTEM_CTX. backfill-file-references.ts insert: SYSTEM_CTX. The two organization/scope backfills write organization_id / scope only. Seed files (metadata-protocol) write through the field-file store. The reap guard tolerates a null key (typeof row.key === 'string' && row.key), so a pending row the guard emptied is collected without a throw.
    • Console/Studio: no data-door write of sys_file in ui/apps/console/src (sys_file appears there only as the id a file field stores); FormPage.tsx renders a readonly field disabled, so a generic form on sys_file now shows the five disabled — consistent with the posture.
    • The reversed Pin the measured sys_file metadata refusal, so a baseline change that grants members sys_file writes reddens a gate instead of shipping quietly #11076 control: the old admin-lands behaviour was relied on by that contrast control alone; no skill and no hand-written doc teaches writing these columns through the data door (skills/**, content/docs outside references/ and releases/). Reversing it is inside triage 6489236866 — read-only to the data door names every non-system caller — and the card's Expected names key and etag beside the three. The moved control still proves the request reaches the row (name lands), so the tautology guard it exists for holds.
  • Strip-with-warn on update, drop-and-start-pending on create: consistent with the triage direction and the claim's order (no refusal on either verb). A committed row is minted only by the service: presign → PUT → /upload/complete, which refuses unless the backend holds the bytes and their size equals the declared size (heldBytesRefusal, storage-routes.ts); the copy, backfill and seed paths are system. So a non-system create starting pending leaves no legitimate committed-row path outside the service.
  • Dogfood battery at the final head: on record in os-dev-report 6510110320 and the PR body — aebcc2cd VERDICT command-exit 0, Test Files 354 passed / 3 skipped (357), Tests 2858 passed / 20 skipped (2878); the first run's one red was the moved Pin the measured sys_file metadata refusal, so a baseline change that grants members sys_file writes reddens a gate instead of shipping quietly #11076 control.
  • Check-runs on the head, read at 2026-10-29T22:52Z: 54 runs, every non-skipped run completed / success — Lint & Repo Gates, TypeScript Type Check, Test Core (8/8 shards and the aggregate), Build Core, Governed Surface Queue Guard, Test UI, Live E2E UI, Check Changeset (both runs), PR-body guards, Lint · ESLint, Lint · repo & tooling gates (carries check:system-context-census), Lint · docs & code guards, Build Docs, Dogfood Regression Gate; Lint UI, Type Check UI, Build & E2E UI skipped by the paths filter (no ui/ path in the diff). Those conclusions are the gate verdicts; nothing was re-run here.

② Semver level

  • @objectstack/service-storage: patch, Clause-②: no (narrowing) and Metadata-protocol: none on both carriers (PR body and changeset), one ADR-0087 marker not-required (no-migration-prescription). Judged right: the kind is none — no authorable key, accepted value or metadata type moves; readonly: true on five fields of a platform object changes the served object metadata of sys_file (a consumer renders them disabled), not what an author can write — and none takes patch; a non-protocol breaking change ships patch with its disposition. The (narrowing) arm is the recognised BREAKING carrier (check-adr-0087-registration signal 4), and Check Changeset is green on the head. The disposition holds: nothing is removed, renamed or tombstoned; an update keeps the stored values, a create starts pending; there is no migration prescription to write, and the changeset body still gives the author the way to change a file (re-upload through the storage doors).
  • Published surface: SYS_FILE_STORAGE_OWNED_COLUMNS is not re-exported — src/index.ts names SystemFile and SystemUploadSession only — so "the published surface does not move" holds.
  • Skills-docs: none holds on the grep above.

③ Boundary flags

  • Declared deviations, judged:
    • pnpm lint not run repo-wide — .claude/agents/os-dev.md makes the repo-wide sweep CI's run and never the dev's; Lint · ESLint is success on the head. Acceptable.
    • The create guard does not read strictReadonlyWrites — no door passes it, and the update side still honours it through the engine. Acceptable, noted.
    • Branch claude/project-thread-36yup5 (harness-pinned, names no issue) — claim 6508175120 is the identity record and PR-body guards is green. Acceptable.
    • Attribution footers carry the card thread's session, the seat signature as a body line. Acceptable.
  • open_questions: none declared; none found that needs the maintainer.
  • Out of scope, noted by the dev: ref_object / ref_id / ref_field and released_ref_* stay plain writable (code reading, no door measured). Leaving them is acceptable for this PR: the triage direction names the lifecycle columns, and a code-reading finding without a repro belongs in the acceptance notes by Prime Directive chore: version packages #10 until a door is measured. RULE only: ownership columns a lifecycle hook writes are as spoofable as status was; the seat files it as its own card once a door is measured, under the dedupe words the report gives. No recipe here.
  • Governance: no governed surface in the file list (Governed Surface Queue Guard green); the review is owed by the narrowing face alone. 508 changed lines; head repo equals base repo. The PR is draft and unarmed; landing stays the seat's after this record, through the queue.

④ Design judgment

  • system-file.object.ts — holds. The five are exactly the set the two trusting checks read plus the two the card's Expected names; name, acl, scope, owner_id, metadata and the ownership columns stay as they were. One consequence the diff does not state: on insert the engine validates a readonly field shape-only — validateRecord's insert walk passes skipRequired = def.readonly === true — so required: true on key and status is no longer engine-enforced on any insert, system included; the PR's own create pin stores key: null. Presence is now the writers' invariant (every service writer supplies both). should-fix (rule, not recipe): keep declared equal to enforced on this object — narrow the declaration, or state in the header that required on the two is held by the storage service's writers and not by the engine; the seat picks.
  • sys-file-storage-owned-columns.ts — holds against the engine it cites: ctx.session.isSystem is the same read file-reference-lifecycle.ts makes; the per-row dispatch gives the batch case; the delete and the re-set through the recording proxy are what the strip keeps. dropStorageOwnedColumns is exported "for the guard's pin", but nothing imports it — the integration suite imports the constant and SystemFile only. should-fix: trim the export or add the pin the comment names; a comment naming a test that does not exist is Prime Directive chore: version packages #10 in miniature.
  • storage-service-plugin.ts — holds; installed beside the sibling hooks under the same registerHook guard, PACKAGE_ID matches the siblings. Observation, no change asked: the create side is composition-dependent where the update side is engine-level, and the branch degrades silently exactly as the siblings do.
  • sys-file-storage-owned-columns.integration.test.ts — holds; the storage: a file field accepts a presigned upload that was never completed (sys_file status pending) and claims it #26479 rig drives the doors as shipped, the forged status differs from the committed one (the rerun leg), both verbs, batch, the end-to-end positive path and the system create are pinned; the three ablation legs (fix committed first, restore equal to HEAD) are on record in the PR body.
  • storage-service-plugin.test.ts — holds; the wiring pin now separates the sys_file-scoped hook and pins it to one beforeInsert.
  • sys-file-metadata-write-refusal.dogfood.test.ts — holds; the four refusal cases are untouched, the control still proves reach (name lands) and reads the two columns kept; the header's sentences were rewritten to the new posture rather than left stale.
  • system-context.mdx row 47g — holds; the Get / Lose sentence matches the code, the generated register names the right symbol, and the census gate is green on the head.
  • .changeset — holds (②).
  • The narrowing against main: it takes an administrator's data-door write to five storage-owned columns away and makes the object header's own promise real (lifecycle status tracked so clients cannot spoof backend facts); no consumer that reads those columns loses anything, and the service's doors still move them.
  • must-fix: none.

Implemented-by: claude/project-thread-36yup5
Reviewed-by: session_01L6bhgUCizoYWQVJWjhdR6F

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F

维护者速读(席位终稿)

  • 改了什么:sys_file 里由存储服务管理的五列(key、mime_type、size、status、etag)不再能从数据入口写入。非系统调用的更新会丢弃这五列并记 warn;非系统调用的创建会被存储服务的 beforeInsert 守卫去掉这五列,行从 pending 开始。
  • 为什么改:原先任何能写 sys_file 的调用方都能把一个从未上传的文件标成 committed,或让行指向不属于它的字节。
  • 风险与回滚:不新增拒绝,只丢弃(与分诊一致);存储服务自己的写入全是系统上下文,已逐个核对、未改动;唯一依赖旧行为的是 Pin the measured sys_file metadata refusal, so a baseline change that grants members sys_file writes reddens a gate instead of shipping quietly #11076 的 dogfood 对照用例,已按本卡反转。回滚 = revert 本 PR。
  • 先看:packages/services/service-storage/src/objects/system-file.object.ts 的五个 readonly;sys-file-storage-owned-columns.ts 与插件里的守卫接线。
  • 两分钟验证:pnpm --filter @objectstack/service-storage test。
  • 需要你做的:无。非受管面,契约审查 PASS,席位落地。

席位意见: ACCEPT。更新走引擎已有的只读剥离,创建由服务内守卫补齐,objectql 未动;两条复核建议(只读列不再受 required 约束、未用的导出)和 ref_* 归属列另开卡。

席位核对

ACCEPT · head aebcc2cd577f372576a95bd5c7892b80e2fb31d2 · 2026-10-29T22:57Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant