Repository navigation
fix(runtime,service-automation): a write in an action, hook or job body, or a flow record node, that landed under a failing roll-up answers its own success - #29283
Conversation
…landed under a failing roll-up (red first) Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
…d under a failing roll-up answers its success ctx.engine, ctx.api and every sandboxed body's ctx.api answer a write the engine stored, whose parent roll-up could not be recomputed after it, with the write's own result; the flow record nodes answer it as the node's success. The stale column is logged once per field at error. A refusal before the write is unchanged. The REST action door and the MCP run_action door read an attached answer on the declarative-update arm only. Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
… makes false Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
…-dv67oe Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
…es with the producer's dispatch predicates The three new #28861 suites' engine doubles now open update / delete / findOne with assertEngineUpdateDispatch / assertEngineDeleteDispatch / assertEngineFindOnePredicate, so each fake refuses what ObjectQL refuses, and the pinned ledger records them (check-engine-double-contract --write). Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
…-dv67oe Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 34 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 331a60e1f66764a7031fd732a2cde0e0921f6048 && git checkout 331a60e1f66764a7031fd732a2cde0e0921f6048
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 39951d2a26590ff93226d3c0cf4ecfdc4136031e 684054c54ad170c6839588b07ed9ccd7757e9555 && git checkout -B drift-repro 39951d2a26590ff93226d3c0cf4ecfdc4136031e && git merge --no-ff 684054c54ad170c6839588b07ed9ccd7757e9555
node scripts/docs-audit/affected-docs.mjs --json 39951d2a26590ff93226d3c0cf4ecfdc4136031e
|
… over a landed write under a failing roll-up (#28861 seat review) Claude-Session: https://claude.ai/code/session_01ADHkmQEc6KnPaxi9d3soLZ Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67 席位核对 · ACCEPT · PR #29283 · 2026-11-02T11:10ZHead-sha: 维护者速读(席位终稿)
核对六项
|
…ascade as a refusal; Clause-② reads no (narrowing) An action body's ctx.engine.delete (PR #29283 answers a landed write's stale-roll-up refusal as success) whose master_detail cascade a dependent's failing roll-up stopped now answers the action door's 400 refusal, the body does not run on, and nothing is deleted. Ablating the engine conversion answers 200 {success:true,data:{deleted:'a1'}} for a delete that rolled back. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr
Requested by Steve · project thread
Fixes #28861
Clause-②: yes (widening)
Metadata-protocol: none
Skills-docs: none — no file under
skills/**or the docs teaches what a body's or a flow node's write answers when a parent roll-up cannot be recomputed after it, and this changes no authorable shape维护者速读
ctx.engine/ctx.api、沙箱里 hook / 动作 / 任务正文的ctx.api.object(x)、以及流程的create_record/update_record/delete_record节点,在写入已落库、只是父记录汇总重算失败(ERR_SUMMARY_RECOMPUTE)时,返回这次写入本来的成功结果,正文和流程继续往下跑;过期汇总列按字段每进程以 error 记一次日志。REST 动作入口和 MCPrun_action只在声明式 update 动作上把拒绝里挂的结果当作成功。packages/runtime/src/action-execution.ts(invokeBusinessAction的 catch 和withoutWriteAnswer、buildActionEngineFacade的多 id 删除);packages/runtime/src/landed-write-answer.ts;packages/services/service-automation/src/builtin/crud-nodes.ts。pnpm --filter @objectstack/runtime exec vitest run src/action-body-write-stale-summary.test.ts src/sandbox/body-write-stale-summary.test.ts,应看到 2 个文件、19 个测试全部通过。What changes
Seat ruling 6570987196 on #28861: a write the engine stored answers its own success at every author-visible write surface when only the parent roll-up recompute after it fails. The refusal is objectql's
SummaryRecomputeError(ERR_SUMMARY_RECOMPUTE), whosewrittenis exactly what the call returns on a healthy recompute. Each surface returns thatwrittenvalue and logs the stale column throughstaleSummaryAnswerLines(@objectstack/types), the same line the data doors log. Every other throw, including a refusal raised before the write is stored, is rethrown unchanged (staleSummaryRefusalOrThrow). No new error code, member, shape or transaction.packages/runtime/src/landed-write-answer.ts):landedWriteAnswer(e, object, surface)is the onecatchbody.answerLandedWritesDespiteStaleSummary(api, surface)wraps every repository write verb of a scoped API, on the API and on every context it derives (object(name),sudo(),withRunAs(...), atransaction(fn)callback's context,beginTransaction()'sctx). It walks the API through the existingderiveThroughSeam, and the verb list is the existingWRITE_RETURN_VERBS. Both are now exported fromstored-metadata-reader-seam.ts, so there is one definition of what a write is and what an API derives. Idempotent.ctx.engine(buildActionEngineFacade,action-execution.ts):insertanswers{ id }read from the written record;updateresolves. A multi-iddeletecatches per id, so a stale roll-up after one id no longer leaves the later ids undeleted.ctx.api(buildActionApi, objectqlcreateContext): wrapped beneathserveStoredMetadataReadsThrough, so the answer is served by the reader seam like any write's return.ctx.api.object(x)(buildSandboxApi,sandbox/body-runner.ts): wrapped beneath both body refusal layers, over whichever source the API is built from: the engine's hook API, the host's actionapi, or a job'sexecutionContext+createContextfallback. Hook, action and job bodies all go through it.builtin/crud-nodes.ts,@objectstack/service-automation):create_record/update_record/delete_recordwrap their one data call. The node answers the sameoutput,outputVariable,droppedFieldsandactedit answers on a clean write, with the stale line on the node's logger aterror. A non-stale throw reaches the node's existingcatchand issuccess: falseas before.packages/runtime/src/domains/automation.ts(feat(automation): an oauth2 connector with clientSource: 'installer' connects with the administrator's OAuth client #29097's file) is not touched.earnedAnswerOfreading is pinned to the declarative-update arm. That arm composes and attaches the ACTION's result (actions: run_action and the REST action run door still answer an error after an action's write lands when the parent summary recompute fails, so the caller runs the action again #28856). Any other arm's refusal could only carry a WRITE's data answer, for example a handler that let out a refusal a data door had attached its answer to.dispatchAdmittedRestAction's catch,domains/actions.ts): reads it only whenisDeclarativeUpdateAction(actionDef); any other arm answers the failure as before actions: run_action and the REST action run door still answer an error after an action's write lands when the parent summary recompute fails, so the caller runs the action again #28856.invokeBusinessAction(MCP): the audit row issucceededonly on the declarative arm. Any other arm's throw leaves with nothing attached (withoutWriteAnswerrebuilds the sameSummaryRecomputeError: class,failures,written, message, stack). Sorun_action's genericlandedWriteResultOrErrorinpackages/mcp/src/mcp-http-tools.ts(not edited) cannot relay a write's answer as the action's result either..changeset/28861-body-write-stale-summary-answer.md(runtime and service-automation patch). The last sentence of the still-pending.changeset/28856-action-stale-summary-answer.mdis corrected: it said aflowaction's record nodes turn the refusal into a node failure and that script/body was a follow-up, and both stop being true with this PR. This deliberately redscheck-empty-changeset(its DELIBERATE CORRECTION class: "do NOT restore it -- say so on the PR and get it confirmed"). Confirmation is asked of the seat here.Tests
No existing test was edited (no forced test edits). Three new files, red first on the test-only commit
897a4c53overorigin/main2af0ae2a. Each engine double opens itsupdate/delete/findOnewith the producer's predicate (assertEngineUpdateDispatch/assertEngineDeleteDispatch/assertEngineFindOnePredicate,@objectstack/metadata-core), so it refuses what ObjectQL refuses;scripts/engine-double-contract.pinned.jsonrecords the 7 new pinned rows (check-engine-double-contract --write, 0 lost).packages/runtime/src/action-body-write-stale-summary.test.ts(14). The realHttpDispatcherover objectql's realScopedContext(createContext), with an audit service and an engine double whose writes apply and then throw objectql's ownSummaryRecomputeError.ctx.engineinsert, update and a multi-id delete (every id deleted).ctx.apiobject(x).insert,update+delete, and a derived context (sudo()).it.each) reach the body as before, nothing written.earnedAnswerOfundefined, codeERR_SUMMARY_RECOMPUTE, and an audit rowfailed.errorline naming"stv_account.visit_count".packages/runtime/src/sandbox/body-write-stale-summary.test.ts(5), on the QuickJS runner: a hook body (the engine's hook API, patch merged), an action body (update + delete, own value returned), a job body (createContextfallback, own outcome), a NEGATIVE PIN (VALIDATION_FAILED/400caught in the body, nothing written), and oneerrorline naming"stb_order.line_total". Red on base: 4 of 5, the negative green.packages/services/service-automation/src/builtin/crud-stale-summary.test.ts(3): acreate_record→update_record→delete_recordrun reaching its end, with exactly one stale line; thecreate_recordexecutor's output (record,droppedFields,acted: 1,outputVariable); and a NEGATIVE PIN where aPERMISSION_DENIEDbefore the write still fails the run with nothing written. Red on base: 2 of 3, the negative green.Ablations: each mutation landed on disk with
ablation-replace, red, then restored to blob == HEAD (final heade591f329)ctx.engine.insertrethrowsaction-execution.tsctx.engine.updaterethrowsaction-execution.tsctx.engine.deleterethrowsaction-execution.tsbuildActionApiwithout the layeraction-execution.tsbuildSandboxApiwithout the layersandbox/body-runner.tscreate_recordrethrowscrud-nodes.tsupdate_recordrethrowscrud-nodes.tsdelete_recordrethrowscrud-nodes.tslandedWriteAnswerswallows every throw)landed-write-answer.tscrud-nodes.tsdomains/actions.tsinvokeBusinessActionlets a write's answer outaction-execution.tsaction-execution.tsAll 13 were first measured on
13f35e09, then rerun on7c5f3e55(after the double pinning) and on the final heade591f329(after mergingmainwith #29036), with the same counts each time. On the first round, the tries at A5, N3 and N5 were refused byablation-replacewith nothing changed on disk ("the replacement count moved 1 -> 1"), because the replacement sat inside its anchor. They were rerun with a/* ablated */-prefixed replacement; the counts above are those runs.Acceptance notes
.changeset/28856-action-stale-summary-answer.mdis still pending, and@objectstack/runtimeis at18.2.0. No published surface narrows.ScopedContextand the engine itself. Bulk callers (the seed loader, the import runner) read the refusal as "written, with a stale summary" in their own results. An in-process code hook'sHookContext.apiis platform or deployer code, not an author's body. Both are unchanged.ObjectRepository.execute()hands a nested handler a rawScopedContext. Its own census lists zero callers, so it reaches no author today.warnper failed recompute is unchanged; the surfaces add the doors' once-per-fielderrorline.packages/spec/**orui/**edit, and no workflow file.Verification
Suites and typecheck
@objectstack/runtimefull local suite on281d91ae(after mergingmainat692a4e4a): 516 of 516 files, 8337 passed and 19 skipped (os-verify-lockVERDICT command-exit 0).@objectstack/service-automationtypecheck exit 0 and full suite 258 of 258 files, 3312 of 3312 tests, on281d91ae.@objectstack/mcpstale-summary neighbours (mcp-run-action-,mcp-create-record-,mcp-update-delete-record-stale-summary.test.ts): 13 of 13.pnpm --filter @objectstack/runtime typecheck(tsc --noEmitandcheck:test-typecheck): exit 0. The first read was exit 1 on the untouchedorganization-ownership-boot-guard.turso-remote.kernel.test.ts(TS2307,@objectstack/driver-turso). That was a missingdistof the optional peer, not this diff; it was green after building@objectstack/driver-turso.mainagain atc44c8ac4(head7365c82a;maintouched objectql and service-automation, notpackages/runtime): service-automation typecheck exit 0 and full suite 259 of 259 files, 3317 of 3317 tests; runtimetsc --noEmitexit 0; runtime vitest over the new files,src/domainsandsrc/sandbox: 219 of 219 files, 4192 of 4192 tests.7c5f3e55(the doubles opened with the producer predicates): the three new files 19 of 19 and 3 of 3.e591f329, after mergingmainat0c0d3b68(fix(runtime): list_actions marks revoke_api_key unavailable to an API-key caller, with the API-key guard's own refusal (#28943) #29036 landed there asaf8448fe, editingaction-execution.ts; merged clean, both intents kept). Everypackages/**package built (78 of 78).pnpm --filter @objectstack/runtime typecheckandpnpm --filter @objectstack/service-automation typecheck: exit 0. service-automation full suite: 260 of 260 files, 3338 of 3338 tests. runtime vitest oversrc/action-*,src/domainsandsrc/sandbox(fix(runtime): list_actions marks revoke_api_key unavailable to an API-key caller, with the API-key guard's own refusal (#28943) #29036'sapi-action-forward.test.tsamong them): 248 of 248 files, 4665 of 4665 tests.Gates
On the final head
e591f329, after building everypackages/**package (turbo run build --filter='./packages/**', 78 of 78 tasks). The same 71 commands also ran on7c5f3e55with the same result:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 71 commands; each was run with< /dev/null, and--ranreconciles 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).node scripts/check-empty-changeset.mjs --base origin/main(exit 1), by design: it names.changeset/28856-action-stale-summary-answer.mdas "present on the merge base and CHANGED by this PR". This is its DELIBERATE CORRECTION class (see What changes), declared here for the seat to confirm.Check Changesetis not a required check.7365c82a):check:engine-double-contractexit 1, because the three new doubles did not open with the producer predicates. This was fixed in7c5f3e55(doubles pinned, ledger written), not by a baseline entry.check-plugin-teardown-shape --self-testexit 3 (prerequisite: the pinned fixture commit621a4876was missing from the shallow clone); green aftergit fetch --depth=1 origin 621a4876….pnpm lint: exit 0.node scripts/check-changeset-no-major.mjs --base origin/main --event <this body>: exit 0 (LEVEL AXIS:yes (widening)andnoneatpatch).check-closing-keyword-parity --body: onlyFixes #28861binds.No live probe: storing a stale roll-up needs the lint roll-up gate turned off at the runtime door (the #28782 recipe). The producer half, the engine raising
SummaryRecomputeErrorwithwrittenafter the write, is objectql's and pinned there (engine-summary-retry.test.ts,summary-recompute-code-constant.test.ts); this change is the author-facing surfaces, pinned above through the realHttpDispatcher,ScopedContext, QuickJS runner andAutomationEngine. Full dogfood is not owed (no!, no narrowing arm).Generated by Claude Code