Skip to content

rest: the 20 REST write routes answer a sandboxed hook's refusal in the hook's own words - #22763

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22719-rest-write-hook-sentence
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22719-rest-write-hook-sentence

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22719

Clause-②: no

What this does

Twenty write routes in @objectstack/rest built their error answer by hand from .message. For a sandboxed hook's refusal, .message is the QuickJS debug wrapper (hook 'NAME' threw: Error: SENTENCE), so these routes shipped the wrapper while the other 24 write doors answer the hook's own sentence. Each family now answers through the shared read the conforming doors use (triage 6101568631), with no local unwrap and no per-route text:

  • Sharing rules (3), suggested bindings and the overlay discard (3), approvals (9), in rest-server.ts. Each family's own arms run first, unchanged. Where its 500 arm used to relay .message, it now asks classifiedRefusalAnswer first. That is the /data door's classification, and it reads sandboxBusinessMessage. The answer is re-dressed in the nested envelope through the family's own emitter. The re-dress is one function, nestedRefusalEnvelope in error-response.ts. It applies the same derivation the record-share family's respondSharingError applies inline: code falls back to the catalog's floor for the status, and declaredCode and userMessage come from the classification.
  • POST /packages/publish (1), in package-routes.ts. This door runs the dispatcher twin's rule (resolveThrownHttpError). It now takes the status it falls back to from classifiedRefusalAnswer, and the sentence from sandboxBusinessMessage. code, declaredCode, userMessage and details stay this door's own mapping, so nothing it already answered moves.
  • External datasources (4), in external-datasource-routes.ts. Both catch arms read the sentence through sandboxBusinessMessage. Status and code do not move (see "Not chosen" below).

The pin's ANSWERS_THE_WRAPPER map is empty. Its 20 rows sit in ANSWERS_THE_SENTENCE, each pinned at the status and code it answers, and §3 now holds the map empty.

Rows whose status or code changed (a sandboxed refusal that declares nothing)

row before after
POST /api/v1/sharing/rules 500 RULE_DEFINE_FAILED, flat 400 VALIDATION_ERROR, nested
DELETE /api/v1/sharing/rules/:idOrName 500 RULE_DELETE_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/sharing/rules/:idOrName/evaluate 500 RULE_EVALUATE_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/security/suggested-bindings/:id/confirm 500 SUGGESTION_CONFIRM_FAILED 400 VALIDATION_ERROR
POST /api/v1/security/suggested-bindings/:id/dismiss 500 SUGGESTION_DISMISS_FAILED 400 VALIDATION_ERROR
POST /api/v1/security/permission-sets/:id/discard-overlay 500 INTERNAL 400 VALIDATION_ERROR
POST /api/v1/approvals/requests/:id/approve 500 APPROVAL_APPROVE_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/reject 500 APPROVAL_REJECT_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/recall 500 APPROVAL_RECALL_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/revise 500 APPROVAL_REVISE_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/resubmit 500 APPROVAL_RESUBMIT_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/reassign 500 APPROVAL_REASSIGN_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/remind 500 APPROVAL_REMIND_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/request-info 500 APPROVAL_REQUEST_INFO_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/approvals/requests/:id/comment 500 APPROVAL_COMMENT_FAILED, flat 400 VALIDATION_ERROR, nested
POST /api/v1/packages/publish 500 INTERNAL_ERROR 400 VALIDATION_ERROR
the 4 external-datasource rows 400 EXTERNAL_DATASOURCE_ERROR / EXTERNAL_IMPORT_ERROR status and code unchanged

On every row the message is now the hook's sentence instead of the wrapper. VALIDATION_ERROR is standardErrorCodeForHttpStatus(400), the same code the record-share family answers for an undeclared sandboxed refusal. A refusal that declares its own code keeps it, and an unregistered spelling rides declaredCode. A declared userMessage rides along too. §5 pins both on the 16 re-dressed rows. "Flat" means { code, error: 'TEXT' }, and "nested" means { success: false, error: { code, message } }. Security's own emitter writes { error: { code, message } } on every arm, and still does.

