Skip to content

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

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

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-dv67oe

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 动作入口和 MCP run_action 只在声明式 update 动作上把拒绝里挂的结果当作成功。
  • 为什么改:以前这个拒绝直接抛进作者代码:写入其实已经生效,但正文下一行不再执行,动作、hook、任务或流程都报失败,调用方重试就会再写一遍。数据入口和 MCP 工具早已按成功答复,作者代码这一侧没有跟上(席位裁定 6570987196)。
  • 风险与回滚:写入前的拒绝(校验、权限、约束)完全照旧;没有新错误码、字段或形状,也没加事务。回滚就是整体 revert 本 PR,没有数据或元数据迁移。
  • 先看: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), whose written is exactly what the call returns on a healthy recompute. Each surface returns that written value and logs the stale column through staleSummaryAnswerLines (@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.

  • Shared layer (new packages/runtime/src/landed-write-answer.ts):
    • landedWriteAnswer(e, object, surface) is the one catch body.
    • 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(...), a transaction(fn) callback's context, beginTransaction()'s ctx). It walks the API through the existing deriveThroughSeam, and the verb list is the existing WRITE_RETURN_VERBS. Both are now exported from stored-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): insert answers { id } read from the written record; update resolves. A multi-id delete catches per id, so a stale roll-up after one id no longer leaves the later ids undeleted.
  • ctx.api (buildActionApi, objectql createContext): wrapped beneath serveStoredMetadataReadsThrough, so the answer is served by the reader seam like any write's return.
  • Sandbox 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 action api, or a job's executionContext + createContext fallback. Hook, action and job bodies all go through it.
  • Flow record nodes (builtin/crud-nodes.ts, @objectstack/service-automation): create_record / update_record / delete_record wrap their one data call. The node answers the same output, outputVariable, droppedFields and acted it answers on a clean write, with the stale line on the node's logger at error. A non-stale throw reaches the node's existing catch and is success: false as 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.
  • The action doors' generic earnedAnswerOf reading 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.
  • Changesets: new .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.md is corrected: it said a flow action'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 reds check-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 897a4c53 over origin/main 2af0ae2a. Each engine double opens its update / delete / findOne with the producer's predicate (assertEngineUpdateDispatch / assertEngineDeleteDispatch / assertEngineFindOnePredicate, @objectstack/metadata-core), so it refuses what ObjectQL refuses; scripts/engine-double-contract.pinned.json records the 7 new pinned rows (check-engine-double-contract --write, 0 lost).

  • packages/runtime/src/action-body-write-stale-summary.test.ts (14). The real HttpDispatcher over objectql's real ScopedContext (createContext), with an audit service and an engine double whose writes apply and then throw objectql's own SummaryRecomputeError.
    • ctx.engine insert, update and a multi-id delete (every id deleted).
    • ctx.api object(x).insert, update + delete, and a derived context (sudo()).
    • NEGATIVE PIN: five pre-write refusals (it.each) reach the body as before, nothing written.
    • Door pin: a script action body that throws a stale refusal carrying an attached answer. REST answers exactly what the same body answers when it throws the bare refusal, and never the attached answer. The MCP bridge throws the refusal with earnedAnswerOf undefined, code ERR_SUMMARY_RECOMPUTE, and an audit row failed.
    • One error line naming "stv_account.visit_count".
    • Red on base: 8 of 14. The 5 negatives and the log pin were green; on base the log line came from the REST door's generic carrier reading of the door pin, a reading this PR removes.
  • 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 (createContext fallback, own outcome), a NEGATIVE PIN (VALIDATION_FAILED / 400 caught in the body, nothing written), and one error line 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): a create_record → update_record → delete_record run reaching its end, with exactly one stale line; the create_record executor's output (record, droppedFields, acted: 1, outputVariable); and a NEGATIVE PIN where a PERMISSION_DENIED before 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 head e591f329)
