Skip to content

Commit a7da4de

Browse files
claude[bot]claude
andauthored
feat(spec): adr-0030-notification-event joins CREATION_ATTESTED_MIGRATION_IDS; its docblock states the ledger-claim matrix (#15710 ruling) (#16068)
* feat(spec): adr-0030-notification-event joins CREATION_ATTESTED_MIGRATION_IDS; docblock states the ledger-claim matrix The maintainer ruled the three questions the registration reserved: last_run_at on every completed non-error run, applied_at only on migrated, verified_at never (no self-check); receipt, not gate; and the id is creation-attested. The docblock states the matrix in place of "silence is not an answer"; the array gains the member; the pins that read the array literally move with it, and a spec pin holds the literal membership, the docblock relations and the arbiter's answer to the receipt shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf * test(platform-objects): the boot-attestation pins carry the third creation-attested id The timing and deferral cases in plugin.test.ts assert the attested set as a sorted literal list or a literal count; both move with the new member, and the contradicted-gate case now attests the other two ids in array order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6530e04 commit a7da4de

5 files changed

Lines changed: 375 additions & 23 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `adr-0030-notification-event` joins `CREATION_ATTESTED_MIGRATION_IDS`, and its docblock states what a run may claim in the `sys_migration` ledger (maintainer ruling 2026-09-05 on #15710)
6+
7+
The ADR-0030 notification-convergence migration id was registered so that
8+
"has this cut-over run here?" is answerable at all, with its ledger semantics
9+
deliberately left open on the constant. The maintainer has now ruled them
10+
(decision batch #47 item 5, verbatim 「同意」 — the question batch #21 reserved),
11+
and this release lands the spec half:
12+
13+
- **Creation-attested.** A datastore created after the cut-over has no legacy
14+
`sys_notification` inbox rows by construction, so the id is now a member of
15+
`CREATION_ATTESTED_MIGRATION_IDS`. A store created from empty on this release
16+
therefore carries a third attestation row in `sys_migration` at boot, in the
17+
same uniform shape as the two ADR-0104 rows (`details.attested:
18+
'datastore-created-empty'`, `applied_at: null`, `blocking: 0`, `verified_at`
19+
set for the fact observed at birth). Existing stores are untouched:
20+
`attestFreshDatastore` writes only on a store it observed being created and
21+
never overwrites a row, so a store created before this release attests
22+
nothing new — its row for this id arrives with the first run of the migration.
23+
- **The ledger-claim matrix**, on the constant's docblock, replacing the
24+
registration-era "silence is not an answer": `last_run_at` on every completed
25+
non-`error` run (`migrated`, `already_done`, `not_applicable`); `applied_at`
26+
only on `migrated`; `verified_at` never set by a run (the migration has no
27+
self-check, and `verified_at` means one passed); `blocking: 0`;
28+
`details.outcome` carries the four-valued result; an `error` run writes no
29+
claim at all.
30+
- **Receipt, not gate.** Nothing reads the row as a precondition, and nothing
31+
may: it is what an operator reads, in the shape the seed-tenancy repair
32+
already uses (`verified_at: null`, `blocking: 0`), which
33+
`isDataMigrationFlagVerified` answers `false` to by design.
34+
35+
Additive: no authorable key, export or accept-set narrows, so no BREAKING
36+
banner applies. Which caller writes the run receipt when the migration runs is
37+
the runner's own contract (`@objectstack/metadata/migrations`) and lands
38+
separately.

packages/platform-objects/src/plugin.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
186186
await boot(engine);
187187

188188
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
189+
'adr-0030-notification-event', // #15710 ruling 3 — creation-attested
189190
'adr-0104-file-references',
190191
'adr-0104-value-shapes',
191192
]);
@@ -264,6 +265,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
264265
await ctx._flush('app:seeded');
265266

266267
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
268+
'adr-0030-notification-event',
267269
'adr-0104-file-references',
268270
'adr-0104-value-shapes',
269271
]);
@@ -280,7 +282,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
280282
await ctx._flush('app:seeded');
281283
await ctx._flushReady();
282284

283-
expect(engine.rows).toHaveLength(2);
285+
expect(engine.rows).toHaveLength(3);
284286
});
285287

