Skip to content

Commit 23aa83c

Browse files
os-billclaude
andauthored
spec(system): DataMigrationFlagSchema gains columns_moved_at, the nullable datetime attesting that a migration's column move ran on this deployment (#17068)
* feat(spec, platform-objects): DataMigrationFlagSchema gains columns_moved_at, the nullable datetime attesting the column move Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * chore(i18n, changeset): regenerate platform-objects bundles for columns_moved_at and declare the widening Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH * docs(spec): regenerate the system/migration reference for columns_moved_at Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfb42c5 commit 23aa83c

14 files changed

Lines changed: 344 additions & 0 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
---
5+
6+
`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.
7+
8+
**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.
9+
10+
**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.
11+
12+
**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.
13+
14+
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.

content/docs/references/system/migration.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ Deployment-level record that a data migration ran here and its self-check passed
378378
| **details** | `string` | optional | JSON-encoded counts from the last run, for diagnostics |
379379
| **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 |
380380
| **deviation_detail** | `string \| null` | optional | JSON-encoded first counterexample behind deviation_observed_at (object, field, type, parse issue), for diagnostics |
381+
| **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 |
381382

382383

383384
---

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3569,6 +3569,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
35693569
label: "Deviation Detail (JSON)",
35703570
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."
35713571
},
3572+
columns_moved_at: {
3573+
label: "Columns Moved At",
3574+
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."
3575+
},
35723576
created_at: {
35733577
label: "Created At"
35743578
},

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3569,6 +3569,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
35693569
label: "Deviation Detail (JSON)",
35703570
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."
35713571
},
3572+
columns_moved_at: {
3573+
label: "Columns Moved At",
3574+
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."
3575+
},
35723576
created_at: {
35733577
label: "Created At"
35743578
},

packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
261261
"objects.sys_migration.fields.applied_at.label": "e95c287d92cd1215",
262262
"objects.sys_migration.fields.blocking.help": "e807388f81f23dda",
263263
"objects.sys_migration.fields.blocking.label": "fde63021cfe650a1",
264+
"objects.sys_migration.fields.columns_moved_at.help": "eaa31d8860c680ec",
265+
"objects.sys_migration.fields.columns_moved_at.label": "9f0cfcef656a7ec8",
264266
"objects.sys_migration.fields.created_at.label": "1f02d416befb595b",
265267
"objects.sys_migration.fields.details.help": "315f8003d77cf2b9",
266268
"objects.sys_migration.fields.details.label": "974694e5c164374e",

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3569,6 +3569,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
35693569
label: "Deviation Detail (JSON)",
35703570
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."
35713571
},
3572+
columns_moved_at: {
3573+
label: "Columns Moved At",
3574+
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."
3575+
},
35723576
created_at: {
35733577
label: "Created At"
35743578
},

packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
258258
"objects.sys_migration.fields.applied_at.label": "e95c287d92cd1215",
259259
"objects.sys_migration.fields.blocking.help": "e807388f81f23dda",
260260
"objects.sys_migration.fields.blocking.label": "fde63021cfe650a1",
261+
"objects.sys_migration.fields.columns_moved_at.help": "eaa31d8860c680ec",
262+
"objects.sys_migration.fields.columns_moved_at.label": "9f0cfcef656a7ec8",
261263
"objects.sys_migration.fields.created_at.label": "1f02d416befb595b",
262264
"objects.sys_migration.fields.details.help": "315f8003d77cf2b9",
263265
"objects.sys_migration.fields.details.label": "974694e5c164374e",

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3569,6 +3569,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
35693569
label: "Deviation Detail (JSON)",
35703570
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."
35713571
},
3572+
columns_moved_at: {
3573+
label: "Columns Moved At",
3574+
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."
3575+
},
35723576
created_at: {
35733577
label: "创建时间"
35743578
},

packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
214214
"objects.sys_metadata_audit.fields.lock_state.options.nooverlay": "49a44e4a03ff124c",
215215
"objects.sys_metadata_history.fields.id.label": "00b0385c9c152888",
216216
"objects.sys_metadata_history.fields.recorded_by.help": "e293bd6cf3c47060",
217+
"objects.sys_migration.fields.columns_moved_at.help": "eaa31d8860c680ec",
218+
"objects.sys_migration.fields.columns_moved_at.label": "9f0cfcef656a7ec8",
217219
"objects.sys_migration.fields.deviation_detail.help": "681fe28dc0c34386",
218220
"objects.sys_migration.fields.deviation_detail.label": "5bf0f0f59a89e2fb",
219221
"objects.sys_migration.fields.deviation_observed_at.help": "d6720808eb570acd",
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#16185] The `sys_migration` half of the declared pair — `columns_moved_at`.
5+
*
6+
* `sys-migration.object.ts` states that the row contract "lives in
7+
* `@objectstack/spec/system` (`DataMigrationFlagSchema`)", which is why the
8+
* schema member and this column are one card and not two. That sentence is
9+
* prose; nothing in the repo held the two halves together, so a later card
10+
* could widen either one alone and every gate would stay green. This pin holds
11+
* them together for the member this card adds.
12+
*
13+
* It pins the column's SHAPE as well as its presence, because the shape is the
14+
* contract the ruling on #15989 chose: the column must be able to be absent.
15+
* A `required: true` column would make "the backfill ran here but the column
16+
* move did not" — a real, expected deployment state — unrepresentable in the
17+
* ledger, and mechanism A was chosen precisely because that state must encode
18+
* as nothing at all. The three sibling attestation columns are asserted beside
19+
* it as the control that "not required" is this table's shape and not an
20+
* oversight on the new one.
21+
*
22+
* ⛔ Not pinned here: any read of the column. This card adds the declaration
23+
* and the column and stops — the writer and the reader are the blocked driver
24+
* card's, and a pin over behaviour that does not exist yet would be a pin over
25+
* nothing.
26+
*/
27+
28+
import { describe, it, expect } from 'vitest';
29+
import { DataMigrationFlagSchema } from '@objectstack/spec/system';
30+
31+
import { SysMigration } from './sys-migration.object.js';
32+
33+
const FIELDS = SysMigration.fields as Record<string, Record<string, unknown>>;
34+
35+
describe('sys_migration.columns_moved_at (#16185)', () => {
36+
it('is declared as a datetime column', () => {
37+
expect(FIELDS.columns_moved_at).toBeDefined();
38+
expect(FIELDS.columns_moved_at.type).toBe('datetime');
39+
});
40+
41+
it('is readonly — writes flow through the migration command, not the API', () => {
42+
expect(FIELDS.columns_moved_at.readonly).toBe(true);
43+
});
44+
45+
it('is NOT required: "backfilled here, columns not moved" must be representable', () => {
46+
// `ObjectSchema.create` resolves an undeclared `required` to `false`, so
47+
// the assertion is against that resolved value, not against absence.
48+
expect(FIELDS.columns_moved_at.required).toBe(false);
49+
// Control — the same shape on the three siblings it joins, against the two
50+
// columns that ARE required, so the assertion above is not vacuous.
51+
for (const sibling of ['verified_at', 'applied_at', 'deviation_observed_at']) {
52+
expect(FIELDS[sibling]?.required, sibling).toBe(false);
53+
}
54+
for (const mandatory of ['id', 'last_run_at']) {
55+
expect(FIELDS[mandatory]?.required, mandatory).toBe(true);
56+
}
57+
});
58+
59+
it('matches the row contract: the schema accepts the value this column stores', () => {
60+
const row = {
61+
id: 'adr-0104-file-references',
62+
last_run_at: '2026-09-01T00:00:00.000Z',
63+
blocking: 0,
64+
columns_moved_at: '2026-09-09T04:00:00.000Z',
65+
};
66+
const parsed = DataMigrationFlagSchema.safeParse(row);
67+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
68+
expect((parsed.data as Record<string, unknown>).columns_moved_at).toBe(row.columns_moved_at);
69+
});
70+
71+
it('leaves the columns the ledger already had exactly where they were', () => {
72+
// Order matters to a reviewer reading the clause-② instrument, not to the
73+
// runtime: a re-order shows up there as a large false delta.
74+
expect(Object.keys(FIELDS)).toEqual([
75+
'id',
76+
'last_run_at',
77+
'verified_at',
78+
'applied_at',
79+
'blocking',
80+
'advisory',
81+
'details',
82+
'deviation_observed_at',
83+
'deviation_detail',
84+
'columns_moved_at',
85+
'created_at',
86+
'updated_at',
87+
]);
88+
});
89+
});

0 commit comments

Comments
 (0)