Skip to content

feat(service-storage): confine the S3 adapter to a required key namespace (Part of #17571) - #17599

Merged
hotlong merged 4 commits into
mainfrom
claude/issue-17571-s3-adapter-key-prefix
Sep 11, 2026
Merged

hotlong merged 4 commits into
mainfrom
claude/issue-17571-s3-adapter-key-prefix

Conversation

@hotlong

@hotlong hotlong commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Part of #17571

Clause-②: yes

What this is

S3StorageAdapterOptions gains a required keyPrefix: string | null — the key namespace an adapter instance is confined to, or null for bucket-root keys — applied at every door and stripped on every way back, so a caller holding the adapter has no door through which it can reach an unprefixed key.

The ruled shape (cloud#1969 option B, quoted on the card by the seat that holds the ruling) is structural isolation:

③ 防 AI 犯错:B 的隔离是结构性的——前缀由内核工厂注入,AI 和租户代码都碰不到它,写错了也只能写进自己的前缀。

Everything below is this repo's call, which the ruling explicitly left open on three of the four points.

Premise re-verified

Measured on today's origin/main = 29d00cc539c2822be79d4d728779312094b35525 (the branch point), not on the abc4b83ce the domain:services seat measured:

  • S3StorageAdapterOptions (packages/services/service-storage/src/s3-storage-adapter.ts:36-51 at that sha) carried no key-prefix option of any spelling — a case-insensitive prefix|namespace|root|scope grep over those lines returned nothing, with a positive control (the same word matches 4× elsewhere in the file, so both the grep and the file were live).
  • The adapter passed the caller's key straight through at every door: Key: key at :169 :183 :193 :203 :217 :344 :370 :389 :413 :434 :452 and Prefix: prefix at :283.
  • buildKey(scope, fileId, filename) (storage-routes.ts:962) composes ${scope}/${fileId}${ext} with scope defaulting to 'user' — no environment dimension.

The premise holds. The card's own line references were still byte-accurate at the branch point.

The shape chosen, and why — this is the part to scrutinise

Name: keyPrefix. Not because the card suggested it (it carries no authority and says so), but because it is what this repo's conventions produce. Prime Directive #3 puts TS config keys in camelCase; the word has to distinguish the thing from two neighbours that already exist in this package — basePath, which StorageTargetInput documents as "URL prefix the adapter signs against — not a storage location", and list(prefix, …)'s own caller-facing argument. A bare prefix would collide with both. keyPrefix names the S3 key namespace and matches the Key / Prefix vocabulary the file already speaks.

Enforcement: a required key whose type is string | null. The ruling allows a required option, a refusing constructor, or a non-empty default a host cannot silently inherit. This is the first, with the opt-out made explicit:

  • Omission is a tsc error at the call site. That is this repo's own recorded remedy for a contract change on adapter code — from the list(prefix) retirement: "a storage adapter is CODE, never stack metadata … The enforced channel is tsc, and it reports at the call site." Measured here, on the two in-repo call sites: error TS2345 … Property 'keyPrefix' is missing in type '{ bucket: string; region: string; }' but required in type 'S3StorageAdapterOptions'.
  • | null rather than ''. A single-tenant host must not be forced to invent a namespace and migrate its bucket — that pressure is exactly what would later reopen the option to an empty value, which is the gap. Writing keyPrefix: null makes opting out a deliberate, greppable act; a fleet can audit unprefixed buckets with one grep.
  • '' and whitespace-only are refused at construction, loudly. That string is what an unset environment variable looks like after interpolation, and quietly reading it as bucket-root is the silent-degradation shape the ruling rejects. A leading / and any .. segment are refused too.
  • A missing trailing / is appended, and this is load-bearing. S3 Prefix is a raw string match, so tenant_1 also matches tenant_10/…: without the normalisation, one namespace would enumerate its neighbour through the isolation mechanism itself. Pinned by a case, not a comment.

Keys are concatenated, never path-joined, so a caller key of ../elsewhere stays a literal key inside the namespace instead of escaping it. Pinned.

Two seams that had to move, or the option would have been a lie

Neither is in the card. Both are the same defect class as the card, inside the same package, and leaving either would have shipped an option that a deployment can lose without noticing — PD #10, never advertise a capability the runtime does not deliver.

  1. StorageServicePlugin.buildAdapterFromValues rebuilds the adapter from the storage settings namespace, constructing a fresh S3StorageAdapterOptions from values and ignoring this.options.s3 entirely. A constructor-only prefix would therefore be dropped by the first settings save, returning a hosted deployment to a shared, unprefixed key space. The host's namespace is now carried onto every rebuilt adapter — and deliberately not read out of values: a boundary an administrator inside the deployment can set or clear is a preference, not a boundary. There is correspondingly no s3_key_prefix key in the storage settings manifest, and a test pins that one appearing there later still changes nothing.
  2. resolveStorageTarget decides whether a swap strands existing bytes. Two prefixes in one bucket are two disjoint object sets, so the namespace belongs in location, not merely in the fingerprint — otherwise a prefix change swaps the adapter with no migration warning at all. env_7 and env_7/ normalise through the adapter's own function so the same namespace spelled two ways is not read as a move (the StorageServicePlugin warns "storage adapter swapped (LocalStorageAdapter → LocalStorageAdapter) … files may be unreachable" on every clean boot #4096 shape, one field along).

LocalStorageAdapterOptions — deliberately unchanged

The ruling left this to this repo, noting rootDir may already do the job. It does, structurally: LocalStorageAdapter.resolvePath() refuses any key containing .. and join(rootDir, key)s everything, and every door goes through it. That is a containment boundary of the same kind, already enforced. A second mechanism would be two ways to say one thing.

Verification

Declared narrowing — verification ran UNLOCKED. scripts/pm/os-verify-lock.sh
could not take the shared verify lock on this host: no usable flock. The shared
verify lock is declared Linux-only (flock is util-linux, and a stock macOS does
not ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.

NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/service-storage test
what reading
pnpm --filter @objectstack/service-storage test Test Files 40 passed (40) · Tests 627 passed (627)
pnpm --filter @objectstack/service-storage typecheck exit 0 — check:test-typecheck: OK … 0 file(s) / 0 error(s)
pnpm --filter @objectstack/service-storage build exit 0 — check-dts-emitted: 2/2 declared declaration file(s) present
pnpm --filter '@objectstack/service-storage^...' build exit 0 (dependency closure, run before believing any typecheck)
pnpm --filter '...@objectstack/service-storage' typecheck exit 0 — downstream consumers, Scope: 9 of 81 workspace projects. Honest reading: no in-repo consumer constructs the S3 adapter or passes s3: options, so this green is a statement that nothing broke, not evidence the narrowing bites downstream. What proves the narrowing shipped is the rebuilt dist/index.d.ts, which carries `keyPrefix: string
node scripts/pm/dispatch-gates.mjs --commands 62 derived families, 62 run, 0 NOT-MEASURED, 0 UNRUN (reconciled with --ran, exit codes recorded). Three needed a repo-wide build first (check:dual-build-cjs-loads, check:i18n, check:type-check-debt — all exit 3 PREREQUISITE NOT MET, not failures); the closure was built and all three then exited 0.
node scripts/check-adr-0087-registration.mjs --base origin/main exit 0 — not-required (runtime-interface-only) -- verified: …#S3StorageAdapterOptions (interface)
pnpm lint (whole repo, eslint . --no-inline-config) exit 0. Run in full, so there is no narrowing to declare on this one.

Ablation — these pins can fail

Run from the committed state, each leg proving the mutation reached disk before the run and proving the restore by blob hash:

leg mutation result
1 Prefix: this.storageKey(prefix)Prefix: prefix 8 failed / 55 passed (2 files red)
2 const key = this.callerKey(bucketKey)const key = bucketKey 9 failed / 54 passed (2 files red)
control restored tree 63 passed, exit 0

On-disk proof per leg: deleted-text count 1 → 0 and injected-text count 0 → 1, anchored on the exact text. Restore proof per leg: git diff HEAD empty and git hash-object back to ab068bf93da82d65c5185042428b5b58e6075f72, the file's HEAD blob. The ablation script carried trap … EXIT INT TERM with absolute paths; no ablation artefact is left in the tree.

Landing

  • Part of, not Fixes. The implementation half is complete and the card's acceptance criterion is driven end to end, but two things outlive this PR and both live on the card: the Clause-② contract review owed before it may be queued, and the downstream obligation the domain:services seat recorded — when this lands, the consuming card is filed in objectstack-ai/cloud by the cloud epic seat, which accepted that in writing. Leaving feat(service-storage): S3 适配器没有 key 前缀选项 —— 多租户共享 bucket 时对象存储层没有第二道隔离防线(cloud#1969 已裁形状的落点) #17571 open keeps both visible.
  • Nothing the consumer must do is implicit. For the cloud seat's card: the kernel factory passes keyPrefix in the plugin's s3 options (new StorageServicePlugin({ adapter: 's3', s3: { bucket, region, keyPrefix: '<environment id>' } })). It must be non-empty — an interpolated empty env var throws at construction rather than writing to the bucket root — and it must not be plumbed through the storage settings namespace, which would hand the boundary to the tenant. Existing objects are not migrated into the new namespace; changing the prefix on a live environment strands what the old one held, and the swap says so.
  • No governed surface in the diff (docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md).

Acceptance notes

  • noted, not filed: packages/services/service-storage/src/s3-storage-adapter.ts carries an uploadChunk docblock that narrates a WeakMap design the code does not implement (it is a plain Map, _uploadKeys) and reads as in-progress reasoning rather than description. It is stale prose, not a defect, a contract violation, or a metadata-authoring trap, so it is out of the three filing classes. Successor: the next PR touching the multipart doors in this file — there is no other one queued.

Generated by Claude Code

hotlong and others added 4 commits September 11, 2026 09:39
…ace (wip)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…irections

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx (via storage.upload (sdk, the route ledger binds it to POST /api/v1/storage/upload/complete))
  • content/docs/api/plugin-endpoints.mdx (via initChunkedUpload (sdk, the bare tail of client method storage.initChunkedUpload, bound to POST /api/v1/storage/upload/chunked), storage.initChunkedUpload (sdk, the route ledger binds it to POST /api/v1/storage/upload/chunked), storage.upload (sdk, the route ledger binds it to POST /api/v1/storage/upload/complete), storage.uploadPart (sdk, the route ledger binds it to PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex), uploadPart (sdk, the bare tail of client method storage.uploadPart, bound to PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex))
  • content/docs/kernel/contracts/storage-service.mdx (via abortChunkedUpload (symbol, a method of class S3StorageAdapter), completeChunkedUpload (symbol, a method of class S3StorageAdapter), getInfo (symbol, a method of class S3StorageAdapter), getPresignedDownload (symbol, a method of class S3StorageAdapter), getPresignedUpload (symbol, a method of class S3StorageAdapter), initiateChunkedUpload (symbol, a method of class S3StorageAdapter), uploadChunk (symbol, a method of class S3StorageAdapter))
  • content/docs/kernel/runtime-services/storage-service.mdx (via getInfo (symbol, a method of class S3StorageAdapter), initChunkedUpload (sdk, the bare tail of client method storage.initChunkedUpload, bound to POST /api/v1/storage/upload/chunked), storage.upload (sdk, the route ledger binds it to POST /api/v1/storage/upload/complete))

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

  • content/docs/releases/v16.mdx (via keyPrefix (symbol, a field of class S3StorageAdapter; a field of interface S3StorageAdapterOptions; a field of interface StorageTargetInput))

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
  • 1 changed file(s) yielded no anchor (packages/services/service-storage/README.md) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 b64ea610313df2c3042d0c3523f5e16c007cf252packageMentionDocs.

Which tree this was computed on

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

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

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

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.

1 participant