Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/notification-event-migration-ledger-claims.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@objectstack/spec': minor
---

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)

The ADR-0030 notification-convergence migration id was registered so that
"has this cut-over run here?" is answerable at all, with its ledger semantics
deliberately left open on the constant. The maintainer has now ruled them
(decision batch #47 item 5, verbatim 「同意」 — the question batch #21 reserved),
and this release lands the spec half:

- **Creation-attested.** A datastore created after the cut-over has no legacy
`sys_notification` inbox rows by construction, so the id is now a member of
`CREATION_ATTESTED_MIGRATION_IDS`. A store created from empty on this release
therefore carries a third attestation row in `sys_migration` at boot, in the
same uniform shape as the two ADR-0104 rows (`details.attested:
'datastore-created-empty'`, `applied_at: null`, `blocking: 0`, `verified_at`
set for the fact observed at birth). Existing stores are untouched:
`attestFreshDatastore` writes only on a store it observed being created and
never overwrites a row, so a store created before this release attests
nothing new — its row for this id arrives with the first run of the migration.
- **The ledger-claim matrix**, on the constant's docblock, replacing the
registration-era "silence is not an answer": `last_run_at` on every completed
non-`error` run (`migrated`, `already_done`, `not_applicable`); `applied_at`
only on `migrated`; `verified_at` never set by a run (the migration has no
self-check, and `verified_at` means one passed); `blocking: 0`;
`details.outcome` carries the four-valued result; an `error` run writes no
claim at all.
- **Receipt, not gate.** Nothing reads the row as a precondition, and nothing
may: it is what an operator reads, in the shape the seed-tenancy repair
already uses (`verified_at: null`, `blocking: 0`), which
`isDataMigrationFlagVerified` answers `false` to by design.

Additive: no authorable key, export or accept-set narrows, so no BREAKING
banner applies. Which caller writes the run receipt when the migration runs is
the runner's own contract (`@objectstack/metadata/migrations`) and lands
separately.
15 changes: 11 additions & 4 deletions packages/platform-objects/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe('PlatformObjectsPlugin: fresh-datastore attestation (#3438, ADR-0104)',
await boot(engine);

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

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

expect(engine.rows).toHaveLength(2);
expect(engine.rows).toHaveLength(3);
});

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

expect(engine.rows.map((r: any) => r.id)).toEqual(['adr-0104-value-shapes']);
expect(engine.rows.map((r: any) => r.id)).toEqual([
'adr-0104-value-shapes',
'adr-0030-notification-event',
]);
});
});

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

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

state.inFlight = 0; // app B settled
await ctx._flush('app:seeded');
expect(engine.rows).toHaveLength(2);
expect(engine.rows).toHaveLength(3);
});

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

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

await ctx._flushReady();

expect(engine.rows).toHaveLength(2);
expect(engine.rows).toHaveLength(3);
});

