Skip to content

feat(spec,metadata-protocol): the publish receipt names the rows a seed created and the rows it only matched - #30427

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-5eqwnz
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-5eqwnz

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Requested by Yibo Wang · project thread

Fixes #30348

Ruling-ref: objectstack-ai/cloud#3598 (6651768430)

Clause-②: yes (widening: an optional, capped per-object landed key on both seedApplied receipts, and insertedIds on the seed loader's per-dataset result)
Metadata-protocol: added
Skills-docs: none owed — no skill, eval or doc teaches seedApplied or landedIds (git grep -n "seedApplied\|landedIds" -- skills content/docs ':!content/docs/references' ':!content/docs/releases': 0 hits; control git grep -c "publish" -- skills/objectstack-ai/SKILL.md: 5); the generated references (content/docs/references/api/protocol.mdx, data/seed-loader.mdx) are regenerated here. The skills lane is paused; nothing is owed to it from this PR.
Prior rulings read: seedapplied,landedids,publish receipt → 1 hits; ADR-0038 Decision §4; thread: none; repo: objectstack-ai/objectstack

维护者速读

  • 改了什么:两个发布接口的回执 seedApplied 新增可选字段 landed,按对象分两张 id 列表:inserted(本次发布新建的行)和 matched(种子按自然键匹配到的已有行),每张最多 200 个 id,超出时 truncated: true 并给出未截断的 total;种子加载器结果新增 insertedIds。
  • 为什么改:执行 cloud#3598 裁决 A。cloud 的「清理示例数据」需要知道发布写了哪些行,回执原来只给条数。拆成两张表是因为默认 ignore 模式下,种子会"落"在键相同的客户已有行上而不写它;只给一张表会让 cloud 删掉客户自己的行。
  • 风险与回滚:纯增字段、可选,旧回执原样解析;代价是公开发布响应里出现行 id(受 200 上限约束,裁决已接受)。回滚即还原本 PR。cloud 只能清理 inserted 里的行,且要跨多次发布累积,因为再次发布时早先插入的行会出现在 matched。
  • 先看:packages/metadata-protocol/src/seed-loader.ts 的 insertedRecordIndexes(两个插入点);packages/metadata-protocol/src/seed-applied-landed.ts(合并、去重、截断);packages/spec/src/api/protocol.zod.ts 的 SeedAppliedLandedSchema 与上限常量。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/publish-seed-receipt-landed-ids.test.ts,预期 9 例全绿(含客户已有行只进 matched、超限截断、空种子给两张空表、无种子无字段)。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/spec (packages/spec/src/api/protocol.zod.ts): seedApplied.landed, one shared schema on both publish responses.
    • SeedAppliedLandedSchema: { object, inserted, matched }, each list a SeedAppliedIdListSchema { ids, total, truncated } with all three required. ids.max(SEED_APPLIED_LANDED_IDS_CAP), in the loader's record order (datasets in load order, records as authored), not the order the writes completed (the loader buffers inserts and flushes them after later skips).
    • SEED_APPLIED_LANDED_IDS_CAP = 200 (source below).
    • PublishMetaItemResponseSchema.seedApplied.landed and PublishPackageDraftsResponseSchema.seedApplied.landed both point at it, with one description text. Optional: absent means "not reported", never "nothing landed".
    • The PublishMetaItemResponseSchema docblock (the three conditional receipts) names the key, the two lists, the cap, the truncation shape and the accepted cost.
  • @objectstack/spec (packages/spec/src/data/seed-loader.zod.ts): SeedLoadResult.insertedIds, the ids among landedIds whose row the load created. landedIds' TSDoc now states what each mode puts in it (below).
  • @objectstack/metadata-protocol:
    • seed-loader.ts: the two insert sites (the batched insert flush and the self-referencing sequential writeRecord with action === 'inserted') mark the record index beside noteLanded; the result carries insertedIds in record order, deduped.
    • seed-applied-landed.ts (new, exported as seedAppliedLanded): folds the loader's per-dataset results into one entry per object, in first-loaded order; ids keep the loader's record order (datasets in load order, records as authored, first occurrence kept), not the order the writes completed. Several datasets of one object merge; ids dedupe; an id any dataset of the object inserted is never also matched; each list is capped. An object whose datasets landed nothing still gets an entry with two empty lists.
    • applySeedBodies returns landed: seedAppliedLanded(r.results) on the path where the loader returned. publishPackageDrafts and publishMetaItem (through runPublishSideEffects) pass it through; their inline return types gain the key.
  • @objectstack/runtime (domains/packages.ts): the route-level fallback apply (applyPublishedSeeds, for protocols that do not self-apply) returns the same landed through the same exported projection.
  • Generated artifacts regenerated: authorable-surface/{api,data}.json, authorable-defaults/data.json, json-schema.manifest/api.json, api-surface, export-origins, declaration-map, the strictness-ledger counts, and content/docs/references/{api/protocol,data/seed-loader,index}.mdx.
  • .changeset/30348-seed-receipt-landed-ids.md: spec minor, metadata-protocol minor, runtime patch.

What "landed" means, per seed mode (measured at seed-loader.ts, every noteLanded site)

Mode inserted matched Not listed
insert / replace every row an insert created none a record whose insert failed
ignore (default since #30081) rows created for records no existing row matched existing rows a record's key matched, left as they were a failed insert
upsert rows created rows updated, rows matched and left unchanged (no-op replay), rows matched whose update the engine refused a failed insert
update none rows updated, matched and unchanged, or refused a record that matched no row

A dry run lists nothing. A matched row whose update was refused is listed because the loader notes it before the write (the row exists and its key resolved).

Consequence for cloud (cloud#3598): only inserted names rows the seed created. A matched row may be a customer's own record that shares the seed's natural key; under ignore it is matched without being written. ⛔ Never clear a matched row on this receipt's word. inserted is per publish: a row an earlier publish inserted shows as matched on a re-publish, so the ledger must accumulate inserted ids across publishes rather than read the latest receipt alone.

The cap and its source

SEED_APPLIED_LANDED_IDS_CAP = 200 ids per list, per object. The card asked for the cloud ledger's cap on seeded_record_ids; objectstack-ai/cloud could not be read from this session (no repository access; an org-wide code search for seeded_record_ids returned 0 hits). There is no platform precedent for a bounded id list on a response, so the number follows the platform's existing bounds on a record-id list: EXPLAIN_BATCH_MAX_RECORD_IDS = 200 (packages/spec/src/security/explain.zod.ts:257, the cap on record ids crossing the public API) and the seed loader's 200-row insert batch (BULK_BATCH_SIZE). The truncation shape follows SearchAllResponseSchema.truncated: declared, never silent. Chosen with spec seat 3 before building. If cloud's ledger cap differs, aligning it is a one-constant change.

Accepted cost (ruling A): row ids appear on the public POST /packages/:id/publish-drafts and POST /meta/:type/:name/publish responses, bounded by the cap.

Why

AI builds land their sample rows when the seed draft is published. The loader computes which rows it landed on (landedIds), but applySeedBodies returned only success, inserted and updated, so cloud's "clear sample data" could not find the rows (measured on a hosted kernel at 1a3ea0c, cloud#3598 report 6650239213). Ruling A puts the ids on the receipt. Splitting them into inserted and matched is what keeps the clear safe under the create-only default.

Verification

Head 8dea3777 (c438aed4 plus the review round's test-and-wording commit 8dea3777; branch merged with origin/main fe835b49), under os-verify-lock, NODE_OPTIONS=--max-old-space-size=4096:

  • pnpm --filter @objectstack/spec test: Test Files 813 passed (813), Tests 25580 passed, 1 todo (at c438aed4). typecheck green for spec, metadata-protocol and runtime (runtime's check:test-typecheck included, after a full turbo run build, 119/119).

  • pnpm --filter @objectstack/metadata-protocol test: Test Files 313 passed, 4 skipped (317), Tests 22398 passed; pnpm --filter @objectstack/objectql test: Test Files 551 passed (551), Tests 10975 passed; pnpm --filter @objectstack/runtime test: Test Files 542 passed (542), Tests 8644 passed, 19 skipped (all at 11b7c671, the implementation commit; the merge brought no change to these files). The new pins re-ran green at c438aed4: objectql 3 files 26 passed, seed-loader-landed-ids.test.ts 14 passed. At 8dea3777 (review round): seed-applied-landed.test.ts + seed-loader-landed-ids.test.ts 23 passed, publish-seed-receipt-landed-ids.test.ts 9 passed, spec protocol.test.ts 242 passed after a full turbo run build (119/119); check:generated green after gen:docs (the reworded ids description).

  • check-changeset-no-major with this body as the event: exit 0 on Metadata-protocol: added; the control with none exits 1 (authorable-surface/authorable-defaults baselines moved).

  • New pins:

    • packages/objectql/src/publish-seed-receipt-landed-ids.test.ts (9, real protocol + real ObjectQL engine + real loader over an in-memory driver; every id read back from the engine): a fresh seed lists exactly the engine's rows under inserted; under ignore a customer's pre-existing row matched by key is matched and the fresh record inserted; a re-publish lists the first publish's rows as matched and nothing inserted; 205 records give the first 200 ids in record order, truncated: true, total: 205, and the schema refuses 205 ids; two seeds of one object fold into one entry; a seed with records: [] reports one entry with two empty lists (reported-and-empty, not absent); a publish with no seed carries no seedApplied; a load that threw reports counts and no landed; the single-item publishMetaItem door carries the same landed and PublishMetaItemResponseSchema keeps it. Both response schemas parse the real responses.
    • packages/metadata-protocol/src/seed-applied-landed.test.ts (9, the projection without an engine): junk entries (null, missing or non-string object) skipped; no results give []; a dataset that landed nothing (or predates insertedIds) gives two empty lists; one dataset's split; exactly 200 ids carried whole with truncated: false, 201 cut to 200 with total: 201, truncated: true, and each list cut on its own; across datasets of one object a row one inserted and another matched stays inserted only, whichever order; objects in first-loaded order and ids deduped, first occurrence kept. Its output parses through SeedAppliedLandedSchema.
    • packages/metadata-protocol/src/seed-loader-landed-ids.test.ts (+6): insertedIds on a fresh load equals landedIds; a replay (skip and update) inserts nothing; ignore over a pre-existing row; a refused update is landed, not inserted; the self-referencing sequential path; a refused insert and a dry run.
    • packages/spec/src/api/protocol.test.ts (+4): both receipts carry landed; existing receipts parse unchanged and gain no key; the cap (200 parses, 201 refused); total, truncated and both lists are required.
  • Existing pins updated: publish-meta-response-conformance.test.ts (the real seed publish now asserts the landed entry) and publish-package-drafts-seed-receipt-counts.test.ts (two toEquals on the success receipt admit landed).

Reverse verification (three mutations, each restored by scripts/ablation-replace.mjs to the HEAD blob)
  1. The batched insert site's mark removed (if (internalId) insertedRecordIndexes.add(recordIndex); deleted from seed-loader.ts, anchor 1 → 0, blob 4140f681fe20 → d5fc404362b8): seed-loader-landed-ids.test.ts 3 failed | 11 passed (14) (fresh load, ignore over a pre-existing row, refused insert). The metadata-protocol tests import the loader from src. Restored: blob == HEAD 4140f681fe20, git diff HEAD empty.
  2. The matched/inserted split removed in the projection ([...landed].filter((id) => !inserted.has(id)) → [...landed].filter(() => true), blob 76e34e96d4fe → e694847b8e36), @objectstack/metadata-protocol rebuilt and ablation-dist-preflight.mjs confirming the marker in 2 built files: publish-seed-receipt-landed-ids.test.ts 6 failed | 2 passed (8) (the two green are the no-seed and threw-load pins, which hold either way). Restored, rebuilt, --absent preflight green, the file back to 8 passed. A first attempt at this mutation was a no-op (the replacement text already occurred once; the tool refused and restored), so its run read the unmutated dist and is discarded.
  3. The cap boundary moved (truncated: ids.length > SEED_APPLIED_LANDED_IDS_CAP → >= in seed-applied-landed.ts, blob 1884875a7f21 → 47cf0511a4af): seed-applied-landed.test.ts 1 failed | 8 passed (9), the exactly-200 pin. The test imports the projection from src. Restored: blob == HEAD 1884875a7f21, git diff HEAD empty.
Gates: dispatch-gates --commands, 107 derived at 8dea377, 107 run, 0 NOT-MEASURED
  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran gates-ran.list at 8dea3777 (re-run in full after the review round; the same 107 also ran all-green at c438aed4): "107 derived famil(ies) accounted for — 107 run, 0 NOT-MEASURED", exit 0. Every line exit 0; check-changeset-no-major, check:generated, check:liveness, check:spec-parsed-alias and check:nul-bytes included.
  • The first pass at 11b7c671 had check:liveness and check:generated red on origin/main's unanchored query/searchFields citation (fixed on main by fix(spec): repoint the query.searchFields liveness row at engine-find.ts#expandSearchOnAst #30368, merged in here), and three exit-3 prerequisites (check-plugin-teardown-shape --self-test: pinned fixture fetched; check:skill-examples, check:dual-build-cjs-loads: no dists). All green on c438aed4 after the merge and a full turbo run build (119/119).
  • Prior rulings read is from node scripts/pm/check-prior-rulings.mjs --card 30348 --terms seedApplied,landedIds,"publish receipt"; ADR-0038 Decision §4 (L3 probes attach to the publish response "like seedApplied") does not rule on the receipt's ids.
  • No ! in the title and no (narrowing) arm: no full dogfood run. CI owns the rest of the farm.

Acceptance notes

  • A load that THROWS (a seed naming a missing object, a storage outage mid-dataset) reports inserted/updated counts from seedLoadPartialWrites but no ids, so landed is absent there. Carrying ids on that path would mean widening SeedLoadPartialWrites; not done here, and cloud must read an absent landed beside success: false as "rows may have landed, ids unknown".
  • Contract review (spec seat 3, PASS) should-fix 4, the ids of datasets that landed before a mid-batch throw being dropped with the rest, is the case in the note above; the seat files it as a follow-up, so it stays out of this PR.
  • Two producer tests use a mocked applySeedBodies (protocol-publish-package-drafts.test.ts), so their toEqual receipts are unchanged.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic (one constant and schemas; the projection lives in metadata-protocol).
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (CI-owned run).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block excepted, per project convention).
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzi6PCniByMTFP6RQ6P

…d names the rows a seed created and the rows it only matched

seedApplied gains an optional `landed` key on both publish responses, one
shared SeedAppliedLandedSchema: per object, `inserted` (rows this publish
created) and `matched` (pre-existing rows a seed key matched), each capped
at SEED_APPLIED_LANDED_IDS_CAP (200) ids with `total` and `truncated`.
The loader publishes SeedLoadResult.insertedIds beside landedIds so the
receipt can tell a created row from a customer row the seed only matched.
Both receipt producers fold through one exported projection.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzi6PCniByMTFP6RQ6P
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation protocol:data tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/runtime, @objectstack/spec, touching 24 documentable anchor(s). ⚠️ 9 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/runtime/src/domains/packages.ts, packages/spec/api-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via meta.publishItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/publish, selected by route anchor /:type/:name/publish), publishItem (sdk, the bare tail of client method meta.publishItem, bound to POST /api/v1/meta/:type/:name/publish))
  • content/docs/api/metadata-api.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — its route source's handler names it))
  • content/docs/concepts/metadata-lifecycle.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — its route source's handler names it))
  • content/docs/kernel/contracts/metadata-service.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — 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/v17/17-0.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — its route source's handler names it))
  • content/docs/releases/v17/17-1.mdx (via publishPackageDrafts (symbol, a method of class ObjectStackProtocolImplementation), /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — its route source's handler names it))
  • content/docs/releases/v17/17-2.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /:type/:name/publish (route, bridged from symbol PublishMetaItemResponseSchema — 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
  • 9 changed file(s) yielded no anchor (packages/metadata-protocol/src/index.ts, packages/runtime/src/domains/packages.ts, packages/spec/api-surface/api.json, …) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 175 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 8fa738fbe370c6cd8742fa14857e1b82d9b5d2a4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 8fa738fbe370c6cd8742fa14857e1b82d9b5d2a4

⚠️ 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 8fa738fbe370c6cd8742fa14857e1b82d9b5d2a4 → 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:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head c438aed · 2026-11-05T10:52Z

Full review of the first head at CONTRACT_REVIEW_TIER (a metadata-protocol addition: the publish receipt's seedApplied carries the landed seed row ids, split into inserted and matched, capped per list).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: c438aed4e40f67928b0fa4e0cb7b8cbaabc3c5a4
Local-runs: none
Scope: the PR diff against merge-base fe835b49aa (all 26 files; implementation 11b7c671 plus the merge of origin/main); packages/spec/src/api/protocol.zod.ts (SEED_APPLIED_LANDED_IDS_CAP :1422, SeedAppliedIdListSchema :1432-1442, SeedAppliedLandedSchema :1473-1484, the shared description :1488-1495, the docblock :1520-1531, the two landed keys :1590 and :1891); packages/spec/src/data/seed-loader.zod.ts (landedIds :441, insertedIds :459); packages/metadata-protocol/src/seed-loader.ts at head (insertedRecordIndexes :1336, noteLanded :1344-1355, the flush :1377-1468 with the mark at :1445, the sequential writeRecord path :2140-2185 with the mark at :2159, the catch :2171, the skip :2195, the update :2208, landedIds and insertedIds :2275-2289, writeRecord :3000-3062, decideWriteAction :3076-3105, extractId :3795-3798, resolveDeferredUpdates); packages/metadata-protocol/src/seed-applied-landed.ts (:30-36, :47-64); packages/metadata-protocol/src/protocol-publish-batch.ts (applySeedBodies :70-186, landed :140, the catch :142-185); packages/metadata-protocol/src/protocol-history-and-publish.ts :374, :1100; packages/metadata-protocol/src/protocol.ts :12375, :12446; packages/metadata-protocol/src/index.ts :22; packages/runtime/src/domains/packages.ts (:2100, applyPublishedSeeds :3840-3985, landed :3983) and packages/runtime/src/seed-loader.ts (a re-export shim); packages/objectql/src/publish-seed-receipt-landed-ids.test.ts (8 cases :170-299); packages/metadata-protocol/src/seed-loader-landed-ids.test.ts (the #30348 describe :290-393, 6 cases); packages/spec/src/api/protocol.test.ts (:3034-3074, 4 cases); packages/objectql/src/publish-meta-response-conformance.test.ts :316-331; packages/objectql/src/publish-package-drafts-seed-receipt-counts.test.ts :197, :231; packages/objectql/src/protocol-publish-package-drafts.test.ts :422-424, :491 (mocked producer); the spec ledgers (api-surface, authorable-surface/{api,data}.json, authorable-defaults/data.json, declaration-map, export-origins, json-schema.manifest), packages/spec/liveness/ (README, seed.json), the generated content/docs/references/{api/protocol,data/seed-loader,index}.mdx, the changeset; scripts/check-changeset-no-major.mjs (:9-15, :907-943, :1594-1606, :2045-2049) and scripts/pm/clause2-line.mjs (:111-121, :269-276); the seat order /mnt/project-files/seat3-orders/30348.md; card #30348 body and 5 comments (os-dev-report 6656124019 included); PR #30427 body, 2 comments, 0 reviews; check-runs on the head.

① Derived judgments

  1. Fidelity at every noteLanded site. There are five sites and two insert marks, and the marks sit exactly where a row is created: (a) the batched flush (:1442-1445) marks recordIndex beside noteLanded when res.ok and the stored id is non-empty; (b) the self-referencing sequential path (:2158-2159) marks only when writeRecord answered action === 'inserted', and writeRecord (:3000-3062) answers that only after engine.insert returned, in every mode (insert :3012-3014, upsert miss :3037-3039, ignore miss :3046-3048, replace :3052-3054, the default arm :3057-3059); (c) the sequential catch (:2171) lands the pre-existing id of a refused UPDATE and marks nothing, and a refused INSERT has no existingId so it lands nowhere; (d) the skip site (:2195) lands the matched id for ignore and a no-op replay, marks nothing; (e) the update site (:2208) lands the matched id BEFORE the write and marks nothing, so a refused update is matched, as the body's table says. decideWriteAction (:3076-3105) routes a matched key in ignore to skip and in upsert/update to update or skip, and only an unmatched key (or any insert/replace record) reaches pendingInserts, so no customer row can enter the flush's ok branch except through the attempt-greater-than-one recheck (:1393-1400, :1430-1437), where a key hit is by construction a row this load's own earlier attempt wrote; that classification is right and is the same ambiguity the inserted counter already carried. Ids are the stored primary keys: extractId (:3795-3798) reads record.id || record._id off the engine's returned row or the loaded existing row, and the re-identification map ([finding] On a walled showcase deployment every organization/create replays fixed-id sys_business_unit seeds that collide on the global id: 9 SeedLoader errors and an unresolved parent reference per new organization #21665) is fed from the same landedId. Dedupe and order: insertedIds (:2284-2289) is the same internalIdByRecordIndex map as landedIds (:2275-2279), filtered by the mark set, sorted by record index, then Set-deduped, so insertedIds is a subset of landedIds by construction. Pass 2 (resolveDeferredUpdates) touches only referencesResolved and the error list on allResults, never the id lists, and pushes no result. A dry run never calls noteLanded (:2241-2247), so both lists are empty. The projection (seed-applied-landed.ts :47-64) folds per object in first-loaded order, unions insertedIds and landedIds over the object's datasets, and derives matched as landed minus inserted (:61), so a second dataset's key hit on a row the first dataset created stays inserted; the objectql pin at :242 measures that against the engine. A result lacking insertedIds (an older loader shape) puts everything under matched, the safe direction.
  2. Cap boundary. cappedList (:30-36): ids.slice(0, 200), total: ids.length, truncated: ids.length > 200, so exactly 200 ids give truncated: false, total: 200 and 201 give 200 ids, truncated: true, total: 201. The schema (:1433) refuses a 201st id; the spec pin at :3061 parses 200 and refuses 201, and the objectql pin at :216 drives 205 records through the real door and asserts the first 200 in record order, total: 205, truncated: true, then shows the schema refusing the uncut list. The exactly-200 boundary is pinned at the schema only, not through the projection (judgment 5).
  3. Both doors and the fallback. applySeedBodies returns landed: seedAppliedLanded(r.results) only on the returned-load path (:140); the catch (:142-185) returns counts from seedLoadPartialWrites with no landed, pinned at :273 (a good seed lands, a missing-object seed throws, inserted: 1, no landed key). publishPackageDrafts passes the whole object through (protocol.ts :12375, :12446); publishMetaItem goes through runPublishSideEffects (protocol-history-and-publish.ts :1100) and copies the object whole (:374); neither picks fields, and both are driven by real-door pins (:170 and :289). The runtime fallback (packages.ts :3983) imports the same projection through index.ts :22 and its loader is the same class (packages/runtime/src/seed-loader.ts re-exports @objectstack/metadata-protocol's SeedLoaderService; git grep "class SeedLoaderService" finds one), so insertedIds is filled there too. The fallback's landed has no door pin of its own; its producer is a one-line fold of the same function, acceptable by reading.
  4. Declarations. PR body and changeset both carry Clause-②: yes (widening: …) (arm widening, the first token inside the parenthesis, clause2-line.mjs :269) and Metadata-protocol: added. added is the grade the checker owes: authorable-surface/api.json (six SeedAppliedIdList/SeedAppliedLanded keys), authorable-surface/data.json (SeedLoadResult:insertedIds) and authorable-defaults/data.json (insertedIds = []) all moved, and a moved baseline under none is refused (check-changeset-no-major.mjs :1594-1606); the changeset grades spec minor, metadata-protocol minor (a new exported symbol seedAppliedLanded and a new receipt key), runtime patch. Check Changeset, PR-body guards, Lint · PM dispatch-gates and Spec property liveness are green on the head. Liveness: the ledger governs registered metadata types (liveness/README.md, seed.json holds Seed's authorable keys); neither SeedLoadResult nor the publish responses have rows, landedIds has none, so insertedIds and landed owe none, and the PR claims none. Honest.
  5. Pins and ablation. Every claim in the order has a pin: exact ids per object at the real door (:170), the over-cap shape (:216), no seed means no field (:260), existing receipts parse unchanged and gain no key (:3050), one shared schema on both responses (:3043, and the real-door parses at :182 and :298), required total/truncated/both lists (:3069), and the loader split per mode (fresh, replay, ignore over a customer row, refused update, self-referencing path, refused insert and dry run, :290-393). Ablation 1 (the flush mark deleted) reddens 3 of 14 loader cases and ablation 2 (the matched filter neutralised in dist) reddens 6 of 8 door cases, both real reds and both reported with blob hashes and restoration. Gaps: no unit pin on seedAppliedLanded itself (junk entries at :50 are tolerated by reading only: null, non-string object, a result with neither list; exactly 200 through cappedList; empty results giving []), no empty-seed door pin (records: [] giving one entry with two empty lists, which the docblock at :41-45 and the description at :1491 promise), and the fallback producer is unpinned. The two unchanged toEqual pins in protocol-publish-package-drafts.test.ts (:440, :502) mock applySeedBodies (:422-424, :491), so they are correct as left; the two real-loader pins the dev did update (:197, :231) admit landed with expect.any(Array) and defer the ids to the new file.
  6. Backward compatibility. landed is optional on both schemas; a receipt without it parses byte-identically (:3050-3058). The inline TS return types gain an optional key only. SeedLoadResultSchema.insertedIds defaults to [] like landedIds, so an old-shaped result parses; a reader of landedIds alone (seed-settlement.ts :50-96, claim-seed-ownership.ts, app-plugin.ts :2022-2051) is untouched. Readers of the old receipt see one extra key.
  7. Body versus code. The two insert sites, the merge/dedupe/cap rules, the per-mode table, the thrown-load omission, the cap source and the test counts (8, +6, +4) all match the code. The body says the inserted list is "in record order" while the schema description (:1434) says "first-landed first"; the loader sorts by record index (:2277, :2287), and a matched row at a later index lands in wall time before a buffered insert at an earlier index flushes, so the schema wording is the looser of the two (judgment ④.3).

② Semver level

@objectstack/spec minor, @objectstack/metadata-protocol minor, @objectstack/runtime patch. Two new exported schemas, one constant, one optional key on two published response schemas and one defaulted key on SeedLoadResult; one new exported function from metadata-protocol. Nothing refused, narrowed or re-meant. Clause-②: yes (widening) and Metadata-protocol: added are the right pair for what the checker measures.

③ Boundary flags

  • Files touched stay inside the order: the two spec files, the loader's two insert sites, one new projection module beside applySeedBodies, the two doors' return types, the runtime fallback (named in the order's "every producer of it, any other route that attaches seedApplied" clause and the third claim's "the publish paths that return seedApplied"), their tests, and regenerated artifacts. seed-settlement.ts and the cloud half are untouched. app-plugin.ts's boot-time landed computation (:2022-2051) is the boot source the order excludes and is untouched.
  • The cap's source: cloud was unreadable from the dev session (the body says so, with a 0-hit org search), and the order's third rung ("pick a cap, give the reason, and stop to tell the seat") was followed: the seat chose 200 with the dev before the build, mirroring EXPLAIN_BATCH_MAX_RECORD_IDS and BULK_BATCH_SIZE. One constant to move if cloud's ledger cap differs.
  • Carried tier: (a) a load that throws mid-batch drops the ids of the datasets that landed before it (SeedLoadPartialWrites carries counts only, :165-169); the receipt says "not reported" and cloud must read absent landed beside success: false as unknown, as the body states; widening SeedLoadPartialWrites is a follow-up card. (b) claim-seed-ownership.ts and seed-settlement.ts read landedIds only and so hand over matched rows too; whether the ownership claim should narrow to insertedIds is a separate ruling, not this card.
  • No governed surface in the file list; no release-owned page edited; the docs-drift bot's five hand-written pages name the route, not the key.

④ Design judgment

The split into inserted and matched is the right shape and the only safe one under the ignore default: a single landed list would have handed cloud's "clear sample data" a customer's row. The projection lives in metadata-protocol once and both producers import it, the cap and the truncation shape are declared in the spec and refused by the schema past the cap, and every claim in the order is pinned at a real door against the engine. Must-fix: none. Should-fix, in order: (1) add seed-applied-landed.test.ts with direct pins on the projection: junk entries (null, non-string object, a result with neither list, a result with landedIds only), empty results giving [], exactly 200 ids giving truncated: false and 201 giving truncated: true, total: 201, and the cross-dataset inserted-then-matched fold without the engine; (2) add an empty-seed door pin (records: []) asserting one entry with two empty lists, since the docblock and the description both promise it; (3) align SeedAppliedIdListSchema.ids's "first-landed first" (:1434) with the loader's "record order" (:2277, :2287) so the two docs say the same thing; (4) file the thrown-load follow-up (ids of datasets landed before the throw) as its own card so cloud's reading of absent landed has a named end. None blocks landing; landing waits on the still-running required contexts (Build Core, Test Core 1-8, Type Check, Lint) going green.

Implemented-by: claude/project-thread-5eqwnz
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: PASS

…on directly and the empty-seed receipt; state the ids' record order

- seed-applied-landed.test.ts pins seedAppliedLanded without an engine:
  junk entries skipped, [] for no results, exactly 200 vs 201 through the
  cap (per list), and the cross-dataset inserted-then-matched merge.
- A seed with `records: []` publishes one landed entry with two empty
  lists, as the description promises.
- SeedAppliedIdListSchema.ids says the loader's record order (datasets in
  load order, records as authored), not "first-landed first".

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzi6PCniByMTFP6RQ6P
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

维护者速读

  • 改了什么: 发布回执的 seedApplied 新增可选键 landed:按对象列出本次种子落地的行 id,分成 inserted(本次新建的行)和 matched(种子按键命中的已有行),每个列表最多 200 个 id,超出时标 truncated 并给出总数。种子加载器的每数据集结果新增 insertedIds。
  • 为什么改: cloud 的「清除示例数据」要知道种子建了哪些行。只给一个 landed 列表不安全:默认 ignore 模式下,命中键的客户自有行也会算进去,清除时会删掉客户数据。
  • 风险与回滚: 纯新增可选键,旧回执照常解析。两个发布入口和运行时兜底路径共用同一个投影函数。加载中途抛错时回执不带 landed(表示「未报告」,不是「没有落地」),已另开卡 metadata-protocol: a seed load that throws mid-batch drops the landed row ids of the datasets that landed before the throw, so the publish receipt carries no seedApplied.landed #30429。回滚即还原本 PR。
  • 先看: packages/spec/src/api/protocol.zod.ts(SEED_APPLIED_LANDED_IDS_CAP、SeedAppliedLandedSchema);packages/metadata-protocol/src/seed-applied-landed.ts;seed-loader.ts 的两处插入标记。
  • 两分钟验证: pnpm --filter @objectstack/metadata-protocol exec vitest run src/seed-applied-landed.test.ts,预期 9 passed。
  • 需要你做的: 无,席位落地。

席位意见:fable 契约复审 PASS,没有必须修的。前三条建议已在一次推送里采纳:投影函数的 9 个直接钉(含 200/201 边界)、空种子的入口钉、id 顺序的措辞改为加载器的记录顺序。第四条(中途抛错时丢失已落地 id)另开 #30429。

席位核对 — ACCEPT · domain:spec#3 · head 8dea3777a4a25877370951d5c2c7a70a0276a6aa

  • Shape: 27 files (+951 −20, generated ledgers and docs included) on origin/main fe835b49:
    • @objectstack/spec: SEED_APPLIED_LANDED_IDS_CAP (200), SeedAppliedIdListSchema { ids, total, truncated }, SeedAppliedLandedSchema per object { object, inserted, matched }, an optional landed on both seedApplied receipts, and SeedLoadResult.insertedIds;
    • @objectstack/metadata-protocol: the loader marks a row inserted at its two insert sites (the batched flush and the sequential writeRecord path), one exported projection seedAppliedLanded, and applySeedBodies returns landed, so publishPackageDrafts and publishMetaItem carry it;
    • @objectstack/runtime: the package-publish fallback seed apply reports the same landed through the same projection;
    • pins, regenerated ledgers and docs, a changeset (spec minor, metadata-protocol minor, runtime patch).
  • Premise measured: at origin/main, the receipt carried counts only, and SeedLoadResult.landedIds mixed inserted and matched rows (the card's measurement).
  • Gates: at 8dea3777 after a full build (119/119), 107 derived gates run, all exit 0, and --ran reconciles with 0 NOT-MEASURED (dev report). Check-runs on this head: 57 success, 10 skipped, 0 red; Check Changeset and the PR-body guards re-run after the seat's body edit.
  • Contract review:
    • Full review, 6656296448 on c438aed4: PASS, no must-fix. It found the insert marks exactly where a row is created at all five noteLanded sites; skip, update-before-write and a refused update land matched ids only; a refused insert and a dry run land nowhere. It confirmed the cap boundary (200 whole, 201 cut with total: 201), that both doors and the fallback pass the object whole, and that old receipts parse unchanged.
    • The seat read the round-1 delta (6 files, +136 −8; no code path changed): seed-applied-landed.test.ts with 9 engine-free pins (junk skipped, empty results, legacy datasets, 200 vs 201 per list, cross-dataset inserted-then-matched in either order, dedupe), whose boundary ablation goes red; the empty-seed door pin; and the ids wording aligned to the loader's record order in the schema, the docblocks, the changeset and the generated protocol.mdx.
  • Declarations: Clause-②: yes (widening), Metadata-protocol: added, ADR-0087 not needed.
  • Accepted deviations: none.
  • Carried:
  • Landing: not governed (check-governed-merges --pr 30427: 0 of 27 paths, 971 lines). The hand-off is pr_ready plus automerge_enable, through the merge queue.

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 protocol:data size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

1 participant