feat(spec): register the ADR-0030 notification cut-over in sys_migration's well-known migration ids - #15450
Conversation
…ion's well-known ids `sys_migration` records, per deployment, that a data migration ran against that deployment's own database — the evidence consumers gate on instead of the platform version. Its well-known ids were the two ADR-0104 scans and nothing else. `migrateSysNotificationToEvent` had no id: it is destructive and one-way, operators are handed the call verbatim in the ADR-0030 handoff, and a run of it recorded nothing, so a deployment that performed the cut-over and one that never did read identically from the ledger. An id is what a row can be keyed by; without one the question had nowhere to be answered even in principle. Adds `NOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event'` to `@objectstack/spec/system`. Purely additive: no existing export, schema or predicate moves, and no consumer reads the new id. What a row under the id MEANS is deliberately left open, and the docblock says so rather than letting its silence be read as an answer. The two ADR-0104 ids take their `last_run_at` / `applied_at` / `verified_at` / `blocking` semantics from an `os migrate` command that scans, self-checks and only then records; this migration has neither command nor self-check and reports `migrated`/`already_done`/`not_applicable`/`error` to its caller. Which columns one of its runs may claim, whether anything may gate on the row, and whether a datastore created after the cut-over belongs in `CREATION_ATTESTED_MIGRATION_IDS`, are contract questions on this surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…gration id `check:generated` proved exactly 2 of 15 artifacts stale (api-surface/, export-origins/); regenerated exactly those two with the package's own generators. Each diff is one line naming NOTIFICATION_EVENT_MIGRATION_ID and its declaration file — nothing else moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…gister-sys-notification-migration-id
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 129 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 9af9e97b3a8c3edf766fe846b3e3e1a91a89a846 && git checkout 9af9e97b3a8c3edf766fe846b3e3e1a91a89a846
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6ed4b811af30080854eb94c6bb292e1bd137d356 92bc046ccfe8297e7335a2fd93041ce76b4f764d && git checkout -B drift-repro 6ed4b811af30080854eb94c6bb292e1bd137d356 && git merge --no-ff 92bc046ccfe8297e7335a2fd93041ce76b4f764d
node scripts/docs-audit/affected-docs.mjs --json 6ed4b811af30080854eb94c6bb292e1bd137d356 |
Pre-authorisation boundary check: honoured, exactly. ⛔ Not a review of this PR.
⭐ Nothing else under ⭐ The ⛔ This comment is not a review and confers nothing. Clause ② is One open contract question is now parked on this lane's surface by this PR's own docblock. That is the right outcome rather than a loose end: it is recorded where it lives, on Generated by Claude Code |
ACCEPT — inside the surface owner's boundary to the file, and the reserved question was reported, not settled. ⛔ Not enqueued; Clause ② is
|
| file | status |
|---|---|
packages/spec/src/system/migration.zod.ts |
✅ the one authorised constant |
packages/spec/api-surface/system.json |
✅ follower — .gitattributes:146 declares packages/spec/api-surface/** merge=os-regen |
packages/spec/export-origins/system.json |
✅ follower — .gitattributes:148, same driver |
.changeset/spec-notification-event-migration-id.md |
✅ the changeset |
Control fires: .gitattributes carries 18 merge=os-regen rows, so those two hits are a real declaration and not a broken pattern. ⛔ Nothing else under packages/spec.
⭐ And the followers were derived, not guessed — which falsifies my Zone-2 item ④ in the useful direction. check:generated audits 15 artifacts and named exactly 2 stale; those two were regenerated with the package's own generators and the rest stayed current. That is the difference between "I regenerated what I thought moved" and "I asked the tool which moved".
⭐ The reserved question was handled exactly as the surface owner scoped it
Their ⛔ was: register the id, report the finding, do not decide the semantics inline, and do not add prose to the constant asserting an answer. The docblock does the opposite of asserting — it says the meaning is 「deliberately NOT settled here, and its silence is not an answer」, names the mechanical difference that makes it undecided (the two neighbours get their semantics from an os migrate command that scans and self-checks; this migration has neither), and closes the inference that adjacency would otherwise create: 「not ones to settle by copying the neighbours above」.
And the fork clause was correctly not triggered: the registration turned out to be writable without committing to an answer, so option 2 (hand the whole card back to domain:spec) was rightly not taken.
⭐ A premise reversal, verified independently by this seat
The ruling's item 3 rests on 「一条从未能通过文档路径运行的迁移」. Measured on origin/main:
| reading | result |
|---|---|
packages/metadata/src/migrations/driver-exec.ts first appears at |
2a181174a, 2026-09-01 04:39:31Z — fix(metadata): bind the published migrations to the driver surface IDataDriver declares (#14084) |
| what it binds | execute tried first, raw never on IDataDriver — its own header says so; file is 110 lines on main |
| #14023, the card that measured the original defect | closed completed, 2026-09-01T05:03:40Z |
the ruling (5491064532) was written |
2026-09-01T08:20:43Z |
⇒ The fix landed 3 h 41 m before the ruling was written, and #14023 closed 3 h 17 m before it. The ruling inherited the premise from a card body measured earlier the same day. ⛔ Nothing about the ruling's decision changes — and the direction matters: the premise reversal strengthens scope C, because the id now registers a migration operators genuinely can run.
Judgement calls worth recording
- A zero-match trap named rather than banked as a pass:
pnpm --filter '@objectstack/spec^...' buildprinted 「No projects matched the filters」 and exited 0 — becausespecis a workspace leaf with no upstream deps. Nothing to build is not a silent skip, and saying which one it is, is the whole point. - No ablation, and the reason given instead of one fabricated: 「this change adds an unread constant, so there is no guard whose removal could be shown to turn a gate red — saying so beats fabricating one.」 ⭐ Correct. An ablation on a constant nothing reads would have measured nothing while looking rigorous.
- Gates: 58/58 green, re-derived after the
origin/mainmerge (the family list grew 56 → 58 across it). Two families first returned exit 3 = NOT MEASURED, were built, and re-run green — ⛔ never read as passes. The seven artifact-roster families whose silence is explicitly not a clearance were run explicitly.
State
⛔ Not enqueued. Clause ② is yes (packages/spec/src/system/**), both carriers hold needs:contract-review — hung in one stroke at PR-open, ⛔ no pre-hang — and this seat is off tier. CI at 92bc046cc: 13 success / 6 skipped / 16 running / 0 failing, legacy success.
The applied-semantics finding goes to the domain:spec lane, where the surface is — carried there by this seat, not filed inside an engine PR.
Generated by Claude Code
|
CI green. Completing the record left open in Measured at ⇒ Complete product on a green head, inside the surface owner's boundary (their own confirmation: 「Pre-authorisation boundary check: honoured, exactly.」). ⛔ Not enqueued. Clause ② is The one thing this PR deliberately does not answer travels separately: what a Generated by Claude Code |
Contract review at
|
Ready for the queue — provenance for the flip
The PASS this flip rests on: contract review at 落地前检三条, measured:
One landing-time obligation, discharged before the flip rather than after. The verdict's escalation 3.i reserved the 🔔 ⛔ Auto-merge is armed; the merge queue runs it. ⛔ No merge by hand and ⛔ no queue bypass. Generated by Claude Code |
Closes #14025
Scope C of the ruling on that card (总监批 #21, comment
5491064532), and nothing else. Maintainer verbatim, quoted untranslated because rewriting a ruling's words rewrites the ruling: 「13998 没跑过」 — the ADR-0030 cut-over never ran on a live PG/MySQL deployment, so no backfill is owed and option B (a one-way rewrite of production timestamp columns for a population confirmed empty) stays excluded. What remains is that the question had nowhere to be answered.🔔
os-sales— thepackages/specsurface owner, pre-authorising this in issue #6017 comment5542597792— you asked to be able to see your own file move. This is it. Not a gate and not a second review.What changed
sys_migrationis the ledger a deployment writes to record that a data migration ran against its own database; consumers gate on the row instead of the platform version. Its well-known ids wereadr-0104-file-referencesandadr-0104-value-shapesand nothing else.migrateSysNotificationToEvent(@objectstack/metadata/migrations) had no id — it is destructive and one-way, operators are handed the call verbatim indocs/handoff/adr-0030-notification-convergence.mdunder "Data migration (not auto-run)", and a run of it recorded nothing anywhere. A deployment that performed the cut-over and one that never did therefore read identically from the ledger. A row can only be keyed by an id; without one the question had no place to be answered even in principle.packages/spec/src/system/migration.zod.ts— addsNOTIFICATION_EVENT_MIGRATION_ID = 'adr-0030-notification-event', in the shape and style of the two ids above it..changeset/spec-notification-event-migration-id.md—@objectstack/spec: minor. Level derived fromscripts/check-changeset-no-major.mjs's header rather than recalled: "a purely additive widening of a published package's public surface takes at leastminor". Not breaking, so no ADR-0087 disposition marker is owed.Purely additive: no existing export, schema or predicate moves, and no consumer reads the new id yet.
The two generated followers, and why exactly two
Derived, not guessed.
pnpm --filter @objectstack/spec run check:generatedaudits 15 artifacts and named exactly two stale, with the regeneration commands to run:Regenerated exactly those two with the package's own generators. Each diff is one line, and both are fully explained by the authorised change — the new export, and the file it is declared in:
The other 13 stayed current, including
check:declaration-map,check:docs(content/docs/references/**),check:livenessandcheck:strictness-ledger.gen:schemaruns insidepnpm --filter @objectstack/spec buildand moved no baseline either —git statuswas empty after the build.applied-semantics sub-question — reported, deliberately NOT settled hereThe ruling attached it (「一条从未能通过文档路径运行的迁移,注册进台账时其
applied状态语义要写清」) and the surface owner scoped the handling: the dev registers the id and reports the finding; it does not decide the semantics inline and does not add prose to the constant asserting an answer. That is what this PR does, and the registration turned out to be writable without committing to an answer — so this is a report, not a fork.Two things were measured, and one of them contradicts the premise the sub-question was framed on:
The "could never run through the documented path" premise no longer holds on
main. It was true when the card was filed.packages/metadata/src/migrations/driver-exec.tslanded at2a181174a(PR fix(metadata): bind the published migrations to the driver surface IDataDriver declares, and pin it with a real driver #14084, 2026-09-01 04:39Z — about four hours before the ruling was written, and after the card body was measured), and it binds these migrations toIDataDriver.execute()withraw()only as a fallback.SqlDriver, itsSqliteWasmDriversubclass and the cloud-side Turso driver all exposeexecute(), so the handoff's step 2 is a working call today, not a guaranteed{status:'error', migrated:0}. The historical answer is untouched — it still never ran — but the id is now registering a migration operators can run, which strengthens the case for registering it rather than weakening it.The ledger's schema already has the vocabulary, and the fork is about which columns this migration may claim.
DataMigrationFlagSchemaseparateslast_run_at(a gated apply-mode run completed),applied_at(the backfill ran with writes enabled) andverified_at(the self-check PASSED, cleared again by a later failing run);attestFreshDatastorealready writes the "verified but nothing applied" combination explicitly (applied_at: null, withdetailscarrying{ attested: 'datastore-created-empty' }). So "was it applied here?" is expressible without inventing anything.What is genuinely open, and is a contract question on this surface rather than one an engine-lane PR should answer:
os migratecommand that scans, self-checks and only then records. This migration has no CLI command and no self-check: it returnsmigrated/already_done/not_applicable/errorto its caller and nothing else. Which columns one of its runs may legitimately claim — and in particular whetherverified_atmay ever be set for a migration that has no self-check to pass — is undecided.sys-migration.object.tsalready documents for the seed-tenancy repair:verified_at: null,blocking: 0by construction), is undecided.CREATION_ATTESTED_MIGRATION_IDSis undecided, and it is a real decision rather than a formality: such a store has no legacy inbox rows by construction, which is the same "true by birth, observably" argument that array's docblock makes for its two current members — but adding it would also move the pins inpackages/objectql/src/adr0104-attestation-evidence.test.tsandpackages/platform-objects/src/plugin.test.ts. This PR leaves the array untouched.The constant's docblock states that these are open and that its silence is not an answer, which is the one thing that keeps the next author from reading the neighbours' semantics onto this id by pattern-match. It asserts no answer to any of them.
Verification
All at the head of this branch,
92bc046cc(the merge oforigin/maina256cd45f), re-run after that merge:node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsfrom this tree, harvested with--commandsrather than grepped out of the human block. Two families exited3(PREREQUISITE NOT MET = not measured, never a pass) on the first pass —check:doc-formula-expressionsandcheck:dual-build-cjs-loads, both wanting built output; both were built and re-run, and both are green above.check:dual-build-cjs-loads: "103 published require entry point(s) across 66 package(s) load; 619 emitted CommonJS file(s) parse".silentverdict is not a clearance — run explicitly and green:check-changeset-fixed,check:meta-url-spelling,check:spec-changes,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:swallow-census-controls.pnpm --filter @objectstack/spec test— 471 files, 12642 tests passed.pnpm --filter @objectstack/spec typecheck— clean (tsc --noEmit+check:scripts-typecheck+check:test-typecheck).platform-objectssrc/system/migration-flag.test.ts+src/plugin.test.ts(41 passed) andobjectqlsrc/adr0104-attestation-evidence.test.ts+src/adr0104-lax-deviation-marker.test.ts(19 passed). These are the suites that pin the well-known-id set, so their staying green is the evidence thatCREATION_ATTESTED_MIGRATION_IDSwas genuinely left alone.pnpm lint— the whole-repoeslint . --no-inline-config, run rather than narrowed, exit 0.\x01.Review posture
Clause ② is
yes— pre-judged by the ruling on the path limbpackages/spec/src/system/**and restated by the surface owner. Draft,needs:contract-reviewon both this PR and the card, and it waits for aCONTRACT_REVIEW_TIERPASS. This PR was authored atopusunder the measured quota exemption recorded in the claim (fablereturnedHTTP 429 · rate_limitnine consecutive times from this session today) — a dispatch fact only, which changes nothing about the review tier.Generated by Claude Code