Skip to content

fix(storage)!: an unclaimed file is served only within its organization - #25704

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25693-file-door-measure
Oct 25, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-25693-file-door-measure

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #25693

Clause-②: no (narrowing)
Metadata-protocol: none

What this changes

This PR makes one rule true: an unclaimed file is served only within its organization, as every other organization-scoped row is read. An unclaimed file is a private file with neither an attachments scope nor a field owner. It is either an upload that no record has claimed yet, or one whose record let it go.

  • The download doors (@objectstack/service-storage, storage-routes.ts, authorizeDownload). GET /api/v1/storage/files/:fileId/url and GET /api/v1/storage/files/:fileId still ask for a signed-in caller first, and answer 401 AUTH_REQUIRED without one. Then they hand an unclaimed file to the download authorizer, as they already do for every other private file. A deny answers 403 FILE_DOWNLOAD_DENIED, the status and code the doors already give a non-attachment file the caller may not read. Its message names no record. An allowed download keeps the presigned TTL this class has always been minted with. No error code is added.
  • The download authorizer (storage-service-plugin.ts, buildFileReadAuthorizer calling the new module-private isWithinCallerOrganization). The existing owner shortcut comes first. After it, an unclaimed file is judged in the two places that judge every other organization-scoped row. The first is the organization wall's verdict (computeTenantLayer0Verdict, ADR-0131 D8), asked over the caller's resolved context for the posture in force. The second is the driver's tenant term: the row is read by id under the caller's active organization (tenantId) and, under group, their organization access set. This package writes no organization comparison of its own. The context's organization is the one resolveRequestExecutionContext vetted against current membership. A caller with no organization scope under a walled posture is in no organization's reach, as the wall decides for every other row. The door answers that deny verdict itself, before the read.

What stays the same:

  • The uploader is served their own upload. This is the owner rule every private file already follows.
  • The single posture serves every signed-in caller, as before.
  • acl: 'public_read' stays anonymous.
  • Attachments-scope and field-owned files keep their parent-derived verdicts.
  • An anonymous caller still gets 401 AUTH_REQUIRED.
  • A kernel with no auth service binds no authorizer and still serves these files without a check.

registerStorageRoutes' exported authorizeFileRead option is now also asked about this class. Its doc says so, and the changeset's BREAKING section covers it.

Measurement

The card's maintainer word was 「立卡,先实测再修」, so the measurement came first. It ran through the public door on a fresh boot (a real HTTP server under the isolated posture, with the organizations runtime loaded) at b77bb37eb2, before any edit. Its one-line result is on the card. The detail is with the seat, off GitHub. The same measurement was repeated on the fixed build at 34c018bbad: the rule holds, and the controls are unchanged. That detail is with the seat too.

