Skip to content

fix(objectql, runtime): a converted stale-roll-up refusal keeps its lead and cause through a sandboxed hook, and is hedged under a failed rollback - #29721

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-el3myu
Nov 3, 2026
Merged

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread

Fixes #29527

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:两处文案准确性修正。① 沙箱(QuickJS)钩子体通过 ctx.api 写入时,若那次写入已被转换成"未落地"拒绝(级联被汇总重算失败卡住、事务已回滚),钩子体原样抛出时,运行器现在交还引擎自己的错误对象,而不是一个 SandboxError;于是钩子派发处能照常改写首句,点名外层写入,cause 保留原始 ERR_SUMMARY_RECOMPUTE。② ObjectQL.transaction 回滚本身也失败时,已经转换过的拒绝也改用"回滚也失败、存储状态未知"的首句,不再说"在会回滚的事务里什么都没写"。
  • 为什么改:fix(objectql)!: a hook's own write's stale-roll-up refusal never answers for the outer write; the unlanded-write refusal re-leads, declares 500, and never claims a failed rollback #29519 合同评审的两条 should-fix。实测主干:外层 insert('hcase') 的调用方读到的是钩子自己那次删除("The delete of "hacct" …"),cause 丢失;回滚失败时级联拒绝仍声称"nothing was written"。
  • 风险与回滚:状态码与 code 全不变(500 INTERNAL_ERROR,数据入口与动作入口实测)。钩子体自己捕获后抛自定义错误、或改写消息后再抛,照旧走 SandboxError 中继。@objectstack/objectql 新增一个导出 isUnlandedWriteRefusal。回滚即 revert。
  • 先看:packages/runtime/src/sandbox/quickjs-runner.ts(holdEngineRefusal 的谓词一行),packages/objectql/src/summary-errors.ts(品牌符号与 rollbackFailedRefusal 的 re-lead),再看 packages/runtime/src/sandbox-hook-unlanded-refusal-lead.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/sandbox-hook-unlanded-refusal-lead.test.ts,预期 6 个通过。
  • 需要你做的:无,席位落地。

What changes

  1. Sandboxed hooks keep the lead and the cause. The QuickJS runner already hands the engine's own duplicate-record refusal back out of a body that lets it leave unchanged (holdEngineRefusal, matched by identity on the host-side error). It now does the same for objectql's converted stale-roll-up refusal. The hook-dispatch seam (triggerHooks, and the whole-operation dispatch beside it) then recognises it and re-leads it as it does a host-code hook's, so the outer write's caller reads The insert of "hcase" did not complete: its beforeInsert hook "purge_before" stopped. … and cause is the line's own ERR_SUMMARY_RECOMPUTE refusal with its failures.
    • Every hook event goes through the same seam, so beforeFind / afterFind and the other read events are covered; beforeFind is pinned for them.
    • A body that catches the refusal and throws its own error, or rewrites anything that crosses the VM on it before re-throwing, is a speaker and keeps the SandboxError relay (two CONTROL pins).
    • An action or job body that lets it leave also hands back the engine's error now. The action door's answer is unchanged: 500, INTERNAL_ERROR, the delete's own sentence, success: false (pinned; the pin is green with and without the fix).
  2. rollbackFailedRefusal hedges every converted refusal. It now re-leads an earlier conversion (relead = true), so a cascade-stopped or hook-stopped refusal escaping an ObjectQL.transaction whose rollback threw leads with The transaction did not complete, and its rollback failed too, so what was written inside it may or may not be stored., cause kept. A transaction whose rollback ran still leaves the converted refusal exactly as it escaped (CONTROL).
  3. isUnlandedWriteRefusal(e), exported from @objectstack/objectql, is the predicate the runner asks. The class stays module-private. The predicate reads a non-enumerable Symbol.for brand rather than instanceof, because this package ships two realms (ESM and CJS) and a reader holding the other realm's class would get false. The re-lead inside summary-errors.ts asks the same predicate.

Status codes and code are unchanged everywhere. packages/spec and protocol.ts are untouched. Changeset: patch for @objectstack/objectql and @objectstack/runtime.

Tests

Reproduced red first (one-time mutation through scripts/ablation-replace.mjs, restored by blob hash)
  • Hypothesis 2 (rollback hedge): rollbackFailedRefusal's re-lead flipped back to the base behaviour (true to false). engine-unlanded-refusal-lead.test.ts: Tests 2 failed, 9 passed. The red message was the base answer: The delete of "line" did not complete: its cascade stopped before the record was removed. Under a transaction that rolls back nothing was written; …. Blob restored 6cdd4cf1 = HEAD.
  • Hypothesis 1 (sandbox relay): holdEngineRefusal's predicate reduced to the duplicate-record class alone (the base). sandbox-hook-unlanded-refusal-lead.test.ts: Tests 3 failed, 3 passed. The three re-lead pins (beforeInsert, afterInsert, beforeFind) red with hook 'purge_before' threw: Error: The delete of "hacct" did not complete: …; status 500 and code INTERNAL_ERROR already held there, so the status does not move. The two CONTROL pins and the action-door pin stayed green. Blob restored 5e56e6d1 = HEAD.
  • Both hypotheses were confirmed as written on the card.
