Skip to content

Commit 270650f

Browse files
os-zhuangclaude
andauthored
feat(migrate): a datastore created from empty attests its data migrations at creation (#3438) (#4215)
* feat(migrate): a datastore created from empty attests its data migrations at creation (#3438) ADR-0104's 2026-07-30 addendum: deployment-level migration flags could only be recorded by a migration run, so a database created on a version that already ships the migrations started lax and stayed lax until someone ran a command that, for them, converts nothing. Every new deployment re-entered the warn regime — and since #3459, kept every released file forever. A store the platform creates from empty now records both flags at that moment. The fact is observed, not assumed, and the test is deliberately strict: every table made by this boot and none found already present. One pre-existing table, one datasource that was already there, one driver that cannot account for its sync — any of those and the deployment attests nothing. Neither seam the addendum suggested survived: "sys_migration is absent" is the 16-to-17 trap (an upgrading database lacks it too) and the schema bootstrap runs identically on both. Nothing recorded datastore newness at all — the drivers knew it for one statement and discarded it — so the observation is now kept as created-vs-found table counts (IDataDriver.getSchemaSyncStats, SQL + memory), aggregated by the engine and consumed at kernel:ready by the service that owns sys_migration. Counted per table, not per sync call: boot syncs more than once, and treating a re-sync of our own table as history would disable attestation everywhere. Attestation never overwrites an existing row and never throws into a boot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KM46HedAWLbkuZyMBXn5wJ * test(driver-sql): close both pools unconditionally in the schema-sync-stats test A knex pool outlives a failed assertion with a live timer, which keeps the test process alive long after the run itself has passed — the shape of failure that shows up as "every test passed, the job never exited". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KM46HedAWLbkuZyMBXn5wJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62a789b commit 270650f

19 files changed

Lines changed: 724 additions & 3 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
docs(adr-0104): record the fresh-datastore attestation seam as implemented — driver-reported created-vs-found table counts, not "sys_migration is missing" (which is the 16→17 trap) — and note that born-attested dogfood now exercises the #3459 collection path on every boot. Releases nothing.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/objectql": minor
5+
"@objectstack/driver-memory": minor
6+
"@objectstack/driver-sql": minor
7+
"@objectstack/service-storage": minor
8+
---
9+
10+
feat(migrate): a datastore created from empty attests its data migrations at creation (#3438, ADR-0104 2026-07-30 addendum)
11+
12+
Deployment-level migration flags could only be recorded by running
13+
`os migrate`. That left a hole at the other end of a deployment's life: a
14+
database created on a version that already ships the migrations started **lax**
15+
and stayed lax until someone thought to run a command that, for them, converts
16+
nothing and finds nothing. Every new deployment re-entered the warn regime, so
17+
the warn regime would never die out — and, since #3459, every new deployment
18+
also kept every released file forever.
19+
20+
A store the platform **creates from empty** now records
21+
`adr-0104-file-references` and `adr-0104-value-shapes` at that moment. Nothing
22+
to run; enforcement and collection are live from the first boot.
23+
24+
**This is not version-gating in disguise.** The fact recorded — no legacy value
25+
is stored here — is *observed*: the store had no history at all. The platform
26+
attests only what it watched itself create, and the test is deliberately
27+
strict: every table made by this boot and **none found already present**. One
28+
pre-existing table anywhere, one datasource that was already there, one driver
29+
that cannot account for its schema sync — any of those and the deployment
30+
attests nothing and produces its evidence by scan, exactly as before. "Found
31+
empty" and "created empty" are not the same claim, and only the second is an
32+
observation.
33+
34+
**New surfaces.** `IDataDriver.getSchemaSyncStats?()` (optional, purely
35+
observational: tables created vs found since connect — implemented by the SQL
36+
and in-memory drivers), `engine.wasDatastoreCreatedFromEmpty()`,
37+
`attestFreshDatastore()` in `@objectstack/platform-objects/system`, and
38+
`VALUE_SHAPES_MIGRATION_ID` / `CREATION_ATTESTED_MIGRATION_IDS` in
39+
`@objectstack/spec/system`. Attestation never overwrites an existing flag row
40+
and never throws into a boot: a failure leaves the deployment lax, which a
41+
migration run can still fix.
42+
43+
**Upgrading changes nothing for an existing database.** It is non-empty when
44+
the platform reaches it, so it is never attested — run
45+
`os migrate files-to-references --apply` as before. Importing legacy values
46+
into an attested deployment is rejected loudly at the write path;
47+
`OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency while you diagnose.

content/docs/deployment/cli.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,25 @@ delete check re-reads the flag fresh, so a later failing run stops collection
675675
without a restart.
676676
</Callout>
677677

678+
#### A database created by this version needs no migration
679+
680+
A deployment whose database the platform **creates from empty** records these
681+
flags at that moment, so it is enforcing from its first boot and never enters
682+
the warn regime at all. Nothing to run: the fact a migration would establish —
683+
no legacy value is stored here — is already settled by the store having no
684+
history.
685+
686+
The platform attests this only for a store it watched itself create: every
687+
table made by that first boot, none found already present. A database that
688+
existed before — an upgrade, a restore, a store shared with anything else —
689+
attests nothing and produces its evidence by running the command, because
690+
"found empty" and "created empty" are not the same claim.
691+
692+
Importing legacy values into such a deployment is rejected at the write path
693+
rather than silently accepted. That is the intended outcome; if you must admit
694+
them temporarily, `OS_ALLOW_LAX_MEDIA_VALUES=1` re-opens leniency, and
695+
re-running the migration re-establishes the flag from the data itself.
696+
678697
### Scaffolding
679698

680699
| Command | Alias | Description |

content/docs/releases/v17.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -984,6 +984,13 @@ records** — never the platform version — is what authorises both strict medi
984984
value shapes and irreversible file collection. Upgrading changes neither;
985985
running the migration does.
986986

987+
**A database created by 17 attests both flags at creation** (#3438), so a new
988+
deployment enforces from its first boot instead of waiting for someone to run a
989+
migration that, for an empty store, does nothing. The platform attests only a
990+
store it watched itself create — every table made by that boot, none found
991+
already there; an upgraded or restored database attests nothing and produces its
992+
evidence by running the command.
993+
987994
**Released-file collection is live behind that same flag** (#3459). On a
988995
verified deployment, a field file whose one owning record lets go — the field
989996
cleared, or the record deleted — is tombstoned into the declared 30-day grace

docs/adr/0104-field-runtime-value-shape-contract.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,10 +855,29 @@ implementing PR's decision (candidates: the system seed that first creates
855855
requirement: it fires only for a store it is itself creating from empty,
856856
never for one it found.
857857

858+
**Seam, as implemented.** Neither candidate survived contact: *"`sys_migration`
859+
is absent"* is precisely the 16→17 trap — an upgrading database lacks that
860+
table too — and the schema bootstrap runs identically on both. Nothing in the
861+
platform recorded datastore newness at all; the drivers knew it for one
862+
statement (`hasTable``createTable`) and discarded it. So the observation is
863+
now kept: each driver counts the tables it **created** against those it
864+
**found** since connect (`IDataDriver.getSchemaSyncStats`), and a store is
865+
attested only when the whole engine reports *created > 0 and existing = 0*.
866+
Every uncertainty resolves to "no" — a driver that cannot report, a deferred or
867+
skipped sync, a second datasource that was already there. Being strict here is
868+
cheap in the only direction that matters: a wrongly-withheld attestation costs
869+
a command someone can still run, a wrongly-granted one asserts a store's
870+
history on no evidence. The write lands at `kernel:ready` from the service that
871+
owns `sys_migration`, and never overwrites an existing row: a store with flag
872+
rows is by definition not one being created.
873+
858874
Born-strict deployments also make the platform's own dogfood the standing
859875
canary for R2 (a codified shape stricter than some legitimate client's
860876
writes): showcase/CRM boots are fresh datastores, so they enforce from birth,
861-
and a false rejection fails our suites before any customer sees it.
877+
and a false rejection fails our suites before any customer sees it. Since
878+
#3459 that canary covers the collection path too — a born-attested dogfood run
879+
exercises release-time tombstoning and the reap guard's re-verify on every
880+
boot, so an over-eager delete fails our suites rather than a customer's data.
862881

863882
### D2 action params: the evidence cannot exist, so strict is the 17.0 default
864883

packages/objectql/src/engine.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2274,4 +2274,48 @@ describe('ObjectQL — file-as-reference migration flag (#3617)', () => {
22742274
const flagReads = vi.mocked(driver.find).mock.calls.filter((c) => c[0] === 'sys_migration');
22752275
expect(flagReads).toHaveLength(2);
22762276
});
2277+
2278+
// ── Was this datastore created from empty? (#3438, ADR-0104) ──────
2279+
// The one input to fresh-datastore attestation. Permission is granted on
2280+
// this answer, so every uncertainty must resolve to `false`.
2281+
describe('wasDatastoreCreatedFromEmpty', () => {
2282+
const withStats = (d: IDataDriver, stats: { created: number; existing: number } | null) => {
2283+
if (stats) (d as any).getSchemaSyncStats = () => stats;
2284+
else delete (d as any).getSchemaSyncStats;
2285+
};
2286+
2287+
it('is true when the driver created tables and found none', () => {
2288+
withStats(driver, { created: 12, existing: 0 });
2289+
expect(engine.wasDatastoreCreatedFromEmpty()).toBe(true);
2290+
});
2291+
2292+
it('is false when even one table was already there', () => {
2293+
withStats(driver, { created: 11, existing: 1 });
2294+
expect(engine.wasDatastoreCreatedFromEmpty()).toBe(false);
2295+
});
2296+
2297+
it('is false when nothing was created (sync skipped or deferred)', () => {
2298+
withStats(driver, { created: 0, existing: 0 });
2299+
expect(engine.wasDatastoreCreatedFromEmpty()).toBe(false);
2300+
});
2301+
2302+
it('is false when no driver can account for its schema sync', () => {
2303+
withStats(driver, null);
2304+
expect(engine.wasDatastoreCreatedFromEmpty()).toBe(false);
2305+
});
2306+
2307+
it('is false when a second datasource was not created by this boot', () => {
2308+
withStats(driver, { created: 12, existing: 0 });
2309+
const other = {
2310+
name: 'other',
2311+
connect: vi.fn().mockResolvedValue(undefined),
2312+
disconnect: vi.fn().mockResolvedValue(undefined),
2313+
capabilities: {} as any,
2314+
} as unknown as IDataDriver;
2315+
withStats(other, { created: 0, existing: 5 });
2316+
engine.registerDriver(other);
2317+
2318+
expect(engine.wasDatastoreCreatedFromEmpty()).toBe(false);
2319+
});
2320+
});
22772321
});

packages/objectql/src/engine.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2262,6 +2262,36 @@ export class ObjectQL implements IDataEngine {
22622262
this.fileReferencesMigrationVerified = null;
22632263
}
22642264

2265+
/**
2266+
* Did this process CREATE the datastore it is talking to, from empty?
2267+
*
2268+
* True only when every driver that can account for its schema sync created
2269+
* tables and found none already there. That conjunction is the whole point:
2270+
* one pre-existing table anywhere means something ran here before us, so
2271+
* this store's history is not ours to vouch for. A driver that cannot
2272+
* report (`getSchemaSyncStats` absent, deferred DDL, sync skipped) makes the
2273+
* answer no rather than maybe — the consumers of this fact grant
2274+
* permissions, so "cannot say" must read as "no".
2275+
*
2276+
* The one caller is the fresh-datastore attestation (#3438, ADR-0104's
2277+
* 2026-07-30 addendum), which records that a store born empty needs no data
2278+
* migration. Ask it only during boot: once the process starts serving, the
2279+
* counts describe a moment that has passed.
2280+
*/
2281+
wasDatastoreCreatedFromEmpty(): boolean {
2282+
let created = 0;
2283+
let existing = 0;
2284+
let reporting = 0;
2285+
for (const driver of this.drivers.values()) {
2286+
const stats = (driver as any).getSchemaSyncStats?.();
2287+
if (!stats) continue;
2288+
reporting += 1;
2289+
created += Number(stats.created) || 0;
2290+
existing += Number(stats.existing) || 0;
2291+
}
2292+
return reporting > 0 && existing === 0 && created > 0;
2293+
}
2294+
22652295
async destroy() {
22662296
this.logger.info('Destroying ObjectQL engine', { driverCount: this.drivers.size });
22672297

packages/platform-objects/src/system/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export {
1717
readDataMigrationFlag,
1818
isDataMigrationVerified,
1919
recordDataMigrationRun,
20+
attestFreshDatastore,
21+
CREATION_ATTESTATION_DETAIL,
2022
type MigrationFlagEngine,
2123
type DataMigrationRunOutcome,
24+
type AttestationLogger,
2225
} from './migration-flag.js';

packages/platform-objects/src/system/migration-flag.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect } from 'vitest';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { CREATION_ATTESTED_MIGRATION_IDS } from '@objectstack/spec/system';
45
import {
56
readDataMigrationFlag,
67
isDataMigrationVerified,
78
recordDataMigrationRun,
9+
attestFreshDatastore,
10+
CREATION_ATTESTATION_DETAIL,
811
type MigrationFlagEngine,
912
} from './migration-flag.js';
1013

@@ -112,3 +115,85 @@ describe('deployment-level data-migration flags (#3617)', () => {
112115
expect(await isDataMigrationVerified(engine, MIGRATION)).toBe(false);
113116
});
114117
});
118+
119+
describe('fresh-datastore attestation (ADR-0104, 2026-07-30 addendum)', () => {
120+
it('attests every creation-attested migration, verified and blocking-free', async () => {
121+
const engine = fakeEngine();
122+
123+
const attested = await attestFreshDatastore(engine);
124+
125+
expect(attested).toEqual([...CREATION_ATTESTED_MIGRATION_IDS]);
126+
expect(engine.tables.sys_migration).toHaveLength(CREATION_ATTESTED_MIGRATION_IDS.length);
127+
for (const id of CREATION_ATTESTED_MIGRATION_IDS) {
128+
expect(await isDataMigrationVerified(engine, id)).toBe(true);
129+
}
130+
});
131+
132+
it('records nothing as applied — no backfill ran, and none was needed', async () => {
133+
const engine = fakeEngine();
134+
135+
await attestFreshDatastore(engine);
136+
137+
const row = engine.tables.sys_migration[0];
138+
expect(row.applied_at).toBeNull();
139+
expect(row.blocking).toBe(0);
140+
});
141+
142+
it('marks the row so evidence-by-birth is distinguishable from evidence-by-scan', async () => {
143+
const engine = fakeEngine();
144+
145+
await attestFreshDatastore(engine);
146+
147+
expect(JSON.parse(String(engine.tables.sys_migration[0].details))).toEqual(
148+
CREATION_ATTESTATION_DETAIL,
149+
);
150+
});
151+
152+
/**
153+
* The load-bearing safety property. An existing row means this store is not
154+
* one being created — whatever the caller believed — so attestation must
155+
* leave it exactly as it found it. Overwriting could only ever RAISE a gate
156+
* the deployment's own evidence had closed.
157+
*/
158+
it('never overwrites an existing row, including one a failed run closed', async () => {
159+
const engine = fakeEngine([
160+
{ id: MIGRATION, last_run_at: 'yesterday', verified_at: null, blocking: 7 },
161+
]);
162+
163+
const attested = await attestFreshDatastore(engine);
164+
165+
expect(attested).not.toContain(MIGRATION);
166+
const row = engine.tables.sys_migration.find((r) => r.id === MIGRATION)!;
167+
expect(row).toMatchObject({ verified_at: null, blocking: 7, last_run_at: 'yesterday' });
168+
expect(await isDataMigrationVerified(engine, MIGRATION)).toBe(false);
169+
});
170+
171+
it('is idempotent — a second call adds nothing', async () => {
172+
const engine = fakeEngine();
173+
174+
await attestFreshDatastore(engine);
175+
const second = await attestFreshDatastore(engine);
176+
177+
expect(second).toEqual([]);
178+
expect(engine.tables.sys_migration).toHaveLength(CREATION_ATTESTED_MIGRATION_IDS.length);
179+
});
180+
181+
it('does nothing when sys_migration is not registered (bare kernel)', async () => {
182+
const engine = fakeEngine([], { registered: false });
183+
184+
expect(await attestFreshDatastore(engine)).toEqual([]);
185+
expect(engine.tables.sys_migration).toHaveLength(0);
186+
});
187+
188+
it('a write failure warns and leaves the deployment lax — it never throws into a boot', async () => {
189+
const engine = fakeEngine();
190+
engine.insert = async () => {
191+
throw new Error('table locked');
192+
};
193+
const logger = { info: vi.fn(), warn: vi.fn() };
194+
195+
await expect(attestFreshDatastore(engine, { logger })).resolves.toEqual([]);
196+
expect(logger.warn).toHaveBeenCalled();
197+
expect(logger.info).not.toHaveBeenCalled();
198+
});
199+
});

0 commit comments

Comments
 (0)