Skip to content

feat(service-storage): mountStorageRoutes — one host door for kernels with no http-server service - #16741

Merged
os-zhuang merged 7 commits into
mainfrom
claude/issue-15169-storage-routes-public-seam
Sep 8, 2026
Merged

feat(service-storage): mountStorageRoutes — one host door for kernels with no http-server service#16741
os-zhuang merged 7 commits into
mainfrom
claude/issue-15169-storage-routes-public-seam

Conversation

@os-trump

@os-trump os-trump commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #15169

Option A, in the single-entry shape the triage seat preferred (5571654797), ratified by the director seat on #15169 (5579652641); carried verbatim by the dispatch 5577865232: one new export, mountStorageRoutes, rather than the three package-internal gate builders the card named.

The problem, restated from the measurement

StorageServicePlugin mounts /api/v1/storage/* itself, at kernel:ready, on the kernel's http-server service. A hosted per-environment tenant kernel registers no such service, so that branch logs "no HTTP server available": the storage service, sys_file / sys_upload_session / sys_attachment, the lifecycle hooks and the reap guards are all present, and every /api/v1/storage/* request answers 404 — an app with an attachment field cannot upload. Settings already had a working host bridge because registerSettingsRoutes and everything it needs are public. Storage could not be bridged the same way: registerStorageRoutes needs three seams that are, deliberately, package-internal — the upload session resolver, the ADR-0104 D3 download authorizer, and the tombstone holder predicate.

🔴 What this shape exposes to consumers — read this first

buildFileReadAuthorizer is the ADR-0104 D3 download-authorization gate, so the boundary that moves is a security boundary. Stated exhaustively.

Added to the published surface (packages/services/service-storage/src/index.ts):

Symbol Kind What a consumer can do with it
mountStorageRoutes(http, kernel, options?) function Register the storage route table on an HTTP surface it owns, composed from a kernel it hands over.
MountStorageRoutesOptions type Wire knobs only: basePath, presignedTtl, sessionTtl, downloadTtl, logger.
StorageRouteKernel type The registry slice the door reads: getService, optional getServiceAsync, optional getKernel.
StorageRoutesMountReport type The return value: basePath plus four booleans/strings — sessionResolver, downloadAuthorizer, tombstoneHolderResolver, metadataStore.

The first parameter is IHttpServer, the same first parameter registerSettingsRoutes(http: IHttpServer, …) takes — the ruling asked for verbatim alignment with the bridge settings already proved, and this is it. Only the registration half (get / post / put) is called, so the host's own route-collecting shim satisfies it as readily as a real adapter.

NOT added, and unreachable: buildAuthSessionResolver, buildFileReadAuthorizer, findFileHolder, and the two package-internal seams this refactor introduced, composeStorageRoutes and toGateRegistry (with StorageGateRegistry / StorageRoutesComposition).

Three independent facts make that unreachability mechanical, not a convention:

  1. The package's exports map publishes "." alone — no subpath pattern — so no deep import reaches storage-service-plugin.js at all.
  2. The built package does not carry them — measured on the rebuilt bundle by importing it, not by grepping. dist/index.js has 26 runtime exports: mountStorageRoutes PRESENT and registerStorageRoutes PRESENT (two firing positive controls), buildFileReadAuthorizer / buildAuthSessionResolver / findFileHolder / composeStorageRoutes / toGateRegistry all absent. dist/index.d.ts names 62 exports; the four new ones are mountStorageRoutes, MountStorageRoutesOptions, StorageRouteKernel, StorageRoutesMountReport, while StorageGateRegistry and StorageRoutesComposition — the two package-internal types this refactor introduced — are absent from it.
  3. The two gate builders are module-private again. The composition refactor had given them an export keyword; nothing imported them, so this PR drops it (commit "the two gate builders stay module-private"). Measured as import EDGES, not as symbol mentions: an import / export … from clause naming the symbol, multi-line aware — buildFileReadAuthorizer 0, buildAuthSessionResolver 0, against firing positive controls in the same run (findFileHolder 4, mountStorageRoutes 2, StorageServicePlugin 15).

What a consumer cannot do, and why: it cannot supply, replace or omit any of the three gates. MountStorageRoutesOptions names none of registerStorageRoutes' three gate keys (resolveSession, authorizeFileRead, resolveFileHolder), and the door reads named fields off its options — the bag is never passed through — so a widened object smuggling one in is inert. Both directions are pinned: a type-level pin (three @ts-expect-error literals, compiled by tsconfig.test.json) and a runtime pin (a consumer "supplying" an always-allow authorizer, after which the anonymous caller is still 401 and nothing is minted).

What it does control, stated honestly: the host chooses the kernel. The gates are built from that kernel's auth service and data engine, so a host that hands over a kernel with a permissive auth service gets permissive authentication — exactly as the plugin's own mount does on such a kernel. What is not substitutable is the authorization logic: parent-record reachability, the fileAccessDelegate dispatch, the tenancy-posture read and its outage relay all stay inside the package, in one function, reached by one composition.

How the one-definition property is preserved

The point of exporting is that the platform keeps one definition of the download gate — option C (a consumer re-implementing it) is what creates a second, divergent one, and it was refused on the cloud side and ratified in triage.

composeStorageRoutes is now the single place resolveSession / authorizeFileRead / resolveFileHolder are wired, and it has exactly two callers: the plugin's own kernel:ready mount, and mountStorageRoutes. The plugin no longer builds the three itself — the deleted lines are the ones that used to. So the host's storage door and the plugin's are one code path by construction: they cannot drift apart, because there is nothing to drift.

§2 of the new suite pins that as behaviour rather than as structure: the plugin's own kernel:ready mount and the host door register byte-for-byte the same route table, and the host door threads the kernel's async registry into the D3 authorizer's tenancy-posture read exactly as the plugin path does (an unreadable posture store answers the declared 503, never a verdict; the control with a readable posture reaches the 401).

resolveFileHolder keeps its own one-definition property from #10246: it is the reap guard's findFileHolder, handed over rather than re-derived, so the sweep that reaps and the download path that refuses cannot answer differently.

Behaviour parity — nothing existing changes

registerStorageRoutes and StorageRoutesOptions are untouched. The plugin mounts what it mounted before: the same base path, the same TTLs, the same three gates, the same StorageMetadataStore. downloadTtl is threaded through the composition and the plugin passes none — StorageServicePluginOptions has no such field — so it arrives undefined and takes the same ?? 300 default it took when it was not passed at all. The one thing removed is the plugin's unused private store field, which nothing read. The door resolves the data engine the way the plugin's kernel:ready branch does — a sync getService('objectql') inside a try/catch, null on absence — so "no engine" means the same thing on both paths.

The gate builders' parameter changed from PluginContext to a normalised registry slice, produced by toGateRegistry. That is the same three-way reading resolveAdmissionTenancyPosture used to perform inline (getServiceAsync off the value, else off getKernel(), else absent), performed once so both mount paths share it — and it is what lets a host holding a bare kernel build the gates without impersonating a plugin context.

Tests

Run under the container's shared verify lock, four steps, VERDICT command-exit 0: dependency-closure build, package build, vitest run (38 files, 593 tests, all passed), and typecheck (tsc --noEmit, the scripts project, and check:test-typecheck — which is what makes the three @ts-expect-error pins below load-bearing rather than decorative). The new suite on its own, verbose: 13 passed, 0 failed.

New suite mount-storage-routes.test.ts, four sections, every fixture kernel a real ObjectKernel so the registry classification the posture read depends on is the registry's own:

  • §1 the door serves and refuses — controls in both directions before any property pin: a member who can read the parent record gets a signed URL and the adapter minted it; anonymous is 401 AUTH_REQUIRED with nothing minted; an admitted member whose parent record is unreachable is 403 FILE_DOWNLOAD_DENIED, not 401. Same two directions on the upload gate, plus the owner_id stamp.
  • §2 one definition — route-table equality between the plugin mount and the host door; the posture-read thread with its 503 and its 401 control; a getService-only accessor (LiteKernel shape) keeps the posture read quiet and the gate bound.
  • §3 no substitution — the type-level pin and the runtime smuggling pin described above.
  • §4 absence is loud — no storage service throws naming the remedy; no auth and no engine mounts with the gates off, reports them off, warns once, and the report carries no function.

The fixture engine's delete / update / findOne route through the producer's own dispatch predicates (assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate), with the three (file, verb) pairs registered in scripts/engine-double-contract.pinned.json by the gate's own --write — a double looser than ObjectQL is how a dead route ships with a green suite.

Lint — a declared narrowing, not a skipped run

pnpm lint is a whole-repo eslint . --no-inline-config; CI owns that run. What is delivered here is the narrowed one, with the three readings that make a narrowing a measurement rather than an omission:

  1. Population, read from ESLint's own config — 6341 tracked files with a lintable extension, all 6341 considered, 0 ignored, measured by asking ESLint.isPathIgnored about each rather than by guessing from the ignore patterns.
  2. What was linted, counted from --format json — 4 files (the diff's lintable ones; the README, the changeset and the JSON ledger are not ESLint's), 0 errors, 0 warnings, exit 0.
  3. Invariance for the other 6337 — this repo runs one eslint.config.mjs, and that file says of itself that it "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file, test or not", a claim measured there with a positive control. With no cross-file type program, nothing in this diff can move the verdict on a file it does not touch.

Gates

dispatch-gates --changed --commands --repo objectstack-ai/objectstack on the merged tree at ae43bfa, every printed command run and recorded byte-for-byte, then --ran to reconcile: 78 derived families, 73 run, 5 NOT-MEASURED, 0 UNRUN (exit 0). All 73 that were measured exited 0, check:route-envelope among them.

check:route-envelope was RED on 210d2b9, and the earlier ledger on this line did not name it because the derivation at that tree did not contain it. Re-derived from 210d2b9 itself, that set is 66 commands and check:route-envelope appears nowhere in it — not in the runnable list, not in the artifact-roster block, not among the too-wide-to-place families. dispatch-gates reaches that family by two spellings only: its own gate script scripts/check-route-envelope.mjs, and the module paths already declared in the gate's MODULES table, each of which it reads as a gate source. A brand-new *-routes.ts is by construction in neither, so the family stayed silent for the one card that moved it. The earlier "66 derived, 66 run" was therefore an accurate account of a set that structurally could not name this gate — not a mis-reported result — and CI caught it because Lint & Repo Gates runs the family unconditionally. Declaring mount-storage-routes.ts in MODULES is also what closes the derivation gap: at ae43bfa the family is matched via gate source packages/services/service-storage/src/mount-storage-routes.ts, which is why the derived total moves 66 → 78.

Gate verdict on the new head, quoted from the gate's own output: ✓ Route-envelope conformance — 11 route module(s) audited: 8 conformant, 2 ratcheted, 1 exempt (was 7 conformant), with ✓ check-route-envelope self-test passed ahead of it — the check:route-envelope script runs its self-test first.

The five NOT-MEASURED families each exit 3, PREREQUISITE NOT MET, and each says in its own words that it measured nothing: check:dts-closure, check:dual-build-cjs-loads, check:sourcemap-no-sources-content (all three read built output; this worktree has only this package's dependency closure built), check:i18n (the workspace CLI is not built) and check:type-check-debt (--re-measure needs the workspace closure built). Read as NOT MEASURED — not green, not red; CI builds everything and measures them there. They are recorded as NOT-MEASURED … :: reason lines in the run record, which is why the reconciliation above separates 73 from 78 rather than claiming 78 measured.

Also run, because dispatch-gates marks their rosters as living in a directory one of these paths is in and warns that such silence is a fact about a list rather than a clearance: check-changeset-fixed, check-published-list-mirrors, check:authz-resolver, check:console-injection, check:error-code-casing, check:filter-alias-parity — all exit 0. check:published-readme-exports is again the one that answers PREREQUISITE NOT MET (exit 3) for the same whole-repo-build reason. (check:engine-double-contract and check:i18n-stale-fill are in the derived 78 above and exited 0 there.)

The derivation ran with --repo objectstack-ai/objectstack, which it checked against this checkout's origin. It reported STALE TREE on the pre-merge tree, so origin/main was merged (ed7243d5) before deriving; the merge is a plain git merge, no rebase and no force-push.

Changeset

minor on @objectstack/service-storage. Not skip-changeset: that label is for a diff that publishes nothing from a released package, and this one widens a released package's public surface. Not patch: it is a new export, an addition rather than a fix. Not breaking, so no ADR-0087 disposition marker is owed.

Clause-② evidence

Clause-②: yes — the deliverable is by definition a widening of the published surface (four new names in index.ts, table above), which is limb one of the clause. The dispatch recorded it at claim time and hung needs:contract-review on the card; this PR carries the same label, as the dual-carrier rule requires.

验收备注

  • Reviewed and kept both files the previous executor left uncommitted: the engine-double dispatch predicates in the new test's fixture engine, and the three ledger rows the gate's --write produced for them. Both are the gate's sanctioned remedy for a newly pinned double.
  • origin/main was merged into this branch before the gate derivation, because dispatch-gates reported STALE TREE and named two gate scripts that had moved. The list re-derived identical afterwards.
  • noted, not filed: check:published-readme-exports needs every published package built and answers PREREQUISITE NOT MET (exit 3) on a partially built tree. That is a property of the gate's whole-repo prerequisite, not of this diff — CI builds everything and measures it there.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37


Generated by Claude Code


Generated by Claude Code

…dispatch (#15169)

The `mountStorageRoutes` fixture engine answered `delete` / `update` /
`findOne` more loosely than `ObjectQL` does, which is how a dead route
ships with its suite green (#4434 / #5619). Route the three verbs through
the producer's own predicates (`assertEngineDeleteDispatch`,
`assertEngineUpdateDispatch`, `assertEngineFindOnePredicate`) and register
the three (file, verb) pairs in the gate's ledger via its `--write`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
`buildAuthSessionResolver` and `buildFileReadAuthorizer` were given an
`export` keyword by the composition refactor and nothing imports them:
measured as import EDGES (an `import`/`export … from` clause naming the
symbol, multi-line aware), both are 0, against firing positive controls
`findFileHolder` 4, `mountStorageRoutes` 2, `StorageServicePlugin` 15.

`buildFileReadAuthorizer` IS the ADR-0104 D3 download-authorization gate,
so the keyword is not free: dropping it makes "a consumer gets no handle
on the gate" hold at the module level too, not only because `index.ts`
declines to re-export and the package's `exports` map publishes `"."`
alone. `composeStorageRoutes` and `toGateRegistry` keep their exports —
`mount-storage-routes.ts` imports them, and they hand back a report of
booleans, never a gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
@github-actions github-actions Bot added the size/l label Sep 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-storage, touching 26 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-storage/README.md, packages/services/service-storage/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/index.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport))
  • content/docs/api/wire-format.mdx (via /api/v1/storage (route, a path literal in DEFAULT_STORAGE_BASE_PATH; a path literal in MountStorageRoutesOptions), /api/v1/storage/* (route, a path literal on a changed line))
  • content/docs/data-modeling/analytics.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport))
  • content/docs/kernel/architecture.mdx (via getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))
  • content/docs/kernel/cluster.mdx (via getKernel (symbol, a method of interface StorageRouteKernel), getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))
  • content/docs/kernel/runtime-services/audit-service.mdx (via getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))
  • content/docs/kernel/runtime-services/storage-service.mdx (via /api/v1/storage (route, a path literal in DEFAULT_STORAGE_BASE_PATH; a path literal in MountStorageRoutesOptions))
  • content/docs/kernel/services-checklist.mdx (via getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel), getServiceAsync (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel), /api/v1/storage (route, a path literal in DEFAULT_STORAGE_BASE_PATH; a path literal in MountStorageRoutesOptions))
  • content/docs/permissions/sharing-rules.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport))
  • content/docs/plugins/anatomy.mdx (via getKernel (symbol, a method of interface StorageRouteKernel), getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))
  • content/docs/plugins/development.mdx (via getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))
  • content/docs/protocol/kernel/config-resolution.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport))
  • content/docs/protocol/kernel/http-protocol.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport))
  • content/docs/protocol/kernel/index.mdx (via getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))

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

  • content/docs/releases/v15.mdx (via downloadTtl (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition))
  • content/docs/releases/v16.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport))
  • content/docs/releases/v17.mdx (via basePath (symbol, a field of interface MountStorageRoutesOptions; a field of interface StorageRoutesComposition; a field of interface StorageRoutesMountReport), getService (symbol, a method of interface StorageGateRegistry; a method of interface StorageRouteKernel))

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
  • 2 changed file(s) yielded no anchor (packages/services/service-storage/README.md, packages/services/service-storage/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: basePath (9 routes)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 7 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 ed7243d52bbc1b6d00a3b621b0dcea4925df32b5packageMentionDocs.

Which tree this was computed on

This run read content/docs from e45a571e88e040b8ccbfe13c472f8831f4cdf008 — the merge of head ae43bfa29cf06e2ff5faf7b15c553fe822d2a2bb into base ed7243d52bbc1b6d00a3b621b0dcea4925df32b5, 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 e45a571e88e040b8ccbfe13c472f8831f4cdf008 && git checkout e45a571e88e040b8ccbfe13c472f8831f4cdf008
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 ae43bfa29cf06e2ff5faf7b15c553fe822d2a2bb && git checkout -B drift-repro ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 && git merge --no-ff ae43bfa29cf06e2ff5faf7b15c553fe822d2a2bb

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

⚠️ 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 ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16741 @ 210d2b9

Verdict: CHANGES REQUIRED — the contract work is sound and the ADR-0104 D3 boundary is preserved; the PR is blocked by a red CI gate (check:route-envelope) that the PR body reports as green, plus a claim-accuracy finding on the gate reconciliation.

Ruling implemented: partially — there is no maintainer ruling to implement; see §1.

Ruling, quoted verbatim

There is no ## Ruling recorded comment on #15169 and no maintainer ruling of any kind. The issue thread has eight comments: two cloud cross-links (hotlong), a triage grading (os-zhuang, 5570152433, which states in terms "it does not rule between A and B"), a pm:retriage objection (hotlong), the triage answer (os-zhuang, 5571654797), the claim/dispatch (os-trump, 5577865232), a tier correction, and the dev report. The closest thing to a ruling is the triage seat's dispatch boundary in 5571654797, carried verbatim by the dispatch 5577865232:

✅ 交付物 = 选项 A。优先做单一入口形状(接受 raw app + 一个 kernel accessor),逐字对齐 settings 已经跑通的那条桥;把三个函数直接摊到公开面是次选,因为公开面越窄,后面越好收

and, on the security boundary:

⚠️ buildFileReadAuthorizer 是 ADR-0104 D3 的下载授权门。导出它意味着一个安全决定的边界移动了——认领时必须在认领评论里明写你选的形状把什么暴露给了消费者,PR 正文同写。这是复核席要看的第一件事。

So: the shape landed (one mountStorageRoutes export, three builders kept internal) is what triage preferred, not what a maintainer ruled, and not the executor's own invention. The card itself named the three builders as option A's first form and the single entry as a parenthetical alternative; triage inverted that preference; the executor followed triage. The triage grading 5570152433 also flagged that the internal declaration at index.ts:30 is described as deliberate and that "the reviewer is reversing a prior decision and should say why" — the PR does address this: the index.ts comment on findFileHolder is amended to say the consumer that arrived is served by the door, so the predicate's blast radius did not widen. Under Clause-② (published-surface widening) the human floor is the contract review, which is this comment; the merge itself remains maintainer-only.

1. Security boundary (ADR-0104 D3) — verified from the tree at 210d2b9

  • (a) packages/services/service-storage/src/index.ts — read in full. Exports added: mountStorageRoutes (value) and MountStorageRoutesOptions / StorageRouteKernel / StorageRoutesMountReport (types). None of buildFileReadAuthorizer, buildAuthSessionResolver, findFileHolder, composeStorageRoutes, toGateRegistry, StorageGateRegistry, StorageRoutesComposition appear in index.ts. ✅
  • (b) package.json exports publishes "." only (import/require × types/default); no subpath, no wildcard. files is dist, README.md, CHANGELOG.md. ✅
  • (c) MountStorageRoutesOptions (mount-storage-routes.ts) carries exactly basePath, presignedTtl, sessionTtl, downloadTtl, logger. The door builds a fresh literal for composeStorageRoutes reading those five named fields — opts is never spread or passed through. composeStorageRoutes itself constructs resolveSession / authorizeFileRead / resolveFileHolder internally and hands registerStorageRoutes a fresh literal too. A widened bag cannot reach registerStorageRoutes. ✅
  • (d) composeStorageRoutes call sites in the tree (grep over packages/ and scripts/, comments excluded): storage-service-plugin.ts:441 (plugin kernel:ready) and mount-storage-routes.ts:130 (the door). Exactly two. ✅ toGateRegistry likewise two. The two gate builders are declared function buildAuthSessionResolver( / function buildFileReadAuthorizer( with no export keyword (commit 5eb7f3e32); import edges for findFileHolder are all intra-package (storage-service-plugin.ts, stranded-orphan-inventory.ts, two tests). ✅
  • Caveat: composeStorageRoutes, toGateRegistry, StorageRouteKernel, StorageGateRegistry, StorageRoutesComposition, StorageRoutesMountReport are exported at module level from storage-service-plugin.ts. They are unreachable through the published surface because of (a)+(b), and mount-storage-routes.ts needs two of them, so this is acceptable — noted so nobody later reads the module-level export as "published".
  • ADR-0104 D3 (docs/adr/0104-field-runtime-value-shape-contract.md §"D3 — File-as-reference"): its Authorization bullet requires that "field-referenced files get parent-derived read checks, reusing the attachments authorizeFileRead verdict model — possession of a URL stops being possession of the bytes." The door binds that same authorizeFileRead from the kernel the host hands over, through the same composition the plugin uses; a consumer can neither omit nor replace it. D3 is preserved, not weakened. What the host does control is which kernel it hands over (and therefore which auth service and engine the gate reads) — the PR body states this honestly and it is the same trust the plugin's own mount already places in the kernel.
  • Behavioural note on the PluginContextStorageGateRegistry refactor: PluginContext (packages/core/src/types.ts) declares getService and getServiceScoped but no getServiceAsync, so toGateRegistry(ctx) falls through to ctx.getKernel?.(), i.e. the exact three-way read resolveAdmissionTenancyPosture performed inline on main. No posture-read regression on the plugin path. ✅

2. Behaviour parity

  • storage-routes.ts is not in the diff; registerStorageRoutes / StorageRoutesOptions unchanged. downloadTtl default is still opts.downloadTtl ?? 300 at storage-routes.ts:164. ✅
  • Plugin mount: previously registerStorageRoutes(httpServer, this.storage, new StorageMetadataStore(engine), { basePath: this.options.basePath ?? '/api/v1/storage', presignedTtl, sessionTtl, resolveSession: buildAuthSessionResolver(ctx), authorizeFileRead: buildFileReadAuthorizer(ctx, engine), resolveFileHolder: <findFileHolder when engine.find>, logger: ctx.logger }). Now composeStorageRoutes(httpServer, toGateRegistry(ctx), { storage, engine, basePath: this.options.basePath ?? '/api/v1/storage', presignedTtl, sessionTtl, logger: ctx.logger }) and the composition rebuilds the identical store/gates/resolveFileHolder guard. StorageServicePluginOptions has no downloadTtl, so the plugin path passes undefined?? 300. Same base path, TTLs, gates. ✅
  • Removed: the plugin's private store field, assigned at kernel:ready and read nowhere else on main. Dead state. ✅

3. Files in the diff (7) and governed paths

.changeset/storage-routes-host-mount.md (+13) · packages/services/service-storage/README.md (+28) · src/index.ts (+17) · src/mount-storage-routes.ts (+161, new) · src/mount-storage-routes.test.ts (+495, new) · src/storage-service-plugin.ts (+216/−30) · scripts/engine-double-contract.pinned.json (+15).

Governed paths (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**): NO — none touched.

engine-double-contract.pinned.json: exactly three rows added, all file: packages/services/service-storage/src/mount-storage-routes.test.ts, verbs delete / findOne / update, pinned: 1 each — matching the three assertEngine* predicates the fixture engine calls. Nothing else in that file changed. ✅

4. Changeset

"@objectstack/service-storage": minor — correct package name (matches package.json name), correct bump for an additive public export. Body states the before (404 on hosted kernels, three internal seams) and the after (one door, gates not accepted in any form, nothing existing changes). No breaking marker owed. ✅

5. Tests (mount-storage-routes.test.ts, 13 cases)

  • 401 / 403 controls: §1 has anonymous → 401 AUTH_REQUIRED with minted() === 0, admitted-but-unreachable parent → 403 FILE_DOWNLOAD_DENIED with minted() === 0, and the positive control (readable parent → 200 + adapter minted). Upload gate both directions with owner_id stamp. ✅
  • Route-table equality: §2 mounts StorageServicePlugin on a fake context with an http-server collector, runs the kernel:ready hooks, and asserts the sorted route keys equal the host door's. ✅ (Note: the plugin arm uses a fake PluginContext, not a real ObjectKernel; the door arm is a real kernel. Table equality is still meaningful, but the gate-behaviour equality on the plugin path is inferred from the shared composition, not asserted end-to-end.)
  • @ts-expect-error pins: tsconfig.test.json includes src/**/*, and check:test-typecheck (in typecheck) runs --project tsconfig.test.json. CI's "TypeScript Type Check" / "Type Check · source gates" are green on head, so the three pins compile and would fail the day a seam is admitted. ✅
  • Runtime smuggling pin: §3 casts a bag with all three seams to MountStorageRoutesOptions and asserts anonymous download and upload still 401. ✅
  • Would removing the door's gate wiring redden a test? Yes: dropping authorizeFileRead from the composition turns the §1 anonymous-download 401 and the §2 LiteKernel 401 into 200s; passing opts through to registerStorageRoutes makes the §3 runtime pin's smuggled always-allow authorizer win. Both arms are load-bearing.

