Repository navigation
fix(metadata-protocol): classify a producer-marked refusal at the producer, so the 503 stays non-quoting (#12536) - #12936
Conversation
…ducer, not as a store outage A metadata app's sandboxed hook on `sys_metadata` can refuse a read and mark its refusal with `userMessage` (#9934 — presence IS the marking). Every such refusal was handed to `metadataStoreUnavailableError`, which builds a fresh Error carrying only code/status/cause; `declaredUserMessage` reads the top level and never `cause`, so the mark died at the producer. A marked refusal and a driver outage left `getMetaItems` as the same 503 with the same sentence. The wrapper now classifies first: a marked refusal travels its own refusal category with the author's text verbatim, and an unmarked failure keeps #8136's deliberately non-quoting 503, byte for byte. `deletePackage`'s per-item `failed[]` / `cleanups[]` follow the same classification and gain the optional `userMessage` member the rows had no channel for. The declined alternative — forwarding the mark across the 503 — is not implemented: nothing from `cause` crosses that door, and a negative pin with a positive control asserts the underlying failure text appears in no field outside it. Maintainer ruling 2026-08-27 (option B). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…he file header The prediction was wrong about section 2's granularity — two of its cases speak about the marked side and must go red on an unfixed producer. Recorded as measured rather than reconciled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
… contract ledger check:engine-double-contract asked for it by name: the new doubles route through assertEngineDeleteDispatch / assertEngineFindOnePredicate, and the pinned ledger has to learn about them or it never protects this file. Regeneration is additive — 2 rows added or grown, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 59dd8bab4969f4e9e3dc1a38e0e2f1810f0bc8bd && git checkout 59dd8bab4969f4e9e3dc1a38e0e2f1810f0bc8bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6228c1f0ef3aad1946763b543e9fb207b8bf7de2 b68b09808d4f19b38dcc828d4fd5f862b4dfc5cb && git checkout -B drift-repro 6228c1f0ef3aad1946763b543e9fb207b8bf7de2 && git merge --no-ff b68b09808d4f19b38dcc828d4fd5f862b4dfc5cb
node scripts/docs-audit/affected-docs.mjs --json 6228c1f0ef3aad1946763b543e9fb207b8bf7de2
|
Fixes #12536
Implements the maintainer's 2026-08-27 ruling (option B, verbatim 「同意」): a
producer-marked application refusal must not be wrapped as a store-unavailable
error at all. The wrapper classifies at the producer — a marked refusal travels its
own refusal category with the author's text intact, and a genuine store fault keeps
#8136's deliberately non-quoting 503. Both prior rulings stand; they stop colliding
at this seam.
⛔ The declined fallback — forwarding the mark across the 503 — is not
implemented, and is pinned against. Nothing from
causecrosses that door.Reproduced first, driving real code (not read off the source)
Measured on the branch point
aef1b7e64, through the realObjectStackProtocolImplementationover an engine whosesys_metadataread rejectswith a hook refusal carrying
userMessage(the #9934 authoring shape):The author's text existed only on
cause, whichdeclaredUserMessagenever reads.The same harness after the change:
What changed
metadataReadFailureError(cause)— one classifying door in front of the wrapper.A mark present (read through
declaredUserMessage, never an inline probe) builds therefusal; anything else goes to
metadataStoreUnavailableError, which is untouched,byte for byte. Routed at both throw sites the card names: the read guard
rethrowUnlessMetadataStoreUnprovisioned(the wholegetMetaItem/getMetaItems/lock / history / audit family) and
deletePackage's own overlay read.markedApplicationRefusalError(cause, userMessage)— the refusal category. Themarked text is the message; it is the only thing taken from the cause, because it is
the one string the producer declared is addressed to the end user. Status and code are
resolved through the imported
resolveThrownHttpError, never re-spelled — a hook thatnames its own
status/statusCodekeeps it (#7867), one that names none is answered400, which iserror-response.ts's owndeclared ?? 400for an undeclared sandboxhook refusal (#9967) rather than a number chosen here, and an uncatalogued
codedemotes so
ApiErrorSchema's closed union (ADR-0112 D4) cannot receive a driver'sdialect.
The per-item path.
DeletePackageResponse.failed[]andUninstallCleanupOutcomegain an optional
userMessage, populated from the item's own failure. Those rows rideinside a
PACKAGE_DELETE_PARTIAL400'sdetails, where no HTTP boundary can carry auserMessageon their behalf, so the channel has to exist on the row.Named explicitly rather than slipped in —
carryDeclaredUserMessageondeleteMetaItem's two re-wrap exits. A re-wrap that drops the mark destroys thechannel exactly as completely as building a fresh error does, and those two exits sit
on the ruled per-item path: without the carry, the
failed[]channel above isreachable only from the lock read and is dead for the actual delete — which is where a
sys_metadatahook refusal lands. It is one line each, the exact sibling of thecarryCatalogedErrorCodethose exits already call, and it copies only the markedfield: the sentence selection, status and code at both exits stay #8136's, unchanged.
Evidence that the scope is bounded:
overlayDeleteFailureMessageis not touched, and§3's driver-fault case pins that an unmarked failure's row is byte-identical.
The falsifiable criterion, both halves
packages/metadata-protocol/src/protocol.marked-refusal-classification.test.ts(+17 tests).declaredUserMessage, ongetMetaItems,getMetaItem, the draft read,deletePackage's read,failed[]andcleanups[].field outside the door. The scan walks every own property of the thrown error (plus
message/name/stack) and theresolveThrownHttpErrorprojection a boundaryactually ships, with
causeexcluded on purpose — that is the operator channel.It also pins that the 503 does not so much as own a
userMessageproperty.three disjoint sentinels (driver text, author mark, hook diagnostic) with a pairwise
non-containment guard that throws at load; the same scanner is proven to find a
sentinel carried on a non-message field, and to find the mark on the marked
refusal. Without that, "outside the door is clean" could be a dead scan.
Reverse verification — ablation restoring
protocol.tsfrom the branch point,mutation confirmed on disk by blob hash + anchored counts, restored with
git checkout HEAD --on an absolute path under atrap, proven by a matching blobhash and an empty
git diff HEAD. No rebuild is owed: the subject is imported byrelative path from inside its own package, and that is measured (the pre- and post-fix
runs of the same harness differ with no package build between them), not assumed.
Predicted §1 red / §3 marked red / §2 green throughout. Measured 11 red, 6 green —
and the prediction was wrong about §2's granularity: two of §2's cases speak about the
marked side and must go red. The two genuinely fault-only cases (the byte-for-byte
envelope and the negative pin) stayed green, and those two are the real over-reach
bound. Recorded in the file header as measured rather than tidied.
Ruled sweep of the sibling wrappers — scanned, deliberately NOT fixed
Findings are filed by the PM, not absorbed here. Two shapes, over
packages/metadata-protocol/src/**non-test sources:Shape A — a wrapper that builds a new error around a caught one (sets
.cause): 4sites, 0 still mark-destroying after this PR.
metadataStoreUnavailableErroris nowreached only for unmarked failures; the other three carry. Inspected and not findings
because they structurally cannot carry a #9934 mark:
throwMetadataServiceUnavailable(its cause is built from a
string[], there is no thrown object) and the fourConflictError→METADATA_CONFLICTre-wraps (they wrap a repository-produced type,not application code). The remaining ~61
new Error+ status/code builders in thepackage are the protocol's own originating refusals — no caught error, nothing to lose.
Shape B — failure reported as response DATA, where no boundary can carry a
userMessage: 17 sinks, 2 carry the mark after this PR, 15 still drop it. Same shapeas
failed[], in nine other methods:protocol.ts:1698toRowApiError(batch rowmessage)protocol.ts:14708,:14738,:14840migrateStoredMetadata(reason)protocol.ts:15575runPublishSideEffectsprotocol.ts:15683applySeedBodiesprotocol.ts:16599,:16611,:16747publishPackageDraftsprotocol.ts:16884discardPackageDraftsprotocol.ts:17452,:17466,:17543duplicatePackageprotocol.ts:18385revertCommitprotocol.ts:18523rollbackToPackageCommitVerification
Run at
b68b09808, the final commit.pnpm --filter @objectstack/metadata-protocol test— 143 passed | 2 skipped (145)files; 2001 passed | 10 skipped (2011) tests. Branch-point baseline for the same
package: 144 files / 1984 tests.
pnpm --filter '...@objectstack/metadata-protocol' typecheck— the prefix form, i.e. the packages that depend on this one): 44 of 79 workspace
projects in scope, green, 0
error TS.pnpm check:type-check-debt—--re-measure: OK — 31 ledger entr(ies) re-measured in 412.0s, 1570 raw tsc error(s) total, none above its recorded number·surplus: none — every entry sits exactly at its measurement, so any new error is red.The new test file therefore added zero type errors, and this package's ledger note
shows its measurement does read
*.test.ts.pnpm check:engine-double-contract— green; the new doubles route throughassertEngineDeleteDispatch/assertEngineFindOnePredicate, and the gate asked forthe pinned ledger to learn about them by name (
--write, 2 rows added or grown, 0lost — purely additive, no baseline raised).
scripts/pm/dispatch-gates.mjsafter the last edit; the ledger commit pulled in seven families no earlier derivation
named (
agent-test-spelling,bash32-floor,cli-command-ids,entry-guard,parse-guard,pnpm-filter-targets,watch-hint-literal). All 30 path-derived plusthe convention-triggered families ran green.
scripts/pm/check-half-states.mjsexits 3 = NOT MEASURED (no GitHub credential inthis container) — not a red, and no reading either way.
pnpm lintwas not run locally; CI owns that run. Declared narrowing,not a claim.
Generated by Claude Code
Generated by Claude Code