Skip to content

fix(runtime): log a sandboxed business refusal once, at info, without a stack - #26249

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-6lh3me
Oct 26, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-6lh3me

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26153

Clause-②: no
Metadata-protocol: none

What changes

A sandboxed hook or action body that refuses on purpose (a throw the HTTP doors answer in the 4xx band, such as hotclm's 422 INVALID_STATE) used to write two ERROR [BodyRunner] lines with full stacks: sandboxed hook threw at the hook's catch, then sandboxed action threw at the action's catch for the same refusal relayed across the VM hop.

Now:

  • A refusal logs once, at info, with no stack. The line is [BodyRunner] sandboxed hook refused (or action refused) and carries appId, the hook or action name, the declared code, the status and the error's message (the debug wrapper, which names every frame). No error object is passed, so no stack.
  • The action does not repeat a refusal its hook already logged. A nested body's refusal that the outer body re-threw unchanged is recognised by identity and the outer catch writes nothing for it. The same holds for a hook whose ctx.api write is refused by another hook.
  • A fault keeps error with its stack. A crash, a denied capability or a declared 5xx is logged exactly as before. For a nested fault both lines are kept (hook and action), as today.
  • Every throw is still rethrown. No HTTP status, body or message changes.

How refusal vs fault is decided

By the status band of mapDataError(err) (@objectstack/types), the ADR-0112 table the /data door answers with and that the bulk-import runner already reuses for its row verdicts. There is no second list of codes or names. The new test pins, for each shape that reaches these catches (nested hook refusal 422, action refusal with no declared status, nested hook crash, action crash, denied capability), that this band equals the band the /actions door actually answers through HttpDispatcher.handleActions. If the two ever drift, that block goes red.

Why quickjs-runner.ts is touched (claim amendment 6420650570)

The fact "this outer SandboxError is a nested refusal relayed unchanged" exists only in the runner's pump loop (readRelayedRefusal). The outer error is a new object, so body-runner.ts could tell it apart only by pattern-stripping .message, which this code forbids. The runner now records such errors in a module-level WeakSet and exports isRelayedSandboxRefusal() to body-runner.ts. It is not exported from the package entry (@objectstack/runtime exports only ., and sandbox/index.ts does not re-export it). The catch-site diff stays at the two catches plus one helper, logBodyThrow, so #26170 (same file) rebases trivially.

Evidence

  • Red first, on main (fbb7599): body-throw-log-level.test.ts, 3 failed / 7 passed. The nested case recorded error '[BodyRunner] sandboxed hook threw' and error '[BodyRunner] sandboxed action threw', both with stacks. The fault and door-parity cases were already green there.
  • After the fix: 10/10 pass.
  • Ablation: with if (isRelayedSandboxRefusal(err)) return; deleted via scripts/ablation-replace.mjs (anchor 1 to 0, blob bfd61dd8ec74 to 43513b2871d9), only the nested case goes red (1 failed / 9 passed). Restored, git diff HEAD empty. The test imports src/, so no dist/ leg applies.
  • Runtime: on head a1db083d (rebased onto origin/main df8aac41, which moved mapDataError and the /actions catch for A NOT NULL refusal on an undeclared (drifted) column still answers differently per door: REST 400 VALIDATION_FAILED with a drift hint, dispatcher /data 500 INTERNAL_ERROR, actions 400 VALIDATION_ERROR with the driver's text #26031): pnpm --filter @objectstack/runtime typecheck exit 0 and pnpm --filter @objectstack/runtime test 457/457 files, 7422 passed, 19 skipped (lock VERDICT command-exit 0).
  • Lint: eslint --no-inline-config on the three touched files, exit 0.
  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands derived 65 commands on head a1db083d; all 65 run, every one exit 0, and --ran reconciles 65 run, 0 NOT-MEASURED, 0 UNRUN. Families CI runs outside that list (Test Core shards, Build Core, the type-check lanes) are left to this PR's check-runs.

Acceptance notes

  • Level info, not warn: a refused click is an ordinary outcome. REST does not log an expected 4xx at all, and warn would keep paging anyone alerting on WARN.
  • Known edge where the doors themselves disagree. A body that throws a code one of mapDataError's 4xx arms recognises together with a declared 5xx status is answered 4xx by /data but 5xx by /actions (the divergence error-response-structured-arm-door-parity.test.ts §4 already names). Such a throw logs info. The reverse case, a 5xx arm code with no declared status (ERR_DATASOURCE_UNAVAILABLE), logs error while /actions answers 400, which is the louder direction. Neither shape has a producer in a sandbox body today.
  • A bare Error out of UnimplementedScriptRunner (no engine installed) reads 5xx under mapDataError, so it stays error, which is right for a misconfiguration.
  • The job body catch (sandboxed job threw) is out of this card's scope and unchanged.
  • #26170 remains open: the translation key for hook refusals is not started here.

Generated by Claude Code

…a stack

A hook refusal the HTTP doors answer in the 4xx band now writes one info
line naming the hook or action, its code and the status; the outer action
catch no longer repeats a nested refusal its hook already logged. A fault
(5xx) keeps the error line with its stack at both catches. The band is read
off mapDataError, and every throw is still rethrown.

Claude-Session: https://claude.ai/code/session_018jZoq6nbK2HEZ8a7BnDwG7
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 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 27 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 6862ef62abd9062b54e92a8d8f82ec1abf1320c1 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 6862ef62abd9062b54e92a8d8f82ec1abf1320c1

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-26T17:17Z

Head a1db083d13, report 6422010732 (on #26153), claim 6420510502 and amendment 6420650570. No contract review is owed: there is no packages/spec/** diff and no narrowing. The refusal's status, body and message are unchanged.

item reading
Shape Draft, base main, Fixes #26153, a patch changeset for @objectstack/runtime, Clause-②: no, Metadata-protocol: none, mergeable clean.
Files 4 files, +327/−10, all on the amended surface: sandbox/body-runner.ts (the two catch sites now call one logBodyThrow), sandbox/quickjs-runner.ts (a module-level WeakSet with isRelayedSandboxRefusal(), not exported from the package index), a new body-throw-log-level.test.ts, and the changeset.
Fix It classifies with mapDataError(err).status, ADR-0112's table that the doors answer with, so there is no second classifier. A 4xx writes one info line naming the hook or action, its code and the status, with no stack. A refusal relayed from a nested body was already logged by that body's catch, so the outer catch writes nothing. A 5xx keeps error with its stack at both catches, as before. Every throw is still rethrown.
Evidence Red first: two error lines with stacks on main for one refused action. The new test pins the band against the /actions door's wire status per shape, plus a fault case that still logs error. The runtime suite passes 457/457 files. 65/65 derived gates exit 0 and --ran reconciles. On this head, 36 check runs succeeded and 11 were expected skips, with 0 failed.
Serial #26170 (domain:spec, not yet dispatched) will touch body-runner.ts later. This diff stays at the catch sites plus one helper, so a later rebase is trivial.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 26, 2026 17:18
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 1d3ad39 Oct 26, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-6lh3me branch October 26, 2026 18:02
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

Development

Successfully merging this pull request may close these issues.

runtime: an app hook's intentional business refusal is logged as ERROR with a stack trace, twice per refused action

2 participants