Green (head ac992d4)
  • src/engine-unlanded-refusal-lead.test.ts (objectql): Tests 11 passed.
  • src/sandbox-hook-unlanded-refusal-lead.test.ts (runtime, real ObjectQL over better-sqlite3, real QuickJSScriptRunner, real HttpDispatcher): Tests 6 passed.
  • pnpm --filter @objectstack/objectql test: Test Files 543 passed, Tests 10605 passed. typecheck: exit 0.
  • pnpm --filter @objectstack/runtime typecheck: exit 0.
  • pnpm --filter @objectstack/runtime test: Test Files 530 passed, Tests 8518 passed, 19 skipped.

Verification

Gates (head ac992d4)
  • node scripts/pm/dispatch-gates.mjs --commands derived 60 commands; all run locally with exit codes captured before any pipe. All 60 exited 0. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3, no dist/ for 37 packages); after a full pnpm build (exit 0) it exited 0: 115 require entry points across 70 packages load.
  • --ran reconciliation: 60 derived, 60 run, 0 NOT-MEASURED, 0 UNRUN.
  • pnpm lint exited 0.
  • origin/main moved 24 commits after the branch point; none touches summary-errors.ts, index.ts, engine.ts or quickjs-runner.ts, so no merge was made.
  • Every other family CI runs (artifact rosters, wide populations, type-check lanes) is read from this PR's check-runs, not re-run here.

Acceptance notes

  • The data door and the MCP tools withhold the prose of a declared 5xx, so there the caller still sees only 500 INTERNAL_ERROR; the re-lead and the cause reach in-process callers, the action door's message and the server log.
  • ScopedContext.transaction (the repository surface's twin) does not swallow a failing rollback, so the rollback's own error leaves it and no "rolled back" claim is made there. Not touched.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No schema touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Card thread session: session_01JQwxbZhgmEnK3HMysTCHGU, seat domain:server#4, claim 6592672964.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQwxbZhgmEnK3HMysTCHGU


Generated by Claude Code

…ead and cause through a sandboxed hook, and is hedged under a failed rollback

Claude-Session: https://claude.ai/code/session_01JQwxbZhgmEnK3HMysTCHGU
Co-authored-by: Claude <noreply@anthropic.com>
…body lets a converted refusal leave

Claude-Session: https://claude.ai/code/session_01JQwxbZhgmEnK3HMysTCHGU
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 218 client-bound route-ledger rows — the other 157 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 157: 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; 102 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 — 37 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 9b4fa6cc9d8295e891d5f8d2264aee1cca36c203 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9b4fa6cc9d8295e891d5f8d2264aee1cca36c203

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

席位核对

ACCEPT · domain:server#4 · head ac992d4d011a18e3c4195a219469e63285a67a64 · 2026-11-03T03:09Z · seat review of record at TIER_DEFAULT for the whole PR. Clause-②: no and Metadata-protocol: none stand, so no contract review is owed. Not governed: check-governed-merges --pr 29721 0 of 6 paths, +359/−9.

  1. Shape: draft, base main, Fixes #29527. 6 files: summary-errors.ts (a non-enumerable Symbol.for brand on the converted refusal, isUnlandedWriteRefusal, and rollbackFailedRefusal now re-leads), its export in packages/objectql/src/index.ts, quickjs-runner.ts (holdEngineRefusal also holds the converted refusal), two test files, and one patch changeset (@objectstack/objectql, @objectstack/runtime). Inside the claim surface (6592672964). No packages/spec.
  2. Premise measured: both hypotheses red first by ablation: the rollback hedge (2 red) and the sandbox hook re-lead (3 red: beforeInsert, afterInsert, beforeFind); blobs restored (dev report 6594143139). Controls: a body that catches and throws its own error, or rewrites the message, still crosses as SandboxError. The action door answers 500 INTERNAL_ERROR with and without the fix, so no door's status or code moves.
  3. Gates on this head: check-runs pinned to ac992d4d: 48 runs, 35 success, 13 skipped, none failed. Dev local: dispatch-gates --commands 60 derived / 60 run, all exit 0 (dual-build-cjs-loads after a full build); --ran 0 NOT MEASURED, 0 UNRUN; pnpm lint 0; objectql 10605 and runtime 8518 tests passed; typecheck 0 for both.
  4. New export name (the dev's open question): isUnlandedWriteRefusal follows the package's settled is… predicate precedent, nearest isHomeAmountRefusal (home-amount-projection.ts), so the seat takes it under the names rule. The brand is realm-safe (ESM and CJS copies agree) and carries no enumerable key, so no serialiser or spread moves. @objectstack/runtime already depends on @objectstack/objectql. Clause-② no accepted: the export widens a package's API, not the protocol.
  5. Landing: not governed, under 3000 lines, no contract review owed ⇒ the seat lands it (pr_ready + automerge_enable). At MERGED: landing record on objectql: a converted landed-write refusal relayed out of a sandboxed hook loses its lead and cause, and a converted refusal under a failed rollback still claims a rollback (#29519 review) #29527 (closed by Fixes), pm:dispatched removed.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 03:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit c9c6828 Nov 3, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-el3myu branch November 3, 2026 03:32
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

1 participant