Uh oh!
There was an error while loading. Please reload this page.
fix(runtime): withhold the message of every declared 5xx at the dispatcher exit (#12281) - #13240
fix(runtime): withhold the message of every declared 5xx at the dispatcher exit (#12281)#13240os-trump wants to merge 1 commit into
Conversation
…tcher exit (#12281) `errorResponseBase` gated its 5xx message withhold on `declaresServerFault` (`status >= 500` AND a non-empty string `code`), while `/data` gates on `declaredHttpStatus` (`status ?? statusCode`, `code` not consulted). Two bands of declared 5xx were withheld one door over and shipped their prose here: one carrying no `code` at all, and one merely spelling `statusCode` despite being fully ADR-0112-compliant. Maintainer ruling 2026-08-27 on #12509 (option D): adopt the structural withhold for every declared 5xx message, aligning to `/data`. The judgement is inherited rather than re-derived -- the door now reads `serverFaultProvenance` from `@objectstack/types`, the same function `demotedDeclaredCode` already reads for the code channel. The gate is the DECLARED status, never the resolved `httpStatus`, so #5667's undeclared-5xx tiering is preserved: a bare `Error` stays legible and still goes through the `looksLikeInternalErrorLeak` heuristic alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 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 7126b8045104d680c37ced0a1595abfab7ffcbbc && git checkout 7126b8045104d680c37ced0a1595abfab7ffcbbc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e0ba21a1bf863c44e341fdad1bbecb755d450c2 3247011623d45f18efcd5bd2ed14139502c38217 && git checkout -B drift-repro 9e0ba21a1bf863c44e341fdad1bbecb755d450c2 && git merge --no-ff 3247011623d45f18efcd5bd2ed14139502c38217
node scripts/docs-audit/affected-docs.mjs --json 9e0ba21a1bf863c44e341fdad1bbecb755d450c2 |
os-trump
commented
Aug 29, 2026
⛔ PM — barred from the merge queue by the clause-② gate (not a defect, no rework owed)This PR is not being armed, and the reason is procedural rather than anything wrong with the change. Recording it here so the bar is visible on the card and nobody re-derives it. The trigger, read from this PR rather than recalled. The body's second line is:
That is the declaration limb, which is path-independent — it fires on the author's own declaration regardless of whether the diff touches The fuse, measured against observed state.
The comparison is against Action taken: hung What this is NOT
What releases itEither of, and only:
Two housekeeping notes for whoever picks this up
Generated by Claude Code |
Fixes#12281
Clause-②: yes — this changes an answer on a public REST door. The contract-review gate attaches to this PR.
Implements the maintainer's 2026-08-27 ruling on #12509 (option D), propagated verbatim to this card:
What was wrong
errorResponseBase(packages/runtime/src/dispatcher-plugin.ts) gated its 5xx message withhold ondeclaresServerFault—status >= 500and a non-empty stringcode./datagates ondeclaredHttpStatus, which readsstatus ?? statusCodeand never consultscode. Two bands of declared 5xx were therefore withheld at/dataand legible here:code— the card's title case. The structural half of/analytics/query仍把 RLS 策略字段名回显给调用方 —— read-scope 拒收的泄漏在姐妹面上没堵,#5367 只堵了 dataset 路由 #5811's withhold required a code, so the no-code half of the declared band fell back tolooksLikeInternalErrorLeakalone: the phrasing heuristic/analytics/query仍把 RLS 策略字段名回显给调用方 —— read-scope 拒收的泄漏在姐妹面上没堵,#5367 只堵了 dataset 路由 #5811's own argument found insufficient, which is why the withhold was made structural in the first place.statusCodespelling — wider, and not named in the card body. A producer declaring{ statusCode: 503, code: 'SERVICE_UNAVAILABLE' }is fully ADR-0112-compliant and was still withheld at/dataand legible here, purely becausedeclaresServerFaultread thestatuskey only.Option D closes both. They are the same file, the same ternary and the same ruling, so they are not split across two cards.
The change
One condition, inherited rather than re-derived:
serverFaultProvenanceis the function #12946 landed in@objectstack/typesfor exactly this — its own docblock names this card as its'declared'limb and says "this function is the shape it will read rather than a second copy it would have to grow".demotedDeclaredCodealready reads the same function for the code channel, so both channels at this door now read one judgement. ⛔ No per-door variant was written: that is the divergence this family has been repaired for twice.resolveThrownHttpError(err, 500)was already called in this function for the code channel; it is now resolved once, above the ternary, so the prose rule and the code rule cannot look at different errors.FROM → TO on the wire
For any route served by the dispatcher plugin (
/analytics,/auth,/i18n,/automation,/notifications,/mcp,/packages):{ status: 503 }, nocode"message":"PRODUCER PROSE""message":"Internal server error"{ statusCode: 503, code: 'SERVICE_UNAVAILABLE' }"message":"PRODUCER PROSE""message":"Internal server error"{ statusCode: 503 }, nocode"message":"PRODUCER PROSE""message":"Internal server error"{ status: 503, code: 'SERVICE_UNAVAILABLE' }"message":"Internal server error"Error(declares nothing)"message":"PRODUCER PROSE""message":"PRODUCER PROSE"Only
messagemoves.code,httpStatus,declaredCodeanddetailsare untouched, so nothing a machine branches on changes, and the untouched error still reaches the operator via__obsRecordedErrorand the log.⛔ The gate is the declared status, never the resolved
httpStatus(which falls back to 500 for a throw that declared nothing). #5667's undeclared-5xx tiering is preserved exactly — that was the one way this change could have done real harm, and it is pinned in both directions.This is a no-op on today's tree
The R39 measurement on this card, accepted by the PM seat, established the population reaching this door is empty:
metadata-protocol'sdeleteMetaItemreaches only the REST/metadoor (the dispatcher plugin mounts neither/metanor/data), andaction-execution.ts's sevenstatusCodethrows are all caught before this exit. That is precisely why now was the cheapest moment: the alignment costs no legibility that exists and buys the invariant forward.POST /api/v1/analytics/queryroute rather than asserting the predicate.Tests
New:
packages/runtime/src/dispatcher-plugin.declared-5xx-prose-withhold.test.ts— 7 declared shapes (both axes, both axes together, the no-regression case, both live producer sentences, a 504) plus 4 controls.Two sibling pins were deliberately reversed, each of which asserted the pre-ruling behaviour:
dispatcher-5xx-demoted-code-withhold.test.ts— the pin fix(types): withhold a demoteddeclaredCodeon an undeclared 5xx — one rule, every door inherits (#12509) #12946 left for this card, whose own comment read "When runtime: a declared 5xx carrying NOcodekeeps its prose on/analytics/querywhere/datawithholds it unconditionally #12281 lands this expectation flips to the generic sentence — deliberately pinned so that lands as a CHANGE rather than as drift nobody sees." This is that landing. A third case was added there pinning that the two axes stay independent.analytics-query-read-scope-withhold.test.ts— "a 5xx with only HALF an envelope stays readable — a code is required, not just a status". Its rationale ("a producer that ships a status without a code has not declared anything") is exactly what option D overturns.Full
@objectstack/runtimesuite on32470116: 201 files / 2968 tests passed,VERDICT command-exit 0.Reverse verification
Direction predicted before running, then observed. Restoring the old predicate turned exactly 8 tests red: the 6 declared cases whose band widened, plus the 2 reversed sibling pins. The
{ status: 503, code }case and all 4 controls stayed green — so the pins discriminate rather than always-fail.Mutation proven on disk by blob hash (
cce89f61→66afa5ba) and by grep counts on the exact injected and removed text, never by an exit code; restore proven by blob hash returning tocce89f61withgit diff HEADempty. The mutation script carried atrap … EXIT INT TERMwith absolute paths.Gates
Re-derived for the actual change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(it read the change set from git itself: 5 paths vs merge basea81aa9dd5). The derivation added families the dispatch list did not name —check:objectql-double-limit,release-rehearsal-clone --self-test, and six convention-triggered ones includingcheck:engine-double-contract,check:where-matcherandcheck:type-check-debt. All run on32470116, exit codes captured before any pipe:Green (31):
check:nul-bytes·check:route-envelope·check:test-source-alias·check:type-source-resolution·check:published-files·check:slot-lookup·check:page-declaration-shape·check:logger-receiver-detach·check:objectql-double-limit·check:cross-package-test-inputs·check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:keyed-text-bounds·check:undeclared-dep-imports·check:changeset-gate-self-tests·check:objectui-changeset·check:pm-half-states·check:type-check-coverage·check:type-check-debt·check:dual-build-cjs-loads·check-comment-mask-adoption·check-plugin-teardown-shape·check-ci-filter-parity·check-shard-attestation· pluscheck-adr-0087-registration,check-changeset-no-major,check-empty-changeset,release-rehearsal-clone --self-test,docs-audit/check-affected-docs,docs-audit/check-drift-comment.check:type-check-debtreported "surplus: none — every entry sits exactly at its measurement, so any new error is red", i.e. the new test file added zero to the ratchet.check:dual-build-cjs-loadsneeded a fullpnpm build(71/71 tasks green) and then passed: 102 require entry points across 66 packages load.NOT MEASURED (2) — each refused to run and says so in its own verdict text; neither is a pass and neither is a red:
check-test-completeness— "PREREQUISITE NOT MET … grades a savedturbo run testlog, and no log was named", exit 3.scripts/pm/check-half-states.mjs— needs a realGITHUB_TOKEN; this container has the proxy placeholder, exit 3.Lint: a declared narrowing, with its three pieces of evidence
Repo-wide
pnpm lintis CI's run. Narrowed to the 4 lintable changed files: 0 errors, 0 warnings, and the narrowing is a measurement rather than a skip because —--format json, not from console prose;eslint.config.mjsdeclares at its line 328, with a recorded positive control, that this repo "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file, test or not" ⇒ this diff cannot move the verdict on any file it did not touch.packages/runtimetypecheckis green, and its tsconfigexcludes**/*.test.ts— verified with--listFiles(dispatcher-plugin.ts1 hit, the new test file 0). So that green is a true statement about the product change and says nothing about the test files; those were typechecked separately under a widened config, where all four touched files contributed 0 errors.Fences honoured
packages/types/src/error-leak.tsandpackages/rest/src/error-response.ts— read only, not edited.packages/runtime/src/http-dispatcher.ts([Decision] Fail-close isAuthGateAllowlisted's "no path ⇒ exempt" default (Option 2 of #7432) #7898's on-hold trigger file) was read only. It is not in the diff. The repair did not reach it.Two findings, filed not fixed
errorResponseBasenever populatesuserMessage, thoughApiErrorSchema.userMessageis declared in spec andbuildApiErroralready supports it. The ruling namesuserMessageas the author-facing channel that compensates for the withheld prose — at this exit that channel is absent, while its caught-path sibling (http-dispatcher.tserrorFromThrown) and/databoth carry it. Not fixed here: adding a wire field is a separate additive public-door increment deserving its own review, and the empty population means nothing loses text today.declaresServerFault's last production consumer; it now survives as a definition plus its own tests in the fencedpackages/types/src/error-leak.ts.Generated by Claude Code