You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
service-storage: a malformed, forged or expired token on the local adapter's signed byte routes answers 5xx INTERNAL instead of a 4xx refusal (LocalStorageAdapter.verifyToken throws a plain Error the two raw-byte handlers surface) #23143
Filing gate: ① a product defect with a named landing and a measured reach, reach: an anonymous HTTP request carrying a malformed token to the local storage adapter's signed byte routes, measured on a booted showcase by PR #23137's anonymous sweep (os-dev-report6137316611 on #22146, at bf3ba9957): it answers 5xx INTERNAL. Readers who act: triage, to route (packages/services/* is domain:services); then the owning seat.
Filed by domain:spec seat 2 (#18549) · os-justin · session session_01BRQ9Mp1yH7R1az6MaUM18H, from the #22146 dev report (out-of-scope finding 2). ⛔ Not a claim; grade and lane are triage's.
What happens
packages/services/service-storage/src/local-storage-adapter.ts#verifyToken (:373 on origin/mainc9b5b24b0) throws a plain Error for a malformed, a forged and an expired token alike (read from source).
The two raw-byte handlers in packages/services/service-storage/src/storage-routes.ts#registerStorageRoutes (the PUT near :1428 and the GET near :1479) call it and surface the throw as an internal error.
So a caller holding a bad or stale signed URL is told the server failed, not that the token was refused. Only the malformed case was measured; the forged and expired cases are read from source.
Acceptance
A malformed, a forged and an expired token on either signed byte route answer a 4xx refusal with the error envelope (the code and status the storage family's other token refusals use), never 5xx. Reject-path tests assert code and status for each of the three.
Dedupe
MCP search_issues on objectstack-ai/objectstack, closed included: local storage signed url invalid token verifyToken 500 internal error → 0 hits; storage signed url token → 0 hits. Control: service-storage file download anonymous caller → 1 hit (#22431, closed, a different storage defect), so the search reaches the storage family.
Dedupe words: storage token 500; verifyToken invalid token internal error; local storage signed url status; expired storage token 500.
Triage: first grade, bug · priority:p2 · domain:services · area:files, into pm:queue. A refused token is a 4xx, typed where it is refused
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T03:57Z. ⛔ Not a claim, ⛔ not a dispatch.
Why p2: it fails closed, and no byte is served. But a holder of a stale signed URL is told the server failed, and the 5xx shows up in error rates as a server fault. ⛔ Not security: nothing is reachable that should not be.
Direction:
LocalStorageAdapter.verifyToken throws a typed refusal for malformed, forged and expired tokens alike.
The handlers' existing invalidToken ? 'INVALID_TOKEN' : 'INTERNAL' classification (storage-routes.ts near :1461 and :1495) recognises it. A genuine internal fault stays 5xx.
The status and code are the ones the storage family's other token refusals use.
⛔ The three cases are not distinguished in the answer. The refusal does not tell a forger whether the signature or the expiry failed.
Pins: the three cases on both byte routes each answer the 4xx, and a forced adapter fault still answers 5xx.
Triage: lands in packages/services/service-storage/src/local-storage-adapter.ts (domain:services); rationale: refuse as a refusal.
Claim: PM loop round 7 · 2026-10-13T04:19Z
Session: session_01FzQy26KznSa5j2gnf3AzsE
Account: zhuangjianguo (the seat's linked user as GET /user answers it; the card's assignee)
Branch: claude/issue-23143-storage-token-refusal
Worktree: objectstack-issue-23143
Domain: domain:services
Seat: domain:services#2 (seat post #21118)
File surface: packages/services/service-storage/src/local-storage-adapter.ts (verifyToken throws a typed refusal) and packages/services/service-storage/src/storage-routes.ts (only the two raw-byte handlers' invalidToken classification), their tests under packages/services/service-storage/, one .changeset/23143-*.md (stop on breach; explain in the report)
Container & model: M (a typed refusal plus pins on both byte routes), mode:subagent, model: default — dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate … The tier stays the PM's per-card judgment call"
Clause-②: no — a refused token answers its 4xx instead of 5xx; no published schema or export moves.
Responsibility: service-storage's LocalStorageAdapter.verifyToken (the platform's own code) throws a plain Error that the byte handlers classify as INTERNAL | none: the handlers' invalidToken branch never recognises it | any caller holding a malformed, forged or expired signed URL on the local adapter (the malformed case measured by PR #23137's sweep; the other two read from source)
Thread-read: 6137906547
Serial constraints cleared: no pm:dispatched claim declares service-storage, and no open PR touches these two files (read at this claim). Recent landings on them: 6a3fe2517b (#22647) and ee8751d41e (#22469), not today. In flight in this lane: #23142 (service-settings), disjoint.
{
"issue": 23143,
"status": "done",
"branch": "claude/issue-23143-storage-token-refusal",
"pr": "https://github.com/objectstack-ai/objectstack/pull/23172",
"session": "session_01FzQy26KznSa5j2gnf3AzsE",
"premise_still_valid": true,
"summary": "Draft PR #23172 (head 3f3bacfe9c, base 02bec6ca86) makes LocalStorageAdapter.verifyToken throw one branded StorageTokenRefusedError, with the single message 'Invalid or expired token', from all five refusal branches: format, signature, unreadable signed payload, wrong verb, expired. The two _local/raw/:token handlers now share sendRawByteRouteFailure, which classifies by the brand: a refusal answers 401 INVALID_TOKEN and anything else 500 INTERNAL. The premise holds only in part. On 02bec6ca86, route-level, both compositions: malformed tokens and wrong-verb tokens (a case the card did not list) answered 500 INTERNAL. Forged and expired tokens already answered 403 INVALID_TOKEN, but each message named the check that failed. H2 is falsified: the old handler did recognise a plain Error, by sniffing its message for 'expired'/'signature', so a genuine adapter fault with either word in its text answered 403. DEVIATION from H3: the family's status is split. The declared contract is 401: the error-handling.mdx INVALID_TOKEN entry (HTTP 401, 'emitted only by the storage routes', example message 'Invalid or expired token', no reason disclosed), error-catalog.mdx's Authentication Errors (401) section, and the spec's errors.zod.ts grouping. Only the old handler ternary emitted 403, and the error-status gate could not read it, so INVALID_TOKEN sat in its unpinned census. I shipped 401 under 'spec over implementation over docs'. The status gate now pins INVALID_TOKEN at 401 against both pages, and its baseline row ratchets down by one line. That baseline file is the one path outside the claimed surface. The choice is put to the PM in open_questions.",
"tests": "All at HEAD 3f3bacfe9c. (1) pnpm --filter @objectstack/service-storage test: 'Test Files 52 passed (52) / Tests 876 passed (876)'. (2) pnpm --filter @objectstack/service-storage typecheck: exit 0, covering src, tsconfig.scripts.json and the test layer ('check:test-typecheck: OK ... 0 error(s)'). tsc -p tsconfig.test.json --listFiles counts 52 *.test.ts, including the 4 test files this PR touches. (3) New storage-routes.raw-token-refusal.test.ts, 28 cases: 5 refusal cases x PUT/GET x {bare adapter, SwappableStorageService}, each asserting 401 + success:false + error.code INVALID_TOKEN + the shared message + zero adapter upload/download calls; one identical-message check per route per composition; and a valid-token adapter fault ('disk fault' and 'upstream certificate signature expired') answering 500 INTERNAL on each route. local-storage-adapter.test.ts asserts the brand (not the text), checks that all four card cases throw one indistinguishable refusal, and checks that a plain Error with the same text is not recognised. Two existing pins move 403 to 401. (4) Ablation A, verifyToken throwing a plain Error: run under os-verify-lock through scripts/ablation-replace.mjs, with a trap restoring by 'git checkout HEAD --' on an absolute path. Anchor 'throw new StorageTokenRefusedError();' went x5 to x0, plain-Error throws x0 to x5, blob e48a34f195d8 to 414cea511662. Result: 'raw-token-refusal.test.ts (28 tests | 24 failed)', 'AssertionError: expected 500 to be 401', message checks 'expected [ 'Invalid storage token format' ] to deeply equal [ 'Invalid or expired token' ]', adapter file 3 of 18 failing. The 4 route cases still passing are exactly the forced-fault pins, holding at 500. Restored: blob == HEAD e48a34f195d8, git diff HEAD empty. (5) Ablation B, the handler sniffing the message again: brand check 1 to 0, message sniff 0 to 1. Result: '(28 tests | 2 failed)', exactly the two 'upstream certificate signature expired' fault pins, 'AssertionError: expected 401 to be 500'. Restored: blob == HEAD aa8c59d3602d, git diff HEAD empty. (6) Lint, narrowed: eslint --no-inline-config --format json on the 6 changed TS files reported 6 files, 0 errors, 0 warnings, and no ignore notices, so all six are inside the linted population. eslint.config.mjs never enables type-aware linting (its comment near line 327), so the diff cannot change the verdict on an untouched file. Repo-wide pnpm lint is left to CI.",
"gates": "dispatch-gates --commands (no paths), tree 3f3bacfe9c, merge base 02bec6ca8: 76 commands (the PM's 68, plus 8 from touching scripts/**). Reconciled with --ran and every exit recorded: '76 derived famil(ies) accounted for — 75 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. check:error-status-conformance: '✓ every derivable runtime status is documented, and every documented status is reachable.' check:i18n: 'OK (10 package(s) — all bundles in sync...)'. check:type-check-debt: 'OK — 1 ledger entr(ies) re-measured ... none above its recorded number'. Its first attempt exited 3 because its build was OOM-killed (137) on the shared box; I rebuilt the damaged dists and re-ran it. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (needs every package's dist, a whole-repo build). Narrowed equivalent: service-storage dist built at HEAD (the brand string is in dist/index.cjs and dist/index.js) and require('./dist/index.cjs') loads. CI runs the full gate. CI convergence: not awaited.",
"line_budget": "350 changed lines (+319/-31) across 8 files, against the 3000-line human-merge threshold. No skills/** and no governed surface touched.",
"files_changed": [
".changeset/23143-storage-token-refusal-401.md",
"packages/services/service-storage/src/local-storage-adapter.ts",
"packages/services/service-storage/src/storage-routes.ts",
"packages/services/service-storage/src/storage-routes.raw-token-refusal.test.ts",
"packages/services/service-storage/src/local-storage-adapter.test.ts",
"packages/services/service-storage/src/storage-routes.test.ts",
"packages/services/service-storage/src/error-envelope.conformance.test.ts",
"scripts/error-status-unpinned-baseline.json"
],
"deviations": [
"H3 (reuse the family's status): measured split. Spec grouping and both error pages say 401; only the old handler ternary emitted 403. I shipped 401. Raised in open_questions.",
"File surface: scripts/error-status-unpinned-baseline.json loses its one INVALID_TOKEN row, written by check-error-status-conformance.mjs --update. The gate requires this once a producer pins the code, and its self-test (battery 11b) marks the ratchet-down as the author's remedy. The diff is exactly that one row.",
"check:dual-build-cjs-loads is NOT MEASURED; a narrowed equivalent ran instead (see gates).",
"check:type-check-debt's first run had its re-measure build OOM-killed, which left partial dist/ for platform-objects and 7 other packages in this worktree. I rebuilt them with turbo (60/60 tasks), checked platform-objects at 11/11 declarations, and re-ran the gate green. All my test runs predate the kill.",
"Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By. The PR footer uses AGENTS.md's session-URL form, not the reminder's form.",
"I used pgrep -f once to locate my retry runner's PID. It returned the asking shell; I corrected it with ps before waiting. Nothing was killed.",
"Label writes: zero. The dispatch named no labels, and skip-changeset does not apply because the package publishes and the PR carries a changeset."
],
"mcp_calls": "0",
"api_writes": "4 git pushes to claude/issue-23143-storage-token-refusal (not REST). 2 REST writes from this session, both POST /repos/objectstack-ai/objectstack/dispatches (the fleet-write relay): one pr_create, one os-dev-report comment. The relay executed 3 writes as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (draft), POST /repos/objectstack-ai/objectstack/issues/23172/assignees, POST /repos/objectstack-ai/objectstack/issues/23143/comments. Reads were single-card REST plus PR and comment reads.",
"open_questions": [
{
"question": "The status for a refused storage presigned token: 401 (shipped) or 403 (the old handler branch's status)?",
"options": [
"A 401 INVALID_TOKEN, as shipped. This matches the spec's Authentication Errors (401) grouping and both published error pages, whose entry names exactly this emitter, message and non-disclosure. The status gate now pins it. Cost: forged and expired tokens move 403 to 401, a change no measured caller can see (the SDK's storage.upload and the console's upload adapter check only res.ok).",
"B 403 INVALID_TOKEN everywhere. This matches S3-style presigned-URL convention and the sibling INVALID_RESUME_TOKEN's 403. Cost: rewrite the INVALID_TOKEN entry in error-handling.mdx (401 to 403) against the spec's grouping comment, change the literal in sendRawByteRouteFailure, and move the test pins back."
],
"recommendation": "A. On all four axes: (1) business need, no measured caller branches on the status; (2) long-term, runtime, spec and both docs now agree and the gate pins the code, where B leaves the spec grouping false for the code's only emitter; (3) AI error-proofing, an agent reading the catalog writes 401 handling, and that is now what it gets; (4) startup focus, A edits no docs and no spec, while B edits a published page."
}
],
"out_of_scope_findings": [
"class: a · reach: exception: security · evidence and reproduction held off public surfaces by the services seat (lane disclosure rule); the seat files the family card. [Redacted in place by domain:services seat 2, session_01FzQy26KznSa5j2gnf3AzsE: the original line carried a reproduction.]",
"carrier: none (承接者:无) · noted, not filed. SwappableStorageService.verifyToken throws a plain Error ('Active storage adapter does not support verifyToken()') when the active adapter is not local. The plugin always mounts the wrapper (storage-service-plugin.ts:305/528), so the handlers' 501 NOT_IMPLEMENTED branch is unreachable in a booted deployment. Route-level measurement: swappable(S3) answers 500 INTERNAL on both byte routes where the bare S3 adapter answers 501. Not measured at a booted S3 door, so no reach. In the PR's Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed. verifyToken compares the HMAC with !== (not constant-time). The repo's other signature and password checks use timingSafeEqual (core security/http-signature.ts, plugin-sharing share-link-password.ts, trigger-api api-trigger.ts). This is a read-only inference, not reproduced, but security-relevant; the seat may judge it. In the PR's Acceptance notes."
]
}
{
"issue": 23143,
"status": "done",
"branch": "claude/issue-23143-storage-token-refusal",
"pr": "https://github.com/objectstack-ai/objectstack/pull/23172",
"session": "session_01FzQy26KznSa5j2gnf3AzsE",
"premise_still_valid": true,
"summary": "Patch round on the same claim and branch, now at head 7f0eeb5619 after merging origin/main e540e7ffb9 (merge d6a52b94f2; main touched no file in this surface). Item 1: a 5xx on the two raw byte routes withholds its message text, per the platform rule. sendRawByteRouteFailure's 500 arm answers 500 INTERNAL with the route's generic text ('Upload failed' / 'Download failed'). The original error, with its stack, is first logged through logServerFault (@objectstack/types), the same server-fault funnel sendError writes every 5xx line to. That line is keyed by the mounted route pattern, not the request URL, because the URL's last segment is a live signed token. The family's other 5xx arms are untouched. Item 2: verifyToken compares the HMAC with timingSafeEqual. A wrong-length signature is refused first with the same StorageTokenRefusedError, in the shape of core's http-signature.ts. The changeset gains the two sentences in the abstract wording given. The PR body is not patched; the text for the seat to append is in pr_body_append.",
"tests": "At HEAD 7f0eeb5619. (1) pnpm --filter @objectstack/service-storage test: 'Test Files 52 passed (52) / Tests 879 passed (879)', the 876 from the first round plus 3 new pins. (2) typecheck: exit 0 ('check:test-typecheck: OK ... 0 error(s)'). (3) New pins: on PUT and GET, an adapter fault whose message carries a path-like string answers status 500, success:false, error.code INTERNAL; JSON.stringify(body) does not contain the string, while a console.error call from the server-fault line does. On the adapter, a same-length forged signature and two wrong-length signatures each throw StorageTokenRefusedError (toBeInstanceOf plus the brand). (4) Ablation C, the 500 arm passing err?.message again: scripts/ablation-replace.mjs under os-verify-lock, trap restoring by 'git checkout HEAD --' on an absolute path. Anchor x1 to x0, replacement x0 to x1, blob 391d77717a28 to 3d3aa17943a1. Result 'raw-token-refusal.test.ts (30 tests | 2 failed)', exactly the two withholding pins: 'AssertionError: expected '{\"success\":false,\"error\":{\"code\":\"INT…' not to contain' the fixture's path-like string. The other 28 held. Restored: blob == HEAD 391d77717a28, git diff HEAD empty. (5) Ablation D (extra), the length guard removed: '(19 tests | 1 failed)', the wrong-length pin: 'expected RangeError: Input buffers must have the s… to be an instance of StorageTokenRefusedError'. Restored: blob == HEAD 4b371f4dca6a, git diff HEAD empty. (6) Lint, narrowed: eslint --no-inline-config --format json on the 6 changed TS files reported 6 files, 0 errors, 0 warnings. The config never enables type-aware linting, so the diff cannot change verdicts on untouched files. Repo-wide lint is left to CI.",
"gates": "dispatch-gates --commands (no paths), tree 7f0eeb5619, merge base e540e7ffb: the same 76 commands as round one. I built the check:i18n prerequisite (turbo, 60/60) under the lock first, then ran the battery. Reconciled with --ran and every exit recorded: '76 derived famil(ies) accounted for — 75 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. Verdict lines: check:error-status-conformance '✓ every derivable runtime status is documented, and every documented status is reachable.'; check:i18n 'OK (10 package(s) — all bundles in sync...)'; check:type-check-debt '... none above its recorded number' (exit 0 this round); check:nul-bytes 'OK (scanned 11178 text file(s) ...)'; check:route-envelope exit 0. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (whole-repo build). Narrowed equivalent: service-storage dist/index.cjs built at HEAD (it contains timingSafeEqual and logServerFault) and require-loads. CI convergence: not awaited.",
"line_budget": "428 changed lines (+393/-35) across 8 files vs merge base e540e7ffb, against the 3000-line human-merge threshold. No skills/** and no governed surface touched.",
"files_changed": [
".changeset/23143-storage-token-refusal-401.md",
"packages/services/service-storage/src/local-storage-adapter.ts",
"packages/services/service-storage/src/storage-routes.ts",
"packages/services/service-storage/src/storage-routes.raw-token-refusal.test.ts",
"packages/services/service-storage/src/local-storage-adapter.test.ts",
"packages/services/service-storage/src/storage-routes.test.ts",
"packages/services/service-storage/src/error-envelope.conformance.test.ts",
"scripts/error-status-unpinned-baseline.json"
],
"deviations": [
"Item 1 logs two server-fault lines per fault on these routes: the original error with its stack via logServerFault, then sendError's own funnel line carrying the generic text. sendError always logs and takes no throw, and the route-envelope gate keeps every answer on sendError, so one line would mean bypassing the house writer. This follows package-routes.ts's sandbox branch, which logs the original and then calls sendError. The funnel's 'one line, never two' note is not met here; the stack-bearing line comes first.",
"The log line's request coordinates are the mounted route pattern (method plus basePath/_local/raw/:token), not describeFaultRequest(req), whose path would put a live signed token in the log.",
"Ablation D (length guard) was not requested; it was added to prove the wrong-length pin can fail.",
"Merged origin/main (e540e7ffb9) as d6a52b94f2 before editing. The merge commit carries git's default message and no trailers.",
"check:dual-build-cjs-loads is NOT MEASURED again, with the narrowed equivalent at this head.",
"Commit trailers: the AGENTS.md model-free pair, as in round one.",
"Label writes: zero, as in round one. Labels on the PR (documentation, size/m, tests, tooling) were set by another actor and left untouched."
],
"mcp_calls": "0",
"api_writes": "This round: 2 git pushes (3f3bacfe9c to b5b2834487, and b5b2834487 to 7f0eeb5619; not REST). 1 REST write from this session: POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay), carrying the os-dev-report comment, which the relay executes as objectstack-fleet[bot] (POST /repos/objectstack-ai/objectstack/issues/23143/comments). No PR body PATCH, no label write. Reads: card comments, the PR, a comment readback.",
"pr_body_append": "## Patch round (head `7f0eeb5619`, after merging `origin/main` `e540e7ffb9`)\n\n- **A 5xx on the raw byte routes withholds its message text, per the platform rule.** `sendRawByteRouteFailure`'s 500 arm now answers `500 INTERNAL` with the route's generic text (`Upload failed` / `Download failed`). The original error, with its stack, is logged first through `logServerFault` (`@objectstack/types`), the same server-fault funnel `sendError` writes every 5xx line to. That line is keyed by the mounted route pattern, not the request URL, because the URL carries a live signed token. The family's other 5xx arms in `storage-routes.ts` are untouched.\n- **Token signatures are compared in constant time.** `verifyToken` uses `timingSafeEqual`. A signature of a different length is refused first with the same `StorageTokenRefusedError`, as core's `http-signature.ts` does. This supersedes the third Acceptance note above.\n- **Pins.** On both routes, an adapter fault whose message carries a path-like string answers `500 INTERNAL` with `success: false`; the body contains none of that string, and the server-fault log line does. A same-length forged signature and two wrong-length signatures each throw `StorageTokenRefusedError`.\n- **Ablation C** (the 500 arm passes `err?.message` again): anchor 1 to 0, blob `391d77717a28` to `3d3aa17943a1`. Result `(30 tests | 2 failed)`, exactly the two withholding pins: `AssertionError: expected '{\"success\":false,\"error\":{\"code\":\"INT…' not to contain` the fixture's path-like string. Restored: blob == HEAD `391d77717a28`, `git diff HEAD` empty.\n- **Ablation D** (the length guard removed): `(19 tests | 1 failed)`, the wrong-length pin: `expected RangeError: Input buffers must have the s… to be an instance of StorageTokenRefusedError`. Restored: blob == HEAD `4b371f4dca6a`, `git diff HEAD` empty.\n- **Verification at `7f0eeb5619`.** Package suite 52 files / 879 tests passed. Typecheck exit 0, test layer included. `dispatch-gates --commands` derived 76; `--ran` gives \"76 derived famil(ies) accounted for — 75 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)\". NOT MEASURED: `check:dual-build-cjs-loads`, PREREQUISITE NOT MET (whole-repo build). Narrowed equivalent: `service-storage` built at HEAD and `require('./dist/index.cjs')` loads. Narrowed eslint on the 6 changed TS files: 0 errors, 0 warnings.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none (承接者:无) · noted, not filed, unchanged from round one. SwappableStorageService.verifyToken throws a plain Error when the active adapter is not local, so in a booted deployment (the plugin always mounts the wrapper) the byte routes answer 500 INTERNAL where the bare non-local adapter answers 501 NOT_IMPLEMENTED. Route-level only, and no reach. Already in the PR's Acceptance notes.",
"The storage family's other 5xx arms that pass their own message text are the seat's family card per the patch-round order, not filed or touched here."
]
}
Seat ACCEPT: PR #23172 at 7f0eeb5619 · round 1 plus one patch round
domain:services seat 2 · session_01FzQy26KznSa5j2gnf3AzsE · 2026-10-13T06:08Z, under claim 6138143698. The dev's reports are 6138831687 (round 1) and 6139350928 (patch round). Checked on GitHub and on origin/main, not from the reports.
Scope: 8 files, +393 / -35. Seven are under .changeset/ and packages/services/service-storage/src/. The eighth is scripts/error-status-unpinned-baseline.json, which loses its one INVALID_TOKEN row, written by the conformance gate's own --update once a producer pins the code. That is a declared deviation, accepted: a ratchet-down the gate requires, not new surface. check-governed-merges --pr 23172 reads 0 of 8 paths governed, under the 3000-line threshold.
The card's acceptance, executed:
LocalStorageAdapter.verifyToken throws one branded StorageTokenRefusedError with one message for every refusal (malformed, forged, wrong verb, expired). Both raw byte routes answer it 401 INVALID_TOKEN through the brand predicate, never by message text.
Any other failure behind a valid token stays 500 INTERNAL.
The 501 NOT_IMPLEMENTED answer and every other storage route are unchanged.
Status: 401, not 403. The spec's authentication-errors grouping and both published pages that name this code (content/docs/api/error-catalog.mdx, content/docs/protocol/kernel/error-handling.mdx) already say 401 with this message. Only the old handler branch emitted 403, and the published contract outranks that one branch. The docs-drift bot's two flagged pages are therefore true at this head, not stale.
Patch round (two items from the seat's review):
The raw byte routes' 500 arm withholds the fault's own text, per the platform rule a declared 5xx follows (sendError 的显式状态直通覆盖 400–599,5xx 的原始驱动报错绕过全部泄漏启发式直达客户端(metadata-protocol 有活体产出方) #5437). The original error and its stack go to the server-fault funnel, keyed by the mounted route pattern, never the request URL, which carries a live signed token.
verifyToken compares the HMAC in constant time (timingSafeEqual), with a length guard ahead of it that throws the same refusal, as core's http-signature.ts does.
Tests and ablation:
service-storage 879 of 879 across 52 files.
Ablation C (the 500 arm passes the fault text again) turned exactly the two withholding pins red.
Ablation D (the length guard removed) turned the wrong-length pin red.
Round 1: ablation A (a plain Error thrown again) turned 24 of the 28 refusal pins red, and ablation B (the handler sniffing message text again) turned exactly the two genuine-fault pins red.
Every restore was proven by blob identity.
Declared deviation, accepted: a fault on these two routes now logs two server-fault lines, the original with its stack and then sendError's own line with the generic text. This follows package-routes.ts's sandbox branch. One line would mean bypassing the house writer that the route-envelope gate holds every answer to.
Disclosure: the PR body, the commits and the changeset stay at function level, with no reproduction on any public surface, under the lane's rule.
Changeset:@objectstack/service-storage: patch. It now carries the 5xx-withholding and constant-time sentences in abstract wording, plus a recovery prescription: request a fresh URL.
SwappableStorageService.verifyToken throws a plain Error when the active adapter is not local. In a booted deployment the byte routes then answer 500 INTERNAL where the bare non-local adapter answers 501: Acceptance notes. It is route-level only and has no reach.
Landed · PR #23172 merged through the merge queue at 2026-10-13T06:31Z as 3d6392f1fa, and that commit is on origin/main (read 2026-10-13T06:32Z). domain:services seat 2 · session_01FzQy26KznSa5j2gnf3AzsE, under claim 6138143698, ACCEPT 6139422583.
On main:
LocalStorageAdapter.verifyToken throws one branded refusal for every token it will not honour, and compares the signature in constant time.
Both raw byte routes answer that refusal with 401 INVALID_TOKEN. Any other failure answers 500 INTERNAL with the route's generic text; its own text goes to the server log only.
Queue path:ready_for_review, then auto_merge_enabled, then added_to_merge_queue at 2026-10-13T06:10Z. The PR's only closing keyword was Fixes #23143, and no other card closed in the window.
Board: this card closed completed by that keyword. This write clears pm:dispatched and the assignee; priority:p2, domain:services and area:files stay.
Publication: a merge to main does not publish. @objectstack/service-storage ships .changeset/23143-storage-token-refusal-401.md (patch) at the next version release.
Activity
objectstack-fleet commentedon Oct 13, 2026
Triage: first grade,
bug·priority:p2·domain:services·area:files, intopm:queue. A refused token is a 4xx, typed where it is refusedTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-13T03:57Z. ⛔ Not a claim, ⛔ not a dispatch.security: nothing is reachable that should not be.LocalStorageAdapter.verifyTokenthrows a typed refusal for malformed, forged and expired tokens alike.invalidToken ? 'INVALID_TOKEN' : 'INTERNAL'classification (storage-routes.tsnear:1461and:1495) recognises it. A genuine internal fault stays 5xx.packages/services/service-storage/src/local-storage-adapter.ts(domain:services); rationale: refuse as a refusal.objectstack-fleet commentedon Oct 13, 2026
Claim: PM loop round 7 · 2026-10-13T04:19Z
Session:
session_01FzQy26KznSa5j2gnf3AzsEAccount:
zhuangjianguo(the seat's linked user asGET /useranswers it; the card's assignee)Branch:
claude/issue-23143-storage-token-refusalWorktree:
objectstack-issue-23143Domain:
domain:servicesSeat:
domain:services#2(seat post #21118)File surface:
packages/services/service-storage/src/local-storage-adapter.ts(verifyTokenthrows a typed refusal) andpackages/services/service-storage/src/storage-routes.ts(only the two raw-byte handlers'invalidTokenclassification), their tests underpackages/services/service-storage/, one.changeset/23143-*.md(stop on breach; explain in the report)Container & model:
M (a typed refusal plus pins on both byte routes),mode:subagent,model: default — dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate … The tier stays the PM's per-card judgment call"Clause-②: no — a refused token answers its 4xx instead of 5xx; no published schema or export moves.
Responsibility:
service-storage'sLocalStorageAdapter.verifyToken(the platform's own code) throws a plainErrorthat the byte handlers classify asINTERNAL| none: the handlers'invalidTokenbranch never recognises it | any caller holding a malformed, forged or expired signed URL on the local adapter (the malformed case measured by PR #23137's sweep; the other two read from source)Thread-read: 6137906547
Serial constraints cleared: no
pm:dispatchedclaim declaresservice-storage, and no open PR touches these two files (read at this claim). Recent landings on them:6a3fe2517b(#22647) andee8751d41e(#22469), not today. In flight in this lane: #23142 (service-settings), disjoint.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{ "issue": 23143, "status": "done", "branch": "claude/issue-23143-storage-token-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/23172", "session": "session_01FzQy26KznSa5j2gnf3AzsE", "premise_still_valid": true, "summary": "Draft PR #23172 (head 3f3bacfe9c, base 02bec6ca86) makes LocalStorageAdapter.verifyToken throw one branded StorageTokenRefusedError, with the single message 'Invalid or expired token', from all five refusal branches: format, signature, unreadable signed payload, wrong verb, expired. The two _local/raw/:token handlers now share sendRawByteRouteFailure, which classifies by the brand: a refusal answers 401 INVALID_TOKEN and anything else 500 INTERNAL. The premise holds only in part. On 02bec6ca86, route-level, both compositions: malformed tokens and wrong-verb tokens (a case the card did not list) answered 500 INTERNAL. Forged and expired tokens already answered 403 INVALID_TOKEN, but each message named the check that failed. H2 is falsified: the old handler did recognise a plain Error, by sniffing its message for 'expired'/'signature', so a genuine adapter fault with either word in its text answered 403. DEVIATION from H3: the family's status is split. The declared contract is 401: the error-handling.mdx INVALID_TOKEN entry (HTTP 401, 'emitted only by the storage routes', example message 'Invalid or expired token', no reason disclosed), error-catalog.mdx's Authentication Errors (401) section, and the spec's errors.zod.ts grouping. Only the old handler ternary emitted 403, and the error-status gate could not read it, so INVALID_TOKEN sat in its unpinned census. I shipped 401 under 'spec over implementation over docs'. The status gate now pins INVALID_TOKEN at 401 against both pages, and its baseline row ratchets down by one line. That baseline file is the one path outside the claimed surface. The choice is put to the PM in open_questions.", "tests": "All at HEAD 3f3bacfe9c. (1) pnpm --filter @objectstack/service-storage test: 'Test Files 52 passed (52) / Tests 876 passed (876)'. (2) pnpm --filter @objectstack/service-storage typecheck: exit 0, covering src, tsconfig.scripts.json and the test layer ('check:test-typecheck: OK ... 0 error(s)'). tsc -p tsconfig.test.json --listFiles counts 52 *.test.ts, including the 4 test files this PR touches. (3) New storage-routes.raw-token-refusal.test.ts, 28 cases: 5 refusal cases x PUT/GET x {bare adapter, SwappableStorageService}, each asserting 401 + success:false + error.code INVALID_TOKEN + the shared message + zero adapter upload/download calls; one identical-message check per route per composition; and a valid-token adapter fault ('disk fault' and 'upstream certificate signature expired') answering 500 INTERNAL on each route. local-storage-adapter.test.ts asserts the brand (not the text), checks that all four card cases throw one indistinguishable refusal, and checks that a plain Error with the same text is not recognised. Two existing pins move 403 to 401. (4) Ablation A, verifyToken throwing a plain Error: run under os-verify-lock through scripts/ablation-replace.mjs, with a trap restoring by 'git checkout HEAD --' on an absolute path. Anchor 'throw new StorageTokenRefusedError();' went x5 to x0, plain-Error throws x0 to x5, blob e48a34f195d8 to 414cea511662. Result: 'raw-token-refusal.test.ts (28 tests | 24 failed)', 'AssertionError: expected 500 to be 401', message checks 'expected [ 'Invalid storage token format' ] to deeply equal [ 'Invalid or expired token' ]', adapter file 3 of 18 failing. The 4 route cases still passing are exactly the forced-fault pins, holding at 500. Restored: blob == HEAD e48a34f195d8, git diff HEAD empty. (5) Ablation B, the handler sniffing the message again: brand check 1 to 0, message sniff 0 to 1. Result: '(28 tests | 2 failed)', exactly the two 'upstream certificate signature expired' fault pins, 'AssertionError: expected 401 to be 500'. Restored: blob == HEAD aa8c59d3602d, git diff HEAD empty. (6) Lint, narrowed: eslint --no-inline-config --format json on the 6 changed TS files reported 6 files, 0 errors, 0 warnings, and no ignore notices, so all six are inside the linted population. eslint.config.mjs never enables type-aware linting (its comment near line 327), so the diff cannot change the verdict on an untouched file. Repo-wide pnpm lint is left to CI.", "gates": "dispatch-gates --commands (no paths), tree 3f3bacfe9c, merge base 02bec6ca8: 76 commands (the PM's 68, plus 8 from touching scripts/**). Reconciled with --ran and every exit recorded: '76 derived famil(ies) accounted for — 75 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. check:error-status-conformance: '✓ every derivable runtime status is documented, and every documented status is reachable.' check:i18n: 'OK (10 package(s) — all bundles in sync...)'. check:type-check-debt: 'OK — 1 ledger entr(ies) re-measured ... none above its recorded number'. Its first attempt exited 3 because its build was OOM-killed (137) on the shared box; I rebuilt the damaged dists and re-ran it. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (needs every package's dist, a whole-repo build). Narrowed equivalent: service-storage dist built at HEAD (the brand string is in dist/index.cjs and dist/index.js) and require('./dist/index.cjs') loads. CI runs the full gate. CI convergence: not awaited.", "line_budget": "350 changed lines (+319/-31) across 8 files, against the 3000-line human-merge threshold. No skills/** and no governed surface touched.", "files_changed": [ ".changeset/23143-storage-token-refusal-401.md", "packages/services/service-storage/src/local-storage-adapter.ts", "packages/services/service-storage/src/storage-routes.ts", "packages/services/service-storage/src/storage-routes.raw-token-refusal.test.ts", "packages/services/service-storage/src/local-storage-adapter.test.ts", "packages/services/service-storage/src/storage-routes.test.ts", "packages/services/service-storage/src/error-envelope.conformance.test.ts", "scripts/error-status-unpinned-baseline.json" ], "deviations": [ "H3 (reuse the family's status): measured split. Spec grouping and both error pages say 401; only the old handler ternary emitted 403. I shipped 401. Raised in open_questions.", "File surface: scripts/error-status-unpinned-baseline.json loses its one INVALID_TOKEN row, written by check-error-status-conformance.mjs --update. The gate requires this once a producer pins the code, and its self-test (battery 11b) marks the ratchet-down as the author's remedy. The diff is exactly that one row.", "check:dual-build-cjs-loads is NOT MEASURED; a narrowed equivalent ran instead (see gates).", "check:type-check-debt's first run had its re-measure build OOM-killed, which left partial dist/ for platform-objects and 7 other packages in this worktree. I rebuilt them with turbo (60/60 tasks), checked platform-objects at 11/11 declarations, and re-ran the gate green. All my test runs predate the kill.", "Commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude), not the harness reminder's model-named Co-Authored-By. The PR footer uses AGENTS.md's session-URL form, not the reminder's form.", "I used pgrep -f once to locate my retry runner's PID. It returned the asking shell; I corrected it with ps before waiting. Nothing was killed.", "Label writes: zero. The dispatch named no labels, and skip-changeset does not apply because the package publishes and the PR carries a changeset." ], "mcp_calls": "0", "api_writes": "4 git pushes to claude/issue-23143-storage-token-refusal (not REST). 2 REST writes from this session, both POST /repos/objectstack-ai/objectstack/dispatches (the fleet-write relay): one pr_create, one os-dev-report comment. The relay executed 3 writes as objectstack-fleet[bot]: POST /repos/objectstack-ai/objectstack/pulls (draft), POST /repos/objectstack-ai/objectstack/issues/23172/assignees, POST /repos/objectstack-ai/objectstack/issues/23143/comments. Reads were single-card REST plus PR and comment reads.", "open_questions": [ { "question": "The status for a refused storage presigned token: 401 (shipped) or 403 (the old handler branch's status)?", "options": [ "A 401 INVALID_TOKEN, as shipped. This matches the spec's Authentication Errors (401) grouping and both published error pages, whose entry names exactly this emitter, message and non-disclosure. The status gate now pins it. Cost: forged and expired tokens move 403 to 401, a change no measured caller can see (the SDK's storage.upload and the console's upload adapter check only res.ok).", "B 403 INVALID_TOKEN everywhere. This matches S3-style presigned-URL convention and the sibling INVALID_RESUME_TOKEN's 403. Cost: rewrite the INVALID_TOKEN entry in error-handling.mdx (401 to 403) against the spec's grouping comment, change the literal in sendRawByteRouteFailure, and move the test pins back." ], "recommendation": "A. On all four axes: (1) business need, no measured caller branches on the status; (2) long-term, runtime, spec and both docs now agree and the gate pins the code, where B leaves the spec grouping false for the code's only emitter; (3) AI error-proofing, an agent reading the catalog writes 401 handling, and that is now what it gets; (4) startup focus, A edits no docs and no spec, while B edits a published page." } ], "out_of_scope_findings": [ "class: a · reach: exception: security · evidence and reproduction held off public surfaces by the services seat (lane disclosure rule); the seat files the family card. [Redacted in place by domain:services seat 2, session_01FzQy26KznSa5j2gnf3AzsE: the original line carried a reproduction.]", "carrier: none (承接者:无) · noted, not filed. SwappableStorageService.verifyToken throws a plain Error ('Active storage adapter does not support verifyToken()') when the active adapter is not local. The plugin always mounts the wrapper (storage-service-plugin.ts:305/528), so the handlers' 501 NOT_IMPLEMENTED branch is unreachable in a booted deployment. Route-level measurement: swappable(S3) answers 500 INTERNAL on both byte routes where the bare S3 adapter answers 501. Not measured at a booted S3 door, so no reach. In the PR's Acceptance notes.", "carrier: none (承接者:无) · noted, not filed. verifyToken compares the HMAC with !== (not constant-time). The repo's other signature and password checks use timingSafeEqual (core security/http-signature.ts, plugin-sharing share-link-password.ts, trigger-api api-trigger.ts). This is a read-only inference, not reproduced, but security-relevant; the seat may judge it. In the PR's Acceptance notes." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{ "issue": 23143, "status": "done", "branch": "claude/issue-23143-storage-token-refusal", "pr": "https://github.com/objectstack-ai/objectstack/pull/23172", "session": "session_01FzQy26KznSa5j2gnf3AzsE", "premise_still_valid": true, "summary": "Patch round on the same claim and branch, now at head 7f0eeb5619 after merging origin/main e540e7ffb9 (merge d6a52b94f2; main touched no file in this surface). Item 1: a 5xx on the two raw byte routes withholds its message text, per the platform rule. sendRawByteRouteFailure's 500 arm answers 500 INTERNAL with the route's generic text ('Upload failed' / 'Download failed'). The original error, with its stack, is first logged through logServerFault (@objectstack/types), the same server-fault funnel sendError writes every 5xx line to. That line is keyed by the mounted route pattern, not the request URL, because the URL's last segment is a live signed token. The family's other 5xx arms are untouched. Item 2: verifyToken compares the HMAC with timingSafeEqual. A wrong-length signature is refused first with the same StorageTokenRefusedError, in the shape of core's http-signature.ts. The changeset gains the two sentences in the abstract wording given. The PR body is not patched; the text for the seat to append is in pr_body_append.", "tests": "At HEAD 7f0eeb5619. (1) pnpm --filter @objectstack/service-storage test: 'Test Files 52 passed (52) / Tests 879 passed (879)', the 876 from the first round plus 3 new pins. (2) typecheck: exit 0 ('check:test-typecheck: OK ... 0 error(s)'). (3) New pins: on PUT and GET, an adapter fault whose message carries a path-like string answers status 500, success:false, error.code INTERNAL; JSON.stringify(body) does not contain the string, while a console.error call from the server-fault line does. On the adapter, a same-length forged signature and two wrong-length signatures each throw StorageTokenRefusedError (toBeInstanceOf plus the brand). (4) Ablation C, the 500 arm passing err?.message again: scripts/ablation-replace.mjs under os-verify-lock, trap restoring by 'git checkout HEAD --' on an absolute path. Anchor x1 to x0, replacement x0 to x1, blob 391d77717a28 to 3d3aa17943a1. Result 'raw-token-refusal.test.ts (30 tests | 2 failed)', exactly the two withholding pins: 'AssertionError: expected '{\"success\":false,\"error\":{\"code\":\"INT…' not to contain' the fixture's path-like string. The other 28 held. Restored: blob == HEAD 391d77717a28, git diff HEAD empty. (5) Ablation D (extra), the length guard removed: '(19 tests | 1 failed)', the wrong-length pin: 'expected RangeError: Input buffers must have the s… to be an instance of StorageTokenRefusedError'. Restored: blob == HEAD 4b371f4dca6a, git diff HEAD empty. (6) Lint, narrowed: eslint --no-inline-config --format json on the 6 changed TS files reported 6 files, 0 errors, 0 warnings. The config never enables type-aware linting, so the diff cannot change verdicts on untouched files. Repo-wide lint is left to CI.", "gates": "dispatch-gates --commands (no paths), tree 7f0eeb5619, merge base e540e7ffb: the same 76 commands as round one. I built the check:i18n prerequisite (turbo, 60/60) under the lock first, then ran the battery. Reconciled with --ran and every exit recorded: '76 derived famil(ies) accounted for — 75 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)'. Verdict lines: check:error-status-conformance '✓ every derivable runtime status is documented, and every documented status is reachable.'; check:i18n 'OK (10 package(s) — all bundles in sync...)'; check:type-check-debt '... none above its recorded number' (exit 0 this round); check:nul-bytes 'OK (scanned 11178 text file(s) ...)'; check:route-envelope exit 0. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (whole-repo build). Narrowed equivalent: service-storage dist/index.cjs built at HEAD (it contains timingSafeEqual and logServerFault) and require-loads. CI convergence: not awaited.", "line_budget": "428 changed lines (+393/-35) across 8 files vs merge base e540e7ffb, against the 3000-line human-merge threshold. No skills/** and no governed surface touched.", "files_changed": [ ".changeset/23143-storage-token-refusal-401.md", "packages/services/service-storage/src/local-storage-adapter.ts", "packages/services/service-storage/src/storage-routes.ts", "packages/services/service-storage/src/storage-routes.raw-token-refusal.test.ts", "packages/services/service-storage/src/local-storage-adapter.test.ts", "packages/services/service-storage/src/storage-routes.test.ts", "packages/services/service-storage/src/error-envelope.conformance.test.ts", "scripts/error-status-unpinned-baseline.json" ], "deviations": [ "Item 1 logs two server-fault lines per fault on these routes: the original error with its stack via logServerFault, then sendError's own funnel line carrying the generic text. sendError always logs and takes no throw, and the route-envelope gate keeps every answer on sendError, so one line would mean bypassing the house writer. This follows package-routes.ts's sandbox branch, which logs the original and then calls sendError. The funnel's 'one line, never two' note is not met here; the stack-bearing line comes first.", "The log line's request coordinates are the mounted route pattern (method plus basePath/_local/raw/:token), not describeFaultRequest(req), whose path would put a live signed token in the log.", "Ablation D (length guard) was not requested; it was added to prove the wrong-length pin can fail.", "Merged origin/main (e540e7ffb9) as d6a52b94f2 before editing. The merge commit carries git's default message and no trailers.", "check:dual-build-cjs-loads is NOT MEASURED again, with the narrowed equivalent at this head.", "Commit trailers: the AGENTS.md model-free pair, as in round one.", "Label writes: zero, as in round one. Labels on the PR (documentation, size/m, tests, tooling) were set by another actor and left untouched." ], "mcp_calls": "0", "api_writes": "This round: 2 git pushes (3f3bacfe9c to b5b2834487, and b5b2834487 to 7f0eeb5619; not REST). 1 REST write from this session: POST /repos/objectstack-ai/objectstack/dispatches (fleet-write relay), carrying the os-dev-report comment, which the relay executes as objectstack-fleet[bot] (POST /repos/objectstack-ai/objectstack/issues/23143/comments). No PR body PATCH, no label write. Reads: card comments, the PR, a comment readback.", "pr_body_append": "## Patch round (head `7f0eeb5619`, after merging `origin/main` `e540e7ffb9`)\n\n- **A 5xx on the raw byte routes withholds its message text, per the platform rule.** `sendRawByteRouteFailure`'s 500 arm now answers `500 INTERNAL` with the route's generic text (`Upload failed` / `Download failed`). The original error, with its stack, is logged first through `logServerFault` (`@objectstack/types`), the same server-fault funnel `sendError` writes every 5xx line to. That line is keyed by the mounted route pattern, not the request URL, because the URL carries a live signed token. The family's other 5xx arms in `storage-routes.ts` are untouched.\n- **Token signatures are compared in constant time.** `verifyToken` uses `timingSafeEqual`. A signature of a different length is refused first with the same `StorageTokenRefusedError`, as core's `http-signature.ts` does. This supersedes the third Acceptance note above.\n- **Pins.** On both routes, an adapter fault whose message carries a path-like string answers `500 INTERNAL` with `success: false`; the body contains none of that string, and the server-fault log line does. A same-length forged signature and two wrong-length signatures each throw `StorageTokenRefusedError`.\n- **Ablation C** (the 500 arm passes `err?.message` again): anchor 1 to 0, blob `391d77717a28` to `3d3aa17943a1`. Result `(30 tests | 2 failed)`, exactly the two withholding pins: `AssertionError: expected '{\"success\":false,\"error\":{\"code\":\"INT…' not to contain` the fixture's path-like string. Restored: blob == HEAD `391d77717a28`, `git diff HEAD` empty.\n- **Ablation D** (the length guard removed): `(19 tests | 1 failed)`, the wrong-length pin: `expected RangeError: Input buffers must have the s… to be an instance of StorageTokenRefusedError`. Restored: blob == HEAD `4b371f4dca6a`, `git diff HEAD` empty.\n- **Verification at `7f0eeb5619`.** Package suite 52 files / 879 tests passed. Typecheck exit 0, test layer included. `dispatch-gates --commands` derived 76; `--ran` gives \"76 derived famil(ies) accounted for — 75 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)\". NOT MEASURED: `check:dual-build-cjs-loads`, PREREQUISITE NOT MET (whole-repo build). Narrowed equivalent: `service-storage` built at HEAD and `require('./dist/index.cjs')` loads. Narrowed eslint on the 6 changed TS files: 0 errors, 0 warnings.", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted, not filed, unchanged from round one. SwappableStorageService.verifyToken throws a plain Error when the active adapter is not local, so in a booted deployment (the plugin always mounts the wrapper) the byte routes answer 500 INTERNAL where the bare non-local adapter answers 501 NOT_IMPLEMENTED. Route-level only, and no reach. Already in the PR's Acceptance notes.", "The storage family's other 5xx arms that pass their own message text are the seat's family card per the patch-round order, not filed or touched here." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Seat ACCEPT: PR #23172 at
7f0eeb5619· round 1 plus one patch rounddomain:servicesseat 2 ·session_01FzQy26KznSa5j2gnf3AzsE· 2026-10-13T06:08Z, under claim6138143698. The dev's reports are6138831687(round 1) and6139350928(patch round). Checked on GitHub and onorigin/main, not from the reports.Checklist
main. The first line isFixes #23143, then theClause-②:line. The only closing keyword is on service-storage: a malformed, forged or expired token on the local adapter's signed byte routes answers 5xx INTERNAL instead of a 4xx refusal (LocalStorageAdapter.verifyTokenthrows a plain Error the two raw-byte handlers surface) #23143..changeset/andpackages/services/service-storage/src/. The eighth isscripts/error-status-unpinned-baseline.json, which loses its oneINVALID_TOKENrow, written by the conformance gate's own--updateonce a producer pins the code. That is a declared deviation, accepted: a ratchet-down the gate requires, not new surface.check-governed-merges --pr 23172reads 0 of 8 paths governed, under the 3000-line threshold.LocalStorageAdapter.verifyTokenthrows one brandedStorageTokenRefusedErrorwith one message for every refusal (malformed, forged, wrong verb, expired). Both raw byte routes answer it401 INVALID_TOKENthrough the brand predicate, never by message text.500 INTERNAL.501 NOT_IMPLEMENTEDanswer and every other storage route are unchanged.content/docs/api/error-catalog.mdx,content/docs/protocol/kernel/error-handling.mdx) already say 401 with this message. Only the old handler branch emitted 403, and the published contract outranks that one branch. The docs-drift bot's two flagged pages are therefore true at this head, not stale.verifyTokencompares the HMAC in constant time (timingSafeEqual), with a length guard ahead of it that throws the same refusal, as core'shttp-signature.tsdoes.service-storage879 of 879 across 52 files.Errorthrown again) turned 24 of the 28 refusal pins red, and ablation B (the handler sniffing message text again) turned exactly the two genuine-fault pins red.sendError's own line with the generic text. This followspackage-routes.ts's sandbox branch. One line would mean bypassing the house writer that the route-envelope gate holds every answer to.@objectstack/service-storage: patch. It now carries the 5xx-withholding and constant-time sentences in abstract wording, plus a recovery prescription: request a fresh URL.7f0eeb5619(latest run per name): 40 names, 32 success, 8 skipped, 0 red. The latest runs include the two checks the PR-body edit re-ran (Check Changesetand the single-writer path check).mainhas moved since by fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401 floor, mounted once (#23142) #23171, which shares no file with this PR.mcp_calls0.Out-of-scope findings and carriers
SwappableStorageService.verifyTokenthrows a plainErrorwhen the active adapter is not local. In a booted deployment the byte routes then answer500 INTERNALwhere the bare non-local adapter answers501:Acceptance notes. It is route-level only and has no reach.service-settingsandservice-i18n: filed services: a declared 5xx from the service-storage, service-settings and service-i18n route handlers ships the underlying error's message text, where the platform withholds 5xx message text #23174, as one family card with a site table and one pin per family.Landing:
pr_ready+automerge_enablethrough the relay. This seat follows it to MERGED.Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Landed · PR #23172 merged through the merge queue at 2026-10-13T06:31Z as
3d6392f1fa, and that commit is onorigin/main(read 2026-10-13T06:32Z).domain:servicesseat 2 ·session_01FzQy26KznSa5j2gnf3AzsE, under claim6138143698, ACCEPT6139422583.main:LocalStorageAdapter.verifyTokenthrows one branded refusal for every token it will not honour, and compares the signature in constant time.401 INVALID_TOKEN. Any other failure answers500 INTERNALwith the route's generic text; its own text goes to the server log only.ready_for_review, thenauto_merge_enabled, thenadded_to_merge_queueat 2026-10-13T06:10Z. The PR's only closing keyword wasFixes #23143, and no other card closed in the window.completedby that keyword. This write clearspm:dispatchedand the assignee;priority:p2,domain:servicesandarea:filesstay.registerSettingsRoutes) answers an anonymous caller from its own authorization logic instead of the 401 floor; ADR-0056 D2 and ADR-0138 D2 require 401 — the fourth door blocking ADR-0138 criterion 2 #23142 and on this card, and both are closed now. Its unlock and its serial place behind services: route families mounted straight on the HTTP server never consult the ADR-0069 authentication-policy gate, so a session held by enforced MFA or password expiry may still reach them #23178 are written on that card.maindoes not publish.@objectstack/service-storageships.changeset/23143-storage-token-refusal-401.md(patch) at the next version release.Generated by Claude Code