# ablation file red
A1 ctx.engine.insert rethrows action-execution.ts 1 of 14
A2 ctx.engine.update rethrows action-execution.ts 1 of 14
A3 ctx.engine.delete rethrows action-execution.ts 1 of 14
A4 buildActionApi without the layer action-execution.ts 3 of 14
A5 buildSandboxApi without the layer sandbox/body-runner.ts 4 of 5
A6 create_record rethrows crud-nodes.ts 2 of 3
A7 update_record rethrows crud-nodes.ts 1 of 3
A8 delete_record rethrows crud-nodes.ts 1 of 3
N1 a pre-write refusal answered as success (landedWriteAnswer swallows every throw) landed-write-answer.ts 8 of 19
N2 the same in the flow nodes crud-nodes.ts 1 of 3
N3 REST door reads every arm's attached answer domains/actions.ts 1 of 14
N4 invokeBusinessAction lets a write's answer out action-execution.ts 1 of 14
N5 the audit row reads every arm's attached answer action-execution.ts 1 of 14

All 13 were first measured on 13f35e09, then rerun on 7c5f3e55 (after the double pinning) and on the final head e591f329 (after merging main with #29036), with the same counts each time. On the first round, the tries at A5, N3 and N5 were refused by ablation-replace with 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

Verification

Suites and typecheck
  • @objectstack/runtime full local suite on 281d91ae (after merging main at 692a4e4a): 516 of 516 files, 8337 passed and 19 skipped (os-verify-lock VERDICT command-exit 0).
  • @objectstack/service-automation typecheck exit 0 and full suite 258 of 258 files, 3312 of 3312 tests, on 281d91ae.
  • @objectstack/mcp stale-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 --noEmit and check:test-typecheck): exit 0. The first read was exit 1 on the untouched organization-ownership-boot-guard.turso-remote.kernel.test.ts (TS2307, @objectstack/driver-turso). That was a missing dist of the optional peer, not this diff; it was green after building @objectstack/driver-turso.
  • After merging main again at c44c8ac4 (head 7365c82a; main touched objectql and service-automation, not packages/runtime): service-automation typecheck exit 0 and full suite 259 of 259 files, 3317 of 3317 tests; runtime tsc --noEmit exit 0; runtime vitest over the new files, src/domains and src/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.
  • Final head e591f329, after merging main at 0c0d3b68 (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 as af8448fe, editing action-execution.ts; merged clean, both intents kept). Every packages/** package built (78 of 78). pnpm --filter @objectstack/runtime typecheck and pnpm --filter @objectstack/service-automation typecheck: exit 0. service-automation full suite: 260 of 260 files, 3338 of 3338 tests. runtime vitest over src/action-*, src/domains and src/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's api-action-forward.test.ts among them): 248 of 248 files, 4665 of 4665 tests.
Gates

On the final head e591f329, after building every packages/** package (turbo run build --filter='./packages/**', 78 of 78 tasks). The same 71 commands also ran on 7c5f3e55 with the same result:

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 71 commands; each was run with < /dev/null, and --ran reconciles 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN (exit 0).
  • 70 of 71 exit 0. The one red is node scripts/check-empty-changeset.mjs --base origin/main (exit 1), by design: it names .changeset/28856-action-stale-summary-answer.md as "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 Changeset is not a required check.
  • First pass on this branch (7365c82a): check:engine-double-contract exit 1, because the three new doubles did not open with the producer predicates. This was fixed in 7c5f3e55 (doubles pinned, ledger written), not by a baseline entry. check-plugin-teardown-shape --self-test exit 3 (prerequisite: the pinned fixture commit 621a4876 was missing from the shallow clone); green after git fetch --depth=1 origin 621a4876….
  • pnpm lint: exit 0.
  • PR body offline: node scripts/check-changeset-no-major.mjs --base origin/main --event <this body>: exit 0 (LEVEL AXIS: yes (widening) and none at patch). check-closing-keyword-parity --body: only Fixes #28861 binds.

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 SummaryRecomputeError with written after 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 real HttpDispatcher, ScopedContext, QuickJS runner and AutomationEngine. Full dogfood is not owed (no !, no narrowing arm).


Generated by Claude Code

…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>
…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>
@github-actions github-actions Bot added the size/l label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/service-automation, touching 20 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 39951d2a26590ff93226d3c0cf4ecfdc4136031e.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 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 — 34 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 39951d2a26590ff93226d3c0cf4ecfdc4136031e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 331a60e1f66764a7031fd732a2cde0e0921f6048 — the merge of head 684054c54ad170c6839588b07ed9ccd7757e9555 into base 39951d2a26590ff93226d3c0cf4ecfdc4136031e, 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 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

⚠️ 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 39951d2a26590ff93226d3c0cf4ecfdc4136031e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
… 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-steve · session_01ADHkmQJdgarUjPLNgFBY67

席位核对 · ACCEPT · PR #29283 · 2026-11-02T11:10Z

Head-sha: 684054c54ad170c6839588b07ed9ccd7757e9555

维护者速读(席位终稿)

核对六项

  1. 形态:draft → main;Fixes #28861,与认领 6572448969 一致;12 个文件 +889/-32,均在申报面内(runtime 写入面、sandbox runner、flow 记录节点、测试、changeset,及对 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 待发 changeset 一句的更正)。
  2. 前提实测:席位另起独立复审(只读,未写本改动)逐面读 diff:引擎只在写入已存下后才抛 SummaryRecomputeError,written 即正常返回值;其他错误一律原样重抛;沙箱自己的拒绝在新层之上,原样到达作者;QuickJS 桥走与正常写入相同的 jsonToHandle;flow 节点 output / droppedFields / outputVariable 不变,其他错误仍进原有 catch;日志用共享的 staleSummaryAnswerLines,每字段一条,双层包装不重复;租户与 RLS 上下文、sudo / withRunAs 派生都保留,所有写入都 await,无重试。dev 红先于 897a4c53,13 处消融(A1–A8、N1–N5)各转红并在终版 head 复跑同数。
  3. 门禁:dev 在 e591f329 跑 --ran 71/71、0 UNRUN,70 exit 0,pnpm lint 0;新 head 只改 changeset,dev 重跑 changeset 相关门禁(no-major 含离线 --event、adr-0087、changeset-gate 自测、deadline census、issue-citations、nul-bytes、doc-authoring)均 0。CI 在跑,入队前席位读本 head 全部 check-runs。
  4. 接受的偏差:open_question 取 A,保留对 .changeset/28856-action-stale-summary-answer.md 末句的更正(原句说 flow 记录节点把该拒绝变成节点失败、脚本 / 主体动作另立后续,本 PR 使其不再成立);改动只此一句,准确。门钉:REST 动作门与 invokeBusinessAction 只在声明式更新分支读 earnedAnswerOf,其他分支用 withoutWriteAnswer 剥掉附着,动作结果不会被主体写入的答案顶替(复审读 domains/actions.ts、mcp-http-tools.ts 确认)。
  5. 范围外去向:复审发现一条先于本 PR 存在的引擎问题:原子级联删除中子记录的汇总重算失败,事务回滚但 ERR_SUMMARY_RECOMPUTE 仍冒到外层,被各「已落地即成功」面答成功。数据接口 fix(lint,metadata-protocol,rest,runtime): refuse a sum/avg roll-up the engine cannot recompute, and answer a landed child write's success #28781 起即如此,已立卡 objectql: a cascade child's ERR_SUMMARY_RECOMPUTE escapes an atomic delete that rolled back, so every "landed write answers success" surface answers success for a delete that did not land #29291 给引擎处理。Metadata-protocol: none。
  6. 落地条款:Clause-②: yes (widening),声明复核在 TIER_DEFAULT 由席位做(本条);无 packages/spec/src/**,无新错误码 / 成员 / 形状,无收窄,免契约复审与 dogfood。Check Changeset(check-empty-changeset)红是更正他 PR 待发 changeset 的既定类别(DELIBERATE CORRECTION),不是必需检查,不恢复、不加 skip-changeset。semver:@objectstack/runtime 等 patch,与 changeset 一致。check-governed-merges --pr 29283:NOT governed。落地授权,维护者原话:「按照 pm-dispatch 规则复核过的都授权」(2026-10-25T11:35Z)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 11:57
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 11:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 153869d Nov 2, 2026
48 of 49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-dv67oe branch November 2, 2026 12:40
os-steve pushed a commit that referenced this pull request Nov 2, 2026
…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
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