Uh oh!
There was an error while loading. Please reload this page.
fix(rest): relay a producer-declared 5xx on /analytics/dataset/query instead of collapsing it to 500 ANALYTICS_QUERY_FAILED - #12280
Conversation
`POST /api/v1/analytics/dataset/query` collapsed every producer-declared 5xx onto a hand-built `500 ANALYTICS_QUERY_FAILED`, where `POST /data/:object` relays the declared status and ADR-0112 code and withholds only the prose (#5582). Measured door-to-door on one error object, a declared `{ status: 503, code: 'SERVICE_UNAVAILABLE' }` answered `503 SERVICE_UNAVAILABLE` on `/data` and `500 ANALYTICS_QUERY_FAILED` here. `/data` is the reference and does not move. Its 5xx arm is lifted into `declaredServerFaultAnswer` and read by both doors, the way the 4xx arm already imports `classifiedRefusalAnswer` — a third local opinion at this boundary is how the two faces came to disagree. The sibling analytics face `/analytics/query` already relayed both halves, so this door was the only one of three overwriting a producer's declaration. The prose withhold is untouched (#5352/#5367/#5811) and `logError` still runs before the relay branch, so a declared status cannot buy a producer past the operator's log. An undeclared fault keeps `500 ANALYTICS_QUERY_FAILED` and #5667's tiering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
📓 Docs Drift CheckThis PR changes 1 package(s): 7 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 — 13 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 464c5e3aa01977427255e0857b82610867fa002f && git checkout 464c5e3aa01977427255e0857b82610867fa002f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 33e81a5fe0edc80ea75dceb685965a193a834720 ce146ab285c49a68d6aaca4562316195ee79181f && git checkout -B drift-repro 33e81a5fe0edc80ea75dceb685965a193a834720 && git merge --no-ff ce146ab285c49a68d6aaca4562316195ee79181f
node scripts/docs-audit/affected-docs.mjs --json 33e81a5fe0edc80ea75dceb685965a193a834720
|
os-trump
commented
Aug 25, 2026
Contract-review verdict: PASS (#11718) — option A endorsedReviewed by the contract-review chain at Reviewed: the source patches ( "Is any consumer keyed on the literal On the A/B fork: A (relay status AND code, byte-identical to Verified in the diff: Landing completed by the chain in the same stroke per the maintainer's standing instruction (2026-08-25, verbatim: 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」): clearing Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11718
POST /api/v1/analytics/dataset/querycollapsed every producer-declared 5xx onto a hand-built500 ANALYTICS_QUERY_FAILED.POST /api/v1/data/:objectrelays the declared status and ADR-0112codeand withholds only the prose (#5582). Measured door-to-door, one error object, both handlers driven in process — this is §8f's own failure text from the ablation below:The reference held — and this door was the only one of three
Zone 2 asked me to verify rather than assume that
/datais the correct reference. It is, and the check turned up a third door that settles it:POST /data/:objectPOST /analytics/query(sibling)dispatcher-plugin.errorResponseBasePOST /analytics/dataset/queryThe sibling analytics face already relays both halves — pinned end to end against a real
AnalyticsServiceinpackages/runtime/src/analytics-query-read-scope-withhold.test.ts, which asserts a read-scope refusal arrives as500+READ_SCOPE_COMPILE_FAILED. So/analytics/dataset/querywas the sole deviant, and it deviated only because it built its 5xx body by hand. No fork to report.The change
/data's 5xx arm is lifted intodeclaredServerFaultAnswer(error-response.ts) and read by both doors — the way arm ①b already importsclassifiedRefusalAnswer. A third local opinion at this boundary is exactly how the two faces came to disagree, so the arm is imported, not restated./data's behaviour does not move; its own pins prove it (2357 tests, below).Arm ③ splits: ③a a declared 5xx → relayed; ③b everything else → unchanged
500 ANALYTICS_QUERY_FAILEDwith #5667's tiering.⛔ Not a re-opening of #5352/#5367/#5811. Those rule the prose, and the prose is still withheld — byte-identically, from the same shared arm. The old pin's stated reason for the collapse was that a producer must not "slip past the
logErrorline that makes it visible to operators"; that concern is answered, not overruled —logErrorruns before the relay branch and is unconditional, and the inverted pin now asserts the operator still gets the full original text.Contract change (⚠️
needs:contract-review){ status: 503, code: 'SERVICE_UNAVAILABLE' }→503 SERVICE_UNAVAILABLE(was500 ANALYTICS_QUERY_FAILED)/datademotes it:503 {"code":"SERVICE_UNAVAILABLE","declaredCode":"WAREHOUSE_UNAVAILABLE"}(finding:packages/rest's flatsendThrownErrorstill puts a thrown error'scodeon the wire un-narrowed — ADR-0112's closure does not reach that door #9232) — measured, not assumedread-scope-sql's ten RLS refusals now answer500 READ_SCOPE_COMPILE_FAILED, not500 ANALYTICS_QUERY_FAILED. Called out because the card flagged it as load-bearing. The 2026-08-06 ruling is untouched in substance — still a SERVER fault, still500, still with the policy content withheld from the body and intact in the log, still not the400 DATASET_INVALIDit was rescued from (all four still asserted). What changed is that this route stopped overwriting the producer's declaration with a code of its own. That overwrite was never ruled; it is what the hand-built envelope happened to emit, and it made one fault answer to two different names depending on which analytics door the caller used. This converges on the sibling face's shipped answer.Verification
Local, at
ce146ab2:pnpm --filter @objectstack/rest test— 146 files / 2357 tests passed,os-verify-lock: VERDICT command-exit 0pnpm --filter @objectstack/rest typecheck—VERDICT command-exit 0node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the actual change set (7 paths), not the dispatch listAblation — the pins fail without the fix
Predicted before running: 10 RED. Measured: 10 RED, exactly that set.
Mutation was the relay branch only (source change reverted, pins kept), confirmed on disk with anchored greps in both directions before any result was read — injected text
1 → 0, ablation marker0 → 1,git diff --statnon-empty — and restored undertrap … EXIT INT TERM, withgit status --porcelainempty afterwards proving byte-identical restore. No rebuild leg applies: the pins import./rest-server.js/./error-response.jsrelatively, so vitest resolves package source, neverdist/.RED without the fix: §8c, §8f, 5× read-scope envelope, 2× filter envelope, 1× dimension-gate.
⭐ Named as regression guards, not red-before evidence — green in both states: §8e (the client-band parity loop, already green before this change) and §8g (new positive control over unchanged ③b behaviour).
§8f is flipped, not deleted — it recorded a measured defect and that record is the only evidence the defect existed, so the assertion is turned around and the reasoning kept, the way §8b was handled for #11684. It now drives both doors with one refusal and asserts they agree on the values the producer declared (status and code and the
declaredCodedemote), plus explicitlynot.toBe(500)/not.toBe('ANALYTICS_QUERY_FAILED')— because "both are 5xx with a code" was already true of the defect.Notes
needs:contract-reviewis cleared by the review chain. Fuse measured —last_served_modelclaude-opus-5≠CONTRACT_REVIEW_TIERclaude-fable-5.rest-server.tsis +52 lines, all inside the one catch, for the four cards queued behind this file.Generated by Claude Code