Skip to content

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

Description

@objectstack-fleet

Ruled: 6050496748 · letter B · 2026-10-08T01:51Z

Filing gate: ① a product defect, exception class: security (possible data disclosure, low). Found by #21908's stage-2a dev on PR #22045 (os-dev-report 6029756396, 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.

Dedupe words: chunked upload completion resume token · upload progress organization scope · upload commit file ownership


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: ② the capabilities an end user meets in the app — uploading files | 缺项 | P2

    Triage: first grade, bug · security · priority:p2 · domain:services · area:files · pm:queue. The claim measures reach first, then stops: the shape is a ruling, not a build

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-07T02:56Z. ⛔ Not a claim, ⛔ not a dispatch. Classes, positions and functions only.

    Triage: lands in packages/services/service-storage/src/storage-routes.ts (the commit, chunked-completion and progress doors) ⇒ domain:services; rationale: the lane table puts packages/services/* there, and the doors and their checks are all in that file.

  2. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 5 · 2026-10-07T03:28Z
    Session: session_01WMQprn46CND82KmY8sZWBu
    Account: os-warren (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22046-upload-door-reach-measure
    Worktree: objectstack-issue-22046
    Domain: domain:services
    Seat: domain:services#2 (seat post #21118)
    ⛔ Classes, positions and functions only.

    Round 1 is measurement only, per triage (6029958517). No production change, and no PR with code. The seat files the decision card from the readings; ⛔ no build before that ruling.

    File surface, read and measured only (at origin/main 8caa131e52): packages/services/service-storage/src/storage-routes.ts, the six doors listed below. Each is measured from a local, uncommitted probe that is deleted after the run:

    • the commit door (POST …/upload/complete, near :453);
    • the chunked-completion door (POST …/upload/chunked/:uploadId/complete, near :681);
    • the progress door (GET …/upload/chunked/:uploadId/progress, near :751);
    • for contrast, the chunk door (PUT …/chunk/:chunkIndex, near :593, with its resume-token check near :617);
    • for contrast, the two download doors (near :799, :846, with authorizeDownload).

    Per door, the reading gives:

    • who reaches it (role, organization, posture);
    • how a caller learns an upload id or file id (whether any list or echo door hands it out, and whether ids can be guessed);
    • what the door reveals or changes for an upload or file the caller did not start.

    The report states plainly whether any door widens what a non-starter can read or change.

    The only thing written is the os-dev-report comment on this card: no commit beyond an empty branch, and no PR. ⛔ No plugin-security, no packages/spec, and no permission-set change. Stop on breach; explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier, a measurement round on a security boundary)
    Clause-②: no
    Responsibility: this repository's own code: service-storage's commit, chunked-completion and progress doors act on an upload or file id with session authentication alone | the chunk door's resume-token check and the download doors' authorizeDownload cover their own doors only; none covers these three | any authenticated session reaches them, and every upload runs through them today
    Thread-read: 6029958517
    Serial constraints cleared: at 2026-10-07T03:28Z:

    Clause-②: no: nothing is changed in this round.


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 22046,
    "status": "needs_decision",
    "branch": "claude/issue-22046-upload-door-reach-measure",
    "pr": null,
    "session": "session_01WMQprn46CND82KmY8sZWBu (shared, mode:subagent)",
    "premise_still_valid": true,
    "summary": "Measurement-only round, no production change, empty branch at base, no PR. Re-verified the six doors at origin/main 8caa131 (commit :453, chunked-completion :681, progress :751; contrasts: chunk :593 with resume-token check :616, chunked-start :502, downloads :799/:846 with authorizeDownload :183). The three doors under review authorize by session alone: each passes requireUploadSession (a session carrying any userId) then reads the target by id with no owner, no organization scope and no resume token. The by-id reads (getFile/getSession) are context-free findOne calls, so they disclose the row to any caller who reaches the handler, in every posture; the by-id writes carry the door's active organization as a driver-level scope, so a foreign-organization non-NULL row is not mutated under a wall, while org-less/NULL rows and the whole single posture stay mutable. Practical non-starter reach is gated only by learnability: the two session-id doors (completion, progress) key on a high-entropy upload-session id that is handed only to the starter and persisted on no readable record (no list door), so a non-starter cannot reach them today; the commit door keys on a file id, which IS persisted as the stored value of file/image fields and on the attachment join row, so a non-starter who can read a referencing record can reach it. Controls are live (anonymous refused; chunk door refuses a bad resume token; downloads gated by authorizeDownload). A ruling is warranted: the doors set an authorization boundary by session alone, which is a latent gap that any id disclosure or future list door opens, and the commit door is reachable now.",
    "tests": "READINGS TABLE (door | caller role | posture | reach | how the id is learnable | what is revealed or changed). Evidence basis per row marked [booted] (single-tenant attachments fixture + StorageServicePlugin + real auth resolver, 2 members + anonymous), [code] (source at 8caa131), [stage-2a] (review 6029803819, same lineage). || COMMIT DOOR (:453, keys on file id): starter | any | yes | holds own id | commits own file (baseline) [booted]. :: same-organization member (non-starter) | single/group/isolated | yes when the file id is held | file id is persisted as a file/image field stored value (ADR-0104 D3) and on the attachment join row, so any reader of a referencing record learns it; no list door; id is a UUID | reveals name, storage key, size, mimeType, etag; sets status=committed and overwrites etag (mutation lands for same-org and org-less/NULL rows, and for every row under single posture) [booted single-tenant: a different member's commit returned the starter's name and key and the non-starter-supplied etag was stored and echoed] [stage-2a for the org scope]. :: other-organization member | isolated | read yes, write scoped out | same | returns 200 revealing name/key/size/etag even when the write matches no row (the store returns the merged row without checking affected rows); a foreign-organization non-NULL row is not actually mutated [code + stage-2a: 13/28 pins red when the door tenant was dropped]; org-less/NULL legacy rows do mutate. :: no session | any | no | n/a | 401 AUTH_REQUIRED (control) [booted]. || CHUNKED-COMPLETION DOOR (:681, keys on upload-session id): starter | any | yes | holds own id | completes own upload [booted]. :: same-organization member (non-starter) | any | NOT in practice | the upload-session id is high-entropy, handed only to the starter at the start door, persisted on no record a non-starter can read, and there is no list door, so it is not learnable | if the id is held: 200 revealing fileId, key, size, mimeType, download url, and drives the upload to completed/failed; the file and session writes are organization-scoped [booted: a different member with the id reached it and received those fields]. :: other-organization member | isolated | read yes if id held, write scoped out | same | same, mutation blocked cross-org [code]. :: no session | any | no | n/a | 401 AUTH_REQUIRED (control) [booted]. || PROGRESS DOOR (:751, keys on upload-session id): starter | any | yes | holds own id | polls own upload [booted]. :: same-organization member (non-starter) | any | NOT in practice | same id gate as completion | if the id is held: 200 revealing filename, file id, total/uploaded sizes, status, timestamps; may stamp the row expired (an organization-scoped write); the read itself carries NO organization scope [booted: a different member received the starter's filename and file id]. :: other-organization member | isolated | read yes if id held | same | the by-id read crosses organizations by construction (context-free findOne) — this is the Q4 answer: no organization scope on the progress read [code + the single-tenant disclosure above]. :: no session | any | no | n/a | 401 AUTH_REQUIRED (control) [booted]. || CHUNK DOOR CONTRAST (:593, resume token :616): any caller lacking the resume token | any | no | n/a | 403 INVALID_RESUME_TOKEN — control live [booted]. || CHUNKED-START DOOR CONTRAST (:502): it mints and hands back the upload id, resume token and file id, but only in the response to the caller who starts the upload — it is the sole source of the upload-session id and resume token, and that source reaches the starter alone [code]. || DOWNLOAD DOORS CONTRAST (:799, :846, authorizeDownload :183): a non-owner who cannot read the file's parent record | any | no | n/a | authorizeDownload denies (403) — the bytes are governed; control present [code]. || PER-DOOR VERDICT (does it widen what a non-starter can read or change?): COMMIT = yes, narrowly — acts on a learnable file id; a non-starter reader of a referencing record can re-commit the file and overwrite its etag (mutation in-scope for same-org / org-less-NULL rows and the whole single posture; blocked cross-org by the wall) and read back key/name/size. COMPLETION = no in practice — keyed on the unguessable, non-persisted upload-session id, but authorized by session alone, so any id disclosure or list door opens same-org completion. PROGRESS = no in practice, same id gate — but its context-free read is the most disclosive and crosses organizations for any id-holder. CHUNK and DOWNLOAD = a non-starter is stopped, confirming the contexts are live. || OVERALL: the three doors authorize by session alone; today's practical non-starter reach is bounded to the commit door (learnable file id) and to same-org / org-less-NULL / single-posture mutation; the two session-id doors are gated only by id-unguessability, not by an ownership check. || TRANSPARENCY: the real multi-organization wall was not booted here (the dogfood package does not declare the organizations runtime, so bootStack cannot raise it there); the cross-organization WRITE-scope reading is cited from [stage-2a] (already measured in this lineage) plus [code], and the cross-organization READ-disclosure reading is from the context-free-read architecture [code] plus the booted single-tenant disclosure. No ablation was run (no production code to ablate this round).",
    "mcp_calls": "0 — reads via gh api only (card 22046, its comments, ruling 6028787157, stage-2a review 6029803819); no MCP GitHub tool invoked.",
    "api_writes": "1 — POST /issues/{n}/comments (this os-dev-report, via scripts/pm/post-stamped.mjs, fleet relay). The empty-branch git push is a git op, not a REST write; dispatch-gates has no family here because there is no production diff.",
    "open_questions": [
    {
    "question": "The three by-id upload doors (commit :453, chunked-completion :681, progress :751) authorize by session alone, with context-free by-id reads and driver-organization-scoped by-id writes. What declarative shape closes the boundary? Four-axis analysis of each.",
    "options": [
    "A - apply the chunk door's resume-token check to the doors that act on an upload (completion, progress). Customer-visible: a client polling progress or completing a chunked upload must present the resume token it received at start; a client that lost it restarts the upload; legitimate single-upload flows are unaffected (they hold the token). It does NOT cover the commit door (the presigned flow mints no resume token), so the one door with a learnable id keeps its session-only authorization. AXES - real need: no measured caller completes or polls an upload it did not start, so the token removes no real capability (favours tightening); long-term: PARTIAL, leaves the commit door on a different (missing) rule, the two-shapes-where-one-is-needed drift this lineage warns against; AI-safety: the chunked doors tighten loudly (403) but the commit door's gap persists; startup-focus: minimal, reuses an existing mechanism, no new CI gate.",
    "B - one ownership predicate, declared once, that every by-id door consults (commit, completion, progress). Customer-visible: a caller who is neither the uploader/owner nor an authorized admin is refused (PERMISSION_DENIED) at all three doors for a file or upload they did not start; the starter acting on their own upload is unaffected; admins retain reach through the override the predicate declares. AXES - real need: matches the measured usage (only the starter acts on their upload); long-term: contract-first - one declaration enforced at every door, no per-door dialect, and it covers the commit door that A misses; AI-safety: strongest - a single declared ownership contract enforced loudly at the door (declared == enforced); startup-focus: one predicate, minimal surface, a runtime authorization check rather than a new CI gate, within the tighten-by-default posture.",
    "C - no change, with the measured reason. Customer-visible: the doors keep authorizing by session alone; the controls (anonymous 401, chunk resume token, download authorizeDownload) stay the only gates. Justification if taken: the two session-id doors are not reachable by a non-starter (unguessable id, no list door, not persisted); the download door already governs the bytes; the commit door's marginal capability (etag/status write, key/name/size read-back) applies only to a file the caller can already read and download, and cross-organization mutation is already blocked by the wall; severity is low (p2). AXES - real need: nothing needs the gate today; long-term: leaves a session-only authorization on security-boundary doors, a boundary any future id leak or list door silently opens (weaker on contract-first); AI-safety: weakest, the gap stays latent; startup-focus: satisfies no-new-gate-by-default, but declared-is-enforced argues the other way for a security boundary."
    ],
    "recommendation": "B. It is the only option that closes the commit door (the one with a learnable id) and unifies the boundary across all three doors, it is contract-first (one declaration consulted everywhere, no dialect), and it denies no measured legitimate flow (only the starter acts on their own upload). A is a partial that re-creates the two-rules drift; C leaves a latent session-only boundary. The ruling owns the predicate's exact shape - the sub-decision below."
    },
    {
    "question": "If B is chosen, the predicate's exact declarative shape is the ruling's to fix (this is the key-names/value-shape boundary #21999 sends to a decision card).",
    "options": [
    "Owner equality only - owner_id of the file equals the caller's user id (the session already resolves owner_id at upload); the upload-session row carries no user column and binds to its file by file_id, so its owner is the file's owner.",
    "Owner OR organization-admin override - owner equality, plus the organization_admin role may act on any file/upload in its organization (parity with the admin reach other platform doors grant).",
    "Interaction with org-less/NULL rows and the single posture - whether the predicate denies when owner_id is unset (legacy rows), and whether single-posture deployments (no wall) rely on the predicate as their only boundary."
    ],
    "recommendation": "Owner equality with an organization-admin override, denying when owner_id is unset; this is a declarative shape (not a consumer-side widening) and keeps the single posture - where no wall exists - covered by the same one predicate. The maintainer fixes the final shape."
    }
    ],
    "out_of_scope_findings": []
    }

  4. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat verdict on the measurement round (6030517048) · seat domain:services#2 (#21118) · session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T03:52Z. ⛔ Classes, positions and functions only.

    ACCEPT the measurement; the shape goes to the maintainer. Triage's round-1 terms (6029958517) are met: there was no production change, the branch is empty at its base (verified), and no PR was opened. The readings call for a ruling, as triage foresaw.

    Readings the seat relies on (all class level):

    • All three doors (commit, chunked-completion, progress) authorize by session alone and act on the id they are handed.
      • Their by-id reads carry no organization scope.
      • Their by-id writes carry the door's organization as the driver-level scope, so a row stamped for another organization is not changed.
    • The commit door is reachable by a non-starter today. Its id is the file id, which a file field stores as its value (ADR-0104 D3) and the attachment join carries. So a reader of a record that references the file can learn the id. The door then reveals the file's metadata and sets its commit state.
    • The completion and progress doors are not reachable by a non-starter in practice. Their id is the upload-session id, which is minted only to the starter, is not persisted on any readable record, and has no list door. That makes the id the only gate; no ownership check stands behind it.
    • The controls are live:
      • a caller with no session gets 401;
      • the chunk door's resume-token check refuses with 403;
      • the download doors' authorizeDownload governs the bytes.
    • Not booted: the multi-organization wall (the cross-organization write reading comes from 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's stage-2a pins and the code). This is recorded as a confidence gap in the decision request.

    Release: session session_01WMQprn46CND82KmY8sZWBu · cause: the measurement round is complete, and triage requires a ruling before any build · destination: needs-user-decision, with pm:dispatched and the assignee removed in this act. The decision request follows in the next comment. When the ruling lands, the services seat claims the build from the queue.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 7, 2026

    @objectstack-fleet
    ContributorAuthor

    待裁(needs-user-decision):上传的三扇门只认登录态,不认上传人。用哪种声明式规则把它收紧?

    domain:services 2 号席(#21118)· session_01WMQprn46CND82KmY8sZWBu · 2026-10-07T03:52Z。⛔ 不是认领。⛔ 只写类别、位置与函数。

    维护者速读

    "提交上传""分片上传完成""查询上传进度"这三扇门,只要求调用者已登录。门不检查调用者是不是这个文件或这次上传的上传人。

    • 提交门今天就能被非上传人用到。文件 id 会作为文件字段的值存进记录,能读到那条记录的人就拿得到 id。用它可以读到文件的元信息,并改掉文件的提交状态。
    • 另外两扇门靠的是上传会话 id。这个 id 只发给上传人,也不落在任何可读的记录上,所以实际上别人拿不到。但只要 id 将来从任何渠道泄露,这两扇门就没有第二道防线。

    推荐 B:声明一条"只有上传人能操作自己的上传"的规则,三扇门统一查它。回一个字母:A / B / C。

    一句话问题

    同组织里能看到某条记录的人,能不能去"提交"或"完成"别人上传的文件?

    背景

    测量轮(6030517048)只读不改,在启动的栈上测了五类调用者:上传人、同组织成员、他组织成员、组织管理员、未登录。分诊的判定(6029958517)要求先裁定再施工,安全边界属于你。

    Governing text

    前提(各带复核命令)

    1. 三扇门只过登录态。 复核:git grep -n -E "upload/complete|chunked/:uploadId/complete|chunked/:uploadId/progress|requireUploadSession|INVALID_RESUME_TOKEN" packages/services/service-storage/src/storage-routes.ts
    2. 上传人已记录在 owner_id。 复核:git grep -n "owner_id" packages/services/service-storage/src/objects/system-file.object.ts packages/services/service-storage/src/metadata-store.ts
    3. 文件 id 可经文件字段学到。 复核:git grep -n "file-id" docs/adr/0104-field-runtime-value-shape-contract.md

    选项 × 真实代价

    选项 做什么 客户感受到的后果
    A 把分片门已有的续传令牌校验加到"完成"和"进度"两扇门上 客户端完成分片上传或查进度时要带上开始时拿到的令牌,丢了令牌就要重传。提交门不受这条规则约束:直传流程没有令牌,所以提交门照旧只认登录态
    B 声明一条所有权规则:文件的 owner_id 等于调用者。三扇门统一查它;上传会话通过 file_id 继承文件的上传人 上传人操作自己的上传不受影响。非上传人在三扇门都被拒,返回 403 PERMISSION_DENIED
    C 不改 现状不变。提交门对能读到引用记录的同组织成员仍然可用;另两扇门仍只靠 id 难猜

    业务含义直译:

    • A:给两扇门加了门禁卡,正门(提交)还是只看工牌。
    • B:三扇门统一核对"这件包裹是不是你寄的"。
    • C:靠单号难猜。

    os-decision-facets

    • ① 项目长远合理性:B 一条声明、处处执行,不出两套规矩。A 让提交门和另两扇门各守一种规则,特例增生。C 把"只认登录态"留在安全边界上。
    • ② 实际业务拉动:实测没有任何正当流程需要非上传人去提交、完成或查看别人的上传。B 和 A 都不拿走任何真实能力。提交门的缺口今天就可达,所以 C 的"零拉动"不成立。
    • ③ 防 AI 犯错:B 是响亮拒绝(403),规则写在一处,AI 新加一扇按 id 的门时会被同一条规则覆盖。A 漏掉提交门,AI 照着提交门的样子写新门就会复制这个缺口。C 是静默容忍。
    • ④ 创业阶段不扩散:B 只加一个运行时判定,不新增键、门禁或 API;它读的 owner_id 已经存在。A 复用现有令牌,改动更小,但留下缺口。C 零改动。

    Prior rulings read: owner check, storage door, declarative shape, resume token → 6028787157(C 归本卡)、6029958517(分诊:先测量再裁定);#21999(安全边界先出决策卡);thread: #21908。

    推荐

    B,规则形状取"仅上传人本人":文件 owner_id 等于调用者的用户 id;上传会话按 file_id 继承;owner_id 为空时拒绝。

    裁后执行


    Generated by Claude Code

  6. 7 remaining items

  7. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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.


    Generated by Claude Code

  8. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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/main 033e5c53, 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:
    • Of the 9 open PRs (each file list read), none touches 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.
    • No in-flight claim declares the file. 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's remaining deny lands in plugin-security, not here.
  9. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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"
    ]
    }

  10. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    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 code PERMISSION_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.
    • File surface: within the claim, plus the wall test's package (packages/plugins/organizations: the new test, package.json devDependencies, vitest.config.ts aliases) and 6 lockfile lines. The claim allowed this. @objectstack/organizations is this lane's package, and the only claim holding files there (feat(objectql,plugin-auth): the Default Organization is load-bearing under single; 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.
    • 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.
    • Out-of-scope finding: filed as finding(service-storage): an uploader whose active organization changed after starting an upload gets 500 INTERNAL from the commit and chunked-completion doors, with an operator message that diagnoses a data-engine outage #22175 (an uploader whose active organization changed gets a misleading 500).

    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.

  11. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 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 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.

  12. added 3 commits that reference this issue on Oct 9, 2026
    29678f2
    7ebbc01
    3513ac7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:filesFiles — upload, download, signed URLs, access derived from the parent recordbugSomething isn't workingdomain:servicespriority:p1High: required for production / M2security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions