spec(system): DataMigrationFlagSchema gains columns_moved_at, the nullable datetime attesting that a migration's column move ran on this deployment - #17068
Conversation
…oved_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
…ns_moved_at and declare the widening Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…ed_at Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…gration-flag-column-move-attest
…gration-flag-column-move-attest
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 769c3e36c65a63d76c847cce2c1fc4dfd17c8cf9 && git checkout 769c3e36c65a63d76c847cce2c1fc4dfd17c8cf9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a016f08b8a321dc39f34a384ef3bff0e7b5c1289 7d2792a79db3a0a7fb1ff55e61ac35b6ef87f653 && git checkout -B drift-repro a016f08b8a321dc39f34a384ef3bff0e7b5c1289 && git merge --no-ff 7d2792a79db3a0a7fb1ff55e61ac35b6ef87f653
node scripts/docs-audit/affected-docs.mjs --json a016f08b8a321dc39f34a384ef3bff0e7b5c1289
|
|
Director seat adoption record — summon #20, Contract review (
|
| Ruling / constraint | Reading on this head |
|---|---|
#15989 ruling 5556979386 Q1 — mechanism A, field on a separate domain:spec card with its own clause-② review; "do not smuggle into a driver card" |
This PR is that card. One member (packages/spec/src/system/migration.zod.ts:276-277) + one column (packages/platform-objects/src/system/sys-migration.object.ts:138-148). packages/drivers/**: 0 files. |
| Card constraint 1 — absence is the contract (optional + nullable, no default) | z.string().datetime().nullable().optional(), no .default(). Pinned: data-migration-flag-column-move.pin.test.ts:78-84 asserts 'columns_moved_at' in parsed === false after parse; :86-90 null survives; :191-196 type-level assignability of both spellings. |
Constraint 2 — ⛔ do not touch isDataMigrationFlagVerified; pin its verdict is unchanged for a row omitting the field |
Arbiter body identical base→head (migration.zod.ts:286-289, zero diff lines). Pin :135-158 asserts absent/null/set verdicts equal to each other AND to the pre-member expectation for all four (verified_at, blocking) cases, for both the arbiter and authorisesIrreversibleAction; :172-182 source limb: body names verified_at, blocking, never columns_moved_at; :166-170 a moved column alone authorises nothing. |
| Constraint 3 — writes nothing, no engine read | Verified: no producer, no consumer in the diff (see Derived (b)). Pin file says so explicitly (:22-25). |
Constraint 4 — Clause-② expect yes, ≥ minor, changeset says what it attests |
Re-derived yes; minor on both packages; body states the attestation (see Semver). |
| Constraint 5 — naming argued, null must read as an expected state | columns_moved_at takes the exact SUBJECT_PASTPARTICIPLE_at form of the sibling deviation_observed_at; describe/help text names null as "an expected steady state rather than an error". Triage's added criterion (same describe() style as verified_at/applied_at/deviation_observed_at) met. |
Boundaries — no member changes / re-ordering; no docs/adr/**, content/docs/releases/** |
+344/−0: no existing member moved; member appended after deviation_detail; column inserted before created_at, order pinned (sys-migration.column-move.pin.test.ts:71-87). Governed scan: none. |
| #16625 ruling A — carrier only with a reviewable increment, both carriers | needs:contract-review on PR and card; Clause-②: yes at line start on PR body and in claim 5595422554. This seat ran PM_SWEEP_REPO=objectstack-ai/objectstack node scripts/pm/check-clause2-carriers.mjs --pair 17068 → exit 0, "both carriers agree". |
pm:blocking |
Unblocks #15989 arm-1 (driver) work — the writer and reader of this member. |
Derived judgments
(a) Declaration vs ADR-0104 semantics. docs/adr/0104-field-runtime-value-shape-contract.md on origin/main (2026-09-05 addendum): the attestation key is the sys_migration row adr-0104-file-references (L1125-1135), written by os migrate files-to-references --apply → runFilesToReferencesMigration → recordDataMigrationRun, or by attestFreshDatastore at birth; step 3b (L1157-1160) is "unquote every cell, retype the column to the string column"; population 2 (L1245-1255) requires the step's completion be "one answer, pinned, and it must fail toward the JSON arm". The describe — "retypes the migrated columns and rewrites the values they hold into the new encoding … Null/absent … is what a consumer that cannot read this field must assume" — matches 3b and the fail-toward-JSON requirement exactly, and the nullable/optional datetime is the ruling's "failure mode is absence". Shape identical to the three sibling attestations (:263, :265, :272).
(b) Producer / consumer — declared = enforced. In the diff: none exists, by the card's own constraint 3. Outside the diff, on this head: writers recordDataMigrationRun (packages/platform-objects/src/system/migration-flag.ts:173-206, row built from a fixed literal — no columns_moved_at), attestFreshDatastore (:310), the engine admit path (packages/objectql/src/engine.ts:8136-8145, deviation fields only); reader readDataMigrationFlag (migration-flag.ts:62-96) projects an explicit column list and therefore drops columns_moved_at — on this head no DataMigrationFlag value produced by the repo's own row reader can carry the member; the engine (engine.ts:7917) reads through that reader into the arbiter only. All within scope; recorded as F1/F2 because they are the exact seams the driver card must open.
(c) Stored-row impact. Additive nullable column; syncSchema is additive and never retypes (ADR L1281-1285) → created on next sync, no data rewrite, no conversion. ADR-0087: not owed — nothing previously accepted is refused; check-adr-0087-registration.mjs fires only on a declared-breaking changeset. Liveness ledger: not owed — scope is BUILTIN_METADATA_TYPE_SCHEMAS (packages/spec/liveness/README.md:27-30); no system/migration ledger file among the 38; the schema's siblings appear in none; CI Spec property liveness green. Baselines (regenerated, not hand-edited): authorable-surface/system.json +1 at the alphabetical slot the generator emits; content/docs/references/system/migration.mdx +1 row in schema order; 4× *.objects.generated.ts (+4, identical English text, the documented merge behaviour) and 3× *.source-hashes.generated.ts (+2). Correctly unmoved: api-surface/system.json, export-origins/system.json, declaration-map/system.json, json-schema.manifest/system.json — all schema-level rosters carrying no members; authorable-surface.base.json is the pinned deletion anchor and must not move; packages/spec/json-schema/ is gitignored build output (.gitignore:63). Coverage instrument: every tracked file on origin/main naming the sibling deviation_detail that is a generated artifact is in the diff; the untouched ones (migration-flag.ts, engine.ts, seed-tenancy-backfill.ts, migrate-sys-notification-to-event.ts, tests, CHANGELOG.mds) enumerate no full column set or are release-owned.
(d) New exports / error codes / other keys. Exports: none (api-surface records existence only; nothing exported added). Error codes: none. Other payload keys: the paired column sys_migration.columns_moved_at (Field.datetime, readonly: true, resolved required: false, pinned sys-migration.column-move.pin.test.ts:36-57).
Semver / changeset
- Clause-②: yes at the mechanical floor — a new key on the published
DataMigrationFlagSchemaand onDataMigrationFlag = z.input<…>, shipped indist,json-schema, andsrc/**/*.zod.tsperpackages/spec/package.jsonfiles[]. .changeset/data-migration-flag-columns-moved-at.md:@objectstack/spec: minor,@objectstack/platform-objects: minor— both in thefixedgroup; level axis (check-changeset-no-major.mjsheader, [finding] No gate answers whether a changeset's LEVEL fits the surface — Check Changeset is green on patch and on minor for the same diff #16055/Check Changesetconcludessuccesswhen it measured nothing, and noeditedtrigger ever re-reads aClause-②:line added after the last push — a correct seat action leaves the level axis unjudged behind a green check #16776) satisfied: a package whose published source moves is graded ≥minor. Body states what the member attests, that absence is the contract, and that the arbiter is untouched. No BREAKING banner — correct: nothing previously accepted is refused. CICheck Changesetgreen.- The corrected PR body's backticked
`Part of #15989`is inert:stripMarkdownCodestrips inline spans beforepartOfTargets;deliveryEvidencesees onlyFixes #16185.
Boundary flags
- Governed surfaces (
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md—scripts/pm/check-governed-merges.mjs:793-799): none touched; ADR-0104 not edited.content/docs/releases/**,packages/drivers/**: untouched. - Cross-PR: parked fix(spec,cli): walk a slotted page's
slotsand a tabs panel'sitems[].children, and give dashboard global filters a bundle group #16883 movesapi-surface/system.json; no contention — this PR does not move it. CI single-writer-path gate green. - Main drift: origin/main is 4 commits past the PR base; none touches a PR path or a generator input; PR
mergeable_state: clean. - Downstream owed by the driver card (driver-sql: store the file family (
file/image/avatar/video/audio) as the baresys_fileid in a string column — dropFILE_REFERENCE_TYPESfromJSON_COLUMN_TYPES, per-deployment switch on theadr-0104-file-referencesflag (ruling on #15041, step 2) #15989 arm-1), not this PR: the step-3/4 writer; thereadDataMigrationFlagprojection; the driver predicate reading the member in addition to the arbiter; and — governed — a docs-only card to carry the chosen name into ADR-0104's step-4 sketch (L1162) and its "the fact it attests widens to include the column" line (L1296-1297).
Findings
- F1 (non-blocking) —
packages/platform-objects/src/system/migration-flag.ts:62-96:readDataMigrationFlagprojects a fixed column list; the declared member is dropped on read. In scope per constraint 3 and explicitly un-pinned (data-migration-flag-column-move.pin.test.ts:22-25), but the driver card must extend this projection or the member never reaches its predicate. No change requested here. - F2 (non-blocking) —
migration-flag.ts:173-206:recordDataMigrationRunrebuilds the row from a literal and itsupdatepath sets only listed keys, so a re-record after step 4 neither sets nor clearscolumns_moved_at; a later failing run nullsverified_atbut leaves the column attested — physically correct, and the ruling's "verified AND non-null" composition still fails closed. The driver card should pin this explicitly. Informational. - F3 (non-blocking) — ADR-0104 on origin/main still sketches step 4 as
{ id, verified_at, blocking: 0 }(L1162) and names no attestation member. Governed, so a docs-only successor; not filed by the dev's out-of-scope list. Note for the PM. - F4 (non-blocking, cosmetic) — the describe/help text's "Null … still store the legacy encoding" is meaningful only for a migration that has a column move; the same sentence is emitted into every
sys_migrationrow's help via the i18n bundles (en.objects.generated.ts+4). "for this migration" scopes it; acceptable. - F5 (non-blocking, cosmetic) — the PR body now ends with two
_Generated by …_footers (session-URL form, then the bare platform form), contradicting the dev report's "exactly 1 footer" read-back — the seat's later body rewrite appears to have appended the second. Not a contract matter.
Dev report open_questions: Q1 (C1 exit 4 on Part of #15989) → A is right and is already moot: the seat rewrote the line, this seat re-ran --pair 17068 → exit 0; hanging a carrier on #15989 would assert a review that does not exist there. Q2 (footer form) → A; see F5; not a contract concern. deviations: attribution trailers — outside contract scope; changeset measured on files[] — confirmed against packages/spec/package.json; generated files regenerated — shapes consistent with their generators; origin/main drift — re-measured, none on PR paths.
CI at read time
44 check runs on 7d2792a7 → 34 latest-per-name: 30 success, 4 skipped, 0 failure, 0 in-progress. Skipped: Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) — all conditional/opt-in. Green and load-bearing here: Check Changeset, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Part-of PR must not also close its card, both "No other open PR may claim…" gates, Type Check ×5, Test Core ×7, Temporal Conformance (live PG + MySQL), Build Core, Build Docs, Dogfood ×5. Computed on merge 769c3e36 (head into a016f08b).
Implemented-by: branch claude/issue-16185-migration-flag-column-move-attest
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)
{"pr":17068,"head":"7d2792a79db3a0a7fb1ff55e61ac35b6ef87f653","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"34 latest-per-name: 30 success, 4 skipped (conditional/opt-in), 0 failed, 0 pending"}
Generated by Claude Code
Fixes #16185
Parent card: #15989 — the ruling that created this one. ⛔ NOT delivered here and deliberately not a
Part ofrelation: that keyword means this PR delivers part of the named card, and this PR delivers none of #15989 (nothing underpackages/drivers/**changes; #15989's arm-1 work stays blocked on this landing). The earlierPart of #15989line was written on the dispatching seat's instruction and was factually wrong; corrected by that seat, and the correction is recorded on #16185.Clause-②: yes
DataMigrationFlagSchemagains one member —columns_moved_at, a nullable optional datetime — and thesys_migrationplatform object gains the matching column. That is the whole behavioural change. Nothing writes the member, nothing reads it, and no other member of the schema is changed, re-typed or re-ordered.Mechanism A was ruled on #15989 (comment 5556979386) and is not re-opened here:
Naming: why
columns_moved_atThe card left the name to this seat and set one criterion — it has to read correctly, as a normal expected state rather than an error, on a row where it is null on a deployment that has run the backfill but not the column move. Triage added a second: it has to land inside the naming and
describe()style of the three members it joins.Both were checked against the members as they stand on this base, not from memory:
verified_atz.string().datetime().nullable().optional()_atapplied_atz.string().datetime().nullable().optional()_atdeviation_observed_atz.string().datetime().nullable().optional()_atcolumns_moved_at(new)z.string().datetime().nullable().optional()_atcolumns_moved_atis the fourth of that set by shape, and takes the third one's exact name form — the two-partSUBJECT_PASTPARTICIPLE_atspelling that already exists in this object for the member whose subject is not the migration itself. Read as null it says "the columns have not been moved here", which is a statement about the world, not a failure: it is the true and expected reading on every deployment that has applied and verified the backfill and not yet run the move.Two alternatives were considered and rejected on this tree:
columns_migrated_at— collides semantically withapplied_at, whose owndescribe()on this base is "When the backfill last ran in apply mode (writes enabled)". The single fact the new member exists to separate is backfill ran from columns moved; a name built on "migrated" re-merges the two words the member is there to pull apart.moved_at— in style, but the subject is load-bearing.verified_atandapplied_attake the migration as their implicit subject; the thing moved here is the columns, not the migration, which is why the sibling with a different subject (deviation_observed_at) also spells its subject out.The word "moved" is the ruling's and the card's own vocabulary for this step ("my media columns have been moved", "the column move and the driver's arm flip must be one act"). Because "moved" could be misread as relocated or renamed, the
describe()says what the step actually does — retypes the columns and rewrites the values they hold into the new encoding — rather than leaning on the verb.Absence is the contract, and it is pinned as one
The card's first binding constraint, verbatim: "Optional + nullable is therefore not a style choice — it is the contract."
packages/spec/src/system/data-migration-flag-column-move.pin.test.tsholds it:'columns_moved_at' in parsedisfalseafter the parse — no default of any kind is injected, including anullone;nullparses green and survives asnull;.strict(), so the instrument is that the value survives the parse, with an undeclaredcolumns_moved_at_typosibling asserted as the control that shows the same parse DOES strip a stray;'','yes','2026-09-09') is rejected, so nothing that is not a timestamp can pose as this evidence;DataMigrationFlag(z.inputof the schema) accepts both an object that omits the member and one that sayscolumns_moved_at: null. Those two declarations are compiled — measured withtsc --listFiles, the pin file is absent from the package's plaintsc --noEmitprogram and present in thetsconfig.test.jsonprogram thatpnpm --filter @objectstack/spec typecheckruns throughcheck:test-typecheck, so a required-or-non-nullable member would reddentypecheck, not sit as dead text.The ONE arbiter is unchanged — the pin the card requires
isDataMigrationFlagVerifiedhas zero diff lines in this PR; both changed source files are pure insertions (git diff --stat: 14 insertions, 0 deletions across the two of them). The card asks for more than that, verbatim: "Pin that its return value is unchanged for a row that omits the new field."A plain truth table would not carry it. A table over
(verified_at, blocking)re-derives today's implementation and would stay green if a later edit made the arbiter ALSO requirecolumns_moved_at, because every row would simply be re-derived under the new clause. So each of the four cases is evaluated three times over the same(verified_at, blocking)pair — member absent, membernull, member set — and the three verdicts are asserted equal to each other AND to the verdict recorded for the pre-member row. A clause reading the new member breaks that equality whichever way it decides.authorisesIrreversibleActionis pinned the same way because it composes the arbiter, and a source limb asserts the arbiter's body namesverified_atandblockingand does not namecolumns_moved_atat all.The controls are there so the negatives are not vacuous: the verified row answers
trueto both predicates, and a row carryingcolumns_moved_atbut noverified_atanswersfalse— a moved column on its own authorises nothing.The declared pair
sys-migration.object.tsstates that the row contract "lives in@objectstack/spec/system(DataMigrationFlagSchema)". That sentence was prose only; nothing held the two halves together.packages/platform-objects/src/system/sys-migration.column-move.pin.test.tsholds them together for this member: the column is adatetime, isreadonly, and isrequired: false— measured,ObjectSchema.createresolves an undeclaredrequiredtofalserather than leaving it absent, and the pin asserts the resolved value with the three sibling attestation columns as the same-shape control andid/last_run_atas therequired: truecontrol. A required column would make "backfilled here, columns not moved" unrepresentable in the ledger, which is the one state mechanism A exists to encode. The pin also asserts the column ORDER of the whole ledger, so a future re-order shows up as a failing test rather than as a wall of false delta in the clause-② instrument.⛔ No read of the column is pinned. This card adds the declaration and the column and stops; the writer and the reader are the blocked driver card's.
Generated files, and one PM assumption confirmed
The PM's reading was that adding a schema MEMBER adds no EXPORT, so
api-surface/system.jsonandexport-origins/system.jsonshould not move whileauthorable-surface/system.jsonshould. Measured: exactly that.packages/spec/authorable-surface/system.json— one line,system/DataMigrationFlag:columns_moved_at.pnpm --filter @objectstack/spec check:authorable-surfacegreen.pnpm --filter @objectstack/spec check:api-surfaceandcheck:export-originsboth green with no baseline movement, so nothing exported changed and there is no contention with the parked PR fix(spec,cli): walk a slotted page'sslotsand a tabs panel'sitems[].children, and give dashboard global filters a bundle group #16883.content/docs/references/system/migration.mdx— one generated row,string | null,optional. Regenerated withgen:schema && gen:docson a committed (non-merge) tree;check:docsgreen afterwards.packages/platform-objects/src/apps/translations/*bundle pairs —pnpm check:i18nreportedplatform-objects DRIFTED (7)before regeneration andin sync (11 bundle(s))afternode scripts/check-i18n-bundles.mjs --write. The three non-default locales receive the English source text, which is the tool's documented merge behaviour and matches every othersys_migrationfield entry already in those bundles.Changeset: measured on both halves, not inferred from the path
skip-changesetwas tested against what each package actually SHIPS (itsfiles[]), after building, withdeviation_detail— the member the previous widening of this same schema added — as the positive control:columns_moved_atdeviation_detailpackages/spec/distpackages/spec/json-schemapackages/spec/src/**/*.zod.ts(shipped verbatim infiles[])packages/spec/api-surfacepackages/platform-objects/distBoth packages ship it, so this is emphatically not a
skip-changesetdiff. Thedistgap onplatform-objects(8 vs 12) is the four translation-bundle files, and it closed once the bundles were regenerated. The changeset isminoron both packages and states what the member ATTESTS — that a migration's column move ran on this deployment, a factapplied_atandverified_atbetween them cannot express — rather than that a field was added.Verification
Gate list derived on this tree, not inherited:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, whose change set comes from git with three-dot semantics against the merge base. Everything below was run on the final head7d2792a7, afterorigin/mainwas merged, and every exit code was landed to disk before being read (cmd > log 2>&1; ex=$?— a pipe rewrites the producer's status).dispatch-gates.mjs --ranreports102 derived famil(ies) accounted for — 102 run, 0 NOT-MEASURED, 0 UNRUN.check:authorable-surfacegreen with the one added line, andcheck:api-surface+check:export-originsgreen with no baseline movement — so nothing exported changed, and there is no contention with the parked PR fix(spec,cli): walk a slotted page'sslotsand a tabs panel'sitems[].children, and give dashboard global filters a bundle group #16883 overapi-surface/system.json.check:dual-build-cjs-loadsneeded a fullpnpm buildfirst; it exits 3 (PREREQUISITE NOT MET, not a pass and not a red) on an unbuilt tree. The workspace was built and it is green: 104 require entry points across 67 packages load, 620 emitted CJS files parse.pnpm --filter @objectstack/spec test— 468 files / 13118 tests passed.test:repo— 28 files / 407 tests passed.pnpm --filter @objectstack/platform-objects test— 38 files / 550 tests passed.pnpm --filter @objectstack/spec --filter @objectstack/platform-objects typecheck— green, including both packages'check:test-typechecklegs, which are what compile the two new pin files.check:docs(the generatedcontent/docs/references/system/migration.mdxhad to be regenerated) andcheck-plugin-teardown-shape.mjs --self-test(red only because its fixture is pinned to a commit this shallow worktree could not reach — green after fetching that object; never a verdict on this diff).scripts/pm/os-verify-lock.shon a stable slot, with verdicts read from the wrapper's ownVERDICT command-exitline. One acquisition timed out at exit 99 (queue-timeout, NOT MEASURED) and was resumed on the same slot rather than re-queued.origin/mainmoved again after this run (a016f08b); the only file it changed that any derived family reads isscripts/engine-double-contract.pinned.json, a roster this diff does not touch — no fake engine is added here. CI decides on the merged head.验收备注
No out-of-scope findings were filed. Two things noted and deliberately not filed:
sys_migrationhas no schema-to-object parity gate — the "declared pair" is prose plus, as of this PR, a pin for one member. Generalising it is a new instrument and not this card's; noted, not filed. Successor: none identified.sys_migrationfield block inzh-CN/ja-JP/es-ESis untranslated English for every member added sincecreated_at, so the new entry joins an existing pattern rather than starting one. Not a defect in the sense of the three filing classes; noted, not filed. Successor: whoever owns the platform-objects translation pass.Generated by Claude Code
Generated by Claude Code