Skip to content

fix(runtime): a seed dataset declared once registers once on an additive multi-package artifact - #19981

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19672-seed-dataset-stamped-dup
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-19672-seed-dataset-stamped-dup

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19672

Clause-②: no

On an additive multi-package artifact, a seed dataset declared once now reaches the shared seed-datasets registry once. An additive artifact carries its collections both flattened at the top level and under packages[]. Before this change the dataset registered twice, so a mode: 'insert' dataset wrote its rows twice per boot and again on every per-org replay.

The grading question: is the additive shape reachable? Yes. It is still produced today, not only by upgrades

Triage comment 5782064125 made this the dispatch's first act: reachable on an upgrade path means p0; not reachable means p1 stands. Measured on origin/main 2c1011b01b, which already contains the emitter change (6057357d):

leg how the artifact was made seed-datasets entries after a real boot
legacy additive, hand-built, JSON round-tripped the shape of every multi-package artifact built before 6057357d 2
today's emitter: standalone action bound to a sibling package's object composeStacks([...], { manifest: 'preserve' }) then JSON: top-level data 1, packages 2 2
today's emitter: one input has no manifest same: top-level data 1, packages 1 2
control: today's emitter, disjoint two-package composition flattened copy stripped, packages only 1
control: single-package preserve no packages key 1

"Real boot" means LiteKernel + the real ObjectQLPlugin + AppPlugin, with registration actually stamping the body copy (_packageId: com.probe.core, _provenance: package). The temporary probe was not committed.

So the answer is reachable, on two routes:

  1. Upgrade path. An existing compiled artifact is booted as is by a newer runtime:
    • os dev without --compile boots whatever dist/objectstack.json holds (packages/cli/src/commands/dev.ts:376). Its staleness check compares source mtimes, not the CLI or runtime version.
    • os start takes --artifact, then $OS_ARTIFACT_PATH, then ./dist/objectstack.json, and compiles only when no artifact exists (packages/cli/src/commands/start.ts:188).
  2. No upgrade needed. composeStacks(…, { manifest: 'preserve' }) keeps the additive shape whenever its three strip conditions fail (packages/spec/src/stack.zod.ts:4611, :5140). That happens when a package's standalone action binds to a sibling package's object, or when an input carries no manifest. Both are legal compositions that current main emits.

By triage's own rule that makes this p0. The seat carries the re-grade; this PR writes no label.

Mechanism, confirmed on 2c1011b01b

  1. AppPlugin.init hands the artifact to the manifest service (app-plugin.ts:394), which registers packages[] only.
  2. registerApp step 6 calls registerItem('data', dataset, 'object', id) (packages/objectql/src/engine.ts:5708), and applyProtection stamps _packageId / _provenance onto the body's dataset in place (registry.ts:3484).
  3. AppPlugin.start then reads this.collections, and resolveArtifactCollections claims a nameless item by whole value (itemIdentity, artifact-collections.ts:246). In a compiled artifact the two halves are different objects, so the reference claim cannot help, and the stamped copy no longer equals the top-level one.
  4. Nothing further down catches it. The #15262 guard compares by reference only (app-plugin.ts:1355), and mergeSeedDatasets is a plain push (seed-datasets.ts:76).

PM mechanism assumption 2 holds: the reader still accepts the legacy additive shape. Nothing converts it or refuses it, and the real boot above reads 2.

The fix: at the identity claim that owns the de-duplication

The value spelling of itemIdentity now leaves out the ADR-0010 registration envelope. The key set is derived from MetadataProtectionFields (@objectstack/spec/kernel), the declaration the metadata schemas spread, and is never restated here. flow-clone.ts in this package derives its drop set the same way.

  • The claim still compares everything the author wrote. A body dataset whose records differ is still a second dataset, and a control test pins that.
  • The stamps are not stripped off the registered copy. The top level is taken whole, so the copy handed on is the unstamped one.
  • No second guard was added downstream. The collector at app-plugin.ts:1355 and mergeSeedDatasets are unchanged.

Class sweep: other nameless kinds stamped before the claim?

Measured by reading every registrar that mutates AppPlugin's own bundle before this.collections is first read:

  • ObjectQL registerApp. Named items are stamped in place, which is harmless under name identity. registerMetadataCollections registers a nameless view container as a spread copy (engine.ts:5967). data is the only nameless kind it stamps in place.
  • The MetadataPlugin artifact door stamps its own ObjectStackDefinitionSchema.parse output (packages/metadata/src/plugin.ts:915), not this bundle.
  • A stamped translations bundle is not even admissible. ArtifactPackageSchema refuses _packageId inside it; this surfaced in the first run of the new resolver test.

