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
14 changes: 14 additions & 0 deletions .changeset/data-migration-flag-columns-moved-at.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/system/migration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3569,6 +3569,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3569,6 +3569,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3569,6 +3569,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3569,6 +3569,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
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: "创建时间"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>;

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<string, unknown>).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',
]);
});
});
12 changes: 12 additions & 0 deletions packages/platform-objects/src/system/sys-migration.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading