Skip to content

fix(metadata-protocol,rest,runtime,mcp): the remaining write doors answer a write that landed under a failing roll-up with their own success - #28843

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-1minft
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-1minft

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

Requested by Leehom · project thread

Fixes #28782

Clause-②: no
Metadata-protocol: none
Skills-docs: none — no file under skills/** teaches what a write door answers when a parent roll-up cannot be recomputed after the write.

维护者速读

What changes

One reading for every remaining door, through #28781's carrier in @objectstack/types (withEarnedAnswer / earnedAnswerOf / staleSummaryAnswerLines); no new carrier, code, member, status or header.

  • Producers attach the answer the write earned to the refusal and rethrow, as createData does since 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, so a caller that does not read it still gets the refusal:
  • Doors read it:
    • packages/rest: public form submit (form-routes.ts, at the createData call, then the door's own 201 { id, fields? } projection), createMany (batch-routes.ts) and clone (data-action-routes.ts) answer 201 with the earned body. stale-summary-answer.ts gains earnedAnswerLogged (log + answer, no dropped-fields header — these doors' success paths set none); earnedWriteDespiteStaleSummary is now that plus the header.
    • packages/mcp: the create_record tool (mcp-http-tools.ts), for both bridges — the runtime bridge reaches the protocol's createData through callData('create').
  • Per-row bulk loops (runBatchDataLoop, runUpdateManyLoop, runDeleteManyLoop in protocol-batch-data.ts): each engine.insert / update / delete gets .catch(landedDespiteStaleSummary), which on the non-atomic arm hands back the refusal's written so the row is built by the arm's own success code ({ id, success: true, data, index }, the delete arm's success), and the loop goes on. On the atomic arm it rethrows: the refusal is raised inside the batch transaction and the rollback answer is the truth. These loops answer a normal success, so they log the stale column themselves ([Protocol], same line).

What written carries. Measured in objectql: a multi-row insert throws once, after every row is stored, with written = all rows (engine.ts insert path); a per-row loop's refusal belongs to that row only. Nothing throws after a partial set.

Tests

Each new file red on origin/main c9a9981da (implementation reverted on disk, metadata-protocol rebuilt) and green on the head.

  • packages/rest — data-write-summary-recompute-other-doors.test.ts (9, RestServer → protocol → ObjectQL → real SqlDriver on better-sqlite3): form submit 201 { id }, row stored once; createMany 201 { object, records, count }; per-object batch create, updateMany, deleteMany (non-atomic) all rows success, succeeded: 2, values stored once and child_sum recomputed; clone 201; an atomic batch still rolls back with nothing stored; a pre-write refusal (missing required parent) answers as before; one error line. Base: 7 red, 2 green (the atomic and pre-write guards).
  • packages/runtime — domains/data-write-summary-recompute-fallback.test.ts (5): the dispatcher with no protocol service, POST 201 / PATCH 200 / DELETE 200, stored once, roll-ups recomputed, pre-write refusal unchanged, one host-logger line. Base: 4 red.
  • packages/mcp — mcp-create-record-stale-summary.test.ts (4, over the HTTP /mcp transport): stdio bridge success body with the row stored once; runtime-bridge shape relays the attached answer; a pre-write refusal is still isError; one log line. Base: 3 red.
  • Ablations, each landed on disk then restored (git diff HEAD empty after each): form reading off → 1 red; createMany reading off → 1 red; clone reading off → 1 red; bulk-loop helper rethrowing always (mp rebuilt) → 3 red (batch, updateMany, deleteMany); createManyData throwing the bare refusal (rebuilt) → 1 red; cloneData the same (rebuilt) → 1 red; fallback throwing the bare refusal → runtime 4 red; MCP tool reading off → 3 red; stdio bridge throwing the bare refusal → 1 red.
  • Suites on 6e3035f3: @objectstack/metadata-protocol 22074 passed (26 skipped), @objectstack/rest 7217 passed (352 skipped), @objectstack/runtime 9242 passed (19 skipped), @objectstack/mcp 635/635.
  • Full dogfood on 6e3035f3 (after merging main, closure rebuilt): Test Files 380 passed, 3 skipped (383); Tests 3114 passed, 28 skipped (3142).

Acceptance notes

  • No wire shape change. Every door answers a body and status it already publishes for the same write when every roll-up recomputes; @objectstack/types is untouched.
  • Atomic arms unchanged, by measurement. options.atomic batches and the cross-object POST /batch raise the refusal inside engine.transaction; the loop's failure rolls the batch back, so no row landed and a retry duplicates nothing. Committing them under a stale roll-up would be a new behaviour, not the reading this card asks for.
  • Not changed. MCP update_record / delete_record (same callData path, not named by the card), insertManyData (the import runner's partial create, ruling 3), import-runner.ts, seed-loader.ts.
  • No packages/spec/** edit; the code is read inline (check:error-code-provenance exit 0).

Verification

Live probe, base vs head, each on a fresh showcase boot. To store the stale roll-up the head's save gate refuses, both boots ran with @objectstack/lint's roll-up entry off at the runtime door (runtimeTypes: [], restored after); base = metadata-protocol/rest/runtime/mcp at origin/main, head = this branch.

pnpm dev -- --fresh -p <port>
curl -X POST /api/v1/auth/sign-in/email -d '{"email":"admin@objectos.ai","password":"admin123"}'
curl -X POST /api/v1/packages -d '{"manifest":{"id":"app.qsf","name":"QSF","version":"0.1.0","type":"app","namespace":"qsf"},"enableOnInstall":true}'
curl -X PUT '/api/v1/meta/object/qsf_parent?mode=draft&package=app.qsf' -d '<child_sum = sum(amount), child_count = count, probe_sf = sum(qsf_child.memo)>'
curl -X PUT '/api/v1/meta/object/qsf_child?mode=draft&package=app.qsf' -d '<memo text, amount number, parent master_detail>'
curl -X POST /api/v1/packages/app.qsf/publish-drafts -d '{}'
curl -X POST /api/v1/data/qsf_child/batch -d '{"operation":"create","records":[{"data":{"name":"b1","amount":3,"parent":"qsfp1"}},{"data":{"name":"b2","amount":4,"parent":"qsfp1"}}]}'
curl -X POST /api/v1/data/qsf_child/createMany -d '[{"name":"m1","amount":1,"parent":"qsfp1"}]'
curl -X POST /api/v1/data/qsf_child/qsfc2/clone -d '{"name":"k1"}'
curl -X POST /api/v1/forms/qsf/submit -d '{"name":"form1","amount":7,"parent":"qsfp1"}'
door base head
batch (create ×2, non-atomic) 200, row 0 success:false ERR_SUMMARY_RECOMPUTE, row 1 NOT_ATTEMPTED; b1 stored, b2 not 200, succeeded: 2, both rows success:true, b1 and b2 stored once
createMany 500 INTERNAL_ERROR, m1 stored 201 { object, records, count }, m1 stored once
clone 500 INTERNAL_ERROR, k1 stored 201 { object, id, sourceId, record }, k1 stored once
form submit 400 reference_not_found before the write same

The form door does not reach the post-write path live: it runs as the anonymous caller even with a session cookie, and the reference check refuses a master-detail parent that caller cannot see, before the write, on base and head alike. Its post-write answer is pinned on a real store by the REST test above. The head boot logged one error line for qsf_parent.probe_sf.

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 71 commands on the diff (63 before the engine-double ledger change widened it); all 71 exit 0 (check:engine-double-contract first read exit 1 on the new MCP double, fixed by routing it through the engine dispatch asserts and --write), and --ran reconciles 71 derived, 71 run, 0 NOT-MEASURED. pnpm --filter @objectstack/spec check:error-code-provenance: exit 0. Scoped eslint over the 12 changed .ts files: exit 0. pnpm check:nul-bytes: exit 0. tsc --noEmit of metadata-protocol, rest, runtime, mcp: exit 0 each. After the final merge of main (a4d7f538): check:engine-double-contract and check:error-code-provenance exit 0, closures rebuilt, the new and #28781 door files green (rest 14, runtime 9, mcp 36 with the stdio exposure suite), metadata-protocol typecheck exit 0; the full dogfood above ran on 6e3035f3, before that merge.


Generated by Claude Code

…swer a write that landed under a failing roll-up with their own success

Co-Authored-By: Claude <noreply@anthropic.com>
…/catch

Co-Authored-By: Claude <noreply@anthropic.com>
…ccess

Co-Authored-By: Claude <noreply@anthropic.com>
…engine dispatch asserts; ledger learns it

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 1, 2026
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/mcp, @objectstack/metadata-protocol, @objectstack/rest, @objectstack/runtime, touching 21 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), data.batch (sdk, the route ledger binds it to POST /api/v1/data/:object/batch, selected by route anchor /:object/batch), data.createMany (sdk, the route ledger binds it to POST /api/v1/data/:object/createMany, selected by route anchor /:object/createMany), /:object/batch (route, bridged from symbol createManyData — its route source's handler names it))
  • content/docs/api/data-api.mdx (via createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), /:object/:id/clone (route, bridged from symbol cloneData — its route source's handler names it; bridged from symbol earnedAnswerLogged — its route source's handler names it), /:object/batch (route, bridged from symbol createManyData — its route source's handler names it), /:object/createMany (route, bridged from symbol createManyData — its route source's handler names it; bridged from symbol earnedAnswerLogged — its route source's handler names it))
  • content/docs/api/declarative-endpoints.mdx (via callData (symbol, a top-level function))
  • content/docs/api/wire-format.mdx (via /:object/batch (route, bridged from symbol createManyData — its route source's handler names it))
  • content/docs/data-modeling/fields.mdx (via createManyData (symbol, a top-level function))
  • content/docs/frontend/data-source.mdx (via /:object/batch (route, bridged from symbol createManyData — its route source's handler names it))
  • content/docs/getting-started/quick-reference.mdx (via /:object/batch (route, bridged from symbol createManyData — its route source's handler names it))
  • content/docs/kernel/contracts/data-engine.mdx (via cloneData (symbol, a method of class ObjectStackProtocolImplementation), createManyData (symbol, a top-level function))
  • content/docs/kernel/services-checklist.mdx (via createManyData (symbol, a top-level function))
  • content/docs/permissions/authorization.mdx (via callData (symbol, a top-level function))
  • content/docs/permissions/system-context.mdx (via callData (symbol, a top-level function))
  • content/docs/protocol/kernel/error-handling.mdx (via createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany))
  • content/docs/protocol/kernel/http-protocol.mdx (via callData (symbol, a top-level function), createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), /:object/batch (route, bridged from symbol createManyData — its route source's handler names it))

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

  • content/docs/releases/implementation-status.mdx (via createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), /:object/:id/clone (route, bridged from symbol cloneData — its route source's handler names it; bridged from symbol earnedAnswerLogged — its route source's handler names it), /:object/batch (route, bridged from symbol createManyData — its route source's handler names it), /:object/createMany (route, bridged from symbol createManyData — its route source's handler names it; bridged from symbol earnedAnswerLogged — its route source's handler names it))
  • content/docs/releases/v16.mdx (via createMany (sdk, the bare tail of client method data.createMany, bound to POST /api/v1/data/:object/createMany), data.batch (sdk, the route ledger binds it to POST /api/v1/data/:object/batch, selected by route anchor /:object/batch))
  • content/docs/releases/v17/17-4.mdx (via cloneData (symbol, a method of class ObjectStackProtocolImplementation), createManyData (symbol, a top-level function), /:object/batch (route, bridged from symbol createManyData — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via /:object/:id/clone (route, bridged from symbol cloneData — its route source's handler names it; bridged from symbol earnedAnswerLogged — its route source's handler names it))

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
  • 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 — 48 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 839aae58c21df11ecafabe631e661c2cfad69199 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 839aae58c21df11ecafabe631e661c2cfad69199

⚠️ 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 839aae58c21df11ecafabe631e661c2cfad69199 → 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-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

席位核对

ACCEPT · domain:server seat 5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt · 2026-11-01T23:42Z · head a4d7f538 · #28782

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

席位意见:与 diff 相符。写入已落库、只是父记录汇总重算失败时,表单提交、批量创建、非原子批量写、clone、callData 回退和 MCP create_record 不再答 500,而是答各自原有的成功结果;原子批量照旧整体回滚,那里本来就没有落库。

  1. Shape: draft against main, Fixes #28782, patch changesets for @objectstack/metadata-protocol, @objectstack/rest, @objectstack/runtime, @objectstack/mcp; Clause-②: no, Metadata-protocol: none. No contract review owed (reviewFace reads no schema, rules or narrowing face). check-governed-merges --pr 28843: NOT governed, +758 / -27. git merge-tree against main: clean.
  2. Against the order: ruling 1 → one reading through 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's carrier at every door: createManyData / cloneData / callData's fallback / the MCP stdio bridge attach the earned answer, and the REST form submit, createMany and clone doors and the MCP create_record tool relay it (earnedAnswerLogged, the shared reader with the stale-summary log line); ruling 2 → non-atomic batch, updateMany and deleteMany loops turn a stored row into its success row and keep going, while atomic batches and cross-object POST /batch stay as they are (the refusal is raised inside the transaction, nothing landed); ruling 3 → pre-write refusals untouched, import and seed readings untouched; ruling 4 → no spec edit, check:error-code-provenance exit 0, no types change.
  3. Accepted deviations: the live form probe could not reach the post-write path (the form door runs as the anonymous caller and the reference check refuses first, on base and head alike), so a real-store REST test covers that door. Full dogfood ran before the last main merge.
  4. Gates: red first REST 7, runtime 4, MCP 3 on base; 9 ablations each red; live probe base vs head (batch: 1 stored row reported failed becomes 2 successes; createMany 500 → 201; clone 500 → 201). 71/71 derived gates exit 0, --ran 0 NOT-MEASURED; dogfood 380 files / 3114 tests passed. Check-runs on this head still running at ACCEPT; auto-merge waits on every required one.
  5. Out of scope: the dev's open question (MCP update_record and delete_record share the gap, unnamed by the card) is answered as a follow-up card, mcp: update_record and delete_record still answer an error after a landed write when the parent summary recompute fails, so an agent retries a change that already happened #28844 (p2, blocked on this card).
  6. Landing: pr_ready + automerge_enable through the relay.

…ord stale-summary test

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

席位核对 · re-land

ACCEPT · domain:server seat 5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt · 2026-11-01T23:49Z · head dce87858 · #28782

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

席位意见:与上次 ACCEPT(6565651976,head a4d7f538)相同;本次只改新 MCP 测试的一行类型导入,修复 check:test-typecheck 的红。

  1. Shape: unchanged.
  2. Against the order: the delta is one test file: ExecutionContext now imported from @objectstack/spec/kernel (where it lives) instead of @objectstack/spec/contracts (TS2724). No source change; test-typecheck-debt.json untouched.
  3. Accepted deviations: as before.
  4. Gates: the red on a4d7f538 was Type Check · workspace → @objectstack/mcp check:test-typecheck, this PR's own new test. Dev: pnpm --filter @objectstack/mcp typecheck exit 0, and the rest / runtime / metadata-protocol typechecks exit 0. Check-runs on this head still running; auto-merge stays armed and waits on every required one.
  5. Out of scope: as before (mcp: update_record and delete_record still answer an error after a landed write when the parent summary recompute fails, so an agent retries a change that already happened #28844).
  6. Landing: auto-merge already enabled; no new relay op.

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