Skip to content

fix(rest): the hand-built write routes answer a sandboxed crash and a declared-5xx refusal as the doors do (#22764) - #22844

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22764-rest-write-fault-withheld
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22764-rest-write-fault-withheld

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22764

Clause-②: no

The hand-built REST write routes now answer two sandboxed-hook failures the way the /data routes 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/main efcbac73c, at the route handler, through the pin harness of packages/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 declaring status: 503, code: 'SERVICE_UNAVAILABLE':

  • The reference. POST /data/:object and /createMany answer a crash 500 INTERNAL_ERROR Internal server error. They answer the declared 503 as 503 SERVICE_UNAVAILABLE Internal server error.
  • The card's rows, now 19. The overlay-discard row retired with its route after the card was filed.
    • The 14 sharing-rule, suggested-binding and approval rows answered a crash with 500 + the family code (RULE_*_FAILED, SUGGESTION_*_FAILED, APPROVAL_*_FAILED) and hook 'mz_guard' threw: TypeError: boom. They answered the declared 503 with the same 500 + family code and the wrapper.
    • POST /packages/publish answered a crash 500 INTERNAL_ERROR with the wrapper, and the declared 503 as 503 SERVICE_UNAVAILABLE with the hook's sentence.
    • The 4 external-datasource rows answered a crash 400 EXTERNAL_DATASOURCE_ERROR / 400 EXTERNAL_IMPORT_ERROR with the wrapper, and the declared 503 at the same 400 with the sentence.
  • Two more ledgered write rows, same defect (measured in the same pass over every driven row):
    • POST /analytics/dataset/query answered a crash 500 ANALYTICS_QUERY_FAILED with the wrapper. Its declared-503 answer already matched the reference.
    • POST /security/explain answered both shapes 500 EXPLAIN_FAILED with the wrapper.
  • Every other driven write row already matched the reference. The only exception is the record-share pair, which withholds the text but keeps its own 500 SHARE_*_FAILED (see Acceptance notes).

The repair: one shared answer, re-dressed per family

error-response.ts gains sandboxFaultAnswer(error). It is the classification door's own answer (resolveErrorResponse, the same one sendThrownError / handleRouteError send) for a sandbox-origin error that crashed or declared a 5xx, and it answers undefined for anything else. It is not a second fault implementation. It is the counterpart of classifiedRefusalAnswer, 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: nestedRefusalEnvelope for 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.

rows crash, after declared 503, after
all 19 card rows, POST /security/explain 500 INTERNAL_ERROR, Internal server error 503 SERVICE_UNAVAILABLE, Internal server error
POST /analytics/dataset/query 500 INTERNAL_ERROR, Internal server error (its flat { code, error }) unchanged (already the reference)

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 } }. A userMessage and a demoted declaredCode ride the answer, as at the doors. The same arms also serve GET /sharing/rules, GET /sharing/rules/:idOrName, GET /security/suggested-bindings and GET /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 in packages/spec moves. 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/query crash arm, POST /security/explain 500 arm) are repaired here under the bounded in-place exemption. All four conditions hold:

    • ① The defect class is the same: a crashed sandbox body's native text, or a declared 5xx unwithheld, on a ledgered REST write row.
    • ② The repair is mechanical, and §6 pins its shape: the same helper and the doors' answer.
    • ③ The file is rest-server.ts, which this card's claim already holds. The claim's census records no open PR or other claim on packages/rest.
    • ④ The gate family is the same, with no new verification surface: §6 of the same pin.

    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 400 with the family's code: §5's declared-409 cases pass unchanged. The metadata door's 409 DESTRUCTIVE_CHANGE that external-import-destructive-remedy.dogfood.test.ts pins at 400 EXTERNAL_IMPORT_ERROR is 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-sharing and plugin-security source 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_LEDGER through §1, with both shapes, 88 cases (43 rows x 2 + the pinned reference + a map-membership check). Each case asserts:

    • the named downstream was called;
    • the answer's sentence equals the reference door's;
    • neither the native text (TypeError|boom, the withheld ledger sentence) nor the wrapper appears anywhere in the body;
    • status and code equal 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 a 200 / 201 and 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=efcbac73c restored the four source files in the tree only: the sandboxFaultAnswer counts went 2/6/3/3 to 0/0/0/0, and git status showed four M. 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 used git checkout HEAD --: all four blob hashes equal HEAD and git diff HEAD is empty, run under an EXIT INT TERM trap. There is no dist leg: every subject is a relative import inside packages/rest/src, so it resolves from source.

  • pnpm --filter @objectstack/rest test passed (272 files, 5333 passed, 326 skipped, lock VERDICT command-exit 0), at 1b89f1c70. It passed again at aeba43296, after merging origin/main and rebuilding the two touched closure members (lint, plugin-security).

  • pnpm --filter @objectstack/rest typecheck passed at aeba43296: tsc --noEmit plus check: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:repo passed at aeba43296: 5 files, 209 passed, 1 skipped. Three of those are the error-response-* door-parity pins.

