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
28 changes: 28 additions & 0 deletions .changeset/attest-fresh-datastore-remedy-register.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/platform-objects': patch
---

`attestFreshDatastore` looks its `os migrate` remedy up instead of defaulting it

When a fresh datastore's own boot has already admitted a value that contradicts a
migration's contract, that id is not attested and the operator is told what closes
the gate on real evidence. The sentence used to be built from a two-way branch: the
file-references id got `files-to-references`, and **every other id** got
`value-shapes` by default.

`CREATION_ATTESTED_MIGRATION_IDS` has three members. For the third —
`adr-0030-notification-event` — that default is a wrong prescription: `os migrate
value-shapes --apply` neither attests nor clears it, and there is no `os migrate
notification-event` sub-command to send an operator to at all (that cut-over is an
operator call with no self-check).

The branch is now an explicit id-to-remedy register, total over the ids a
value-shape tally can contradict. The loop asks it rather than falling into an arm,
so an id with no value-shape contract is never-contradictable by that evidence and
is attested on the birth observation as before. A new member therefore inherits no
remedy: adding a third arm that happened to be right today would only have moved the
same defect onto the fourth member.

No behaviour changes for the two ADR-0104 ids, which is where every reachable path
runs today: the shipped engine keys its admitted-violation tally from a closed
`'media' | 'value-shape'` union, so it cannot name a third id.
86 changes: 86 additions & 0 deletions packages/platform-objects/src/system/migration-flag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { describe, it, expect, vi } from 'vitest';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import {
CREATION_ATTESTED_MIGRATION_IDS,
FILE_REFERENCES_MIGRATION_ID,
NOTIFICATION_EVENT_MIGRATION_ID,
VALUE_SHAPES_MIGRATION_ID,
} from '@objectstack/spec/system';
import {
readDataMigrationFlag,
Expand Down Expand Up @@ -301,5 +303,89 @@ describe('fresh-datastore attestation (ADR-0104, 2026-07-30 addendum)', () => {

expect(await attestFreshDatastore(engine)).toEqual([...CREATION_ATTESTED_MIGRATION_IDS]);
});

/**
* #16067. The remedy sentence used to be a two-way branch whose `else`
* gave `os migrate value-shapes` to every id that was not the file one.
* These pins test that DEFAULT, which is where the defect lived — a pin
* that only exercised the two ADR-0104 ids passed on the broken code, and
* still would.
*
* ⚠️ Reachability, measured rather than assumed: the SHIPPED engine cannot
* key this tally with a third id (`ObjectQL.noteAdmittedValueShapeViolation`
* derives the key from a closed `'media' | 'value-shape'` union, and it is
* the only writer of the map). But `valueShapeViolationsAdmitted` is an
* OPTIONAL, duck-typed member of {@link MigrationFlagEngine} returning an
* open `Record<string, …>` — any other engine satisfies it, as the doubles
* in this very file do. So the default was one non-ObjectQL producer away
* from being read by an operator, and "unreachable" was never a property
* of the seam.
*/
describe('the remedy is looked up, never defaulted (#16067)', () => {
/** The mapping the operator-facing sentence must obey, restated here so
* a change to the production map has to be made twice, on purpose. */
const REMEDY_BY_ID: Record<string, string> = {
[FILE_REFERENCES_MIGRATION_ID]: 'files-to-references',
[VALUE_SHAPES_MIGRATION_ID]: 'value-shapes',
};

it('an id with NO value-shape contract is never-contradictable, and is told to run nothing', async () => {
const engine = fakeEngine();
engine.valueShapeViolationsAdmitted = () => ({
[NOTIFICATION_EVENT_MIGRATION_ID]: VIOLATED,
});
const logger = { info: vi.fn(), warn: vi.fn() };

const attested = await attestFreshDatastore(engine, { logger });

// A value-shape tally is evidence about value shapes. The ADR-0030
// cut-over's fact — no legacy per-user inbox row here — is not one, so
// this counterexample disproves nothing about it and the birth
// observation still settles it.
expect(attested).toContain(NOTIFICATION_EVENT_MIGRATION_ID);
expect(await isDataMigrationVerified(engine, NOTIFICATION_EVENT_MIGRATION_ID)).toBe(true);

const warnings = logger.warn.mock.calls.map((c) => String(c[0] ?? '')).join('\n');
// ⭐ The card's pin: the operator is NOT sent to `os migrate
// value-shapes`, which neither attests nor clears this id. There is no
// `os migrate notification-event` to send them to either — that
// cut-over is an operator call with no self-check — so the correct
// sentence here is no sentence.
expect(warnings).not.toContain('value-shapes');
expect(warnings).toBe('');
});

/**
* Total over the array, so a FOURTH member is judged the moment it is
* added instead of inheriting whatever the last branch happened to say.
*/
it.each([...CREATION_ATTESTED_MIGRATION_IDS])(
'a contradiction for %s is never handed another migration\'s command',
async (id) => {
const engine = fakeEngine();
engine.valueShapeViolationsAdmitted = () => ({ [id]: VIOLATED });
const logger = { info: vi.fn(), warn: vi.fn() };

const attested = await attestFreshDatastore(engine, { logger });
const warnings = logger.warn.mock.calls.map((c) => String(c[0] ?? '')).join('\n');
const own = REMEDY_BY_ID[id];

if (own === undefined) {
expect(attested).toContain(id);
expect(warnings).toBe('');
} else {
expect(attested).not.toContain(id);
expect(warnings).toContain(`os migrate ${own} --apply`);
}

// The half a bigger ternary would still get wrong: no id may ever be
// prescribed a command that belongs to a different id.
for (const [other, command] of Object.entries(REMEDY_BY_ID)) {
if (other === id) continue;
expect(warnings).not.toContain(`os migrate ${command}`);
}
},
);
});
});
});
46 changes: 44 additions & 2 deletions packages/platform-objects/src/system/migration-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DATA_MIGRATION_FLAG_OBJECT,
FILE_REFERENCES_MIGRATION_ID,
isDataMigrationFlagVerified,
VALUE_SHAPES_MIGRATION_ID,
type DataMigrationFlag,
} from '@objectstack/spec/system';