286288
/**
@@ -306,7 +308,10 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
306308
await ctx._flush('app:seeded');
307309
await ctx._flushReady();
308310

309-
expect(engine.rows.map((r: any) => r.id)).toEqual(['adr-0104-value-shapes']);
311+
expect(engine.rows.map((r: any) => r.id)).toEqual([
312+
'adr-0104-value-shapes',
313+
'adr-0030-notification-event',
314+
]);
310315
});
311316
});
312317

@@ -368,6 +373,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
368373
await ctx._flush('app:seeded');
369374

370375
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
376+
'adr-0030-notification-event',
371377
'adr-0104-file-references',
372378
'adr-0104-value-shapes',
373379
]);
@@ -390,7 +396,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
390396

391397
state.inFlight = 0; // app B settled
392398
await ctx._flush('app:seeded');
393-
expect(engine.rows).toHaveLength(2);
399+
expect(engine.rows).toHaveLength(3);
394400
});
395401

396402
/**
@@ -446,6 +452,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
446452
await ctx._flushReady();
447453

448454
expect(engine.rows.map((r: any) => r.id).sort()).toEqual([
455+
'adr-0030-notification-event',
449456
'adr-0104-file-references',
450457
'adr-0104-value-shapes',
451458
]);
@@ -461,7 +468,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
461468

462469
await ctx._flushReady();
463470

464-
expect(engine.rows).toHaveLength(2);
471+
expect(engine.rows).toHaveLength(3);
465472
});
466473

467474
/**

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { describe, it, expect, vi } from 'vitest';
99
// why this file's `update` entry sat in the gate's DEBT ledger until #5619 sank
1010
// the predicate into a package that depends on neither side.
1111
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
12-
import { CREATION_ATTESTED_MIGRATION_IDS } from '@objectstack/spec/system';
12+
import {
13+
CREATION_ATTESTED_MIGRATION_IDS,
14+
NOTIFICATION_EVENT_MIGRATION_ID,
15+
} from '@objectstack/spec/system';
1316
import {
1417
readDataMigrationFlag,
1518
isDataMigrationVerified,
@@ -161,6 +164,29 @@ describe('fresh-datastore attestation (ADR-0104, 2026-07-30 addendum)', () => {
161164
);
162165
});
163166

167+
/**
168+
* #15710 ruling 3. The ADR-0030 cut-over id is attested at birth like the
169+
* two ADR-0104 ids — a store created after the cut-over never held a legacy
170+
* inbox row — and in the SAME uniform shape: `applied_at: null`,
171+
* `blocking: 0`, `details.attested`, `verified_at` set for the birth fact.
172+
* What a RUN of that migration may claim (never `verified_at`) lives on the
173+
* id's docblock in `@objectstack/spec`; this pins that the writer treats it
174+
* as one more member and invents no per-id shape. Named by symbol, not by
175+
* iterating the array: the loop above would stay green with the member gone.
176+
*/
177+
it('attests the ADR-0030 notification-event id at birth, in the uniform shape', async () => {
178+
const engine = fakeEngine();
179+
180+
const attested = await attestFreshDatastore(engine);
181+
182+
expect(attested).toContain(NOTIFICATION_EVENT_MIGRATION_ID);
183+
const row = engine.tables.sys_migration.find((r) => r.id === NOTIFICATION_EVENT_MIGRATION_ID)!;
184+
expect(row).toMatchObject({ applied_at: null, blocking: 0, advisory: 0 });
185+
expect(row.verified_at).toBeTruthy();
186+
expect(JSON.parse(String(row.details))).toEqual(CREATION_ATTESTATION_DETAIL);
187+
expect(await isDataMigrationVerified(engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true);
188+
});
189+
164190
/**
165191
* The load-bearing safety property. An existing row means this store is not
166192
* one being created — whatever the caller believed — so attestation must

packages/spec/src/system/migration.zod.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -185,38 +185,75 @@ export const VALUE_SHAPES_MIGRATION_ID = 'adr-0104-value-shapes';
185185
* question has no place to be answered even in principle. That absence, not the
186186
* migration, is what this constant repairs.
187187
*
188-
* WARNING — what a row under this id MEANS is deliberately NOT settled here,
189-
* and its silence is not an answer. The two ids above are written by an
190-
* `os migrate` command that scans, self-checks, and only then records, which is
191-
* what gives `last_run_at` / `applied_at` / `verified_at` / `blocking` their
192-
* meaning for them. This migration has no such command and no self-check: it
193-
* reports `migrated` / `already_done` / `not_applicable` / `error` to its
194-
* caller and nothing else. Which of those columns a run of it may legitimately
195-
* claim, whether anything may gate on the row, and whether a datastore created
196-
* after the cut-over belongs in {@link CREATION_ATTESTED_MIGRATION_IDS}, are
197-
* open contract questions on this surface (#14025) — not facts this constant
198-
* asserts, and not ones to settle by copying the neighbours above.
188+
* What a run of it may claim in the ledger is RULED (maintainer 「同意」 to
189+
* decision batch #47 item 5, recorded on #15710 — the question batch #21
190+
* reserved when the id was registered under #14025). The two ids above take
191+
* their column semantics from an `os migrate` command that scans, self-checks,
192+
* and only then records; this migration has no such command and no self-check
193+
* — it reports `migrated` / `already_done` / `not_applicable` / `error` to its
194+
* caller and nothing else — so its claims are narrower than theirs:
195+
*
196+
* - `last_run_at`: set on every COMPLETED non-`error` run — `migrated`,
197+
* `already_done` and `not_applicable` alike.
198+
* - `applied_at`: set only on `migrated` (legacy inbox rows were rewritten).
199+
* - `verified_at`: NEVER set by a run of this migration. `verified_at` means
200+
* a self-check passed, and there is no self-check to pass.
201+
* - `blocking`: `0` by construction — nothing counts discrepancies.
202+
* - `details.outcome`: the four-valued result, verbatim.
203+
* - an `error` run writes NO ledger claim at all.
204+
*
205+
* Receipt, not gate. Nothing reads a row under this id as a precondition, and
206+
* nothing may: a gate would need the self-check that does not exist. The row
207+
* is what an operator reads, in the shape `sys-migration.object.ts`
208+
* (`@objectstack/platform-objects`) already documents for the seed-tenancy
209+
* repair — `verified_at: null`, `blocking: 0` by construction — which is
210+
* exactly the shape {@link isDataMigrationFlagVerified} answers `false` to.
211+
*
212+
* Creation-attested. A datastore created after the cut-over has no legacy
213+
* inbox rows by construction, and its creator observed it come into being
214+
* with none — the same "true by birth, observably" argument
215+
* {@link CREATION_ATTESTED_MIGRATION_IDS} makes for its other members — so
216+
* this id is a member of that array. Leaving it out would make a fresh
217+
* store's ledger read "never ran" for a registered id, which is false. The
218+
* attestation is not a run: `attestFreshDatastore`
219+
* (`@objectstack/platform-objects`) writes one uniform shape for every member
220+
* — `details.attested: 'datastore-created-empty'`, `applied_at: null`,
221+
* `blocking: 0`, and `verified_at` set for the fact observed at birth — so on
222+
* a fresh store this row does read as verified, by birth and never by a run,
223+
* and it still gates nothing.
224+
*
225+
* Which caller writes the run receipt when the migration runs is the runner's
226+
* contract (`@objectstack/metadata/migrations`), decided in its own lane — not
227+
* here.
199228
*/
200229
export const NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event';
201230

202231
/**
203232
* The migrations a datastore attests at CREATION rather than by scanning.
204233
*
205-
* Both facts these ids stand for — no legacy file value here, no malformed
206-
* stored value here — are true by construction of an empty store, and true
207-
* *observably*: the creator watched it come into being with no rows at all.
208-
* That is the same observed-transition discipline the gates run on, not
209-
* version-gating in disguise; a store that merely *looks* empty when found
210-
* earns nothing, because "found empty" is an inference and "created empty" is
211-
* an observation.
234+
* Every fact these ids stand for — no legacy file value here, no malformed
235+
* stored value here, no legacy `sys_notification` inbox row here — is true by
236+
* construction of an empty store, and true *observably*: the creator watched
237+
* it come into being with no rows at all. That is the same observed-transition
238+
* discipline the gates run on, not version-gating in disguise; a store that
239+
* merely *looks* empty when found earns nothing, because "found empty" is an
240+
* inference and "created empty" is an observation.
212241
*
213242
* Without this, every deployment born on a version that already ships the
214243
* migrations would start lax and stay lax until someone ran a command that is,
215244
* for them, a no-op — so the warn regime would never die out.
245+
*
246+
* The third member is the ADR-0030 cut-over
247+
* ({@link NOTIFICATION_EVENT_MIGRATION_ID}): a store created after it never
248+
* held a per-user inbox row for the migration to split, so the birth
249+
* observation settles that fact exactly as it settles the two ADR-0104 ones.
250+
* Its attestation row is the same uniform shape as theirs; what a RUN of that
251+
* migration may claim differs, and lives on the id's own docblock.
216252
*/
217253
export const CREATION_ATTESTED_MIGRATION_IDS = [
218254
FILE_REFERENCES_MIGRATION_ID,
219255
VALUE_SHAPES_MIGRATION_ID,
256+
NOTIFICATION_EVENT_MIGRATION_ID,
220257
] as const;
221258

222259
export const DataMigrationFlagSchema = lazySchema(() => z.object({

0 commit comments

Comments
 (0)