Declarations

  • Clause-②: no (narrowing). Under a walled posture the doors serve an unclaimed file to fewer callers, and nothing is widened. The claim carries Clause-②: no with no arm. The dispatch asked for the (narrowing) arm, and AGENTS.md Post-Task Checklist step 3 names that arm for this case, so it is added. The report notes the difference.
  • Metadata-protocol: none. Nothing a metadata author writes changes.
  • One patch changeset on @objectstack/service-storage, titled fix(storage)!:. It carries the BREAKING section (Before / Now / Fix) and the ADR-0087 disposition not-required (no-migration-prescription).
  • This PR touches no governed surface. skip-changeset is not applied.
  • It does not touch file-reference-lifecycle.ts (PR fix(rest)!: an anonymous public-form submission carries no file #25682 is open there), so the two PRs are file-disjoint.

Tests (head 34c018bbad)

  • New packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts, with 15 tests. It uses a real ObjectQL and SqlDriver (sqlite :memory:) and the real StorageServicePlugin booted as it ships, so the door, the session gate, the authorizer and resolveRequestExecutionContext are all real. Each case calls both doors, and where a file is served it follows the minted URL to the bytes. The stand-ins are the session read (two test headers), the permission store (sys_member fixture rows; empty for every other unregistered object) and the tenancy service.
    • isolated: refused (403 FILE_DOWNLOAD_DENIED at both doors, no URL minted, nothing about the file in the body) to a caller acting in another organization, to a signed-in caller acting in no organization, and to a caller whose session names the file's organization without a membership in it. Served to a colleague in the file's organization and to the uploader. The uploader is also served while acting elsewhere (the owner rule). An anonymous caller gets 401.
    • Controls under isolated: public_read is served to anyone, and an attached file keeps ATTACHMENT_DOWNLOAD_DENIED for another organization and is served to a colleague.
    • group: served to a member of the file's organization who is acting in another of their organizations, and refused to a caller with no membership in it.
    • single: served to every signed-in caller whatever organization the session names, and refused (401) to an anonymous caller.
  • storage-routes.test.ts: five existing pins asserted the session-only branch this PR removes. Each was re-judged on its own subject: the unclaimed class now asks the authorizer, its deny answers 403 FILE_DOWNLOAD_DENIED, an allowed download keeps the presigned TTL, and the bare-kernel pin binds no authorizer, as a bare kernel binds none. One route-level pin was added: a denied signed-in caller is refused at both doors and no URL is minted.
  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=2: Test Files 63 passed (63), Tests 1147 passed (1147).
  • pnpm --filter @objectstack/service-storage typecheck: exit 0. The test-layer program (tsconfig.test.json, checked with --listFiles) compiles both test files.
  • eslint --no-inline-config with the root config over packages/services/service-storage/src: exit 0.
  • Full dogfood battery (owed for the (narrowing) arm): pnpm --filter @objectstack/dogfood test under the shared lock, with no shard and no path, on this head. Its counts are in the os-dev-report comment on files: an authorization-adjacent gap on a file-serving door (detail withheld pending measurement) #25693.

Proof (ablation)

Each leg was mutated through node scripts/ablation-replace.mjs, which checks on disk that the anchor hit and that the blob changed, then restores and checks the result against the HEAD blob. The subject is this package's source, imported relatively by both suites, so no dist/ is involved.

  • Leg 1, the door's organization scoping removed for the unclaimed class. 10 tests went red: all five refusal pins of the new file (three under isolated, the refusal-comparison pin, and one under group) and the five route-level pins of the unclaimed class. 54 stayed green: every served pin, both controls and the single block. Restored: blob b34c59e7fdcb equals HEAD's, and git diff HEAD is empty.
  • Leg 2, the wall's deny exit removed. Exactly the 2 pins for a caller with no organization scope went red ("acts in no organization", and "names the file's organization without a membership in it"). The other 13 stayed green, so the driver's tenant term alone still refuses another organization. Restored: blob 1b929b2e9d08 equals HEAD's, and git diff HEAD is empty.

Acceptance notes

  • The uploader is served from any organization. This is the owner shortcut the authorizer already applies to every private file. Should an unclaimed upload be readable by its uploader alone, or by the whole organization? Neither the upload door nor ADR-0104 settles that. This PR keeps the organization scope (plus the uploader) and routes the question to the seat.
  • A platform administrator reads the unclaimed files of the organization they act in, like any member. The door holds no permission sets, so it grants no wall exemption. The engine makes the same choice for a system read.
  • A file row with no organization (invalid state on a walled deployment under ADR-0131) is in no organization's reach under a walled posture. Only its uploader is served it.

Gates

The local subset is the full node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack list for this diff, reconciled with --ran. Every family CI runs is read from this PR's check-runs. Both are in the os-dev-report comment on #25693.


Generated by Claude Code

A file with neither an attachments scope nor a field owner is now judged by
the download authorizer like every other private file: after the signed-in
check it is served within its organization, as every other
organization-scoped row is read. The organization wall's verdict decides
the posture's answer and the driver's tenant term decides the reach; no
organization comparison is written here. The uploader shortcut is kept,
`single` is unchanged and `acl: 'public_read'` stays anonymous.

Claude-Session: https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB
Co-authored-by: Claude <noreply@anthropic.com>
…laimed branch; changeset

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/plugin-endpoints.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/automation/approvals.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/data-modeling/objects.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/deployment/cli.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/getting-started/common-patterns.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/kernel/contracts/storage-service.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/permissions/attachments-access.mdx (via FILE_DOWNLOAD_DENIED (literal, a string literal in registerStorageFamily), public_read (literal, a string literal in a comment in StorageRoutesOptions), sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/permissions/authorization.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/permissions/permissions-matrix.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/permissions/rls.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/permissions/sharing-rules.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/protocol/objectql/security.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/protocol/objectql/types.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions), sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/ui/translations.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))

