Skip to content

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

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

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

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29422
Fixes #29421

Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** or the hand-written docs teaches what a write answers when a hook's own write, a cascade or a transaction meets a roll-up that cannot be recomputed; the generated reference pages only list the code

Narrowing note: the seat amended the claim from Clause-②: no once the fix shape was chosen (option A on #29422, option B on #29421 item 2). A write whose hook let its own write's stale-roll-up refusal escape answered success with the hook's record. It now refuses, and an aborting after* hook's unit rolls both rows back. The action door also answers these refusals 500 instead of 400. So the title carries !, full dogfood ran locally, and the seat runs a contract review at tier.

维护者速读

  • 改了什么:钩子(hook)通过 ctx.api 自己写的记录如果汇总重算失败(ERR_SUMMARY_RECOMPUTE),这个错误不再冒给外层写入的调用方当作"外层写入成功"。外层写入现在报内部错误(500 INTERNAL_ERROR),文案点名外层写入和钩子,原错误挂在 cause 上。另外三处顺带修正:级联多层时文案点名最外层对象;这类错误显式声明 status: 500、code: INTERNAL_ERROR;事务回滚本身失败时不再说"已回滚"。
  • 为什么改:主干实测 POST /api/v1/data/case 答 201,可返回的 id 和记录是钩子写的那条 line,不是 case。带 abort 钩子时整个单元还会把两行都提交。
  • 风险与回滚:只改"嵌套写入的汇总失败、外层写入因此没完成"这一类答复。其它钩子错误照旧;onError: 'log' 的钩子照旧不让外层失败;外层写入自己的汇总失败照旧答 ERR_SUMMARY_RECOMPUTE。动作入口对这类错误从 400 改为 500,数据入口的响应体字节不变。没有新错误码,没有数据迁移,回滚就是整体 revert。
  • 先看:packages/objectql/src/summary-errors.ts(hookStoppedRefusal、UnlandedWriteRefusal 类与重新领头、rollbackFailedRefusal);packages/objectql/src/engine.ts 的 triggerHooks 和 dispatchUnscopedMultiWriteHooks 两处 handler 调用,以及 ObjectQL.transaction 的回滚 catch。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-hook-stale-summary-refusal.test.ts src/engine-unlanded-refusal-lead.test.ts,应看到 14 个测试全部通过。
  • 需要你做的:无,席位落地(契约复核后)。

What changes

#29422: a hook's own write. The one seam a hook handler's error leaves through is triggerHooks (await entry.handler(ctx)); the unscoped multi-write dispatch is its twin. At that seam a stale-roll-up refusal is converted with hookStoppedRefusal. Before, the refusal crossed it carrying the HOOK's write as written, so the outer door answered the hook's record as its own result. Now the outer write refuses with an internal failure that names it and the hook (The insert of "case" did not complete: its afterInsert hook "line_writer" stopped. …), and the refusal is kept as cause. Consequences:

  • An aborting after* hook's unit (inAfterAbortUnit) no longer sees SummaryRecomputeError, so it rolls both rows back, as HookSchema.onError abort says. Only the outer write's OWN refusal still reaches it and commits.
  • With no unit (a code-registered hook, a driver without transactions, a caller's unit), the text says that what was written before that point stays written.
  • before* hooks go through the same seam: the outer write never ran.
  • Bounds the seat set: only the ERR_SUMMARY_RECOMPUTE family is converted, and every other hook error passes through unchanged (pinned). A hook declared onError: 'log' swallows it before the seam, so the outer write still succeeds (pinned).

#29421: the unlanded-write refusal (summary-errors.ts):

  1. Lead. The converted error is now a module-private class, UnlandedWriteRefusal. When a cascade walk or a hook meets an earlier conversion, it re-leads with its own write and keeps the original refusal as cause. A three-level cascade's caller reads the root object. A transaction does not re-lead: what escapes it already names the write that did not complete.
  2. Structural status. The class declares status: 500 and code: 'INTERNAL_ERROR'. The data door's body is byte-identical (500 { error: "Internal server error", code: "INTERNAL_ERROR" }), now declared instead of reached by falling through every heuristic. The action door now answers 500 INTERNAL_ERROR instead of 400, with the same sentence. status alone was measured and rejected: a declared 5xx with no code drops code from the data door's body.
  3. Rollback failure. When ObjectQL.transaction's rollback throws, the refusal is rollbackFailedRefusal ("…its rollback failed too, so what was written inside it may or may not be stored"), not "rolled back". ScopedContext.transaction does not swallow a rollback failure, so it is unchanged.

Tests

  • New packages/objectql/src/engine-hook-stale-summary-refusal.test.ts (7): aborting afterInsert, afterUpdate and afterDelete (refused, rolled back, nothing stored); a code-registered hook with no unit (refused with the hedged text, both rows stored); beforeInsert; and three CONTROLS: onError: 'log' succeeds, the outer write's own refusal is still ERR_SUMMARY_RECOMPUTE and commits, and another hook error passes through unchanged.
  • New packages/objectql/src/engine-unlanded-refusal-lead.test.ts (7): a three-level cascade where the root's caller reads the root; the middle level's own caller; re-lead at every depth through cascade and hook seams with cause kept; the rollback-throws hedge. CONTROLS: an unrelated code-less error, the leaf's own refusal, and a successful rollback.
  • New packages/rest/src/data-write-hook-stale-summary-refusal.test.ts (3, real SqlDriver): POST with a metadata-bound hook answers 500 with the exact body and stores neither row; POST with a code hook answers the same and stores both rows; CONTROL a line's own POST answers 201 with its own row.
  • Forced test edits (named): packages/objectql/src/engine-cascade-summary-rollback.test.ts (fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land #29381's pin), where five err.code assertions go from undefined to 'INTERNAL_ERROR' plus status 500; packages/runtime/src/action-delete-cascade-summary-rollback.test.ts, where the action door goes 400 → 500 with code: INTERNAL_ERROR (the seat accepted this); and packages/rest/src/data-delete-cascade-summary-rollback.test.ts, tightened from status + code to the whole body (the byte-identity pin the seat asked for).

Acceptance notes

Verification

All readings on objectstack-ai/objectstack at head b81dcfa4 (the branch's three commits plus the changeset, merged with origin/main 04e0c2c0), after pnpm install --frozen-lockfile and a full turbo run build --filter='./packages/**' (78/78).

Measurement on main before the fix (#29422)

Harness: RestServer → protocol → ObjectQL → SqlDriver (better-sqlite3), on main 8ca9dd7b, with a scratch test that was not committed. An afterInsert hook on case writes a line through ctx.api, and the line's roll-up refuses.

  • POST /data/case with a metadata-bound hook answered 201 {object:"case", id: LINE_ID, record: LINE_ROW}, where LINE_ID and LINE_ROW are the id and row of the line the hook wrote (name "from-hook"). Both rows were stored, because the unit committed.
  • POST /data/codecase with a code-registered hook (no unit) gave the same shape. Both rows were stored.
  • The engine's read: code ERR_SUMMARY_RECOMPUTE, written = the line row, failures line -> bucket.memo_sum.
  • CONTROL POST /data/line answered 201 with its own row.
Item 2: door readings for the three shapes of the converted error
declares data door DELETE action door POST
nothing (#29381 shape) 500 {error, code: INTERNAL_ERROR} 400 {error:{code: VALIDATION_ERROR, message, httpStatus:400}}
status: 500 only 500 {error}, with code dropped 500 INTERNAL_ERROR + sentence
status: 500 + code: INTERNAL_ERROR (this PR) 500 {error, code: INTERNAL_ERROR}, byte-identical 500 INTERNAL_ERROR + sentence
Tests
  • pnpm --filter @objectstack/objectql test && pnpm --filter @objectstack/objectql typecheck (under the lock): VERDICT command-exit 0. Test Files 536 passed (536), Tests 10398 passed (10398); typecheck exit 0.
  • rest src/data-delete-cascade-summary-rollback.test.ts src/data-write-hook-stale-summary-refusal.test.ts src/data-write-summary-recompute-refused.test.ts: 3 files, 10 tests passed. runtime src/action-delete-cascade-summary-rollback.test.ts: 2 tests passed.
  • Full dogfood, pnpm --filter @objectstack/dogfood test under the lock, with no shard and no path: VERDICT command-exit 0. Test Files 397 passed | 3 skipped (400), Tests 3277 passed | 28 skipped (3305).
  • Lint: root eslint --no-inline-config on packages/objectql/src plus the three door test files: exit 0, no output.
Ablations (each restored, with git diff HEAD empty, by scripts/ablation-replace.mjs)
  • Hook seam (triggerHooks: throw hookStoppedRefusal(…) → throw e), engine pin: 4 failed | 3 passed. AssertionError: expected 'ERR_SUMMARY_RECOMPUTE' to be undefined on the three after* cases, the code-hook case and beforeInsert. The three controls stay green.
  • Re-lead (relead && e instanceof UnlandedWriteRefusal ? e.cause : e → e): 2 failed | 5 passed. The three-level case reads The delete of "line" did not complete….
  • Rollback hedge (rollbackFailed ? rollbackFailedRefusal(err) : rolledBackRefusal(err) → rolledBackRefusal(err)): 1 failed | 6 passed. It reads The transaction rolled back, so nothing written inside it was stored.
  • Declared code dropped (status kept), objectql dist rebuilt, with ablation-dist-preflight showing the marker present: data-door pins 3 failed (expected { error: 'Internal server error' } to deeply equal { error, code }); the action pin stays green.
  • Status and code both dropped (the fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land #29381 shape), dist rebuilt, marker present: data-door pins 5/5 green, which shows the body is byte-identical to the old heuristic path. Action pin: 1 failed, expected 400 to be 500, body code VALIDATION_ERROR.
  • Restore leg: objectql dist rebuilt, and ablation-dist-preflight --absent passed for both markers.
Gates: dispatch-gates --commands, 63 derived
  • All 63 ran; --ran reconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED".
  • Two first-pass exit 3s were re-run green. check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched. check:type-check-debt had queued behind this branch's own dogfood lock past its budget; on the re-run it was OK, 0 raw errors.
  • CI families: see this PR's check-runs.

Generated by Claude Code

…ers for the write the hook is bound to

A hook that writes through ctx.api and lets that write's
ERR_SUMMARY_RECOMPUTE escape no longer hands the refusal (and the hook
write's `written`) to the outer write's caller. The one seam a handler's
error leaves through (triggerHooks, and the unscoped multi-write
dispatch) converts it with hookStoppedRefusal: a code-less error that
leads with the outer write, the refusal kept as `cause`. An aborting
after* hook's unit now rolls both rows back; with no unit the text says
what stays written. Every other hook error, and a hook declared
onError 'log', is unchanged.

Claude-Session: https://claude.ai/code/session_01ADHkmPCSqt8uDw17RmX9hu
Co-authored-by: Claude <noreply@anthropic.com>
…, declares 500 INTERNAL_ERROR, and never claims a failed rollback

- A conversion that meets an earlier one re-leads it (cascade walk and
  hook seam), so a nested cascade's caller reads its own object; `cause`
  stays the original ERR_SUMMARY_RECOMPUTE at every depth.
- The converted error declares `status: 500` and `code: INTERNAL_ERROR`,
  so doors read it structurally. Data door body byte-identical; the
  action door now answers 500 INTERNAL_ERROR (was 400), same sentence.
- ObjectQL.transaction no longer says "rolled back" when its rollback
  threw: rollbackFailedRefusal says nothing is known about what is stored.

Forced test edits: the #29381 objectql pin's code assertions (undefined
-> INTERNAL_ERROR), the action pin's 400 -> 500, and the data door pin
tightened to the whole body.

Claude-Session: https://claude.ai/code/session_01ADHkmPCSqt8uDw17RmX9hu
Co-authored-by: Claude <noreply@anthropic.com>
…p never answers the hook's record

The data-door pin for the hook-seam fix: a metadata-bound afterInsert
answers 500 INTERNAL_ERROR with neither row stored, a code-registered
one the same with both rows stored, and a line's own POST still 201.

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

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx (via INTERNAL_ERROR (literal, a string literal in UnlandedWriteRefusal; a string literal in a comment on a changed line))
  • content/docs/api/error-catalog.mdx (via INTERNAL_ERROR (literal, a string literal in UnlandedWriteRefusal; a string literal in a comment on a changed line))
  • content/docs/plugins/development.mdx (via INTERNAL_ERROR (literal, a string literal in UnlandedWriteRefusal; a string literal in a comment on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via INTERNAL_ERROR (literal, a string literal in UnlandedWriteRefusal; a string literal in a comment on a changed line))

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

  • content/docs/releases/v17/17-0.mdx (via INTERNAL_ERROR (literal, a string literal in UnlandedWriteRefusal; a string literal in a comment on a changed line))
  • content/docs/releases/v18/18-2.mdx (via INTERNAL_ERROR (literal, a string literal in UnlandedWriteRefusal; a string literal in a comment on a changed line))

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 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 20 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 399df5bba888ff7cf7531d01d8dcb61d7690438a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 399df5bba888ff7cf7531d01d8dcb61d7690438a

⚠️ 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 399df5bba888ff7cf7531d01d8dcb61d7690438a → 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

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head b81dcfa · 2026-11-02T17:27Z

This seat dispatched the narrowing face and reviews it at CONTRACT_REVIEW_TIER, through an at-tier reviewer that is not the drafter.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b81dcfa4fa0d3f7ab32be0f586e32481ff31132d
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/summary-errors.ts, packages/objectql/src/engine-hook-stale-summary-refusal.test.ts, packages/objectql/src/engine-unlanded-refusal-lead.test.ts, packages/objectql/src/engine-cascade-summary-rollback.test.ts, packages/rest/src/data-write-hook-stale-summary-refusal.test.ts, packages/rest/src/data-delete-cascade-summary-rollback.test.ts, packages/runtime/src/action-delete-cascade-summary-rollback.test.ts + packages/objectql/src/engine-hook-dispatch.ts (inAfterAbortUnit), packages/objectql/src/hook-wrappers.ts (onError), packages/objectql/src/hook-binder.ts, packages/objectql/src/index.ts, packages/types/src/data-error-classification.ts (classifyDataError, declaredServerFaultAnswer), packages/types/src/error-leak.ts (declaresServerFault), packages/types/src/thrown-http-error.ts (resolveThrownHttpError, serverFaultProvenance), packages/types/src/driver-fault-redaction.ts, packages/types/src/summary-recompute-answer.ts, packages/rest/src/error-response.ts, packages/runtime/src/http-dispatcher.ts (errorFromThrown, error), packages/runtime/src/domains/actions.ts, packages/runtime/src/dispatcher-plugin.ts (errorResponseBase), packages/runtime/src/landed-write-answer.ts, packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/sandbox/quickjs-runner.ts (SANDBOX_ERROR_PASSTHROUGH, HeldEngineRefusal), packages/metadata-protocol/src/protocol-batch-data.ts, packages/spec/src/data/hook.zod.ts (onError), .changeset/29291-cascade-summary-refusal-rolled-back.md (on main), scripts/check-empty-changeset.mjs (foreign changeset rule)

Inputs read: PR #29519 body and file list (9 files, +754/-29, author objectstack-fleet[bot]), the net diff 04e0c2c0c..b81dcfa4f (the merge commit's main parent is 04e0c2c0c, an ancestor of the head; the three-dot diff against the stale local origin/main was not used), every cited file with git show on the head, cards #29422 and #29421 with every comment (triage 6580094669 and 6580112859, the three claims on each card, the declaration revision 6581877746 on #29422, the os-dev-reports 6583653128 and 6583697241 read as claims), the contract review record 6579773378 on PR #29381, and the head's check-runs (34 non-skipped: 32 success, Test Core (3/8) and Test Core (8/8) still in_progress at the time of writing, nothing failed). Nothing was built, run or re-run.

① Derived judgments

  1. Hook seam, triggerHooks (engine.ts 6011-6017): await entry.handler(context) is now wrapped, and a thrown value leaves as hookStoppedRefusal(e, event, context.object, entry.hookName). The twin, dispatchUnscopedMultiWriteHooks (6333-6338), is wrapped the same way. These are the only two sites in the engine that call a hook handler (the third entry.handler(ctx) at 6434 is executeAction, the action registry, not a hook). Every dispatch path goes through them: single-record before* and after*, the per-row after* contexts (buildPerRowAfterContexts then triggerHooks), dispatchPerRowBeforeHooks (calls triggerHooks), bulk insert per row, and the unscoped multi-write before* dispatch. Right.
  2. The conversion predicate (unlandedWriteRefusal): matches code === SUMMARY_RECOMPUTE_CODE on the escaped error, or on its cause when the escaped error is itself an UnlandedWriteRefusal and relead is set. Everything else is returned by identity (pinned: the boom control and the code-less control). A hook declared onError: 'log' is swallowed in hook-wrappers.ts 439-457 before the seam (pinned). async: true hooks detach and never reach the seam. A hook that succeeds hits no catch. The bounds the seat set hold. Right.
  3. The converted error, UnlandedWriteRefusal: module-private class, status = 500, code = 'INTERNAL_ERROR', own cause set to the original refusal, no written, no failures, name stays 'Error'. Message names the stale summaries from cause.failures[].parentObject.field. index.ts still exports only SummaryRecomputeError, SUMMARY_RECOMPUTE_CODE and the SummaryRecomputeFailure type from this module; hookStoppedRefusal and rollbackFailedRefusal are module exports of summary-errors.ts only; package exports are unchanged. Nothing new is published, no new error code (INTERNAL_ERROR is the standard ADR-0112 member). Right.
  4. Item 1, re-lead: cascadeStoppedRefusal and hookStoppedRefusal pass relead = true; rolledBackRefusal and rollbackFailedRefusal do not. A three-level cascade's root caller reads the root; cause is the original refusal at every depth. redactPropagatedDriverFault rebuilds a copy with Object.setPrototypeOf(copy, Object.getPrototypeOf(error)) and carries own properties, so instanceof UnlandedWriteRefusal, status, code and cause survive the transaction catch, and a hook whose own write was a rolled-back ctx.api.transaction is still re-led at the hook seam. Right.
  5. Item 2, the data door (REST /api/v1/data): staleSummaryRefusalOrThrow matches by code, so the converted error is rethrown to handleRouteError, then classifyDataError. The sandbox-crash and own-fault terminals do not fire, no structured arm matches, the sandbox unwrap arm is skipped (no innerMessage), and the declared-status passthrough takes it: declaredServerFaultAnswer returns { status: 500, body: { error: 'Internal server error', ...thrownCodeFields } }; declaresServerFault is true (5xx and a non-empty string code), thrownCodeFields yields { code: 'INTERNAL_ERROR' } with no demotion (registered code), no refusal flag so no prose, no userMessage. Body { error, code } in that key order, the same bytes as UNCLASSIFIED_FAULT. The dev's "status alone drops code" reading is right too: declaresServerFault needs the code. Byte-identity pinned at the REST door for both DELETE and the new POST cases. Right.
  6. Item 2, the action door (domains/actions.ts catch): the branch "an error that NAMES its own HTTP status" (typeof err.status === 'number') now takes it before the 400 terminal, errorFromThrown(err, 500) resolves status 500 and code INTERNAL_ERROR, and the dispatcher's error() withholds prose only on looksLikeInternalErrorLeak, so the sentence rides. 400 to 500, same sentence, pinned in the runtime test. Named in the changeset and the PR body. Right.
  7. Item 3: ObjectQL.transaction sets rollbackFailed in the swallowed rollback catch and converts with rollbackFailedRefusal instead of rolledBackRefusal. ScopedContext.transaction does not swallow a rollback failure, so its catch is unchanged. Pinned both ways. Right.
  8. inAfterAbortUnit (engine-hook-dispatch.ts 72-87) is unchanged: it still catches instanceof SummaryRecomputeError inside the callback and commits. The docblock on opensAfterAbortUnit now says only the write's own refusal reaches it, and that is true in the tree: a hook's refusal is converted at the seam one frame below the handler, before it can reach this catch, so the unit rolls back (pinned for afterInsert, afterUpdate, afterDelete: begun 1, committed 0, rolledBack 1, neither row stored). Right.
  9. Batch door (protocol-batch-data.ts): the atomic arm aborts through the identity-checked ABORT sentinel, which rolledBackRefusal leaves untouched; the non-atomic per-row landedDespiteStaleSummary matches by code, so a hook-stopped row is rethrown as that row's failure and classified by mapDataError (500 INTERNAL_ERROR). No written of the hook's write reaches a batch result. Unchanged by the diff and right.
  10. The accept set narrowed: a write whose hook let its own write's stale-roll-up refusal escape answered the hook's record as a success (201 with the hook's id and row, both rows committed under an aborting after* unit, measured by the dev on main 8ca9dd7b); it now refuses. The action door's answer for a stopped cascade or a rolled-back transaction moved 400 to 500. Both are narrowings of @objectstack/objectql answers; both named. Right, with one under-naming carried to ④ finding 1.
  11. Forced test edits (engine-cascade-summary-rollback.test.ts five code asserts to 'INTERNAL_ERROR' plus status 500; action-delete-cascade-summary-rollback.test.ts 400 to 500 plus the code; data-delete-cascade-summary-rollback.test.ts tightened to the whole body): each follows from items 2 and 3 and is named in the PR body. Right.

② Semver level

Changeset .changeset/29422-hook-write-stale-summary-refusal.md: '@objectstack/objectql': patch, title carries !, body carries the **BREAKING** banner, exactly one Clause-②: no (narrowing) line and one Metadata-protocol: none line at line start, and exactly one ADR-0087 marker, not-required (no-migration-prescription), with the other five categories closed on facts. The PR body carries the same two declaration lines once each at line start (lines 6 and 7); the "Narrowing note" lines on both carriers spell the key only inline in backticks, which the reader does not take as a declaration. Under ruling A (the level follows the metadata protocol alone) none at patch is right for an engine-answer narrowing that moves no spec key, authorable spelling, stored shape or exported declaration. The ADR-0087 marker's claim "nothing exported is removed or narrowed" holds: the new class and the two new helpers are not reachable through the package exports. Check Changeset on the head is success. The narrowing face is correctly declared and this record is at its tier.

③ Boundary flags

④ Design judgment

The published spec on main says the refusal means "the triggering records WERE written" (the SummaryRecomputeError docblock in the shipped .d.ts, summary-recompute-answer.ts), HookSchema.onError abort (the default) says an aborting after* hook rolls the row and the chain's ctx.api writes back, and no spec surface says a hook's own write is the outer write's answer. The narrowing takes no declared capability away at any consumer: every reader of the refusal (staleSummaryRefusalOrThrow, landedWriteAnswer, landedDespiteStaleSummary, the import runner, the seed loader) matches by code, keeps its accept set wherever the refusal is still true (the hook's own write, a write outside any unit, the outer write's own roll-up), and stops being handed a written that is not its own. It makes two spec sentences real at the producer: onError: abort now rolls both rows back, and the refusal's "WERE written" is never read for a row the unit rolled back. The seam is the right one: triggerHooks and its unscoped twin are the only two places a handler's throw can leave, so one conversion there covers before*, after*, per-row, bulk and multi dispatch alike, and the onError: 'log' swallow sits one frame below it, so the declared non-aborting policy is honoured without the seam knowing about it.

Attempts to break it, with findings (none a must-fix):

  • Leak of the hook's written through another path. Batch: per-row catch matches by code, atomic arm aborts by sentinel; no. Per-row and multi: every per-row context is dispatched through triggerHooks; no. Three verbs: the three doors call triggerHooks for before* and after*, and dispatchUnscopedMultiWriteHooks for the unscoped before*; no. Nested hooks: hook A on the case writes a line, the line's hook B refuses; B's refusal is converted at B's seam (not the family, so the line's inAfterAbortUnit is not even opened inside A's ambient unit), escapes A's handler and is re-led at A's seam with cause the original; no. Sandboxed hook body: body-runner.ts 1168 wraps the body's ctx.api in answerLandedWritesDespiteStaleSummary, so the body's own write's refusal is answered as its written row and never throws; a family refusal that a body re-throws by hand crosses the VM with its code and is converted at the seam by code; no. A hook that catches the refusal and throws its own error carrying written is the hook's choice, not a seam. Holds.
  • Finding 1, should-fix (naming, and a relay gap). A sandboxed hook body whose ctx.api write is a cascade delete or a ctx.api.transaction that the refusal stopped receives the already-converted error. hostErrorToVm marshals it into the VM as name, message and the SANDBOX_ERROR_PASSTHROUGH fields (code, fields, status, userMessage); when the body lets it escape, it leaves the runner as a SandboxError carrying innerMessage, status: 500 and code: 'INTERNAL_ERROR' (only DuplicateRecordError is held by identity, HeldEngineRefusal). At the hook seam it is neither the class nor the family, so it passes unchanged. Two consequences the tree shows. (a) At the REST data door the outer write's answer moves: before this PR the SandboxError declared no status and the sandbox unwrap arm answered 400 { error: the inner sentence, code: UNDECLARED_HOOK_REFUSAL_CODE }; now declaredHttpStatus is 500, the arm falls through to the declared-status passthrough, and the answer is 500 { error: 'Internal server error', code: 'INTERNAL_ERROR' }. That is the same direction as the named action-door move, but the changeset's sentence "The data door's answer is byte-identical" is true only of the direct refusal; the data door's answer through a sandboxed hook moves 400 to 500 and loses the sentence. Name it in the changeset, or measure it and say so. (b) The re-lead does not happen through a sandboxed body: the caller reads the inner write's sentence inside the sandbox wrapper, and cause is lost at the VM boundary. Status and rows are right. This is item 1's class one hop further; it belongs to the sandbox relay, not to summary-errors.ts, and wants its own card.
  • Finding 2, should-fix (wording edge). rollbackFailedRefusal converts only the raw family. When an ObjectQL.transaction whose rollback threw is escaped by an already-converted refusal (hook-stopped or cascade-stopped), the error passes through with its own clause "Under a transaction that rolls back nothing was written; otherwise what was written before that point stays written", which does not cover an unknown state. The same hedge item 3 added for the raw family should apply to an escaped UnlandedWriteRefusal when rollbackFailed is set (re-lead it, or append the failed-rollback clause).
  • Finding 3, note (not a finding against the tree). triggerHooks also dispatches beforeFind and afterFind, so a find hook whose ctx.api write refuses now answers The find of "x" did not complete: its afterFind hook "h" stopped. with status 500. Before, the family refusal escaped find() and the read failed through the message heuristics; a read's accept set does not move, only the sentence and the declared status. The PR body says "before* and after* hooks alike" and does not name the find pair. Worth a sentence in the changeset.
  • Finding 4, note. The dispatcher data door and the MCP tools exit through errorResponseBase (resolveThrownHttpError(err, 500)): status stays 500 and code stays INTERNAL_ERROR, but a declared 5xx's prose is withheld (serverFaultProvenance is 'declared') where the undeclared one's was relayed. A body change on an unmeasured door, consistent with the /data door's withhold; not a status change. The dev measured only REST and the action door.
  • Legitimate hook behaviour: onError: 'log' swallows before the seam (pinned); a non-family error returns by identity (pinned); a succeeding hook hits no catch; async: true detaches; skipAutomations skips before the call. Unchanged.
  • Is the 500 always true when a unit exists? Yes. The converted error is not instanceof SummaryRecomputeError, so inAfterAbortUnit rethrows it through the transaction catch, the rollback runs, rolledBackRefusal leaves it unchanged (not the family), and the redaction keeps status and code. The write did not land and the roll-up failure is an internal one, which is what the seat ruled it. With a caller's own unit (ambient or threaded), opensAfterAbortUnit returns false and the caller's transaction() rolls back the same way.
  • Is the hedge honest without a unit? Yes, as pinned: a code-registered after* hook stores both rows and the text says what was written stays written; a before* hook never writes the outer row and the hook's row stays, which the text covers. The only gap is finding 2's failed-rollback case.
  • Export leakage: none (index.ts, core.ts, package exports unchanged).

Judged on the tree at b81dcfa4, not on the PR body's account. No must-fix.

Implemented-by: claude/project-thread-vzrurx
Reviewed-by: session_01ADHkmQKwKFCp1m6Pb6Q8c1

VERDICT: PASS

…oks, and the declared-5xx prose withhold

Contract-review round on #29519 (record PASS, should-fix 1 and notes 3-4):
the byte-identical sentence is scoped to the direct refusal; a converted
refusal relayed out of a sandboxed hook body now answers 500
INTERNAL_ERROR at the REST data door (was 400 with the inner sentence);
the seam covers beforeFind/afterFind; the dispatcher data door and MCP
tools withhold a declared 5xx's prose with status unchanged.

Claude-Session: https://claude.ai/code/session_01ADHkmPCSqt8uDw17RmX9hu
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head 0fbbbf7 · 2026-11-02T17:46Z

The same reviewer does a delta review: this commit only changes the changeset text to land should-fixes from the record on b81dcfa4.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0fbbbf7939061d72eda01281a440c8aa36ebe620
Local-runs: none
Scope: .changeset/29422-hook-write-stale-summary-refusal.md (the one path the delta touches; prose face, read here because this record judges it against the code) + packages/objectql/src/engine.ts, packages/objectql/src/summary-errors.ts, packages/types/src/data-error-classification.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/sandbox/quickjs-runner.ts (the files the two edited sentences make claims about, all unchanged since b81dcfa4)

Delta review over the full record on b81dcfa4fa0d3f7ab32be0f586e32481ff31132d (same reviewer, same PR #29519). The new head is one commit, 0fbbbf793, whose parent is b81dcfa4 exactly. git diff b81dcfa4..0fbbbf79 touches one file, .changeset/29422-hook-write-stale-summary-refusal.md, +2/-2: two bullet lines of the body are rewritten. No source, test, frontmatter, declaration line or marker moved. The PR's file list is still 9 files, +754/-29. Check-runs on the new head at the time of writing: Check PR Size and Governed Surface Queue Guard success, every other non-skipped run in_progress, nothing failed; the previous head's battery was green except two Test Core shards still running, and no code changed since. Nothing was built, run or re-run.

① Derived judgments

The ① items of the b81dcfa4 record (1 to 11) carry over unchanged: the tree outside the changeset is byte-identical, so every accept-set and status reading made there still holds. The delta is judged on whether the rewritten sentences are true of that tree.

  1. New sentence, find hooks: "for beforeFind / afterFind too: a find hook whose own write refuses now fails the read with the same declared 500 (the read failed before as well, only its sentence and status were inferred)". True: triggerHooks dispatches beforeFind and afterFind through the same wrapped call (engine.ts 6011-6017), hookStoppedRefusal derives the verb find, and before the fix the family refusal left find() as an error that the doors classified by message. The read's accept set does not move; the sentence and the declared status do. Named right (closes note 3 of the previous record).
  2. Rewritten sentence, the direct refusal: "For the refusal itself, the REST data door's answer is byte-identical: 500 { error: "Internal server error", code: "INTERNAL_ERROR" }". True and now correctly scoped to the refusal reaching the door directly (declaredServerFaultAnswer, declaresServerFault true, thrownCodeFields gives { code: 'INTERNAL_ERROR' }, no refusal flag). Named right.
  3. New sentence, the sandboxed-hook relay: "A converted refusal relayed out of a sandboxed hook body (its ctx.api cascade delete or transaction was stopped, and the body let the error escape) now answers 500 INTERNAL_ERROR at the REST data door, where it answered 400 with the inner sentence." True: hostErrorToVm carries code and status across the VM (SANDBOX_ERROR_PASSTHROUGH), the escaped SandboxError carries status: 500 and code: 'INTERNAL_ERROR', the hook seam passes it unchanged (neither the class nor the family), and the data door's sandbox unwrap arm falls through to the declared-status passthrough where it used to answer 400 { error: innerMessage, code: UNDECLARED_HOOK_REFUSAL_CODE }. The condition is stated correctly too: the body's own write's family refusal never escapes, because body-runner.ts 1168 wraps the body's ctx.api in answerLandedWritesDespiteStaleSummary. Named right (closes should-fix 1(a) of the previous record on the naming axis).
  4. New sentence, the dispatcher and MCP doors: "The dispatcher data door and the MCP tools keep status 500 and code INTERNAL_ERROR for these refusals, but now withhold the prose, as they do for any declared 5xx." True of errorResponseBase (dispatcher-plugin.ts 739-795): resolveThrownHttpError(err, 500) gave status 500 and the derived INTERNAL_ERROR before, and gives the declared pair now; serverFaultProvenance turns 'declared', so the message becomes INTERNAL_ERROR_MESSAGE. Named right (closes note 4 of the previous record).
  5. Nothing else moved. The remaining body text, the title, the **BREAKING** banner, the remedy paragraph and the frontmatter are the same bytes as on b81dcfa4.

② Semver level

Unchanged and still right: '@objectstack/objectql': patch, title with !, one **BREAKING** banner, exactly one line-initial Clause-②: no (narrowing) and one Metadata-protocol: none (counted on the new file: 1 and 1), exactly one ADR-0087 marker, not-required (no-migration-prescription), whose text is unchanged and still true (no spec key, authorable spelling, stored shape or exported declaration moves; the sandboxed-hook relay and the find-hook sentences describe engine answers, not metadata). Under ruling A none at patch is the level for this narrowing. The changeset is now accurate and complete for ②: every door move the previous record found unnamed is named, and the byte-identity claim is scoped to the case it is true of. The PR body's two declaration lines are unaffected by the commit. Check Changeset on the new head was still in_progress when this record was written; its verdict is the landing pre-check's, and nothing in the delta changes what it reads.

③ Boundary flags

④ Design judgment

No design surface moved. The narrowing judged on b81dcfa4 (the seam conversion, its bounds, the declared status, the re-lead, the rollback hedge) is unchanged byte for byte, and the four findings of that record's ④ stand as recorded: none is a must-fix. What this commit changes is the published account of the narrowing, and each new sentence was checked against the cited code above and found true; the changeset no longer over-claims byte-identity and no longer leaves a door move unnamed. The one thing a reader of the changeset still does not get is the re-lead gap through a sandboxed body, which is right to leave out here: the changeset describes what the release does, and that gap is the follow-up's to fix and to name when it lands.

Judged on the tree at 0fbbbf79, not on the PR body's or the coordinator's account of it. No must-fix.

Implemented-by: claude/project-thread-vzrurx
Reviewed-by: session_01ADHkmQKwKFCp1m6Pb6Q8c1

VERDICT: PASS

@objectstack-fleet

objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

席位意见:达档契约复审 PASS(b81dcfa4),增量复审 PASS(本 head,只改 changeset 文案)。should-fix 的代码部分 → #29527。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T17:49Z · head 0fbbbf7939 · Fixes #29422, Fixes #29421 (family claims; declaration revised on #29422)

  1. Shape: draft against main, a Fixes line for each card. A patch changeset for @objectstack/objectql, with a ! title, a BREAKING banner and the ADR-0087 marker not-required (no-migration-prescription). Clause-②: no (narrowing), Metadata-protocol: none. Not governed: 783 changed lines, 9 files.
  2. Against the order: fix shape A at the hook-dispatch seam, applied only to the landed-write refusal family. A non-aborting onError is honoured. The before* arm and the no-unit hedge are covered. objectql: the unlanded-write refusal from #29291 leads with an inner object on a nested cascade, reaches 500 by message heuristics, and claims "rolled back" when the rollback itself failed #29421 items 1–3 are done, with option B for item 2. The at-tier contract review confirms it: PASS, then a delta PASS on this head.
  3. Accepted deviations: the file surface extends to the rest and runtime tests (option B conditions), and a third commit carries the REST door pin. The pending fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land #29381 changeset is left as is: a foreign pending changeset cannot be edited, as the record ③ found.
  4. Gates: the dev reports, on b81dcfa4 (the code is unchanged at this head): objectql 10,398 tests and typecheck green, full dogfood green locally (397/400 files), lint clean, and dispatch-gates 63/63 with 0 NOT-MEASURED. Ablations went red on every leg, and the data-door byte-identity control stayed green. The changeset gates were re-run on this head. The seat readies the PR only when no check-run on this head is red or running.
  5. Out of scope: 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 (the sandboxed-hook relay keeps the lead and cause; every converted refusal is hedged on a failed rollback).
  6. Landing: pr_ready + automerge_enable through the relay.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 18:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 41499208591 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (2/8) — 失败步骤: Run this shard's tests

    @objectstack/spec:test:  FAIL   local  scripts/dropped-refinements.test.ts > the committed ledger > names at least one site per entry, and its header totals match its body
      ↳ 失败原因: @objectstack/spec:test: AssertionError: expected 993 to be 998 // Object.is equality
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 30 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

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/l tests tooling

Projects

None yet

1 participant