Found in passing while landing #15005 (reader program 2/4). Pre-existing on origin/main, untouched by that PR, and not in its scope — filed rather than folded in.
The defect
AppPlugin.start() collects seed datasets from two locations, packages/runtime/src/app-plugin.ts:
// 1. Top-level `data` field
if (Array.isArray(<the top-level data>)) {
seedDatasets.push(...<the top-level data>);
}
// 2. Legacy: `manifest.data` (backward compatibility)
const manifest = this.bundle.manifest || this.bundle;
if (manifest && Array.isArray(manifest.data)) {
seedDatasets.push(...manifest.data);
}
When the bundle carries no manifest key, manifest falls back to this.bundle itself — so step 2 re-reads the very array step 1 just pushed, and every dataset lands in seedDatasets twice.
The sibling block for translations, ~590 lines further down in the same method, does the same two-location read and does carry the guard that makes it safe:
manifest.translations !== <the top-level translations>
So one of the two collectors has the reference check and the other does not. That asymmetry is the whole bug.
Why the shape is reachable, not hypothetical
The flat bundle — manifest fields written directly on the bundle rather than nested under manifest: — is a shape AppPlugin supports by design (const sys = bundle?.manifest || bundle in the constructor) and one this repo's own tests construct:
packages/runtime/src/app-plugin.jobs.test.ts builds new AppPlugin({ id: 'com.test.jobs', jobs: […], functions: {…} }).
Any such bundle carrying a top-level data array hits it.
Why it is not harmless
mergeSeedDatasets (packages/runtime/src/seed-datasets.ts) does not de-duplicate — it is a plain list.push(...datasets) onto the shared registry, so both copies reach the loader and both reach every later per-org replay.
For mode: 'upsert' datasets with an externalId the second pass is idempotent and the only cost is doubled work. For mode: 'insert' datasets it is not: the dataset is applied twice per boot. That is the same end state as the closed #3434 (mode:'insert' datasets duplicating on replay boots, showcase memberships 3 → 6 → 9), reached by a different route — that card was about repeated boots, this is one boot collecting the dataset twice — so the fix there does not cover this.
Suggested fix
Give the data fallback the guard its translations sibling already has, i.e. skip step 2 when manifest.data is the same array the top level already contributed. Worth checking in the same pass whether any other two-location read in start() is missing it.
Verification notes for whoever takes it
A regression test wants a bundle with no manifest key and a top-level data, asserting the dataset count reaching readSeedDatasets(ctx) is 1 rather than 2. packages/runtime/src/app-plugin.seed.test.ts and app-plugin.disabled-seed.test.ts are the existing rigs.
Searched before filing (AppPlugin seed datasets collected twice manifest.data legacy fallback duplicate seed dataset) with a positive control query in the same session; #3434, #3453 and #9070 are the neighbours and none of them is this.
Found in passing while landing #15005 (reader program 2/4). Pre-existing on
origin/main, untouched by that PR, and not in its scope — filed rather than folded in.The defect
AppPlugin.start()collects seed datasets from two locations,packages/runtime/src/app-plugin.ts:When the bundle carries no
manifestkey,manifestfalls back tothis.bundleitself — so step 2 re-reads the very array step 1 just pushed, and every dataset lands inseedDatasetstwice.The sibling block for
translations, ~590 lines further down in the same method, does the same two-location read and does carry the guard that makes it safe:So one of the two collectors has the reference check and the other does not. That asymmetry is the whole bug.
Why the shape is reachable, not hypothetical
The flat bundle — manifest fields written directly on the bundle rather than nested under
manifest:— is a shapeAppPluginsupports by design (const sys = bundle?.manifest || bundlein the constructor) and one this repo's own tests construct:packages/runtime/src/app-plugin.jobs.test.tsbuildsnew AppPlugin({ id: 'com.test.jobs', jobs: […], functions: {…} }).Any such bundle carrying a top-level
dataarray hits it.Why it is not harmless
mergeSeedDatasets(packages/runtime/src/seed-datasets.ts) does not de-duplicate — it is a plainlist.push(...datasets)onto the shared registry, so both copies reach the loader and both reach every later per-org replay.For
mode: 'upsert'datasets with anexternalIdthe second pass is idempotent and the only cost is doubled work. Formode: 'insert'datasets it is not: the dataset is applied twice per boot. That is the same end state as the closed #3434 (mode:'insert'datasets duplicating on replay boots, showcase memberships 3 → 6 → 9), reached by a different route — that card was about repeated boots, this is one boot collecting the dataset twice — so the fix there does not cover this.Suggested fix
Give the
datafallback the guard itstranslationssibling already has, i.e. skip step 2 whenmanifest.datais the same array the top level already contributed. Worth checking in the same pass whether any other two-location read instart()is missing it.Verification notes for whoever takes it
A regression test wants a bundle with no
manifestkey and a top-leveldata, asserting the dataset count reachingreadSeedDatasets(ctx)is 1 rather than 2.packages/runtime/src/app-plugin.seed.test.tsandapp-plugin.disabled-seed.test.tsare the existing rigs.Searched before filing (
AppPlugin seed datasets collected twice manifest.data legacy fallback duplicate seed dataset) with a positive control query in the same session; #3434, #3453 and #9070 are the neighbours and none of them is this.