Repository navigation
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
Conversation
…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>
…jectql Claude-Session: https://claude.ai/code/session_01ADHkmPCSqt8uDw17RmX9hu Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 20 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
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 |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract reviewServed-tier: Inputs read: PR #29519 body and file list (9 files, +754/-29, author ① Derived judgments
② Semver levelChangeset ③ Boundary flags
④ Design judgmentThe published spec on Attempts to break it, with findings (none a must-fix):
Judged on the tree at Implemented-by: 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>
|
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 |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 Contract reviewServed-tier: Delta review over the full record on ① Derived judgmentsThe ① items of the
② Semver levelUnchanged and still right: ③ Boundary flags
④ Design judgmentNo design surface moved. The narrowing judged on Judged on the tree at Implemented-by: VERDICT: PASS |
|
domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 维护者速读
席位意见:达档契约复审 PASS( 席位核对ACCEPT ·
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 41499208591 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
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 codeNarrowing note: the seat amended the claim from
Clause-②: noonce 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 abortingafter*hook's unit rolls both rows back. The action door also answers these refusals500instead of400. So the title carries!, full dogfood ran locally, and the seat runs a contract review at tier.维护者速读
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 withhookStoppedRefusal. Before, the refusal crossed it carrying the HOOK's write aswritten, 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 ascause. Consequences:after*hook's unit (inAfterAbortUnit) no longer seesSummaryRecomputeError, so it rolls both rows back, asHookSchema.onErrorabortsays. Only the outer write's OWN refusal still reaches it and commits.before*hooks go through the same seam: the outer write never ran.ERR_SUMMARY_RECOMPUTEfamily is converted, and every other hook error passes through unchanged (pinned). A hook declaredonError: 'log'swallows it before the seam, so the outer write still succeeds (pinned).#29421: the unlanded-write refusal (
summary-errors.ts):UnlandedWriteRefusal. When a cascade walk or a hook meets an earlier conversion, it re-leads with its own write and keeps the original refusal ascause. 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.status: 500andcode: '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 answers500 INTERNAL_ERRORinstead of400, with the same sentence.statusalone was measured and rejected: a declared 5xx with no code dropscodefrom the data door's body.ObjectQL.transaction's rollback throws, the refusal isrollbackFailedRefusal("…its rollback failed too, so what was written inside it may or may not be stored"), not "rolled back".ScopedContext.transactiondoes not swallow a rollback failure, so it is unchanged.Tests
packages/objectql/src/engine-hook-stale-summary-refusal.test.ts(7): abortingafterInsert,afterUpdateandafterDelete(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 stillERR_SUMMARY_RECOMPUTEand commits, and another hook error passes through unchanged.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 withcausekept; the rollback-throws hedge. CONTROLS: an unrelated code-less error, the leaf's own refusal, and a successful rollback.packages/rest/src/data-write-hook-stale-summary-refusal.test.ts(3, real SqlDriver): POST with a metadata-bound hook answers500with 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 answers201with its own row.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 fiveerr.codeassertions go from undefined to'INTERNAL_ERROR'plusstatus500;packages/runtime/src/action-delete-cascade-summary-rollback.test.ts, where the action door goes 400 → 500 withcode: INTERNAL_ERROR(the seat accepted this); andpackages/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
code". This PR's changeset says it supersedes that sentence; the fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land #29381 changeset was not edited.ui/**andpackages/clientwere grepped for a branch on an action's400. None reads it for these refusals.classifyLoadErroris for data fetches,flowResponse.tskeys onFLOW_FAILED, and the analytics400arm is another door.Verification
All readings on
objectstack-ai/objectstackat headb81dcfa4(the branch's three commits plus the changeset, merged with origin/main04e0c2c0), afterpnpm install --frozen-lockfileand a fullturbo 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. AnafterInserthook oncasewrites alinethroughctx.api, and the line's roll-up refuses.POST /data/casewith a metadata-bound hook answered201 {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/codecasewith a code-registered hook (no unit) gave the same shape. Both rows were stored.code ERR_SUMMARY_RECOMPUTE,written= the line row, failuresline -> bucket.memo_sum.POST /data/lineanswered201with its own row.Item 2: door readings for the three shapes of the converted error
DELETEPOST{error, code: INTERNAL_ERROR}{error:{code: VALIDATION_ERROR, message, httpStatus:400}}status: 500only{error}, withcodedroppedINTERNAL_ERROR+ sentencestatus: 500+code: INTERNAL_ERROR(this PR){error, code: INTERNAL_ERROR}, byte-identicalINTERNAL_ERROR+ sentenceTests
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.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. runtimesrc/action-delete-cascade-summary-rollback.test.ts: 2 tests passed.pnpm --filter @objectstack/dogfood testunder 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).--no-inline-configonpackages/objectql/srcplus the three door test files: exit 0, no output.Ablations (each restored, with git diff HEAD empty, by scripts/ablation-replace.mjs)
triggerHooks:throw hookStoppedRefusal(…)→throw e), engine pin: 4 failed | 3 passed.AssertionError: expected 'ERR_SUMMARY_RECOMPUTE' to be undefinedon the three after* cases, the code-hook case andbeforeInsert. The three controls stay green.relead && e instanceof UnlandedWriteRefusal ? e.cause : e→e): 2 failed | 5 passed. The three-level case readsThe delete of "line" did not complete….rollbackFailed ? rollbackFailedRefusal(err) : rolledBackRefusal(err)→rolledBackRefusal(err)): 1 failed | 6 passed. It readsThe transaction rolled back, so nothing written inside it was stored.ablation-dist-preflightshowing the marker present: data-door pins 3 failed (expected { error: 'Internal server error' } to deeply equal { error, code }); the action pin stays green.expected 400 to be 500, body codeVALIDATION_ERROR.ablation-dist-preflight --absentpassed for both markers.Gates: dispatch-gates --commands, 63 derived
--ranreconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED".check-plugin-teardown-shape --self-testneeded its pinned fixture commit fetched.check:type-check-debthad queued behind this branch's own dogfood lock past its budget; on the re-run it was OK, 0 raw errors.Generated by Claude Code