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
security(service-storage): three upload doors authorize by session alone, with no ownership or resume-token check on the file or upload they name (the owner-check class the #21908 ruling sent to its own card) #22046
Filing gate: ① a product defect, exception class: security (possible data disclosure, low). Found by #21908's stage-2a dev on PR #22045 (os-dev-report6029756396, out_of_scope_findings[0]). Filed by domain:services seat 2 (seat post #21118), session_01WMQprn46CND82KmY8sZWBu, for triage. ⛔ Not graded or routed here; ⛔ not a claim. ⛔ Classes, positions and functions only.
Why this card exists. The maintainer's ruling on #21908 (6028787157) declined option C: "an owner check added to the doors, which is a change to who may read which file and belongs on its own card, ruled on a declarative shape, not folded into this close-out". This is that card. PR #22045 leaves every door's reach as it was, so what follows is pre-existing.
First step for whoever takes it: measure reachability
Per the filing gate's exception, nothing here is measured at a public door yet. The first act is to measure, per door:
which callers reach it (role, organization, posture);
what it reveals or changes for an upload or file the caller did not start.
The grade and the shape follow from that reading.
What the doors do (packages/services/service-storage/src/storage-routes.ts, read on origin/main)
The chunk door (near :593) checks the upload's resume token (near :617) before it writes.
The chunked-completion door (near :681) and the progress door (near :751) authenticate the session but check no resume token. The progress door's by-id read carries no organization scope, and its answer names the upload's file name and file id.
The commit door (near :453) authenticates the session but checks no ownership of the file id it commits.
The download doors (near :799, :846) run authorizeDownload on the row before they disclose anything. They are listed only for contrast.
Direction (for triage; the ruling asks for a declarative shape)
The ruling routes this to "a declarative shape". Under #21999's escalation rule ("a fix that sets a security boundary by guesswork, such as key names, value shapes or pattern tables, ⇒ a decision card first, compared against the declarative options"), the shape may need a decision before any build. Two candidates are visible:
the chunk door's own resume-token check, applied to the doors that act on an upload;
an ownership predicate declared once, which every by-id door consults.
The seat records them only; it does not choose.
Dedupe: MCP search_issues, repo-scoped. 「storage upload door owner check chunked completion resume token progress file ownership」 and 「upload session resume token not checked on complete or progress」 return only #17354 and #7870, neither of which is this. The positive control is those same related storage cards coming back.
Ruling: batch #286 item 3 · letter B (sub-questions: no administrator exception; an empty owner_id is refused) · maintainer 「22046 同意」 2026-10-08T01:47Z
Director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (GitHub os-zhuang; written as objectstack-fleet[bot] via the relay). Batch #286 was presented in chat with the domain:services#2 seat's question 6030550530 and its options A / B / C; the maintainer asked for a detailed analysis first (given in chat with the seat's own reading of the doors) and then answered 「22046 同意」, which takes B with both sub-questions at their recommendation. Freshness gate: the body and all five comments (newest 6030550530) were re-read before this record; nothing newer exists. Thread-read: 6030550530.
The ruling
B. One ownership rule, declared once and checked at all three doors: the caller's user id must equal the file's owner_id (sys_file.owner_id, stamped from session.userId at both upload inserts, storage-routes.ts:415 and :532, and read by no door today); an upload session inherits its file's uploader through file_id. A caller who is not the uploader is refused with 403 PERMISSION_DENIED at the commit door (/upload/complete, :453), the chunked-completion door and the progress door alike. ⛔ Not taken: A (the resume-token check added to the two chunked doors only, which leaves the commit door, the one reachable today through a file id learned from any readable record with a file field, under session authentication alone) and C (no change).
Settled in the same stroke:
No administrator exception. No measured administrator flow uses these three doors; the rule reads "the uploader only". An exception, if a real need appears, is additive and comes on its own card.
An empty owner_id is refused. The rows without an uploader are committed uploads from before the stamp; none of them passes through these doors again. A door that meets one refuses it rather than guessing an owner.
What the rule does not change: who may read a file's bytes (the download door's authorizeDownload stands), who may start an upload, and the tenant stamp the doors already carry.
Why B on the first axis: the uploader is already a recorded fact on the row; one declared rule read at every door is the shape a new by-id door inherits by construction, where A would leave two regimes and a copyable gap.
Landing shape, as the seat's 裁后执行 fixes it: the three doors call one ownership predicate; each door carries a positive pin (the uploader passes) and a negative pin (a non-uploader gets 403), each negative pin ablated; the composed multi-organization case is read against the stage-2a pins since the wall was not booted in the measurement round.
Handover · the first of the two producer rows held privately with the maintainer on #21908 is this card's door · director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (via the relay) 2026-10-08T01:53Z
The maintainer handed the private row to this seat in chat today, because the domain:services seat 1 that was to take it has signed off (6009663942) and could not be reached. Its door is the commit door this card already names, and its reading is covered by the ruling B recorded here (6050496748): the caller must be the file's uploader, checked at the door after the by-id read and before any write or disclosure, which closes the private reading as well as the one this card measured. ⛔ The private reading's details stay off this thread; the claimant receives them from the maintainer, and the pins of the landing cover them.
Priority:priority:p2 → priority:p1 in this act, on the maintainer's grading of the private row (P1, measured reachable). The seat does not re-grade the public reading; the card now carries the higher of the two.
Handover addendum · one pin named for the landing · director seat, summon #35, session_01VYToj6PQehTEKNrjGM9akg (via the relay) 2026-10-08T01:56Z
To the ruling B (6050496748) and the handover (6050553414): each of the three doors carries, beside the same-organization non-uploader pin, a negative pin for a caller from ANOTHER organization naming a file or upload it did not start, answered 403 PERMISSION_DENIED with the same body as the same-organization case, measured on a booted wall (the measurement round did not boot one). The ownership rule is the check; the pin proves the by-id read that precedes it discloses nothing and writes nothing across the wall.
Claim: PM loop round 1 · 2026-10-08T02:23Z
Session: session_01WkL6Eijt432S1Y7ekb6ovQ
Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-22046-upload-door-owner-check
Worktree: objectstack-issue-22046
Domain: domain:services
Seat: domain:services#1 (seat post #6021)
Ruling-ref: 6050496748 (ruling B, director batch #286 item 3, maintainer 「22046 同意」; retrieved in this pass), with the addendum 6050589755 and the handover 6050553414. ⛔ Classes, positions and functions only.
File surface (at origin/main033e5c53, after PR #22045 landed as a7a48b78):
packages/services/service-storage/src/storage-routes.ts: the commit door (/upload/complete, near :453), the chunked-completion door (near :681) and the progress door (near :751) each consult ONE ownership rule after the by-id read and before any write or disclosure. The caller's user id must equal the file's owner_id; an upload session reaches its uploader through file_id; an empty owner_id is refused; no administrator exception. A non-uploader gets 403 PERMISSION_DENIED.
The rule is declared once in service-storage (in that file or one new module beside it).
Tests in service-storage. Per door: a positive pin (the uploader passes), a same-organization non-uploader pin and a cross-organization pin on a booted wall, both 403 PERMISSION_DENIED with the same body; each negative pin ablated. If the booted-wall pin cannot live in service-storage, it goes in the smallest package that boots a wall, and the report names that file for a cross-lane declaration.
A changeset for @objectstack/service-storage, graded and dispositioned as the changeset gates read it.
⛔ No packages/spec, no plugin-security, no change to the download doors' authorizeDownload, to who may start an upload, or to the tenant stamp. Stop on breach; explain in the report.
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, a ruled build on a security boundary)
Clause-②: no (narrowing)
A non-uploader's commit, completion and progress calls, accepted today on session alone, are refused. Nothing widens.
Responsibility: this repository's own code: service-storage's commit, chunked-completion and progress doors act on a file or upload id with session authentication alone | none: the chunk door's resume-token check and the download doors' authorizeDownload cover their own doors only | any authenticated session; the commit door is reachable today by a reader of a record that references the file (measured 6030517048)
Thread-read: 6050589755
Serial constraints cleared:
os-dev-report
{
"issue": 22046,
"status": "done",
"branch": "claude/issue-22046-upload-door-owner-check",
"pr": "#22170",
"session": "session_01WkL6Eijt432S1Y7ekb6ovQ (the dispatching PM session; mode:subagent)",
"premise_still_valid": true,
"summary": "Ruling B implemented as ruled, head 67e5a1e, draft PR 22170 (first line Fixes #22046, line-start Clause-②: no (narrowing) copied from the claim). ONE predicate isFileUploader(callerUserId, file) in new packages/services/service-storage/src/upload-ownership.ts (caller user id must equal the file's owner_id; empty owner_id and missing file row refused; no administrator exception; refusal constants 403 / PERMISSION_DENIED / one constant message). storage-routes.ts adds requireUploader(authSession, file, res), called by the commit door after getFile and before updateFile, and by the chunked-completion and progress doors after getSession, reading the session's file by file_id, before the expiry write, the status writes and any answer. Download doors, start doors, tenant stamp/scope, not-found answers and the chunk door are unchanged. Open mode (no session resolver wired, i.e. a kernel with no auth service) passes the gate: no caller identity exists there; flagged in open_questions. Hypotheses: H1 confirmed at 033e5c5 (owner_id written at :415 and :532, read by no door; all three doors call requireUploadSession then read by id). H2 confirmed (sys_upload_session has no user column; binds to its file by file_id). H3 confirmed (the progress door's expireIfPastDeadline writes; the check precedes it, pinned with an expired session). H4: dogfood cannot boot the wall (ADR-0132 pin forbids it declaring @objectstack/organizations); the wall boots in packages/plugins/organizations via bootStack with extraPlugins [OrganizationsPlugin, StorageServicePlugin] and OS_TENANCY_POSTURE=isolated + OS_AUTH_MEMBERSHIP_POLICY=invite-only, with the tenancy service read back as posture isolated, isolationActive true, degraded false. H5 confirmed (sendError + ADR-0112 nested envelope; PERMISSION_DENIED typed as StandardErrorCode; every refusal pin asserts code + status, never prose). H6 measured: unknown id gives 404 FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND (unchanged) and a foreign id gives 403, so the pair is an existence signal for an id the caller holds; not changed, flagged in open_questions. files_changed (10): .changeset/22046-upload-doors-uploader-only.md (service-storage minor, BREAKING banner, adr-0087 not-required (no-migration-prescription)); packages/services/service-storage/src/{upload-ownership.ts (new), storage-routes.ts, upload-door-ownership.test.ts (new, 23 cases), tenant-audit-update-delete-half-repairs.test.ts (route fixtures gain owner_id u1 plus the progress case's file row), error-envelope.conformance.test.ts (+1 case)}; CROSS-LANE (declare): packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts (new, 10 cases), packages/plugins/organizations/package.json (devDependencies @objectstack/service-storage and @objectstack/verify), packages/plugins/organizations/vitest.config.ts (both aliased to src as check:test-source-alias prescribes; env OS_REGISTRY_LOG warn as check:registry-log-declared requires; header line 'The three entries' became 'Some of the entries'), pnpm-lock.yaml (+6 lines). deviations: the cross-lane package.json, vitest.config.ts and lockfile edits go beyond the dispatch's file fence and are the necessary cost of H4's allowance (the test cannot resolve its subjects otherwise); nothing in the dispatch's forbidden list (packages/spec, plugin-security, download doors, start doors, tenant stamp) was touched. Commits carry AGENTS.md's model-free trailer pair; the harness reminder's model-named Co-Authored-By form and its PR footer form were not used, because AGENTS.md takes precedence. CI was not awaited and is in_progress.",
"tests": "All at head 67e5a1e unless stated. RED before the fix (base 033e5c5 plus the pin file, doors unmodified): pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/upload-door-ownership.test.ts gave Tests 12 failed | 11 passed (23). Every negative pin was red (status not 403) and every positive, not-found and open-mode pin was green. GREEN: pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 gave Test Files 43 passed (43), Tests 683 passed (683). pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2 gave Test Files 10 passed (10), Tests 141 passed (141); the wall file is 10/10 and emits 0 [Registry] lines. Dogfood wire consumers (attachments-permission-matrix, field-file-collection, predicate-write-unreadable-not-matched, write-door-unreadable-is-not-found) gave Tests 56 passed | 1 skipped (57); the skip is the existing multi-org block. pnpm --filter @objectstack/service-storage --filter @objectstack/organizations run typecheck exited 0, check:test-typecheck OK for both. tsc -p tsconfig.test.json --listFiles lists upload-door-ownership.test.ts and the wall test in their programs. ABLATION per door via node scripts/ablation-replace.mjs --file packages/services/service-storage/src/storage-routes.ts --anchor 'if (!requireUploader(...)) return;' --delete -- vitest, with the fix committed first. Each leg logged 'ok mutation landed: anchor 1 -> 0, blob 3ea485cd37fe -> e51b31c1083a (commit) / 3b7ec1e3041b (completion) / e98073ed1015 (progress)' and 'ok restored: blob == HEAD (3ea485cd37fe) and git diff HEAD is empty'. Service-storage pins: commit 4 failed | 19 passed, completion 4 | 19, progress 4 | 19; exactly that door's negative pins went red. Wall pins: each door 2 failed | 8 passed; that door's same-organization pin and cross-organization pin each went red on its own assertion (status not 403). No dist leg was needed: both suites resolve @objectstack/service-storage to src (relative import; vitest alias). LINT (proven narrowing): pnpm exec eslint --no-inline-config --format json on the 7 touched lintable files gave 7 files, 0 errors, 0 warnings, exit 0. The population is every touched .ts file and eslint reports none as ignored; eslint.config.mjs enables no type-aware rules, so the diff cannot move a verdict on an untouched file. GATES: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 67e5a1e derived 79 commands, the same list as at 89b93c6. All 79 were run at 67e5a1e and each exited 0. dispatch-gates --ran with the recorded exit codes printed '79 run, 0 NOT-MEASURED (a DERIVED zero)'. First pass at 89b93c6: check:registry-log-declared exit 1 was a real finding, fixed in 67e5a1e. check-plugin-teardown-shape --self-test exit 3 was shallow history; fixture commit 621a487 was fetched and it re-ran 0. check:dual-build-cjs-loads and check:i18n exit 3 had no dist; after the full build (72 tasks, 71 cached) both re-ran 0. CI: in_progress, not awaited.",
"mcp_calls": "0 — no MCP GitHub tool invoked; reads via gh api (card 22046 and its 9 comments; PR 22170 read-back).",
"api_writes": "3 — all through the fleet relay (repository_dispatch, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft; read-back identical, 7930 bytes); POST /repos//issues/22170/assignees (label-write --assign os-bill; read-back MATCHES); POST /repos//issues/22046/comments (this os-dev-report, post-stamped). git push x5 to the feature branch (git ops, not REST).",
"open_questions": [
{
"question": "H6 boundary flag: at the three doors an unknown id answers 404 (FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND) and an existing id the caller did not start answers 403 PERMISSION_DENIED, so the pair tells a caller whether an id they hold exists. Keep, or answer the foreign id as not-found?",
"options": [
"A: keep as ruled (404 unknown, 403 foreign). Real need: no measured flow needs the two told apart. The upload-session id is high-entropy and handed only to the starter, and the file id is already learnable from any record whose file field holds it, so the signal is the existence of an id the caller already holds. Long-term: the ruling's declared answer, one body for every refusal. AI-safety: the 403 names the real condition, so a client is told the remedy instead of chasing a missing row. Startup focus: no change, no new gate.",
"B: answer a foreign id with the door's own not-found (404). Real need: closes an existence oracle that no measured flow exploits. Long-term: matches the not-visible-is-not-found convention of the data write doors, but re-opens a ruled answer and makes a foreign upload indistinguishable from a lost one. AI-safety: a 404 for a live upload sends a client to restart work it cannot finish anyway. Startup focus: small change, but it needs a new ruling."
],
"recommendation": "A, because the signal reveals only the existence of an id the caller already holds, the ruling fixed 403 explicitly, and the 403 states the true remedy; revisit only if a door that enumerates ids appears."
},
{
"question": "Open mode: with no session resolver wired (a kernel with no auth service; production composes the resolver from the auth service), requireUploadSession returns no session and the rule is not applied, so the three doors stay open as the resolveSession option documents. Is that the intended reading of 'an empty owner_id is refused'?",
"options": [
"A: keep (implemented). The rule applies whenever a resolver is wired. Real need: open mode is the bare-kernel and test composition, and every upload there lands with no owner by construction. Long-term: an ownership rule needs an identity layer, and a mode without one cannot enforce it. AI-safety: open mode already announces once at boot that upload routes accept anonymous requests. Startup focus: no change.",
"B: refuse in open mode too (no caller id, so refused). The literal reading of the empty-owner clause. Every upload a bare kernel takes could then never be committed or completed, so open mode's declared upload surface stops working for embedders and tests."
],
"recommendation": "A, because the ruling's reason for refusing an empty owner (rows from before the stamp never pass these doors again) holds only where a resolver stamps owners; under open mode every new upload has an empty owner by construction."
}
],
"out_of_scope_findings": [
"class: a · reach: public door + wrong answer, measured on a booted organization wall (packages/plugins/organizations, bootStack + OrganizationsPlugin + StorageServicePlugin, throwaway probe at 67e5a1e, deleted after the run) · The UPLOADER who switches their active organization after starting an upload gets 500 INTERNAL from the commit door and from the chunked-completion door, and the progress door still answers 200. The 500 carries an operator message that diagnoses a data-engine outage ('Restore the data engine (connectivity / permissions / schema migration)'). The cause is the by-id write's tenant scope, which is the acting organization: it misses the row stamped for the starting organization, and the store raises that scoped miss with the engine-failure consequence text. This is pre-existing: the ownership rule passes the uploader, and this diff leaves the tenant scope unchanged as ruled. Seam: service-storage storage-routes.ts commit and chunked-completion doors to metadata-store.ts updateFile / updateSession. · dedupe words: upload commit 500 after organization switch · storage metadata store scoped update miss reported as engine outage · chunked completion INTERNAL tenant scope"
]
}
Seat ACCEPT: PR #22170 at 67e5a1ef · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T03:59Z. ⛔ Classes, positions and functions only.
Checked against GitHub and origin/main, not against the report's prose (os-dev-report on this card).
Form: draft, base main, first line Fixes #22046, line-start Clause-②: no (narrowing) copied from the claim 6050877248. The PR assignee is os-bill, matching the card.
Ruling B (6050496748) in the diff:
one predicate, isFileUploader, declared once in the new upload-ownership.ts;
storage-routes.ts calls it through requireUploader at the commit door (after getFile, before updateFile), the chunked-completion door and the progress door (after the session read, through the session's file_id, before the expiry write, the status writes and any answer);
an empty owner_id and a missing file row are refused; there is no administrator exception;
the download doors, the start doors, the tenant stamp and scope, the not-found answers and the chunk door are untouched.
Pins: every refusal pin asserts status 403, success: false and codePERMISSION_DENIED, plus that no row data reaches the body (expectNotUploader, expectRefused). Per door, the negative pins went red on their own door's ablation and back on restore (blob equal to HEAD). The cross-organization pins run on a booted wall in packages/plugins/organizations, with the posture read back as isolated, per the addendum 6050589755.
Fixture triage: the route block in tenant-audit-update-delete-half-repairs.test.ts gains owner_id on its seeds and the progress case's file row, so it keeps pinning its write context instead of measuring the new refusal. Accepted.
Changeset, sentence by sentence:minor with the BREAKING banner (no .changeset/pre.json on main, so the launch-window convention applies); the three door paths match basePath's default /api/v1/storage; "both upload-start doors already stamp from the session" (owner_id near :415 and :532); "a deployment with no auth service … keeps them open as before" (requireUploadSession returns no session only when no resolver is wired); "an upload left pending with no recorded uploader cannot be committed" (the empty-owner refusal). All hold. ADR-0087 not-required (no-migration-prescription): no metadata, export or stored shape changes.
Governed: 0 of 10 paths (check-governed-merges --pr 22170); 893 changed lines. Contract review: not owed (no Clause-②: yes, no packages/spec path, no governed path).
The dev's two boundary flags, answered by the seat (the maintainer may veto):
403 versus 404: kept. The ruling names 403 PERMISSION_DENIED, and the not-found answer is outside it. What the pair reveals is whether an id the caller already holds exists. It stays in the PR's Acceptance notes.
Open mode: kept. With no session resolver wired there is no caller identity, nothing is relaxed against today, and the ruling's subject is a session-authenticated door.
The private reading named in the handover 6050553414 was not received by this seat. Ruling B's pins are its coverage, as the handover states.
CI at this stamp: 22 success, 2 skipped, 7 in progress, 1 failure. The failure is Validate Package Dependencies, the OSV scan matching next@16.3.6. main's own scheduled scan is red on it too (run 37721176529), and its anchor card is #22148, so it is not this PR's. The landing waits for every check to be green: ready and auto-merge follow once #22148's fix or PR #22138 (base-relative scans) lands and main is merged into this branch.
PR #22170 merged through the merge queue as 29678f2c. isFileUploader (upload-ownership.ts) and its three call sites are on origin/main in storage-routes.ts. The card was closed completed by the PR's Fixes line; pm:dispatched and the assignee are removed in this act.
Activity
objectstack-fleet commentedon Oct 8, 2026
Ruling: batch #286 item 3 · letter B (sub-questions: no administrator exception; an empty
owner_idis refused) · maintainer 「22046 同意」 2026-10-08T01:47ZDirector seat, summon #35,
session_01VYToj6PQehTEKNrjGM9akg(GitHubos-zhuang; written asobjectstack-fleet[bot]via the relay). Batch #286 was presented in chat with thedomain:services#2seat's question 6030550530 and its options A / B / C; the maintainer asked for a detailed analysis first (given in chat with the seat's own reading of the doors) and then answered 「22046 同意」, which takes B with both sub-questions at their recommendation. Freshness gate: the body and all five comments (newest 6030550530) were re-read before this record; nothing newer exists. Thread-read: 6030550530.The ruling
B. One ownership rule, declared once and checked at all three doors: the caller's user id must equal the file's
owner_id(sys_file.owner_id, stamped fromsession.userIdat both upload inserts,storage-routes.ts:415and:532, and read by no door today); an upload session inherits its file's uploader throughfile_id. A caller who is not the uploader is refused with403 PERMISSION_DENIEDat the commit door (/upload/complete,:453), the chunked-completion door and the progress door alike. ⛔ Not taken: A (the resume-token check added to the two chunked doors only, which leaves the commit door, the one reachable today through a file id learned from any readable record with a file field, under session authentication alone) and C (no change).Settled in the same stroke:
owner_idis refused. The rows without an uploader are committed uploads from before the stamp; none of them passes through these doors again. A door that meets one refuses it rather than guessing an owner.authorizeDownloadstands), who may start an upload, and the tenant stamp the doors already carry.Why B on the first axis: the uploader is already a recorded fact on the row; one declared rule read at every door is the shape a new by-id door inherits by construction, where A would leave two regimes and a copyable gap.
State
needs-user-decision→pm:queuein this act;bug·priority:p2·security·domain:services·area:filesunchanged, no assignee: thedomain:servicesseat claims the build from its queue, starting from the tree after PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 (stage 2a of security(spec, plugin-security): the AI tool contract says a context with no caller runs "RLS-on, sees-nothing", but plugin-security hands a principal-less context straight through, and on a hosted kernel it read and wrote more than a member may #21908). Body:Ruled:line added in the same act.403), each negative pin ablated; the composed multi-organization case is read against the stage-2a pins since the wall was not booted in the measurement round.Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Handover · the first of the two producer rows held privately with the maintainer on #21908 is this card's door · director seat, summon #35,
session_01VYToj6PQehTEKNrjGM9akg(via the relay) 2026-10-08T01:53ZThe maintainer handed the private row to this seat in chat today, because the
domain:servicesseat 1 that was to take it has signed off (6009663942) and could not be reached. Its door is the commit door this card already names, and its reading is covered by the ruling B recorded here (6050496748): the caller must be the file's uploader, checked at the door after the by-id read and before any write or disclosure, which closes the private reading as well as the one this card measured. ⛔ The private reading's details stay off this thread; the claimant receives them from the maintainer, and the pins of the landing cover them.priority:p2→priority:p1in this act, on the maintainer's grading of the private row (P1, measured reachable). The seat does not re-grade the public reading; the card now carries the higher of the two.object_operationposture) and the third (the webhook redelivery door) are being analysed with the maintainer and get their own cards.Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Handover addendum · one pin named for the landing · director seat, summon #35,
session_01VYToj6PQehTEKNrjGM9akg(via the relay) 2026-10-08T01:56ZTo the ruling B (6050496748) and the handover (6050553414): each of the three doors carries, beside the same-organization non-uploader pin, a negative pin for a caller from ANOTHER organization naming a file or upload it did not start, answered
403 PERMISSION_DENIEDwith the same body as the same-organization case, measured on a booted wall (the measurement round did not boot one). The ownership rule is the check; the pin proves the by-id read that precedes it discloses nothing and writes nothing across the wall.Generated by Claude Code
objectstack-fleet commentedon Oct 8, 2026
Claim: PM loop round 1 · 2026-10-08T02:23Z
Session:
session_01WkL6Eijt432S1Y7ekb6ovQAccount:
os-bill(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-22046-upload-door-owner-checkWorktree:
objectstack-issue-22046Domain:
domain:servicesSeat:
domain:services#1(seat post #6021)Ruling-ref: 6050496748 (ruling B, director batch #286 item 3, maintainer 「22046 同意」; retrieved in this pass), with the addendum 6050589755 and the handover 6050553414. ⛔ Classes, positions and functions only.
File surface (at
origin/main033e5c53, after PR #22045 landed asa7a48b78):packages/services/service-storage/src/storage-routes.ts: the commit door (/upload/complete, near:453), the chunked-completion door (near:681) and the progress door (near:751) each consult ONE ownership rule after the by-id read and before any write or disclosure. The caller's user id must equal the file'sowner_id; an upload session reaches its uploader throughfile_id; an emptyowner_idis refused; no administrator exception. A non-uploader gets403 PERMISSION_DENIED.service-storage(in that file or one new module beside it).service-storage. Per door: a positive pin (the uploader passes), a same-organization non-uploader pin and a cross-organization pin on a booted wall, both403 PERMISSION_DENIEDwith the same body; each negative pin ablated. If the booted-wall pin cannot live inservice-storage, it goes in the smallest package that boots a wall, and the report names that file for a cross-lane declaration.@objectstack/service-storage, graded and dispositioned as the changeset gates read it.⛔ No
packages/spec, noplugin-security, no change to the download doors'authorizeDownload, to who may start an upload, or to the tenant stamp. Stop on breach; explain in the report.Container & model:
M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default tier, a ruled build on a security boundary)Clause-②: no (narrowing)
Responsibility:
this repository's own code: service-storage's commit, chunked-completion and progress doors act on a file or upload id with session authentication alone | none: the chunk door's resume-token check and the download doors' authorizeDownload cover their own doors only | any authenticated session; the commit door is reachable today by a reader of a record that references the file (measured 6030517048)Thread-read: 6050589755
Serial constraints cleared:
storage-routes.ts. PR fix(service-storage, service-messaging): the storage store's by-id methods and the HTTP outbox's redeliver take the explicit system opt-in #22045 (the by-id methods this file calls) has landed.plugin-security, not here.objectstack-fleet commentedon Oct 8, 2026
os-dev-report
{
"issue": 22046,
"status": "done",
"branch": "claude/issue-22046-upload-door-owner-check",
"pr": "#22170",
"session": "session_01WkL6Eijt432S1Y7ekb6ovQ (the dispatching PM session; mode:subagent)",
"premise_still_valid": true,
"summary": "Ruling B implemented as ruled, head 67e5a1e, draft PR 22170 (first line
Fixes #22046, line-startClause-②: no (narrowing)copied from the claim). ONE predicateisFileUploader(callerUserId, file)in newpackages/services/service-storage/src/upload-ownership.ts(caller user id must equal the file's owner_id; empty owner_id and missing file row refused; no administrator exception; refusal constants 403 / PERMISSION_DENIED / one constant message).storage-routes.tsaddsrequireUploader(authSession, file, res), called by the commit door aftergetFileand beforeupdateFile, and by the chunked-completion and progress doors aftergetSession, reading the session's file byfile_id, before the expiry write, the status writes and any answer. Download doors, start doors, tenant stamp/scope, not-found answers and the chunk door are unchanged. Open mode (no session resolver wired, i.e. a kernel with no auth service) passes the gate: no caller identity exists there; flagged in open_questions. Hypotheses: H1 confirmed at 033e5c5 (owner_id written at :415 and :532, read by no door; all three doors call requireUploadSession then read by id). H2 confirmed (sys_upload_session has no user column; binds to its file by file_id). H3 confirmed (the progress door's expireIfPastDeadline writes; the check precedes it, pinned with an expired session). H4: dogfood cannot boot the wall (ADR-0132 pin forbids it declaring @objectstack/organizations); the wall boots in packages/plugins/organizations via bootStack with extraPlugins [OrganizationsPlugin, StorageServicePlugin] and OS_TENANCY_POSTURE=isolated + OS_AUTH_MEMBERSHIP_POLICY=invite-only, with the tenancy service read back as posture isolated, isolationActive true, degraded false. H5 confirmed (sendError + ADR-0112 nested envelope; PERMISSION_DENIED typed as StandardErrorCode; every refusal pin asserts code + status, never prose). H6 measured: unknown id gives 404 FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND (unchanged) and a foreign id gives 403, so the pair is an existence signal for an id the caller holds; not changed, flagged in open_questions. files_changed (10): .changeset/22046-upload-doors-uploader-only.md (service-storage minor, BREAKING banner, adr-0087 not-required (no-migration-prescription)); packages/services/service-storage/src/{upload-ownership.ts (new), storage-routes.ts, upload-door-ownership.test.ts (new, 23 cases), tenant-audit-update-delete-half-repairs.test.ts (route fixtures gain owner_id u1 plus the progress case's file row), error-envelope.conformance.test.ts (+1 case)}; CROSS-LANE (declare): packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts (new, 10 cases), packages/plugins/organizations/package.json (devDependencies @objectstack/service-storage and @objectstack/verify), packages/plugins/organizations/vitest.config.ts (both aliased to src as check:test-source-alias prescribes; env OS_REGISTRY_LOG warn as check:registry-log-declared requires; header line 'The three entries' became 'Some of the entries'), pnpm-lock.yaml (+6 lines). deviations: the cross-lane package.json, vitest.config.ts and lockfile edits go beyond the dispatch's file fence and are the necessary cost of H4's allowance (the test cannot resolve its subjects otherwise); nothing in the dispatch's forbidden list (packages/spec, plugin-security, download doors, start doors, tenant stamp) was touched. Commits carry AGENTS.md's model-free trailer pair; the harness reminder's model-named Co-Authored-By form and its PR footer form were not used, because AGENTS.md takes precedence. CI was not awaited and is in_progress.","tests": "All at head 67e5a1e unless stated. RED before the fix (base 033e5c5 plus the pin file, doors unmodified):
pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2 src/upload-door-ownership.test.tsgave Tests 12 failed | 11 passed (23). Every negative pin was red (status not 403) and every positive, not-found and open-mode pin was green. GREEN:pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2gave Test Files 43 passed (43), Tests 683 passed (683).pnpm --filter @objectstack/organizations exec vitest run --maxWorkers=2gave Test Files 10 passed (10), Tests 141 passed (141); the wall file is 10/10 and emits 0 [Registry] lines. Dogfood wire consumers (attachments-permission-matrix, field-file-collection, predicate-write-unreadable-not-matched, write-door-unreadable-is-not-found) gave Tests 56 passed | 1 skipped (57); the skip is the existing multi-org block.pnpm --filter @objectstack/service-storage --filter @objectstack/organizations run typecheckexited 0, check:test-typecheck OK for both.tsc -p tsconfig.test.json --listFileslists upload-door-ownership.test.ts and the wall test in their programs. ABLATION per door vianode scripts/ablation-replace.mjs --file packages/services/service-storage/src/storage-routes.ts --anchor 'if (!requireUploader(...)) return;' --delete -- vitest, with the fix committed first. Each leg logged 'ok mutation landed: anchor 1 -> 0, blob 3ea485cd37fe -> e51b31c1083a (commit) / 3b7ec1e3041b (completion) / e98073ed1015 (progress)' and 'ok restored: blob == HEAD (3ea485cd37fe) and git diff HEAD is empty'. Service-storage pins: commit 4 failed | 19 passed, completion 4 | 19, progress 4 | 19; exactly that door's negative pins went red. Wall pins: each door 2 failed | 8 passed; that door's same-organization pin and cross-organization pin each went red on its own assertion (status not 403). No dist leg was needed: both suites resolve @objectstack/service-storage to src (relative import; vitest alias). LINT (proven narrowing):pnpm exec eslint --no-inline-config --format jsonon the 7 touched lintable files gave 7 files, 0 errors, 0 warnings, exit 0. The population is every touched .ts file and eslint reports none as ignored; eslint.config.mjs enables no type-aware rules, so the diff cannot move a verdict on an untouched file. GATES:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat 67e5a1e derived 79 commands, the same list as at 89b93c6. All 79 were run at 67e5a1e and each exited 0.dispatch-gates --ranwith the recorded exit codes printed '79 run, 0 NOT-MEASURED (a DERIVED zero)'. First pass at 89b93c6: check:registry-log-declared exit 1 was a real finding, fixed in 67e5a1e. check-plugin-teardown-shape --self-test exit 3 was shallow history; fixture commit 621a487 was fetched and it re-ran 0. check:dual-build-cjs-loads and check:i18n exit 3 had no dist; after the full build (72 tasks, 71 cached) both re-ran 0. CI: in_progress, not awaited.","mcp_calls": "0 — no MCP GitHub tool invoked; reads via gh api (card 22046 and its 9 comments; PR 22170 read-back).",
"api_writes": "3 — all through the fleet relay (repository_dispatch, executed as objectstack-fleet[bot]): POST /repos/objectstack-ai/objectstack/pulls (pr_create, draft; read-back identical, 7930 bytes); POST /repos//issues/22170/assignees (label-write --assign os-bill; read-back MATCHES); POST /repos//issues/22046/comments (this os-dev-report, post-stamped). git push x5 to the feature branch (git ops, not REST).",
"open_questions": [
{
"question": "H6 boundary flag: at the three doors an unknown id answers 404 (FILE_NOT_FOUND / UPLOAD_SESSION_NOT_FOUND) and an existing id the caller did not start answers 403 PERMISSION_DENIED, so the pair tells a caller whether an id they hold exists. Keep, or answer the foreign id as not-found?",
"options": [
"A: keep as ruled (404 unknown, 403 foreign). Real need: no measured flow needs the two told apart. The upload-session id is high-entropy and handed only to the starter, and the file id is already learnable from any record whose file field holds it, so the signal is the existence of an id the caller already holds. Long-term: the ruling's declared answer, one body for every refusal. AI-safety: the 403 names the real condition, so a client is told the remedy instead of chasing a missing row. Startup focus: no change, no new gate.",
"B: answer a foreign id with the door's own not-found (404). Real need: closes an existence oracle that no measured flow exploits. Long-term: matches the not-visible-is-not-found convention of the data write doors, but re-opens a ruled answer and makes a foreign upload indistinguishable from a lost one. AI-safety: a 404 for a live upload sends a client to restart work it cannot finish anyway. Startup focus: small change, but it needs a new ruling."
],
"recommendation": "A, because the signal reveals only the existence of an id the caller already holds, the ruling fixed 403 explicitly, and the 403 states the true remedy; revisit only if a door that enumerates ids appears."
},
{
"question": "Open mode: with no session resolver wired (a kernel with no auth service; production composes the resolver from the auth service), requireUploadSession returns no session and the rule is not applied, so the three doors stay open as the resolveSession option documents. Is that the intended reading of 'an empty owner_id is refused'?",
"options": [
"A: keep (implemented). The rule applies whenever a resolver is wired. Real need: open mode is the bare-kernel and test composition, and every upload there lands with no owner by construction. Long-term: an ownership rule needs an identity layer, and a mode without one cannot enforce it. AI-safety: open mode already announces once at boot that upload routes accept anonymous requests. Startup focus: no change.",
"B: refuse in open mode too (no caller id, so refused). The literal reading of the empty-owner clause. Every upload a bare kernel takes could then never be committed or completed, so open mode's declared upload surface stops working for embedders and tests."
],
"recommendation": "A, because the ruling's reason for refusing an empty owner (rows from before the stamp never pass these doors again) holds only where a resolver stamps owners; under open mode every new upload has an empty owner by construction."
}
],
"out_of_scope_findings": [
"class: a · reach: public door + wrong answer, measured on a booted organization wall (packages/plugins/organizations, bootStack + OrganizationsPlugin + StorageServicePlugin, throwaway probe at 67e5a1e, deleted after the run) · The UPLOADER who switches their active organization after starting an upload gets 500 INTERNAL from the commit door and from the chunked-completion door, and the progress door still answers 200. The 500 carries an operator message that diagnoses a data-engine outage ('Restore the data engine (connectivity / permissions / schema migration)'). The cause is the by-id write's tenant scope, which is the acting organization: it misses the row stamped for the starting organization, and the store raises that scoped miss with the engine-failure consequence text. This is pre-existing: the ownership rule passes the uploader, and this diff leaves the tenant scope unchanged as ruled. Seam: service-storage storage-routes.ts commit and chunked-completion doors to metadata-store.ts updateFile / updateSession. · dedupe words: upload commit 500 after organization switch · storage metadata store scoped update miss reported as engine outage · chunked completion INTERNAL tenant scope"
]
}
objectstack-fleet commentedon Oct 8, 2026
Seat ACCEPT: PR #22170 at
67e5a1ef· seatdomain:services#1(#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T03:59Z. ⛔ Classes, positions and functions only.Checked against GitHub and
origin/main, not against the report's prose (os-dev-reporton this card).main, first lineFixes #22046, line-startClause-②: no (narrowing)copied from the claim6050877248. The PR assignee isos-bill, matching the card.6050496748) in the diff:isFileUploader, declared once in the newupload-ownership.ts;storage-routes.tscalls it throughrequireUploaderat the commit door (aftergetFile, beforeupdateFile), the chunked-completion door and the progress door (after the session read, through the session'sfile_id, before the expiry write, the status writes and any answer);owner_idand a missing file row are refused; there is no administrator exception;status403,success: falseandcodePERMISSION_DENIED, plus that no row data reaches the body (expectNotUploader,expectRefused). Per door, the negative pins went red on their own door's ablation and back on restore (blob equal to HEAD). The cross-organization pins run on a booted wall inpackages/plugins/organizations, with the posture read back asisolated, per the addendum6050589755.tenant-audit-update-delete-half-repairs.test.tsgainsowner_idon its seeds and the progress case's file row, so it keeps pinning its write context instead of measuring the new refusal. Accepted.packages/plugins/organizations: the new test,package.jsondevDependencies,vitest.config.tsaliases) and 6 lockfile lines. The claim allowed this.@objectstack/organizationsis this lane's package, and the only claim holding files there (feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195:organizations-plugin.ts,claim-orphan-org-rows.ts) touches none of these.minorwith the BREAKING banner (no.changeset/pre.jsononmain, so the launch-window convention applies); the three door paths matchbasePath's default/api/v1/storage; "both upload-start doors already stamp from the session" (owner_idnear:415and:532); "a deployment with noauthservice … keeps them open as before" (requireUploadSessionreturns no session only when no resolver is wired); "an upload left pending with no recorded uploader cannot be committed" (the empty-owner refusal). All hold. ADR-0087not-required (no-migration-prescription): no metadata, export or stored shape changes.check-governed-merges --pr 22170); 893 changed lines. Contract review: not owed (noClause-②: yes, nopackages/specpath, no governed path).403 PERMISSION_DENIED, and the not-found answer is outside it. What the pair reveals is whether an id the caller already holds exists. It stays in the PR's Acceptance notes.6050553414was not received by this seat. Ruling B's pins are its coverage, as the handover states.500).CI at this stamp: 22 success, 2 skipped, 7 in progress, 1 failure. The failure is
Validate Package Dependencies, the OSV scan matchingnext@16.3.6.main's own scheduled scan is red on it too (run37721176529), and its anchor card is #22148, so it is not this PR's. The landing waits for every check to be green: ready and auto-merge follow once #22148's fix or PR #22138 (base-relative scans) lands andmainis merged into this branch.objectstack-fleet commentedon Oct 8, 2026
Landed ·
domain:servicesseat 1 (#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T05:32Z. ⛔ Classes, positions and functions only.PR #22170 merged through the merge queue as
29678f2c.isFileUploader(upload-ownership.ts) and its three call sites are onorigin/maininstorage-routes.ts. The card was closedcompletedby the PR'sFixesline;pm:dispatchedand the assignee are removed in this act.Blocked-by:line (the director's record6050734928); its unlock is the triage seat's sweep, and its second private row still has its own card.403/404pair and the open-mode reading stay as the seat's ACCEPT6051906667records them, open to the maintainer's veto.fix(service-storage)!: the upload commit, chunked-completion and prog…
fix(service-storage): answer an upload's organization change with 409…
fix(service-storage): answer an organization change at the chunk door…