⛔ 10 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions), sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/index.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/v15.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions), sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/v16.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/v17/17-0.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/v17/17-2.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/releases/v17/17-5.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/v17/index.mdx (via sys_file (literal, a string literal in isWithinCallerOrganization))
  • content/docs/releases/v18/18-0.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))
  • content/docs/releases/v18/18-1.mdx (via public_read (literal, a string literal in a comment in StorageRoutesOptions))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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

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

Which tree this was computed on

This run read content/docs from 3efe02e1b9189e8381837c82675522ea22db5224 — the merge of head 34c018bbad650498162ddc8198eb65b554d60958 into base b1790841bb0bf2c7c696e858de51e8105704471a, which is what actions/checkout gives a pull_request run. Not the PR head.

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

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

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

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (seat review, TIER_DEFAULT) · domain:server seat 2 · sunhaolin · session_01JVTd3PprStGa26aez5omLB · 2026-10-24T19:01Z · head 34c018bbad · claim 6387155589 · report 6388246222

  • Shape: draft, base main, author objectstack-fleet[bot], assignee sunhaolin, Fixes #25693, title fix(storage)!: …, Clause-②: no (narrowing) and Metadata-protocol: none line-initial in the body and the changeset, patch on @objectstack/service-storage with a BREAKING banner and ADR-0087 not-required (no-migration-prescription). 5 files in packages/services/service-storage/src/; not governed; 644 changed lines.
  • Read on the diff: the download doors keep their signed-in check for a file with neither an attachments scope nor a field owner, then hand it to the download authorizer as every other private file is handed; a deny answers 403 FILE_DOWNLOAD_DENIED, the code the doors already use, with a message that names no record; no error code is added. The authorizer keeps its owner shortcut first, then judges the file with the organization wall's verdict (computeTenantLayer0Verdict, the posture in force) and the driver's tenant term (a by-id read carrying the caller's tenantId and access set); no hand-written organization comparison. Unchanged: single, public_read, attachments and field-owned files, the anonymous 401, and a kernel with no auth service.
  • Measured first, as the maintainer asked: the card carries the one-line result (comment 6387385669). The fix was re-measured through the same real door on a fresh boot.
  • Proof: 15 new tests on a real ObjectQL, SqlDriver and the real storage plugin across isolated, group and single, with public_read and attached-file controls; two ablation legs, each restored by hash.
  • Open question (organization-scoped plus uploader, as shipped, or uploader only until claimed): put to the maintainer by the seat; this PR ships the organization-scoped rule.
  • Disclosure: the PR, the changeset and the card state the rule only; the measurement went to the seat off GitHub.
  • CI on head 34c018bbad at this read: 23 success (Check Changeset included), 7 skipped, 9 in progress, 0 red. Dogfood battery (owed by the arm), as reported pinned to 34c018bbad: os-verify-lock VERDICT command-exit 0, Test Files 295 passed | 3 skipped, Tests 2520 passed | 18 skipped, no failure.
  • Writes: mcp_calls 0; 4 relay writes, all read back identical.

Not landed in this act. The (narrowing) arm owes one CONTRACT_REVIEW_TIER review before the queue; it goes to the director seat (maintainer, in this seat's session: 「你没有fable,可以让总监复审」). needs:contract-review is hung on this PR in this act; the PR stays draft until a same-form ## Contract review PASS at CONTRACT_REVIEW_TIER is on its current head, and the open CI has concluded green. Then: pr_ready + automerge_enable by this seat.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 34c018bbad650498162ddc8198eb65b554d60958
Local-runs: none
Scope: packages/services/service-storage/src/storage-routes.ts, packages/services/service-storage/src/storage-service-plugin.ts, packages/services/service-storage/src/storage-routes.test.ts, packages/services/service-storage/src/unclaimed-file-organization-scope.test.ts (narrowing face: every non-prose path; prose face .changeset/25693-unclaimed-file-organization-scope.md read, not scope)

① Derived judgments

  • Declaration is right. Clause-②: no (narrowing): under a walled posture the two download doors serve an unclaimed file to fewer callers than before, nothing widens. Metadata-protocol: none: how a door authorizes a request; no key, spelling or stored shape an author writes moves. Both lines are line-initial in the body and the changeset. No Ruling-ref is owed: the card carries the maintainer's own word in the seat's session (「立卡,先实测再修」) and triage 6387006016; no director ruling exists for this card, and none was needed.
  • The maintainer's order was followed in sequence. Measurement first through the public door on a fresh boot at b77bb37eb2 (card comment 6387385669: reproduces), then the fix, then the same measurement on the fixed build. Detail withheld from GitHub under the security-family discipline, as the card prescribes; this record states nothing beyond what the PR body already states.
  • Implementation matches the rule it claims. The door keeps the session gate for the unclaimed class (401 AUTH_REQUIRED unchanged), then hands the file to the download authorizer as it already does for attachments-scope and field-owned files. The authorizer keeps the owner shortcut first, then isWithinCallerOrganization judges in the two places the platform judges every organization-scoped row: computeTenantLayer0Verdict over the vetted context for the posture in force (a deny answered before any read), then a by-id read of sys_file under the caller's tenantId and, for group, accessible_org_ids. No organization comparison is hand-written in this package; single serves every signed-in caller as before; public_read stays anonymous; a kernel with no auth service binds no authorizer. A deny answers the door's existing 403 FILE_DOWNLOAD_DENIED with a message that names no record; an allowed unclaimed download keeps the presigned TTL (gated ? downloadTtl : presignedTtl). No error code is added.
  • Proof is real-engine. 15 new tests on a real ObjectQL + SqlDriver with the real StorageServicePlugin booted as it ships, across isolated, group and single, with public_read and attached-file controls; five route-level pins re-judged on their new subject plus one added; two ablation legs each restored blob-identical (leg 1: 10 red / 54 green; leg 2: exactly the 2 no-organization-scope pins red). Dogfood battery owed by the arm reported pinned to this head in 6388246222 (295 files, no failure).
  • File-disjoint from PR fix(rest)!: an anonymous public-form submission carries no file #25682 (file-reference-lifecycle.ts untouched), as the claim required.

② Semver level

'@objectstack/service-storage': patch with a fix(storage)!: title, a BREAKING (Before / Now / Fix) section and ADR-0087 not-required (no-migration-prescription) is exactly AGENTS.md step 3 for a non-protocol served-set narrowing. The no-migration-prescription reading holds: no stored row or source is rewritten; the remedy is operational (act in the file's organization, have a record claim it, or mark it public_read). All 46 check runs on head 34c018bbad are success or skipped (Test Core 1–6/6, Build Core, Dogfood Regression Gate, Type Check ×5, Lint ×9, Check Changeset ×2, Governed Surface Queue Guard, claim gates); none red, none pending.

③ Boundary flags

  • Uploader-from-anywhere vs organization-only for an unclaimed upload (PR Acceptance notes, seat 6388275683 open question). The PR ships the organization scope plus the owner shortcut every private file already has; the stricter uploader-only reading would be a further narrowing with its own changeset. Neither ADR-0104 nor the upload door settles it. The seat has put it to the maintainer; it does not block this PR, and a later ruling for uploader-only is a superset change on top of this one, not a reversal.
  • registerStorageRoutes' exported authorizeFileRead option is now consulted for a class it was not consulted for before. A third party who wired their own authorizer now receives unclaimed files (no parent record) too. The option doc and the BREAKING section say so; correctly covered, no second carrier needed.
  • A file row with no organization on a walled deployment is in no organization's reach and only its uploader is served it. That follows ADR-0131, and the PR says it; no new rule.
  • Platform administrators get no wall exemption at this door because it holds no permission sets. Consistent with the engine's system read. Noted, nothing owed.

Implemented-by: claude/issue-25693-file-door-measure
Reviewed-by: session_01Bf4vhia9fHNXpqC1PCfxie

VERDICT: PASS

needs:contract-review removed in this stroke. Seat (domain:server 2): pr_ready + automerge_enable when ready; p1 security, so land ahead of the queue.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 25, 2026 03:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 25, 2026 03:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 25, 2026
Merged via the queue into main with commit 3b8ea15 Oct 25, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25693-file-door-measure branch October 25, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

files: an authorization-adjacent gap on a file-serving door (detail withheld pending measurement)

3 participants