Gates (at aeba43296)

  • 65 derived families (the 54 dispatch-time ones, plus 11 changeset families added by this diff's .changeset file), each run with its exit captured before any pipe: 65 exit 0. dispatch-gates --ran reconciles: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN".

  • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (no full build in this worktree). After a cached full turbo 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.

    • ① The population is read from eslint.config.mjs: files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'] minus the build dirs. All five .ts files of this diff are in it, since none reported "File ignored".
    • ② eslint --no-inline-config --format json on those five files gave 5 files, 0 errors and 0 warnings.
    • ③ Invariance: the config never enables type-aware linting (no parserOptions.project or projectService, 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 lint run is CI's.

Acceptance notes


Generated by Claude Code

… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 11 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/permission-sets.mdx (via /security/suggested-bindings/:id/dismiss (route, bridged from symbol sandboxFaultAnswer — its route source's handler names it))
  • content/docs/permissions/sharing-rules.mdx (via /sharing/rules/:idOrName/evaluate (route, bridged from symbol sandboxFaultAnswer — its route source's handler names it))
  • content/docs/permissions/system-context.mdx (via registerExternalDatasourceRoutes (symbol, a top-level function))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-7.mdx (via EXTERNAL_IMPORT_ERROR (literal, a string literal in registerExternalDatasourceRoutes))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 69d421805896d8d598bd27a0d035e494f7de6630 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f37138305e775c87ad0f71e7120eae5dddc94579 — the merge of head fdb8488408863479515df11cec26a52ca0c8f3a1 into base 69d421805896d8d598bd27a0d035e494f7de6630, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 69d421805896d8d598bd27a0d035e494f7de6630 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Addendum: merged origin/main at 69d421805, re-verified at fdb848840

Patch round on the coordinator's request. The PR stays a draft.

Merge. fdb848840 merges origin/main 69d421805 (18 commits past a6f3a13f2, the round-one base) into aeba43296. It is a merge commit, with no rebase and no force-push.

Approvals arm, re-read in the merged file. The order is unchanged at all four handleApprovalError call sites, which cover the nine write routes (approve, reject, recall, revise, resubmit, reassign, remind, request-info, comment):

  1. the prefix mapping;
  2. classifiedRefusalAnswer;
  3. sandboxFaultAnswer, which logs and then answers;
  4. return false, to the caller's outer 500 APPROVAL_*_FAILED.

The sharing-rule, suggested-binding, analytics and explain arms are also unchanged (five call sites of sandboxFaultAnswer in rest-server.ts, as before).

Ledger and the 6th section of the pin. rest-route-ledger.ts is byte-unchanged by the merge: 44 write rows before and 44 after (43 driven, 1 exempt). The 6th section is therefore 88 cases before and 88 after. That count is measured per section with the JSON reporter at fdb848840: sections 1 to 6 have 1 / 43 / 1 / 2 / 54 / 88 cases, all passed.

Rebuild. I ran pnpm install --frozen-lockfile because the lockfile moved, then rebuilt rest's dependency closure through turbo (24 of 24 tasks, @objectstack/spec included). A full turbo run build (73 of 73, 24 cached) supplied check:dual-build-cjs-loads.

Results at fdb848840. Each exit was captured before any pipe.

  • Pin rest-write-route-hook-refusal-sentence.ledger.test.ts: exit 0, Tests 189 passed (189).
  • pnpm --filter @objectstack/rest test: exit 0 (lock VERDICT command-exit 0), Test Files 273 passed (273), Tests 5333 passed | 326 skipped (5659). There is one file more than in round one: list-search-total-door.test.ts from fix(objectql,metadata-protocol,spec): a paged search reports the counted total #22857, with 6 cases. The total is unchanged because feat(plugin-approvals): an empty admin_rescue slate opens on the organization's administrators; retire the unstaffed arm #22850 cut the unstaffed section of rest-server-approvals-unknown-filter.test.ts from 8 cases to 2.
  • pnpm --filter @objectstack/rest typecheck: exit 0 (tsc --noEmit plus check:test-typecheck: OK).
  • Gates: the derivation at fdb848840 is the same 65 families as round one, and all 65 exited 0. dispatch-gates --ran reports "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN". check:dual-build-cjs-loads reports "107 published require entry point(s) across 66 package(s) load".
  • Lint narrowing, re-run: eslint.config.mjs and its two baseline inputs are unchanged by the merge, and the five .ts files of the diff give 5 files, 0 errors and 0 warnings.

PR body. Not edited. Its counts are stated against aeba43296 and remain true as stated.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 17:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 17:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit d952358 Oct 11, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22764-rest-write-fault-withheld branch October 11, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants