diff --git a/.changeset/30081-seed-mode-default-create-only.md b/.changeset/30081-seed-mode-default-create-only.md
new file mode 100644
index 00000000000..e496783079b
--- /dev/null
+++ b/.changeset/30081-seed-mode-default-create-only.md
@@ -0,0 +1,25 @@
+---
+'@objectstack/spec': minor
+'@objectstack/runtime': minor
+'@objectstack/metadata-protocol': minor
+'@objectstack/cloud-connection': minor
+---
+
+feat(spec)!: a seed is create-only by default — an omitted `mode` is `ignore`, so a restart, an install or an upgrade never writes a seeded value back over a customer's edit (#30081)
+
+Clause-②: yes (narrowing) — the declared default of `Seed.mode` (and of `SeedLoaderConfig.defaultMode`, now the same field) moves from `upsert` to `ignore`. The accept set is unchanged and every authored `mode` parses exactly as before; what narrows is what a replay does for a dataset that declares no mode: it no longer updates an existing row.
+Metadata-protocol: semantic
+
+
+
+**BREAKING** for a seed dataset that declares no `mode` and relies on a replay to write its values back onto existing rows.
+
+- **Before.** A seed with no `mode` replayed as `upsert`. A seed loads again on every single-tenant boot (production included, under `os start` and `os serve`), on every package install or upgrade and on every publish, so each replay wrote the shipped value back over a customer's edit to a seeded row, with no warning. Measured on a production boot before the change (card comment 6633189577): an API edit to a seeded row went back to the shipped value on the next restart, after a boot-artifact upgrade, and after an `os package install` upgrade.
+- **Now.** `SeedSchema.mode` defaults to `ignore`: a replay inserts a record whose row is missing and leaves an existing row exactly as it is. A row nobody edited is not rewritten either, so its `updated_at` does not move, and a value changed in a later seed version reaches only a row the load creates. Maintainer ruling 6631016988 (option A).
+- **One default, stated once.** `SeedLoaderConfig.defaultMode` is `SeedSchema`'s own `mode` field, default included. The six replay sites (`@objectstack/runtime`: the inline boot seed, the per-organization replayer, the dev hot reload and the published-seed apply; `@objectstack/metadata-protocol`: the publish batch; `@objectstack/cloud-connection`: install, reseed and the rehydrate healer) stop restating `defaultMode: 'upsert'`, and the save door reads the schema's default for a seed item that declares no mode.
+- **Compiled artifacts.** `os build` stores the parsed seed, so a package built before this change carries a written `mode: "upsert"` on every dataset. The loader cannot tell that value from an authored one, and nothing converts it: such a package keeps today's replay until it is rebuilt.
+- **Installed packages.** A package installed with `os package install` (`@objectstack/cloud-connection`'s install-local) keeps the body it was installed with in its install ledger, and the sample-data reseed (`POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data`) replays that body's datasets in their stored mode. So an entry installed from a pre-change artifact keeps writing the shipped values back over edited rows on every reseed until the **rebuilt artifact is re-installed**; rebuilding alone changes nothing for it. An entry installed from a rebuilt artifact reseeds create-only: it puts back the rows that are missing and leaves an edited row as it is. The ledger's other replays never overwrite: the boot rehydrate seeds nothing, the heal seeds only when every seeded object is empty, and the per-organization replay runs only for a new organization.
+- **Stored `seed` items.** A `seed` metadata item stored with no `mode` is not rewritten either. Its next publish parses it to `ignore`, so that publish inserts only the rows it is missing where it used to write its values back over existing rows, with no signal.
+- **Unchanged.** An explicit `mode: 'upsert'` still writes every declared column back on each replay, and `columns: { FIELD: { applyOn: 'create' } }` still exempts the columns a customer or a job owns. `insert`, `update` and `replace` behave as before.
+
+**The remedy.** Rebuild every package so its compiled artifact carries the mode its source declares, and re-install the rebuilt artifact wherever the package was installed with `os package install`, so its install ledger carries that mode too. To put an installed package's sample data back to the shipped values, purge it (`…/purge-sample-data`) and then reseed. For a dataset that is reference data the package must keep current, declare `mode: 'upsert'`, and mark the columns the customer or a job owns `applyOn: 'create'`. Leave `mode` unset for business and starter data the customer owns once it is loaded.
diff --git a/content/docs/data-modeling/seed-data.mdx b/content/docs/data-modeling/seed-data.mdx
index 3cbdac0ba62..769c05d23be 100644
--- a/content/docs/data-modeling/seed-data.mdx
+++ b/content/docs/data-modeling/seed-data.mdx
@@ -25,9 +25,9 @@ import { defineSeed } from '@objectstack/spec/data';
import { Account } from './objects/account.object';
export const accountsSeed = defineSeed(Account, {
- externalId: 'name', // field used as the upsert / idempotency key
- mode: 'upsert', // create if new, update if found
+ externalId: 'name', // field that matches a record to an existing row on replay
env: ['dev', 'test'], // only load in dev and test environments
+ // mode omitted → 'ignore': create-only, a replay never overwrites an edit
records: [
{
name: 'Acme Corporation',
@@ -64,41 +64,68 @@ which half TypeScript checks and which half the call checks.
The `mode` field controls how the seed runner behaves when it encounters an existing
record (matched by `externalId`).
+Seeds are **replayed**, not applied once: a seed loads again on every boot of a
+single-tenant runtime (production included), on every package install or upgrade,
+and on every publish. The mode decides what each of those replays does to a row a
+customer has since edited.
+
| Mode | Behavior | Use Case |
|:-----|:---------|:---------|
-| `upsert` | Create if new, update if found | Default — idempotent for most data |
+| `ignore` | **Default.** Create-only: insert a record whose row is missing, leave an existing row exactly as it is | Business and starter data the customer owns once it is loaded |
+| `upsert` | Create if new, and on **every** replay write each declared column back onto the matched row | Reference data the package must keep current |
| `insert` | Insert **every** record unconditionally — no existing-row check, so it **duplicates the table on each replay boot** ([#3434](https://github.com/objectstack-ai/objectstack/issues/3434)). Rarely what you want | Append-only rows loaded exactly once (never re-run) |
| `update` | Update only, skip if not found | Migration patches on existing rows |
-| `ignore` | Create if new, silently skip duplicates | Bootstrap data that must not overwrite user edits |
| `replace` | Insert without checking for an existing match — the seed loader does **not** delete anything itself; the target table must already be empty (or cleared by the caller) | Cache / lookup tables rebuilt from an already-truncated table |
-### `upsert` — Recommended Default
+### `ignore` — The Default (Create-Only)
```typescript
-defineSeed(Currency, {
- externalId: 'code',
- mode: 'upsert',
+defineSeed(Account, {
+ externalId: 'name',
+ // no mode: a replay inserts a missing account and leaves an edited one alone
records: [
- { code: 'USD', name: 'US Dollar', symbol: '$' },
- { code: 'EUR', name: 'Euro', symbol: '€' },
- { code: 'GBP', name: 'British Pound', symbol: '£' },
+ { name: 'Acme Corporation', industry: 'technology' },
+ { name: 'Globex Industries', industry: 'manufacturing' },
],
});
```
-### `ignore` — Bootstrap Without Overwriting
+A seed with no `mode` never writes over a row that already exists. A customer who
+renames an account, or a job that advances a status, keeps that change across every
+restart and upgrade. The flip side: a value you later change in the seed reaches only
+a row the load creates. To see a seed edit in development, start from a clean
+database (`os dev --fresh`).
+
+### `upsert` — Reference Data the Package Keeps Current
```typescript
-defineSeed(SystemRole, {
+defineSeed(Currency, {
externalId: 'code',
- mode: 'ignore',
+ mode: 'upsert', // every replay writes these columns back onto the matched row
records: [
- { code: 'admin', label: 'Administrator' },
- { code: 'viewer', label: 'Viewer' },
+ { code: 'USD', name: 'US Dollar', symbol: '$' },
+ { code: 'EUR', name: 'Euro', symbol: '€' },
+ { code: 'GBP', name: 'British Pound', symbol: '£' },
],
});
```
+Declare `upsert` only for data the package must keep refreshed. Every replay puts
+each declared column back, so an edit a customer made to such a row is overwritten
+on the next boot or upgrade, silently. To refresh most columns but leave the ones a
+customer or a job owns, mark those columns create-only — see
+[`applyOn: 'create'`](#columns-your-app-owns-after-the-first-seed--applyon-create).
+
+
+ The default used to be `upsert`. A package **built** before the change carries a
+ written `mode: "upsert"` on every dataset in its compiled artifact (the build stores
+ the parsed seed), so it keeps replaying with `upsert` until it is rebuilt. A package
+ installed with `os package install` keeps the body it was installed with, and its
+ sample-data reseed replays that body, so it also has to be **re-installed** from the
+ rebuilt artifact. Rebuild (and re-install) to take the create-only default; declare
+ `mode: 'upsert'` on the datasets that must stay current.
+
+
### `replace` — Insert Into an Already-Cleared Table
```typescript
@@ -123,8 +150,8 @@ Seeds are **replayed** — they re-load on every dev-server boot and every packa
re-publish, not applied once. `insert` writes every record with **no existing-row
check**, so a replayed `insert` dataset duplicates its table on each restart
(a 3-row fixture becomes 6, then 9 — [#3434](https://github.com/objectstack-ai/objectstack/issues/3434)).
-It is almost never the right mode: reach for `upsert` (or `ignore`) with a stable
-`externalId` instead. `os validate` warns on any `mode: 'insert'` seed.
+It is almost never the right mode: leave `mode` unset (create-only), or declare
+`upsert` for reference data, with a stable `externalId` instead. `os validate` warns on any `mode: 'insert'` seed.
---
@@ -641,11 +668,12 @@ code instead bakes the choice into your build output, so shipping another
market means another build. Neither approach removes what the market you left
behind already wrote — see [Locale Scoping](#locale-scoping).
-### Use `upsert` by default
+### Leave `mode` unset for data the customer owns
-`upsert` is idempotent and the safest default. Only change the mode when the use
-case requires it — for example, `ignore` when you must not overwrite user edits to
-system defaults, or `replace` for ephemeral cache tables.
+The default, `ignore`, is idempotent and never overwrites an edit. Declare a mode only
+when the use case requires it — `upsert` for reference data the package keeps current
+(with `applyOn: 'create'` on the columns the customer or a job owns), or `replace`
+for ephemeral cache tables.
### Seed in dependency order
@@ -673,7 +701,7 @@ function defineSeed<
objectDef: TObj,
config: {
externalId?: string | string[]; // single field, or a composite list (join tables); default: 'name'
- mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'upsert'
+ mode?: 'insert' | 'update' | 'upsert' | 'replace' | 'ignore'; // default: 'ignore'
columns?: { [field: string]: { applyOn?: 'create' | 'always' } }; // per-column write policy; default: 'always'
env?: Array<'prod' | 'dev' | 'test'>; // default: ['prod','dev','test']
locale?: string[]; // BCP-47 tags; omitted = every locale
diff --git a/content/docs/getting-started/examples.mdx b/content/docs/getting-started/examples.mdx
index 83216e8716b..88a685f52e6 100644
--- a/content/docs/getting-started/examples.mdx
+++ b/content/docs/getting-started/examples.mdx
@@ -106,7 +106,7 @@ examples/app-todo/
| Concept | File | What You'll Learn |
|:--------|:-----|:------------------|
| Object & Fields | `task.object.ts` | `Field.text()`, `Field.select()`, field options, indexes |
-| Seed Data | `src/data/index.ts` | `defineSeed()` with upsert mode, 8 sample records |
+| Seed Data | `src/data/index.ts` | `defineSeed()` with the create-only default, 8 sample records |
| Actions | `task.actions.ts` | Script actions, modal actions with params |
| App Navigation | `todo.app.ts` | Navigation groups, object links, dashboard links |
| Automation | `task.flow.ts` | Autolaunched flow with record-triggered logic |
@@ -211,7 +211,7 @@ import { defineSeed } from '@objectstack/spec/data';
import { Task } from '../objects/task.object';
const tasks = defineSeed(Task, {
- mode: 'upsert',
+ // no mode: create-only, so a restart never overwrites a task the user edited
externalId: 'subject',
records: [
{ subject: 'Learn ObjectStack', status: 'completed', priority: 'high' },
diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx
index a31200a510e..d185002b5e7 100644
--- a/content/docs/references/api/package-api.mdx
+++ b/content/docs/references/api/package-api.mdx
@@ -243,7 +243,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Target Object Name |
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
-| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
+| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. |
| **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
@@ -351,7 +351,7 @@ Package manifest installed through POST /api/v1/packages (AUTHORING stage; scope
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Target Object Name |
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
-| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
+| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. |
| **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
diff --git a/content/docs/references/data/seed-loader.mdx b/content/docs/references/data/seed-loader.mdx
index a217c7cba0e..ff04fc0ead4 100644
--- a/content/docs/references/data/seed-loader.mdx
+++ b/content/docs/references/data/seed-loader.mdx
@@ -194,7 +194,7 @@ Seed data loader configuration
| **dryRun** | `boolean` | optional (default: `false`) | Validate references without writing data |
| **haltOnError** | `boolean` | optional (default: `false`) | Stop on first reference resolution error |
| **multiPass** | `boolean` | optional (default: `true`) | Enable multi-pass loading for circular dependencies |
-| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Default conflict resolution strategy |
+| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | Default conflict resolution strategy: the seed `mode` default (`ignore`, create-only) |
| **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert |
| **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment |
@@ -229,7 +229,7 @@ Seed loader request with datasets and configuration
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Target Object Name |
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
-| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
+| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. |
| **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
@@ -250,7 +250,7 @@ Seed loader request with datasets and configuration
| **dryRun** | `boolean` | optional (default: `false`) | Validate references without writing data |
| **haltOnError** | `boolean` | optional (default: `false`) | Stop on first reference resolution error |
| **multiPass** | `boolean` | optional (default: `true`) | Enable multi-pass loading for circular dependencies |
-| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Default conflict resolution strategy |
+| **defaultMode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | Default conflict resolution strategy: the seed `mode` default (`ignore`, create-only) |
| **batchSize** | `integer` | optional (default: `1000`) | Maximum records per batch insert/upsert |
| **transaction** | `boolean` | optional (default: `false`) | Wrap entire load in a transaction (all-or-nothing) |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>` | optional | Only load datasets matching this environment |
diff --git a/content/docs/references/data/seed.mdx b/content/docs/references/data/seed.mdx
index 0a92f45a5cd..ad186a9c641 100644
--- a/content/docs/references/data/seed.mdx
+++ b/content/docs/references/data/seed.mdx
@@ -30,7 +30,7 @@ const result = SeedSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Target Object Name |
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
-| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
+| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. |
| **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx
index 225a3b89279..64aba9b6b5d 100644
--- a/content/docs/references/kernel/manifest.mdx
+++ b/content/docs/references/kernel/manifest.mdx
@@ -97,7 +97,7 @@ Install mode: managed (registered as code, upgradeable, sealed) | unmanaged (cop
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Target Object Name |
| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check |
-| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy |
+| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"ignore"`) | What a load does with a record whose `externalId` matches an existing row. `ignore` (default) is create-only: a missing row is inserted and an existing row is left as it is, so a replay (every boot, a package install or upgrade, a publish) never writes a seeded value back over a later edit. `upsert` also writes every declared column back onto the matched row on each replay; declare it for reference data the package keeps current. `update` writes matched rows only, `insert` inserts every record with no match, and `replace` inserts into a table the caller cleared. |
| **columns** | `Record }>` | optional | Per-column write policy, keyed by field name: `{ status: { applyOn: 'create' } }` writes `status` only when the seed creates the row. Omitted, every column is written on create and update. |
| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments |
| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale. The publish and install paths do not filter by locale — they load every dataset and warn |
diff --git a/examples/app-crm/src/data/index.ts b/examples/app-crm/src/data/index.ts
index 17c60bd4231..c76dc24c34a 100644
--- a/examples/app-crm/src/data/index.ts
+++ b/examples/app-crm/src/data/index.ts
@@ -8,8 +8,15 @@ import { Opportunity } from '../objects/opportunity.object.js';
import { Lead } from '../objects/lead.object.js';
import { Activity } from '../objects/activity.object.js';
+// Every dataset below is the customer's business data, so none declares a
+// `mode`: a replay (each boot, a package install or upgrade) inserts a record
+// whose row is missing and leaves an existing row as the customer left it —
+// the seed default, create-only. A changed value here therefore reaches only
+// a database the seed has not loaded yet; relative dates (`daysFromNow`,
+// `daysAgo`) are resolved when the row is first inserted. Start from a clean
+// database (`os dev --fresh`) to see an edit here or to re-anchor the dates.
+
const accounts = defineSeed(Account, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Acme Corp', industry: 'technology', annual_revenue: 5_000_000, website: 'https://acme.example' },
@@ -19,7 +26,6 @@ const accounts = defineSeed(Account, {
});
const contacts = defineSeed(Contact, {
- mode: 'upsert',
externalId: 'email',
records: [
{ first_name: 'Ada', last_name: 'Lovelace', email: 'ada@acme.example', account: 'Acme Corp' },
@@ -29,7 +35,6 @@ const contacts = defineSeed(Contact, {
});
const opportunities = defineSeed(Opportunity, {
- mode: 'upsert',
externalId: 'name',
records: [
// --- Open pipeline (no close yet) -----------------------------------
@@ -57,7 +62,6 @@ const opportunities = defineSeed(Opportunity, {
});
const leads = defineSeed(Lead, {
- mode: 'upsert',
externalId: 'email',
// No `assigned_to` on these rows. It is a user field, and no user exists
// when the seed runs: people come from sign-up, and the dev admin is
@@ -101,7 +105,6 @@ const leads = defineSeed(Lead, {
});
const activities = defineSeed(Activity, {
- mode: 'upsert',
externalId: 'subject',
records: [
{
diff --git a/examples/app-showcase/src/data/seed/index.ts b/examples/app-showcase/src/data/seed/index.ts
index 52f98b391cf..d17e177af78 100644
--- a/examples/app-showcase/src/data/seed/index.ts
+++ b/examples/app-showcase/src/data/seed/index.ts
@@ -88,8 +88,12 @@ const ADA = AUDITOR_DEMO_USER.email;
* information-dense empty state rather than broken images.
*/
+// No dataset in this file declares `mode: 'upsert'`: each is the demo
+// workspace's business data, which a user edits once it is seeded, so every
+// replay is create-only (the seed default) — a missing row is inserted and an
+// existing row is left as the user left it. A changed value here reaches only
+// a database the seed has not loaded yet (`os dev --fresh`).
const accounts = defineSeed(Account, {
- mode: 'upsert',
externalId: 'name',
records: [
// `status` is required and no default is applied at seed-insert time, so it
@@ -126,7 +130,6 @@ const accounts = defineSeed(Account, {
// (cascading) lookup demo: picking an Account on an invoice scopes its
// Contact picker to that account's people (invoice.contact dependsOn account).
const contacts = defineSeed(Contact, {
- mode: 'upsert',
externalId: 'email',
records: [
{ name: 'Nora West', email: 'nora@northwind.example', phone: '+1 555 010 1111', company: 'Northwind', title: 'Procurement Lead', account: 'Northwind', stage: 'qualified' },
@@ -166,11 +169,12 @@ const contacts = defineSeed(Contact, {
* from the `state_machine` rule (#3433). That lets each project be seeded
* DIRECTLY at its real status; no FSM-walk workaround (this used to seed all
* five as `planned` then upsert-hop them through legal transitions, #3415).
- * `mode: 'upsert'` keeps replay idempotent — an unchanged row no-ops, so
- * dev-server restarts and package re-publishes never churn or re-validate.
+ * The dataset declares no `mode`, so a replay is create-only (the seed
+ * default): dev-server restarts and package re-publishes insert a missing
+ * project and leave an existing one — and any edit made to it — alone, so
+ * nothing churns or re-validates.
*/
const projects = defineSeed(Project, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Website Relaunch', account: 'Northwind', status: 'active', health: 'green', budget: 150_000, spent: 60_000, owner: ADA, start_date: cel`daysAgo(30)`, end_date: cel`daysFromNow(60)` },
@@ -183,7 +187,6 @@ const projects = defineSeed(Project, {
// Tasks across all five board columns, with dates + locations to drive every view.
const tasks = defineSeed(Task, {
- mode: 'upsert',
externalId: 'title',
records: [
{ title: 'Audit current IA', project: 'Website Relaunch', assignee: ADA, status: 'done', priority: 'medium', estimate_hours: 8, progress: 100, done: true, created_at: cel`daysAgo(20)`, start_date: cel`daysAgo(20)`, end_date: cel`daysAgo(18)`, due_date: cel`daysAgo(18)`, location: { lat: 47.6062, lng: -122.3321 } },
@@ -200,7 +203,6 @@ const tasks = defineSeed(Task, {
});
const categories = defineSeed(Category, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Engineering', sort_order: 1, color: '#3B82F6' },
@@ -213,7 +215,6 @@ const categories = defineSeed(Category, {
// Org-chart hierarchy seeded by `name` external id; `parent` references another
// record's name, building a 3-level tree the `tree` view renders.
const businessUnits = defineSeed(BusinessUnit, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Acme Corporation', kind: 'company', manager: 'Dana Wong', headcount: 0 },
@@ -248,7 +249,6 @@ const businessUnits = defineSeed(BusinessUnit, {
*/
const orgUnits = SeedSchema.parse({
object: 'sys_business_unit',
- mode: 'upsert',
externalId: 'id',
records: [
{ id: 'bu_acme', name: 'Acme Corporation', code: 'ACME', kind: 'company', active: true },
@@ -265,7 +265,6 @@ const orgUnits = SeedSchema.parse({
// from the definition — no binding row exists to seed.
const teams = defineSeed(Team, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'Platform', lead: 'linus@example.com', capacity_hours: 200 },
@@ -276,7 +275,6 @@ const teams = defineSeed(Team, {
// Catalog products the invoice line's `product` lookup picks from. Selecting
// one auto-fills the line's description + unit_price (matching field names).
const products = defineSeed(Product, {
- mode: 'upsert',
externalId: 'sku',
records: [
{ sku: 'WIDGET-A', name: 'Widget A', description: 'Standard widget', unit_price: 29.99, active: true },
@@ -319,7 +317,6 @@ const memberships = defineSeed(ProjectMembership, {
// The multi-value USER field (`f_users`) stays unseeded on purpose: sys_user
// rows come from sign-up, not seeds — see the note on the object.
const fieldZoo = defineSeed(FieldZoo, {
- mode: 'upsert',
externalId: 'name',
records: [
{
@@ -358,7 +355,6 @@ const fieldZoo = defineSeed(FieldZoo, {
// `showcase_invoice_line` is `controlled_by_parent`, the lines below follow
// automatically — ada sees INV-1001/1002's lines but never linus's INV-1003.
const invoices = defineSeed(Invoice, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'INV-1001', account: 'Northwind', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(10)`, tax_rate: 8, region: 'amer' },
@@ -394,7 +390,6 @@ const invoices = defineSeed(Invoice, {
// start clock; the goods lines are empty on purpose, which is the fixture's
// empty-cell case.
const invoiceLines = defineSeed(InvoiceLine, {
- mode: 'upsert',
externalId: 'description',
records: [
{ description: 'INV-1001 \u00b7 Consulting hours', invoice: 'INV-1001', product: 'SERVICE-HR', position: 0, quantity: 10, unit_price: 150, amount: 1500, service_start: '09:15' },
@@ -412,7 +407,6 @@ const invoiceLines = defineSeed(InvoiceLine, {
// story in the app by flipping a line's status in the inline grid and watching
// approved_amount / rejected_count move. Merchants are unique → externalId.
const expenseReports = defineSeed(ExpenseReport, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'EXP-2001', employee: 'Ada Lovelace', status: 'submitted', submitted_on: cel`daysAgo(5)` },
@@ -436,7 +430,6 @@ const expenseReports = defineSeed(ExpenseReport, {
* documents for exactly this — ` + duration("Nh…")`.
*/
const expenseLines = defineSeed(ExpenseLine, {
- mode: 'upsert',
externalId: 'merchant',
records: [
// EXP-2001 → total 1500.50 · approved 960 · reimbursable 512 · rejected 0 · over$500 2
@@ -463,7 +456,6 @@ const expenseLines = defineSeed(ExpenseLine, {
// renders on first boot — the "every view renders real data" principle. One
// per status; the `closed` row doubles as live prey for InquiryPurgeFlow.
const inquiries = defineSeed(Inquiry, {
- mode: 'upsert',
externalId: 'email',
records: [
{ name: 'Priya Raman', email: 'priya@meridian.example', company: 'Meridian Labs', message: 'Interested in the delivery workspace for a 40-person team.', status: 'new', source: 'website' },
@@ -473,7 +465,6 @@ const inquiries = defineSeed(Inquiry, {
});
const preferences = defineSeed(Preference, {
- mode: 'upsert',
externalId: 'name',
records: [
{ name: 'My Preferences', theme: 'light', default_landing: 'my_work', email_digest: 'daily', items_per_page: 50, notifications_enabled: true, compact_density: false },
@@ -488,7 +479,6 @@ const preferences = defineSeed(Preference, {
* (only `showcase_ops` may create; everyone else is read-only by design).
*/
const announcements = defineSeed(Announcement, {
- mode: 'upsert',
externalId: 'title',
records: [
{ title: 'Welcome to the Showcase workspace', body: 'This demo org exercises the full permission model: positions, permission sets, sharing rules and field-level security. Log in as different personas to compare what each can see and edit.' },
@@ -512,7 +502,6 @@ const announcements = defineSeed(Announcement, {
* proves nothing.
*/
const clientBriefs = defineSeed(ClientBrief, {
- mode: 'upsert',
externalId: 'title',
records: [
{
diff --git a/examples/app-todo/src/data/index.ts b/examples/app-todo/src/data/index.ts
index ac2b4aa2a85..d9af204dbf0 100644
--- a/examples/app-todo/src/data/index.ts
+++ b/examples/app-todo/src/data/index.ts
@@ -4,8 +4,11 @@ import { defineSeed } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';
import { Task } from '../objects/task.object';
+// The tasks are the user's own once seeded, so the dataset declares no
+// `mode`: a replay inserts a missing task and leaves an existing one as the
+// user left it (the seed default, create-only). Relative dates are resolved
+// when the row is first inserted; `os dev --fresh` starts from a clean database.
const tasks = defineSeed(Task, {
- mode: 'upsert',
externalId: 'subject',
// NOTE: `owner` is a lookup to `sys_user`; there's no stable seed key for the
// bootstrap admin (created by auth), so it's left unset rather than pointing
diff --git a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts
index d6fd42e83c0..0f0bde4714b 100644
--- a/packages/cloud-connection/src/marketplace-install-local-heal.test.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-heal.test.ts
@@ -180,9 +180,12 @@ describe('rehydrate sample-data healing', () => {
await rehydrateWith({}, { crm_x: [], crm_y: [] });
expect(loadCalls).toHaveLength(1);
- // The full bundled datasets are replayed, as an upsert.
+ // The full bundled datasets are replayed, each in its own mode: the
+ // healer sets no mode of its own, so a dataset that declares none
+ // takes the spec default, create-only (#30081).
expect(loadCalls[0].seeds).toHaveLength(2);
- expect(loadCalls[0].config).toMatchObject({ defaultMode: 'upsert', multiPass: true });
+ expect(loadCalls[0].config).toMatchObject({ multiPass: true });
+ expect(loadCalls[0].config).not.toHaveProperty('defaultMode');
// No org id in single-tenant mode.
expect(loadCalls[0].config.organizationId).toBeUndefined();
diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
index ec8cba1e9c5..61e7c9fb877 100644
--- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts
@@ -3783,11 +3783,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
/**
* POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data
*
- * Re-runs SeedLoaderService against the cached manifest's `data` arrays.
- * Idempotent (upsert by id). Useful when:
+ * Re-runs SeedLoaderService against the cached manifest's `data` arrays,
+ * each dataset in the mode the installed body carries ([#30081] the spec
+ * default `ignore`, create-only, when it declares none). Idempotent.
+ * Useful when:
* • The user installed an app and skipped sample data
- * • A purge was undone
- * • The user wants a clean baseline back after editing demo rows
+ * • A purge was undone, or demo rows were deleted
+ * It puts back the rows that are missing and leaves an edited row as it
+ * is; to get the shipped values back over edits, purge first, then
+ * reseed. A ledger entry installed from an artifact built before #30081
+ * carries a written `mode: "upsert"` and still writes the shipped values
+ * back on every reseed until the rebuilt artifact is re-installed.
*
* A success answers the loader's four counts — `inserted`, `updated`,
* `skipped`, `errors` — so the caller sees why nothing changed. A run over
@@ -3927,7 +3933,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
* {@link purgeSeedRows} carries the rules; this handler supplies the scope.
*
* • A seed row is matched by the seed's own key — each dataset's
- * `externalId`, the key the install's upsert and the reseed match on.
+ * `externalId`, the key the install and the reseed match on.
* A row whose key no seed record declares (user-authored data) is never
* touched; a key carried by more than one row in scope is not guessed.
* • Scope is the install's: under an organization wall, the caller's
@@ -4618,7 +4624,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
};
/**
- * One SeedLoaderService run over `datasets` (upsert, multi-pass) — the
+ * One SeedLoaderService run over `datasets` (multi-pass, each dataset in
+ * its own `mode`, the spec default `ignore` when it declares none) — the
* shared engine behind install-time seeding, the reseed endpoint and the
* rehydrate-time healer. Throws when objectql/metadata are unavailable;
* per-row write failures are counted into `errors`, not thrown.
@@ -4642,7 +4649,10 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// ADR-0036 / seed rename: the field is `seeds` (was `datasets`).
seeds: datasets,
config: {
- defaultMode: 'upsert',
+ // [#30081] No mode of its own: an install, an upgrade or a
+ // reseed inserts the rows a dataset is missing and leaves the
+ // customer's existing rows alone, unless the dataset declares
+ // `mode: 'upsert'` (the spec default, ruling 6631016988).
multiPass: true,
...(organizationId ? { organizationId } : {}),
},
diff --git a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts
index 5adb185ed04..07c8bc2319b 100644
--- a/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts
+++ b/packages/cloud-connection/src/marketplace-install-local-reseed.test.ts
@@ -28,10 +28,12 @@ import { tmpdir } from 'node:os';
// Controls what the (mocked) seed loader reports back. The handler under test
// only cares about result.summary.total{Inserted,Updated,Skipped} + result.errors.
let seedResult: any = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
+// Every request the (mocked) loader was handed, in order.
+let loadCalls: any[] = [];
vi.mock('@objectstack/runtime', () => ({
SeedLoaderService: class {
- async load() { return seedResult; }
+ async load(request: any) { loadCalls.push(request); return seedResult; }
},
}));
// Only the seed-request parse is stubbed; every other export stays the real one,
@@ -108,6 +110,7 @@ let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mil-reseed-'));
seedResult = { summary: { totalInserted: 0, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
+ loadCalls = [];
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); });
@@ -232,3 +235,39 @@ describe('reseed honest result', () => {
expect(res.payload?.data).toMatchObject({ inserted: 1, skipped: 1, errors: 1 });
});
});
+
+/**
+ * The reseed follows the create-only seed default (#30081). The handler sets
+ * no mode of its own, so the request it builds — parsed here by the REAL
+ * `SeedLoaderRequestSchema`, which the suite stubs to identity above — hands
+ * the loader a mode-less dataset as `ignore`: a reseed inserts the rows that
+ * are missing and leaves a row the customer kept, or edited, as it is. A
+ * dataset that declares `mode: 'upsert'` keeps it.
+ */
+describe('reseed follows the create-only seed default', () => {
+ it('sets no mode of its own; a mode-less dataset parses to ignore, a declared upsert stays upsert', async () => {
+ const { SeedLoaderRequestSchema: RealSchema } =
+ await vi.importActual('@objectstack/spec/data');
+ const rawApp = await installAndGetRoutes();
+ const declared = { object: 'pm_x', mode: 'upsert', records: [{ name: 'ref' }] };
+ const installRes = await rawApp.routes.get('POST /api/v1/marketplace/install-local')!(
+ makeC({ manifest: { ...MANIFEST, id: 'app.test.declared', data: [declared] } }),
+ );
+ expect(installRes.payload?.success).toBe(true);
+ loadCalls = [];
+ seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] };
+
+ await rawApp.routes.get(RESEED)!(makeC({}, 'app.test.proj'));
+ await rawApp.routes.get(RESEED)!(makeC({}, 'app.test.declared'));
+ expect(loadCalls).toHaveLength(2);
+
+ for (const call of loadCalls) {
+ expect(call.config).toMatchObject({ multiPass: true });
+ expect(call.config).not.toHaveProperty('defaultMode');
+ }
+ const silent = RealSchema.parse(loadCalls[0]);
+ expect(silent.seeds.map((d) => d.mode)).toEqual(['ignore']);
+ expect(silent.config.defaultMode).toBe('ignore');
+ expect(RealSchema.parse(loadCalls[1]).seeds.map((d) => d.mode)).toEqual(['upsert']);
+ });
+});
diff --git a/packages/metadata-protocol/src/protocol-publish-batch.ts b/packages/metadata-protocol/src/protocol-publish-batch.ts
index 838c1b9a1d2..0b43a2f1c55 100644
--- a/packages/metadata-protocol/src/protocol-publish-batch.ts
+++ b/packages/metadata-protocol/src/protocol-publish-batch.ts
@@ -112,7 +112,9 @@ export async function applySeedBodies(
const parsedRequest = SeedLoaderRequestSchema.safeParse({
seeds,
config: {
- defaultMode: 'upsert',
+ // [#30081] No mode of its own: a published seed's `mode` is
+ // the spec default (`ignore`, create-only) unless it declares
+ // one, so a re-publish never writes over a customer's edit.
multiPass: true,
// [ADR-0131 D6, §12] No caller organization: a seed dataset
// names the organization it populates itself, and the
diff --git a/packages/metadata-protocol/src/protocol-save-door-refusals.ts b/packages/metadata-protocol/src/protocol-save-door-refusals.ts
index edf2c3fe986..89d62d1e99d 100644
--- a/packages/metadata-protocol/src/protocol-save-door-refusals.ts
+++ b/packages/metadata-protocol/src/protocol-save-door-refusals.ts
@@ -62,7 +62,7 @@ import {
import { resolveItemLock, withItemLockFamily } from './item-lock.js';
import type { ItemAddress } from './item-lock.js';
import { itemDiscriminator } from '@objectstack/metadata-core';
-import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns } from '@objectstack/spec/data';
+import { externalBindingDatasourceRefusal, resolveInjectedSystemColumns, SeedSchema } from '@objectstack/spec/data';
import { METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared';
import { defaultRecordPageObject } from '@objectstack/spec/ui';
import { MetadataProtectionFields, validateObjectNamespacePrefix } from '@objectstack/spec/kernel';
@@ -879,10 +879,11 @@ export async function seedExternalIdSaveRefusal(
const seed = item as { object?: unknown; mode?: unknown; externalId?: unknown };
if (typeof seed.object !== 'string' || seed.object === '') return undefined;
const objectName = seed.object;
- // `SeedSchema`'s defaults, as the loader applies them (`mode: 'upsert'`,
+ // `SeedSchema`'s defaults, as the loader applies them (the `mode` default,
+ // read from the schema rather than restated here [#30081], and
// `externalId: 'name'`): a body read before the parse filled them keys the
// same way a parsed one does.
- const mode = typeof seed.mode === 'string' ? seed.mode : 'upsert';
+ const mode = typeof seed.mode === 'string' ? seed.mode : SeedSchema.shape.mode.parse(undefined);
if (!KEYED_SEED_MODES.has(mode)) return undefined;
const declared = seed.externalId !== undefined;
const externalId = declared ? seed.externalId : 'name';
diff --git a/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts b/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts
index 26bea21141b..840af8737f7 100644
--- a/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts
+++ b/packages/metadata-protocol/src/seed-loader-composite-external-id.test.ts
@@ -105,7 +105,9 @@ function createMetadata(): IMetadataService {
} as unknown as IMetadataService;
}
-// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on).
+// An upsert config, multi-pass on: how a dataset that declares no mode replays
+// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset
+// carries its own, `ignore` when it declares none.)
const CONFIG = {
dryRun: false,
haltOnError: false,
diff --git a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts
index aa11de17476..6e737eed455 100644
--- a/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts
+++ b/packages/metadata-protocol/src/seed-loader-landed-ids.test.ts
@@ -124,7 +124,9 @@ function createMetadata(): IMetadataService {
} as unknown as IMetadataService;
}
-// The AppPlugin inline-seed config: upsert, multi-pass.
+// An upsert config, multi-pass on: how a dataset that declares no mode replays
+// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset
+// carries its own, `ignore` when it declares none.)
const CONFIG = {
dryRun: false,
haltOnError: false,
diff --git a/packages/metadata-protocol/src/seed-loader-replay.test.ts b/packages/metadata-protocol/src/seed-loader-replay.test.ts
index b4fcdc3e02c..33cc2fe41d9 100644
--- a/packages/metadata-protocol/src/seed-loader-replay.test.ts
+++ b/packages/metadata-protocol/src/seed-loader-replay.test.ts
@@ -108,7 +108,9 @@ function createMetadata(): IMetadataService {
} as unknown as IMetadataService;
}
-// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on).
+// An upsert config, multi-pass on: how a dataset that declares no mode replays
+// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset
+// carries its own, `ignore` when it declares none.)
const CONFIG = {
dryRun: false,
haltOnError: false,
diff --git a/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts b/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts
index eb442fae22b..d81c0206e8c 100644
--- a/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts
+++ b/packages/metadata-protocol/src/seed-loader-state-machine-exempt.test.ts
@@ -142,7 +142,9 @@ function createMetadata(): IMetadataService {
} as unknown as IMetadataService;
}
-// Mirrors the AppPlugin inline-seed config (defaultMode upsert, multiPass on).
+// An upsert config, multi-pass on: how a dataset that declares no mode replays
+// here. (The AppPlugin request itself sets no mode since #30081 — a parsed dataset
+// carries its own, `ignore` when it declares none.)
const CONFIG = {
dryRun: false,
haltOnError: false,
diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts
index 94326730178..6af5ac58707 100644
--- a/packages/runtime/src/app-plugin.ts
+++ b/packages/runtime/src/app-plugin.ts
@@ -1814,7 +1814,9 @@ export class AppPlugin implements Plugin {
const request = SeedLoaderRequestSchema.parse({
seeds: datasetsNow,
config: {
- defaultMode: 'upsert',
+ // [#30081] No mode of its own: each dataset's
+ // `mode` is the spec default (`ignore`,
+ // create-only) unless the dataset declares one.
multiPass: true,
organizationId,
// `os.org` is derived from organizationId inside
@@ -1916,7 +1918,11 @@ export class AppPlugin implements Plugin {
const request = SeedLoaderRequestSchema.parse({
seeds: normalizedDatasets,
config: {
- defaultMode: 'upsert',
+ // [#30081] No mode of its own: a replay on a
+ // later boot inserts a missing row and leaves an
+ // existing one as the customer left it, unless the
+ // dataset declares `mode: 'upsert'` (the spec
+ // default, `SeedSchema.mode`, ruling 6631016988).
multiPass: true,
identity: seedIdentity,
...(seedLocale ? { locale: seedLocale } : {}),
@@ -2340,7 +2346,7 @@ export class AppPlugin implements Plugin {
const request = SeedLoaderRequestSchema.parse({
seeds,
config: {
- defaultMode: 'upsert',
+ // [#30081] No mode of its own — the spec default applies.
multiPass: true,
...(seedLocale ? { locale: seedLocale } : {}),
},
diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts
index 0366853f482..24765d51f2f 100644
--- a/packages/runtime/src/domains/packages.ts
+++ b/packages/runtime/src/domains/packages.ts
@@ -3964,7 +3964,8 @@ _context: HttpProtocolContext,
const parsedRequest = SeedLoaderRequestSchema.safeParse({
seeds: datasets,
config: {
- defaultMode: 'upsert',
+ // [#30081] No mode of its own: each seed's `mode` is the spec
+ // default (`ignore`, create-only) unless the seed declares one.
multiPass: true,
},
});
diff --git a/packages/runtime/src/seed-datasets.test.ts b/packages/runtime/src/seed-datasets.test.ts
index c10aec703bb..937db858545 100644
--- a/packages/runtime/src/seed-datasets.test.ts
+++ b/packages/runtime/src/seed-datasets.test.ts
@@ -217,12 +217,14 @@ describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () =>
'crm_contact',
'hot_lead',
]);
- // Tenant-scoped, upsert, multi-pass — as the org replay requires.
+ // Tenant-scoped, multi-pass — as the org replay requires. No mode of
+ // its own: each dataset replays in the mode it declares, the spec
+ // default (create-only) when it declares none (#30081).
expect(loadCalls[0].config).toMatchObject({
- defaultMode: 'upsert',
multiPass: true,
organizationId: 'org_fresh',
});
+ expect(loadCalls[0].config).not.toHaveProperty('defaultMode');
expect(result).toMatchObject({ inserted: 3 });
});
@@ -245,3 +247,51 @@ describe('AppPlugin per-org replayer replays the live union (#3453 seam)', () =>
expect(loadCalls.at(-1).seeds.map((s: any) => s.object)).toEqual(['crm_account', 'hot_lead']);
});
});
+
+// ────────────────────────────────────────────────────────────────────────────
+/**
+ * #30081 — one seed default, stated once. The inline seed a single-tenant boot
+ * runs builds its request with no mode of its own: each dataset replays in the
+ * mode it declares, the spec default (`ignore`, create-only) when it declares
+ * none. The per-organization replayer above and the install-local doors
+ * (cloud-connection) are pinned the same way.
+ */
+describe('AppPlugin inline boot seed sets no mode of its own (#30081)', () => {
+ const OLD_MULTI = process.env.OS_MULTI_ORG_ENABLED;
+
+ beforeEach(() => {
+ loadCalls = [];
+ seedResult = loaderResult({ totalInserted: 1, totalUpdated: 0, totalSkipped: 0 });
+ delete process.env.OS_MULTI_ORG_ENABLED; // single-tenant: the inline seed runs at boot
+ });
+ afterEach(() => {
+ if (OLD_MULTI === undefined) delete process.env.OS_MULTI_ORG_ENABLED;
+ else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI;
+ vi.restoreAllMocks();
+ });
+
+ it('hands the loader a multi-pass request with no defaultMode', async () => {
+ const services = new Map();
+ services.set('objectql', { insert: vi.fn(async () => undefined) });
+ services.set('metadata', {}); // present → the SeedLoaderService path, not the basic-insert fallback
+ const ctx: any = {
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
+ getService: (n: string) => (services.has(n) ? services.get(n) : undefined),
+ registerService: (n: string, v: any) => {
+ if (services.has(n)) throw new Error(`[Kernel] Service '${n}' already registered`);
+ services.set(n, v);
+ },
+ getServices: () => new Map(services),
+ hook: vi.fn(),
+ trigger: vi.fn(),
+ };
+
+ await new AppPlugin(appBundle('config-app-boot', ds('crm_account', 'a1'))).start(ctx);
+
+ await vi.waitFor(() => expect(loadCalls).toHaveLength(1));
+ expect(loadCalls[0].seeds.map((s: any) => s.object)).toEqual(['crm_account']);
+ expect(loadCalls[0].config).toMatchObject({ multiPass: true });
+ expect(loadCalls[0].config).not.toHaveProperty('defaultMode');
+ expect(loadCalls[0].config).not.toHaveProperty('organizationId');
+ });
+});
diff --git a/packages/runtime/src/seed-default-create-only-replay.integration.test.ts b/packages/runtime/src/seed-default-create-only-replay.integration.test.ts
new file mode 100644
index 00000000000..9fac4a5dc73
--- /dev/null
+++ b/packages/runtime/src/seed-default-create-only-replay.integration.test.ts
@@ -0,0 +1,181 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * A seed with no `mode` is create-only (#30081) — measured on a REAL
+ * `SqlDriver` under a real `ObjectQL`.
+ *
+ * A seed is replayed on every single-tenant boot, on every package install or
+ * upgrade and on every publish. With the old `upsert` default each replay
+ * wrote the shipped value back over a customer's edit to a seeded row, in
+ * production too and with no signal (measured on the card before the change,
+ * ruling 6631016988, option A). The default is now `ignore`.
+ *
+ * The request is built the way every replay site builds it after the change:
+ * through `SeedLoaderRequestSchema`, with no `defaultMode` of its own. Pinned,
+ * for a dataset that declares no mode, across a replay of a NEW seed version:
+ *
+ * 1. A customer's edit to a seeded row survives the replay.
+ * 2. A record the new version adds, missing from the table, is inserted.
+ * 3. A row the customer never touched is left as it is — the new version's
+ * value does not reach it and its `updated_at` does not move.
+ *
+ * Control, same steps with `mode: 'upsert'` declared: the shipped value is
+ * written back over the customer's edit and onto the untouched row, whose
+ * `updated_at` moves — so (1) and (3) above are the default's doing, not a
+ * loader that writes nothing.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { ObjectQL } from '@objectstack/objectql';
+import { SeedLoaderService } from '@objectstack/metadata-protocol';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { SeedLoaderRequestSchema } from '@objectstack/spec/data';
+import {
+ captureExpectedReadRefusals,
+ type ExpectedReadRefusalCapture,
+} from './expected-read-refusal-noise.js';
+
+const ROW = {
+ name: 'seed_owned_row',
+ fields: {
+ name: { type: 'text', required: true },
+ stage: { type: 'text' },
+ amount: { type: 'number' },
+ },
+};
+const OBJECTS = [ROW];
+const SYSTEM = { context: { isSystem: true } } as any;
+const logger = { info() {}, warn() {}, error() {}, debug() {} };
+
+/** The package's first version, then the upgrade it ships later. */
+const V1 = [
+ { name: 'edited', stage: 'new', amount: 1 },
+ { name: 'untouched', stage: 'new', amount: 2 },
+];
+const V2 = [
+ { name: 'edited', stage: 'shipped-v2', amount: 1 },
+ { name: 'untouched', stage: 'new', amount: 20 },
+ { name: 'added', stage: 'new', amount: 3 },
+];
+
+/** The request every replay site builds: parsed, with no mode of its own. */
+function request(records: unknown[], mode?: 'upsert') {
+ return SeedLoaderRequestSchema.parse({
+ seeds: [{ object: 'seed_owned_row', records, ...(mode ? { mode } : {}) }],
+ config: { multiPass: true },
+ });
+}
+
+function metadataFor(objects: any[]) {
+ const byName = new Map(objects.map((o) => [o.name, o]));
+ return {
+ getObject: async (name: string) => byName.get(name),
+ listObjects: async () => objects,
+ register: async () => {}, get: async (_t: string, n: string) => byName.get(n),
+ list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [],
+ } as any;
+}
+
+describe('the replay request every site builds (#30081)', () => {
+ it('carries the create-only default and no mode of the caller\'s own', () => {
+ const parsed = request(V1);
+ expect(parsed.seeds[0].mode).toBe('ignore');
+ expect(parsed.config.defaultMode).toBe('ignore');
+ expect(request(V1, 'upsert').seeds[0].mode).toBe('upsert');
+ });
+});
+
+describe('a seed with no mode is create-only on replay, on a REAL SqlDriver (#30081)', () => {
+ let dir: string | null = null;
+ let engine: ObjectQL | null = null;
+ let noise: ExpectedReadRefusalCapture | null = null;
+
+ afterEach(async () => {
+ vi.useRealTimers();
+ try { await engine?.destroy(); } catch { /* noop */ }
+ engine = null;
+ if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
+ expect(noise?.tablesSeen() ?? ['no capture was installed']).toEqual([]);
+ noise = null;
+ });
+
+ async function boot() {
+ // Only `Date` is faked, so each step stamps a distinct `updated_at`.
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date('2026-11-04T10:00:00.000Z'));
+ dir = mkdtempSync(join(tmpdir(), 'os-seed-create-only-'));
+ const driver = new SqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: join(dir, 'data.sqlite') },
+ useNullAsDefault: true,
+ });
+ noise = captureExpectedReadRefusals(['sys_organization']);
+ noise.captureDriver(driver);
+ await driver.initObjects(OBJECTS);
+ engine = new ObjectQL();
+ noise.captureEngine(engine);
+ engine.registerDriver(driver, true);
+ await engine.init();
+ for (const o of OBJECTS) engine.registry.registerObject(o as any);
+ const knex = (driver as any).knex;
+ const stored = async (name: string) =>
+ (await knex('seed_owned_row').where({ name }).first()) as Record | undefined;
+ const loader = new SeedLoaderService(engine as any, metadataFor(OBJECTS), logger);
+
+ /** First load, then a customer edit, then the upgrade's replay. */
+ async function installEditUpgrade(mode?: 'upsert') {
+ const first = await loader.load(request(V1, mode));
+ expect(first.errors.map((x: any) => x.message)).toEqual([]);
+ expect(first.summary.totalInserted).toBe(2);
+ const untouchedBefore = await stored('untouched');
+
+ vi.setSystemTime(new Date('2026-11-04T11:00:00.000Z'));
+ const edited = await stored('edited');
+ await engine!.update('seed_owned_row', { id: edited!.id, stage: 'customer-edit' }, SYSTEM);
+ expect((await stored('edited'))!.stage).toBe('customer-edit');
+
+ vi.setSystemTime(new Date('2026-11-04T12:00:00.000Z'));
+ const replay = await loader.load(request(V2, mode));
+ expect(replay.errors.map((x: any) => x.message)).toEqual([]);
+ return { replay, untouchedBefore: untouchedBefore! };
+ }
+
+ return { stored, installEditUpgrade };
+ }
+
+ it('a customer edit survives, a missing row is inserted, an untouched row and its updated_at stay put', async () => {
+ const { stored, installEditUpgrade } = await boot();
+ const { replay, untouchedBefore } = await installEditUpgrade();
+
+ expect(replay.summary.totalInserted).toBe(1);
+ expect(replay.summary.totalUpdated).toBe(0);
+ expect(replay.summary.totalSkipped).toBe(2);
+
+ // (1) the customer's edit is still there
+ expect((await stored('edited'))!.stage).toBe('customer-edit');
+ // (2) the record the upgrade added is inserted
+ expect(await stored('added')).toMatchObject({ stage: 'new', amount: 3 });
+ // (3) the untouched row keeps its first value and its updated_at
+ const untouchedAfter = (await stored('untouched'))!;
+ expect(untouchedBefore.updated_at).toBeTruthy();
+ expect(untouchedAfter.amount).toBe(2);
+ expect(untouchedAfter.updated_at).toEqual(untouchedBefore.updated_at);
+ });
+
+ it('control: an explicit upsert still writes the shipped value back on replay', async () => {
+ const { stored, installEditUpgrade } = await boot();
+ const { replay, untouchedBefore } = await installEditUpgrade('upsert');
+
+ expect(replay.summary.totalInserted).toBe(1);
+ expect(replay.summary.totalUpdated).toBe(2);
+
+ expect((await stored('edited'))!.stage).toBe('shipped-v2');
+ expect(await stored('added')).toMatchObject({ stage: 'new', amount: 3 });
+ const untouchedAfter = (await stored('untouched'))!;
+ expect(untouchedAfter.amount).toBe(20);
+ expect(untouchedAfter.updated_at).not.toEqual(untouchedBefore.updated_at);
+ });
+});
diff --git a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts
index 734ac0020ba..a647ab1da7f 100644
--- a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts
+++ b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts
@@ -147,7 +147,7 @@ async function plantOwn(plant: (o: string, r: Record) => Promis
function scopedRequest(organizationId: string, seeds: unknown = SEEDS) {
return SeedLoaderRequestSchema.parse({
seeds,
- config: { defaultMode: 'upsert', multiPass: true, organizationId },
+ config: { multiPass: true, organizationId },
});
}
@@ -246,8 +246,8 @@ describe('every scoped caller reads the same way (sqlite-file)', () => {
});
// `MarketplaceInstallLocalPlugin.runInlineSeed` serves both doors and builds
- // the same request for each: `defaultMode: 'upsert'`, `multiPass: true`, the
- // caller's organization.
+ // the same request for each: `multiPass: true` and the caller's organization,
+ // each dataset in its own mode.
it('walled install and reseed: the request runInlineSeed builds for both', async () => {
const { engine, all, plant } = await boot();
await plantOwn(plant);
diff --git a/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts b/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts
index 8e425f7edbc..04807e20c05 100644
--- a/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts
+++ b/packages/runtime/src/seed-replay-per-organization-identity.integration.test.ts
@@ -126,7 +126,7 @@ function createLogger() {
/**
* Boot the walled composition and hand back the replayer AppPlugin registered.
* The replayer is the production closure: it builds the per-organization
- * request (`defaultMode: 'upsert'`, `multiPass: true`, `organizationId`) and
+ * request (`multiPass: true`, `organizationId`, each dataset in its own mode) and
* runs the real loader against the real engine.
*/
async function bootWalled(dataset: typeof ORG_UNITS) {
diff --git a/packages/spec/authorable-defaults/data.json b/packages/spec/authorable-defaults/data.json
index b15afc4aa00..9175f9200e6 100644
--- a/packages/spec/authorable-defaults/data.json
+++ b/packages/spec/authorable-defaults/data.json
@@ -97,19 +97,19 @@
"data/ScriptValidation:severity = \"error\"",
"data/Seed:env = [\"prod\",\"dev\",\"test\"]",
"data/Seed:externalId = \"name\"",
- "data/Seed:mode = \"upsert\"",
+ "data/Seed:mode = \"ignore\"",
"data/SeedColumn:applyOn = \"always\"",
"data/SeedLoadResult:errors = []",
"data/SeedLoadResult:landedIds = []",
"data/SeedLoadResult:referencesDropped = 0",
"data/SeedLoadResult:summariesStale = 0",
"data/SeedLoaderConfig:batchSize = 1000",
- "data/SeedLoaderConfig:defaultMode = \"upsert\"",
+ "data/SeedLoaderConfig:defaultMode = \"ignore\"",
"data/SeedLoaderConfig:dryRun = false",
"data/SeedLoaderConfig:haltOnError = false",
"data/SeedLoaderConfig:multiPass = true",
"data/SeedLoaderConfig:transaction = false",
- "data/SeedLoaderRequest:config = {\"batchSize\":1000,\"defaultMode\":\"upsert\",\"dryRun\":false,\"haltOnError\":false,\"multiPass\":true,\"transaction\":false}",
+ "data/SeedLoaderRequest:config = {\"batchSize\":1000,\"defaultMode\":\"ignore\",\"dryRun\":false,\"haltOnError\":false,\"multiPass\":true,\"transaction\":false}",
"data/ShardingConfig:enabled = false",
"data/SortNode:order = \"asc\"",
"data/SqliteConfig:filename = \":memory:\"",
diff --git a/packages/spec/scripts/lib/default-changes.ts b/packages/spec/scripts/lib/default-changes.ts
index ef686a91d61..4aa649575c2 100644
--- a/packages/spec/scripts/lib/default-changes.ts
+++ b/packages/spec/scripts/lib/default-changes.ts
@@ -58,6 +58,35 @@
import type { DeclaredDefaultChange } from './authorable-defaults.js';
+/**
+ * Shared by the three seed-mode rows below (#30081): the dataset default, the
+ * loader configuration's fallback that now IS that default, and the request's
+ * parsed default `config` that carries it.
+ */
+const SEED_MODE_CREATE_ONLY_REASON =
+ 'A RULED default change (ruling 6631016988 on #30081, option A, after a production measurement): a '
+ + 'seed is create-only by default. A load inserts the records whose row is missing and leaves every '
+ + 'existing row as it is, so a replay — every single-tenant boot, a package install or upgrade, a '
+ + 'publish of a `seed` item — no longer writes a seeded value back over a customer\'s edit. Measured '
+ + 'before the change on `os start`, `os serve`, a boot-artifact upgrade and an `os package install` '
+ + 'upgrade: each put an edited seeded row back to the shipped value, silently. What moves for a '
+ + 'consumer: a dataset that omits `mode` parses to `ignore` where it parsed to `upsert`, so a replay '
+ + 'no longer refreshes rows that already exist — a changed seed value reaches only a row the load '
+ + 'creates. To keep refreshing a dataset (reference data the package keeps current), write '
+ + '`mode: \'upsert\'`, and exempt the columns the customer or a job owns with '
+ + '`columns: { FIELD: { applyOn: \'create\' } }`. A package BUILT before this change carries a written `mode: "upsert"` in its compiled '
+ + 'artifact (`os build` stores the parsed seed), which the loader cannot tell from an authored one, '
+ + 'so it keeps replaying with upsert until it is rebuilt; nothing converts it. An install-local '
+ + 'package (`os package install`) keeps the body it was installed with in its install ledger, and its '
+ + 'sample-data reseed replays that body in its stored mode, so an entry installed from such an '
+ + 'artifact keeps writing the shipped values back on every reseed until the rebuilt artifact is '
+ + 're-installed: a rebuild alone changes nothing for it. The ledger\'s other replays never overwrite: '
+ + 'the boot rehydrate seeds nothing, the heal seeds only when every seeded object is empty, and the '
+ + 'per-organization replay runs only for a new organization. A stored `seed` metadata item with no '
+ + '`mode` is not rewritten either: its next publish parses it to `ignore`, so that publish inserts '
+ + 'only the rows it is missing where it used to write its values back over existing rows, with no '
+ + 'signal. D3 entry: `seed-mode-default-create-only`.';
+
/**
* Declared default changes, keyed by the protocol major that shipped them.
*
@@ -742,5 +771,31 @@ export const DEFAULT_CHANGES_BY_MAJOR: Readonly {
expect(config.dryRun).toBe(false);
expect(config.haltOnError).toBe(false);
expect(config.multiPass).toBe(true);
- expect(config.defaultMode).toBe('upsert');
+ expect(config.defaultMode).toBe('ignore');
expect(config.batchSize).toBe(1000);
expect(config.transaction).toBe(false);
expect(config.env).toBeUndefined();
});
+ it('takes its defaultMode from the seed mode default — one default, stated once (#30081)', () => {
+ const seedDefault = SeedSchema.parse({ object: 'product', records: [] }).mode;
+ expect(SeedLoaderConfigSchema.parse({}).defaultMode).toBe(seedDefault);
+ });
+
it('should accept dry-run configuration', () => {
const config = SeedLoaderConfigSchema.parse({
dryRun: true,
@@ -582,7 +588,10 @@ describe('SeedLoaderRequestSchema', () => {
});
expect(request.seeds).toHaveLength(1);
expect(request.config.dryRun).toBe(false);
- expect(request.config.defaultMode).toBe('upsert');
+ expect(request.config.defaultMode).toBe('ignore');
+ // Each dataset is parsed through SeedSchema, so a mode-less dataset
+ // reaches the loader already carrying the create-only default.
+ expect(request.seeds[0].mode).toBe('ignore');
});
it('should accept a request with full configuration', () => {
diff --git a/packages/spec/src/data/seed-loader.zod.ts b/packages/spec/src/data/seed-loader.zod.ts
index d0b0b482b81..3f77d785300 100644
--- a/packages/spec/src/data/seed-loader.zod.ts
+++ b/packages/spec/src/data/seed-loader.zod.ts
@@ -240,10 +240,18 @@ export const SeedLoaderConfigSchema = lazySchema(() => z.object({
/**
* Default dataset mode when not specified per-dataset.
- * @default 'upsert'
+ *
+ * It is {@link SeedSchema}'s own `mode` field, default included, so the
+ * seed default is stated once: `ignore` (create-only, ruling 6631016988 on
+ * objectstack#30081). A request parsed through
+ * {@link SeedLoaderRequestSchema} fills every dataset's `mode` from that
+ * same default, so this value is read only for a dataset that reaches the
+ * loader unparsed (`validate()`). A caller ⛔ never restates a mode of its
+ * own here.
+ * @default 'ignore'
*/
- defaultMode: SeedMode.default('upsert')
- .describe('Default conflict resolution strategy'),
+ defaultMode: SeedSchema.shape.mode
+ .describe('Default conflict resolution strategy: the seed `mode` default (`ignore`, create-only)'),
/**
* Maximum number of records to process in a single batch.
diff --git a/packages/spec/src/data/seed.test.ts b/packages/spec/src/data/seed.test.ts
index bbc8885e18e..64b890581b1 100644
--- a/packages/spec/src/data/seed.test.ts
+++ b/packages/spec/src/data/seed.test.ts
@@ -54,10 +54,18 @@ describe('SeedSchema', () => {
});
expect(dataset.externalId).toBe('name');
- expect(dataset.mode).toBe('upsert');
+ // Create-only by default (ruling 6631016988 on #30081): a replay inserts
+ // the missing rows and leaves an existing row as the customer left it.
+ expect(dataset.mode).toBe('ignore');
expect(dataset.env).toEqual(['prod', 'dev', 'test']);
});
+ it('keeps every authored mode as written — only an omitted mode takes the default', () => {
+ for (const mode of ['insert', 'update', 'upsert', 'replace', 'ignore'] as const) {
+ expect(SeedSchema.parse({ object: 'product', mode, records: [] }).mode).toBe(mode);
+ }
+ });
+
it('should validate object name format (snake_case)', () => {
expect(() => SeedSchema.parse({
object: 'valid_object_name',
diff --git a/packages/spec/src/data/seed.zod.ts b/packages/spec/src/data/seed.zod.ts
index 404d450969f..dc7831d364a 100644
--- a/packages/spec/src/data/seed.zod.ts
+++ b/packages/spec/src/data/seed.zod.ts
@@ -23,9 +23,9 @@ import {
export const SeedMode = z.enum([
'insert', // Try to insert, fail on duplicate
'update', // Only update found records, ignore new
- 'upsert', // Create new or Update existing (Standard)
+ 'upsert', // Create new or update existing: every replay writes the declared columns back
'replace', // Delete ALL records in object then insert (Dangerous - use for cache tables)
- 'ignore' // Try to insert, silently skip duplicates
+ 'ignore' // Create-only (the default): insert missing rows, leave existing rows alone
]);
/**
@@ -165,9 +165,40 @@ export const SeedSchema = lazySchema(() => strictObject({
.describe('Field (or composite list of fields) matched for the uniqueness check'),
/**
- * Import Strategy
+ * Import Strategy — what a load does with a record whose `externalId`
+ * matches a row that already exists.
+ *
+ * The default, `ignore`, makes a seed create-only: a load inserts the
+ * records whose row is missing and leaves every existing row exactly as it
+ * is. A seed is replayed — on every single-tenant boot, on every package
+ * install or upgrade, on every publish of a `seed` item — and once a seeded
+ * row reaches a customer the row is the customer's: a replay must not write
+ * the shipped value back over their edit, silently, on the next restart
+ * (ruling 6631016988 on objectstack#30081, which measured exactly that on a
+ * production boot and on both upgrade paths while the default was `upsert`).
+ *
+ * An author who wants a replay to refresh the rows — reference data the
+ * package keeps current — declares `mode: 'upsert'`, and can still exempt
+ * the columns the customer or a job owns after the first load with
+ * `columns: { FIELD: { applyOn: 'create' } }` ({@link SeedColumnSchema}).
+ *
+ * A package built before this default moved carries a written
+ * `mode: 'upsert'` in its compiled artifact (`os build` stores the parsed
+ * seed), and the loader cannot tell that value from an authored one, so such
+ * a package keeps replaying with upsert until it is rebuilt — and, where it
+ * was installed with `os package install`, until the rebuilt artifact is
+ * re-installed, because the install ledger keeps the body as installed and
+ * its sample-data reseed replays that body.
*/
- mode: SeedMode.default('upsert').describe('Conflict resolution strategy'),
+ mode: SeedMode.default('ignore')
+ .describe(
+ 'What a load does with a record whose `externalId` matches an existing row. `ignore` (default) ' +
+ 'is create-only: a missing row is inserted and an existing row is left as it is, so a replay ' +
+ '(every boot, a package install or upgrade, a publish) never writes a seeded value back over a ' +
+ 'later edit. `upsert` also writes every declared column back onto the matched row on each replay; ' +
+ 'declare it for reference data the package keeps current. `update` writes matched rows only, ' +
+ '`insert` inserts every record with no match, and `replace` inserts into a table the caller cleared.',
+ ),
/**
* Column Policies
diff --git a/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts b/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts
new file mode 100644
index 00000000000..f0d51861bf6
--- /dev/null
+++ b/packages/spec/src/migrations/entries/semantic/18.seed-mode-default-create-only.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import type { SemanticMigration } from '../../types.js';
+
+// The declared default of `SeedSchema.mode` moved from `upsert` to `ignore`
+// (maintainer ruling 6631016988 on objectstack#30081, option A), and
+// `SeedLoaderConfig.defaultMode` became that same field. The ruling asked for
+// a production measurement first: on `os start`, `os serve`, a boot-artifact
+// upgrade and an `os package install` upgrade, a customer's API edit to a
+// seeded row went back to the shipped value on the next replay, with no
+// signal (card comment 6633189577). Same class as
+// `ui-nav-group-expanded-default-open`: no key is added or removed, so there
+// is no tombstone, no RETIRED_KEYS_BY_MAJOR row and no D2 conversion. The
+// contract look on the card (6633227800) ruled out a conversion that guesses:
+// a compiled artifact's written `mode: "upsert"` cannot be told from an
+// authored one. The citations live here, not in the printed guidance, which
+// carries no tracker number.
+export const entry: SemanticMigration = {
+ id: 'seed-mode-default-create-only',
+ surface: 'data.Seed.mode — an OMITTED mode on a seed dataset (and SeedLoaderConfig.defaultMode, which is that same default)',
+ replacement: 'nothing, to take the platform default: a replay inserts the rows a dataset is missing and '
+ + 'leaves every existing row as the customer left it. To keep refreshing a dataset on every replay — '
+ + 'reference data the package keeps current — write it: `mode: \'upsert\'`, and exempt the columns '
+ + 'the customer or a job owns with `columns: { FIELD: { applyOn: \'create\' } }`',
+ reason:
+ 'A RULED behaviour change on a default, so there is nothing to rewrite and nothing to refuse: a '
+ + 'seed is create-only by default. A seed is replayed on every single-tenant boot, on every package '
+ + 'install or upgrade and on every publish of a `seed` item, and with the old `upsert` default each '
+ + 'replay wrote the shipped value back over a customer\'s edit to a seeded row, in production too, '
+ + 'silently. The default is now `ignore`: a missing row is inserted and an existing row is left as '
+ + 'it is, so a changed seed value reaches only a row the load creates. Not losslessly convertible, '
+ + 'because the question is intent, not text: only the author knows whether a silent dataset is '
+ + 'starter data the customer now owns or reference data the package must keep current, and a '
+ + 'mechanical pass that wrote `mode: \'upsert\'` onto every silent dataset would restore the '
+ + 'overwrite the ruling removed. A package built before this change carries a written '
+ + '`mode: "upsert"` in its compiled artifact (`os build` stores the parsed seed); the loader cannot '
+ + 'tell that value from an authored one, so such a package keeps replaying with upsert until it is '
+ + 'rebuilt, and nothing converts it. A package installed with `os package install` keeps the body it '
+ + 'was installed with in its install ledger, and its sample-data reseed replays that body in its '
+ + 'stored mode, so an entry installed from such an artifact keeps writing the shipped values back on '
+ + 'every reseed until the rebuilt artifact is re-installed: a rebuild alone changes nothing for it. '
+ + 'The ledger\'s other replays never overwrite: the boot rehydrate seeds nothing, the heal seeds only '
+ + 'when every seeded object is empty, and the per-organization replay runs only for a new '
+ + 'organization. A stored `seed` metadata item with no `mode` is not rewritten either: its next '
+ + 'publish parses it to `ignore`, so that publish inserts only the rows it is missing where it used '
+ + 'to write its values back over existing rows, with no signal. The accept set is unchanged — the '
+ + 'same five modes — and every authored `mode` parses exactly as before.',
+ acceptanceCriteria:
+ 'A seed dataset that omits `mode` parses to `mode: \'ignore\'`, and `SeedLoaderConfig.defaultMode` '
+ + 'parses to `ignore`. Loaded twice with a customer edit in between, such a dataset keeps the edit, '
+ + 'inserts a record whose row is missing, and leaves an untouched row\'s `updated_at` as it was. A '
+ + 'dataset that declares `mode: \'upsert\'` still writes its declared columns back onto the matched '
+ + 'row on every replay. Every dataset that must stay current on replay declares `mode: \'upsert\'`, '
+ + 'and every package is rebuilt so its compiled artifact carries the mode its source declares, and '
+ + 're-installed wherever it was installed with `os package install`, so its install ledger does too.',
+};
diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts
index 2ee9f27d96b..f3aa7d2f778 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -1316,8 +1316,9 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = {
* using the specified conflict resolution strategy.
*
* Uses the standard SeedSchema which supports:
- * - `externalId`: Idempotency key for upsert matching (default: 'name')
- * - `mode`: Conflict resolution (upsert, insert, ignore, replace)
+ * - `externalId`: Idempotency key that matches a record to an existing row (default: 'name')
+ * - `mode`: Conflict resolution (default `ignore`, create-only; `upsert` for
+ * reference data a replay keeps current; `insert`, `update`, `replace`)
* - `env`: Environment scoping (prod, dev, test)
*
* @example
@@ -1325,7 +1326,6 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = {
* data: [
* {
* object: 'account',
- * mode: 'upsert',
* externalId: 'name',
* records: [
* { name: 'Acme Corp', type: 'customer', industry: 'technology' },