The fix lives in the identity rule, so any nameless kind is covered. The resolver pin exercises datasourceMapping beside data to show the rule is not special-cased for seeds.

Tests

  • artifact-collections.test.ts:
    • A JSON round-tripped additive artifact whose body data and datasourceMapping copies are stamped with the real applyProtection resolves each to 1, and hands on the unstamped top-level array by reference.
    • Control: a body dataset whose records differ still arrives.
  • app-plugin.seed-stamped-additive.test.ts (new), with an insert-mode dataset declared once:
    • A real boot (LiteKernel + ObjectQLPlugin + AppPlugin) first asserts that registration stamped the body copy, then asserts seed-datasets has exactly 1 entry.
    • A second package's own dataset still arrives (2 entries).
    • Row level: with the body copy stamped, AppPlugin.start calls objectql.insert exactly once.

Ablation (committed fix, then node scripts/ablation-replace.mjs in WRAP mode plus an outer trap restore):

  • Mutation: anchor stableIdentity(withoutRegistrationEnvelope(item)) replaced with stableIdentity(item). Anchor count 1 to 0, replacement 0 to 1, blob 5351f76d to 2686c9b1.
  • Result: 4 tests red, 20 green. The four red:
    • stamped resolver pin: length 2, expected 1
    • real-boot registry pin: 2, expected 1
    • second-package pin: 4 entries
    • row pin: insert called 2 times
  • The authored-difference control stayed green (the expected direction).
  • Restore proven: blob 5351f76d equals the HEAD blob, and git diff HEAD is empty.

Runs, all at 26a5faab:

  • pnpm --filter @objectstack/runtime test: Test Files 274 passed (274), Tests 3821 passed | 1 skipped (3822).
  • pnpm --filter @objectstack/runtime typecheck: exit 0. check:test-typecheck: OK. tsc -p tsconfig.test.json --listFiles includes both test files.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 58; 56 exit 0. Reconciliation with --ran: 58 derived, 56 run, 2 NOT-MEASURED, 0 UNRUN.
    • NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET, 37 packages have no dist/ in this worktree and a full pnpm build does not fit the foreground cap. Supplemental reading: require('./dist/index.cjs') on the rebuilt runtime loads 260 exports.
    • NOT MEASURED: node scripts/check-plugin-teardown-shape.mjs --self-test, reason: exit 3, its positive control is pinned to a commit this shallow clone cannot reach. It grades the checker, not this diff; the non-self-test run of the same gate exited 0.
  • pnpm lint, as a proven narrowing, eslint --no-inline-config --format json on the changed files: files=3, errors=0, warnings=0.
    • Population from eslint's own config: isPathIgnored / calculateConfigForFile on all 4 changed paths. The 3 TS files are linted; the changeset .md is ignored.
    • Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project, no typed rules; stated at lines 326-328). Its only file reads are its own two baselines, which this diff does not touch, so no untouched file's verdict can move.

Changeset: .changeset/19672-seed-dataset-stamped-duplicate.md, @objectstack/runtime patch.

Acceptance notes

  • Residual not covered, and not reachable today. applyProtection also translates an author-facing protection block into _lock* and deletes the block. Identity does not normalise that pair. No nameless item stamped in place can carry protection: SeedSchema is strict and declares no protection key, and the one nameless in-place stamp is data.
  • Observation, not measured through the CLI. composeStacks(…, { manifest: 'preserve', objectConflict: 'merge' }) over two packages that each own one object name emits an artifact that a real boot refuses with ArtifactObjectNameConflictError (ADR-0130 D3), naming the extend fix. The refusal is loud and correct. Whether os compile refuses the same composition earlier was not measured, so this is recorded here and not filed.
  • The cli: the stack-collection and docs readers return nothing for a non-array packages while refusing a malformed entry, which leaves the cli outside ruling #15293-A #19925 readers (non-array packages) are out of scope and untouched. So are packages/runtime/src/domains/mcp.ts and packages/cli/src/commands/serve.ts.

Durable attribution: authored by the os-dev agent dispatched by the domain:cli PM seat, on branch claude/issue-19672-seed-dataset-stamped-dup.


Generated by Claude Code

…taset on an additive artifact

`resolveArtifactCollections` claims a nameless item (a seed dataset, a
`datasourceMapping` rule) by whole value. Boot registration stamps the
ADR-0010 envelope (`_packageId` / `_provenance`) onto the package body's
copy in place, and AppPlugin resolves its collections after that, so on a
compiled additive artifact the stamped body copy and the unstamped
top-level copy stopped comparing equal: a dataset declared once reached the
shared seed registry twice and a `mode: 'insert'` row was written twice per
boot.