6. CI on head 210d2b94e (40 check runs)

32 success · 1 failure · 0 in_progress · 7 skipped. Red: Lint & Repo Gates (run 34182167181). Failing step is pnpm check:route-envelope:

✗ Route-envelope conformance (#3843)
  packages/services/service-storage/src/mount-storage-routes.ts
    NOT DECLARED. Add it to MODULES in scripts/check-route-envelope.mjs.

The gate discovers every *-routes.ts under packages/ and treats an undeclared module as an error, never a default. mount-storage-routes.ts matches the convention and is not in MODULES. scripts/check-route-envelope.mjs is byte-identical between the PR head and origin/main, so this is not main-drift; it is a property of this diff.

Branch vs origin/main: main is 5 commits ahead of the merge (8ccf7a1df, d61bad03f, 7c12e475e, 1ea349f0e, 1b25cedae); none touches packages/services/service-storage, scripts/engine-double-contract.pinned.json or check-route-envelope.mjs. Not behind in a way that matters. mergeable_state: blocked, draft.

Findings

F1 — check:route-envelope red on head (blocking). mount-storage-routes.ts is discovered by the *-routes.ts convention and undeclared. Expectation: add it to MODULES in scripts/check-route-envelope.mjs — it writes no response itself (all bodies come from storage-routes.ts via registerStorageRoutes), so { responses: 0, ok: 0, err: 0 } is the accurate row; then confirm the Lint & Repo Gates job is green on the new head. Do not rename the file to dodge the gate.

F2 — PR-body gate claim does not match CI. The body says "66 derived families, 66 run, 0 NOT-MEASURED … 62 exited 0 outright" with only dts-closure (re-run green) and three whole-workspace gates as exceptions; check:route-envelope is neither named as red nor as not-measured, yet it fails on the identical script in CI. Either the derivation did not include it or it was run and its result mis-reported. Expectation: when re-running after F1, state which of the two it was and re-derive --commands / --ran so the ledger in the PR body is reconcilable with CI.

F3 — No maintainer ruling exists; shape is triage-preferred (advisory). The PR body's "the triage seat ruled preferable" overstates 5571654797, which is a dispatch preference, not a ## Ruling recorded. Expectation: no change to code; the merging maintainer should read this PR as the Clause-② surface-widening decision itself and either ratify the single-entry shape at merge or ask for the three-builder form. Nothing in the tree prevents the latter being added later without breaking the door.

F4 — Route-table equality is asserted across a fake PluginContext and a real kernel (advisory). The §2 pin proves both paths register the same nine paths; gate parity on the plugin path is by construction of the shared composition rather than by an end-to-end assertion on a real ObjectKernel plugin mount. Acceptable for this PR given (d) above; a follow-up could mount the plugin on a real kernel with an http-server collector and assert the same 401/403 arms through the plugin's door.

F5 — Module-level export on package-internal seams (informational). composeStorageRoutes, toGateRegistry, StorageGateRegistry, StorageRoutesComposition are exported from storage-service-plugin.ts for mount-storage-routes.ts to import; unreachable through ".". No change requested; recorded so the "not exported" claim is read as "not on the published surface", which is the claim that matters.


Generated by Claude Code

`mount-storage-routes.ts` matches the `*-routes.ts` discovery convention, so
`check:route-envelope` found it and refused it as NOT DECLARED — undeclared is
an error, never a default. It writes no response body of its own: it binds the
three package-internal seams and hands the surface to `registerStorageRoutes`,
so every `/api/v1/storage/*` body is still written by `storage-routes.ts`
through the shared sendOk/sendError pair. Declared `{ responses: 0, ok: 0,
err: 0 }` with a note in the neighbours' shape. No ratchet, no vendorWire, no
other entry touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
@github-actions github-actions Bot added the size/l label Sep 8, 2026
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Patch round — F1/F2 discharged (director seat)

Head is now ae43bfa29cf06e2ff5faf7b15c553fe822d2a2bb. Two commits since 210d2b9: the gate declaration, then a plain git merge origin/main (ed7243d5) taken because dispatch-gates reported STALE TREE and named the very files it derives from. No rebase, no amend, no force-push.

F1 — the change. packages/services/service-storage/src/mount-storage-routes.ts is now declared in MODULES of scripts/check-route-envelope.mjs as { responses: 0, ok: 0, err: 0 }, with a note in the neighbours' shape. No ratchet, no vendorWire, no other entry touched; the diff is 12 added lines in that one file and nothing else. The zero is structural rather than measured-and-hoped: the door has no .json( write site at all — it binds the three package-internal seams and hands the surface to registerStorageRoutes via composeStorageRoutes, so every /api/v1/storage/* body is still written by storage-routes.ts through the shared pair. A write site appearing here later would mean the door started building bodies of its own, which is exactly the review the number exists to force. The file was not renamed.

check:route-envelope verdict on ae43bfa — exit 0, quoted from the gate's own output (the pnpm script runs the self-test first):

✓ check-route-envelope self-test passed
✓ Route-envelope conformance — 11 route module(s) audited: 8 conformant, 2 ratcheted, 1 exempt
  all bodies written by packages/types/src/response-envelope.ts (2 write sites, pinned)

It was 7 conformant on 210d2b9, where the same command exited 1 with NOT DECLARED.

F2 — reconciled gate line. dispatch-gates --changed --commands --repo objectstack-ai/objectstack, every printed command run and recorded byte-for-byte, then --ran: 78 derived families, 73 run, 5 NOT-MEASURED, 0 UNRUN (exit 0). All 73 measured families exited 0. The 5 are check:dts-closure, check:dual-build-cjs-loads, check:sourcemap-no-sources-content, check:i18n and check:type-check-debt — each exits 3, PREREQUISITE NOT MET, each says in its own words that it measured nothing, and each is recorded as a NOT-MEASURED … :: reason line rather than counted green.

Which of the two it was: the derivation did not include it. Re-derived from 210d2b9 itself, that set is 66 commands and check:route-envelope appears nowhere in it — not in the runnable list, not in the artifact-roster block, not among the too-wide-to-place families. dispatch-gates reaches that family by two spellings only: its own gate script scripts/check-route-envelope.mjs, and the module paths already declared in the gate's MODULES table, each read as a gate source. A brand-new *-routes.ts is in neither, so the family stayed silent for the one card that moved it. The earlier "66 derived, 66 run" was an accurate account of a set that structurally could not name this gate — not a mis-reported result. Declaring the module is also what closes the derivation gap: at ae43bfa the family is matched via gate source packages/services/service-storage/src/mount-storage-routes.ts, which is why the total moves 66 → 78.

Also run, because dispatch-gates warns that their roster-based silence is a fact about a list and not a clearance: check-changeset-fixed, check-published-list-mirrors, check:authz-resolver, check:console-injection, check:error-code-casing, check:filter-alias-parity — all exit 0. check:published-readme-exports again answers PREREQUISITE NOT MET (exit 3) on a partially built tree.

The "## Gates" section of the PR body now carries this reading, and the shape sentence is corrected to name the triage seat's preference (5571654797) and the director seat's ratification on #15169 (5579652641). Nothing else in the body was changed.

Not done, and why: F3/F4/F5 are advisory and were not in this dispatch's scope; the PR stays draft, unlabelled and un-merged. Recorded for the director seat rather than filed, since filing would widen this round: the derivation's blind spot above is general — dispatch-gates can only name check:route-envelope for modules the gate already declares, so the exact case the gate exists to catch, a NEW route module, is invisible to every dispatch brief until someone declares it. The remedy the tool itself names is to declare the scan surface (the subtree spelling) beside the MODULES roster.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16741 @ ae43bfa — re-review

Verdict: PASS WITH FINDINGS — F1 and F2 from the 210d2b9 review are discharged on this head and verified independently from the tree; F3 is discharged by the director seat's ruling on #15169; CI on ae43bfa29 is fully green including Lint & Repo Gates, the job that runs check:route-envelope. Nothing in the residual findings blocks landing. The six security-relevant files are byte-identical to the head the prior review verified, so the ADR-0104 D3 conclusions carry forward unchanged and are re-checked below.

Ruling implemented: yes.

Ruling, quoted verbatim (#15169, 5579652641, director seat, decision batch #84)

Ruled: ratified. Grounds, verified by the review from the tree: ADR-0104 D3 is preserved — buildFileReadAuthorizer, buildAuthSessionResolver, findFileHolder, composeStorageRoutes, toGateRegistry are not exported, the package exports map is "." only, MountStorageRoutesOptions carries none of the three gate keys and the door reads named fields, composeStorageRoutes has exactly two callers (plugin kernel:ready mount and the host door) so the platform keeps one definition of the download gate. The three-builder shape the card named would have put the D3 gate itself on the published surface; the single-entry shape widens by one function and keeps the gate inside. Clause-② surface widening of @objectstack/service-storage at minor is accepted.

Execution: F1/F2 (undeclared mount-storage-routes.ts in check-route-envelope.mjs MODULES; gate ledger re-derived) are in a patch round now; then re-review at tier and the director seat lands it (non-governed).

Every fact the ruling rests on is re-verified on ae43bfa in §3 below.

Prior findings — discharge table

# Finding at 210d2b9 Status @ ae43bfa Evidence
F1 check:route-envelope red — mount-storage-routes.ts undeclared in MODULES Discharged a69736671 adds the entry; CI step Response-envelope guard on head: ✓ Route-envelope conformance — 11 route module(s) audited: 8 conformant, 2 ratcheted, 1 exempt (§2, §5)
F2 PR-body gate ledger unreconcilable with CI Discharged ## Gates now states the family was RED on 210d2b9, explains why the 66-set structurally could not name it, and reports 78 derived / 73 run / 5 NOT-MEASURED exit-3 (§4)
F3 No maintainer ruling; shape was triage-preferred Discharged 5579652641 ratifies the single-entry shape; PR body's shape sentence now cites 5571654797 + 5579652641 (§4)
F4 Route-table equality asserted across a fake PluginContext and a real kernel Carried, advisory Test file byte-identical to 210d2b9; unchanged, not owed by this card
F5 Module-level export on package-internal seams Carried, informational storage-service-plugin.ts byte-identical; unreachable via "." (§3)

1. What moved between 210d2b9 and ae43bfa29

git diff $(git merge-base origin/main ae43bfa29)..ae43bfa29 — merge-base is ed7243d52 (= current origin/main base), 8 files: .changeset/storage-routes-host-mount.md · packages/services/service-storage/README.md · src/index.ts · src/mount-storage-routes.ts · src/mount-storage-routes.test.ts · src/storage-service-plugin.ts · scripts/check-route-envelope.mjs · scripts/engine-double-contract.pinned.json.

  • Per-file git diff --quiet 210d2b9 ae43bfa29 -- <f>: the changeset, README, index.ts, mount-storage-routes.ts, mount-storage-routes.test.ts and storage-service-plugin.ts are byte-identical to 210d2b9. ✅
  • scripts/check-route-envelope.mjs moved: git diff --stat 210d2b9 a69736671 = that one file, +12 / −0, nothing else — matching the patch comment's claim exactly. ✅
  • scripts/engine-double-contract.pinned.json reads as "moved" between the two heads, but that is main-drift, not a PR change: the PR-side hunk (git diff <old-base> 210d2b9 -- <file> vs git diff ed7243d52 ae43bfa29 -- <file>) is line-for-line identical (the same three mount-storage-routes.test.ts rows, delete / findOne / update, pinned: 1); main itself added 15 lines to that ledger between the two bases. ✅
  • The merge commit ae43bfa29 has parents a69736671 + ed7243d52; git merge-tree --write-tree a69736671 ed7243d52 re-derives tree 1155fffec…, equal to ae43bfa29^{tree} — a clean, content-neutral merge carrying nothing of its own. ✅
  • Governed paths (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**): absent. ✅

2. The MODULES entry

scripts/check-route-envelope.mjs:267'packages/services/service-storage/src/mount-storage-routes.ts': { responses: 0, ok: 0, err: 0 }, preceded by a [#15169] note. Shape matches the neighbours exactly (share-link-routes.ts:255, storage-routes.ts:242, settings-routes.ts:243 all declare the same three-key zero literal); no ratchet, no vendorWire, no other MODULES row touched (the diff against origin/main is the 12-line insertion only). ✅

The zero is measured the way the gate measures it: the gate's counter (countResponseWrites, ~line 1549) counts <receiver>.json( call sites excluding req.json() request reads. grep -c '\.json(' on mount-storage-routes.ts = 0; broadened to .json( | .send( | .status( | sendOk | sendErr | ok( | err( on non-comment lines = 0. The door's only side effect on the HTTP surface is composeStorageRoutes(http, toGateRegistry(kernel), {...}) at line 130, which hands the surface to registerStorageRoutes; storage-routes.ts is declared at MODULES:242 with the same zero literal, its bodies going through the shared packages/types/src/response-envelope.ts pair (the gate's own line: "all bodies written by packages/types/src/response-envelope.ts (2 write sites, pinned)"). So the entry is structurally true, not measured-and-hoped. ✅

3. ADR-0104 D3 boundary — re-verified on ae43bfa

  • src/index.ts read in full: the only new exports are mountStorageRoutes (value) and MountStorageRoutesOptions / StorageRouteKernel / StorageRoutesMountReport (types). Grep over non-comment lines for buildFileReadAuthorizer | buildAuthSessionResolver | findFileHolder | composeStorageRoutes | toGateRegistry: none (the names appear only in the explanatory comments). ✅
  • package.json exports: "." only — import/require × types/default, no subpath, no wildcard. ✅
  • MountStorageRoutesOptions (mount-storage-routes.ts) declares exactly basePath, presignedTtl, sessionTtl, downloadTtl, logger. Non-comment occurrences of resolveSession | authorizeFileRead | resolveFileHolder in that file: 0. ✅
  • composeStorageRoutes( call sites on head (git grep over packages/ + scripts/, declaration excluded): mount-storage-routes.ts:130 and storage-service-plugin.ts:441exactly two, as the ruling states. ✅

4. PR body ## Gates and the shape sentence

5. CI on ae43bfa29

get_check_runs: 36 check runs — 33 success · 0 failure · 0 in_progress · 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in) — all path/opt-in skips, none of them the envelope gate). mergeable_state: clean; still draft, still carrying needs:contract-review.

Lint & Repo Gates (job 101946849166): success, completed 05:46:03Z. Because #16754 (now in the merge) scopes some families of that job by path, I read the job log rather than trusting the job colour: the Run pnpm check:route-envelope step executed at 05:42:44Z (node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs) and printed ✓ check-route-envelope self-test passed then ✓ Route-envelope conformance — 11 route module(s) audited: 8 conformant, 2 ratcheted, 1 exempt. The step in lint.yml:2656 carries no if: path condition. The verdict is not conditional.

6. The recorded derivation blind spot — does it deserve a card?

The patch agent recorded (not filed) that dispatch-gates cannot name check:route-envelope for a NEW *-routes.ts module until that module is declared in MODULES, i.e. the exact case the gate exists to catch is invisible to every dispatch brief, and proposed "declare the scan surface (the subtree spelling) beside the MODULES roster".

Checked against the tree: this is already ledgered, and the proposed remedy was already refused. scripts/pm/bare-root-worklist.mjs carries CENSUS_REFUSE_WIDE row 'check:route-envelope packages' — verdict REFUSE-WIDE, 2181 of 5837 tracked packages/ files opened (37.4%) at 2aa8456cf, lineage #14131 / #14325 / #14692 / #14695 — with the reasoning that the gate's walk(join(ROOT, 'packages')) population is the root, so a subtree declaration would be TRUE but would name this gate on every card touching a package. So the general finding is known and the subtree spelling is a closed door.

My reading: a card is warranted, but only in a narrower form than the one the patch agent named. What actually triggers this gate's error path is not "any file under packages/" but "a file matching *-routes.ts under packages/ that is not in MODULES" — a pattern-shaped hint, not a subtree. If dispatch-gates can express a pattern hint (or grow one), the width objection in the REFUSE-WIDE row does not apply to it, and the derivation would name the family for exactly the cards that create a new route module. That is a follow-up to #14695's lineage, not a rider on this PR, and not filed here.

Residual findings

R1 — F4 carried (advisory, unchanged). Route-table equality is still asserted across a fake PluginContext and a real kernel; gate parity on the plugin path rests on the shared composition (two call sites, verified). Expectation: none for this PR; a follow-up could mount the plugin on a real kernel with an http-server collector and drive the same 401/403 arms through the plugin's door.

R2 — F5 carried (informational, unchanged). composeStorageRoutes, toGateRegistry, StorageGateRegistry, StorageRoutesComposition remain module-level exports of storage-service-plugin.ts, unreachable through ".". Expectation: none; recorded so "not exported" continues to be read as "not on the published surface".

R3 — Derivation blind spot for new *-routes.ts modules (advisory, not filed). See §6. Expectation: a narrowly scoped card under the #14695 lineage proposing a pattern-shaped hint; no change to this PR.

R4 — PR body ## Tests paragraph reports the pre-merge local run (informational). The 38-file / 593-test figure was measured before ed7243d52 was merged in. Because the six package files are byte-identical to that head and CI's Test Core (1–6) and Type Check · jobs are green on ae43bfa, the claim is covered, not stale in substance. Expectation: none.

What still blocks landing

Nothing from this seat. The PR is draft and carries needs:contract-review; per the ruling, the director seat lands it (non-governed). This seat approves, merges, readies, labels or edits nothing.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 8, 2026 05:55
@os-zhuang
os-zhuang enabled auto-merge September 8, 2026 05:55
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit dd2184a Sep 8, 2026
44 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15169-storage-routes-public-seam branch September 8, 2026 06:16
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 tests tooling

Projects

None yet

3 participants