Repository navigation
fix(rest): the hand-built write routes answer a sandboxed crash and a declared-5xx refusal as the doors do (#22764) - #22844
Conversation
… doors do on the hand-built write families Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
… a declared-5xx refusal at the doors' answer Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…nd-built write routes Claude-Session: https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4 Co-authored-by: Claude <noreply@anthropic.com>
…st-write-fault-withheld
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 17 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 f37138305e775c87ad0f71e7120eae5dddc94579 && git checkout f37138305e775c87ad0f71e7120eae5dddc94579
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 69d421805896d8d598bd27a0d035e494f7de6630 fdb8488408863479515df11cec26a52ca0c8f3a1 && git checkout -B drift-repro 69d421805896d8d598bd27a0d035e494f7de6630 && git merge --no-ff fdb8488408863479515df11cec26a52ca0c8f3a1
node scripts/docs-audit/affected-docs.mjs --json 69d421805896d8d598bd27a0d035e494f7de6630
|
…st-write-fault-withheld
Addendum: merged
|
Fixes #22764
Clause-②: no
The hand-built REST write routes now answer two sandboxed-hook failures the way the
/dataroutes do: a hook body that crashed, and a hook refusal that declared a server-band status. Both are faults, and the text is withheld. A hook's refusal still keeps its sentence on every row.What was wrong (re-measured before any edit)
Measured at
origin/mainefcbac73c, at the route handler, through the pin harness ofpackages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts. The two shapes were a crashed body (.innerMessage=TypeError: boom) and a sandboxed refusal declaringstatus: 503, code: 'SERVICE_UNAVAILABLE':POST /data/:objectand/createManyanswer a crash500 INTERNAL_ERRORInternal server error. They answer the declared 503 as503 SERVICE_UNAVAILABLEInternal server error.500+ the family code (RULE_*_FAILED,SUGGESTION_*_FAILED,APPROVAL_*_FAILED) andhook 'mz_guard' threw: TypeError: boom. They answered the declared 503 with the same500+ family code and the wrapper.POST /packages/publishanswered a crash500 INTERNAL_ERRORwith the wrapper, and the declared 503 as503 SERVICE_UNAVAILABLEwith the hook's sentence.400 EXTERNAL_DATASOURCE_ERROR/400 EXTERNAL_IMPORT_ERRORwith the wrapper, and the declared 503 at the same400with the sentence.POST /analytics/dataset/queryanswered a crash500 ANALYTICS_QUERY_FAILEDwith the wrapper. Its declared-503 answer already matched the reference.POST /security/explainanswered both shapes500 EXPLAIN_FAILEDwith the wrapper.500 SHARE_*_FAILED(see Acceptance notes).The repair: one shared answer, re-dressed per family
error-response.tsgainssandboxFaultAnswer(error). It is the classification door's own answer (resolveErrorResponse, the same onesendThrownError/handleRouteErrorsend) for a sandbox-origin error that crashed or declared a 5xx, and it answersundefinedfor anything else. It is not a second fault implementation. It is the counterpart ofclassifiedRefusalAnswer, which answers the refusal half and declines every 5xx.Each family asks it after its refusal arm and before its own 500 terminal. It re-dresses the answer in the family's own envelope:
nestedRefusalEnvelopefor the nested families, the flat body for analytics. It logs the original error first, because the operator is now the only reader of the native text. The publish and external-datasource doors gained that log line. The approvals helper logs inside itself, because answering skips the outer arm's log.POST /security/explain500 INTERNAL_ERROR,Internal server error503 SERVICE_UNAVAILABLE,Internal server errorPOST /analytics/dataset/query500 INTERNAL_ERROR,Internal server error(its flat{ code, error })Envelopes: sharing-rule, approvals, explain, publish and external use the shared nested
{ success: false, error: { code, message } }. Suggested bindings use their family's{ error: { code, message } }. AuserMessageand a demoteddeclaredCoderide the answer, as at the doors. The same arms also serveGET /sharing/rules,GET /sharing/rules/:idOrName,GET /security/suggested-bindingsandGET /datasources/:name/external/tables, so those reads answer the two shapes the same way.Codes. PM assumption 3 asked whether a family keeps its own code on a fault. Following the card's direction ("as the data doors do"), these two shapes now answer the doors' code. Every family code stays live for every other fault, and §5 of the pin still holds the 19 rows' plain-fault terminal at
RULE_*_FAILED/APPROVAL_*_FAILED/SUGGESTION_*_FAILED/INTERNAL_ERROR/EXTERNAL_*. So the ledger entries (content/docs/references/api/error-code-ledger.mdx, auto-generated) keep their emitters and nothing inpackages/specmoves. No test outside this pin asserted a family code on either sandbox shape: the full rest suite is green with no edit to any other test.Scope
The two extra rows (
POST /analytics/dataset/querycrash arm,POST /security/explain500 arm) are repaired here under the bounded in-place exemption. All four conditions hold:rest-server.ts, which this card's claim already holds. The claim's census records no open PR or other claim onpackages/rest.File-surface supplement to the claim: the analytics dataset-query fault arm and the security-explain fault arm, both in
rest-server.ts. Without them, §6 could not enumerate the ledger's write rows by construction, which the triage grade's pin asks for.Fence held (the declared-4xx question, options A/B/C). Nothing here moves a declared-4xx answer. The helper is sandbox-scoped and answers only a crash or a declared 5xx. On the four external rows a sandboxed refusal declaring a 4xx, and a non-sandbox declared 4xx, still answer
400with the family's code: §5's declared-409 cases pass unchanged. The metadata door's409 DESTRUCTIVE_CHANGEthatexternal-import-destructive-remedy.dogfood.test.tspins at400 EXTERNAL_IMPORT_ERRORis a non-sandbox throw and does not reach the helper. That is option A, the status quo.Not widened to non-sandbox declared 5xx. A service's own declared 5xx keeps each route's terminal. On publish, that terminal's prose withhold is byte for byte its dispatcher twin's heuristic. A census of
plugin-approvals,plugin-sharingandplugin-securitysource finds no declared 5xx.service-package's one (packageSeamUnreadableError, 503) is a seat refusal answered by that twin rule, so it is left alone.Tests
The pin, §6 (new): every write row §2 drives, enumerated from
REST_ROUTE_LEDGERthrough §1, with both shapes, 88 cases (43 rows x 2 + the pinned reference + a map-membership check). Each case asserts:TypeError|boom, the withheldledgersentence) nor the wrapper appears anywhere in the body;statusandcodeequal the reference's (500 INTERNAL_ERROR/503 SERVICE_UNAVAILABLE, pinned literally on the reference case), except the record-share pair, which is named with its reason. The two import rows answer per row inside a200/201and are held to the reference sentence.§2 remains the control: a refusal keeps its sentence on the same rows.
Ablation at
92aee9407(fix and pin committed first).git restore --source=efcbac73crestored the four source files in the tree only: thesandboxFaultAnswercounts went 2/6/3/3 to 0/0/0/0, andgit statusshowed fourM. The pin then ran 41 failed / 148 passed (189). The 41 are exactly 19 rows x 2 shapes + the analytics crash + the explain crash and declared 503, and no other section went red. The restore usedgit checkout HEAD --: all four blob hashes equalHEADandgit diff HEADis empty, run under anEXIT INT TERMtrap. There is no dist leg: every subject is a relative import insidepackages/rest/src, so it resolves from source.pnpm --filter @objectstack/rest testpassed (272 files, 5333 passed, 326 skipped, lockVERDICT command-exit 0), at1b89f1c70. It passed again ataeba43296, after mergingorigin/mainand rebuilding the two touched closure members (lint,plugin-security).pnpm --filter @objectstack/rest typecheckpassed ataeba43296:tsc --noEmitpluscheck:test-typecheck: OK. The pin file is in the test program (tsc -p tsconfig.test.json --listFilesOnly: 1 hit, 277 test files).pnpm --filter @objectstack/rest test:repopassed ataeba43296: 5 files, 209 passed, 1 skipped. Three of those are theerror-response-*door-parity pins.Gates (at
aeba43296)65 derived families (the 54 dispatch-time ones, plus 11 changeset families added by this diff's
.changesetfile), each run with its exit captured before any pipe: 65 exit 0.dispatch-gates --ranreconciles: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN".check:dual-build-cjs-loadsfirst answeredPREREQUISITE NOT MET(no full build in this worktree). After a cached fullturbo run build(72 of 73 cached), it reported "107 published require entry point(s) across 66 package(s) load".pnpm lint: proven narrowing, not the full run.eslint.config.mjs:files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minus the build dirs. All five.tsfiles of this diff are in it, since none reported "File ignored".eslint --no-inline-config --format jsonon those five files gave 5 files, 0 errors and 0 warnings.parserOptions.projectorprojectService, no typed rules). Its local rules are per-file AST rules, and its only disk reads are two baseline JSONs at config load, which this diff does not touch. So the diff cannot move any untouched file's verdict.The full
pnpm lintrun is CI's.Acceptance notes
POST /data/:object/:id/shares,DELETE /data/:object/:id/shares/:shareId). For both sandbox fault shapes it answers500 SHARE_*_FAILEDwithInternal server error: the text is withheld (sharingFaultMessage), but the status and code are the family's own. Its comments record the fold of a declared 5xx into that 500 as deliberate (rest: the record-share routes classify refusals bymessage.startsWith(CODE)and interpolateerror.messageinto a hand-built 500 — a declaredstatus/codeis ignored and the sandbox wrapper reaches the client #11683, [finding] share 家族在两个绕过分类 re-dress 的出口上仍丢弃 producer 的userMessage—— 500 故障终端与 ADR-0111 前缀臂 #12693). It is not one of the card's rows, so §6 names it inOWN_FAULT_TERMINALwith that reason instead of converging it. It is noted, not filed.500(400on external). §5 pins this, as doessharing-envelope.test.tsfor the record-share family, and the arms' comments record it as deliberate. The data doors withhold an unclassified fault's text instead. This card's direction names the two sandbox shapes only, so nothing moved. It is noted, not filed.Generated by Claude Code