The value spelling of the identity claim now leaves the registration
envelope out, with the key set derived from `MetadataProtectionFields`
rather than restated.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 26 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 2c1011b01bc071c545f72f2761647b8d9ab56375 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2c1011b01bc071c545f72f2761647b8d9ab56375

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Face review (post-hoc, released)

Served-tier: CONTRACT_REVIEW_TIER
Merge-sha: 3fd3a4f91bb5f62d6e9e17d5d28609a2a50646a1
Local-runs: none

Scope: the changeset (and named docs) of PR #19981, released; this review cannot block anything and exists to find false released prose.

Sentence verdicts

(.changeset/19672-seed-dataset-stamped-duplicate.md at the merge; no docs page in scope)

  1. "A seed dataset declared once on an additive multi-package artifact ... is now registered once per boot." TRUE. packages/runtime/src/artifact-collections.ts:267-299 (REGISTRATION_ENVELOPE_KEYS, withoutRegistrationEnvelope, itemIdentity); pinned by app-plugin.seed-stamped-additive.test.ts (real LiteKernel + ObjectQLPlugin + AppPlugin boot, registry length 1, insert called once).
  2. "Before this, it reached the shared seed-datasets registry twice, so a mode: 'insert' dataset wrote its rows twice on every boot and again on every per-organization replay." TRUE. Old value spelling at the removed line (value:${stableIdentity(item)}); the per-org replayer re-reads the live shared list on every call, packages/runtime/src/app-plugin.ts:1409-1419, so a doubled entry replays doubled. Ablation in the dev report: 4 pins red with the old spelling.
  3. "A dataset has no name, so it is matched on its whole value." TRUE. packages/spec/src/data/seed.zod.ts:34 (SeedSchema strictObject: object, externalId, records, mode, env, locale; no name); itemIdentity falls back to value identity for a nameless item.
  4. "Boot registration stamps the ADR-0010 envelope (_packageId, _provenance) onto the package body's copy in place, and AppPlugin reads its collections after that." TRUE. packages/objectql/src/engine.ts:5708 (registerItem('data', dataset, ...) on the body's own array), packages/objectql/src/registry.ts:3484 (applyProtection(item, { packageId }) in place); AppPlugin.start reads this.collections after init registered packages[].
  5. "In a compiled objectstack.json the two halves are separate objects, so the stamped copy and the unstamped top-level copy no longer compared equal." TRUE (JSON round trip; the resolver test uses a JSON round-tripped artifact).
  6. "The value comparison now leaves out the registration envelope. The key set comes from MetadataProtectionFields and is not listed by hand, so every nameless collection item is matched on what its author wrote." TRUE. artifact-collections.ts:267 derives from MetadataProtectionFields (packages/spec/src/kernel/metadata-protection.zod.ts:81); the resolver pin exercises datasourceMapping beside data.
  7. "Every multi-package artifact built before the emitter stopped writing the flattened copy has this shape. os dev without --compile, os start and --artifact / OS_ARTIFACT_PATH boot it as is after an upgrade." TRUE. packages/cli/src/commands/dev.ts:376-399 (without --compile, boots whatever dist/objectstack.json holds; staleness check is mtime-based), packages/cli/src/commands/start.ts:188 (ladder --artifact then $OS_ARTIFACT_PATH then ./dist/objectstack.json, compile only when none exists).
  8. "The current composeStacks(..., { manifest: 'preserve' }) still emits this shape when the package bodies do not reproduce the flattened copy. Two examples are a standalone action bound to a sibling package's object, and an input with no manifest." TRUE. packages/spec/src/stack.zod.ts:4607-4622 (packagesCarryEveryInputCollection: if (!stack.manifest) return false), :4709 (packagesReproduceComposedCollections, whose docblock names the sibling-package action echo), :5133-5140 (strip only when both hold).
  9. "Nothing an author writes changes. An artifact whose datasets are all upsert ends up with the same rows as before; each dataset is simply no longer applied twice." TRUE (identity rule only; upsert is idempotent).

Still true on origin/main?

Yes. artifact-collections.ts:267/276/299 unchanged on origin/main; stack.zod.ts:4953-4966 and :5540 keep both strip conditions; dev.ts:399 and start.ts:188 keep the boot ladders. packages/runtime/CHANGELOG.md:1630 carries the reviewed text verbatim under 3fd3a4f.

Finding

NONE.

Reviewed-by: session_01VvcEokUG1tvVxkceYfR5XB

VERDICT: CLEAN


Generated by Claude Code

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/m tests tooling

Projects

None yet

1 participant