Ride-along (triage 6101568631: no decision owed)

On the 15 sharing-rule, security and approval rows, a non-sandbox refusal can declare its own status (or statusCode) and code without matching any route-specific arm. It is now answered at its declared status in the nested envelope, where it used to get 500 FAMILY_CODE. §5 pins this with a declared 409 RESOURCE_CONFLICT. POST /packages/publish already answered a declared status, so it is unchanged.

These are unchanged, and §5 pins them on all 20 rows: a plain fault keeps each route's own terminal (its status, its code and its words). The route-specific arms run first and answer exactly as before. Those are the sharing-rule prefixes, the approvals mapping and the security typed arm.

Not chosen: the external-datasource status (dispatch assumption 5)

These four routes answer every refusal at 400 with their own code. Adopting the shared classification there would change a 4xx domain answer. The import route relays the metadata door's declared 409 DESTRUCTIVE_CHANGE as 400 EXTERNAL_IMPORT_ERROR, which external-import-destructive-remedy.dogfood.test.ts pins, and the classification would answer 409. So these rows take the shared read only. One consequence remains: a sandboxed refusal that declares, for example, 409 answers 400 EXTERNAL_* here with the correct sentence, where the conforming doors answer 409. Reported, not chosen.

Verification (head 2525cfcf6, after merging origin/main 7aa8d51c0)

  • The pin (rest-write-route-hook-refusal-sentence.ledger.test.ts, --project local): 105 passed (§1 1, §2 44, §3 1, §4 2, §5 57).
  • Reverse verification. The fix was committed first (b90ead477). Then the four sources were restored from the base e7e6544ab into the working tree only. On-disk proof: the nestedRefusalEnvelope / refusalSentence / classifiedRefusalAnswer counts in the reverted files read 0. The pin then went red on 51 of 105 cases: the 20 moved §2 rows, the 15 declared-409 ride-along cases and the 16 declared-channel cases. The other 54 stayed green: the 24 original §2 rows, the 20 plain-fault cases, the 5 publish and external declared-409 cases, §1, §3, §4 and §5's roster case. The direction was red, as expected. Restoring used git checkout HEAD, with a trap. After that, git hash-object matched the HEAD blob for all four files and git diff HEAD was empty.
  • @objectstack/rest: pnpm --filter @objectstack/rest test (the local project) gave 272 files, 5239 passed and 326 skipped. test:repo gave 5 files, 191 passed and 1 skipped. typecheck (tsc --noEmit plus check:test-typecheck) was green. Earlier, both projects together at 150c886a6 gave 277 files, 5373 passed and 327 skipped.
  • Gates. dispatch-gates --commands was re-derived on this change: 65 families, the dispatch's 57 plus 8 that the changeset brings. All 65 were run at 2525cfcf6, and --ran reports 65 run, 0 NOT-MEASURED and 0 UNRUN. Selected verdict lines:
    • check:route-envelope: rest-server.ts holds at stringError 39 and siblingCode 54, so no new dialect site was added.
    • check:error-status-conformance: every derivable runtime status is documented.
    • check:issue-citations: 9 citations, all resolve.
    • check:nul-bytes: OK.
    • check-adr-0087-registration: no declared-breaking changeset.
  • Lint, as a proven narrowing. eslint --no-inline-config --format json ran over the 5 touched TS files: 5 results, 0 errors, 0 warnings, 0 ignored. The population is read from eslint's own config. The changeset .md matches no config object (--print-config answers undefined). Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, as its own note states). Its only cross-file reads are two baseline JSONs this diff does not touch. So the diff cannot change the verdict on any untouched file.

