You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
service-storage: sys_file lifecycle columns (status, size, mime_type) are writable through the data door, so a never-uploaded file can be marked committed and then claimed by a file field #27399
QA-source: #27392 · attachments-storage.field-accept-maxsize-server-enforced · observed beside the clauses
What happens
sys_file records the storage service's own view of an upload: status (pending → committed → deleted), size and mime_type. Two checks read those columns:
But sys_file declares these columns as ordinary writable fields, so PATCH /api/v1/data/sys_file/ID rewrites them. A caller allowed to write sys_file rows can therefore:
mark a presigned upload committed without its bytes ever arriving, and have a record's file field accept it (201);
rewrite size or mime_type after commit, so the field constraints judge values that do not describe the stored bytes.
On a stock boot only an administrator reaches this. A member's PATCH of sys_file answers 403. It is an integrity gap in the storage lifecycle, not a privilege escalation. The object's own header says the lifecycle status is tracked so that clients cannot spoof backend facts.
Reproduction (fresh showcase boot, admin signed in through the form)
POST /api/v1/storage/upload/presigned with {"filename":"ghost.png","mimeType":"image/png","size":1234} → 200; read fileId. Do not PUT any bytes and do not call complete.
GET /api/v1/data/sys_file/FILE_ID → status: pending.
PATCH /api/v1/data/sys_file/FILE_ID with {"status":"committed","size":999} → 200.
GET /api/v1/data/sys_file/FILE_ID → status: committed, size: 999.
On an object with a file field, for example the qa-media-constraints recipe's qamedia_media.poster, POST /api/v1/data/qamedia_media with {"name":"ghost","poster":"FILE_ID"} → 201, and the file is claimed.
Expected: the lifecycle columns are owned by the storage service. A data-door write to status, size, mime_type (and the storage key and etag) is refused or ignored, and a never-uploaded file stays pending.
Actual: the PATCH answers 200, and the file field then accepts a file that has no bytes.
This was reproduced twice at c4c515ad, on two independent boots.
Mechanism
packages/services/service-storage/src/objects/system-file.object.ts declares status as a plain Field.select and size / mime_type as plain fields. Neither is readonly, and no storage-owned write guard applies. The data door's generic update therefore writes them like any other column.
Where a fix would go
Mark the storage-owned lifecycle columns readonly on the data door, so that only the storage service's own writes (complete, chunk, delete) move them. Alternatively, have the file-field claim re-verify the held bytes rather than trusting the row.
Read fresh. sys_file's lifecycle columns (status, size, mime_type) are writable through the data door. So a never-uploaded file can be marked committed and accepted by a file field, and accept / maxSize can be made to judge false values. Only an administrator reaches it on a stock boot, so it is an integrity gap, not an escalation. p2.
It is extracted from the 18.3.0 RC run, so it is listed in wave anchor [sweep] v18.3.0 release test sweep #27377 §5 by the runner, and it is retested on the next wave.
Claim: PM loop round 3 · card thread
Session: session_01L6bhgUCizoYWQVJWjhdR6F (the seat); the card thread is session_01L6bhgWddm8zjbee2kUdmmA
Account: os-sales
Branch: claude/project-thread-36yup5 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
Worktree: objectstack-issue-27399 in the card thread's own container
Domain: domain:server
Seat: domain:server#2
File surface: packages/services/service-storage/src/objects/system-file.object.ts (the lifecycle columns), the service-storage writers of sys_file (only to make a non-system write system-context, if one is found), one service-storage-owned create-side guard for a non-system sys_file insert (a hook or validation in packages/services/service-storage, since the engine's static readonly strip exempts sys_ objects on insert), and tests. ⛔ Not packages/objectql (the engine-wide sys_ insert exemption stays as it is; stop and report if the guard cannot live in the storage service). Stop on breach; explain in the report.
Container & model: M, mode:thread, model: default (dispatch-gates --tier: no path-derived mandate), effort: high
Clause-②: no (narrowing)
Metadata-protocol: none
Responsibility: the storage service's sys_file object (this lane) | the engine strips readonly columns on update but exempts sys_ objects on insert | any non-system writer of sys_file through the data door
Thread-read: 6489236866
Premise: holds (read on main 2026-10-29T20:58Z and by the card thread: the lifecycle columns are plain writable fields; on update the static readonly strip would apply, on insert sys_ objects are exempt)
Serial constraints cleared: none (no open PR edits these files). A contract review is owed before approval.
{
"issue": 27399,
"status": "done",
"branch": "claude/project-thread-36yup5",
"pr": "https://github.com/objectstack-ai/objectstack/pull/27765",
"session": "session_01L6bhgWddm8zjbee2kUdmmA",
"premise_still_valid": true,
"summary": "sys_file's five storage-owned columns (key, mime_type, size, status, etag) are now readonly on the object, so the engine's existing static readonly strip keeps the stored values on every non-system UPDATE (by id and by predicate; stripped with a warn, not refused). Measured: on INSERT the engine exempts every sys_ object (staticReadonlyInsertSubject returns null), so a new service-storage beforeInsert guard (installSysFileStorageOwnedColumnsGuard) drops those five from a non-system create and starts the row pending; no refusal added on either verb, packages/objectql untouched. Every writer of the columns was already system context (StorageMetadataStore for presign / complete / chunked / field-file / attach, file-reference claim-release-copy, attachment tombstone, backfills); none changed. Moved pins: the #11076 dogfood admin contrast control (it pinned the admin write landing, the behaviour this card reverses) and the plugin wiring pin; system-context census row 47g added for the guard's isSystem read.",
"tests": "New src/sys-file-storage-owned-columns.integration.test.ts, 12/12 on the #26479 rig (card steps 1-5, each column by id, predicate update, single and batch create, presign-PUT-complete-claim end to end, system create keeps values). Ablation via scripts/ablation-replace.mjs, fix committed first, restore blob == HEAD each leg, src-imported so no dist leg: leg 1 every readonly true to false in system-file.object.ts (6 hits) 10 failed / 2 passed; leg 2 guard install deleted 2 failed / 10 passed; leg 3 guard isSystem exemption deleted 8 failed / 4 passed. Final head aebcc2cd: pnpm --filter @objectstack/service-storage test and typecheck under the lock VERDICT command-exit 0, 71/71 files, 1299/1299 tests; eslint --no-inline-config on the package and the changed dogfood file exit 0.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 94 families at aebcc2cd, all 94 run on that head, each exit 0; --ran reconcile: 94 run, 0 NOT-MEASURED (a DERIVED zero). First-run reds fixed in the diff: check:adr-0087-registration (no-migration-prescription marker added), check:system-context-census (row 47g). Prerequisites cleared: plugin-teardown self-test fixture commit fetched; 7 dists built for check:dual-build-cjs-loads. No open PR (17 read) touches these files. CI on PR 27765: in_progress at report time (Lint, Type Check lanes started).",
"dogfood_battery": "aebcc2cd · VERDICT command-exit 0 · Test Files 354 passed / 3 skipped (357) · Tests 2858 passed / 20 skipped (2878) · no failure. First run on 6f79d563 had 1 failure, sys-file-metadata-write-refusal.dogfood.test.ts admin contrast control, moved in this PR.",
"mcp_calls": "6 — reads only: issue_read (get, get_comments x3), pull_request_read (get 27693, get_check_runs 27765), list_pull_requests. No MCP write tool.",
"api_writes": "2 — git push of the branch (not REST); relay pr_create (POST pulls, run 40967169876, body read back identical); this os-dev-report comment via post-stamped (POST issues/27399/comments).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted, not filed: the field-reference ownership columns ref_object / ref_id / ref_field and released_ref_object / released_ref_id on sys_file are plain writable fields to a caller allowed to write sys_file (code reading only, no door measured) · dedupe words: sys_file ref_object writable, file ownership columns data door, released_ref_id spoof",
"deviations: the order asked for pnpm lint; os-dev.md leaves the repo-wide sweep to CI, so eslint ran on the changed package and file. The create guard does not read strictReadonlyWrites (no data door passes it). PR attribution lines use this card thread's link and footer session (session-context requirement), with the seat signature as a body line."
]
}
Activity
objectstack-fleet commentedon Oct 29, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: 文件 | none | P②
Triage: p2 ·
domain:server·area:files· 2026-10-29T05:59Z⛔ Not a claim.
sys_file's lifecycle columns (status,size,mime_type) are writable through the data door. So a never-uploaded file can be markedcommittedand accepted by a file field, andaccept/maxSizecan be made to judge false values. Only an administrator reaches it on a stock boot, so it is an integrity gap, not an escalation. p2.readonly/ system-owned on the object), and storage: a file field accepts a presigned upload that was never completed (sys_file status pending) and claims it #26479 and Storage upload/complete commits a file record for bytes the backend never stored; the download then fails 500 #24548 keep trusting them.bug,pm:queue.Generated by Claude Code
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Claim: PM loop round 3 · card thread
Session:
session_01L6bhgUCizoYWQVJWjhdR6F(the seat); the card thread issession_01L6bhgWddm8zjbee2kUdmmAAccount:
os-salesBranch:
claude/project-thread-36yup5(the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)Worktree:
objectstack-issue-27399in the card thread's own containerDomain:
domain:serverSeat:
domain:server#2File surface:
packages/services/service-storage/src/objects/system-file.object.ts(the lifecycle columns), the service-storage writers ofsys_file(only to make a non-system write system-context, if one is found), one service-storage-owned create-side guard for a non-systemsys_fileinsert (a hook or validation inpackages/services/service-storage, since the engine's static readonly strip exemptssys_objects on insert), and tests. ⛔ Notpackages/objectql(the engine-widesys_insert exemption stays as it is; stop and report if the guard cannot live in the storage service). Stop on breach; explain in the report.Container & model:
M,mode:thread,model: default (dispatch-gates --tier: no path-derived mandate),effort: highClause-②: no (narrowing)
Metadata-protocol: none
Responsibility:
the storage service's sys_file object (this lane) | the engine strips readonly columns on update but exempts sys_ objects on insert | any non-system writer of sys_file through the data doorThread-read: 6489236866
Premise: holds (read on main 2026-10-29T20:58Z and by the card thread: the lifecycle columns are plain writable fields; on update the static readonly strip would apply, on insert
sys_objects are exempt)Serial constraints cleared: none (no open PR edits these files). A contract review is owed before approval.
objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
os-dev-report
{ "issue": 27399, "status": "done", "branch": "claude/project-thread-36yup5", "pr": "https://github.com/objectstack-ai/objectstack/pull/27765", "session": "session_01L6bhgWddm8zjbee2kUdmmA", "premise_still_valid": true, "summary": "sys_file's five storage-owned columns (key, mime_type, size, status, etag) are now readonly on the object, so the engine's existing static readonly strip keeps the stored values on every non-system UPDATE (by id and by predicate; stripped with a warn, not refused). Measured: on INSERT the engine exempts every sys_ object (staticReadonlyInsertSubject returns null), so a new service-storage beforeInsert guard (installSysFileStorageOwnedColumnsGuard) drops those five from a non-system create and starts the row pending; no refusal added on either verb, packages/objectql untouched. Every writer of the columns was already system context (StorageMetadataStore for presign / complete / chunked / field-file / attach, file-reference claim-release-copy, attachment tombstone, backfills); none changed. Moved pins: the #11076 dogfood admin contrast control (it pinned the admin write landing, the behaviour this card reverses) and the plugin wiring pin; system-context census row 47g added for the guard's isSystem read.", "tests": "New src/sys-file-storage-owned-columns.integration.test.ts, 12/12 on the #26479 rig (card steps 1-5, each column by id, predicate update, single and batch create, presign-PUT-complete-claim end to end, system create keeps values). Ablation via scripts/ablation-replace.mjs, fix committed first, restore blob == HEAD each leg, src-imported so no dist leg: leg 1 every readonly true to false in system-file.object.ts (6 hits) 10 failed / 2 passed; leg 2 guard install deleted 2 failed / 10 passed; leg 3 guard isSystem exemption deleted 8 failed / 4 passed. Final head aebcc2cd: pnpm --filter @objectstack/service-storage test and typecheck under the lock VERDICT command-exit 0, 71/71 files, 1299/1299 tests; eslint --no-inline-config on the package and the changed dogfood file exit 0.", "gates": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 94 families at aebcc2cd, all 94 run on that head, each exit 0; --ran reconcile: 94 run, 0 NOT-MEASURED (a DERIVED zero). First-run reds fixed in the diff: check:adr-0087-registration (no-migration-prescription marker added), check:system-context-census (row 47g). Prerequisites cleared: plugin-teardown self-test fixture commit fetched; 7 dists built for check:dual-build-cjs-loads. No open PR (17 read) touches these files. CI on PR 27765: in_progress at report time (Lint, Type Check lanes started).", "dogfood_battery": "aebcc2cd · VERDICT command-exit 0 · Test Files 354 passed / 3 skipped (357) · Tests 2858 passed / 20 skipped (2878) · no failure. First run on 6f79d563 had 1 failure, sys-file-metadata-write-refusal.dogfood.test.ts admin contrast control, moved in this PR.", "mcp_calls": "6 — reads only: issue_read (get, get_comments x3), pull_request_read (get 27693, get_check_runs 27765), list_pull_requests. No MCP write tool.", "api_writes": "2 — git push of the branch (not REST); relay pr_create (POST pulls, run 40967169876, body read back identical); this os-dev-report comment via post-stamped (POST issues/27399/comments).", "open_questions": [], "out_of_scope_findings": [ "carrier: none · noted, not filed: the field-reference ownership columns ref_object / ref_id / ref_field and released_ref_object / released_ref_id on sys_file are plain writable fields to a caller allowed to write sys_file (code reading only, no door measured) · dedupe words: sys_file ref_object writable, file ownership columns data door, released_ref_id spoof", "deviations: the order asked for pnpm lint; os-dev.md leaves the repo-wide sweep to CI, so eslint ran on the changed package and file. The create guard does not read strictReadonlyWrites (no data door passes it). PR attribution lines use this card thread's link and footer session (session-context requirement), with the seat signature as a body line." ] }objectstack-fleet commentedon Oct 29, 2026
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
ACCEPT → PR #27765 · head
aebcc2cd· 2026-10-29T22:58Z · landing sent (contract review PASS6510442206). Follow-ups: #27778 (review should-fix 1 and 2), #27767 (ref_*ownership columns).chore(changeset): leave the #27399 release note as it is