/**
Expand Down
28 changes: 27 additions & 1 deletion packages/platform-objects/src/system/migration-flag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { describe, it, expect, vi } from 'vitest';
// why this file's `update` entry sat in the gate's DEBT ledger until #5619 sank
// the predicate into a package that depends on neither side.
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { CREATION_ATTESTED_MIGRATION_IDS } from '@objectstack/spec/system';
import {
CREATION_ATTESTED_MIGRATION_IDS,
NOTIFICATION_EVENT_MIGRATION_ID,
} from '@objectstack/spec/system';
import {
readDataMigrationFlag,
isDataMigrationVerified,
Expand Down Expand Up @@ -161,6 +164,29 @@ describe('fresh-datastore attestation (ADR-0104, 2026-07-30 addendum)', () => {
);
});

/**
* #15710 ruling 3. The ADR-0030 cut-over id is attested at birth like the
* two ADR-0104 ids — a store created after the cut-over never held a legacy
* inbox row — and in the SAME uniform shape: `applied_at: null`,
* `blocking: 0`, `details.attested`, `verified_at` set for the birth fact.
* What a RUN of that migration may claim (never `verified_at`) lives on the
* id's docblock in `@objectstack/spec`; this pins that the writer treats it
* as one more member and invents no per-id shape. Named by symbol, not by
* iterating the array: the loop above would stay green with the member gone.
*/
it('attests the ADR-0030 notification-event id at birth, in the uniform shape', async () => {
const engine = fakeEngine();

const attested = await attestFreshDatastore(engine);

expect(attested).toContain(NOTIFICATION_EVENT_MIGRATION_ID);
const row = engine.tables.sys_migration.find((r) => r.id === NOTIFICATION_EVENT_MIGRATION_ID)!;
expect(row).toMatchObject({ applied_at: null, blocking: 0, advisory: 0 });
expect(row.verified_at).toBeTruthy();
expect(JSON.parse(String(row.details))).toEqual(CREATION_ATTESTATION_DETAIL);
expect(await isDataMigrationVerified(engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true);
});

/**
* The load-bearing safety property. An existing row means this store is not
* one being created — whatever the caller believed — so attestation must
Expand Down
73 changes: 55 additions & 18 deletions packages/spec/src/system/migration.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,38 +185,75 @@ export const VALUE_SHAPES_MIGRATION_ID = 'adr-0104-value-shapes';
* question has no place to be answered even in principle. That absence, not the
* migration, is what this constant repairs.
*
* WARNING — what a row under this id MEANS is deliberately NOT settled here,
* and its silence is not an answer. The two ids above are written by an
* `os migrate` command that scans, self-checks, and only then records, which is
* what gives `last_run_at` / `applied_at` / `verified_at` / `blocking` their
* meaning for them. This migration has no such command and no self-check: it
* reports `migrated` / `already_done` / `not_applicable` / `error` to its
* caller and nothing else. Which of those columns a run of it may legitimately
* claim, whether anything may gate on the row, and whether a datastore created
* after the cut-over belongs in {@link CREATION_ATTESTED_MIGRATION_IDS}, are
* open contract questions on this surface (#14025) — not facts this constant
* asserts, and not ones to settle by copying the neighbours above.
* What a run of it may claim in the ledger is RULED (maintainer 「同意」 to
* decision batch #47 item 5, recorded on #15710 — the question batch #21
* reserved when the id was registered under #14025). The two ids above take
* their column semantics from an `os migrate` command that scans, self-checks,
* and only then records; this migration has no such command and no self-check
* — it reports `migrated` / `already_done` / `not_applicable` / `error` to its
* caller and nothing else — so its claims are narrower than theirs:
*
* - `last_run_at`: set on every COMPLETED non-`error` run — `migrated`,
* `already_done` and `not_applicable` alike.
* - `applied_at`: set only on `migrated` (legacy inbox rows were rewritten).
* - `verified_at`: NEVER set by a run of this migration. `verified_at` means
* a self-check passed, and there is no self-check to pass.
* - `blocking`: `0` by construction — nothing counts discrepancies.
* - `details.outcome`: the four-valued result, verbatim.
* - an `error` run writes NO ledger claim at all.
*
* Receipt, not gate. Nothing reads a row under this id as a precondition, and
* nothing may: a gate would need the self-check that does not exist. The row
* is what an operator reads, in the shape `sys-migration.object.ts`
* (`@objectstack/platform-objects`) already documents for the seed-tenancy
* repair — `verified_at: null`, `blocking: 0` by construction — which is
* exactly the shape {@link isDataMigrationFlagVerified} answers `false` to.
*
* Creation-attested. A datastore created after the cut-over has no legacy
* inbox rows by construction, and its creator observed it come into being
* with none — the same "true by birth, observably" argument
* {@link CREATION_ATTESTED_MIGRATION_IDS} makes for its other members — so
* this id is a member of that array. Leaving it out would make a fresh
* store's ledger read "never ran" for a registered id, which is false. The
* attestation is not a run: `attestFreshDatastore`
* (`@objectstack/platform-objects`) writes one uniform shape for every member
* — `details.attested: 'datastore-created-empty'`, `applied_at: null`,
* `blocking: 0`, and `verified_at` set for the fact observed at birth — so on
* a fresh store this row does read as verified, by birth and never by a run,
* and it still gates nothing.
*
* Which caller writes the run receipt when the migration runs is the runner's
* contract (`@objectstack/metadata/migrations`), decided in its own lane — not
* here.
*/
export const NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event';

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

export const DataMigrationFlagSchema = lazySchema(() => z.object({
Expand Down
Loading
Loading