Acceptance notes

  • File surface. The claim names rest-server.ts for the publish answer, but that answer is built in package-routes.ts, which this PR edits. error-response.ts gains nestedRefusalEnvelope, which is not exported from the package index. Both files are in packages/rest. No public export, error code or key is added.
  • One re-dress, two spellings. The record-share family's respondSharingError keeps its inline copy of the derivation that nestedRefusalEnvelope states. Moving it onto the function is a byte-identical refactor of an arm outside this card's surface. Carrier: none.
  • Measured but not repaired: a sandboxed body these rows answer as a FAULT still relays the wrapper. Measured at 2525cfcf6 through this pin's harness, at the route handler:
    • A body that crashed (TypeError: boom) gets hook 'mz_guard' threw: TypeError: boom on all 20 rows: 500 with the family code on 16 rows, and 400 EXTERNAL_* on the 4 external rows.
    • A refusal that declared 503 gets 500 with the family code and the wrapper on the 15 rest-server rows. It gets 503 with the sentence on publish, and 400 with the sentence on the external rows.
    • The data doors answer both shapes as faults with the prose withheld (§4).
    • This is the fault-side sibling of this card, the shape §4 holds for the import doors. It is in the report for the seat.
  • Cross-lane. Merging origin/main at 7aa8d51c0 brought no edit to packages/rest from security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739's lane or any other lane.

Generated by Claude Code

…he hook's own words

Sharing rules, suggested bindings / overlay discard and approvals ask
classifiedRefusalAnswer before their 500 arm relays .message, re-dressed in
the nested envelope through nestedRefusalEnvelope. POST /packages/publish
takes its fallback status from the same classification and its sentence
from sandboxBusinessMessage. The external-datasource catch arms read the
sentence through sandboxBusinessMessage with status and code unmoved. The
pin's ANSWERS_THE_WRAPPER map is empty; its 20 rows sit in
ANSWERS_THE_SENTENCE with the status and code they answer.

Claude-Session: https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k
Co-authored-by: Claude <noreply@anthropic.com>
Section 5 drives the twenty repaired rows with a plain fault (each keeps its
own terminal), a declared 409 refusal (the three 500-arm families and the
publish door answer it at 409; external datasources keep their own 400) and
a sandboxed refusal carrying an unregistered code and a marked sentence
(declaredCode and userMessage ride the nested envelope).

Claude-Session: https://claude.ai/code/session_019SvPnd2bzECRNmAU9i6E4k
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 13 documentable anchor(s).

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/permission-sets/:id/discard-overlay (route, bridged from symbol nestedRefusalEnvelope — its route source's handler names it))
  • content/docs/permissions/sharing-rules.mdx (via /rules/:idOrName (route, a path literal in a comment on a changed line), /sharing/rules/:idOrName/evaluate (route, bridged from symbol nestedRefusalEnvelope — 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
  • 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 e86530088a137275d803db29cb7c3afbc0f9f10e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f1c23b9fc2ad596a82bd42ea7675bebeb1dd6570 — the merge of head 2525cfcf6f3c364bf09303a90d9902a13983bb8f into base e86530088a137275d803db29cb7c3afbc0f9f10e, 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 f1c23b9fc2ad596a82bd42ea7675bebeb1dd6570 && git checkout f1c23b9fc2ad596a82bd42ea7675bebeb1dd6570
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e86530088a137275d803db29cb7c3afbc0f9f10e 2525cfcf6f3c364bf09303a90d9902a13983bb8f && git checkout -B drift-repro e86530088a137275d803db29cb7c3afbc0f9f10e && git merge --no-ff 2525cfcf6f3c364bf09303a90d9902a13983bb8f

node scripts/docs-audit/affected-docs.mjs --json e86530088a137275d803db29cb7c3afbc0f9f10e

⚠️ 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 e86530088a137275d803db29cb7c3afbc0f9f10e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 04:21
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 04:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 9859e4e Oct 11, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22719-rest-write-hook-sentence branch October 11, 2026 04:49
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