diff --git a/.changeset/data-migration-flag-columns-moved-at.md b/.changeset/data-migration-flag-columns-moved-at.md new file mode 100644 index 0000000000..81d599537d --- /dev/null +++ b/.changeset/data-migration-flag-columns-moved-at.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": minor +--- + +`DataMigrationFlagSchema` gains `columns_moved_at`, and the `sys_migration` platform object gains the matching column: the deployment-level attestation that a migration's COLUMN MOVE ran here — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. + +**What it attests** is a fact the ledger could not previously express. `applied_at` says the backfill ran in apply mode; `verified_at` says the self-check passed. Neither says anything about the physical columns, because the backfill and the column move are separate acts and only the first of them had somewhere to be recorded. A deployment can therefore have applied AND verified a migration and still store the legacy encoding. `columns_moved_at` is that second fact, carried as its own member rather than as a widening of either existing one: folding it into `verified_at` would change what an already-verified row authorises on every deployment that has never heard of a column move. + +**Absence is the contract, not a default.** The member is optional and nullable, and nothing in this change writes it. Null or absent means the columns still hold the legacy encoding — a real, expected steady state on any deployment that has run the backfill but not the move, and never an error state — so every row that exists in the world today, and any consumer that cannot read the member at all, lands on the legacy encoding with no extra logic. A required member, or a default value, would destroy the exact property the mechanism was chosen for. + +**Nothing reads it yet, and the arbiter is untouched.** `isDataMigrationFlagVerified` — documented as the ONE arbiter for the existing consumers (reap gating, the strict value-shape flip) — is unchanged in this diff, and is now pinned to return the same verdict for a row that omits the new member as it returned before the member existed; `authorisesIrreversibleAction`, which composes it, is pinned the same way. The predicate that will require `columns_moved_at` non-null belongs to the driver work this change unblocks, and reads it in addition to the arbiter, never inside it. + +This is an additive widening: `DataMigrationFlag` (`z.input` of the schema) gains one optional member, no existing member changes or moves, and no export is added or removed. diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 16c6653d45..c0cd3bb137 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -378,6 +378,7 @@ Deployment-level record that a data migration ran here and its self-check passed | **details** | `string` | optional | JSON-encoded counts from the last run, for diagnostics | | **deviation_observed_at** | `string \| null` | optional | When this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises | | **deviation_detail** | `string \| null` | optional | JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), for diagnostics | +| **columns_moved_at** | `string \| null` | optional | When this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null/absent says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume | --- diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index bc240a52fe..ac27a34879 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3569,6 +3569,10 @@ export const enObjects: NonNullable = { label: "Deviation Detail (JSON)", help: "JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), so an operator can find the value that closed the irreversible gate." }, + columns_moved_at: { + label: "Columns Moved At", + help: "When this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume." + }, created_at: { label: "Created At" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 7af5e037d0..4e11a53ecd 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3569,6 +3569,10 @@ export const esESObjects: NonNullable = { label: "Deviation Detail (JSON)", help: "JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), so an operator can find the value that closed the irreversible gate." }, + columns_moved_at: { + label: "Columns Moved At", + help: "When this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume." + }, created_at: { label: "Created At" }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index e1249389a4..4b3fb11f70 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -261,6 +261,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_migration.fields.applied_at.label": "e95c287d92cd1215", "objects.sys_migration.fields.blocking.help": "e807388f81f23dda", "objects.sys_migration.fields.blocking.label": "fde63021cfe650a1", + "objects.sys_migration.fields.columns_moved_at.help": "eaa31d8860c680ec", + "objects.sys_migration.fields.columns_moved_at.label": "9f0cfcef656a7ec8", "objects.sys_migration.fields.created_at.label": "1f02d416befb595b", "objects.sys_migration.fields.details.help": "315f8003d77cf2b9", "objects.sys_migration.fields.details.label": "974694e5c164374e", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 05236608c3..f1edefb714 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3569,6 +3569,10 @@ export const jaJPObjects: NonNullable = { label: "Deviation Detail (JSON)", help: "JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), so an operator can find the value that closed the irreversible gate." }, + columns_moved_at: { + label: "Columns Moved At", + help: "When this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume." + }, created_at: { label: "Created At" }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index ceb4abfe58..0158495444 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -258,6 +258,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_migration.fields.applied_at.label": "e95c287d92cd1215", "objects.sys_migration.fields.blocking.help": "e807388f81f23dda", "objects.sys_migration.fields.blocking.label": "fde63021cfe650a1", + "objects.sys_migration.fields.columns_moved_at.help": "eaa31d8860c680ec", + "objects.sys_migration.fields.columns_moved_at.label": "9f0cfcef656a7ec8", "objects.sys_migration.fields.created_at.label": "1f02d416befb595b", "objects.sys_migration.fields.details.help": "315f8003d77cf2b9", "objects.sys_migration.fields.details.label": "974694e5c164374e", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 754072c7ef..8acbb30403 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3569,6 +3569,10 @@ export const zhCNObjects: NonNullable = { label: "Deviation Detail (JSON)", help: "JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), so an operator can find the value that closed the irreversible gate." }, + columns_moved_at: { + label: "Columns Moved At", + help: "When this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume." + }, created_at: { label: "创建时间" }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index b7cd8cd96d..00ed074b43 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -214,6 +214,8 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_metadata_audit.fields.lock_state.options.nooverlay": "49a44e4a03ff124c", "objects.sys_metadata_history.fields.id.label": "00b0385c9c152888", "objects.sys_metadata_history.fields.recorded_by.help": "e293bd6cf3c47060", + "objects.sys_migration.fields.columns_moved_at.help": "eaa31d8860c680ec", + "objects.sys_migration.fields.columns_moved_at.label": "9f0cfcef656a7ec8", "objects.sys_migration.fields.deviation_detail.help": "681fe28dc0c34386", "objects.sys_migration.fields.deviation_detail.label": "5bf0f0f59a89e2fb", "objects.sys_migration.fields.deviation_observed_at.help": "d6720808eb570acd", diff --git a/packages/platform-objects/src/system/sys-migration.column-move.pin.test.ts b/packages/platform-objects/src/system/sys-migration.column-move.pin.test.ts new file mode 100644 index 0000000000..a0fb2aafb8 --- /dev/null +++ b/packages/platform-objects/src/system/sys-migration.column-move.pin.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16185] The `sys_migration` half of the declared pair — `columns_moved_at`. + * + * `sys-migration.object.ts` states that the row contract "lives in + * `@objectstack/spec/system` (`DataMigrationFlagSchema`)", which is why the + * schema member and this column are one card and not two. That sentence is + * prose; nothing in the repo held the two halves together, so a later card + * could widen either one alone and every gate would stay green. This pin holds + * them together for the member this card adds. + * + * It pins the column's SHAPE as well as its presence, because the shape is the + * contract the ruling on #15989 chose: the column must be able to be absent. + * A `required: true` column would make "the backfill ran here but the column + * move did not" — a real, expected deployment state — unrepresentable in the + * ledger, and mechanism A was chosen precisely because that state must encode + * as nothing at all. The three sibling attestation columns are asserted beside + * it as the control that "not required" is this table's shape and not an + * oversight on the new one. + * + * ⛔ Not pinned here: any read of the column. This card adds the declaration + * and the column and stops — the writer and the reader are the blocked driver + * card's, and a pin over behaviour that does not exist yet would be a pin over + * nothing. + */ + +import { describe, it, expect } from 'vitest'; +import { DataMigrationFlagSchema } from '@objectstack/spec/system'; + +import { SysMigration } from './sys-migration.object.js'; + +const FIELDS = SysMigration.fields as Record>; + +describe('sys_migration.columns_moved_at (#16185)', () => { + it('is declared as a datetime column', () => { + expect(FIELDS.columns_moved_at).toBeDefined(); + expect(FIELDS.columns_moved_at.type).toBe('datetime'); + }); + + it('is readonly — writes flow through the migration command, not the API', () => { + expect(FIELDS.columns_moved_at.readonly).toBe(true); + }); + + it('is NOT required: "backfilled here, columns not moved" must be representable', () => { + // `ObjectSchema.create` resolves an undeclared `required` to `false`, so + // the assertion is against that resolved value, not against absence. + expect(FIELDS.columns_moved_at.required).toBe(false); + // Control — the same shape on the three siblings it joins, against the two + // columns that ARE required, so the assertion above is not vacuous. + for (const sibling of ['verified_at', 'applied_at', 'deviation_observed_at']) { + expect(FIELDS[sibling]?.required, sibling).toBe(false); + } + for (const mandatory of ['id', 'last_run_at']) { + expect(FIELDS[mandatory]?.required, mandatory).toBe(true); + } + }); + + it('matches the row contract: the schema accepts the value this column stores', () => { + const row = { + id: 'adr-0104-file-references', + last_run_at: '2026-09-01T00:00:00.000Z', + blocking: 0, + columns_moved_at: '2026-09-09T04:00:00.000Z', + }; + const parsed = DataMigrationFlagSchema.safeParse(row); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + expect((parsed.data as Record).columns_moved_at).toBe(row.columns_moved_at); + }); + + it('leaves the columns the ledger already had exactly where they were', () => { + // Order matters to a reviewer reading the clause-② instrument, not to the + // runtime: a re-order shows up there as a large false delta. + expect(Object.keys(FIELDS)).toEqual([ + 'id', + 'last_run_at', + 'verified_at', + 'applied_at', + 'blocking', + 'advisory', + 'details', + 'deviation_observed_at', + 'deviation_detail', + 'columns_moved_at', + 'created_at', + 'updated_at', + ]); + }); +}); diff --git a/packages/platform-objects/src/system/sys-migration.object.ts b/packages/platform-objects/src/system/sys-migration.object.ts index dc21a49df3..ec2740e133 100644 --- a/packages/platform-objects/src/system/sys-migration.object.ts +++ b/packages/platform-objects/src/system/sys-migration.object.ts @@ -135,6 +135,18 @@ export const SysMigration = ObjectSchema.create({ 'issue), so an operator can find the value that closed the irreversible gate.', }), + columns_moved_at: Field.datetime({ + label: 'Columns Moved At', + readonly: true, + description: + 'When this deployment last completed the COLUMN MOVE for this migration — the step that ' + + 'retypes the migrated columns and rewrites the values they hold into the new encoding. ' + + 'Separate evidence from applied_at and verified_at, which attest the backfill and its ' + + 'self-check only: a deployment can carry both and still store the legacy encoding. Null ' + + 'says exactly that, and is an expected steady state rather than an error — it is what a ' + + 'consumer that cannot read this field must assume.', + }), + created_at: Field.datetime({ label: 'Created At', readonly: true, diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 85396346d9..1488775e64 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -281,6 +281,7 @@ "system/DataMigrationFlag:advisory", "system/DataMigrationFlag:applied_at", "system/DataMigrationFlag:blocking", + "system/DataMigrationFlag:columns_moved_at", "system/DataMigrationFlag:details", "system/DataMigrationFlag:deviation_detail", "system/DataMigrationFlag:deviation_observed_at", diff --git a/packages/spec/src/system/data-migration-flag-column-move.pin.test.ts b/packages/spec/src/system/data-migration-flag-column-move.pin.test.ts new file mode 100644 index 0000000000..71a68b8985 --- /dev/null +++ b/packages/spec/src/system/data-migration-flag-column-move.pin.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#16185] `columns_moved_at` — the `DataMigrationFlagSchema` member that + * attests the COLUMN MOVE ran on this deployment, pinned at the two properties + * the ruling on #15989 chose mechanism A for. + * + * The ruling picked "one nullable datetime on the flag row" over the + * alternatives because its failure mode is ABSENCE: the row is written in the + * same act that moves the columns, so it cannot disagree with itself, and every + * row that exists in the world today — plus any consumer that cannot read the + * member at all — lands on the legacy encoding with no extra logic. That makes + * optional + nullable a CONTRACT, not a style choice, and it is the first thing + * pinned here. + * + * The second is a NEGATIVE. `isDataMigrationFlagVerified` is documented as "the + * ONE arbiter" for the existing consumers (reap gating #3459, strict flip + * #3438); widening it would change what an already-verified row authorises on + * deployments that have never heard of a column move. This card adds the + * declaration and the column and stops — nothing writes the member, nothing + * reads it yet, and the arbiter's answer for a row that omits it must be the + * same answer it gave before the member existed. Prose cannot carry that; a + * truth table can. + * + * ## Why the arbiter is pinned as an INVARIANCE and not as a truth table alone + * + * A truth table over `(verified_at, blocking)` re-states today's implementation + * and would stay green if a later edit made the arbiter ALSO require + * `columns_moved_at` — because every row in such a table would simply be + * re-derived with the new clause. So each case is asserted three times over the + * same `(verified_at, blocking)` pair — member absent, member null, member set + * — and the three verdicts are asserted EQUAL to each other and to the recorded + * expectation. A clause reading the new member breaks the equality, whatever it + * decides. The source limb below closes the same door from the other side. + * + * `authorisesIrreversibleAction` is pinned with it because it composes the + * arbiter: the same widening reached through the composition would be just as + * silent. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, it, expect } from 'vitest'; + +import { + DataMigrationFlagSchema, + authorisesIrreversibleAction, + hasObservedDeviation, + isDataMigrationFlagVerified, + type DataMigrationFlag, +} from './migration.zod'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const SOURCE = join(HERE, 'migration.zod.ts'); + +const MOVED_AT = '2026-09-09T04:00:00.000Z'; + +/** A row shaped the way `recordDataMigrationRun` writes one, minus the new member. */ +function baseRow(over: Partial = {}): DataMigrationFlag { + return { + id: 'adr-0104-file-references', + last_run_at: '2026-09-01T00:00:00.000Z', + verified_at: '2026-09-01T00:00:00.000Z', + applied_at: '2026-09-01T00:00:00.000Z', + blocking: 0, + ...over, + }; +} + +describe('columns_moved_at — absence is the contract (#16185, ruling on #15989 Q1)', () => { + it('parses green when the member is absent — every row alive today', () => { + const result = DataMigrationFlagSchema.safeParse(baseRow()); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); + + it('injects NO default: an omitting row stays omitting after parse', () => { + const result = DataMigrationFlagSchema.safeParse(baseRow()); + expect(result.success).toBe(true); + // `in`, not `=== undefined`: a default of `null` or `''` would be a value + // the absent arm must never acquire, and both would read as "present". + expect('columns_moved_at' in (result.data as object)).toBe(false); + }); + + it('accepts an explicit null — the same fact said out loud', () => { + const result = DataMigrationFlagSchema.safeParse(baseRow({ columns_moved_at: null })); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect((result.data as DataMigrationFlag).columns_moved_at).toBeNull(); + }); + + it('is a DECLARED member, not a passthrough key — the value survives the parse', () => { + // The object is not `.strict()`, so an undeclared key parses green too; it + // is STRIPPED. Reading the value back is what distinguishes the two. + const result = DataMigrationFlagSchema.safeParse(baseRow({ columns_moved_at: MOVED_AT })); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect((result.data as DataMigrationFlag).columns_moved_at).toBe(MOVED_AT); + // Control for the same instrument: an undeclared sibling IS dropped. + const control = DataMigrationFlagSchema.safeParse({ + ...baseRow(), + columns_moved_at_typo: MOVED_AT, + }); + expect(control.success).toBe(true); + expect('columns_moved_at_typo' in (control.data as object)).toBe(false); + }); + + it('is a datetime, so a non-timestamp cannot masquerade as evidence', () => { + for (const bad of ['', 'yes', '2026-09-09']) { + const result = DataMigrationFlagSchema.safeParse(baseRow({ columns_moved_at: bad })); + expect(result.success, `expected ${JSON.stringify(bad)} to be rejected`).toBe(false); + } + }); + + it('is the fourth member of the datetime-attestation set, not a replacement for one', () => { + const row = baseRow({ columns_moved_at: MOVED_AT, deviation_observed_at: null }); + const result = DataMigrationFlagSchema.safeParse(row); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + const parsed = result.data as DataMigrationFlag; + expect(parsed.verified_at).toBe(row.verified_at); + expect(parsed.applied_at).toBe(row.applied_at); + expect(parsed.deviation_observed_at).toBeNull(); + expect(parsed.columns_moved_at).toBe(MOVED_AT); + }); +}); + +describe('the ONE arbiter is unchanged by this card (#16185 constraint 2)', () => { + /** `(verified_at, blocking)` and the verdict recorded BEFORE the new member existed. */ + const CASES: Array<{ label: string; over: Partial; verified: boolean }> = [ + { label: 'verified, no blocking', over: { verified_at: MOVED_AT, blocking: 0 }, verified: true }, + { label: 'verified but blocking', over: { verified_at: MOVED_AT, blocking: 3 }, verified: false }, + { label: 'never verified', over: { verified_at: null, blocking: 0 }, verified: false }, + { label: 'verified_at empty string', over: { verified_at: '', blocking: 0 }, verified: false }, + ]; + + it.each(CASES)( + 'isDataMigrationFlagVerified: $label — same verdict with the member absent, null and set', + ({ over, verified }) => { + const absent = isDataMigrationFlagVerified(baseRow(over)); + const nulled = isDataMigrationFlagVerified(baseRow({ ...over, columns_moved_at: null })); + const set = isDataMigrationFlagVerified(baseRow({ ...over, columns_moved_at: MOVED_AT })); + expect(absent).toBe(verified); + expect(nulled).toBe(absent); + expect(set).toBe(absent); + }, + ); + + it.each(CASES)( + 'authorisesIrreversibleAction: $label — the composition is invariant too', + ({ over, verified }) => { + const absent = authorisesIrreversibleAction(baseRow(over)); + const nulled = authorisesIrreversibleAction(baseRow({ ...over, columns_moved_at: null })); + const set = authorisesIrreversibleAction(baseRow({ ...over, columns_moved_at: MOVED_AT })); + // No deviation observed on these rows, so it tracks the arbiter exactly. + expect(absent).toBe(verified); + expect(nulled).toBe(absent); + expect(set).toBe(absent); + }, + ); + + it('the true case is real, so the false cases are not vacuous', () => { + expect(isDataMigrationFlagVerified(baseRow())).toBe(true); + expect(authorisesIrreversibleAction(baseRow())).toBe(true); + expect(hasObservedDeviation(baseRow())).toBe(false); + }); + + it('a moved column alone authorises nothing — the arbiter still needs its own evidence', () => { + const moved = baseRow({ verified_at: null, blocking: 0, columns_moved_at: MOVED_AT }); + expect(isDataMigrationFlagVerified(moved)).toBe(false); + expect(authorisesIrreversibleAction(moved)).toBe(false); + }); + + it('the arbiter reads verified_at and blocking, and names the new member nowhere', () => { + const source = readFileSync(SOURCE, 'utf8'); + const decl = source.indexOf('export function isDataMigrationFlagVerified('); + expect(decl, 'the arbiter moved — re-anchor this pin').toBeGreaterThan(-1); + const close = source.indexOf('\n}', decl); + expect(close, 'unterminated arbiter body').toBeGreaterThan(decl); + const body = source.slice(decl, close); + expect(body).toContain('verified_at'); + expect(body).toContain('blocking'); + expect(body).not.toContain('columns_moved_at'); + }); +}); + +/** + * Type-level half of "absence is the contract": both spellings must be + * assignable to {@link DataMigrationFlag}, which is `z.input` of the schema. A + * required member would redden the first, a non-nullable one the second. These + * are checked by `pnpm --filter @objectstack/spec typecheck`, not at runtime. + */ +const OMITS_THE_MEMBER: DataMigrationFlag = { + id: 'adr-0104-file-references', + last_run_at: MOVED_AT, + blocking: 0, +}; +const SAYS_NULL: DataMigrationFlag = { ...OMITS_THE_MEMBER, columns_moved_at: null }; + +describe('the inferred input type keeps both spellings assignable', () => { + it('compiles, and the values are what the declarations say', () => { + expect('columns_moved_at' in OMITS_THE_MEMBER).toBe(false); + expect(SAYS_NULL.columns_moved_at).toBeNull(); + }); +}); diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 0f57558f50..4289cf7ad8 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -273,6 +273,8 @@ export const DataMigrationFlagSchema = lazySchema(() => z.object({ .describe('When this deployment last ADMITTED a value the verified contract rejects, via an OS_ALLOW_LAX_* escape hatch. Does not clear verified_at — it withdraws the irreversible half of what the certificate authorises'), deviation_detail: z.string().nullable().optional() .describe('JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), for diagnostics'), + columns_moved_at: z.string().datetime().nullable().optional() + .describe('When this deployment last completed the COLUMN MOVE for this migration — the step that retypes the migrated columns and rewrites the values they hold into the new encoding. Separate evidence from applied_at and verified_at, which attest the backfill and its self-check only: a deployment can carry both and still store the legacy encoding. Null/absent says exactly that, and is an expected steady state rather than an error — it is what a consumer that cannot read this field must assume'), }).describe('Deployment-level record that a data migration ran here and its self-check passed — the evidence gate consumers read instead of the platform version')); export type DataMigrationFlag = z.input;