Expand Down Expand Up @@ -205,6 +206,42 @@ export async function recordDataMigrationRun(
return flag;
}

/**
* The `os migrate` sub-command that re-earns each id a boot's own admitted
* value can CONTRADICT — and, by having no row for anything else, the register
* of which ids that is.
*
* ## Why a map and not a branch
*
* The counterexample this reads comes from
* {@link MigrationFlagEngine.valueShapeViolationsAdmitted}, whose whole subject
* is ADR-0104 value shapes. So membership here is one question — *does this id
* stand for a value-shape contract a stored value can disprove?* — and the
* answer for every id that has one is a DIFFERENT command. A two-way branch
* answered both at once: it read the file id and gave every other id
* `value-shapes` by default. That default was silently wrong the moment a third
* id joined {@link CREATION_ATTESTED_MIGRATION_IDS} — `adr-0030-notification-event`
* would have been told to run `os migrate value-shapes --apply`, a command that
* does not attest it, does not clear it, and has nothing to do with it (there is
* no `os migrate notification-event` at all; that cut-over is an operator call
* with no self-check, ruled on `NOTIFICATION_EVENT_MIGRATION_ID`'s docblock).
*
* ⛔ So a new member must NOT inherit a remedy. An id absent from this map is
* never-contradictable *by this evidence* — a value-shape tally says nothing
* about a fact that is not about value shapes — and it is attested on the birth
* observation like any other. Adding a third arm that happens to be right today
* would only move the same defect onto the fourth member; adding a ROW is a
* deliberate act, and its absence prescribes nothing rather than prescribing
* the wrong thing.
*
* A `Map`, not an object literal: `id` reaches this from a caller-supplied
* array, and an object would answer `'toString'` with a function.
*/
const VALUE_SHAPE_CONTRACT_REMEDY: ReadonlyMap<string, string> = new Map([
[FILE_REFERENCES_MIGRATION_ID, 'files-to-references'],
[VALUE_SHAPES_MIGRATION_ID, 'value-shapes'],
]);

/** Marker written into a creation-attested row's `details`, so an operator
* reading a verified flag can tell evidence-by-scan from evidence-by-birth. */
export const CREATION_ATTESTATION_DETAIL = { attested: 'datastore-created-empty' } as const;
Expand Down Expand Up @@ -291,7 +328,12 @@ export async function attestFreshDatastore(
try {
if (await readDataMigrationFlag(engine, id)) continue; // not ours to write
// #4769 — a boot may not prove a contract it has already broken.
const contradiction = admitted[id];
// Asked of the register, not of a default: an id with no value-shape
// contract cannot be contradicted by a value-shape tally, so it is
// attested on the birth observation instead of being handed another
// migration's remedy. See VALUE_SHAPE_CONTRACT_REMEDY.
const remedy = VALUE_SHAPE_CONTRACT_REMEDY.get(id);
const contradiction = remedy === undefined ? undefined : admitted[id];
if (contradiction && contradiction.count > 0) {
const at = contradiction.first;
const where = at?.object && at?.field ? `${at.object}.${at.field}` : 'a record';
Expand All @@ -301,7 +343,7 @@ export async function attestFreshDatastore(
`(${where}${at?.detail ? `: ${at.detail}` : ''}). The store was created empty, but it ` +
'is no longer empty and what it now holds contradicts the claim — the gate stays ' +
'open (warn-first). Fix the data, then run `os migrate ' +
(id === FILE_REFERENCES_MIGRATION_ID ? 'files-to-references' : 'value-shapes') +
remedy +
' --apply` to close it on real evidence (ADR-0104).',
);
continue;
Expand Down
Loading