Repository navigation
fix(service-storage): sys_file's storage-owned columns (key, mime_type, size, status, etag) are not writable outside the storage service - #27765
Conversation
… the storage service Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA Co-authored-by: Claude <noreply@anthropic.com>
…reInsert Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA Co-authored-by: Claude <noreply@anthropic.com>
…wing 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>
📓 Docs Drift CheckThis PR changes 1 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head aebcc2c · 2026-10-29T22:39Z |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: ① Derived judgmentsRendered 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
② Semver level
③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F 维护者速读(席位终稿)
席位意见: ACCEPT。更新走引擎已有的只读剥离,创建由服务内守卫补齐,objectql 未动;两条复核建议(只读列不再受 required 约束、未用的导出)和 席位核对ACCEPT · head
|
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-writtencontent/docspage teaches writingsys_file'skey,mime_type,size,statusoretagthrough the data door (git grepfor those columns besidesys_fileoutsidecontent/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, branchclaude/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,再被记录的文件字段认领;或事后改大小和类型骗过字段约束。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 measuredsys_filemetadata refusal, so a baseline change that grants memberssys_filewrites 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
SYS_FILE_STORAGE_OWNED_COLUMNSinsystem-file.object.tsnames the five columns the storage service owns:key(where the bytes live),mime_type,sizeandetag(what the backend was told and answered) andstatus(how far the upload got).keyandetagare in the same class as the three the card names: a forgedkeypoints a row at bytes it never uploaded, andetagis the backend's own answer at/upload/complete. The ownership columns (ref_*,released_ref_*),name,acl,scope,owner_idandmetadataare not in it and stay as they were. The constant is module-internal (not re-exported fromsrc/index.ts), so the published surface does not move.readonly: true. Measured: the engine's static readonly strip runs on an UPDATE of every object,sys_included, for every context that is notisSystem(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 andthis.loggergets the readonly warn. Only a caller passingstrictReadonlyWritesis refused (ERR_READONLY_FIELD_REJECTED), as for every readonly column; the data doors do not pass it.staticReadonlyInsertSubject(@objectstack/objectql) returnsnullfor everysys_object, leaving its columns to the object's own guard, soreadonlyalone does not touch a non-system create. The newinstallSysFileStorageOwnedColumnsGuard(sys-file-storage-owned-columns.ts) is that guard: abeforeInserthook onsys_file, installed byStorageServicePluginatkernel:readybeside the attachment lifecycle hooks. For a write whose context is notisSystem(context-less included, as the update strip judges), it deletes any of the five the payload carries, setsstatus: 'pending'and logs onewarnnaming the dropped columns. It does not refuse: the row is written with the caller's other columns, a file field refuses it (it ispending), and the lifecycle'spendingretention collects it. ⛔packages/objectql'ssys_insert exemption is not touched.isSystemreturn is one elevation read, socontent/docs/permissions/system-context.mdxgains row 47g for it (installSysFileStorageOwnedColumnsGuard) and its generated site register is rewritten bycheck-system-context-census.mjs --write.StorageMetadataStore.createFile/updateFile/deleteFile(systemInsertOptionsFor,{ isSystem: true, tenantId? }), which the presigned, PUT,/upload/complete, chunked upload (storage-routes.ts),field-file-service.tsandfile-attachment-service.tsdoors 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 onlyorganization_id/scope. Nosys_filewrite outsideservice-storage(git grepfor an insert / update of'sys_file'inpackages/**source).Tests
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 (realObjectQLover sqlite:memory:,StorageServicePluginon the local adapter, the upload doors called as routes, data writes as a signed-in caller with no system context), 12 cases:readonlyonSystemFile;{ status: 'committed', size: 999 }, the row keeps every storage-owned value (stillpending), 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_valueonposter);it.eachover the five), andnamein the same payload lands;aclin the same payload lands;pendingwith all five null,namekept, and a file field refuses it; a batch create the same on every row;/upload/complete→ claim path still writes every column (mime_type,size,status: committed,etagfrom complete, akeyholding the file id) and a record's file field claims it;sys-file-metadata-write-refusal.dogfood.test.ts(the Pin the measuredsys_filemetadata refusal, so a baseline change that grants memberssys_filewrites reddens a gate instead of shipping quietly #11076 RBAC refusal pin). Its non-admin cases (403PERMISSION_DENIED, columns unchanged) are untouched. Its platform-admin contrast control asserted the admin's PATCH ofmime_type/sizeLANDS, the exact behaviour this card reverses (the full battery's one red on the first run). The control now sendsnamein the same payload and asserts 200, thatnamelanded (so the request still reaches the row) and thatmime_type/sizekeep the seeded values. The header's sentences that said the columns carry noreadonlyare updated; the file always said it was written to survive this posture call.storage-service-plugin.test.ts's wiring case asserted every scoped hook namessys_attachment; it now counts the guard's onebeforeInsertonsys_fileseparately and pins it.Acceptance notes
scripts/ablation-replace.mjs, fix committed first; restore blob == HEAD andgit diff HEADempty on each). The suite importsservice-storage'ssrc/directly, so nodist/rebuild is in any leg.readonly: true,insystem-file.object.tstofalse(6 hits,idincluded): 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.installSysFileStorageOwnedColumnsGuard(...)call in the plugin: 2 failed / 10 passed (the single and the batch create).isSystemexemption: 8 failed / 4 passed. Red: the end-to-end path and the system create (the presign's own insert lost itskeyandsize), and the update cases whose committed fixture is built by that path.pendingover a committed file, and the leg above is the rerun.pnpm lint;.claude/agents/os-dev.mdmakes the repo-wide sweep CI's run, so eslint ran on the changed package (andLint & Repo Gatesruns the sweep). The order asked for "no new refusal": none is added on either verb.strictReadonlyWrites(the engine's insert strip would refuse under it on a non-sys_object); no data door passes that option.ref_object/ref_id/ref_fieldandreleased_ref_*are also plain writable fields to a caller allowed to writesys_file; whether that is a defect is a separate question from this card's lifecycle columns.claude/project-thread-36yup5, not theclaude/issue-N-slugconvention; the claim above is its identity record.Verification
All on head
aebcc2cd(fix and tests886ec313..f886aad7, merged withorigin/mainat6f79d563, then the ADR-0087 marker, the dogfood control and the census row).Package suite, typecheck, eslint
pnpm --filter @objectstack/service-storage test && … typecheckunderos-verify-lock.shonaebcc2cd: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-widepnpm lintsweep is left toLint & Repo Gates.Dogfood battery
6f79d563, closure built withturbo 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 seededapplication/octet-stream/ 11, expected the admin's values). Moved as described under Tests; that file alone then 6 passed (6).aebcc2cd, closure rebuilt thenpnpm --filter @objectstack/dogfood testunderos-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 ataebcc2cd; all 94 run on that head, eachexit 0.check:adr-0087-registrationfirst red (no disposition marker;no-migration-prescriptionadded);check:system-context-censusfirst red (the guard'sisSystemread had no row; row 47g added);check:plugin-teardown-shape --self-testfirst exit 3 (shallow clone; its pinned fixture commit fetched);check:dual-build-cjs-loadsfirst exit 3 (7 dists missing; built).--ranreconcile: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).origin/main), so nomerge-treepair was owed.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgWddm8zjbee2kUdmmA