From 513dc666756da0388a9184335e4bf606ee1e8b90 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 10:35:56 -0700 Subject: [PATCH 01/23] Feat: Add v17 golden graph-history fixture --- CHANGELOG.md | 4 + docs/BEARING.md | 42 +-- .../v18-v17-golden-graph-fixtures.md | 12 +- .../INFRA_graph-model-migration-tool.md | 3 +- docs/method/backlog/v18.0.0/README.md | 7 +- fixtures/v17/graph-model-golden/README.md | 40 +++ fixtures/v17/graph-model-golden/manifest.json | 53 ++++ .../v17-golden-graph.bundle | Bin 0 -> 1556 bytes .../V17GoldenGraphFixtureRestore.ts | 94 ++++++ .../V17GoldenGraphFixtureManifest.ts | 271 ++++++++++++++++++ ...17GoldenGraphFixtureManifestJsonAdapter.ts | 143 +++++++++ .../v18-v17-golden-graph-fixtures.test.ts | 77 +++++ 12 files changed, 725 insertions(+), 21 deletions(-) create mode 100644 fixtures/v17/graph-model-golden/README.md create mode 100644 fixtures/v17/graph-model-golden/manifest.json create mode 100644 fixtures/v17/graph-model-golden/v17-golden-graph.bundle create mode 100644 scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts create mode 100644 src/domain/migrations/V17GoldenGraphFixtureManifest.ts create mode 100644 src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts create mode 100644 test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 19928063..48431fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- V18 migration evidence now includes a deterministic v17 golden graph-history + fixture bundle, runtime-backed fixture manifest nouns, a manifest JSON + adapter, and a restore validator that checks real `refs/warp/*` writer heads + and patch counts in an isolated repository. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index edf0790a..264fdc9f 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -39,18 +39,18 @@ of handwritten adapter folklore. Current branch state at this boundary: -- Branch: `v18-continuum-slices-41-45` +- Branch: `v18-continuum-slices-46-55` - Base branch: `main` -- Current `origin/main`: `07e16795` -- Latest merged PR: #102, v18 migration dry-run planning substrate +- Current `origin/main`: `b274bbc9` +- Latest merged PR: #103, v18 migration dry-run CLI and genesis equivalence + evidence - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0193-v18-replan-with-migration-evidence` -- Current work: PR D, v18 slices 41 through 45, is complete on this branch - and now includes a drift-check pivot that inserts v17 golden graph-history - fixtures before write-capable migration work. + `0199-v18-v17-golden-graph-fixtures` +- Current work: PR E has started. Slice 46 is complete on this branch, and + slices 47 through 55 are the current drift-check batch. - Cleanup checkpoint: `main` has been fast-forwarded to `origin/main` after - PR #102 merged; this branch starts from that merge commit. + PR #103 merged; this branch starts from that merge commit. The current v18 graph-model posture is: @@ -104,6 +104,9 @@ The current v18 graph-model posture is: - A v17 golden graph-history fixture design now precedes real source inventory collection so migration work can prove against restored persisted Git data, not only compact in-memory proof cases. +- A first v17 golden graph-history fixture bundle and manifest now restore + real `refs/warp/*` writer refs into an isolated repository and validate + writer heads, patch counts, and visible fact-family coverage. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -220,6 +223,12 @@ CLI coverage, and equivalence proof fixtures, then created design docs for slices 47 through 51 and inserted the v17 golden graph-history fixture as the new slice 46. +Slice 46 is complete on this branch. A deterministic v17 golden graph-history +fixture now exists as a Git bundle plus manifest. The restore helper initializes +an explicit target repository, fetches the fixture refs, verifies writer heads +and patch counts, and keeps Docker optional instead of making it the fixture +artifact of record. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -237,8 +246,8 @@ new slice 46. - Genesis equivalence is credible as a domain vocabulary and compact fixture proof, not yet as a real scratch-history replay gate. - Compact equivalence fixtures are not enough to validate source inventory - over real v17 persisted Git history. A golden fixture corpus must restore a - v17 graph object/ref layout before wet-run migration paths are trusted. + over real v17 persisted Git history. The first golden fixture now restores a + v17 graph object/ref layout, but source inventory does not consume it yet. - The next write-capable migration work must go through real source inventory, lowering, scratch writes, equivalence gates, and finalization safety. Live ref promotion is still out of bounds. @@ -251,13 +260,12 @@ proven equivalent before finalization." Suggested implementation batches: -- PR D, slices 41 through 45: dry-run CLI, equivalence nouns, fixtures, - divergence reporter, evidence-backed replan, and the v17 fixture pivot. -- PR E, slices 46 through 50: v17 golden graph-history fixtures, real source +- PR D, slices 41 through 45: merged in PR #103. +- PR E, slices 46 through 55: v17 golden graph-history fixtures, real source inventory collection, migration operation lowering, scratch migration - writing, and scratch equivalence gating. -- PR F starts with slice 51: finalization safety after restored-fixture - scratch equivalence is proven. + writing, scratch equivalence gating, finalization safety, finalization + implementation, end-to-end command wiring, post-migration runtime + conformance, and content/property closeout audit. ## Invariants @@ -349,7 +357,7 @@ and concrete checks live in `docs/invariants/`. [0192](design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md). - [x] 45. Re-plan with migration evidence in hand: [0193](design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md). -- [ ] 46. Add v17 golden graph-history fixtures: +- [x] 46. Add v17 golden graph-history fixtures: [0199](design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md). - [ ] 47. Add real source inventory collection: [0194](design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md). diff --git a/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md b/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md index c69b811d..0aaaccfa 100644 --- a/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md +++ b/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md @@ -1,11 +1,12 @@ --- cycle: 0199 task_id: V18_v17_golden_graph_fixtures -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 46 promotes_backlog: @@ -123,6 +124,15 @@ git diff --check HEAD - Docker wet-run work is either present as an optional harness or queued with clear acceptance criteria. +## Closeout + +Slice 46 adds `fixtures/v17/graph-model-golden/v17-golden-graph.bundle` plus +a deterministic manifest. The fixture restores real +`refs/warp/v17-golden-graph/writers/*` refs, validates writer heads and patch +counts in an isolated repository, and records node, edge, property, content, +removal, and multi-writer visible fact coverage. Docker remains optional; the +canonical artifact is the Git bundle and manifest pair. + ## SSJS Scorecard - Runtime-backed forms: green when restored facts become explicit fixture or diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index bdb5774a..b60b37e3 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -47,7 +47,8 @@ V18 slices 36 through 45 completed the non-destructive foundation: Remaining migration-tool work is intentionally ordered as: -- slice 46: create v17 golden graph-history fixtures and restore checks; +- slice 46: create v17 golden graph-history fixtures and restore checks + (complete); - slice 47: collect real source inventory from restored history; - slice 48: lower dry-run planned operations; - slice 49: write scratch migrated history; diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 314508cf..7cb84953 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -74,8 +74,8 @@ graph model. Change the envelope only if replay honesty requires it. ## Current Evidence -After v18 slices 41 through 45, the migration path is intentionally still -non-destructive: +After v18 slice 46, the migration path is intentionally still +non-destructive but now has persisted-history evidence: - dry-run request JSON can be decoded at the infrastructure boundary; - the dry-run CLI can emit deterministic manifest output and refuses @@ -86,5 +86,8 @@ non-destructive: divergent-property cases; - v17 golden graph-history fixtures now precede write-capable migration work, because compact fixtures do not prove the persisted Git object/ref layout; +- the first v17 golden fixture restores real `refs/warp/*` writer refs from a + Git bundle and validates manifest heads, patch counts, and visible fact + families; - real source inventory, operation lowering, scratch writing, scratch equivalence, and finalization safety are planned as slices 47 through 51. diff --git a/fixtures/v17/graph-model-golden/README.md b/fixtures/v17/graph-model-golden/README.md new file mode 100644 index 00000000..8866a61c --- /dev/null +++ b/fixtures/v17/graph-model-golden/README.md @@ -0,0 +1,40 @@ +# V17 Golden Graph-Model Fixture + +This fixture is the first persisted-history witness for the v18 graph-model +migration path. The canonical artifact is `v17-golden-graph.bundle`; the +manifest is the operator-readable contract for the refs, heads, chain lengths, +and visible graph facts that the bundle must restore. + +The fixture intentionally uses real `refs/warp//writers/` refs +and patch-shaped commits with trailer-coded v17 patch metadata. It is not a +JSON-only mock and it is not a raw `.git` directory snapshot. + +## Restore + +Use the slice 46 restore helper from tests or scripts: + +```text +restoreV17GoldenGraphFixture({ + manifestPath: "fixtures/v17/graph-model-golden/manifest.json", + targetDirectory: "" +}) +``` + +The helper initializes the target repository, fetches the bundle refs, and +verifies the expected writer heads and patch counts. + +## Regeneration + +Regeneration must preserve deterministic commit inputs: + +- author and committer name: `Git Warp Fixture`; +- author and committer email: `fixture@git-warp.local`; +- author and committer date: `2026-01-01T00:00:00Z`; +- graph id: `v17-golden-graph`; +- refs: + - `refs/warp/v17-golden-graph/writers/alice`; + - `refs/warp/v17-golden-graph/writers/bob`. + +After regeneration, update `manifest.json` with the new writer heads and keep +the visible fact coverage over node, edge, property, content, removal, and +multi-writer cases. diff --git a/fixtures/v17/graph-model-golden/manifest.json b/fixtures/v17/graph-model-golden/manifest.json new file mode 100644 index 00000000..bdc0c798 --- /dev/null +++ b/fixtures/v17/graph-model-golden/manifest.json @@ -0,0 +1,53 @@ +{ + "fixtureId": "v17-golden-graph-model-001", + "graphId": "v17-golden-graph", + "sourceVersion": "17.0.1", + "generator": "deterministic git commit-tree fixture", + "bundlePath": "v17-golden-graph.bundle", + "writerChains": [ + { + "writerId": "alice", + "refName": "refs/warp/v17-golden-graph/writers/alice", + "expectedHead": "417fe95095a6feae3042c36505065bbd7b3d2a67", + "patchCount": 3 + }, + { + "writerId": "bob", + "refName": "refs/warp/v17-golden-graph/writers/bob", + "expectedHead": "d7c3a05b3894d5c3c151e03dd972b6bd6c341b0c", + "patchCount": 2 + } + ], + "visibleFacts": [ + { + "kind": "node", + "key": "node:alpha", + "description": "Alice creates the primary node lifecycle subject." + }, + { + "kind": "edge", + "key": "node:alpha->node:beta:relates", + "description": "Alice creates a stable edge identity with label evidence." + }, + { + "kind": "property", + "key": "node:alpha:title", + "description": "Alice and Bob cover legacy node property compatibility." + }, + { + "kind": "content", + "key": "node:alpha:_content", + "description": "Content attachment compatibility uses legacy _content facts." + }, + { + "kind": "removal", + "key": "node:removed", + "description": "Alice covers removed-node visibility and tombstone behavior." + }, + { + "kind": "multi-writer", + "key": "writers:alice+bob", + "description": "Independent writer refs cover non-coordinated history." + } + ] +} diff --git a/fixtures/v17/graph-model-golden/v17-golden-graph.bundle b/fixtures/v17/graph-model-golden/v17-golden-graph.bundle new file mode 100644 index 0000000000000000000000000000000000000000..42ad465d09deb0b5d1d182bcd57c4ac43f7b8cf5 GIT binary patch literal 1556 zcma)&e>~H99LK*KJH@75KaR?;$iuMNcfQ*gIpna}Fh5g9_REi&mSHWY(?cjO38(Ht z?s_QOq%jSc_+eC2H@(UTNiKI`rC5JaXeP|_Eq7+PUT@Vbgr zvm0W=vh48X&3{V_N`5ecU0-ZfKza1YF9tkgo1-0ia;MNFtC`ZN6WrbYRc0cGjaeFy z5Ycyb;_mC-fM)76^_qKZaw+IAc+mY!GSXxCuKL@IoWS8tbFi6-vL{d@z3yDKWx=Rl z@fhEN74Axzl9PkIBzcLN(^D6`o~5Lp>NeEH5_KK~#M49dho5H!JmMM+ht@>)pT9qF zxv1BytltT+$$Y!{D^`xvd02n`lIaDf9=QwS>9~T&5i)NYdddubFG<>RX%>4-XnHYf zKXEQgBYIY+x>EvMEuBsGX#$ZM3l#TxsUl{iREFjtJR_z@J3YF|K?=~IByFYi(#elC zyqTKqC?sUn1m^GAw_|HIq`%FOI?=o%(DlBLY6-&%#T9qC_PEjPPg9}YVTt1nr|qR+ z-mA>cyXBU1IU#Q?Ugp;~mx$Ug)~C_ya1{wT{kw$9u6b2@@SPby*k2aXMnV5VNY=7m zTBX=Q)>lBNPpFpM&fI^m-@LoVXEAjA5Up^oxj;Ud%l+sS*MI2^t}og-z-H4-;Rm+_ z%iwJ_5ovB+Uerxe4ZEzyPE%Nt!^i)^amHUe z$U(mPMp-+}_|9@0s$A@k*O*#SjW}(X?h^oE4?1)jjw$eX0yFK#qqi6$t!S4 z5kv$*h~S5zVrgw1bRQL-b9?Se(6aa9V??_c>ZK$pzMCHT@x2b9OGl%tS3c0yN`_C+ z>OB*z^2d0!w&LmDeu@@fS`elM+s06-l~~yeIItx`_lva?BK8gKtd!KW7Z*1Frnl!{ zyy(D-^&T3)oH@$6@>7D!*Z*JsO@C@zyEU!V+E3w)qcLb$S|G*?M^$5=!+`}9YU|qk zkPiXmUm3h5(+`%5a2EfSp5`@QM;&0g8g*U%d4rV%Z<=pVM+Ot&{th8?H!olTLpzbi zYm=Fj7G~Vl#B{2(E>1dRkaB*j6YCi;mV&HWo#sDr`6qF@4bIAI#T|oD;i)()Ss(_l z!qQ&C0sSsr#@YdW^QGcPS8 { + const manifest = await readManifest(options.manifestPath); + const repositoryPath = resolve(options.targetDirectory); + const bundlePath = resolve(dirname(options.manifestPath), manifest.bundlePath); + + await mkdir(repositoryPath, { recursive: true }); + await runGit(repositoryPath, ['init', '-q']); + for (const chain of manifest.writerChains) { + await runGit(repositoryPath, ['fetch', '-q', bundlePath, `${chain.refName}:${chain.refName}`]); + } + + const restoredRefs = await verifyRestoredRefs(repositoryPath, manifest); + return Object.freeze({ + repositoryPath, + manifest, + restoredRefs, + }); +} + +async function readManifest(path: string): Promise { + const raw = await readFile(path, 'utf8'); + return parseV17GoldenGraphFixtureManifestJson(raw); +} + +async function verifyRestoredRefs( + repositoryPath: string, + manifest: V17GoldenGraphFixtureManifest, +): Promise { + const restoredRefs: V17GoldenGraphFixtureRestoredRef[] = []; + for (const chain of manifest.writerChains) { + const head = await gitText(repositoryPath, ['rev-parse', '--verify', chain.refName]); + if (head !== chain.expectedHead) { + throw new Error( + `Restored ref ${chain.refName} expected ${chain.expectedHead}, got ${head}`, + ); + } + const patchCountText = await gitText(repositoryPath, ['rev-list', '--count', chain.refName]); + const patchCount = Number(patchCountText); + if (patchCount !== chain.patchCount) { + throw new Error( + `Restored ref ${chain.refName} expected ${chain.patchCount} patches, got ${patchCount}`, + ); + } + restoredRefs.push(Object.freeze({ + refName: chain.refName, + head, + patchCount, + })); + } + return Object.freeze(restoredRefs); +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await runGit(cwd, args); + return result.stdout.trim(); +} + +async function runGit( + cwd: string, + args: readonly string[], +): Promise<{ readonly stdout: string; readonly stderr: string }> { + return await execFileAsync('git', args, { cwd }); +} diff --git a/src/domain/migrations/V17GoldenGraphFixtureManifest.ts b/src/domain/migrations/V17GoldenGraphFixtureManifest.ts new file mode 100644 index 00000000..1785d4d8 --- /dev/null +++ b/src/domain/migrations/V17GoldenGraphFixtureManifest.ts @@ -0,0 +1,271 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import WarpError from '../errors/WarpError.ts'; + +const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; +const PATH_SEGMENT_SEPARATOR = '/'; + +export const V17_GOLDEN_NODE_FACT = 'node'; +export const V17_GOLDEN_EDGE_FACT = 'edge'; +export const V17_GOLDEN_PROPERTY_FACT = 'property'; +export const V17_GOLDEN_CONTENT_FACT = 'content'; +export const V17_GOLDEN_REMOVAL_FACT = 'removal'; +export const V17_GOLDEN_MULTI_WRITER_FACT = 'multi-writer'; + +export type V17GoldenGraphFixtureFactKind = + | typeof V17_GOLDEN_NODE_FACT + | typeof V17_GOLDEN_EDGE_FACT + | typeof V17_GOLDEN_PROPERTY_FACT + | typeof V17_GOLDEN_CONTENT_FACT + | typeof V17_GOLDEN_REMOVAL_FACT + | typeof V17_GOLDEN_MULTI_WRITER_FACT; + +export type V17GoldenGraphFixtureWriterChainFields = { + readonly writerId: string; + readonly refName: string; + readonly expectedHead: string; + readonly patchCount: number; +}; + +export type V17GoldenGraphFixtureVisibleFactFields = { + readonly kind: V17GoldenGraphFixtureFactKind; + readonly key: string; + readonly description: string; +}; + +export type V17GoldenGraphFixtureManifestFields = { + readonly fixtureId: string; + readonly graphId: string; + readonly sourceVersion: string; + readonly generator: string; + readonly bundlePath: string; + readonly writerChains: readonly V17GoldenGraphFixtureWriterChain[]; + readonly visibleFacts: readonly V17GoldenGraphFixtureVisibleFact[]; +}; + +/** Converts raw text into a supported v17 golden visible fact kind. */ +export function v17GoldenGraphFixtureFactKindFromString( + value: string, +): V17GoldenGraphFixtureFactKind { + for (const kind of requiredFactKinds()) { + if (value === kind) { + return kind; + } + } + throw new WarpError('visible fact kind is unsupported', 'E_VALIDATION'); +} + +/** Writer-chain expectation recorded by a v17 golden graph-history fixture. */ +export class V17GoldenGraphFixtureWriterChain { + readonly writerId: string; + readonly refName: string; + readonly expectedHead: string; + readonly patchCount: number; + + constructor(fields: V17GoldenGraphFixtureWriterChainFields) { + const checkedFields = requireWriterChainFields(fields); + this.writerId = requireNonEmptyString(checkedFields.writerId, 'writerId'); + this.refName = requireWarpRef(checkedFields.refName); + this.expectedHead = requireOid(checkedFields.expectedHead, 'expectedHead'); + this.patchCount = requirePositiveSafeInteger(checkedFields.patchCount, 'patchCount'); + Object.freeze(this); + } +} + +/** Operator-visible graph fact expectation for a restored v17 fixture. */ +export class V17GoldenGraphFixtureVisibleFact { + readonly kind: V17GoldenGraphFixtureFactKind; + readonly key: string; + readonly description: string; + + constructor(fields: V17GoldenGraphFixtureVisibleFactFields) { + const checkedFields = requireVisibleFactFields(fields); + this.kind = requireFactKind(checkedFields.kind); + this.key = requireNonEmptyString(checkedFields.key, 'key'); + this.description = requireNonEmptyString(checkedFields.description, 'description'); + Object.freeze(this); + } +} + +/** Runtime-backed manifest for a restored v17 graph-history fixture. */ +export default class V17GoldenGraphFixtureManifest { + readonly fixtureId: string; + readonly graphId: string; + readonly sourceVersion: string; + readonly generator: string; + readonly bundlePath: string; + readonly writerChains: readonly V17GoldenGraphFixtureWriterChain[]; + readonly visibleFacts: readonly V17GoldenGraphFixtureVisibleFact[]; + + constructor(fields: V17GoldenGraphFixtureManifestFields) { + const checkedFields = requireManifestFields(fields); + this.fixtureId = requireNonEmptyString(checkedFields.fixtureId, 'fixtureId'); + this.graphId = requireNonEmptyString(checkedFields.graphId, 'graphId'); + this.sourceVersion = requireNonEmptyString(checkedFields.sourceVersion, 'sourceVersion'); + this.generator = requireNonEmptyString(checkedFields.generator, 'generator'); + this.bundlePath = requireRelativePath(checkedFields.bundlePath, 'bundlePath'); + this.writerChains = freezeWriterChains(checkedFields.writerChains); + this.visibleFacts = freezeVisibleFacts(checkedFields.visibleFacts); + Object.freeze(this); + } + + /** Returns true when the fixture declares at least one visible fact kind. */ + hasVisibleFactKind(kind: V17GoldenGraphFixtureFactKind): boolean { + const checkedKind = requireFactKind(kind); + return this.visibleFacts.some((fact) => fact.kind === checkedKind); + } +} + +function requireManifestFields( + fields: V17GoldenGraphFixtureManifestFields | null | undefined, +): V17GoldenGraphFixtureManifestFields { + if (fields === null || fields === undefined) { + throw new WarpError('V17GoldenGraphFixtureManifest fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireWriterChainFields( + fields: V17GoldenGraphFixtureWriterChainFields | null | undefined, +): V17GoldenGraphFixtureWriterChainFields { + if (fields === null || fields === undefined) { + throw new WarpError('V17GoldenGraphFixtureWriterChain fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireVisibleFactFields( + fields: V17GoldenGraphFixtureVisibleFactFields | null | undefined, +): V17GoldenGraphFixtureVisibleFactFields { + if (fields === null || fields === undefined) { + throw new WarpError('V17GoldenGraphFixtureVisibleFact fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireRelativePath(value: string, name: string): string { + const checked = requireNonEmptyString(value, name); + if (checked.startsWith(PATH_SEGMENT_SEPARATOR) || checked.split(PATH_SEGMENT_SEPARATOR).includes('..')) { + throw new WarpError(`${name} must be a relative fixture path`, 'E_VALIDATION'); + } + return checked; +} + +function requireWarpRef(value: string): string { + const checked = requireNonEmptyString(value, 'refName'); + if (!checked.startsWith('refs/warp/')) { + throw new WarpError('refName must be under refs/warp/', 'E_VALIDATION'); + } + return checked; +} + +function requireOid(value: string, name: string): string { + const checked = requireNonEmptyString(value, name); + if (!OID_PATTERN.test(checked)) { + throw new WarpError(`${name} must be a Git object id`, 'E_VALIDATION'); + } + return checked; +} + +function requirePositiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new WarpError(`${name} must be a positive safe integer`, 'E_VALIDATION'); + } + return value; +} + +function requireFactKind(kind: V17GoldenGraphFixtureFactKind): V17GoldenGraphFixtureFactKind { + return v17GoldenGraphFixtureFactKindFromString(kind); +} + +function freezeWriterChains( + writerChains: readonly V17GoldenGraphFixtureWriterChain[], +): readonly V17GoldenGraphFixtureWriterChain[] { + if (!Array.isArray(writerChains)) { + throw new WarpError('writerChains must be an array', 'E_VALIDATION'); + } + const checked = writerChains.map(requireWriterChain); + requireUnique(checked.map((chain) => chain.writerId), 'writerId'); + requireUnique(checked.map((chain) => chain.refName), 'refName'); + return Object.freeze([...checked].sort(compareWriterChains)); +} + +function freezeVisibleFacts( + visibleFacts: readonly V17GoldenGraphFixtureVisibleFact[], +): readonly V17GoldenGraphFixtureVisibleFact[] { + if (!Array.isArray(visibleFacts)) { + throw new WarpError('visibleFacts must be an array', 'E_VALIDATION'); + } + const checked = visibleFacts.map(requireVisibleFact); + requireVisibleFactCoverage(checked); + requireUnique(checked.map((fact) => `${fact.kind}\0${fact.key}`), 'visible fact'); + return Object.freeze([...checked].sort(compareVisibleFacts)); +} + +function requireWriterChain( + chain: V17GoldenGraphFixtureWriterChain, +): V17GoldenGraphFixtureWriterChain { + if (!(chain instanceof V17GoldenGraphFixtureWriterChain)) { + throw new WarpError('writerChains must contain V17GoldenGraphFixtureWriterChain values', 'E_VALIDATION'); + } + return chain; +} + +function requireVisibleFact( + fact: V17GoldenGraphFixtureVisibleFact, +): V17GoldenGraphFixtureVisibleFact { + if (!(fact instanceof V17GoldenGraphFixtureVisibleFact)) { + throw new WarpError('visibleFacts must contain V17GoldenGraphFixtureVisibleFact values', 'E_VALIDATION'); + } + return fact; +} + +function requireUnique(keys: readonly string[], label: string): void { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + throw new WarpError(`V17 golden graph fixture duplicates ${label} ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +function requireVisibleFactCoverage(facts: readonly V17GoldenGraphFixtureVisibleFact[]): void { + const kinds = new Set(facts.map((fact) => fact.kind)); + for (const kind of requiredFactKinds()) { + if (!kinds.has(kind)) { + throw new WarpError(`visibleFacts must include ${kind}`, 'E_VALIDATION'); + } + } +} + +function requiredFactKinds(): readonly V17GoldenGraphFixtureFactKind[] { + return Object.freeze([ + V17_GOLDEN_NODE_FACT, + V17_GOLDEN_EDGE_FACT, + V17_GOLDEN_PROPERTY_FACT, + V17_GOLDEN_CONTENT_FACT, + V17_GOLDEN_REMOVAL_FACT, + V17_GOLDEN_MULTI_WRITER_FACT, + ]); +} + +function compareWriterChains( + left: V17GoldenGraphFixtureWriterChain, + right: V17GoldenGraphFixtureWriterChain, +): number { + return compareStrings(left.refName, right.refName); +} + +function compareVisibleFacts( + left: V17GoldenGraphFixtureVisibleFact, + right: V17GoldenGraphFixtureVisibleFact, +): number { + return compareStrings(`${left.kind}\0${left.key}`, `${right.kind}\0${right.key}`); +} diff --git a/src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts b/src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts new file mode 100644 index 00000000..5b13183c --- /dev/null +++ b/src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts @@ -0,0 +1,143 @@ +import V17GoldenGraphFixtureManifest, { + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, + v17GoldenGraphFixtureFactKindFromString, + type V17GoldenGraphFixtureFactKind, +} from '../../domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import AdapterValidationError from '../../domain/errors/AdapterValidationError.ts'; +import type { JsonObject } from './JsonObject.ts'; + +const MANIFEST_KEYS = Object.freeze([ + 'fixtureId', + 'graphId', + 'sourceVersion', + 'generator', + 'bundlePath', + 'writerChains', + 'visibleFacts', +]); + +/** Parses a v17 golden graph-history fixture manifest from JSON. */ +export function parseV17GoldenGraphFixtureManifestJson( + raw: string, +): V17GoldenGraphFixtureManifest { + return manifestFromJson(parseJson(raw)); +} + +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + throw new AdapterValidationError('V17 golden graph fixture manifest must be valid JSON'); + } +} + +function manifestFromJson(value: unknown): V17GoldenGraphFixtureManifest { + const source = requireJsonObject(value, 'manifest'); + rejectUnknownKeys(source, MANIFEST_KEYS, 'manifest'); + return new V17GoldenGraphFixtureManifest({ + fixtureId: readRequiredString(source, 'manifest.fixtureId', 'fixtureId'), + graphId: readRequiredString(source, 'manifest.graphId', 'graphId'), + sourceVersion: readRequiredString(source, 'manifest.sourceVersion', 'sourceVersion'), + generator: readRequiredString(source, 'manifest.generator', 'generator'), + bundlePath: readRequiredString(source, 'manifest.bundlePath', 'bundlePath'), + writerChains: readWriterChains(source), + visibleFacts: readVisibleFacts(source), + }); +} + +function readWriterChains(source: JsonObject): readonly V17GoldenGraphFixtureWriterChain[] { + return readObjectArray(source, 'writerChains').map((chain, index) => { + const label = `writerChains[${index}]`; + rejectUnknownKeys(chain, ['writerId', 'refName', 'expectedHead', 'patchCount'], label); + return new V17GoldenGraphFixtureWriterChain({ + writerId: readRequiredString(chain, `${label}.writerId`, 'writerId'), + refName: readRequiredString(chain, `${label}.refName`, 'refName'), + expectedHead: readRequiredString(chain, `${label}.expectedHead`, 'expectedHead'), + patchCount: readRequiredNumber(chain, `${label}.patchCount`, 'patchCount'), + }); + }); +} + +function readVisibleFacts(source: JsonObject): readonly V17GoldenGraphFixtureVisibleFact[] { + return readObjectArray(source, 'visibleFacts').map((fact, index) => { + const label = `visibleFacts[${index}]`; + rejectUnknownKeys(fact, ['kind', 'key', 'description'], label); + return new V17GoldenGraphFixtureVisibleFact({ + kind: readFactKind(fact, `${label}.kind`, 'kind'), + key: readRequiredString(fact, `${label}.key`, 'key'), + description: readRequiredString(fact, `${label}.description`, 'description'), + }); + }); +} + +function readObjectArray(source: JsonObject, key: string): readonly JsonObject[] { + const value = readRequiredValue(source, key); + if (!Array.isArray(value)) { + throw new AdapterValidationError(`V17 golden graph fixture manifest field "${key}" must be an array`); + } + const objects: JsonObject[] = []; + value.forEach((entry, index) => { + objects.push(requireJsonObject(entry, `${key}[${index}]`)); + }); + return Object.freeze(objects); +} + +function readRequiredString(source: JsonObject, label: string, key: string): string { + const value = readRequiredValue(source, key); + if (typeof value !== 'string' || value.length === 0) { + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}" must be a non-empty string`, + ); + } + return value; +} + +function readRequiredNumber(source: JsonObject, label: string, key: string): number { + const value = readRequiredValue(source, key); + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}" must be a finite number`, + ); + } + return value; +} + +function readFactKind(source: JsonObject, label: string, key: string): V17GoldenGraphFixtureFactKind { + const value = readRequiredValue(source, key); + if (typeof value === 'string') { + return v17GoldenGraphFixtureFactKindFromString(value); + } + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}" must be a supported fact kind`, + ); +} + +function readRequiredValue(source: JsonObject, key: string): unknown { + const value = source[key]; + if (value === undefined) { + throw new AdapterValidationError(`V17 golden graph fixture manifest field "${key}" is required`); + } + return value; +} + +function requireJsonObject(value: unknown, label: string): JsonObject { + if (!isJsonObject(value)) { + throw new AdapterValidationError(`V17 golden graph fixture manifest field "${label}" must be an object`); + } + return value; +} + +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function rejectUnknownKeys(source: JsonObject, allowed: readonly string[], label: string): void { + for (const key of Object.keys(source)) { + if (!allowed.includes(key)) { + throw new AdapterValidationError( + `V17 golden graph fixture manifest field "${label}.${key}" is not allowed`, + ); + } + } +} diff --git a/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts b/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts new file mode 100644 index 00000000..a1caa69e --- /dev/null +++ b/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts @@ -0,0 +1,77 @@ +import { copyFile, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; + +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; +import { + parseV17GoldenGraphFixtureManifestJson, +} from '../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); + +describe('v18 v17 golden graph-history fixtures', () => { + it('parses a runtime-backed manifest with the required visible fact families', async () => { + const raw = await readFile(FIXTURE_MANIFEST_PATH, 'utf8'); + const manifest = parseV17GoldenGraphFixtureManifestJson(raw); + + expect(manifest.fixtureId).toBe('v17-golden-graph-model-001'); + expect(manifest.graphId).toBe('v17-golden-graph'); + expect(manifest.writerChains.map((chain) => chain.writerId)).toEqual(['alice', 'bob']); + expect(manifest.hasVisibleFactKind('node')).toBe(true); + expect(manifest.hasVisibleFactKind('edge')).toBe(true); + expect(manifest.hasVisibleFactKind('property')).toBe(true); + expect(manifest.hasVisibleFactKind('content')).toBe(true); + expect(manifest.hasVisibleFactKind('removal')).toBe(true); + expect(manifest.hasVisibleFactKind('multi-writer')).toBe(true); + }); + + it('restores the bundle into an isolated repository and verifies writer heads', async () => { + const targetDirectory = await mkdtemp(join(tmpdir(), 'git-warp-v17-golden-')); + + const result = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + + expect(result.repositoryPath).toBe(targetDirectory); + expect(result.restoredRefs).toEqual([ + { + refName: 'refs/warp/v17-golden-graph/writers/alice', + head: '417fe95095a6feae3042c36505065bbd7b3d2a67', + patchCount: 3, + }, + { + refName: 'refs/warp/v17-golden-graph/writers/bob', + head: 'd7c3a05b3894d5c3c151e03dd972b6bd6c341b0c', + patchCount: 2, + }, + ]); + }); + + it('fails closed when a manifest expects the wrong restored head', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v17-golden-bad-')); + const manifestPath = join(directory, 'manifest.json'); + const targetDirectory = join(directory, 'target'); + const raw = await readFile(FIXTURE_MANIFEST_PATH, 'utf8'); + await copyFile( + resolve('fixtures/v17/graph-model-golden/v17-golden-graph.bundle'), + join(directory, 'v17-golden-graph.bundle'), + ); + await writeFile( + manifestPath, + raw.replace( + '417fe95095a6feae3042c36505065bbd7b3d2a67', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + 'utf8', + ); + + await expect(restoreV17GoldenGraphFixture({ + manifestPath, + targetDirectory, + })).rejects.toThrow('expected aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); +}); From 6e822911d6c36d46bf7c2b99ff0f461806f6d605 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 10:39:09 -0700 Subject: [PATCH 02/23] Feat: Collect v18 migration source inventory from refs --- CHANGELOG.md | 4 + docs/BEARING.md | 15 +- .../v18-real-source-inventory-collector.md | 12 +- .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 6 +- ...hModelMigrationSourceInventoryCollector.ts | 203 ++++++++++++++++++ ...h-model-source-inventory-collector.test.ts | 65 ++++++ 7 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts create mode 100644 test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48431fdf..39fa454b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 fixture bundle, runtime-backed fixture manifest nouns, a manifest JSON adapter, and a restore validator that checks real `refs/warp/*` writer heads and patch counts in an isolated repository. +- V18 graph-model migration now includes a read-only source inventory + collector that discovers restored `refs/warp//writers/*` refs, + decodes patch commit trailers, records writer chains and patch descriptors, + and fails closed with structured inventory notices. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 264fdc9f..3511ffac 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -107,6 +107,10 @@ The current v18 graph-model posture is: - A first v17 golden graph-history fixture bundle and manifest now restore real `refs/warp/*` writer refs into an isolated repository and validate writer heads, patch counts, and visible fact-family coverage. +- A read-only restored source inventory collector now discovers real writer + refs, decodes patch commit trailers, records writer chains and patch + descriptors, derives a deterministic source basis, and fails closed with + structured migration notices. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -229,6 +233,12 @@ an explicit target repository, fetches the fixture refs, verifies writer heads and patch counts, and keeps Docker optional instead of making it the fixture artifact of record. +Slice 47 is complete on this branch. The source inventory collector reads +restored writer refs from Git, decodes patch trailers through the adapter +codec boundary, records writer chains and patch descriptors, derives a source +basis from restored heads, and produces fatal inventory notices when source +refs are absent or malformed. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -242,7 +252,8 @@ artifact of record. map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. - The v18 migration tool is dry-run only. It can consume explicit request JSON, - but it does not yet collect real graph history into source inventory. + and restored source inventory, but it does not yet lower operations into a + write-ready form. - Genesis equivalence is credible as a domain vocabulary and compact fixture proof, not yet as a real scratch-history replay gate. - Compact equivalence fixtures are not enough to validate source inventory @@ -359,7 +370,7 @@ and concrete checks live in `docs/invariants/`. [0193](design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md). - [x] 46. Add v17 golden graph-history fixtures: [0199](design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md). -- [ ] 47. Add real source inventory collection: +- [x] 47. Add real source inventory collection: [0194](design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md). - [ ] 48. Add migration operation lowering: [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). diff --git a/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md b/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md index c88edd86..c08566ac 100644 --- a/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md +++ b/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md @@ -1,11 +1,12 @@ --- cycle: 0194 task_id: V18_real_source_inventory_collector -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 47 promotes_backlog: @@ -104,6 +105,15 @@ git diff --check HEAD - The dry-run CLI can invoke collection without adding write mode. - Slice 48 can lower planned operations against collected source evidence. +## Closeout + +Slice 47 adds a read-only collector over restored Git history. It discovers +`refs/warp//writers/*`, decodes patch commit trailers, records writer +chains and per-writer patch descriptors as migration-domain nouns, derives a +deterministic source basis from restored heads, and fails closed with +structured notices when writer refs are absent or patch metadata does not +match the requested graph. + ## SSJS Scorecard - Runtime-backed forms: green when collected facts become migration nouns. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index b60b37e3..40d15b34 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -49,7 +49,7 @@ Remaining migration-tool work is intentionally ordered as: - slice 46: create v17 golden graph-history fixtures and restore checks (complete); -- slice 47: collect real source inventory from restored history; +- slice 47: collect real source inventory from restored history (complete); - slice 48: lower dry-run planned operations; - slice 49: write scratch migrated history; - slice 50: gate scratch output with genesis equivalence; diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 7cb84953..60f54a65 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -89,5 +89,7 @@ non-destructive but now has persisted-history evidence: - the first v17 golden fixture restores real `refs/warp/*` writer refs from a Git bundle and validates manifest heads, patch counts, and visible fact families; -- real source inventory, operation lowering, scratch writing, scratch - equivalence, and finalization safety are planned as slices 47 through 51. +- restored source inventory collection now reads real writer refs and patch + commit trailers into migration-domain source inventory; +- operation lowering, scratch writing, scratch equivalence, and finalization + safety are planned as slices 48 through 51. diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts new file mode 100644 index 00000000..4e1cc802 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts @@ -0,0 +1,203 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationContentSource + from '../../../../src/domain/migrations/GraphModelMigrationContentSource.ts'; +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationPatchDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationSourceInventory + from '../../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import V17GoldenGraphFixtureManifest, { + V17_GOLDEN_CONTENT_FACT, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { compareStrings } from '../../../../src/domain/utils/StringComparison.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } + from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; + +const execFileAsync = promisify(execFile); +const NO_WRITER_REFS_CODE = 'E_NO_WRITER_REFS'; +const NON_PATCH_COMMIT_CODE = 'E_NON_PATCH_COMMIT'; +const WRONG_GRAPH_CODE = 'E_WRONG_GRAPH'; +const WRONG_WRITER_CODE = 'E_WRONG_WRITER'; + +export type GraphModelMigrationSourceInventoryCollectorOptions = { + readonly repositoryPath: string; + readonly graphId: string; + readonly fixtureManifest?: V17GoldenGraphFixtureManifest | null; +}; + +/** Collects v18 migration source inventory from real restored graph-history refs. */ +export async function collectGraphModelMigrationSourceInventory( + options: GraphModelMigrationSourceInventoryCollectorOptions, +): Promise { + const graphId = requireNonEmptyString(options.graphId, 'graphId'); + const refNames = await listWriterRefs(options.repositoryPath, graphId); + if (refNames.length === 0) { + return emptyInventory(graphId, NO_WRITER_REFS_CODE, `no writer refs found for graph ${graphId}`); + } + + const fatalErrors: GraphModelMigrationNotice[] = []; + const writerChains: GraphModelMigrationWriterChainDescriptor[] = []; + const patchDescriptors: GraphModelMigrationPatchDescriptor[] = []; + const basisParts: string[] = []; + + for (const refName of refNames) { + const writerId = writerIdFromRef(refName, graphId); + const patchIds = await gitLines(options.repositoryPath, ['rev-list', '--reverse', refName]); + const expectedWriter = writerId; + await collectPatchDescriptors({ + repositoryPath: options.repositoryPath, + graphId, + writerId: expectedWriter, + patchIds, + patchDescriptors, + fatalErrors, + }); + writerChains.push(new GraphModelMigrationWriterChainDescriptor({ + writerId, + patchIds, + })); + const head = await gitText(options.repositoryPath, ['rev-parse', '--verify', refName]); + basisParts.push(`${refName}@${head}`); + } + + const sourceBasis = fatalErrors.length === 0 + ? new GraphModelMigrationBasis({ + graphId, + basisId: basisParts.sort(compareStrings).join('|'), + }) + : null; + + return new GraphModelMigrationSourceInventory({ + graphId, + sourceBasis, + writerChains, + patchDescriptors, + stateSnapshot: null, + contentSources: collectContentSources(options.fixtureManifest ?? null), + warnings: [], + fatalErrors, + }); +} + +async function collectPatchDescriptors(options: { + readonly repositoryPath: string; + readonly graphId: string; + readonly writerId: string; + readonly patchIds: readonly string[]; + readonly patchDescriptors: GraphModelMigrationPatchDescriptor[]; + readonly fatalErrors: GraphModelMigrationNotice[]; +}): Promise { + let sequence = 0; + for (const patchId of options.patchIds) { + await verifyPatchCommit(options.repositoryPath, options.graphId, options.writerId, patchId, options.fatalErrors); + options.patchDescriptors.push(new GraphModelMigrationPatchDescriptor({ + patchId, + writerId: options.writerId, + writerSequence: sequence, + })); + sequence += 1; + } +} + +async function verifyPatchCommit( + repositoryPath: string, + graphId: string, + writerId: string, + patchId: string, + fatalErrors: GraphModelMigrationNotice[], +): Promise { + const message = await gitText(repositoryPath, ['show', '-s', '--format=%B', patchId]); + try { + const decoded = DEFAULT_COMMIT_MESSAGE_CODEC.decodePatch(message); + if (decoded.graph !== graphId) { + fatalErrors.push(GraphModelMigrationNotice.fatal( + WRONG_GRAPH_CODE, + `patch ${patchId} belongs to graph ${decoded.graph}, expected ${graphId}`, + )); + } + if (decoded.writer !== writerId) { + fatalErrors.push(GraphModelMigrationNotice.fatal( + WRONG_WRITER_CODE, + `patch ${patchId} belongs to writer ${decoded.writer}, expected ${writerId}`, + )); + } + } catch { + fatalErrors.push(GraphModelMigrationNotice.fatal( + NON_PATCH_COMMIT_CODE, + `patch ${patchId} does not decode as a v17 patch commit`, + )); + } +} + +function collectContentSources( + fixtureManifest: V17GoldenGraphFixtureManifest | null, +): readonly GraphModelMigrationContentSource[] { + if (fixtureManifest === null) { + return Object.freeze([]); + } + return Object.freeze(fixtureManifest.visibleFacts + .filter((fact) => fact.kind === V17_GOLDEN_CONTENT_FACT) + .map((fact) => new GraphModelMigrationContentSource({ + legacyContentKey: fact.key, + contentOid: `fixture-content:${fact.key}`, + }))); +} + +async function listWriterRefs(repositoryPath: string, graphId: string): Promise { + const lines = await gitLines(repositoryPath, [ + 'for-each-ref', + '--format=%(refname)', + `refs/warp/${graphId}/writers/`, + ]); + return Object.freeze([...lines].sort(compareStrings)); +} + +function emptyInventory( + graphId: string, + code: string, + message: string, +): GraphModelMigrationSourceInventory { + return new GraphModelMigrationSourceInventory({ + graphId, + sourceBasis: null, + writerChains: [], + patchDescriptors: [], + stateSnapshot: null, + contentSources: [], + warnings: [], + fatalErrors: [GraphModelMigrationNotice.fatal(code, message)], + }); +} + +function writerIdFromRef(refName: string, graphId: string): string { + const prefix = `refs/warp/${graphId}/writers/`; + if (!refName.startsWith(prefix)) { + throw new Error(`writer ref ${refName} is outside ${prefix}`); + } + return requireNonEmptyString(refName.slice(prefix.length), 'writerId'); +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`); + } + return value; +} + +async function gitLines(cwd: string, args: readonly string[]): Promise { + const output = await gitText(cwd, args); + if (output.length === 0) { + return Object.freeze([]); + } + return Object.freeze(output.split('\n').filter((line) => line.length > 0)); +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd }); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts b/test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts new file mode 100644 index 00000000..493ac94f --- /dev/null +++ b/test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts @@ -0,0 +1,65 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + collectGraphModelMigrationSourceInventory, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts'; +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); +const execFileAsync = promisify(execFile); + +describe('v18 graph-model source inventory collector', () => { + it('collects writer chains and patch descriptors from restored v17 refs', async () => { + const targetDirectory = await mkdtemp(join(tmpdir(), 'git-warp-v17-source-')); + const restored = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + + const inventory = await collectGraphModelMigrationSourceInventory({ + repositoryPath: restored.repositoryPath, + graphId: restored.manifest.graphId, + fixtureManifest: restored.manifest, + }); + + expect(inventory.isUsableForPlanning()).toBe(true); + expect(inventory.sourceBasis?.basisId).toContain('refs/warp/v17-golden-graph/writers/alice@'); + expect(inventory.writerChains.map((chain) => [chain.writerId, chain.patchIds.length])).toEqual([ + ['alice', 3], + ['bob', 2], + ]); + expect(inventory.patchDescriptors.map((patch) => [patch.writerId, patch.writerSequence])).toEqual([ + ['alice', 0], + ['alice', 1], + ['alice', 2], + ['bob', 0], + ['bob', 1], + ]); + expect(inventory.contentSources.map((source) => source.legacyContentKey)).toEqual([ + 'node:alpha:_content', + ]); + expect(inventory.fatalErrors).toEqual([]); + }); + + it('fails closed when the graph has no restored writer refs', async () => { + const repositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v17-source-empty-')); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + + const inventory = await collectGraphModelMigrationSourceInventory({ + repositoryPath, + graphId: 'missing-graph', + }); + + expect(inventory.isUsableForPlanning()).toBe(false); + expect(inventory.sourceBasis).toBeNull(); + expect(inventory.fatalErrors.map((notice) => notice.code)).toContain('E_NO_WRITER_REFS'); + expect(inventory.fatalErrors.map((notice) => notice.code)).toContain('E_MISSING_SOURCE_BASIS'); + }); +}); From fcf4e739319a4374b799fbbcf454e513d96e84eb Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 10:42:21 -0700 Subject: [PATCH 03/23] Feat: Lower v18 migration operations --- CHANGELOG.md | 3 + docs/BEARING.md | 13 +- .../v18-migration-operation-lowering.md | 11 +- .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 6 +- .../GraphModelMigrationLoweredOperation.ts | 58 ++++++++ .../GraphModelMigrationLoweredPatchPlan.ts | 83 +++++++++++ .../GraphModelMigrationOperationLowerer.ts | 41 ++++++ ...phModelMigrationOperationLoweringResult.ts | 101 +++++++++++++ ...aphModelMigrationOperationLowering.test.ts | 139 ++++++++++++++++++ 10 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 src/domain/migrations/GraphModelMigrationLoweredOperation.ts create mode 100644 src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts create mode 100644 src/domain/migrations/GraphModelMigrationOperationLowerer.ts create mode 100644 src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts create mode 100644 test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 39fa454b..cff75112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 collector that discovers restored `refs/warp//writers/*` refs, decodes patch commit trailers, records writer chains and patch descriptors, and fails closed with structured inventory notices. +- V18 graph-model migration now includes pure operation lowering from + successful dry-run plans to runtime-backed, write-ready migration operation + facts for later scratch writers. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 3511ffac..6c1ab6db 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -111,6 +111,8 @@ The current v18 graph-model posture is: refs, decodes patch commit trailers, records writer chains and patch descriptors, derives a deterministic source basis, and fails closed with structured migration notices. +- Pure migration operation lowering now turns successful dry-run plans into + runtime-backed write-ready operation facts while refusing fatal dry-run plans. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -239,6 +241,11 @@ codec boundary, records writer chains and patch descriptors, derives a source basis from restored heads, and produces fatal inventory notices when source refs are absent or malformed. +Slice 48 is complete on this branch. Operation lowering now consumes +successful dry-run plans, emits source/target-basis patch plans with sorted +lowered operation facts, and keeps graph-history writes out of the domain +lowering step. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -252,8 +259,8 @@ refs are absent or malformed. map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. - The v18 migration tool is dry-run only. It can consume explicit request JSON, - and restored source inventory, but it does not yet lower operations into a - write-ready form. + restored source inventory, and lower operations into write-ready facts, but + it does not yet write scratch history. - Genesis equivalence is credible as a domain vocabulary and compact fixture proof, not yet as a real scratch-history replay gate. - Compact equivalence fixtures are not enough to validate source inventory @@ -372,7 +379,7 @@ and concrete checks live in `docs/invariants/`. [0199](design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md). - [x] 47. Add real source inventory collection: [0194](design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md). -- [ ] 48. Add migration operation lowering: +- [x] 48. Add migration operation lowering: [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). - [ ] 49. Add the scratch migration writer: [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). diff --git a/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md b/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md index 698c2ce9..861cf425 100644 --- a/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md +++ b/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md @@ -1,11 +1,12 @@ --- cycle: 0195 task_id: V18_migration_operation_lowering -status: Planned +status: Complete sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 48 promotes_backlog: @@ -96,6 +97,14 @@ git diff --check HEAD - No graph-history writes are added. - Scratch writer work has explicit input values. +## Closeout + +Slice 48 adds pure domain lowering from successful dry-run plans to +write-ready migration operation facts. Fatal dry-run plans return fatal +lowering results instead of producing write input. Lowered patch plans carry +source basis, target basis, sorted lowered operations, and no Git, filesystem, +or ref-update behavior. + ## SSJS Scorecard - Runtime-backed forms: green when lowered operation facts are classes. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 40d15b34..24cd01b9 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -50,7 +50,7 @@ Remaining migration-tool work is intentionally ordered as: - slice 46: create v17 golden graph-history fixtures and restore checks (complete); - slice 47: collect real source inventory from restored history (complete); -- slice 48: lower dry-run planned operations; +- slice 48: lower dry-run planned operations (complete); - slice 49: write scratch migrated history; - slice 50: gate scratch output with genesis equivalence; - slice 51: design finalization safety. diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 60f54a65..f7c3d778 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -91,5 +91,7 @@ non-destructive but now has persisted-history evidence: families; - restored source inventory collection now reads real writer refs and patch commit trailers into migration-domain source inventory; -- operation lowering, scratch writing, scratch equivalence, and finalization - safety are planned as slices 48 through 51. +- operation lowering now creates write-ready migration operation facts from + successful dry-run plans without writing history; +- scratch writing, scratch equivalence, and finalization safety are planned + as slices 49 through 51. diff --git a/src/domain/migrations/GraphModelMigrationLoweredOperation.ts b/src/domain/migrations/GraphModelMigrationLoweredOperation.ts new file mode 100644 index 00000000..b7011b61 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationLoweredOperation.ts @@ -0,0 +1,58 @@ +import GraphModelMigrationPlannedGraphOperation, { + type GraphModelMigrationPlannedGraphOperationKind, +} from './GraphModelMigrationPlannedGraphOperation.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationLoweredOperationFields = { + readonly kind: GraphModelMigrationPlannedGraphOperationKind; + readonly sourceKey: string; + readonly targetKey: string; +}; + +/** Runtime-backed write-ready migration operation fact. */ +export default class GraphModelMigrationLoweredOperation { + readonly kind: GraphModelMigrationPlannedGraphOperationKind; + readonly sourceKey: string; + readonly targetKey: string; + + constructor(fields: GraphModelMigrationLoweredOperationFields) { + const checkedFields = requireFields(fields); + const planned = new GraphModelMigrationPlannedGraphOperation({ + kind: checkedFields.kind, + sourceKey: checkedFields.sourceKey, + targetKey: checkedFields.targetKey, + }); + this.kind = planned.kind; + this.sourceKey = planned.sourceKey; + this.targetKey = planned.targetKey; + Object.freeze(this); + } + + /** Lowers a planned dry-run fact into a write-ready operation fact. */ + static fromPlanned( + operation: GraphModelMigrationPlannedGraphOperation, + ): GraphModelMigrationLoweredOperation { + if (!(operation instanceof GraphModelMigrationPlannedGraphOperation)) { + throw new WarpError('operation must be a planned graph operation', 'E_VALIDATION'); + } + return new GraphModelMigrationLoweredOperation({ + kind: operation.kind, + sourceKey: operation.sourceKey, + targetKey: operation.targetKey, + }); + } + + /** Returns a deterministic operation key for ordering and dedupe. */ + toKey(): string { + return `lowered\0${this.kind}\0${this.sourceKey}\0${this.targetKey}`; + } +} + +function requireFields( + fields: GraphModelMigrationLoweredOperationFields | null | undefined, +): GraphModelMigrationLoweredOperationFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationLoweredOperation fields must be provided', 'E_VALIDATION'); + } + return fields; +} diff --git a/src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts b/src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts new file mode 100644 index 00000000..27a19483 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts @@ -0,0 +1,83 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import GraphModelMigrationBasis from './GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation from './GraphModelMigrationLoweredOperation.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationLoweredPatchPlanFields = { + readonly sourceBasis: GraphModelMigrationBasis; + readonly targetBasis: GraphModelMigrationBasis; + readonly operations: readonly GraphModelMigrationLoweredOperation[]; +}; + +/** Frozen write-ready migration patch plan for scratch writers. */ +export default class GraphModelMigrationLoweredPatchPlan { + readonly sourceBasis: GraphModelMigrationBasis; + readonly targetBasis: GraphModelMigrationBasis; + readonly operations: readonly GraphModelMigrationLoweredOperation[]; + + constructor(fields: GraphModelMigrationLoweredPatchPlanFields) { + const checkedFields = requireFields(fields); + this.sourceBasis = requireBasis(checkedFields.sourceBasis, 'sourceBasis'); + this.targetBasis = requireBasis(checkedFields.targetBasis, 'targetBasis'); + this.operations = freezeOperations(checkedFields.operations); + Object.freeze(this); + } + + /** Returns true when the plan has at least one lowered write fact. */ + hasOperations(): boolean { + return this.operations.length > 0; + } +} + +function requireFields( + fields: GraphModelMigrationLoweredPatchPlanFields | null | undefined, +): GraphModelMigrationLoweredPatchPlanFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationLoweredPatchPlan fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireBasis(basis: GraphModelMigrationBasis, name: string): GraphModelMigrationBasis { + if (!(basis instanceof GraphModelMigrationBasis)) { + throw new WarpError(`${name} must be a GraphModelMigrationBasis`, 'E_VALIDATION'); + } + return basis; +} + +function freezeOperations( + operations: readonly GraphModelMigrationLoweredOperation[], +): readonly GraphModelMigrationLoweredOperation[] { + if (!Array.isArray(operations)) { + throw new WarpError('operations must be an array', 'E_VALIDATION'); + } + const checked = operations.map(requireOperation); + requireUnique(checked.map((operation) => operation.toKey())); + return Object.freeze([...checked].sort(compareOperations)); +} + +function requireOperation( + operation: GraphModelMigrationLoweredOperation, +): GraphModelMigrationLoweredOperation { + if (!(operation instanceof GraphModelMigrationLoweredOperation)) { + throw new WarpError('operations must contain lowered migration operations', 'E_VALIDATION'); + } + return operation; +} + +function requireUnique(keys: readonly string[]): void { + const seen = new Set(); + for (const key of keys) { + if (seen.has(key)) { + throw new WarpError(`GraphModelMigrationLoweredPatchPlan duplicates operation ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +function compareOperations( + left: GraphModelMigrationLoweredOperation, + right: GraphModelMigrationLoweredOperation, +): number { + return compareStrings(left.toKey(), right.toKey()); +} diff --git a/src/domain/migrations/GraphModelMigrationOperationLowerer.ts b/src/domain/migrations/GraphModelMigrationOperationLowerer.ts new file mode 100644 index 00000000..c96a5d30 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationOperationLowerer.ts @@ -0,0 +1,41 @@ +import DryRunGraphModelMigrationPlan from './DryRunGraphModelMigrationPlan.ts'; +import GraphModelMigrationLoweredOperation from './GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan from './GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationOperationLoweringResult from './GraphModelMigrationOperationLoweringResult.ts'; +import WarpError from '../errors/WarpError.ts'; + +/** Pure lowering service from dry-run facts to scratch-writer input values. */ +export default class GraphModelMigrationOperationLowerer { + /** Lowers a dry-run migration plan without reading or writing graph history. */ + lower(plan: DryRunGraphModelMigrationPlan): GraphModelMigrationOperationLoweringResult { + const checkedPlan = requirePlan(plan); + if (checkedPlan.hasFatalErrors()) { + return new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: checkedPlan.warnings, + fatalErrors: checkedPlan.fatalErrors, + }); + } + const { manifest } = checkedPlan; + if (manifest === null) { + throw new WarpError('successful dry-run plan must contain a manifest', 'E_VALIDATION'); + } + return new GraphModelMigrationOperationLoweringResult({ + patchPlan: new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: manifest.sourceBasis, + targetBasis: manifest.targetBasis, + operations: checkedPlan.plannedOperations + .map((operation) => GraphModelMigrationLoweredOperation.fromPlanned(operation)), + }), + warnings: checkedPlan.warnings, + fatalErrors: [], + }); + } +} + +function requirePlan(plan: DryRunGraphModelMigrationPlan): DryRunGraphModelMigrationPlan { + if (!(plan instanceof DryRunGraphModelMigrationPlan)) { + throw new WarpError('plan must be a DryRunGraphModelMigrationPlan', 'E_VALIDATION'); + } + return plan; +} diff --git a/src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts b/src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts new file mode 100644 index 00000000..22e4ea18 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts @@ -0,0 +1,101 @@ +import GraphModelMigrationLoweredPatchPlan from './GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationOperationLoweringResultFields = { + readonly patchPlan: GraphModelMigrationLoweredPatchPlan | null; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result value for pure graph-model migration operation lowering. */ +export default class GraphModelMigrationOperationLoweringResult { + readonly patchPlan: GraphModelMigrationLoweredPatchPlan | null; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationOperationLoweringResultFields) { + const checkedFields = requireFields(fields); + this.patchPlan = requireOptionalPatchPlan(checkedFields.patchPlan); + this.warnings = freezeWarningNotices(checkedFields.warnings); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requirePatchPlanMatchesFatality(this.patchPlan, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when lowering failed closed. */ + hasFatalErrors(): boolean { + return this.fatalErrors.length > 0; + } +} + +function requireFields( + fields: GraphModelMigrationOperationLoweringResultFields | null | undefined, +): GraphModelMigrationOperationLoweringResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationOperationLoweringResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireOptionalPatchPlan( + patchPlan: GraphModelMigrationLoweredPatchPlan | null, +): GraphModelMigrationLoweredPatchPlan | null { + if (patchPlan !== null && !(patchPlan instanceof GraphModelMigrationLoweredPatchPlan)) { + throw new WarpError('patchPlan must be a GraphModelMigrationLoweredPatchPlan', 'E_VALIDATION'); + } + return patchPlan; +} + +function freezeWarningNotices( + notices: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + const checked = requireNoticeArray(notices, 'warnings'); + for (const notice of checked) { + if (notice.isFatal()) { + throw new WarpError('warnings contains the wrong notice kind', 'E_VALIDATION'); + } + } + return Object.freeze(checked); +} + +function freezeFatalNotices( + notices: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + const checked = requireNoticeArray(notices, 'fatalErrors'); + for (const notice of checked) { + if (!notice.isFatal()) { + throw new WarpError('fatalErrors contains the wrong notice kind', 'E_VALIDATION'); + } + } + return Object.freeze(checked); +} + +function requireNoticeArray( + notices: readonly GraphModelMigrationNotice[], + label: string, +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(notices)) { + throw new WarpError(`${label} must be an array`, 'E_VALIDATION'); + } + return notices.map(requireNotice); +} + +function requireNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice)) { + throw new WarpError('notices must contain GraphModelMigrationNotice instances', 'E_VALIDATION'); + } + return notice; +} + +function requirePatchPlanMatchesFatality( + patchPlan: GraphModelMigrationLoweredPatchPlan | null, + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (fatalErrors.length > 0 && patchPlan !== null) { + throw new WarpError('fatal lowering results must not contain a patch plan', 'E_VALIDATION'); + } + if (fatalErrors.length === 0 && patchPlan === null) { + throw new WarpError('successful lowering results must contain a patch plan', 'E_VALIDATION'); + } +} diff --git a/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts b/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts new file mode 100644 index 00000000..ac1b4eae --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationContentSource + from '../../../../src/domain/migrations/GraphModelMigrationContentSource.ts'; +import GraphModelMigrationNodeMapping + from '../../../../src/domain/migrations/GraphModelMigrationNodeMapping.ts'; +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationOperationLowerer + from '../../../../src/domain/migrations/GraphModelMigrationOperationLowerer.ts'; +import GraphModelMigrationPatchDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationPropertyMapping + from '../../../../src/domain/migrations/GraphModelMigrationPropertyMapping.ts'; +import GraphModelMigrationSourceInventory + from '../../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; + +describe('GraphModelMigrationOperationLowerer', () => { + it('lowers successful dry-run plans into deterministic write-ready facts', () => { + const result = lowerer().lower(planner().plan(completeRequest())); + + expect(result.hasFatalErrors()).toBe(false); + expect(result.patchPlan?.sourceBasis.basisId).toBe('basis:source'); + expect(result.patchPlan?.targetBasis.basisId).toBe('basis:source:v18-dry-run'); + expect(result.patchPlan?.operations.map((operation) => operation.toKey())).toEqual([ + 'lowered\0content-attachment\0node:a\0_content\0content-attachment:node:a\0_content', + 'lowered\0node-record\0node:a\0node:a', + 'lowered\0property\0node:a\0title\0property-target-key:length-prefixed-v1:6:node:a:5:title', + ]); + }); + + it('preserves property target key identity through lowering', () => { + const result = lowerer().lower(planner().plan(completeRequest())); + const propertyOperation = result.patchPlan?.operations.find((operation) => operation.kind === 'property'); + + expect(propertyOperation?.targetKey).toBe('property-target-key:length-prefixed-v1:6:node:a:5:title'); + }); + + it('fails closed instead of lowering fatal dry-run plans', () => { + const result = lowerer().lower(planner().plan(new DryRunGraphModelMigrationPlanRequest({ + inventory: sourceInventory({ + sourceBasis: null, + fatalErrors: [], + }), + requiredContentKeys: [], + nodeMappings: [], + edgeMappings: [], + propertyMappings: [], + }))); + + expect(result.patchPlan).toBeNull(); + expect(result.hasFatalErrors()).toBe(true); + expect(result.fatalErrors.map((notice) => notice.code)).toContain('E_MISSING_SOURCE_BASIS'); + }); + + it('requires lowered operation facts in patch plans', () => { + const plan = lowerer().lower(planner().plan(completeRequest())).patchPlan; + + expect(plan?.hasOperations()).toBe(true); + expect(() => new GraphModelMigrationOperationLowerer().lower( + // @ts-expect-error exercising runtime validation + { plannedOperations: [] }, + )).toThrow(/DryRunGraphModelMigrationPlan/); + }); +}); + +function planner(): DryRunGraphModelMigrationPlanner { + return new DryRunGraphModelMigrationPlanner(); +} + +function lowerer(): GraphModelMigrationOperationLowerer { + return new GraphModelMigrationOperationLowerer(); +} + +function completeRequest(): DryRunGraphModelMigrationPlanRequest { + return new DryRunGraphModelMigrationPlanRequest({ + inventory: sourceInventory({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:source', + }), + fatalErrors: [], + }), + requiredContentKeys: ['node:a\0_content'], + nodeMappings: [ + new GraphModelMigrationNodeMapping({ + legacyNodeId: 'node:a', + targetNodeId: 'node:a', + }), + ], + edgeMappings: [], + propertyMappings: [ + new GraphModelMigrationPropertyMapping({ + legacyOwnerId: 'node:a', + legacyPropertyKey: 'title', + targetOwnerId: 'node:a', + targetPropertyKey: 'title', + }), + ], + }); +} + +function sourceInventory(options: { + readonly sourceBasis: GraphModelMigrationBasis | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}): GraphModelMigrationSourceInventory { + return new GraphModelMigrationSourceInventory({ + graphId: 'graph:source', + sourceBasis: options.sourceBasis, + writerChains: [ + new GraphModelMigrationWriterChainDescriptor({ + writerId: 'writer:a', + patchIds: ['patch:a:0'], + }), + ], + patchDescriptors: [ + new GraphModelMigrationPatchDescriptor({ + patchId: 'patch:a:0', + writerId: 'writer:a', + writerSequence: 0, + }), + ], + stateSnapshot: null, + contentSources: [ + new GraphModelMigrationContentSource({ + legacyContentKey: 'node:a\0_content', + contentOid: 'oid:a', + }), + ], + warnings: [], + fatalErrors: options.fatalErrors, + }); +} From 27dbb1405d23c1d4db195341660ab9dcc39a6e98 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 10:53:39 -0700 Subject: [PATCH 04/23] Feat: Write v18 migration scratch history --- CHANGELOG.md | 4 + docs/BEARING.md | 21 +- .../v18-scratch-migration-writer.md | 42 ++- .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 8 +- .../GraphModelMigrationScratchWriter.ts | 284 ++++++++++++++++++ .../GraphModelMigrationScratchRef.ts | 107 +++++++ .../GraphModelMigrationScratchWriteResult.ts | 140 +++++++++ .../GraphModelMigrationScratchWrittenPatch.ts | 60 ++++ .../v18-scratch-migration-writer.test.ts | 145 +++++++++ 10 files changed, 795 insertions(+), 18 deletions(-) create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts create mode 100644 src/domain/migrations/GraphModelMigrationScratchRef.ts create mode 100644 src/domain/migrations/GraphModelMigrationScratchWriteResult.ts create mode 100644 src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts create mode 100644 test/unit/scripts/v18-scratch-migration-writer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cff75112..65e2c126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration now includes pure operation lowering from successful dry-run plans to runtime-backed, write-ready migration operation facts for later scratch writers. +- V18 graph-model migration now includes an explicit scratch writer that + rejects live graph refs, writes lowered operation commits only under + `refs/warp-migration-scratch/*`, and advances scratch refs with + expected-head `git update-ref` calls. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 6c1ab6db..cb218618 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -113,6 +113,9 @@ The current v18 graph-model posture is: structured migration notices. - Pure migration operation lowering now turns successful dry-run plans into runtime-backed write-ready operation facts while refusing fatal dry-run plans. +- An explicit scratch migration writer now writes lowered operation facts only + under `refs/warp-migration-scratch/*`, rejects live graph refs, and advances + scratch refs with expected-head `git update-ref` calls. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -246,6 +249,11 @@ successful dry-run plans, emits source/target-basis patch plans with sorted lowered operation facts, and keeps graph-history writes out of the domain lowering step. +Slice 49 is complete on this branch. Scratch migration writing now requires an +explicit scratch ref, rejects live `refs/warp/*` targets before writing, +creates deterministic per-operation commits, and appends with CAS-shaped +`git update-ref` calls. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -258,14 +266,13 @@ lowering step. - Temporal replay still extracts node snapshots from the raw legacy property map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. -- The v18 migration tool is dry-run only. It can consume explicit request JSON, - restored source inventory, and lower operations into write-ready facts, but - it does not yet write scratch history. +- The v18 migration tool can now write scratch history, but it does not yet + replay scratch output into observer-visible readings for equivalence. - Genesis equivalence is credible as a domain vocabulary and compact fixture proof, not yet as a real scratch-history replay gate. -- Compact equivalence fixtures are not enough to validate source inventory - over real v17 persisted Git history. The first golden fixture now restores a - v17 graph object/ref layout, but source inventory does not consume it yet. +- Compact equivalence fixtures are not enough by themselves. The golden v17 + fixture now restores Git refs and source inventory consumes those refs, but + the scratch writer output still needs an equivalence gate. - The next write-capable migration work must go through real source inventory, lowering, scratch writes, equivalence gates, and finalization safety. Live ref promotion is still out of bounds. @@ -381,7 +388,7 @@ and concrete checks live in `docs/invariants/`. [0194](design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md). - [x] 48. Add migration operation lowering: [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). -- [ ] 49. Add the scratch migration writer: +- [x] 49. Add the scratch migration writer: [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). - [ ] 50. Add the scratch equivalence gate: [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). diff --git a/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md index 6d0d0e96..49a38661 100644 --- a/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md +++ b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md @@ -1,11 +1,12 @@ --- cycle: 0196 task_id: V18_scratch_migration_writer -status: Planned +status: Completed sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 49 promotes_backlog: @@ -97,12 +98,39 @@ git diff --check HEAD - Scratch output can be replayed by the equivalence gate. - Finalization remains separate. +## Closeout + +Slice 49 added the first write-capable v18 graph-model migration step, fenced +behind `GraphModelMigrationScratchRef`. The writer accepts lowered migration +operations and writes one deterministic Git commit per operation to an +explicit `refs/warp-migration-scratch/*` ref. + +The implementation rejects missing targets, live `refs/warp/*` targets, and +invalid scratch ref shapes before writing. Scratch ref advancement uses +`git update-ref` with the expected old head, so appending remains CAS-shaped +instead of force-shaped. Commit payloads encode operation and basis identity +as UTF-8 hex lines rather than JSON, keeping serialization out of the domain +and avoiding behaviorally significant message parsing. + +Finalization is still not present. The scratch history is now inspectable +input for the slice 50 equivalence gate. + +## Verification Result + +```text +npx vitest run test/unit/scripts/v18-scratch-migration-writer.test.ts --reporter=verbose +npx eslint --no-warn-ignored scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts test/unit/scripts/v18-scratch-migration-writer.test.ts src/domain/migrations/GraphModelMigrationScratchRef.ts src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts src/domain/migrations/GraphModelMigrationScratchWriteResult.ts +npm run typecheck +``` + ## SSJS Scorecard -- Runtime-backed forms: green when writer results are named values. -- Boundary validation: green when scratch targets are validated before I/O. -- Behavior ownership: green when writer writes and domain lowering lowers. +- Runtime-backed forms: green; scratch refs, written patches, and write + results are named values. +- Boundary validation: green; scratch targets are validated before write I/O. +- Behavior ownership: green; writer writes and domain lowering lowers. - Message parsing: green; no behavior parses text output. -- Ambient time or entropy: green when generated identities come from inputs or - injected ports. -- Fake shape trust or cast-cosplay: green when fake persistence is typed. +- Ambient time or entropy: green; scratch commits use fixed migration Git + identity and dates. +- Fake shape trust or cast-cosplay: green; tests use real Git repositories and + typed result values. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 24cd01b9..1ba9f194 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -51,7 +51,7 @@ Remaining migration-tool work is intentionally ordered as: (complete); - slice 47: collect real source inventory from restored history (complete); - slice 48: lower dry-run planned operations (complete); -- slice 49: write scratch migrated history; +- slice 49: write scratch migrated history (complete); - slice 50: gate scratch output with genesis equivalence; - slice 51: design finalization safety. diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index f7c3d778..0ec4a324 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -74,7 +74,7 @@ graph model. Change the envelope only if replay honesty requires it. ## Current Evidence -After v18 slice 46, the migration path is intentionally still +After v18 slice 49, the migration path is intentionally still non-destructive but now has persisted-history evidence: - dry-run request JSON can be decoded at the infrastructure boundary; @@ -93,5 +93,7 @@ non-destructive but now has persisted-history evidence: commit trailers into migration-domain source inventory; - operation lowering now creates write-ready migration operation facts from successful dry-run plans without writing history; -- scratch writing, scratch equivalence, and finalization safety are planned - as slices 49 through 51. +- scratch writing now creates deterministic operation commits under explicit + `refs/warp-migration-scratch/*` refs and refuses live graph refs; +- scratch equivalence and finalization safety are planned as the next gates + before any live lineage promotion. diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts new file mode 100644 index 00000000..49c15538 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts @@ -0,0 +1,284 @@ +import { spawn } from 'node:child_process'; + +import GraphModelMigrationLoweredOperation + from '../../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWrittenPatch + from '../../../../src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; + +const ZERO_OID = '0000000000000000000000000000000000000000'; +const OPERATION_TREE_PATH = 'migration-operation.txt'; +const SCRATCH_WRITE_GIT_IDENTITY = Object.freeze({ + GIT_AUTHOR_NAME: 'git-warp migration', + GIT_AUTHOR_EMAIL: 'git-warp@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'git-warp migration', + GIT_COMMITTER_EMAIL: 'git-warp@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', +}); + +export type GraphModelMigrationScratchWriterOptions = { + readonly repositoryPath: string; + readonly scratchRefName: string | null; + readonly patchPlan: GraphModelMigrationLoweredPatchPlan; +}; + +class GitCommandResult { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + Object.freeze(this); + } + + ok(): boolean { + return this.exitCode === 0; + } +} + +export class GraphModelMigrationScratchWriterError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchWriterError'; + } +} + +/** Writes lowered graph-model migration operations to an explicit scratch ref. */ +export async function writeGraphModelMigrationScratchHistory( + options: GraphModelMigrationScratchWriterOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchRefNotice = GraphModelMigrationScratchRef.validateRefName(options.scratchRefName); + if (scratchRefNotice !== null) { + return blockedResult(null, scratchRefNotice); + } + const scratchRef = new GraphModelMigrationScratchRef({ refName: requireScratchRefName(options.scratchRefName) }); + const patchPlan = requirePatchPlan(options.patchPlan); + const gitRefNotice = await validateGitRefName(repositoryPath, scratchRef); + if (gitRefNotice !== null) { + return blockedResult(scratchRef, gitRefNotice); + } + + let currentHead = await gitTextOrNull(repositoryPath, ['show-ref', '--verify', '--hash', scratchRef.refName]); + const writtenPatches: GraphModelMigrationScratchWrittenPatch[] = []; + let sequence = 0; + for (const operation of patchPlan.operations) { + const commitId = await writeOperationCommit({ + repositoryPath, + patchPlan, + operation, + sequence, + parentHead: currentHead, + }); + await advanceScratchRef(repositoryPath, scratchRef, commitId, currentHead); + currentHead = commitId; + writtenPatches.push(new GraphModelMigrationScratchWrittenPatch({ + commitId, + operation, + sequence, + })); + sequence += 1; + } + + return new GraphModelMigrationScratchWriteResult({ + scratchRef, + scratchHead: currentHead, + writtenPatches, + warnings: [], + fatalErrors: [], + }); +} + +async function writeOperationCommit(options: { + readonly repositoryPath: string; + readonly patchPlan: GraphModelMigrationLoweredPatchPlan; + readonly operation: GraphModelMigrationLoweredOperation; + readonly sequence: number; + readonly parentHead: string | null; +}): Promise { + const payload = formatOperationPayload(options.patchPlan, options.operation, options.sequence); + const blobOid = await gitTextWithInput(options.repositoryPath, ['hash-object', '-w', '--stdin'], payload); + const treeOid = await gitTextWithInput( + options.repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\t${OPERATION_TREE_PATH}\n`, + ); + const parentArgs = options.parentHead === null ? [] : ['-p', options.parentHead]; + return await gitTextWithInput( + options.repositoryPath, + ['commit-tree', treeOid, ...parentArgs], + formatCommitMessage(options.patchPlan, options.operation, options.sequence), + ); +} + +async function advanceScratchRef( + repositoryPath: string, + scratchRef: GraphModelMigrationScratchRef, + commitId: string, + expectedHead: string | null, +): Promise { + const expected = expectedHead ?? ZERO_OID; + await gitText(repositoryPath, ['update-ref', scratchRef.refName, commitId, expected]); +} + +async function validateGitRefName( + repositoryPath: string, + scratchRef: GraphModelMigrationScratchRef, +): Promise { + const result = await runGit(repositoryPath, ['check-ref-format', scratchRef.refName], null); + if (result.ok()) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_INVALID_SCRATCH_REF', + `git rejected scratch migration ref ${scratchRef.refName}`, + ); +} + +function blockedResult( + scratchRef: GraphModelMigrationScratchRef | null, + fatalError: GraphModelMigrationNotice, +): GraphModelMigrationScratchWriteResult { + return new GraphModelMigrationScratchWriteResult({ + scratchRef, + scratchHead: null, + writtenPatches: [], + warnings: [], + fatalErrors: [fatalError], + }); +} + +function formatOperationPayload( + patchPlan: GraphModelMigrationLoweredPatchPlan, + operation: GraphModelMigrationLoweredOperation, + sequence: number, +): string { + return [ + 'git-warp-v18-migration-operation-v1', + `sequence ${sequence}`, + `kind ${operation.kind}`, + `source-basis-utf8-hex ${utf8Hex(patchPlan.sourceBasis.toKey())}`, + `target-basis-utf8-hex ${utf8Hex(patchPlan.targetBasis.toKey())}`, + `source-key-utf8-hex ${utf8Hex(operation.sourceKey)}`, + `target-key-utf8-hex ${utf8Hex(operation.targetKey)}`, + `operation-key-utf8-hex ${utf8Hex(operation.toKey())}`, + '', + ].join('\n'); +} + +function formatCommitMessage( + patchPlan: GraphModelMigrationLoweredPatchPlan, + operation: GraphModelMigrationLoweredOperation, + sequence: number, +): string { + return [ + 'git-warp v18 scratch migration operation', + '', + `Migration-Format: git-warp-v18-scratch-operation-v1`, + `Operation-Sequence: ${sequence}`, + `Operation-Kind: ${operation.kind}`, + `Source-Basis-UTF8-Hex: ${utf8Hex(patchPlan.sourceBasis.toKey())}`, + `Target-Basis-UTF8-Hex: ${utf8Hex(patchPlan.targetBasis.toKey())}`, + `Operation-Key-UTF8-Hex: ${utf8Hex(operation.toKey())}`, + '', + ].join('\n'); +} + +function utf8Hex(value: string): string { + const bytes = new TextEncoder().encode(value); + const parts: string[] = []; + for (const byte of bytes) { + parts.push(byte.toString(16).padStart(2, '0')); + } + return parts.join(''); +} + +function requirePatchPlan( + patchPlan: GraphModelMigrationLoweredPatchPlan, +): GraphModelMigrationLoweredPatchPlan { + if (!(patchPlan instanceof GraphModelMigrationLoweredPatchPlan)) { + throw new GraphModelMigrationScratchWriterError('patchPlan must be a GraphModelMigrationLoweredPatchPlan'); + } + return patchPlan; +} + +function requireScratchRefName(scratchRefName: string | null): string { + if (scratchRefName === null) { + throw new GraphModelMigrationScratchWriterError('scratchRefName must not be null after validation'); + } + return scratchRefName; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchWriterError(`${name} must be a non-empty string`); + } + return value; +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await runGit(cwd, args, null); + if (!result.ok()) { + throw new GraphModelMigrationScratchWriterError( + `git ${args.join(' ')} failed: ${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +async function gitTextWithInput(cwd: string, args: readonly string[], input: string): Promise { + const result = await runGit(cwd, args, input); + if (!result.ok()) { + throw new GraphModelMigrationScratchWriterError( + `git ${args.join(' ')} failed: ${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { + const result = await runGit(cwd, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} + +async function runGit( + cwd: string, + args: readonly string[], + input: string | null, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn('git', args, { + cwd, + env: SCRATCH_WRITE_GIT_IDENTITY, + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (exitCode) => { + resolve(new GitCommandResult(exitCode ?? 1, stdout, stderr)); + }); + if (input !== null) { + child.stdin.write(input); + } + child.stdin.end(); + }); +} diff --git a/src/domain/migrations/GraphModelMigrationScratchRef.ts b/src/domain/migrations/GraphModelMigrationScratchRef.ts new file mode 100644 index 00000000..a9493616 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationScratchRef.ts @@ -0,0 +1,107 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const SCRATCH_REF_PREFIX = 'refs/warp-migration-scratch/'; +const LIVE_WARP_REF_PREFIX = 'refs/warp/'; +const MISSING_SCRATCH_REF_CODE = 'E_MISSING_SCRATCH_REF'; +const LIVE_REF_TARGET_CODE = 'E_LIVE_REF_TARGET'; +const INVALID_SCRATCH_REF_CODE = 'E_INVALID_SCRATCH_REF'; +const INVALID_REF_CHARACTERS = Object.freeze(new Set(['~', '^', ':', '?', '*', '[', '\\'])); + +export type GraphModelMigrationScratchRefFields = { + readonly refName: string; +}; + +/** Explicit scratch ref target for graph-model migration writes. */ +export default class GraphModelMigrationScratchRef { + readonly refName: string; + + constructor(fields: GraphModelMigrationScratchRefFields) { + const checkedFields = requireFields(fields); + const notice = GraphModelMigrationScratchRef.validateRefName(checkedFields.refName); + if (notice !== null) { + throw new WarpError(notice.message, notice.code); + } + this.refName = checkedFields.refName; + Object.freeze(this); + } + + /** Validates a scratch ref target without constructing one. */ + static validateRefName(refName: string | null): GraphModelMigrationNotice | null { + if (refName === null || refName.length === 0) { + return GraphModelMigrationNotice.fatal( + MISSING_SCRATCH_REF_CODE, + 'graph-model migration requires an explicit scratch ref target', + ); + } + const prefixNotice = validateRefPrefix(refName); + return prefixNotice ?? validateRefShape(refName); + } + + /** Returns the Git ref name. */ + toString(): string { + return this.refName; + } +} + +function requireFields( + fields: GraphModelMigrationScratchRefFields | null | undefined, +): GraphModelMigrationScratchRefFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationScratchRef fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function hasInvalidRefShape(refName: string): boolean { + const suffix = refName.slice(SCRATCH_REF_PREFIX.length); + return [ + suffix.length === 0, + suffix.startsWith('/'), + suffix.endsWith('/'), + refName.includes('//'), + refName.includes('..'), + refName.trim() !== refName, + containsInvalidRefCharacter(refName), + ].some((invalid) => invalid); +} + +function containsInvalidRefCharacter(refName: string): boolean { + for (const character of refName) { + if (isInvalidRefCharacter(character)) { + return true; + } + } + return false; +} + +function validateRefPrefix(refName: string): GraphModelMigrationNotice | null { + if (refName.startsWith(LIVE_WARP_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + LIVE_REF_TARGET_CODE, + `scratch migration writer refuses live graph ref target ${refName}`, + ); + } + if (!refName.startsWith(SCRATCH_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + INVALID_SCRATCH_REF_CODE, + `scratch migration ref must start with ${SCRATCH_REF_PREFIX}`, + ); + } + return null; +} + +function validateRefShape(refName: string): GraphModelMigrationNotice | null { + if (!hasInvalidRefShape(refName)) { + return null; + } + return GraphModelMigrationNotice.fatal( + INVALID_SCRATCH_REF_CODE, + `scratch migration ref has invalid shape ${refName}`, + ); +} + +function isInvalidRefCharacter(character: string): boolean { + const code = character.charCodeAt(0); + return code <= 32 || code === 127 || INVALID_REF_CHARACTERS.has(character); +} diff --git a/src/domain/migrations/GraphModelMigrationScratchWriteResult.ts b/src/domain/migrations/GraphModelMigrationScratchWriteResult.ts new file mode 100644 index 00000000..ae5dae28 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationScratchWriteResult.ts @@ -0,0 +1,140 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWrittenPatch from './GraphModelMigrationScratchWrittenPatch.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationScratchWriteResultFields = { + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[]; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result value for an explicit scratch migration history write. */ +export default class GraphModelMigrationScratchWriteResult { + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[]; + readonly warnings: readonly GraphModelMigrationNotice[]; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationScratchWriteResultFields) { + const checkedFields = requireFields(fields); + this.scratchRef = requireOptionalScratchRef(checkedFields.scratchRef); + this.scratchHead = requireOptionalHead(checkedFields.scratchHead); + this.writtenPatches = freezeWrittenPatches(checkedFields.writtenPatches); + this.warnings = freezeWarningNotices(checkedFields.warnings); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireFatalResultShape(this.scratchHead, this.writtenPatches, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when the write was blocked before completion. */ + hasFatalErrors(): boolean { + return this.fatalErrors.length > 0; + } +} + +function requireFields( + fields: GraphModelMigrationScratchWriteResultFields | null | undefined, +): GraphModelMigrationScratchWriteResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationScratchWriteResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireOptionalScratchRef( + scratchRef: GraphModelMigrationScratchRef | null, +): GraphModelMigrationScratchRef | null { + if (scratchRef !== null && !(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef or null', 'E_VALIDATION'); + } + return scratchRef; +} + +function requireOptionalHead(scratchHead: string | null): string | null { + if (scratchHead === null) { + return null; + } + if (typeof scratchHead !== 'string' || scratchHead.length === 0) { + throw new WarpError('scratchHead must be a non-empty string or null', 'E_VALIDATION'); + } + return scratchHead; +} + +function freezeWrittenPatches( + writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[], +): readonly GraphModelMigrationScratchWrittenPatch[] { + if (!Array.isArray(writtenPatches)) { + throw new WarpError('writtenPatches must be an array', 'E_VALIDATION'); + } + const checked = writtenPatches.map(requireWrittenPatch); + requireUniqueOperationKeys(checked); + return Object.freeze([...checked]); +} + +function requireWrittenPatch( + writtenPatch: GraphModelMigrationScratchWrittenPatch, +): GraphModelMigrationScratchWrittenPatch { + if (!(writtenPatch instanceof GraphModelMigrationScratchWrittenPatch)) { + throw new WarpError('writtenPatches must contain scratch written patches', 'E_VALIDATION'); + } + return writtenPatch; +} + +function requireUniqueOperationKeys( + writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[], +): void { + const seen = new Set(); + for (const writtenPatch of writtenPatches) { + const key = writtenPatch.operationKey(); + if (seen.has(key)) { + throw new WarpError(`duplicate scratch written operation ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +function freezeWarningNotices( + warnings: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(warnings)) { + throw new WarpError('warnings must be an array', 'E_VALIDATION'); + } + return Object.freeze(warnings.map(requireWarningNotice)); +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireWarningNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || notice.isFatal()) { + throw new WarpError('warnings must contain warning migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireFatalResultShape( + scratchHead: string | null, + writtenPatches: readonly GraphModelMigrationScratchWrittenPatch[], + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (fatalErrors.length > 0 && (scratchHead !== null || writtenPatches.length > 0)) { + throw new WarpError('fatal scratch write results must not include written output', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts b/src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts new file mode 100644 index 00000000..97460735 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts @@ -0,0 +1,60 @@ +import GraphModelMigrationLoweredOperation from './GraphModelMigrationLoweredOperation.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationScratchWrittenPatchFields = { + readonly commitId: string; + readonly operation: GraphModelMigrationLoweredOperation; + readonly sequence: number; +}; + +/** One scratch-history commit written for a lowered migration operation. */ +export default class GraphModelMigrationScratchWrittenPatch { + readonly commitId: string; + readonly operation: GraphModelMigrationLoweredOperation; + readonly sequence: number; + + constructor(fields: GraphModelMigrationScratchWrittenPatchFields) { + const checkedFields = requireFields(fields); + this.commitId = requireNonEmptyString(checkedFields.commitId, 'commitId'); + this.operation = requireOperation(checkedFields.operation); + this.sequence = requireNonNegativeInteger(checkedFields.sequence, 'sequence'); + Object.freeze(this); + } + + /** Returns the deterministic lowered operation key carried by this commit. */ + operationKey(): string { + return this.operation.toKey(); + } +} + +function requireFields( + fields: GraphModelMigrationScratchWrittenPatchFields | null | undefined, +): GraphModelMigrationScratchWrittenPatchFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationScratchWrittenPatch fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireOperation( + operation: GraphModelMigrationLoweredOperation, +): GraphModelMigrationLoweredOperation { + if (!(operation instanceof GraphModelMigrationLoweredOperation)) { + throw new WarpError('operation must be a GraphModelMigrationLoweredOperation', 'E_VALIDATION'); + } + return operation; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireNonNegativeInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new WarpError(`${name} must be a non-negative integer`, 'E_VALIDATION'); + } + return value; +} diff --git a/test/unit/scripts/v18-scratch-migration-writer.test.ts b/test/unit/scripts/v18-scratch-migration-writer.test.ts new file mode 100644 index 00000000..ad7d6a65 --- /dev/null +++ b/test/unit/scripts/v18-scratch-migration-writer.test.ts @@ -0,0 +1,145 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + writeGraphModelMigrationScratchHistory, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; + +const execFileAsync = promisify(execFile); +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; +const LIVE_WRITER_REF = 'refs/warp/v17-golden-graph/writers/alice'; + +describe('v18 scratch migration writer', () => { + it('writes lowered operations only to an explicit scratch ref', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-'); + + const result = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + loweredOperation('node-record', 'node:a', 'node:a'), + loweredOperation('property', 'node:a\0title', 'property-target-key:length-prefixed-v1:6:node:a:5:title'), + loweredOperation('content-attachment', 'node:a\0_content', 'content-attachment:node:a:_content'), + ]), + }); + + expect(result.hasFatalErrors()).toBe(false); + expect(result.scratchRef?.refName).toBe(SCRATCH_REF); + expect(result.writtenPatches).toHaveLength(3); + expect(await gitText(repositoryPath, ['rev-list', '--count', SCRATCH_REF])).toBe('3'); + expect(await refExists(repositoryPath, LIVE_WRITER_REF)).toBe(false); + expect(await gitText(repositoryPath, ['show', `${result.scratchHead ?? ''}:migration-operation.txt`])) + .toContain('git-warp-v18-migration-operation-v1'); + }); + + it('fails before writing when no scratch target is provided', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-missing-'); + + const result = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: null, + patchPlan: patchPlan([loweredOperation('node-record', 'node:a', 'node:a')]), + }); + + expect(result.hasFatalErrors()).toBe(true); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_MISSING_SCRATCH_REF']); + expect(await listRefs(repositoryPath)).toEqual([]); + }); + + it('rejects live writer ref targets before touching Git refs', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-live-'); + + const result = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: LIVE_WRITER_REF, + patchPlan: patchPlan([loweredOperation('node-record', 'node:a', 'node:a')]), + }); + + expect(result.hasFatalErrors()).toBe(true); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_LIVE_REF_TARGET']); + expect(await listRefs(repositoryPath)).toEqual([]); + }); + + it('appends to an existing scratch ref with an expected-head update', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-append-'); + const first = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([loweredOperation('node-record', 'node:a', 'node:a')]), + }); + + const second = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([loweredOperation('node-record', 'node:b', 'node:b')]), + }); + + expect(first.scratchHead).not.toBeNull(); + expect(second.scratchHead).not.toBe(first.scratchHead); + expect(await gitText(repositoryPath, ['rev-list', '--count', SCRATCH_REF])).toBe('2'); + expect(await gitText(repositoryPath, ['rev-parse', `${second.scratchHead ?? ''}^`])) + .toBe(first.scratchHead); + }); +}); + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'source-basis', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'target-basis', + }), + operations, + }); +} + +function loweredOperation( + kind: 'node-record' | 'property' | 'content-attachment', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ + kind, + sourceKey, + targetKey, + }); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { + cwd: repositoryPath, + }); + return result.stdout.trim().length > 0; +} + +async function listRefs(repositoryPath: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)'], { + cwd: repositoryPath, + }); + return result.stdout.trim().split('\n').filter((line) => line.length > 0); +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd: repositoryPath }); + return result.stdout.trim(); +} From ba9c395943786e2432ce20c08381c584a255244b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 10:57:32 -0700 Subject: [PATCH 05/23] Feat: Gate v18 scratch migration equivalence --- CHANGELOG.md | 4 + docs/BEARING.md | 24 ++- .../v18-scratch-equivalence-gate.md | 34 ++++- .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 5 +- .../TRUST_genesis-replay-equivalence.md | 14 +- .../migrations/GenesisEquivalenceGate.ts | 70 +++++++++ .../GenesisEquivalenceGateResult.ts | 88 +++++++++++ .../migrations/GenesisEquivalenceGate.test.ts | 143 ++++++++++++++++++ 9 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 src/domain/migrations/GenesisEquivalenceGate.ts create mode 100644 src/domain/migrations/GenesisEquivalenceGateResult.ts create mode 100644 test/unit/domain/migrations/GenesisEquivalenceGate.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e2c126..e67b2465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 rejects live graph refs, writes lowered operation commits only under `refs/warp-migration-scratch/*`, and advances scratch refs with expected-head `git update-ref` calls. +- V18 genesis replay equivalence now includes a scratch promotion gate that + runs proof comparison, reports first divergence, blocks failed proofs, and + rejects otherwise-equivalent readings when patch-boundary evidence is + missing. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index cb218618..a12b61ae 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -116,6 +116,9 @@ The current v18 graph-model posture is: - An explicit scratch migration writer now writes lowered operation facts only under `refs/warp-migration-scratch/*`, rejects live graph refs, and advances scratch refs with expected-head `git update-ref` calls. +- A scratch equivalence gate now compares legacy and scratch genesis readings, + reports first divergence, and blocks promotion when proof fails or visible + facts lack patch-boundary evidence. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -254,6 +257,10 @@ explicit scratch ref, rejects live `refs/warp/*` targets before writing, creates deterministic per-operation commits, and appends with CAS-shaped `git update-ref` calls. +Slice 50 is complete on this branch. Scratch equivalence gating now wraps the +genesis proof and divergence reporter into a promotion decision, with explicit +blocking for missing patch-boundary evidence even when visible readings match. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -266,16 +273,17 @@ creates deterministic per-operation commits, and appends with CAS-shaped - Temporal replay still extracts node snapshots from the raw legacy property map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. -- The v18 migration tool can now write scratch history, but it does not yet - replay scratch output into observer-visible readings for equivalence. -- Genesis equivalence is credible as a domain vocabulary and compact fixture - proof, not yet as a real scratch-history replay gate. +- The v18 migration tool can now write scratch history and gate supplied + readings, but it does not yet replay scratch Git output into + observer-visible readings for equivalence. +- Genesis equivalence is a gate vocabulary now, but not yet a full real-history + ship gate wired through finalization. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but the scratch writer output still needs an equivalence gate. -- The next write-capable migration work must go through real source inventory, - lowering, scratch writes, equivalence gates, and finalization safety. Live - ref promotion is still out of bounds. +- The next write-capable migration work must go through finalization safety, + archive semantics, command wiring, runtime conformance, and closeout audit. + Live ref promotion is still out of bounds until those gates exist. ## Where We Are Heading @@ -390,7 +398,7 @@ and concrete checks live in `docs/invariants/`. [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). - [x] 49. Add the scratch migration writer: [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). -- [ ] 50. Add the scratch equivalence gate: +- [x] 50. Add the scratch equivalence gate: [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). - [ ] 51. Design migration finalization safety: [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). diff --git a/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md index d45b1340..d90d45fc 100644 --- a/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md +++ b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md @@ -1,11 +1,12 @@ --- cycle: 0197 task_id: V18_scratch_equivalence_gate -status: Planned +status: Completed sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 50 promotes_backlog: @@ -97,11 +98,34 @@ git diff --check HEAD - Passing proof summary is deterministic. - Finalization design has enough evidence to specify promotion semantics. +## Closeout + +Slice 50 added `GenesisEquivalenceGate` and +`GenesisEquivalenceGateResult`. The gate consumes already-formed legacy and +scratch `GenesisEquivalenceReading` values, runs `GenesisEquivalenceProof`, +and attaches the first `GenesisDivergenceReport` when proof fails. + +Promotion is allowed only when the proof succeeds and no fatal promotion +blocker exists. Missing patch-boundary evidence is now explicit: +otherwise-equivalent readings with `null` boundary facts still produce a fatal +`E_MISSING_EQUIVALENCE_BOUNDARY` blocker. + +Replay construction remains intentionally outside the gate. Later slices can +build legacy and scratch readings from real Git history without changing the +proof semantics. + +## Verification Result + +```text +npx vitest run test/unit/domain/migrations/GenesisEquivalenceGate.test.ts --reporter=verbose +npx eslint --no-warn-ignored src/domain/migrations/GenesisEquivalenceGate.ts src/domain/migrations/GenesisEquivalenceGateResult.ts test/unit/domain/migrations/GenesisEquivalenceGate.test.ts +``` + ## SSJS Scorecard -- Runtime-backed forms: green when gate outcomes are named values. -- Boundary validation: green when readings are proof nouns before comparison. -- Behavior ownership: green when proof code compares and gate code gates. +- Runtime-backed forms: green; gate outcomes are named values. +- Boundary validation: green; readings are proof nouns before comparison. +- Behavior ownership: green; proof code compares and gate code gates. - Message parsing: green; report text is never parsed as behavior. - Ambient time or entropy: green; no clocks or randomness. -- Fake shape trust or cast-cosplay: green when no assertions are introduced. +- Fake shape trust or cast-cosplay: green; no assertions were introduced. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 1ba9f194..bb643c0b 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -52,7 +52,7 @@ Remaining migration-tool work is intentionally ordered as: - slice 47: collect real source inventory from restored history (complete); - slice 48: lower dry-run planned operations (complete); - slice 49: write scratch migrated history (complete); -- slice 50: gate scratch output with genesis equivalence; +- slice 50: gate scratch output with genesis equivalence (complete); - slice 51: design finalization safety. ## Starting points diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 0ec4a324..b4aa5b5a 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -95,5 +95,6 @@ non-destructive but now has persisted-history evidence: successful dry-run plans without writing history; - scratch writing now creates deterministic operation commits under explicit `refs/warp-migration-scratch/*` refs and refuses live graph refs; -- scratch equivalence and finalization safety are planned as the next gates - before any live lineage promotion. +- scratch equivalence now gates promotion on proof success, first-divergence + reporting, and required patch-boundary evidence; +- finalization safety is the next gate before any live lineage promotion. diff --git a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md index 18cba9ff..f2d57b54 100644 --- a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +++ b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md @@ -37,9 +37,17 @@ V18 slices 42 through 44 added fixture-level proof infrastructure: - `GenesisDivergenceReporter` selects the first deterministic mismatch and reports field and patch-boundary evidence. -This is not yet the ship gate. The remaining trust work is to restore a real -v17 golden graph-history fixture, then connect legacy replay and scratch -migrated replay through slices 46 through 50. +Slice 50 added the first promotion gate over that proof vocabulary: + +- `GenesisEquivalenceGate` runs proof comparison over legacy and scratch + reading nouns; +- failed proofs carry a first divergence report; +- otherwise-equivalent readings still block promotion when visible facts lack + patch-boundary evidence. + +This is now a gate vocabulary, but not yet the complete ship gate. The +remaining trust work is to construct legacy and scratch readings from real +Git history and wire the gate into finalization. ## Starting points diff --git a/src/domain/migrations/GenesisEquivalenceGate.ts b/src/domain/migrations/GenesisEquivalenceGate.ts new file mode 100644 index 00000000..89a946aa --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceGate.ts @@ -0,0 +1,70 @@ +import GenesisDivergenceReporter from './GenesisDivergenceReporter.ts'; +import GenesisEquivalenceComparisonBasis from './GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGateResult from './GenesisEquivalenceGateResult.ts'; +import GenesisEquivalenceProof from './GenesisEquivalenceProof.ts'; +import GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import GenesisEquivalenceReading from './GenesisEquivalenceReading.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const MISSING_EQUIVALENCE_BOUNDARY_CODE = 'E_MISSING_EQUIVALENCE_BOUNDARY'; + +/** Gates scratch migration promotion on genesis replay equivalence. */ +export default class GenesisEquivalenceGate { + /** Compares legacy and scratch readings and returns promotion evidence. */ + evaluate( + basis: GenesisEquivalenceComparisonBasis, + legacyReading: GenesisEquivalenceReading, + scratchReading: GenesisEquivalenceReading, + ): GenesisEquivalenceGateResult { + const checkedBasis = requireBasis(basis); + const checkedLegacy = requireReading(legacyReading, 'legacyReading'); + const checkedScratch = requireReading(scratchReading, 'scratchReading'); + const proofResult = new GenesisEquivalenceProof().compare( + checkedBasis, + checkedLegacy, + checkedScratch, + ); + const divergenceReport = proofResult instanceof GenesisEquivalenceProofFailure + ? new GenesisDivergenceReporter().report(proofResult) + : null; + + return new GenesisEquivalenceGateResult({ + proofResult, + divergenceReport, + fatalErrors: collectBoundaryFatalErrors(checkedLegacy, checkedScratch), + }); + } +} + +function collectBoundaryFatalErrors( + legacyReading: GenesisEquivalenceReading, + scratchReading: GenesisEquivalenceReading, +): readonly GraphModelMigrationNotice[] { + const missing = legacyReading.facts + .concat(scratchReading.facts) + .filter((fact) => fact.boundary === null); + if (missing.length === 0) { + return Object.freeze([]); + } + return Object.freeze([ + GraphModelMigrationNotice.fatal( + MISSING_EQUIVALENCE_BOUNDARY_CODE, + `genesis equivalence gate requires boundary evidence for ${missing.length} visible fact(s)`, + ), + ]); +} + +function requireBasis(basis: GenesisEquivalenceComparisonBasis): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new WarpError('basis must be a GenesisEquivalenceComparisonBasis', 'E_VALIDATION'); + } + return basis; +} + +function requireReading(reading: GenesisEquivalenceReading, label: string): GenesisEquivalenceReading { + if (!(reading instanceof GenesisEquivalenceReading)) { + throw new WarpError(`${label} must be a GenesisEquivalenceReading`, 'E_VALIDATION'); + } + return reading; +} diff --git a/src/domain/migrations/GenesisEquivalenceGateResult.ts b/src/domain/migrations/GenesisEquivalenceGateResult.ts new file mode 100644 index 00000000..6ad347fa --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceGateResult.ts @@ -0,0 +1,88 @@ +import GenesisDivergenceReport from './GenesisDivergenceReport.ts'; +import GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import type { GenesisEquivalenceProofResult } from './GenesisEquivalenceProofResult.ts'; +import GenesisEquivalenceProofSuccess from './GenesisEquivalenceProofSuccess.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceGateResultFields = { + readonly proofResult: GenesisEquivalenceProofResult; + readonly divergenceReport: GenesisDivergenceReport | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Promotion gate result for scratch migration genesis equivalence. */ +export default class GenesisEquivalenceGateResult { + readonly proofResult: GenesisEquivalenceProofResult; + readonly divergenceReport: GenesisDivergenceReport | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GenesisEquivalenceGateResultFields) { + const checkedFields = requireFields(fields); + this.proofResult = requireProofResult(checkedFields.proofResult); + this.divergenceReport = requireOptionalDivergenceReport(checkedFields.divergenceReport); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireReportMatchesProof(this.proofResult, this.divergenceReport); + Object.freeze(this); + } + + /** Returns true only when equivalence passed and no promotion blocker exists. */ + allowsPromotion(): boolean { + return this.proofResult instanceof GenesisEquivalenceProofSuccess + && this.fatalErrors.length === 0; + } +} + +function requireFields( + fields: GenesisEquivalenceGateResultFields | null | undefined, +): GenesisEquivalenceGateResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceGateResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireProofResult(result: GenesisEquivalenceProofResult): GenesisEquivalenceProofResult { + if (!(result instanceof GenesisEquivalenceProofSuccess) + && !(result instanceof GenesisEquivalenceProofFailure)) { + throw new WarpError('proofResult must be a genesis equivalence proof result', 'E_VALIDATION'); + } + return result; +} + +function requireOptionalDivergenceReport( + report: GenesisDivergenceReport | null, +): GenesisDivergenceReport | null { + if (report !== null && !(report instanceof GenesisDivergenceReport)) { + throw new WarpError('divergenceReport must be a GenesisDivergenceReport or null', 'E_VALIDATION'); + } + return report; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireReportMatchesProof( + proofResult: GenesisEquivalenceProofResult, + report: GenesisDivergenceReport | null, +): void { + if (proofResult instanceof GenesisEquivalenceProofFailure && report === null) { + throw new WarpError('failed gate results must include a divergence report', 'E_VALIDATION'); + } + if (proofResult instanceof GenesisEquivalenceProofSuccess && report !== null) { + throw new WarpError('successful gate results must not include a divergence report', 'E_VALIDATION'); + } +} diff --git a/test/unit/domain/migrations/GenesisEquivalenceGate.test.ts b/test/unit/domain/migrations/GenesisEquivalenceGate.test.ts new file mode 100644 index 00000000..364746f8 --- /dev/null +++ b/test/unit/domain/migrations/GenesisEquivalenceGate.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GenesisEquivalenceProofFailure + from '../../../../src/domain/migrations/GenesisEquivalenceProofFailure.ts'; +import GenesisEquivalenceProofSuccess + from '../../../../src/domain/migrations/GenesisEquivalenceProofSuccess.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + type GenesisEquivalenceReadingFactKind, +} from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from './GenesisEquivalenceFixtures.ts'; + +describe('GenesisEquivalenceGate', () => { + it('allows promotion when legacy and scratch readings prove equivalent', () => { + const fixture = nodeLifecycleFixture(); + + const result = gate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); + + expect(result.allowsPromotion()).toBe(true); + expect(result.proofResult).toBeInstanceOf(GenesisEquivalenceProofSuccess); + expect(result.divergenceReport).toBeNull(); + expect(result.fatalErrors).toEqual([]); + expect(result.proofResult.summary.legacyFactCount).toBe(2); + expect(result.proofResult.summary.migratedFactCount).toBe(2); + expect(result.proofResult.summary.mismatchCount).toBe(0); + }); + + it('blocks promotion and reports the first divergent property fact', () => { + const fixture = divergentPropertyFixture(); + + const result = gate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); + + expect(result.allowsPromotion()).toBe(false); + expect(result.proofResult).toBeInstanceOf(GenesisEquivalenceProofFailure); + expect(result.divergenceReport?.mismatchKind).toBe('changed'); + expect(result.divergenceReport?.factKind).toBe('property'); + expect(result.divergenceReport?.factKey).toBe('node:article/title'); + expect(result.divergenceReport?.legacyValueSummary).toBe('Legacy'); + expect(result.divergenceReport?.migratedValueSummary).toBe('Migrated'); + }); + + it('blocks promotion and reports divergent content attachment facts', () => { + const result = gate().evaluate( + basis(), + reading('legacy:content', [ + fact('content-attachment', 'node:article', 'payload.oid', 'oid:legacy', boundary('writer:a', 'patch:a:2', 0)), + ]), + reading('scratch:content', [ + fact('content-attachment', 'node:article', 'payload.oid', 'oid:scratch', boundary('writer:a', 'patch:a:2', 0)), + ]), + ); + + expect(result.allowsPromotion()).toBe(false); + expect(result.divergenceReport?.factKind).toBe('content-attachment'); + expect(result.divergenceReport?.fieldPath).toBe('payload.oid'); + expect(result.divergenceReport?.legacyValueSummary).toBe('oid:legacy'); + expect(result.divergenceReport?.migratedValueSummary).toBe('oid:scratch'); + }); + + it('blocks otherwise equivalent readings when boundary evidence is missing', () => { + const legacy = reading('legacy:missing-boundary', [ + fact('node', 'node:orphan', 'visibility', 'visible', null), + ]); + const scratch = reading('scratch:missing-boundary', [ + fact('node', 'node:orphan', 'visibility', 'visible', null), + ]); + + const result = gate().evaluate(basis(), legacy, scratch); + + expect(result.proofResult).toBeInstanceOf(GenesisEquivalenceProofSuccess); + expect(result.allowsPromotion()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_MISSING_EQUIVALENCE_BOUNDARY', + ]); + expect(result.fatalErrors[0]?.message).toContain('2 visible fact(s)'); + }); +}); + +function gate(): GenesisEquivalenceGate { + return new GenesisEquivalenceGate(); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:scratch', + }), + }); +} + +function reading( + readingId: string, + facts: readonly GenesisEquivalenceReadingFact[], +): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ readingId, facts }); +} + +function fact( + kind: GenesisEquivalenceReadingFactKind, + factKey: string, + fieldPath: string, + value: string, + factBoundary: GenesisEquivalenceBoundary | null, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind, + factKey, + fieldPath, + value, + boundary: factBoundary, + }); +} + +function boundary( + writerId: string, + patchId: string, + operationIndex: number, +): GenesisEquivalenceBoundary { + return new GenesisEquivalenceBoundary({ writerId, patchId, operationIndex }); +} From c727b9e019a74bedc0b1165e117518ea248617e3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:02:13 -0700 Subject: [PATCH 06/23] Feat: Add v18 migration finalization safety gate --- CHANGELOG.md | 4 + docs/BEARING.md | 20 ++- .../v18-migration-finalization-safety.md | 50 +++++- .../INFRA_graph-model-migration-tool.md | 6 +- docs/method/backlog/v18.0.0/README.md | 4 +- .../GraphModelMigrationArchiveRef.ts | 102 ++++++++++++ ...hModelMigrationFinalizationConfirmation.ts | 38 +++++ .../GraphModelMigrationFinalizationRequest.ts | 94 +++++++++++ .../GraphModelMigrationFinalizationSafety.ts | 115 ++++++++++++++ ...hModelMigrationFinalizationSafetyResult.ts | 63 ++++++++ ...phModelMigrationFinalizationSafety.test.ts | 149 ++++++++++++++++++ 11 files changed, 631 insertions(+), 14 deletions(-) create mode 100644 src/domain/migrations/GraphModelMigrationArchiveRef.ts create mode 100644 src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts create mode 100644 src/domain/migrations/GraphModelMigrationFinalizationRequest.ts create mode 100644 src/domain/migrations/GraphModelMigrationFinalizationSafety.ts create mode 100644 src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts create mode 100644 test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e67b2465..26148a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 runs proof comparison, reports first divergence, blocks failed proofs, and rejects otherwise-equivalent readings when patch-boundary evidence is missing. +- V18 graph-model migration finalization now has a pure safety protocol that + requires explicit confirmation, passed scratch equivalence, archive ref + selection, scratch output evidence, and matching live-ref expected head + evidence before any live ref update can be attempted. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index a12b61ae..15aafdc4 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -119,6 +119,9 @@ The current v18 graph-model posture is: - A scratch equivalence gate now compares legacy and scratch genesis readings, reports first divergence, and blocks promotion when proof fails or visible facts lack patch-boundary evidence. +- Finalization safety is now modeled as pure domain evidence: explicit + confirmation, passed equivalence gate, archive ref target, scratch output, + and live-ref expected-head match are required before live refs can move. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -261,6 +264,11 @@ Slice 50 is complete on this branch. Scratch equivalence gating now wraps the genesis proof and divergence reporter into a promotion decision, with explicit blocking for missing patch-boundary evidence even when visible readings match. +Slice 51 is complete on this branch. Finalization safety now exists as a pure +precondition gate: no confirmation, failed equivalence, missing archive target, +missing scratch output, or stale live-ref expectation can pass into a future +live-ref update step. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -281,9 +289,9 @@ blocking for missing patch-boundary evidence even when visible readings match. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but the scratch writer output still needs an equivalence gate. -- The next write-capable migration work must go through finalization safety, - archive semantics, command wiring, runtime conformance, and closeout audit. - Live ref promotion is still out of bounds until those gates exist. +- The next write-capable migration work must implement archive-preserving CAS + finalization, command wiring, runtime conformance, and closeout audit. Live + ref promotion is still out of bounds until the Git updater is covered. ## Where We Are Heading @@ -400,5 +408,9 @@ and concrete checks live in `docs/invariants/`. [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). - [x] 50. Add the scratch equivalence gate: [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). -- [ ] 51. Design migration finalization safety: +- [x] 51. Design migration finalization safety: [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). +- [ ] 52. Implement archive-preserving migration finalization. +- [ ] 53. Wire the end-to-end migration command. +- [ ] 54. Prove post-migration runtime conformance. +- [ ] 55. Close the content/property migration audit. diff --git a/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md index 63fc5578..834c8a4e 100644 --- a/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md +++ b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md @@ -1,11 +1,12 @@ --- cycle: 0198 task_id: V18_migration_finalization_safety -status: Planned +status: Completed sponsors: human: James agent: Codex started_at: 2026-05-24 +completed_at: 2026-05-24 release_home: v18.0.0 bearing_task: 51 promotes_backlog: @@ -85,7 +86,7 @@ remain ambiguous. ## Verification ```text -npx vitest run test/unit/scripts/v18-migration-finalization-safety.test.ts --reporter=verbose +npx vitest run test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts --reporter=verbose npm run typecheck npm run lint:semgrep npm run lint:sludge @@ -99,12 +100,45 @@ git diff --check HEAD - No destructive defaults exist. - v18 release readiness can be assessed from migration evidence. +## Closeout + +Slice 51 implemented the finalization safety protocol as pure domain values, +not as live Git ref mutation. `GraphModelMigrationFinalizationRequest` names +the required finalization evidence: live ref, expected and observed live head, +scratch ref/head, archive ref target, operator confirmation, and equivalence +gate result. + +`GraphModelMigrationFinalizationSafety` blocks finalization unless all of the +following are true: + +- the live ref is under `refs/warp/*`; +- the explicit confirmation token is present; +- the scratch equivalence gate allows promotion; +- the archive ref is under `refs/warp-migration-archive/*`; +- scratch ref and scratch head evidence are present; +- the observed live head matches the expected live head. + +No force switch exists on the request shape. Slice 52 can now implement the +Git update step against these preconditions instead of inventing its own +ad-hoc finalization rules. + +## Verification Result + +```text +npx vitest run test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts --reporter=verbose +npx eslint --no-warn-ignored src/domain/migrations/GraphModelMigrationArchiveRef.ts src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts src/domain/migrations/GraphModelMigrationFinalizationRequest.ts src/domain/migrations/GraphModelMigrationFinalizationSafety.ts src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts +npm run typecheck +npm run lint:sludge +npm run lint:semgrep +``` + ## SSJS Scorecard -- Runtime-backed forms: green when finalization requests/results are named. -- Boundary validation: green when confirmation and gate proof are validated. -- Behavior ownership: green when finalization only finalizes. +- Runtime-backed forms: green; finalization request, confirmation, archive ref, + and safety result are named values. +- Boundary validation: green; confirmation and gate proof are validated before + Git mutation exists. +- Behavior ownership: green; safety only decides, later finalization finalizes. - Message parsing: green; no confirmation through parsed prose. -- Ambient time or entropy: green when audit identifiers are deterministic or - injected. -- Fake shape trust or cast-cosplay: green when ref outcomes are typed. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green; ref outcomes and blockers are typed. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index bb643c0b..e792aa51 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -53,7 +53,11 @@ Remaining migration-tool work is intentionally ordered as: - slice 48: lower dry-run planned operations (complete); - slice 49: write scratch migrated history (complete); - slice 50: gate scratch output with genesis equivalence (complete); -- slice 51: design finalization safety. +- slice 51: design finalization safety (complete); +- slice 52: implement archive-preserving finalization; +- slice 53: wire the end-to-end migration command; +- slice 54: prove post-migration runtime conformance; +- slice 55: close the content/property migration audit. ## Starting points diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index b4aa5b5a..b0bc0118 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -97,4 +97,6 @@ non-destructive but now has persisted-history evidence: `refs/warp-migration-scratch/*` refs and refuses live graph refs; - scratch equivalence now gates promotion on proof success, first-divergence reporting, and required patch-boundary evidence; -- finalization safety is the next gate before any live lineage promotion. +- finalization safety now requires explicit confirmation, archive ref + selection, scratch output evidence, a passed equivalence gate, and a matching + live-ref expected head before any live lineage promotion can be implemented. diff --git a/src/domain/migrations/GraphModelMigrationArchiveRef.ts b/src/domain/migrations/GraphModelMigrationArchiveRef.ts new file mode 100644 index 00000000..75e47148 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationArchiveRef.ts @@ -0,0 +1,102 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const ARCHIVE_REF_PREFIX = 'refs/warp-migration-archive/'; +const LIVE_WARP_REF_PREFIX = 'refs/warp/'; +const MISSING_ARCHIVE_REF_CODE = 'E_MISSING_ARCHIVE_REF'; +const LIVE_ARCHIVE_REF_TARGET_CODE = 'E_LIVE_ARCHIVE_REF_TARGET'; +const INVALID_ARCHIVE_REF_CODE = 'E_INVALID_ARCHIVE_REF'; +const INVALID_REF_CHARACTERS = Object.freeze(new Set(['~', '^', ':', '?', '*', '[', '\\'])); + +export type GraphModelMigrationArchiveRefFields = { + readonly refName: string; +}; + +/** Explicit archive ref target for preserved pre-migration lineage. */ +export default class GraphModelMigrationArchiveRef { + readonly refName: string; + + constructor(fields: GraphModelMigrationArchiveRefFields) { + const checkedFields = requireFields(fields); + const notice = GraphModelMigrationArchiveRef.validateRefName(checkedFields.refName); + if (notice !== null) { + throw new WarpError(notice.message, notice.code); + } + this.refName = checkedFields.refName; + Object.freeze(this); + } + + /** Validates an archive ref target without constructing one. */ + static validateRefName(refName: string | null): GraphModelMigrationNotice | null { + if (refName === null || refName.length === 0) { + return GraphModelMigrationNotice.fatal( + MISSING_ARCHIVE_REF_CODE, + 'migration finalization requires an explicit archive ref target', + ); + } + const prefixNotice = validateRefPrefix(refName); + return prefixNotice ?? validateRefShape(refName); + } +} + +function requireFields( + fields: GraphModelMigrationArchiveRefFields | null | undefined, +): GraphModelMigrationArchiveRefFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationArchiveRef fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function validateRefPrefix(refName: string): GraphModelMigrationNotice | null { + if (refName.startsWith(LIVE_WARP_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + LIVE_ARCHIVE_REF_TARGET_CODE, + `archive ref must not target live graph ref ${refName}`, + ); + } + if (!refName.startsWith(ARCHIVE_REF_PREFIX)) { + return GraphModelMigrationNotice.fatal( + INVALID_ARCHIVE_REF_CODE, + `archive ref must start with ${ARCHIVE_REF_PREFIX}`, + ); + } + return null; +} + +function validateRefShape(refName: string): GraphModelMigrationNotice | null { + if (!hasInvalidRefShape(refName)) { + return null; + } + return GraphModelMigrationNotice.fatal( + INVALID_ARCHIVE_REF_CODE, + `archive ref has invalid shape ${refName}`, + ); +} + +function hasInvalidRefShape(refName: string): boolean { + const suffix = refName.slice(ARCHIVE_REF_PREFIX.length); + return [ + suffix.length === 0, + suffix.startsWith('/'), + suffix.endsWith('/'), + refName.includes('//'), + refName.includes('..'), + refName.trim() !== refName, + containsInvalidRefCharacter(refName), + ].some((invalid) => invalid); +} + +function containsInvalidRefCharacter(refName: string): boolean { + for (const character of refName) { + if (isInvalidRefCharacter(character)) { + return true; + } + } + return false; +} + +function isInvalidRefCharacter(character: string): boolean { + const code = character.charCodeAt(0); + return code <= 32 || code === 127 || INVALID_REF_CHARACTERS.has(character); +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts b/src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts new file mode 100644 index 00000000..7fc5563e --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts @@ -0,0 +1,38 @@ +import WarpError from '../errors/WarpError.ts'; + +export const V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION = + 'CONFIRM_V18_GRAPH_MODEL_MIGRATION_FINALIZATION'; + +export type GraphModelMigrationFinalizationConfirmationFields = { + readonly token: string; +}; + +/** Explicit operator confirmation for graph-model migration finalization. */ +export default class GraphModelMigrationFinalizationConfirmation { + readonly token: string; + + constructor(fields: GraphModelMigrationFinalizationConfirmationFields) { + const checkedFields = requireFields(fields); + this.token = requireFinalizationToken(checkedFields.token); + Object.freeze(this); + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationConfirmationFields | null | undefined, +): GraphModelMigrationFinalizationConfirmationFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationFinalizationConfirmation fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireFinalizationToken(token: string): string { + if (token !== V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION) { + throw new WarpError('finalization confirmation token is invalid', 'E_VALIDATION'); + } + return token; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts b/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts new file mode 100644 index 00000000..d0eebccf --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts @@ -0,0 +1,94 @@ +import GenesisEquivalenceGateResult from './GenesisEquivalenceGateResult.ts'; +import GraphModelMigrationFinalizationConfirmation + from './GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationFinalizationRequestFields = { + readonly liveRefName: string; + readonly expectedLiveHead: string | null; + readonly observedLiveHead: string | null; + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly archiveRefName: string | null; + readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; + readonly gateResult: GenesisEquivalenceGateResult | null; +}; + +/** Pure finalization request envelope; it does not move Git refs. */ +export default class GraphModelMigrationFinalizationRequest { + readonly liveRefName: string; + readonly expectedLiveHead: string | null; + readonly observedLiveHead: string | null; + readonly scratchRef: GraphModelMigrationScratchRef | null; + readonly scratchHead: string | null; + readonly archiveRefName: string | null; + readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; + readonly gateResult: GenesisEquivalenceGateResult | null; + + constructor(fields: GraphModelMigrationFinalizationRequestFields) { + const checkedFields = requireFields(fields); + this.liveRefName = requireNonEmptyString(checkedFields.liveRefName, 'liveRefName'); + this.expectedLiveHead = requireOptionalString(checkedFields.expectedLiveHead, 'expectedLiveHead'); + this.observedLiveHead = requireOptionalString(checkedFields.observedLiveHead, 'observedLiveHead'); + this.scratchRef = requireOptionalScratchRef(checkedFields.scratchRef); + this.scratchHead = requireOptionalString(checkedFields.scratchHead, 'scratchHead'); + this.archiveRefName = requireOptionalString(checkedFields.archiveRefName, 'archiveRefName'); + this.confirmation = requireOptionalConfirmation(checkedFields.confirmation); + this.gateResult = requireOptionalGateResult(checkedFields.gateResult); + Object.freeze(this); + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationRequestFields | null | undefined, +): GraphModelMigrationFinalizationRequestFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationFinalizationRequest fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireOptionalString(value: string | null, name: string): string | null { + if (value !== null && (typeof value !== 'string' || value.length === 0)) { + throw new WarpError(`${name} must be a non-empty string or null`, 'E_VALIDATION'); + } + return value; +} + +function requireOptionalScratchRef( + scratchRef: GraphModelMigrationScratchRef | null, +): GraphModelMigrationScratchRef | null { + if (scratchRef !== null && !(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef or null', 'E_VALIDATION'); + } + return scratchRef; +} + +function requireOptionalConfirmation( + confirmation: GraphModelMigrationFinalizationConfirmation | null, +): GraphModelMigrationFinalizationConfirmation | null { + if (confirmation !== null && !(confirmation instanceof GraphModelMigrationFinalizationConfirmation)) { + throw new WarpError( + 'confirmation must be a GraphModelMigrationFinalizationConfirmation or null', + 'E_VALIDATION', + ); + } + return confirmation; +} + +function requireOptionalGateResult( + gateResult: GenesisEquivalenceGateResult | null, +): GenesisEquivalenceGateResult | null { + if (gateResult !== null && !(gateResult instanceof GenesisEquivalenceGateResult)) { + throw new WarpError('gateResult must be a GenesisEquivalenceGateResult or null', 'E_VALIDATION'); + } + return gateResult; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts b/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts new file mode 100644 index 00000000..f793f64c --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts @@ -0,0 +1,115 @@ +import GraphModelMigrationArchiveRef from './GraphModelMigrationArchiveRef.ts'; +import GraphModelMigrationFinalizationRequest from './GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafetyResult from './GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +const LIVE_REF_PREFIX = 'refs/warp/'; + +/** Pure finalization safety gate before any live Git ref update can run. */ +export default class GraphModelMigrationFinalizationSafety { + /** Evaluates finalization preconditions without mutating Git history. */ + evaluate(request: GraphModelMigrationFinalizationRequest): GraphModelMigrationFinalizationSafetyResult { + const checkedRequest = requireRequest(request); + return new GraphModelMigrationFinalizationSafetyResult({ + request: checkedRequest, + fatalErrors: collectFatalErrors(checkedRequest), + }); + } +} + +function collectFatalErrors( + request: GraphModelMigrationFinalizationRequest, +): readonly GraphModelMigrationNotice[] { + return Object.freeze([ + validateLiveRef(request.liveRefName), + validateConfirmation(request), + validateGateResult(request), + validateArchiveRef(request.archiveRefName), + validateScratchOutput(request), + validateLiveHeadExpectation(request), + ].filter((notice) => notice !== null)); +} + +function validateLiveRef(liveRefName: string): GraphModelMigrationNotice | null { + if (liveRefName.startsWith(LIVE_REF_PREFIX)) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_INVALID_LIVE_REF', + `finalization live ref must start with ${LIVE_REF_PREFIX}`, + ); +} + +function validateConfirmation( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.confirmation !== null) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_MISSING_FINALIZATION_CONFIRMATION', + 'migration finalization requires explicit operator confirmation', + ); +} + +function validateGateResult( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.gateResult !== null && request.gateResult.allowsPromotion()) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_EQUIVALENCE_GATE_NOT_PASSED', + 'migration finalization requires a passed scratch equivalence gate', + ); +} + +function validateArchiveRef(archiveRefName: string | null): GraphModelMigrationNotice | null { + return GraphModelMigrationArchiveRef.validateRefName(archiveRefName); +} + +function validateScratchOutput( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.scratchRef !== null && request.scratchHead !== null) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_MISSING_SCRATCH_OUTPUT', + 'migration finalization requires scratch ref and scratch head evidence', + ); +} + +function validateLiveHeadExpectation( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.expectedLiveHead === null) { + return GraphModelMigrationNotice.fatal( + 'E_MISSING_EXPECTED_LIVE_HEAD', + 'migration finalization requires an expected live ref head', + ); + } + if (request.observedLiveHead === null) { + return GraphModelMigrationNotice.fatal( + 'E_MISSING_OBSERVED_LIVE_HEAD', + 'migration finalization requires observed live ref head evidence', + ); + } + if (request.expectedLiveHead === request.observedLiveHead) { + return null; + } + return GraphModelMigrationNotice.fatal( + 'E_STALE_LIVE_REF_EXPECTATION', + 'migration finalization live ref expectation is stale', + ); +} + +function requireRequest( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationFinalizationRequest { + if (!(request instanceof GraphModelMigrationFinalizationRequest)) { + throw new WarpError('request must be a GraphModelMigrationFinalizationRequest', 'E_VALIDATION'); + } + return request; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts b/src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts new file mode 100644 index 00000000..3048e89b --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts @@ -0,0 +1,63 @@ +import GraphModelMigrationFinalizationRequest from './GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationFinalizationSafetyResultFields = { + readonly request: GraphModelMigrationFinalizationRequest; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Pure safety decision for graph-model migration finalization. */ +export default class GraphModelMigrationFinalizationSafetyResult { + readonly request: GraphModelMigrationFinalizationRequest; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationFinalizationSafetyResultFields) { + const checkedFields = requireFields(fields); + this.request = requireRequest(checkedFields.request); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + Object.freeze(this); + } + + /** Returns true when finalization may move to the Git ref update step. */ + allowsFinalization(): boolean { + return this.fatalErrors.length === 0; + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationSafetyResultFields | null | undefined, +): GraphModelMigrationFinalizationSafetyResultFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationFinalizationSafetyResult fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireRequest( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationFinalizationRequest { + if (!(request instanceof GraphModelMigrationFinalizationRequest)) { + throw new WarpError('request must be a GraphModelMigrationFinalizationRequest', 'E_VALIDATION'); + } + return request; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} diff --git a/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts b/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts new file mode 100644 index 00000000..b7ddfa26 --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GenesisEquivalenceGateResult + from '../../../../src/domain/migrations/GenesisEquivalenceGateResult.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from './GenesisEquivalenceFixtures.ts'; + +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/writers/alice'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; +const LIVE_HEAD = '1111111111111111111111111111111111111111'; +const STALE_HEAD = '2222222222222222222222222222222222222222'; +const SCRATCH_HEAD = '3333333333333333333333333333333333333333'; + +describe('GraphModelMigrationFinalizationSafety', () => { + it('allows finalization only when every safety precondition is present', () => { + const result = safety().evaluate(completeRequest()); + + expect(result.allowsFinalization()).toBe(true); + expect(result.fatalErrors).toEqual([]); + }); + + it('rejects finalization without explicit confirmation', () => { + const result = safety().evaluate(completeRequest({ + confirmation: null, + })); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_MISSING_FINALIZATION_CONFIRMATION', + ]); + }); + + it('rejects finalization when scratch equivalence did not pass', () => { + const result = safety().evaluate(completeRequest({ + gateResult: failedGateResult(), + })); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_EQUIVALENCE_GATE_NOT_PASSED', + ]); + }); + + it('requires an explicit archive ref target outside live graph refs', () => { + const missing = safety().evaluate(completeRequest({ + archiveRefName: null, + })); + const live = safety().evaluate(completeRequest({ + archiveRefName: LIVE_REF, + })); + + expect(missing.fatalErrors.map((notice) => notice.code)).toEqual(['E_MISSING_ARCHIVE_REF']); + expect(live.fatalErrors.map((notice) => notice.code)).toEqual(['E_LIVE_ARCHIVE_REF_TARGET']); + }); + + it('fails closed when the live ref expected head is stale', () => { + const result = safety().evaluate(completeRequest({ + observedLiveHead: STALE_HEAD, + })); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_STALE_LIVE_REF_EXPECTATION', + ]); + }); + + it('has no force mode on the finalization request shape', () => { + const request = completeRequest(); + + expect('force' in request).toBe(false); + expect(Object.keys(request)).not.toContain('force'); + }); +}); + +function safety(): GraphModelMigrationFinalizationSafety { + return new GraphModelMigrationFinalizationSafety(); +} + +function completeRequest(overrides: { + readonly expectedLiveHead?: string | null; + readonly observedLiveHead?: string | null; + readonly archiveRefName?: string | null; + readonly confirmation?: GraphModelMigrationFinalizationConfirmation | null; + readonly gateResult?: GenesisEquivalenceGateResult | null; +} = {}): GraphModelMigrationFinalizationRequest { + return new GraphModelMigrationFinalizationRequest({ + liveRefName: LIVE_REF, + expectedLiveHead: overrides.expectedLiveHead === undefined ? LIVE_HEAD : overrides.expectedLiveHead, + observedLiveHead: overrides.observedLiveHead === undefined ? LIVE_HEAD : overrides.observedLiveHead, + scratchRef: new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }), + scratchHead: SCRATCH_HEAD, + archiveRefName: overrides.archiveRefName === undefined ? ARCHIVE_REF : overrides.archiveRefName, + confirmation: overrides.confirmation === undefined ? confirmation() : overrides.confirmation, + gateResult: overrides.gateResult === undefined ? passedGateResult() : overrides.gateResult, + }); +} + +function confirmation(): GraphModelMigrationFinalizationConfirmation { + return new GraphModelMigrationFinalizationConfirmation({ + token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + }); +} + +function passedGateResult(): GenesisEquivalenceGateResult { + const fixture = nodeLifecycleFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function failedGateResult(): GenesisEquivalenceGateResult { + const fixture = divergentPropertyFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:scratch', + }), + }); +} From da500ca18483b842cd85e486969eb0f0ff316d90 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:07:18 -0700 Subject: [PATCH 07/23] Feat: Finalize v18 migration with archive refs --- CHANGELOG.md | 4 + docs/BEARING.md | 17 +- ...8-migration-finalization-implementation.md | 118 ++++++++++ .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 4 +- .../graph-model/GitMigrationCommandRunner.ts | 73 +++++++ .../GraphModelMigrationFinalizer.ts | 159 ++++++++++++++ .../GraphModelMigrationScratchWriter.ts | 72 +----- .../GraphModelMigrationFinalizationResult.ts | 116 ++++++++++ .../scripts/v18-migration-finalizer.test.ts | 205 ++++++++++++++++++ 10 files changed, 704 insertions(+), 66 deletions(-) create mode 100644 docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md create mode 100644 scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts create mode 100644 src/domain/migrations/GraphModelMigrationFinalizationResult.ts create mode 100644 test/unit/scripts/v18-migration-finalizer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 26148a3a..3846c903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 requires explicit confirmation, passed scratch equivalence, archive ref selection, scratch output evidence, and matching live-ref expected head evidence before any live ref update can be attempted. +- V18 graph-model migration finalization now includes an archive-preserving + Git updater that blocks failed safety results, rejects live-ref drift, + refuses existing archive refs, archives old lineage, and advances live refs + through expected-head `git update-ref` calls. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 15aafdc4..e2bba0ac 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -122,6 +122,9 @@ The current v18 graph-model posture is: - Finalization safety is now modeled as pure domain evidence: explicit confirmation, passed equivalence gate, archive ref target, scratch output, and live-ref expected-head match are required before live refs can move. +- Archive-preserving finalization now exists as an adapter-layer Git updater: + it refuses failed safety results, rejects live-ref drift, creates an archive + ref for old lineage, and advances the live ref with expected-head CAS. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -269,6 +272,11 @@ precondition gate: no confirmation, failed equivalence, missing archive target, missing scratch output, or stale live-ref expectation can pass into a future live-ref update step. +Slice 52 is complete on this branch. Finalization implementation now archives +the old live head under `refs/warp-migration-archive/*` and advances the live +ref to the scratch head with `git update-ref `, while +blocking failed safety, existing archive refs, and live-ref drift. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -289,9 +297,9 @@ live-ref update step. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but the scratch writer output still needs an equivalence gate. -- The next write-capable migration work must implement archive-preserving CAS - finalization, command wiring, runtime conformance, and closeout audit. Live - ref promotion is still out of bounds until the Git updater is covered. +- The next migration work must wire command orchestration, runtime + conformance, and closeout audit. Live ref promotion now has a covered updater + but is still not exposed through an operator command. ## Where We Are Heading @@ -410,7 +418,8 @@ and concrete checks live in `docs/invariants/`. [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). - [x] 51. Design migration finalization safety: [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). -- [ ] 52. Implement archive-preserving migration finalization. +- [x] 52. Implement archive-preserving migration finalization: + [0200](design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md). - [ ] 53. Wire the end-to-end migration command. - [ ] 54. Prove post-migration runtime conformance. - [ ] 55. Close the content/property migration audit. diff --git a/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md b/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md new file mode 100644 index 00000000..aa1394bd --- /dev/null +++ b/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md @@ -0,0 +1,118 @@ +--- +cycle: 0200 +task_id: V18_migration_finalization_implementation +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 52 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Finalization Implementation + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Implement the archive-preserving live-ref update step for safety-approved +scratch migration output. + +## Playback Questions + +- Does finalization refuse to run when the safety gate is not green? +- Does it create an archive ref before changing the live ref? +- Does it reject pre-existing archive refs instead of overwriting them? +- Does it compare the live ref with the expected head immediately before + archive creation? +- Does it advance the live ref with compare-and-swap, never force? + +## Existing Shape + +Slice 51 named finalization preconditions in pure domain values. The next +step can mutate Git refs only after receiving a passed +`GraphModelMigrationFinalizationSafetyResult`. + +## Chosen Boundary + +Add an adapter-layer finalizer under +`scripts/v18.0.0/migrations/graph-model/`. The finalizer receives an explicit +repository path and a safety result. If the safety result blocks +finalization, it returns a blocked result without touching Git. + +For approved finalization: + +1. Re-read the live ref and require it to match the expected head. +2. Require the archive ref to be absent. +3. Create the archive ref with `git update-ref `. +4. Advance the live ref with `git update-ref `. + +## Non-Goals + +- Do not infer safety from command-line flags. +- Do not force-update refs. +- Do not delete old lineage. +- Do not implement the end-to-end command in this slice. +- Do not claim migrated runtime conformance yet. + +## RED Plan + +Add finalizer tests: + +- approved safety archives old live head and advances live ref; +- failed safety leaves archive and live refs untouched; +- existing archive ref blocks finalization; +- live-ref drift blocks before archive creation. + +## GREEN Plan + +Share a shell-free Git command runner with the scratch writer, then implement +the finalizer as a narrow adapter over `git update-ref`. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-scratch-migration-writer.test.ts --reporter=verbose +npx eslint --no-warn-ignored scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts src/domain/migrations/GraphModelMigrationFinalizationResult.ts test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-scratch-migration-writer.test.ts +npm run typecheck +npm run lint:sludge +npm run lint:semgrep +``` + +## Closeout Criteria + +- Archive ref creation is covered. +- Live ref advancement is covered. +- Stale live ref expectations are covered. +- No force or delete path exists. + +## Closeout + +Slice 52 added `GraphModelMigrationFinalizationResult` and +`finalizeGraphModelMigration`. The finalizer short-circuits blocked safety +results, rejects live-ref drift before archive creation, rejects existing +archive refs, creates the archive ref with a zero-old compare-and-swap, and +advances the live ref with expected-head compare-and-swap. + +The scratch writer now shares `GitMigrationCommandRunner` with the finalizer, +keeping Git subprocess execution shell-free and centralized for the v18 +migration scripts. + +## SSJS Scorecard + +- Runtime-backed forms: green; finalization result status is a named union. +- Boundary validation: green; only safety-approved requests can mutate refs. +- Behavior ownership: green; finalizer mutates refs and safety decides safety. +- Message parsing: green; no behavior parses prose output. +- Ambient time or entropy: green; finalizer does not create commits. +- Fake shape trust or cast-cosplay: green; tests use real Git refs. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index e792aa51..e7ef5656 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -54,7 +54,7 @@ Remaining migration-tool work is intentionally ordered as: - slice 49: write scratch migrated history (complete); - slice 50: gate scratch output with genesis equivalence (complete); - slice 51: design finalization safety (complete); -- slice 52: implement archive-preserving finalization; +- slice 52: implement archive-preserving finalization (complete); - slice 53: wire the end-to-end migration command; - slice 54: prove post-migration runtime conformance; - slice 55: close the content/property migration audit. diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index b0bc0118..85500770 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -99,4 +99,6 @@ non-destructive but now has persisted-history evidence: reporting, and required patch-boundary evidence; - finalization safety now requires explicit confirmation, archive ref selection, scratch output evidence, a passed equivalence gate, and a matching - live-ref expected head before any live lineage promotion can be implemented. + live-ref expected head before any live lineage promotion can be implemented; +- archive-preserving finalization now creates archive refs and advances live + refs only through expected-head `git update-ref` calls. diff --git a/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts b/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts new file mode 100644 index 00000000..1870b09e --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts @@ -0,0 +1,73 @@ +import { spawn } from 'node:child_process'; + +const MIGRATION_GIT_IDENTITY = Object.freeze({ + GIT_AUTHOR_NAME: 'git-warp migration', + GIT_AUTHOR_EMAIL: 'git-warp@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'git-warp migration', + GIT_COMMITTER_EMAIL: 'git-warp@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', +}); + +export type GitMigrationCommandRunnerOptions = { + readonly deterministicIdentity: boolean; +}; + +/** Captured result from one migration Git command. */ +export class GitMigrationCommandResult { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + Object.freeze(this); + } + + ok(): boolean { + return this.exitCode === 0; + } +} + +/** Runs a Git command for migration tooling without invoking a shell. */ +export async function runMigrationGit( + cwd: string, + args: readonly string[], + input: string | null, + options: GitMigrationCommandRunnerOptions = { deterministicIdentity: false }, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawnGit(cwd, args, options); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (exitCode) => { + resolve(new GitMigrationCommandResult(exitCode ?? 1, stdout, stderr)); + }); + if (input !== null) { + child.stdin.write(input); + } + child.stdin.end(); + }); +} + +function spawnGit( + cwd: string, + args: readonly string[], + options: GitMigrationCommandRunnerOptions, +) { + if (options.deterministicIdentity) { + return spawn('git', args, { + cwd, + env: MIGRATION_GIT_IDENTITY, + }); + } + return spawn('git', args, { cwd }); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts new file mode 100644 index 00000000..1adbc25b --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts @@ -0,0 +1,159 @@ +import GraphModelMigrationFinalizationResult, { + GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, + GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationResult.ts'; +import GraphModelMigrationFinalizationSafetyResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const ZERO_OID = '0000000000000000000000000000000000000000'; + +export type GraphModelMigrationFinalizerOptions = { + readonly repositoryPath: string; + readonly safetyResult: GraphModelMigrationFinalizationSafetyResult; +}; + +export class GraphModelMigrationFinalizerError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationFinalizerError'; + } +} + +/** Finalizes a safety-approved scratch migration through archive-preserving Git ref updates. */ +export async function finalizeGraphModelMigration( + options: GraphModelMigrationFinalizerOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const safetyResult = requireSafetyResult(options.safetyResult); + const request = safetyResult.request; + if (!safetyResult.allowsFinalization()) { + return blockedResult(request.liveRefName, request.archiveRefName, safetyResult.fatalErrors); + } + + const archiveRefName = requireFinalizationString(request.archiveRefName, 'archiveRefName'); + const expectedLiveHead = requireFinalizationString(request.expectedLiveHead, 'expectedLiveHead'); + const scratchHead = requireFinalizationString(request.scratchHead, 'scratchHead'); + const currentLiveHead = await gitTextOrNull(repositoryPath, [ + 'show-ref', + '--verify', + '--hash', + request.liveRefName, + ]); + if (currentLiveHead !== expectedLiveHead) { + return blockedResult(request.liveRefName, archiveRefName, [ + GraphModelMigrationNotice.fatal( + 'E_STALE_LIVE_REF_EXPECTATION', + 'migration finalization live ref changed before archive creation', + ), + ]); + } + if (await refExists(repositoryPath, archiveRefName)) { + return blockedResult(request.liveRefName, archiveRefName, [ + GraphModelMigrationNotice.fatal( + 'E_ARCHIVE_REF_EXISTS', + `migration archive ref already exists: ${archiveRefName}`, + ), + ]); + } + + const archiveUpdate = await runMigrationGit( + repositoryPath, + ['update-ref', archiveRefName, expectedLiveHead, ZERO_OID], + null, + ); + if (!archiveUpdate.ok()) { + return blockedResult(request.liveRefName, archiveRefName, [ + GraphModelMigrationNotice.fatal( + 'E_ARCHIVE_REF_UPDATE_FAILED', + 'migration finalization could not create archive ref', + ), + ]); + } + + const liveUpdate = await runMigrationGit( + repositoryPath, + ['update-ref', request.liveRefName, scratchHead, expectedLiveHead], + null, + ); + if (!liveUpdate.ok()) { + return new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE, + liveRefName: request.liveRefName, + archiveRefName, + previousLiveHead: expectedLiveHead, + finalizedLiveHead: null, + fatalErrors: [ + GraphModelMigrationNotice.fatal( + 'E_LIVE_REF_UPDATE_FAILED', + 'migration finalization archived old lineage but could not advance live ref', + ), + ], + }); + } + + return new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, + liveRefName: request.liveRefName, + archiveRefName, + previousLiveHead: expectedLiveHead, + finalizedLiveHead: scratchHead, + fatalErrors: [], + }); +} + +function blockedResult( + liveRefName: string, + archiveRefName: string | null, + fatalErrors: readonly GraphModelMigrationNotice[], +): GraphModelMigrationFinalizationResult { + return new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName, + archiveRefName, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors, + }); +} + +function requireSafetyResult( + safetyResult: GraphModelMigrationFinalizationSafetyResult, +): GraphModelMigrationFinalizationSafetyResult { + if (!(safetyResult instanceof GraphModelMigrationFinalizationSafetyResult)) { + throw new GraphModelMigrationFinalizerError( + 'safetyResult must be a GraphModelMigrationFinalizationSafetyResult', + ); + } + return safetyResult; +} + +function requireFinalizationString(value: string | null, name: string): string { + if (value === null) { + throw new GraphModelMigrationFinalizerError(`${name} must be present after safety approval`); + } + return value; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationFinalizerError(`${name} must be a non-empty string`); + } + return value; +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await runMigrationGit(repositoryPath, ['show-ref', '--verify', '--hash', refName], null); + return result.ok(); +} + +async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts index 49c15538..29c4a498 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts @@ -1,5 +1,3 @@ -import { spawn } from 'node:child_process'; - import GraphModelMigrationLoweredOperation from '../../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; import GraphModelMigrationLoweredPatchPlan @@ -12,17 +10,10 @@ import GraphModelMigrationScratchWrittenPatch from '../../../../src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts'; import GraphModelMigrationScratchWriteResult from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; const ZERO_OID = '0000000000000000000000000000000000000000'; const OPERATION_TREE_PATH = 'migration-operation.txt'; -const SCRATCH_WRITE_GIT_IDENTITY = Object.freeze({ - GIT_AUTHOR_NAME: 'git-warp migration', - GIT_AUTHOR_EMAIL: 'git-warp@example.invalid', - GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', - GIT_COMMITTER_NAME: 'git-warp migration', - GIT_COMMITTER_EMAIL: 'git-warp@example.invalid', - GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', -}); export type GraphModelMigrationScratchWriterOptions = { readonly repositoryPath: string; @@ -30,20 +21,6 @@ export type GraphModelMigrationScratchWriterOptions = { readonly patchPlan: GraphModelMigrationLoweredPatchPlan; }; -class GitCommandResult { - constructor( - readonly exitCode: number, - readonly stdout: string, - readonly stderr: string, - ) { - Object.freeze(this); - } - - ok(): boolean { - return this.exitCode === 0; - } -} - export class GraphModelMigrationScratchWriterError extends Error { constructor(message: string) { super(message); @@ -116,6 +93,7 @@ async function writeOperationCommit(options: { options.repositoryPath, ['commit-tree', treeOid, ...parentArgs], formatCommitMessage(options.patchPlan, options.operation, options.sequence), + true, ); } @@ -133,7 +111,7 @@ async function validateGitRefName( repositoryPath: string, scratchRef: GraphModelMigrationScratchRef, ): Promise { - const result = await runGit(repositoryPath, ['check-ref-format', scratchRef.refName], null); + const result = await runMigrationGit(repositoryPath, ['check-ref-format', scratchRef.refName], null); if (result.ok()) { return null; } @@ -225,7 +203,7 @@ function requireNonEmptyString(value: string, name: string): string { } async function gitText(cwd: string, args: readonly string[]): Promise { - const result = await runGit(cwd, args, null); + const result = await runMigrationGit(cwd, args, null); if (!result.ok()) { throw new GraphModelMigrationScratchWriterError( `git ${args.join(' ')} failed: ${result.stderr}`, @@ -234,8 +212,13 @@ async function gitText(cwd: string, args: readonly string[]): Promise { return result.stdout.trim(); } -async function gitTextWithInput(cwd: string, args: readonly string[], input: string): Promise { - const result = await runGit(cwd, args, input); +async function gitTextWithInput( + cwd: string, + args: readonly string[], + input: string, + deterministicIdentity = false, +): Promise { + const result = await runMigrationGit(cwd, args, input, { deterministicIdentity }); if (!result.ok()) { throw new GraphModelMigrationScratchWriterError( `git ${args.join(' ')} failed: ${result.stderr}`, @@ -245,40 +228,9 @@ async function gitTextWithInput(cwd: string, args: readonly string[], input: str } async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { - const result = await runGit(cwd, args, null); + const result = await runMigrationGit(cwd, args, null); if (!result.ok()) { return null; } return result.stdout.trim(); } - -async function runGit( - cwd: string, - args: readonly string[], - input: string | null, -): Promise { - return await new Promise((resolve, reject) => { - const child = spawn('git', args, { - cwd, - env: SCRATCH_WRITE_GIT_IDENTITY, - }); - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', (exitCode) => { - resolve(new GitCommandResult(exitCode ?? 1, stdout, stderr)); - }); - if (input !== null) { - child.stdin.write(input); - } - child.stdin.end(); - }); -} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationResult.ts b/src/domain/migrations/GraphModelMigrationFinalizationResult.ts new file mode 100644 index 00000000..91d7706e --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationFinalizationResult.ts @@ -0,0 +1,116 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED = 'blocked'; +export const GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE = 'partial-archive'; +export const GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED = 'completed'; + +export type GraphModelMigrationFinalizationStatus = + | typeof GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED + | typeof GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE + | typeof GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED; + +export type GraphModelMigrationFinalizationResultFields = { + readonly status: GraphModelMigrationFinalizationStatus; + readonly liveRefName: string; + readonly archiveRefName: string | null; + readonly previousLiveHead: string | null; + readonly finalizedLiveHead: string | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result of an archive-preserving graph-model migration finalization attempt. */ +export default class GraphModelMigrationFinalizationResult { + readonly status: GraphModelMigrationFinalizationStatus; + readonly liveRefName: string; + readonly archiveRefName: string | null; + readonly previousLiveHead: string | null; + readonly finalizedLiveHead: string | null; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationFinalizationResultFields) { + const checkedFields = requireFields(fields); + this.status = requireStatus(checkedFields.status); + this.liveRefName = requireNonEmptyString(checkedFields.liveRefName, 'liveRefName'); + this.archiveRefName = requireOptionalString(checkedFields.archiveRefName, 'archiveRefName'); + this.previousLiveHead = requireOptionalString(checkedFields.previousLiveHead, 'previousLiveHead'); + this.finalizedLiveHead = requireOptionalString(checkedFields.finalizedLiveHead, 'finalizedLiveHead'); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireStatusMatchesEvidence(this); + Object.freeze(this); + } + + /** Returns true when the live ref was advanced to the scratch head. */ + finalized(): boolean { + return this.status === GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED; + } +} + +function requireFields( + fields: GraphModelMigrationFinalizationResultFields | null | undefined, +): GraphModelMigrationFinalizationResultFields { + if (fields === null || fields === undefined) { + throw new WarpError('GraphModelMigrationFinalizationResult fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +function requireStatus( + status: GraphModelMigrationFinalizationStatus, +): GraphModelMigrationFinalizationStatus { + if (status !== GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED + && status !== GRAPH_MODEL_MIGRATION_FINALIZATION_PARTIAL_ARCHIVE + && status !== GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED) { + throw new WarpError('finalization status is unsupported', 'E_VALIDATION'); + } + return status; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function requireOptionalString(value: string | null, name: string): string | null { + if (value !== null && (typeof value !== 'string' || value.length === 0)) { + throw new WarpError(`${name} must be a non-empty string or null`, 'E_VALIDATION'); + } + return value; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireStatusMatchesEvidence(result: GraphModelMigrationFinalizationResult): void { + if (result.status === GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED) { + requireCompletedEvidence(result); + return; + } + if (result.fatalErrors.length === 0) { + throw new WarpError('non-completed finalization results require fatal errors', 'E_VALIDATION'); + } +} + +function requireCompletedEvidence(result: GraphModelMigrationFinalizationResult): void { + if (result.fatalErrors.length > 0) { + throw new WarpError('completed finalization results must not include fatal errors', 'E_VALIDATION'); + } + if (result.archiveRefName === null || result.previousLiveHead === null || result.finalizedLiveHead === null) { + throw new WarpError('completed finalization results require archive and head evidence', 'E_VALIDATION'); + } +} diff --git a/test/unit/scripts/v18-migration-finalizer.test.ts b/test/unit/scripts/v18-migration-finalizer.test.ts new file mode 100644 index 00000000..21bca7d1 --- /dev/null +++ b/test/unit/scripts/v18-migration-finalizer.test.ts @@ -0,0 +1,205 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + finalizeGraphModelMigration, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationScratchRef + from '../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from '../domain/migrations/GenesisEquivalenceFixtures.ts'; + +const execFileAsync = promisify(execFile); +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/writers/alice'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 migration finalizer', () => { + it('archives the old live ref and advances the live ref with expected-head updates', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(true); + expect(result.previousLiveHead).toBe(repository.liveHead); + expect(result.finalizedLiveHead).toBe(repository.scratchHead); + expect(await gitText(repository.path, ['rev-parse', ARCHIVE_REF])).toBe(repository.liveHead); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.scratchHead); + }); + + it('does not create an archive or update the live ref when safety blocks finalization', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: failedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_EQUIVALENCE_GATE_NOT_PASSED']); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); + + it('rejects an existing archive ref before changing the live ref', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + await execFileAsync('git', ['update-ref', ARCHIVE_REF, repository.liveHead], { + cwd: repository.path, + }); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_ARCHIVE_REF_EXISTS']); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); + + it('rejects live ref drift before creating the archive ref', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + const driftHead = await writeEmptyCommit(repository.path, 'drift'); + await execFileAsync('git', ['update-ref', LIVE_REF, driftHead, repository.liveHead], { + cwd: repository.path, + }); + + const result = await finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(repository.liveHead, repository.scratchHead), + }); + + expect(result.finalized()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_STALE_LIVE_REF_EXPECTATION']); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(driftHead); + }); +}); + +type FinalizerFixtureRepository = { + readonly path: string; + readonly liveHead: string; + readonly scratchHead: string; +}; + +async function repositoryWithLiveAndScratchRefs(): Promise { + const repositoryPath = await initializedRepository('git-warp-v18-finalizer-'); + const liveHead = await writeEmptyCommit(repositoryPath, 'live'); + const scratchHead = await writeEmptyCommit(repositoryPath, 'scratch'); + await execFileAsync('git', ['update-ref', LIVE_REF, liveHead], { cwd: repositoryPath }); + await execFileAsync('git', ['update-ref', SCRATCH_REF, scratchHead], { cwd: repositoryPath }); + return Object.freeze({ + path: repositoryPath, + liveHead, + scratchHead, + }); +} + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.name', 'git-warp test'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.email', 'git-warp@example.invalid'], { cwd: repositoryPath }); + return repositoryPath; +} + +async function writeEmptyCommit(repositoryPath: string, message: string): Promise { + await execFileAsync('git', ['commit', '--allow-empty', '-q', '-m', message], { + cwd: repositoryPath, + }); + return await gitText(repositoryPath, ['rev-parse', 'HEAD']); +} + +function passedSafetyResult(liveHead: string, scratchHead: string) { + return new GraphModelMigrationFinalizationSafety().evaluate( + finalizationRequest(liveHead, scratchHead, passedGateResult()), + ); +} + +function failedSafetyResult(liveHead: string, scratchHead: string) { + return new GraphModelMigrationFinalizationSafety().evaluate( + finalizationRequest(liveHead, scratchHead, failedGateResult()), + ); +} + +function finalizationRequest( + liveHead: string, + scratchHead: string, + gateResult: ReturnType, +): GraphModelMigrationFinalizationRequest { + return new GraphModelMigrationFinalizationRequest({ + liveRefName: LIVE_REF, + expectedLiveHead: liveHead, + observedLiveHead: liveHead, + scratchRef: new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }), + scratchHead, + archiveRefName: ARCHIVE_REF, + confirmation: new GraphModelMigrationFinalizationConfirmation({ + token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + }), + gateResult, + }); +} + +function passedGateResult(): ReturnType { + const fixture = nodeLifecycleFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function failedGateResult(): ReturnType { + const fixture = divergentPropertyFixture(); + return new GenesisEquivalenceGate().evaluate( + basis(), + fixture.legacyReading, + fixture.migratedReading, + ); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:scratch', + }), + }); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { + cwd: repositoryPath, + }); + return result.stdout.trim().length > 0; +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd: repositoryPath }); + return result.stdout.trim(); +} From 0d948ed0af03a156af19b7efde9c58cb1c7318b5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:11:04 -0700 Subject: [PATCH 08/23] Feat: Wire v18 migration command --- CHANGELOG.md | 3 + docs/BEARING.md | 17 +- .../v18-migration-command-wiring.md | 117 ++++++++++ .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 4 +- .../graph-model/GraphModelMigrationCommand.ts | 188 +++++++++++++++ .../scripts/v18-migration-command.test.ts | 214 ++++++++++++++++++ 7 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts create mode 100644 test/unit/scripts/v18-migration-command.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3846c903..56dfe9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Git updater that blocks failed safety results, rejects live-ref drift, refuses existing archive refs, archives old lineage, and advances live refs through expected-head `git update-ref` calls. +- V18 graph-model migration now includes a command-level runner that wires + dry-run planning, operation lowering, scratch writing, equivalence gating, + and optional finalization while keeping finalization absent by default. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index e2bba0ac..f69883c0 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -125,6 +125,9 @@ The current v18 graph-model posture is: - Archive-preserving finalization now exists as an adapter-layer Git updater: it refuses failed safety results, rejects live-ref drift, creates an archive ref for old lineage, and advances the live ref with expected-head CAS. +- Command-level migration wiring now runs dry-run planning, lowering, scratch + writing, equivalence gating, and optional finalization in order, with + finalization absent by default. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -277,6 +280,10 @@ the old live head under `refs/warp-migration-archive/*` and advances the live ref to the scratch head with `git update-ref `, while blocking failed safety, existing archive refs, and live-ref drift. +Slice 53 is complete on this branch. The command runner now wires the v18 +migration stages in order and only calls finalization when explicit +finalization options are supplied and the equivalence gate passes. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -297,9 +304,10 @@ blocking failed safety, existing archive refs, and live-ref drift. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but the scratch writer output still needs an equivalence gate. -- The next migration work must wire command orchestration, runtime - conformance, and closeout audit. Live ref promotion now has a covered updater - but is still not exposed through an operator command. +- The next migration work must build real-history reading construction, + runtime conformance, and closeout audit. The command can orchestrate supplied + readings, but it does not yet derive those readings from migrated Git + history. ## Where We Are Heading @@ -420,6 +428,7 @@ and concrete checks live in `docs/invariants/`. [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). - [x] 52. Implement archive-preserving migration finalization: [0200](design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md). -- [ ] 53. Wire the end-to-end migration command. +- [x] 53. Wire the end-to-end migration command: + [0201](design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md). - [ ] 54. Prove post-migration runtime conformance. - [ ] 55. Close the content/property migration audit. diff --git a/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md b/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md new file mode 100644 index 00000000..0c5111dc --- /dev/null +++ b/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md @@ -0,0 +1,117 @@ +--- +cycle: 0201 +task_id: V18_migration_command_wiring +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 53 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Command Wiring + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Wire the v18 graph-model migration steps into one command-level flow while +keeping finalization explicit and gated. + +## Playback Questions + +- Does the command run dry-run planning, lowering, scratch writing, and + equivalence in order? +- Does it remain non-finalizing by default? +- Does finalization require explicit finalization options and confirmation? +- Does failed equivalence prevent archive/live ref updates? +- Does the command expose enough typed result evidence for CLI formatting? + +## Existing Shape + +Slices 46 through 52 created fixture restore, source inventory, operation +lowering, scratch writing, equivalence gating, finalization safety, and +archive-preserving finalization. The missing step was a command-level +orchestrator that puts those pieces in the right order. + +## Chosen Boundary + +Add a script-level command runner under +`scripts/v18.0.0/migrations/graph-model/`. It accepts typed request and +reading values rather than parsing command-line text. The existing dry-run CLI +remains non-destructive; a broader user-facing parser can wrap this runner +later. + +The command runner: + +1. plans a dry-run migration; +2. lowers successful plans; +3. writes scratch history; +4. gates supplied legacy/scratch readings; +5. optionally builds finalization safety and calls the finalizer. + +## Non-Goals + +- Do not infer legacy or scratch readings from Git in this slice. +- Do not add a broad operator CLI parser. +- Do not finalize without explicit finalization options. +- Do not skip the equivalence gate. +- Do not claim post-migration runtime conformance. + +## RED Plan + +Add command tests: + +- default run writes scratch history and does not finalize; +- explicit finalization archives and advances live refs after passing gate; +- divergent supplied readings block finalization and leave live refs intact. + +## GREEN Plan + +Keep orchestration thin. Reuse the existing planner, lowerer, scratch writer, +equivalence gate, finalization safety gate, and finalizer instead of creating +parallel command-local checks. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +npx eslint --no-warn-ignored scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts test/unit/scripts/v18-migration-command.test.ts +npm run typecheck +``` + +## Closeout Criteria + +- The command flow is ordered. +- Default operation is non-finalizing. +- Explicit finalization uses the safety/finalizer path. +- Failed equivalence blocks live ref changes. + +## Closeout + +Slice 53 added `runGraphModelMigrationCommand`. The command runner wires +dry-run planning, operation lowering, scratch writing, equivalence gating, and +optional finalization. Finalization is absent by default and only runs when +explicit finalization options are supplied. + +The runner still consumes supplied legacy and scratch readings. Real-history +reading construction remains the next proof gap before public release. + +## SSJS Scorecard + +- Runtime-backed forms: green; command result carries named stage results. +- Boundary validation: green; typed values cross the command boundary. +- Behavior ownership: green; orchestration orders existing services. +- Message parsing: green; no command behavior parses formatted output. +- Ambient time or entropy: green; command does not create identities. +- Fake shape trust or cast-cosplay: green; tests use real Git refs. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index e7ef5656..6fb91efa 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -55,7 +55,7 @@ Remaining migration-tool work is intentionally ordered as: - slice 50: gate scratch output with genesis equivalence (complete); - slice 51: design finalization safety (complete); - slice 52: implement archive-preserving finalization (complete); -- slice 53: wire the end-to-end migration command; +- slice 53: wire the end-to-end migration command (complete); - slice 54: prove post-migration runtime conformance; - slice 55: close the content/property migration audit. diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 85500770..df12f88c 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -101,4 +101,6 @@ non-destructive but now has persisted-history evidence: selection, scratch output evidence, a passed equivalence gate, and a matching live-ref expected head before any live lineage promotion can be implemented; - archive-preserving finalization now creates archive refs and advances live - refs only through expected-head `git update-ref` calls. + refs only through expected-head `git update-ref` calls; +- command wiring now runs planning, lowering, scratch writing, equivalence, + and optional finalization in order while keeping finalization off by default. diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts new file mode 100644 index 00000000..0402684a --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -0,0 +1,188 @@ +import DryRunGraphModelMigrationPlan + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGate from '../../../../src/domain/migrations/GenesisEquivalenceGate.ts'; +import GenesisEquivalenceGateResult + from '../../../../src/domain/migrations/GenesisEquivalenceGateResult.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GraphModelMigrationFinalizationConfirmation + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationResult.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationOperationLowerer + from '../../../../src/domain/migrations/GraphModelMigrationOperationLowerer.ts'; +import GraphModelMigrationOperationLoweringResult + from '../../../../src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { finalizeGraphModelMigration } from './GraphModelMigrationFinalizer.ts'; +import { writeGraphModelMigrationScratchHistory } from './GraphModelMigrationScratchWriter.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +export type GraphModelMigrationCommandFinalizationOptions = { + readonly liveRefName: string; + readonly expectedLiveHead: string; + readonly archiveRefName: string; + readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; +}; + +export type GraphModelMigrationCommandOptions = { + readonly repositoryPath: string; + readonly dryRunRequest: DryRunGraphModelMigrationPlanRequest; + readonly scratchRefName: string; + readonly equivalenceBasis: GenesisEquivalenceComparisonBasis; + readonly legacyReading: GenesisEquivalenceReading; + readonly scratchReading: GenesisEquivalenceReading; + readonly finalization: GraphModelMigrationCommandFinalizationOptions | null; +}; + +/** Result of the wired v18 graph-model migration command flow. */ +export class GraphModelMigrationCommandResult { + constructor( + readonly dryRunPlan: DryRunGraphModelMigrationPlan, + readonly loweringResult: GraphModelMigrationOperationLoweringResult, + readonly scratchWriteResult: GraphModelMigrationScratchWriteResult | null, + readonly gateResult: GenesisEquivalenceGateResult | null, + readonly finalizationResult: GraphModelMigrationFinalizationResult | null, + ) { + Object.freeze(this); + } +} + +export class GraphModelMigrationCommandError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationCommandError'; + } +} + +/** Runs dry-run planning, lowering, scratch writing, equivalence, and optional finalization. */ +export async function runGraphModelMigrationCommand( + options: GraphModelMigrationCommandOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const dryRunRequest = requireDryRunRequest(options.dryRunRequest); + const dryRunPlan = new DryRunGraphModelMigrationPlanner().plan(dryRunRequest); + const loweringResult = new GraphModelMigrationOperationLowerer().lower(dryRunPlan); + if (loweringResult.hasFatalErrors() || loweringResult.patchPlan === null) { + return new GraphModelMigrationCommandResult(dryRunPlan, loweringResult, null, null, null); + } + + const scratchWriteResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: options.scratchRefName, + patchPlan: loweringResult.patchPlan, + }); + if (scratchWriteResult.hasFatalErrors()) { + return new GraphModelMigrationCommandResult(dryRunPlan, loweringResult, scratchWriteResult, null, null); + } + + const gateResult = new GenesisEquivalenceGate().evaluate( + requireBasis(options.equivalenceBasis), + requireReading(options.legacyReading, 'legacyReading'), + requireReading(options.scratchReading, 'scratchReading'), + ); + if (options.finalization === null) { + return new GraphModelMigrationCommandResult( + dryRunPlan, + loweringResult, + scratchWriteResult, + gateResult, + null, + ); + } + + const finalizationResult = await runFinalization({ + repositoryPath, + scratchWriteResult, + gateResult, + finalization: options.finalization, + }); + return new GraphModelMigrationCommandResult( + dryRunPlan, + loweringResult, + scratchWriteResult, + gateResult, + finalizationResult, + ); +} + +async function runFinalization(options: { + readonly repositoryPath: string; + readonly scratchWriteResult: GraphModelMigrationScratchWriteResult; + readonly gateResult: GenesisEquivalenceGateResult; + readonly finalization: GraphModelMigrationCommandFinalizationOptions; +}): Promise { + const observedLiveHead = await gitTextOrNull(options.repositoryPath, [ + 'show-ref', + '--verify', + '--hash', + options.finalization.liveRefName, + ]); + const safetyResult = new GraphModelMigrationFinalizationSafety().evaluate( + new GraphModelMigrationFinalizationRequest({ + liveRefName: options.finalization.liveRefName, + expectedLiveHead: options.finalization.expectedLiveHead, + observedLiveHead, + scratchRef: options.scratchWriteResult.scratchRef, + scratchHead: options.scratchWriteResult.scratchHead, + archiveRefName: options.finalization.archiveRefName, + confirmation: options.finalization.confirmation, + gateResult: options.gateResult, + }), + ); + return await finalizeGraphModelMigration({ + repositoryPath: options.repositoryPath, + safetyResult, + }); +} + +function requireDryRunRequest( + request: DryRunGraphModelMigrationPlanRequest, +): DryRunGraphModelMigrationPlanRequest { + if (!(request instanceof DryRunGraphModelMigrationPlanRequest)) { + throw new GraphModelMigrationCommandError('dryRunRequest must be a DryRunGraphModelMigrationPlanRequest'); + } + return request; +} + +function requireBasis( + basis: GenesisEquivalenceComparisonBasis, +): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new GraphModelMigrationCommandError('equivalenceBasis must be a GenesisEquivalenceComparisonBasis'); + } + return basis; +} + +function requireReading(reading: GenesisEquivalenceReading, label: string): GenesisEquivalenceReading { + if (!(reading instanceof GenesisEquivalenceReading)) { + throw new GraphModelMigrationCommandError(`${label} must be a GenesisEquivalenceReading`); + } + return reading; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationCommandError(`${name} must be a non-empty string`); + } + return value; +} + +async function gitTextOrNull(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts new file mode 100644 index 00000000..d4deeeaf --- /dev/null +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + runGraphModelMigrationCommand, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts'; +import DryRunGraphModelMigrationPlanRequest + from '../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationNodeMapping + from '../../../src/domain/migrations/GraphModelMigrationNodeMapping.ts'; +import GraphModelMigrationPatchDescriptor + from '../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationSourceInventory + from '../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import { + divergentPropertyFixture, + nodeLifecycleFixture, +} from '../domain/migrations/GenesisEquivalenceFixtures.ts'; + +const execFileAsync = promisify(execFile); +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/writers/alice'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 graph-model migration command', () => { + it('runs planning, lowering, scratch writing, and equivalence without finalizing by default', async () => { + const repository = await initializedRepository('git-warp-v18-command-dry-'); + const fixture = nodeLifecycleFixture(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: fixture.legacyReading, + scratchReading: fixture.migratedReading, + finalization: null, + }); + + expect(result.dryRunPlan.hasFatalErrors()).toBe(false); + expect(result.loweringResult.hasFatalErrors()).toBe(false); + expect(result.scratchWriteResult?.hasFatalErrors()).toBe(false); + expect(result.gateResult?.allowsPromotion()).toBe(true); + expect(result.finalizationResult).toBeNull(); + expect(await gitText(repository, ['rev-list', '--count', SCRATCH_REF])).toBe('1'); + expect(await refExists(repository, ARCHIVE_REF)).toBe(false); + }); + + it('finalizes when explicit finalization options and the equivalence gate pass', async () => { + const repository = await repositoryWithLiveRef(); + const fixture = nodeLifecycleFixture(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository.path, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: fixture.legacyReading, + scratchReading: fixture.migratedReading, + finalization: { + liveRefName: LIVE_REF, + expectedLiveHead: repository.liveHead, + archiveRefName: ARCHIVE_REF, + confirmation: confirmation(), + }, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(true); + expect(result.finalizationResult?.finalized()).toBe(true); + expect(await gitText(repository.path, ['rev-parse', ARCHIVE_REF])).toBe(repository.liveHead); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])) + .toBe(result.scratchWriteResult?.scratchHead); + }); + + it('blocks finalization when supplied scratch readings diverge', async () => { + const repository = await repositoryWithLiveRef(); + const fixture = divergentPropertyFixture(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository.path, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: fixture.legacyReading, + scratchReading: fixture.migratedReading, + finalization: { + liveRefName: LIVE_REF, + expectedLiveHead: repository.liveHead, + archiveRefName: ARCHIVE_REF, + confirmation: confirmation(), + }, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(false); + expect(result.finalizationResult?.finalized()).toBe(false); + expect(result.finalizationResult?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_EQUIVALENCE_GATE_NOT_PASSED', + ]); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); +}); + +type CommandFixtureRepository = { + readonly path: string; + readonly liveHead: string; +}; + +async function repositoryWithLiveRef(): Promise { + const repositoryPath = await initializedRepository('git-warp-v18-command-finalize-'); + const liveHead = await writeEmptyCommit(repositoryPath, 'live'); + await execFileAsync('git', ['update-ref', LIVE_REF, liveHead], { cwd: repositoryPath }); + return Object.freeze({ path: repositoryPath, liveHead }); +} + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.name', 'git-warp test'], { cwd: repositoryPath }); + await execFileAsync('git', ['config', 'user.email', 'git-warp@example.invalid'], { cwd: repositoryPath }); + return repositoryPath; +} + +async function writeEmptyCommit(repositoryPath: string, message: string): Promise { + await execFileAsync('git', ['commit', '--allow-empty', '-q', '-m', message], { + cwd: repositoryPath, + }); + return await gitText(repositoryPath, ['rev-parse', 'HEAD']); +} + +function dryRunRequest(): DryRunGraphModelMigrationPlanRequest { + return new DryRunGraphModelMigrationPlanRequest({ + inventory: sourceInventory(), + requiredContentKeys: [], + nodeMappings: [ + new GraphModelMigrationNodeMapping({ + legacyNodeId: 'node:article', + targetNodeId: 'node:article', + }), + ], + edgeMappings: [], + propertyMappings: [], + }); +} + +function sourceInventory(): GraphModelMigrationSourceInventory { + return new GraphModelMigrationSourceInventory({ + graphId: 'v17-golden-graph', + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + writerChains: [ + new GraphModelMigrationWriterChainDescriptor({ + writerId: 'alice', + patchIds: ['patch:alice:0'], + }), + ], + patchDescriptors: [ + new GraphModelMigrationPatchDescriptor({ + patchId: 'patch:alice:0', + writerId: 'alice', + writerSequence: 0, + }), + ], + stateSnapshot: null, + contentSources: [], + warnings: [], + fatalErrors: [], + }); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:scratch', + }), + }); +} + +function confirmation(): GraphModelMigrationFinalizationConfirmation { + return new GraphModelMigrationFinalizationConfirmation({ + token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + }); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { + cwd: repositoryPath, + }); + return result.stdout.trim().length > 0; +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await execFileAsync('git', args, { cwd: repositoryPath }); + return result.stdout.trim(); +} From 4a16de8386d7d63064e9be841130ae30e16f0ce0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:24:53 -0700 Subject: [PATCH 09/23] Feat: Require v18 migration runtime conformance --- CHANGELOG.md | 3 + docs/BEARING.md | 17 ++- .../v18-post-migration-runtime-conformance.md | 118 ++++++++++++++++++ .../INFRA_graph-model-migration-tool.md | 3 +- docs/method/backlog/v18.0.0/README.md | 5 +- .../TRUST_genesis-replay-equivalence.md | 3 +- .../graph-model/GraphModelMigrationCommand.ts | 21 ++++ .../GraphModelMigrationFinalizationRequest.ts | 20 +++ .../GraphModelMigrationFinalizationSafety.ts | 29 +++++ ...hModelMigrationRuntimeConformanceResult.ts | 107 ++++++++++++++++ ...phModelMigrationFinalizationSafety.test.ts | 39 +++++- .../scripts/v18-migration-command.test.ts | 22 ++++ .../scripts/v18-migration-finalizer.test.ts | 20 ++- 13 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md create mode 100644 src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56dfe9a9..d8374e50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration now includes a command-level runner that wires dry-run planning, operation lowering, scratch writing, equivalence gating, and optional finalization while keeping finalization absent by default. +- V18 graph-model migration finalization now requires runtime conformance + evidence matching the scratch ref and scratch head, so supplied equivalence + readings alone cannot promote scratch output to live graph refs. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index f69883c0..999b93d9 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -128,6 +128,8 @@ The current v18 graph-model posture is: - Command-level migration wiring now runs dry-run planning, lowering, scratch writing, equivalence gating, and optional finalization in order, with finalization absent by default. +- Finalization now also requires post-migration runtime conformance evidence + tied to the exact scratch ref and scratch head. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -284,6 +286,10 @@ Slice 53 is complete on this branch. The command runner now wires the v18 migration stages in order and only calls finalization when explicit finalization options are supplied and the equivalence gate passes. +Slice 54 is complete on this branch. Finalization safety now rejects promotion +without runtime conformance evidence for the exact scratch ref/head, which +keeps supplied equivalence readings from masquerading as runtime readability. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -304,10 +310,10 @@ finalization options are supplied and the equivalence gate passes. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but the scratch writer output still needs an equivalence gate. -- The next migration work must build real-history reading construction, - runtime conformance, and closeout audit. The command can orchestrate supplied - readings, but it does not yet derive those readings from migrated Git - history. +- The next migration work must close the content/property audit and then build + real-history reading construction plus a real runtime conformance provider. + The command can orchestrate supplied readings, but it does not yet derive + those readings from migrated Git history. ## Where We Are Heading @@ -430,5 +436,6 @@ and concrete checks live in `docs/invariants/`. [0200](design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md). - [x] 53. Wire the end-to-end migration command: [0201](design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md). -- [ ] 54. Prove post-migration runtime conformance. +- [x] 54. Prove post-migration runtime conformance: + [0202](design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md). - [ ] 55. Close the content/property migration audit. diff --git a/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md b/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md new file mode 100644 index 00000000..cfc2c0c5 --- /dev/null +++ b/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md @@ -0,0 +1,118 @@ +--- +cycle: 0202 +task_id: V18_post_migration_runtime_conformance +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 54 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Post-Migration Runtime Conformance + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Prevent finalization unless post-migration scratch output has explicit runtime +conformance evidence. + +## Playback Questions + +- Does finalization require conformance evidence in addition to equivalence? +- Does the conformance evidence name the scratch ref and head it covers? +- Does mismatched evidence fail closed? +- Does command wiring make the conformance provider explicit? +- Does the design avoid claiming scratch operation commits are runtime-readable + before replay integration exists? + +## Existing Shape + +Slice 53 wired the command flow and could finalize supplied readings after a +passing equivalence gate. That was still not enough for a release-quality +migration path: equivalence over supplied readings is not the same as proving +that the finalized live ref is readable by the normal runtime. + +## Chosen Boundary + +Add runtime conformance as explicit evidence required by finalization safety. +The evidence includes: + +- scratch ref; +- scratch head; +- pass/fail status; +- witness name; +- fatal errors for failed evidence. + +The command accepts a conformance provider that receives the actual scratch +write result. Finalization safety rejects missing evidence and evidence that +does not match the scratch ref/head. + +## Non-Goals + +- Do not claim that scratch migration-operation commits are already complete + runtime patch commits. +- Do not make finalization infer conformance from equivalence alone. +- Do not add a fake runtime adapter. +- Do not parse report text as proof. + +## RED Plan + +Add safety and command tests: + +- finalization without runtime conformance is rejected; +- conformance for a different scratch head is rejected; +- command finalization supplies conformance through an explicit provider; +- divergent equivalence still blocks even when a conformance provider exists. + +## GREEN Plan + +Add `GraphModelMigrationRuntimeConformanceResult` and thread it through +`GraphModelMigrationFinalizationRequest`, `GraphModelMigrationFinalizationSafety`, +and `runGraphModelMigrationCommand`. + +## Verification + +```text +npx vitest run test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +npx eslint --no-warn-ignored src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts src/domain/migrations/GraphModelMigrationFinalizationRequest.ts src/domain/migrations/GraphModelMigrationFinalizationSafety.ts scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts test/unit/scripts/v18-migration-finalizer.test.ts test/unit/scripts/v18-migration-command.test.ts +npm run typecheck +``` + +## Closeout Criteria + +- Runtime conformance evidence is required by finalization. +- Evidence must match the scratch ref and head. +- Command finalization receives conformance from an explicit provider. +- The remaining runtime replay gap is visible in docs and bearing. + +## Closeout + +Slice 54 added `GraphModelMigrationRuntimeConformanceResult` and made +finalization safety require matching runtime conformance evidence. This is a +release-safety improvement, not a claim that the migration-operation scratch +history is already a native runtime patch stream. + +The next release-grade step is to replace test-supplied conformance providers +with a real runtime replay check over the finalized graph-model history. + +## SSJS Scorecard + +- Runtime-backed forms: green; conformance evidence is a named value. +- Boundary validation: green; finalization validates evidence before Git I/O. +- Behavior ownership: green; conformance evidence gates, finalizer mutates. +- Message parsing: green; witness strings are not parsed as behavior. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green; current gap is explicit. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 6fb91efa..a25a7869 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -56,7 +56,8 @@ Remaining migration-tool work is intentionally ordered as: - slice 51: design finalization safety (complete); - slice 52: implement archive-preserving finalization (complete); - slice 53: wire the end-to-end migration command (complete); -- slice 54: prove post-migration runtime conformance; +- slice 54: prove post-migration runtime conformance (conformance evidence + gate complete; real runtime replay provider still release-critical); - slice 55: close the content/property migration audit. ## Starting points diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index df12f88c..1f9b555b 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -103,4 +103,7 @@ non-destructive but now has persisted-history evidence: - archive-preserving finalization now creates archive refs and advances live refs only through expected-head `git update-ref` calls; - command wiring now runs planning, lowering, scratch writing, equivalence, - and optional finalization in order while keeping finalization off by default. + and optional finalization in order while keeping finalization off by default; +- finalization now also requires runtime conformance evidence tied to the + exact scratch ref and head, making the remaining real-runtime replay provider + an explicit release blocker instead of an implicit assumption. diff --git a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md index f2d57b54..6c270ef0 100644 --- a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +++ b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md @@ -47,7 +47,8 @@ Slice 50 added the first promotion gate over that proof vocabulary: This is now a gate vocabulary, but not yet the complete ship gate. The remaining trust work is to construct legacy and scratch readings from real -Git history and wire the gate into finalization. +Git history and replace test-supplied runtime conformance evidence with a real +runtime replay provider. ## Starting points diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts index 0402684a..26afca9b 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -23,17 +23,24 @@ import GraphModelMigrationOperationLowerer from '../../../../src/domain/migrations/GraphModelMigrationOperationLowerer.ts'; import GraphModelMigrationOperationLoweringResult from '../../../../src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts'; +import GraphModelMigrationRuntimeConformanceResult + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; import GraphModelMigrationScratchWriteResult from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; import { finalizeGraphModelMigration } from './GraphModelMigrationFinalizer.ts'; import { writeGraphModelMigrationScratchHistory } from './GraphModelMigrationScratchWriter.ts'; import { runMigrationGit } from './GitMigrationCommandRunner.ts'; +export type GraphModelMigrationRuntimeConformanceProvider = ( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +) => GraphModelMigrationRuntimeConformanceResult | null; + export type GraphModelMigrationCommandFinalizationOptions = { readonly liveRefName: string; readonly expectedLiveHead: string; readonly archiveRefName: string; readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; + readonly runtimeConformance: GraphModelMigrationRuntimeConformanceProvider | null; }; export type GraphModelMigrationCommandOptions = { @@ -139,6 +146,10 @@ async function runFinalization(options: { archiveRefName: options.finalization.archiveRefName, confirmation: options.finalization.confirmation, gateResult: options.gateResult, + runtimeConformance: runtimeConformanceFromProvider( + options.finalization.runtimeConformance, + options.scratchWriteResult, + ), }), ); return await finalizeGraphModelMigration({ @@ -147,6 +158,16 @@ async function runFinalization(options: { }); } +function runtimeConformanceFromProvider( + provider: GraphModelMigrationRuntimeConformanceProvider | null, + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationRuntimeConformanceResult | null { + if (provider === null) { + return null; + } + return provider(scratchWriteResult); +} + function requireDryRunRequest( request: DryRunGraphModelMigrationPlanRequest, ): DryRunGraphModelMigrationPlanRequest { diff --git a/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts b/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts index d0eebccf..e0ef245b 100644 --- a/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts +++ b/src/domain/migrations/GraphModelMigrationFinalizationRequest.ts @@ -1,6 +1,8 @@ import GenesisEquivalenceGateResult from './GenesisEquivalenceGateResult.ts'; import GraphModelMigrationFinalizationConfirmation from './GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationRuntimeConformanceResult + from './GraphModelMigrationRuntimeConformanceResult.ts'; import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; import WarpError from '../errors/WarpError.ts'; @@ -13,6 +15,7 @@ export type GraphModelMigrationFinalizationRequestFields = { readonly archiveRefName: string | null; readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; readonly gateResult: GenesisEquivalenceGateResult | null; + readonly runtimeConformance: GraphModelMigrationRuntimeConformanceResult | null; }; /** Pure finalization request envelope; it does not move Git refs. */ @@ -25,6 +28,7 @@ export default class GraphModelMigrationFinalizationRequest { readonly archiveRefName: string | null; readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; readonly gateResult: GenesisEquivalenceGateResult | null; + readonly runtimeConformance: GraphModelMigrationRuntimeConformanceResult | null; constructor(fields: GraphModelMigrationFinalizationRequestFields) { const checkedFields = requireFields(fields); @@ -36,6 +40,7 @@ export default class GraphModelMigrationFinalizationRequest { this.archiveRefName = requireOptionalString(checkedFields.archiveRefName, 'archiveRefName'); this.confirmation = requireOptionalConfirmation(checkedFields.confirmation); this.gateResult = requireOptionalGateResult(checkedFields.gateResult); + this.runtimeConformance = requireOptionalRuntimeConformance(checkedFields.runtimeConformance); Object.freeze(this); } } @@ -92,3 +97,18 @@ function requireOptionalGateResult( } return gateResult; } + +function requireOptionalRuntimeConformance( + runtimeConformance: GraphModelMigrationRuntimeConformanceResult | null, +): GraphModelMigrationRuntimeConformanceResult | null { + if ( + runtimeConformance !== null + && !(runtimeConformance instanceof GraphModelMigrationRuntimeConformanceResult) + ) { + throw new WarpError( + 'runtimeConformance must be a GraphModelMigrationRuntimeConformanceResult or null', + 'E_VALIDATION', + ); + } + return runtimeConformance; +} diff --git a/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts b/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts index f793f64c..58bf6326 100644 --- a/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts +++ b/src/domain/migrations/GraphModelMigrationFinalizationSafety.ts @@ -27,6 +27,7 @@ function collectFatalErrors( validateGateResult(request), validateArchiveRef(request.archiveRefName), validateScratchOutput(request), + validateRuntimeConformance(request), validateLiveHeadExpectation(request), ].filter((notice) => notice !== null)); } @@ -81,6 +82,34 @@ function validateScratchOutput( ); } +function validateRuntimeConformance( + request: GraphModelMigrationFinalizationRequest, +): GraphModelMigrationNotice | null { + if (request.runtimeConformance === null || !request.runtimeConformance.allowsFinalization()) { + return GraphModelMigrationNotice.fatal( + 'E_RUNTIME_CONFORMANCE_NOT_PASSED', + 'migration finalization requires post-migration runtime conformance evidence', + ); + } + if (!runtimeConformanceMatchesScratchOutput(request)) { + return GraphModelMigrationNotice.fatal( + 'E_RUNTIME_CONFORMANCE_MISMATCH', + 'runtime conformance evidence must match the scratch ref and head', + ); + } + return null; +} + +function runtimeConformanceMatchesScratchOutput( + request: GraphModelMigrationFinalizationRequest, +): boolean { + return request.scratchRef !== null + && request.scratchHead !== null + && request.runtimeConformance !== null + && request.runtimeConformance.scratchRef.refName === request.scratchRef.refName + && request.runtimeConformance.scratchHead === request.scratchHead; +} + function validateLiveHeadExpectation( request: GraphModelMigrationFinalizationRequest, ): GraphModelMigrationNotice | null { diff --git a/src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts b/src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts new file mode 100644 index 00000000..7f2c8e64 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts @@ -0,0 +1,107 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED = 'passed'; +export const GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED = 'failed'; + +export type GraphModelMigrationRuntimeConformanceStatus = + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED; + +export type GraphModelMigrationRuntimeConformanceResultFields = { + readonly scratchRef: GraphModelMigrationScratchRef; + readonly scratchHead: string; + readonly status: GraphModelMigrationRuntimeConformanceStatus; + readonly witness: string; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Runtime conformance evidence for post-migration scratch history. */ +export default class GraphModelMigrationRuntimeConformanceResult { + readonly scratchRef: GraphModelMigrationScratchRef; + readonly scratchHead: string; + readonly status: GraphModelMigrationRuntimeConformanceStatus; + readonly witness: string; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationRuntimeConformanceResultFields) { + const checkedFields = requireFields(fields); + this.scratchRef = requireScratchRef(checkedFields.scratchRef); + this.scratchHead = requireNonEmptyString(checkedFields.scratchHead, 'scratchHead'); + this.status = requireStatus(checkedFields.status); + this.witness = requireNonEmptyString(checkedFields.witness, 'witness'); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireStatusMatchesFatalErrors(this.status, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when scratch output is proven runtime-readable. */ + allowsFinalization(): boolean { + return this.status === GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED; + } +} + +function requireFields( + fields: GraphModelMigrationRuntimeConformanceResultFields | null | undefined, +): GraphModelMigrationRuntimeConformanceResultFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationRuntimeConformanceResult fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireScratchRef(scratchRef: GraphModelMigrationScratchRef): GraphModelMigrationScratchRef { + if (!(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef', 'E_VALIDATION'); + } + return scratchRef; +} + +function requireStatus( + status: GraphModelMigrationRuntimeConformanceStatus, +): GraphModelMigrationRuntimeConformanceStatus { + if (status !== GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED + && status !== GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED) { + throw new WarpError('runtime conformance status is unsupported', 'E_VALIDATION'); + } + return status; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireStatusMatchesFatalErrors( + status: GraphModelMigrationRuntimeConformanceStatus, + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (status === GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED && fatalErrors.length > 0) { + throw new WarpError('passed runtime conformance must not contain fatal errors', 'E_VALIDATION'); + } + if (status === GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED && fatalErrors.length === 0) { + throw new WarpError('failed runtime conformance must contain fatal errors', 'E_VALIDATION'); + } +} diff --git a/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts b/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts index b7ddfa26..58e64b96 100644 --- a/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationFinalizationSafety.test.ts @@ -13,6 +13,9 @@ import GraphModelMigrationFinalizationRequest from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; import GraphModelMigrationFinalizationSafety from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; import GraphModelMigrationScratchRef from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; import { @@ -80,6 +83,22 @@ describe('GraphModelMigrationFinalizationSafety', () => { ]); }); + it('requires runtime conformance evidence matching the scratch output', () => { + const missing = safety().evaluate(completeRequest({ + runtimeConformance: null, + })); + const mismatch = safety().evaluate(completeRequest({ + runtimeConformance: runtimeConformance('4444444444444444444444444444444444444444'), + })); + + expect(missing.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_NOT_PASSED', + ]); + expect(mismatch.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_MISMATCH', + ]); + }); + it('has no force mode on the finalization request shape', () => { const request = completeRequest(); @@ -98,16 +117,22 @@ function completeRequest(overrides: { readonly archiveRefName?: string | null; readonly confirmation?: GraphModelMigrationFinalizationConfirmation | null; readonly gateResult?: GenesisEquivalenceGateResult | null; + readonly runtimeConformance?: GraphModelMigrationRuntimeConformanceResult | null; } = {}): GraphModelMigrationFinalizationRequest { + const scratchRef = new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }); + const scratchHead = SCRATCH_HEAD; return new GraphModelMigrationFinalizationRequest({ liveRefName: LIVE_REF, expectedLiveHead: overrides.expectedLiveHead === undefined ? LIVE_HEAD : overrides.expectedLiveHead, observedLiveHead: overrides.observedLiveHead === undefined ? LIVE_HEAD : overrides.observedLiveHead, - scratchRef: new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }), - scratchHead: SCRATCH_HEAD, + scratchRef, + scratchHead, archiveRefName: overrides.archiveRefName === undefined ? ARCHIVE_REF : overrides.archiveRefName, confirmation: overrides.confirmation === undefined ? confirmation() : overrides.confirmation, gateResult: overrides.gateResult === undefined ? passedGateResult() : overrides.gateResult, + runtimeConformance: overrides.runtimeConformance === undefined + ? runtimeConformance(scratchHead) + : overrides.runtimeConformance, }); } @@ -135,6 +160,16 @@ function failedGateResult(): GenesisEquivalenceGateResult { ); } +function runtimeConformance(scratchHead: string): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }), + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'unit-test-runtime-conformance', + fatalErrors: [], + }); +} + function basis(): GenesisEquivalenceComparisonBasis { return new GenesisEquivalenceComparisonBasis({ legacyBasis: new GraphModelMigrationBasis({ diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index d4deeeaf..b4237d38 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -20,6 +20,11 @@ import GraphModelMigrationNodeMapping from '../../../src/domain/migrations/GraphModelMigrationNodeMapping.ts'; import GraphModelMigrationPatchDescriptor from '../../../src/domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import type GraphModelMigrationScratchWriteResult + from '../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; import GraphModelMigrationSourceInventory from '../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; import GraphModelMigrationWriterChainDescriptor @@ -74,6 +79,7 @@ describe('v18 graph-model migration command', () => { expectedLiveHead: repository.liveHead, archiveRefName: ARCHIVE_REF, confirmation: confirmation(), + runtimeConformance: runtimeConformance, }, }); @@ -100,6 +106,7 @@ describe('v18 graph-model migration command', () => { expectedLiveHead: repository.liveHead, archiveRefName: ARCHIVE_REF, confirmation: confirmation(), + runtimeConformance: runtimeConformance, }, }); @@ -201,6 +208,21 @@ function confirmation(): GraphModelMigrationFinalizationConfirmation { }); } +function runtimeConformance( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationRuntimeConformanceResult | null { + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + return null; + } + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: scratchWriteResult.scratchRef, + scratchHead: scratchWriteResult.scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'unit-test-runtime-conformance', + fatalErrors: [], + }); +} + async function refExists(repositoryPath: string, refName: string): Promise { const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { cwd: repositoryPath, diff --git a/test/unit/scripts/v18-migration-finalizer.test.ts b/test/unit/scripts/v18-migration-finalizer.test.ts index 21bca7d1..6a651ec8 100644 --- a/test/unit/scripts/v18-migration-finalizer.test.ts +++ b/test/unit/scripts/v18-migration-finalizer.test.ts @@ -19,6 +19,9 @@ import GraphModelMigrationFinalizationRequest from '../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; import GraphModelMigrationFinalizationSafety from '../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; import GraphModelMigrationScratchRef from '../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; import { @@ -147,17 +150,19 @@ function finalizationRequest( scratchHead: string, gateResult: ReturnType, ): GraphModelMigrationFinalizationRequest { + const scratchRef = new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }); return new GraphModelMigrationFinalizationRequest({ liveRefName: LIVE_REF, expectedLiveHead: liveHead, observedLiveHead: liveHead, - scratchRef: new GraphModelMigrationScratchRef({ refName: SCRATCH_REF }), + scratchRef, scratchHead, archiveRefName: ARCHIVE_REF, confirmation: new GraphModelMigrationFinalizationConfirmation({ token: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, }), gateResult, + runtimeConformance: runtimeConformance(scratchRef, scratchHead), }); } @@ -192,6 +197,19 @@ function basis(): GenesisEquivalenceComparisonBasis { }); } +function runtimeConformance( + scratchRef: GraphModelMigrationScratchRef, + scratchHead: string, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef, + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'unit-test-runtime-conformance', + fatalErrors: [], + }); +} + async function refExists(repositoryPath: string, refName: string): Promise { const result = await execFileAsync('git', ['for-each-ref', '--format=%(refname)', refName], { cwd: repositoryPath, From 6b9ddaa1c6a1cbe7f702dc816c8ecd38c04256d3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:27:33 -0700 Subject: [PATCH 10/23] Docs: Close v18 content property migration audit --- CHANGELOG.md | 3 + docs/BEARING.md | 25 ++- .../v18-content-property-closeout-audit.md | 164 ++++++++++++++++++ .../INFRA_graph-model-migration-tool.md | 2 +- docs/method/backlog/v18.0.0/README.md | 4 +- ...18-content-property-closeout-audit.test.ts | 78 +++++++++ 6 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md create mode 100644 test/unit/scripts/v18-content-property-closeout-audit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d8374e50..42b6f0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration finalization now requires runtime conformance evidence matching the scratch ref and scratch head, so supplied equivalence readings alone cannot promote scratch output to live graph refs. +- V18 graph-model migration closeout now records the remaining raw + content/property compatibility files and adds an executable audit shape test + so new raw compatibility boundaries require deliberate review. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 999b93d9..dc2ff7df 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -130,6 +130,8 @@ The current v18 graph-model posture is: finalization absent by default. - Finalization now also requires post-migration runtime conformance evidence tied to the exact scratch ref and scratch head. +- The remaining raw content/property compatibility files are now listed in an + executable closeout audit. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -290,6 +292,11 @@ Slice 54 is complete on this branch. Finalization safety now rejects promotion without runtime conformance evidence for the exact scratch ref/head, which keeps supplied equivalence readings from masquerading as runtime readability. +Slice 55 is complete on this branch. The content/property closeout audit now +enumerates every current `src/domain` file that still touches raw legacy +content/property compatibility patterns and fails if that set drifts without +review. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -297,8 +304,8 @@ keeps supplied equivalence readings from masquerading as runtime readability. complete. - The source audit still finds raw property-map dependencies in named compatibility, serialization, replay, reducer/op-strategy, visible-scope, - logical-index, and migration-source boundaries. The audit command was - `rg -n "decodePropKey|decodeEdgePropKey|state\\.prop" src/domain`. + logical-index, and migration-source boundaries. The closeout audit pattern is + `decodePropKey|decodeEdgePropKey|state\\.prop|_content` over `src/domain`. - Temporal replay still extracts node snapshots from the raw legacy property map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. @@ -309,11 +316,12 @@ keeps supplied equivalence readings from masquerading as runtime readability. ship gate wired through finalization. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but - the scratch writer output still needs an equivalence gate. -- The next migration work must close the content/property audit and then build - real-history reading construction plus a real runtime conformance provider. - The command can orchestrate supplied readings, but it does not yet derive - those readings from migrated Git history. + the command still needs real-history reading construction from migrated Git + output. +- The next migration work must build real-history reading construction plus a + real runtime conformance provider. The command can orchestrate supplied + readings, but it does not yet derive those readings from migrated Git + history. ## Where We Are Heading @@ -438,4 +446,5 @@ and concrete checks live in `docs/invariants/`. [0201](design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md). - [x] 54. Prove post-migration runtime conformance: [0202](design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md). -- [ ] 55. Close the content/property migration audit. +- [x] 55. Close the content/property migration audit: + [0203](design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md). diff --git a/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md new file mode 100644 index 00000000..241072d4 --- /dev/null +++ b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md @@ -0,0 +1,164 @@ +--- +cycle: 0203 +task_id: V18_content_property_closeout_audit +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 55 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md + - docs/method/backlog/v18.0.0/PROTO_legacy-props-as-projection.md +--- + +# V18 Content Property Closeout Audit + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Close this v18 batch by making remaining raw content/property compatibility +boundaries explicit before the drift check. + +## Playback Questions + +- Which `src/domain` files still mention raw compatibility content or property + storage? +- Are those files named boundaries rather than accidental public read leaks? +- Does a test fail if a new raw compatibility boundary appears without review? +- Does BEARING record the remaining release blockers honestly? +- Does this closeout avoid claiming storage migration is complete? + +## Existing Shape + +Slices 46 through 54 built persisted-history fixtures, source inventory, +operation lowering, scratch writing, equivalence gating, finalization safety, +archive-preserving finalization, command wiring, and runtime conformance +evidence gating. + +The content/property storage plane is still not fully cut over. Legacy +`_content*` and raw property-map state remain compatibility inputs for the +current runtime and for migration evidence. + +## Chosen Boundary + +Run the raw compatibility audit over `src/domain` for: + +```text +decodePropKey|decodeEdgePropKey|state\.prop|_content +``` + +Then add an executable shape test that requires every matching file to appear +in this design document. + +## Current Raw Compatibility Files + +The current audited files are: + +- `src/domain/graph/LegacyContentPropertyKeys.ts` +- `src/domain/services/ContentAttachmentProjection.ts` +- `src/domain/services/CoordinateFactExport.ts` +- `src/domain/services/ImmutableSnapshot.ts` +- `src/domain/services/JoinReducer.ts` +- `src/domain/services/KeyCodec.ts` +- `src/domain/services/OpStrategies.ts` +- `src/domain/services/OpStrategy.ts` +- `src/domain/services/PatchBuilder.ts` +- `src/domain/services/PatchBuilderValidation.ts` +- `src/domain/services/PatchCommitter.ts` +- `src/domain/services/TemporalQuery.ts` +- `src/domain/services/VisibleStateScope.ts` +- `src/domain/services/index/LogicalIndexBuildService.ts` +- `src/domain/services/state/CheckpointSerializer.ts` +- `src/domain/services/state/StateDiff.ts` +- `src/domain/services/state/StateSerializer.ts` +- `src/domain/services/state/WarpState.ts` +- `src/domain/services/state/checkpointHelpers.ts` +- `src/domain/services/strand/StrandPatchService.ts` +- `src/domain/services/transfer/transferOps.ts` +- `src/domain/types/CoordinateComparison.ts` +- `src/domain/types/ops/EdgePropSet.ts` +- `src/domain/types/ops/NodePropSet.ts` +- `src/domain/types/ops/PropSet.ts` +- `src/domain/types/ops/propHelpers.ts` + +## Classification + +These files fall into bounded categories: + +- Legacy content compatibility key ownership: + `LegacyContentPropertyKeys`, `ContentAttachmentProjection`. +- Fact export and coordinate comparison over existing operation shapes: + `CoordinateFactExport`, `CoordinateComparison`. +- Runtime mutation and compatibility operation execution: + `JoinReducer`, `OpStrategies`, `OpStrategy`, `PatchBuilder`, + `PatchCommitter`, `StrandPatchService`, `transferOps`, and the op helper + classes. +- Guard, replay, serialization, snapshot, scope, and index boundaries: + `PatchBuilderValidation`, `TemporalQuery`, `ImmutableSnapshot`, + `VisibleStateScope`, `LogicalIndexBuildService`, `CheckpointSerializer`, + `StateDiff`, `StateSerializer`, `WarpState`, and `checkpointHelpers`. +- Codec ownership: `KeyCodec`. + +## Non-Goals + +- Do not remove legacy raw storage in this slice. +- Do not claim native runtime replay over migrated scratch history. +- Do not modify reducers or serializers during the audit. +- Do not add release version changes. + +## RED Plan + +Add a test that scans `src/domain` for the audit pattern and fails when the +matching file set differs from this documented list. + +## GREEN Plan + +Document every current match and make the test compare against the list. +Future work that adds or removes raw compatibility boundaries must update the +design evidence deliberately. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-content-property-closeout-audit.test.ts --reporter=verbose +npx eslint --no-warn-ignored test/unit/scripts/v18-content-property-closeout-audit.test.ts +npm run typecheck +npm run lint:md +npm run lint:sludge +npm run lint:semgrep +git diff --check +``` + +## Closeout Criteria + +- The raw compatibility file set is explicit. +- The design document contains every audited file path. +- A test fails on unreviewed boundary drift. +- BEARING names the remaining release blockers. + +## Closeout + +Slice 55 closes the branch batch, not the v18 migration program. The audit +proves that raw content/property compatibility surfaces are still present and +bounded. The remaining public-release work is to build real-history reading +construction and a real runtime conformance provider, then reduce this audited +legacy storage surface through subsequent migration slices. + +## SSJS Scorecard + +- Runtime-backed forms: green; no new runtime model was invented. +- Boundary validation: green; raw boundaries are enumerated. +- Behavior ownership: green; audit does not move behavior. +- Message parsing: green; no message parsing. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green; remaining gaps are explicit. diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index a25a7869..02a2a078 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -58,7 +58,7 @@ Remaining migration-tool work is intentionally ordered as: - slice 53: wire the end-to-end migration command (complete); - slice 54: prove post-migration runtime conformance (conformance evidence gate complete; real runtime replay provider still release-critical); -- slice 55: close the content/property migration audit. +- slice 55: close the content/property migration audit (complete). ## Starting points diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 1f9b555b..47aa75ab 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -106,4 +106,6 @@ non-destructive but now has persisted-history evidence: and optional finalization in order while keeping finalization off by default; - finalization now also requires runtime conformance evidence tied to the exact scratch ref and head, making the remaining real-runtime replay provider - an explicit release blocker instead of an implicit assumption. + an explicit release blocker instead of an implicit assumption; +- raw content/property compatibility boundaries are now enumerated by an + executable closeout audit so new raw boundaries require deliberate review. diff --git a/test/unit/scripts/v18-content-property-closeout-audit.test.ts b/test/unit/scripts/v18-content-property-closeout-audit.test.ts new file mode 100644 index 00000000..e69b595d --- /dev/null +++ b/test/unit/scripts/v18-content-property-closeout-audit.test.ts @@ -0,0 +1,78 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const RAW_COMPATIBILITY_PATTERN = /decodePropKey|decodeEdgePropKey|state\.prop|_content/u; +const DESIGN_DOC = 'docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md'; +const EXPECTED_RAW_COMPATIBILITY_FILES = Object.freeze([ + 'src/domain/graph/LegacyContentPropertyKeys.ts', + 'src/domain/services/ContentAttachmentProjection.ts', + 'src/domain/services/CoordinateFactExport.ts', + 'src/domain/services/ImmutableSnapshot.ts', + 'src/domain/services/JoinReducer.ts', + 'src/domain/services/KeyCodec.ts', + 'src/domain/services/OpStrategies.ts', + 'src/domain/services/OpStrategy.ts', + 'src/domain/services/PatchBuilder.ts', + 'src/domain/services/PatchBuilderValidation.ts', + 'src/domain/services/PatchCommitter.ts', + 'src/domain/services/TemporalQuery.ts', + 'src/domain/services/VisibleStateScope.ts', + 'src/domain/services/index/LogicalIndexBuildService.ts', + 'src/domain/services/state/CheckpointSerializer.ts', + 'src/domain/services/state/StateDiff.ts', + 'src/domain/services/state/StateSerializer.ts', + 'src/domain/services/state/WarpState.ts', + 'src/domain/services/state/checkpointHelpers.ts', + 'src/domain/services/strand/StrandPatchService.ts', + 'src/domain/services/transfer/transferOps.ts', + 'src/domain/types/CoordinateComparison.ts', + 'src/domain/types/ops/EdgePropSet.ts', + 'src/domain/types/ops/NodePropSet.ts', + 'src/domain/types/ops/PropSet.ts', + 'src/domain/types/ops/propHelpers.ts', +]); + +describe('v18 content/property closeout audit', () => { + it('keeps raw compatibility boundaries explicit and reviewed', async () => { + const matches = await findRawCompatibilityFiles('src/domain'); + + expect(matches).toEqual(EXPECTED_RAW_COMPATIBILITY_FILES); + }); + + it('documents every remaining raw compatibility boundary', async () => { + const doc = await readFile(DESIGN_DOC, 'utf8'); + + for (const file of EXPECTED_RAW_COMPATIBILITY_FILES) { + expect(doc).toContain(file); + } + }); +}); + +async function findRawCompatibilityFiles(root: string): Promise { + const files = await collectTypeScriptFiles(root); + const matches: string[] = []; + for (const file of files) { + const content = await readFile(file, 'utf8'); + if (RAW_COMPATIBILITY_PATTERN.test(content)) { + matches.push(file); + } + } + return Object.freeze(matches.sort()); +} + +async function collectTypeScriptFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await collectTypeScriptFiles(path)); + continue; + } + if (entry.isFile() && path.endsWith('.ts')) { + files.push(relative('', path)); + } + } + return Object.freeze(files); +} From f079e5c4d9ca13112a28bd2f12278833c47a0523 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:33:49 -0700 Subject: [PATCH 11/23] Feat: Build v17 fixture genesis readings --- CHANGELOG.md | 3 + docs/BEARING.md | 4 + ...v18-legacy-fixture-reading-construction.md | 39 ++++++ .../TRUST_genesis-replay-equivalence.md | 4 + .../V17GoldenGraphFixtureGenesisReading.ts | 117 ++++++++++++++++++ ...17GoldenGraphFixtureGenesisReading.test.ts | 38 ++++++ 6 files changed, 205 insertions(+) create mode 100644 docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md create mode 100644 src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts create mode 100644 test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b6f0c7..b37dd5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration closeout now records the remaining raw content/property compatibility files and adds an executable audit shape test so new raw compatibility boundaries require deliberate review. +- V18 genesis replay migration now includes a pure builder that projects the + v17 golden fixture manifest into `GenesisEquivalenceReading` facts with + deterministic boundary evidence. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index dc2ff7df..fa40d8fd 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -132,6 +132,8 @@ The current v18 graph-model posture is: tied to the exact scratch ref and scratch head. - The remaining raw content/property compatibility files are now listed in an executable closeout audit. +- Legacy fixture manifests can now be projected into genesis-equivalence + readings with deterministic patch-boundary evidence. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -448,3 +450,5 @@ and concrete checks live in `docs/invariants/`. [0202](design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md). - [x] 55. Close the content/property migration audit: [0203](design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md). +- [x] 56. Construct legacy fixture genesis readings: + [0204](design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md). diff --git a/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md b/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md new file mode 100644 index 00000000..fb7fea6e --- /dev/null +++ b/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md @@ -0,0 +1,39 @@ +--- +cycle: 0204 +task_id: V18_legacy_fixture_reading_construction +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 56 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Legacy Fixture Reading Construction + +## Hill + +Construct a `GenesisEquivalenceReading` from the restored v17 golden fixture +manifest instead of relying only on hand-authored compact fixture readings. + +## Chosen Boundary + +`V17GoldenGraphFixtureGenesisReading` is a pure migration-domain builder. It +projects manifest-visible facts into equivalence facts and assigns +deterministic boundary evidence from the manifest writer chains. + +## Closeout + +Slice 56 added the builder and test coverage over +`fixtures/v17/graph-model-golden/manifest.json`. The reading is still +manifest-declared evidence, not a full replay-derived read model. + +## Verification + +```text +npx vitest run test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md index 6c270ef0..e66359ef 100644 --- a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +++ b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md @@ -50,6 +50,10 @@ remaining trust work is to construct legacy and scratch readings from real Git history and replace test-supplied runtime conformance evidence with a real runtime replay provider. +Slice 56 added a pure reading builder for the v17 golden fixture manifest. It +is a bridge from persisted fixture metadata to equivalence facts, but it is +not yet a full replay-derived read model. + ## Starting points - `test/` diff --git a/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts b/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts new file mode 100644 index 00000000..b451beb9 --- /dev/null +++ b/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts @@ -0,0 +1,117 @@ +import GenesisEquivalenceBoundary from './GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceReading from './GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + type GenesisEquivalenceReadingFactKind, +} from './GenesisEquivalenceReadingFact.ts'; +import V17GoldenGraphFixtureManifest, { + V17_GOLDEN_CONTENT_FACT, + V17_GOLDEN_EDGE_FACT, + V17_GOLDEN_MULTI_WRITER_FACT, + V17_GOLDEN_NODE_FACT, + V17_GOLDEN_PROPERTY_FACT, + V17_GOLDEN_REMOVAL_FACT, + type V17GoldenGraphFixtureVisibleFact, +} from './V17GoldenGraphFixtureManifest.ts'; +import WarpError from '../errors/WarpError.ts'; + +const LEGACY_FIXTURE_READING_PREFIX = 'v17-golden-fixture'; + +type ProjectedFactFields = { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; +}; + +/** Builds a genesis-equivalence reading from a v17 golden fixture manifest. */ +export default class V17GoldenGraphFixtureGenesisReading { + /** Projects declared fixture facts into observer-visible equivalence facts. */ + build(manifest: V17GoldenGraphFixtureManifest): GenesisEquivalenceReading { + const checkedManifest = requireManifest(manifest); + return new GenesisEquivalenceReading({ + readingId: `${LEGACY_FIXTURE_READING_PREFIX}:${checkedManifest.fixtureId}`, + facts: checkedManifest.visibleFacts.map((fact, index) => projectFact(checkedManifest, fact, index)), + }); + } +} + +function projectFact( + manifest: V17GoldenGraphFixtureManifest, + fact: V17GoldenGraphFixtureVisibleFact, + index: number, +): GenesisEquivalenceReadingFact { + const projected = projectionFor(fact); + return new GenesisEquivalenceReadingFact({ + kind: projected.kind, + factKey: projected.factKey, + fieldPath: projected.fieldPath, + value: projected.value, + boundary: boundaryFor(manifest, index), + }); +} + +function projectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { + if (fact.kind === V17_GOLDEN_NODE_FACT) { + return projection({ kind: 'node', factKey: fact.key, fieldPath: 'visibility', value: 'visible' }); + } + if (fact.kind === V17_GOLDEN_EDGE_FACT) { + return projection({ kind: 'edge', factKey: fact.key, fieldPath: 'visibility', value: 'visible' }); + } + return compatibilityProjectionFor(fact); +} + +function compatibilityProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { + if (fact.kind === V17_GOLDEN_PROPERTY_FACT) { + return projection({ kind: 'property', factKey: fact.key, fieldPath: 'value', value: fact.description }); + } + if (fact.kind === V17_GOLDEN_CONTENT_FACT) { + return projection({ + kind: 'content-attachment', + factKey: fact.key, + fieldPath: 'payload.oid', + value: `fixture-content:${fact.key}`, + }); + } + return nonVisibleLifecycleProjectionFor(fact); +} + +function nonVisibleLifecycleProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { + if (fact.kind === V17_GOLDEN_REMOVAL_FACT) { + return projection({ kind: 'node', factKey: fact.key, fieldPath: 'visibility', value: 'removed' }); + } + if (fact.kind === V17_GOLDEN_MULTI_WRITER_FACT) { + return projection({ + kind: 'property', + factKey: fact.key, + fieldPath: 'coverage', + value: fact.description, + }); + } + throw new WarpError('unsupported v17 fixture visible fact kind', 'E_VALIDATION'); +} + +function projection(fields: ProjectedFactFields): ProjectedFactFields { + return Object.freeze(fields); +} + +function boundaryFor( + manifest: V17GoldenGraphFixtureManifest, + index: number, +): GenesisEquivalenceBoundary { + const chain = manifest.writerChains[index % manifest.writerChains.length]; + if (chain === undefined) { + throw new WarpError('v17 fixture manifest must contain writer chain evidence', 'E_VALIDATION'); + } + return new GenesisEquivalenceBoundary({ + writerId: chain.writerId, + patchId: chain.expectedHead, + operationIndex: index, + }); +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new WarpError('manifest must be a V17GoldenGraphFixtureManifest', 'E_VALIDATION'); + } + return manifest; +} diff --git a/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts new file mode 100644 index 00000000..f0ee7953 --- /dev/null +++ b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import V17GoldenGraphFixtureGenesisReading + from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); + +describe('V17GoldenGraphFixtureGenesisReading', () => { + it('projects the v17 golden fixture manifest into genesis equivalence facts', async () => { + const manifest = parseV17GoldenGraphFixtureManifestJson( + await readFile(FIXTURE_MANIFEST_PATH, 'utf8'), + ); + + const reading = new V17GoldenGraphFixtureGenesisReading().build(manifest); + + expect(reading.readingId).toBe('v17-golden-fixture:v17-golden-graph-model-001'); + expect(reading.facts.map((fact) => fact.toKey())).toEqual([ + 'content-attachment\0node:alpha:_content\0payload.oid', + 'edge\0node:alpha->node:beta:relates\0visibility', + 'node\0node:alpha\0visibility', + 'node\0node:removed\0visibility', + 'property\0node:alpha:title\0value', + 'property\0writers:alice+bob\0coverage', + ]); + expect(reading.facts.map((fact) => fact.boundary?.writerId)).toEqual([ + 'alice', + 'bob', + 'bob', + 'bob', + 'alice', + 'alice', + ]); + }); +}); From 5a55ef0357b7e07c0531eaf59e7195d1b6f1da50 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:35:44 -0700 Subject: [PATCH 12/23] Feat: Build scratch migration genesis readings --- CHANGELOG.md | 3 + docs/BEARING.md | 4 + ...-scratch-operation-reading-construction.md | 39 ++++ .../TRUST_genesis-replay-equivalence.md | 4 + ...raphModelMigrationScratchReadingBuilder.ts | 209 ++++++++++++++++++ .../v18-scratch-reading-builder.test.ts | 79 +++++++ 6 files changed, 338 insertions(+) create mode 100644 docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts create mode 100644 test/unit/scripts/v18-scratch-reading-builder.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b37dd5a9..f11fc9b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 genesis replay migration now includes a pure builder that projects the v17 golden fixture manifest into `GenesisEquivalenceReading` facts with deterministic boundary evidence. +- V18 graph-model migration now includes a scratch reading builder that reads + scratch operation commits and projects them into genesis-equivalence facts + with scratch commit boundary evidence. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index fa40d8fd..4540a738 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -134,6 +134,8 @@ The current v18 graph-model posture is: executable closeout audit. - Legacy fixture manifests can now be projected into genesis-equivalence readings with deterministic patch-boundary evidence. +- Scratch migration operation commits can now be projected into + genesis-equivalence readings with scratch commit boundary evidence. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -452,3 +454,5 @@ and concrete checks live in `docs/invariants/`. [0203](design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md). - [x] 56. Construct legacy fixture genesis readings: [0204](design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md). +- [x] 57. Construct scratch operation genesis readings: + [0205](design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md). diff --git a/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md b/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md new file mode 100644 index 00000000..6fb5e638 --- /dev/null +++ b/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md @@ -0,0 +1,39 @@ +--- +cycle: 0205 +task_id: V18_scratch_operation_reading_construction +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 57 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Scratch Operation Reading Construction + +## Hill + +Build `GenesisEquivalenceReading` values from scratch migration operation +commits. + +## Chosen Boundary + +`GraphModelMigrationScratchReadingBuilder` is a script-layer Git adapter. It +reads `migration-operation.txt` from scratch commits and projects operation +facts into equivalence facts with scratch commit boundary evidence. + +## Closeout + +Slice 57 removes another hand-authored test fixture dependency. The builder is +still operation-derived; it is not yet normal runtime replay over native graph +history. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-scratch-reading-builder.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md index e66359ef..34dc1baf 100644 --- a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +++ b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md @@ -54,6 +54,10 @@ Slice 56 added a pure reading builder for the v17 golden fixture manifest. It is a bridge from persisted fixture metadata to equivalence facts, but it is not yet a full replay-derived read model. +Slice 57 added a scratch reading builder over migration-operation commits. It +constructs equivalence facts from scratch Git history, but remains +operation-derived rather than normal runtime replay. + ## Starting points - `test/` diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts new file mode 100644 index 00000000..3dd1e8c2 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts @@ -0,0 +1,209 @@ +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + type GenesisEquivalenceReadingFactKind, +} from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import type { GraphModelMigrationPlannedGraphOperationKind } + from '../../../../src/domain/migrations/GraphModelMigrationPlannedGraphOperation.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const OPERATION_TREE_PATH = 'migration-operation.txt'; + +export type GraphModelMigrationScratchReadingBuilderOptions = { + readonly repositoryPath: string; + readonly scratchRefName: string; + readonly readingId: string; +}; + +class ScratchOperationPayload { + constructor( + readonly kind: GraphModelMigrationPlannedGraphOperationKind, + readonly sourceKey: string, + readonly targetKey: string, + ) { + Object.freeze(this); + } +} + +export class GraphModelMigrationScratchReadingBuilderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchReadingBuilderError'; + } +} + +/** Builds an equivalence reading from scratch migration operation commits. */ +export async function buildGraphModelMigrationScratchReading( + options: GraphModelMigrationScratchReadingBuilderOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchRef = new GraphModelMigrationScratchRef({ refName: options.scratchRefName }); + const commitIds = await gitLines(repositoryPath, ['rev-list', '--reverse', scratchRef.refName]); + const facts: GenesisEquivalenceReadingFact[] = []; + let operationIndex = 0; + for (const commitId of commitIds) { + const payload = parseScratchOperationPayload( + await gitText(repositoryPath, ['show', `${commitId}:${OPERATION_TREE_PATH}`]), + ); + facts.push(factFromPayload(payload, commitId, operationIndex)); + operationIndex += 1; + } + return new GenesisEquivalenceReading({ + readingId: requireNonEmptyString(options.readingId, 'readingId'), + facts, + }); +} + +function factFromPayload( + payload: ScratchOperationPayload, + commitId: string, + operationIndex: number, +): GenesisEquivalenceReadingFact { + const projected = projectedFactFromPayload(payload); + return new GenesisEquivalenceReadingFact({ + kind: projected.kind, + factKey: projected.factKey, + fieldPath: projected.fieldPath, + value: projected.value, + boundary: new GenesisEquivalenceBoundary({ + writerId: 'scratch-migration', + patchId: commitId, + operationIndex, + }), + }); +} + +function projectedFactFromPayload(payload: ScratchOperationPayload): { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; +} { + if (payload.kind === 'node-record') { + return projected('node', payload.targetKey, 'visibility', 'visible'); + } + if (payload.kind === 'edge-record') { + return projected('edge', payload.targetKey, 'visibility', 'visible'); + } + return compatibilityFactFromPayload(payload); +} + +function compatibilityFactFromPayload(payload: ScratchOperationPayload): { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; +} { + if (payload.kind === 'property') { + return projected('property', payload.targetKey, 'value', `migration-source:${payload.sourceKey}`); + } + if (payload.kind === 'content-attachment') { + return projected( + 'content-attachment', + payload.targetKey, + 'payload.oid', + `migration-source:${payload.sourceKey}`, + ); + } + throw new GraphModelMigrationScratchReadingBuilderError(`unsupported scratch operation kind ${payload.kind}`); +} + +function projected( + kind: GenesisEquivalenceReadingFactKind, + factKey: string, + fieldPath: string, + value: string, +) { + return Object.freeze({ kind, factKey, fieldPath, value }); +} + +function parseScratchOperationPayload(text: string): ScratchOperationPayload { + const lines = text.split('\n').filter((line) => line.length > 0); + if (lines[0] !== 'git-warp-v18-migration-operation-v1') { + throw new GraphModelMigrationScratchReadingBuilderError('scratch operation payload header is unsupported'); + } + const fields = payloadFields(lines.slice(1)); + return new ScratchOperationPayload( + requireKind(fields.get('kind')), + requireField(fields, 'source-key-utf8-hex'), + requireField(fields, 'target-key-utf8-hex'), + ); +} + +function payloadFields(lines: readonly string[]): ReadonlyMap { + const fields = new Map(); + for (const line of lines) { + const separator = line.indexOf(' '); + if (separator <= 0) { + throw new GraphModelMigrationScratchReadingBuilderError(`invalid scratch operation line ${line}`); + } + fields.set(line.slice(0, separator), line.slice(separator + 1)); + } + return fields; +} + +function requireKind(value: string | undefined): GraphModelMigrationPlannedGraphOperationKind { + if ( + value === 'node-record' + || value === 'edge-record' + || value === 'property' + || value === 'content-attachment' + ) { + return value; + } + throw new GraphModelMigrationScratchReadingBuilderError('scratch operation kind is unsupported'); +} + +function requireField(fields: ReadonlyMap, fieldName: string): string { + const encoded = fields.get(fieldName); + if (encoded === undefined) { + throw new GraphModelMigrationScratchReadingBuilderError(`scratch operation is missing ${fieldName}`); + } + return utf8FromHex(encoded); +} + +function utf8FromHex(hex: string): string { + if (hex.length % 2 !== 0) { + throw new GraphModelMigrationScratchReadingBuilderError('hex field has odd length'); + } + const bytes: number[] = []; + for (let index = 0; index < hex.length; index += 2) { + bytes.push(parseHexByte(hex.slice(index, index + 2))); + } + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +function parseHexByte(hex: string): number { + const value = Number.parseInt(hex, 16); + if (!Number.isInteger(value)) { + throw new GraphModelMigrationScratchReadingBuilderError(`invalid hex byte ${hex}`); + } + return value; +} + +async function gitLines(cwd: string, args: readonly string[]): Promise { + const output = await gitText(cwd, args); + if (output.length === 0) { + return Object.freeze([]); + } + return Object.freeze(output.split('\n').filter((line) => line.length > 0)); +} + +async function gitText(cwd: string, args: readonly string[]): Promise { + const result = await runMigrationGit(cwd, args, null); + if (!result.ok()) { + throw new GraphModelMigrationScratchReadingBuilderError(`git ${args.join(' ')} failed: ${result.stderr}`); + } + return result.stdout.trim(); +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchReadingBuilderError(`${name} must be a non-empty string`); + } + return value; +} diff --git a/test/unit/scripts/v18-scratch-reading-builder.test.ts b/test/unit/scripts/v18-scratch-reading-builder.test.ts new file mode 100644 index 00000000..8e109efd --- /dev/null +++ b/test/unit/scripts/v18-scratch-reading-builder.test.ts @@ -0,0 +1,79 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { buildGraphModelMigrationScratchReading } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; +import { writeGraphModelMigrationScratchHistory } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; + +const execFileAsync = promisify(execFile); +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 scratch reading builder', () => { + it('builds genesis equivalence facts from scratch operation commits', async () => { + const repositoryPath = await initializedRepository(); + await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + operation('node-record', 'node:a', 'node:a'), + operation('property', 'node:a/title', 'property:node:a/title'), + ]), + }); + + const reading = await buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:v18', + }); + + expect(reading.facts.map((fact) => fact.toKey())).toEqual([ + 'node\0node:a\0visibility', + 'property\0property:node:a/title\0value', + ]); + expect(reading.facts.map((fact) => fact.value)).toEqual([ + 'visible', + 'migration-source:node:a/title', + ]); + expect(reading.facts.every((fact) => fact.boundary?.writerId === 'scratch-migration')).toBe(true); + }); +}); + +async function initializedRepository(): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v18-scratch-reading-')); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:scratch', + }), + operations, + }); +} + +function operation( + kind: 'node-record' | 'property', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); +} From eca210336a6a0e9f8d12aca3995651647fffa98a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:40:07 -0700 Subject: [PATCH 13/23] Feat: Add v18 migration command reading providers --- CHANGELOG.md | 3 + docs/BEARING.md | 4 ++ .../v18-command-reading-providers.md | 33 +++++++++++ .../INFRA_graph-model-migration-tool.md | 3 + .../graph-model/GraphModelMigrationCommand.ts | 38 +++++++++++-- .../scripts/v18-migration-command.test.ts | 56 +++++++++++++++++++ 6 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f11fc9b0..a13ca7c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration now includes a scratch reading builder that reads scratch operation commits and projects them into genesis-equivalence facts with scratch commit boundary evidence. +- V18 graph-model migration command wiring now accepts reading providers so + legacy and scratch equivalence readings can be constructed after scratch + history has been written. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 4540a738..2c220aa3 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -136,6 +136,8 @@ The current v18 graph-model posture is: readings with deterministic patch-boundary evidence. - Scratch migration operation commits can now be projected into genesis-equivalence readings with scratch commit boundary evidence. +- The migration command can now construct equivalence readings through command + reading providers after scratch writing. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -456,3 +458,5 @@ and concrete checks live in `docs/invariants/`. [0204](design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md). - [x] 57. Construct scratch operation genesis readings: [0205](design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md). +- [x] 58. Add command reading providers: + [0206](design/0206-v18-command-reading-providers/v18-command-reading-providers.md). diff --git a/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md b/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md new file mode 100644 index 00000000..aa864b5c --- /dev/null +++ b/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md @@ -0,0 +1,33 @@ +--- +cycle: 0206 +task_id: V18_command_reading_providers +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 58 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Command Reading Providers + +## Hill + +Let the migration command construct equivalence readings after scratch writing +instead of requiring pre-built readings. + +## Closeout + +Slice 58 added command reading providers. The command still accepts explicit +readings for focused tests, but can now call a legacy provider and a +scratch-provider after scratch history exists. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 02a2a078..7d6edfed 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -59,6 +59,9 @@ Remaining migration-tool work is intentionally ordered as: - slice 54: prove post-migration runtime conformance (conformance evidence gate complete; real runtime replay provider still release-critical); - slice 55: close the content/property migration audit (complete). +- slice 56: construct legacy fixture genesis readings (complete); +- slice 57: construct scratch operation genesis readings (complete); +- slice 58: add command reading providers (complete). ## Starting points diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts index 26afca9b..0afa46c7 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -35,6 +35,13 @@ export type GraphModelMigrationRuntimeConformanceProvider = ( scratchWriteResult: GraphModelMigrationScratchWriteResult, ) => GraphModelMigrationRuntimeConformanceResult | null; +export type GraphModelMigrationCommandReadingProviders = { + readonly legacyReading: () => Promise; + readonly scratchReading: ( + scratchWriteResult: GraphModelMigrationScratchWriteResult, + ) => Promise; +}; + export type GraphModelMigrationCommandFinalizationOptions = { readonly liveRefName: string; readonly expectedLiveHead: string; @@ -48,8 +55,9 @@ export type GraphModelMigrationCommandOptions = { readonly dryRunRequest: DryRunGraphModelMigrationPlanRequest; readonly scratchRefName: string; readonly equivalenceBasis: GenesisEquivalenceComparisonBasis; - readonly legacyReading: GenesisEquivalenceReading; - readonly scratchReading: GenesisEquivalenceReading; + readonly legacyReading: GenesisEquivalenceReading | null; + readonly scratchReading: GenesisEquivalenceReading | null; + readonly readingProviders: GraphModelMigrationCommandReadingProviders | null; readonly finalization: GraphModelMigrationCommandFinalizationOptions | null; }; @@ -94,10 +102,11 @@ export async function runGraphModelMigrationCommand( return new GraphModelMigrationCommandResult(dryRunPlan, loweringResult, scratchWriteResult, null, null); } + const readings = await resolveReadings(options, scratchWriteResult); const gateResult = new GenesisEquivalenceGate().evaluate( requireBasis(options.equivalenceBasis), - requireReading(options.legacyReading, 'legacyReading'), - requireReading(options.scratchReading, 'scratchReading'), + readings.legacyReading, + readings.scratchReading, ); if (options.finalization === null) { return new GraphModelMigrationCommandResult( @@ -124,6 +133,25 @@ export async function runGraphModelMigrationCommand( ); } +async function resolveReadings( + options: GraphModelMigrationCommandOptions, + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): Promise<{ + readonly legacyReading: GenesisEquivalenceReading; + readonly scratchReading: GenesisEquivalenceReading; +}> { + if (options.readingProviders !== null) { + return Object.freeze({ + legacyReading: await options.readingProviders.legacyReading(), + scratchReading: await options.readingProviders.scratchReading(scratchWriteResult), + }); + } + return Object.freeze({ + legacyReading: requireReading(options.legacyReading, 'legacyReading'), + scratchReading: requireReading(options.scratchReading, 'scratchReading'), + }); +} + async function runFinalization(options: { readonly repositoryPath: string; readonly scratchWriteResult: GraphModelMigrationScratchWriteResult; @@ -186,7 +214,7 @@ function requireBasis( return basis; } -function requireReading(reading: GenesisEquivalenceReading, label: string): GenesisEquivalenceReading { +function requireReading(reading: GenesisEquivalenceReading | null, label: string): GenesisEquivalenceReading { if (!(reading instanceof GenesisEquivalenceReading)) { throw new GraphModelMigrationCommandError(`${label} must be a GenesisEquivalenceReading`); } diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index b4237d38..74e5a975 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -8,10 +8,18 @@ import { describe, expect, it } from 'vitest'; import { runGraphModelMigrationCommand, } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts'; +import { buildGraphModelMigrationScratchReading } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; import DryRunGraphModelMigrationPlanRequest from '../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GenesisEquivalenceBoundary + from '../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; import GenesisEquivalenceComparisonBasis from '../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceReading + from '../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact + from '../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; import GraphModelMigrationFinalizationConfirmation, { V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, @@ -51,6 +59,7 @@ describe('v18 graph-model migration command', () => { equivalenceBasis: basis(), legacyReading: fixture.legacyReading, scratchReading: fixture.migratedReading, + readingProviders: null, finalization: null, }); @@ -74,6 +83,7 @@ describe('v18 graph-model migration command', () => { equivalenceBasis: basis(), legacyReading: fixture.legacyReading, scratchReading: fixture.migratedReading, + readingProviders: null, finalization: { liveRefName: LIVE_REF, expectedLiveHead: repository.liveHead, @@ -101,6 +111,7 @@ describe('v18 graph-model migration command', () => { equivalenceBasis: basis(), legacyReading: fixture.legacyReading, scratchReading: fixture.migratedReading, + readingProviders: null, finalization: { liveRefName: LIVE_REF, expectedLiveHead: repository.liveHead, @@ -118,6 +129,32 @@ describe('v18 graph-model migration command', () => { expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); }); + + it('can construct readings through command-owned providers after scratch writing', async () => { + const repository = await initializedRepository('git-warp-v18-command-providers-'); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => legacyNodeReading(), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath: repository, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:provider', + }), + }, + finalization: null, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(true); + expect(result.gateResult?.proofResult.summary.legacyFactCount).toBe(1); + expect(result.gateResult?.proofResult.summary.migratedFactCount).toBe(1); + }); }); type CommandFixtureRepository = { @@ -162,6 +199,25 @@ function dryRunRequest(): DryRunGraphModelMigrationPlanRequest { }); } +function legacyNodeReading(): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ + readingId: 'legacy:provider', + facts: [ + new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: 'node:article', + fieldPath: 'visibility', + value: 'visible', + boundary: new GenesisEquivalenceBoundary({ + writerId: 'alice', + patchId: 'patch:alice:0', + operationIndex: 0, + }), + }), + ], + }); +} + function sourceInventory(): GraphModelMigrationSourceInventory { return new GraphModelMigrationSourceInventory({ graphId: 'v17-golden-graph', From bd39d1b9cf61c2f49733e64dfafc20aa558e555f Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:44:27 -0700 Subject: [PATCH 14/23] Feat: Add v18 scratch runtime conformance provider --- CHANGELOG.md | 3 + docs/BEARING.md | 18 +- ...18-scratch-runtime-conformance-provider.md | 39 ++++ .../INFRA_graph-model-migration-tool.md | 2 + .../graph-model/GraphModelMigrationCommand.ts | 8 +- ...rationScratchRuntimeConformanceProvider.ts | 171 ++++++++++++++++++ .../scripts/v18-migration-command.test.ts | 4 +- ...ratch-runtime-conformance-provider.test.ts | 147 +++++++++++++++ 8 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts create mode 100644 test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a13ca7c0..c4e5ef92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration command wiring now accepts reading providers so legacy and scratch equivalence readings can be constructed after scratch history has been written. +- V18 graph-model migration now includes an adapter-level scratch runtime + conformance provider that verifies scratch refs still point at their + expected heads and can be read back into genesis evidence. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 2c220aa3..0d1f85eb 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -138,6 +138,9 @@ The current v18 graph-model posture is: genesis-equivalence readings with scratch commit boundary evidence. - The migration command can now construct equivalence readings through command reading providers after scratch writing. +- Scratch migration runtime conformance now has an adapter-level provider that + verifies the scratch ref still points at the expected head and reads + operation commits back into genesis evidence. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -315,19 +318,18 @@ review. - Temporal replay still extracts node snapshots from the raw legacy property map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. -- The v18 migration tool can now write scratch history and gate supplied - readings, but it does not yet replay scratch Git output into - observer-visible readings for equivalence. +- The v18 migration tool can now write scratch history and derive scratch + operation readings, but it does not yet open scratch output through the full + production graph runtime. - Genesis equivalence is a gate vocabulary now, but not yet a full real-history ship gate wired through finalization. - Compact equivalence fixtures are not enough by themselves. The golden v17 fixture now restores Git refs and source inventory consumes those refs, but the command still needs real-history reading construction from migrated Git output. -- The next migration work must build real-history reading construction plus a - real runtime conformance provider. The command can orchestrate supplied - readings, but it does not yet derive those readings from migrated Git - history. +- The next migration work must wire real-history reading and runtime + conformance providers through finalization, then broaden the evidence beyond + scratch operation readback where the production runtime needs it. ## Where We Are Heading @@ -460,3 +462,5 @@ and concrete checks live in `docs/invariants/`. [0205](design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md). - [x] 58. Add command reading providers: [0206](design/0206-v18-command-reading-providers/v18-command-reading-providers.md). +- [x] 59. Add a scratch runtime conformance provider: + [0207](design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md). diff --git a/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md b/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md new file mode 100644 index 00000000..867d4c57 --- /dev/null +++ b/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md @@ -0,0 +1,39 @@ +--- +cycle: 0207 +task_id: V18_scratch_runtime_conformance_provider +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 59 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Scratch Runtime Conformance Provider + +## Hill + +Replace test-supplied runtime conformance evidence with an adapter-level +provider that reads scratch migration history back from Git before +finalization can trust it. + +## Closeout + +Slice 59 added `GraphModelMigrationScratchRuntimeConformanceProvider`. The +provider verifies that the scratch ref still points at the expected scratch +head, then builds scratch genesis-equivalence evidence from the actual +operation commits. + +This is intentionally an operation-history readback provider. It does not yet +claim full production runtime replay through the normal graph-opening path. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts --reporter=verbose +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 7d6edfed..97cae374 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -62,6 +62,8 @@ Remaining migration-tool work is intentionally ordered as: - slice 56: construct legacy fixture genesis readings (complete); - slice 57: construct scratch operation genesis readings (complete); - slice 58: add command reading providers (complete). +- slice 59: add a scratch runtime conformance provider (operation-history + readback complete; production runtime replay still release-critical). ## Starting points diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts index 0afa46c7..0b274ddf 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -33,7 +33,7 @@ import { runMigrationGit } from './GitMigrationCommandRunner.ts'; export type GraphModelMigrationRuntimeConformanceProvider = ( scratchWriteResult: GraphModelMigrationScratchWriteResult, -) => GraphModelMigrationRuntimeConformanceResult | null; +) => Promise; export type GraphModelMigrationCommandReadingProviders = { readonly legacyReading: () => Promise; @@ -174,7 +174,7 @@ async function runFinalization(options: { archiveRefName: options.finalization.archiveRefName, confirmation: options.finalization.confirmation, gateResult: options.gateResult, - runtimeConformance: runtimeConformanceFromProvider( + runtimeConformance: await runtimeConformanceFromProvider( options.finalization.runtimeConformance, options.scratchWriteResult, ), @@ -189,9 +189,9 @@ async function runFinalization(options: { function runtimeConformanceFromProvider( provider: GraphModelMigrationRuntimeConformanceProvider | null, scratchWriteResult: GraphModelMigrationScratchWriteResult, -): GraphModelMigrationRuntimeConformanceResult | null { +): Promise { if (provider === null) { - return null; + return Promise.resolve(null); } return provider(scratchWriteResult); } diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts new file mode 100644 index 00000000..a0903b26 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts @@ -0,0 +1,171 @@ +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { buildGraphModelMigrationScratchReading } + from './GraphModelMigrationScratchReadingBuilder.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const WITNESS_ID = 'git-warp-v18-scratch-operation-readback-v1'; + +export type GraphModelMigrationScratchRuntimeConformanceProviderOptions = { + readonly repositoryPath: string; +}; + +export type GraphModelMigrationScratchRuntimeConformanceProvider = ( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +) => Promise; + +/** Builds runtime conformance evidence by reading scratch operation history back from Git. */ +export function createGraphModelMigrationScratchRuntimeConformanceProvider( + options: GraphModelMigrationScratchRuntimeConformanceProviderOptions, +): GraphModelMigrationScratchRuntimeConformanceProvider { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + return async (scratchWriteResult) => await verifyGraphModelMigrationScratchRuntimeConformance({ + repositoryPath, + scratchWriteResult, + }); +} + +/** Verifies that scratch migration output is still readable at its expected head. */ +export async function verifyGraphModelMigrationScratchRuntimeConformance(options: { + readonly repositoryPath: string; + readonly scratchWriteResult: GraphModelMigrationScratchWriteResult; +}): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchWriteResult = requireScratchWriteResult(options.scratchWriteResult); + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + return null; + } + const observedHead = await observedScratchHead(repositoryPath, scratchWriteResult.scratchRef); + if (observedHead === null) { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_REF_UNREADABLE', + `scratch migration ref ${scratchWriteResult.scratchRef.refName} is not readable`, + ); + } + if (observedHead !== scratchWriteResult.scratchHead) { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_HEAD_CHANGED', + `scratch migration ref ${scratchWriteResult.scratchRef.refName} no longer points at expected head`, + ); + } + return await readBackScratchHistory(repositoryPath, scratchWriteResult); +} + +async function readBackScratchHistory( + repositoryPath: string, + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): Promise { + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + throw new GraphModelMigrationScratchRuntimeConformanceProviderError( + 'scratch output must be present before readback', + ); + } + try { + const reading = await buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName: scratchWriteResult.scratchRef.refName, + readingId: 'scratch-runtime-conformance', + }); + if (reading.facts.length !== scratchWriteResult.writtenPatches.length) { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_OPERATION_COUNT', + 'scratch readback fact count does not match written operation count', + ); + } + return passedResult(scratchWriteResult.scratchRef, scratchWriteResult.scratchHead, reading.facts.length); + } catch { + return failedResult( + scratchWriteResult.scratchRef, + scratchWriteResult.scratchHead, + 'E_RUNTIME_CONFORMANCE_SCRATCH_HISTORY_UNREADABLE', + 'scratch migration history cannot be read back as genesis evidence', + ); + } +} + +async function observedScratchHead( + repositoryPath: string, + scratchRef: GraphModelMigrationScratchRef, +): Promise { + const result = await runMigrationGit( + repositoryPath, + ['show-ref', '--verify', '--hash', scratchRef.refName], + null, + ); + if (!result.ok()) { + return null; + } + const head = result.stdout.trim(); + if (head.length === 0) { + return null; + } + return head; +} + +function passedResult( + scratchRef: GraphModelMigrationScratchRef, + scratchHead: string, + factCount: number, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef, + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: `${WITNESS_ID} facts=${factCount}`, + fatalErrors: [], + }); +} + +function failedResult( + scratchRef: GraphModelMigrationScratchRef, + scratchHead: string, + code: string, + message: string, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef, + scratchHead, + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: WITNESS_ID, + fatalErrors: [GraphModelMigrationNotice.fatal(code, message)], + }); +} + +function requireScratchWriteResult( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationScratchWriteResult { + if (!(scratchWriteResult instanceof GraphModelMigrationScratchWriteResult)) { + throw new GraphModelMigrationScratchRuntimeConformanceProviderError( + 'scratchWriteResult must be a GraphModelMigrationScratchWriteResult', + ); + } + return scratchWriteResult; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchRuntimeConformanceProviderError(`${name} must be a non-empty string`); + } + return value; +} + +export class GraphModelMigrationScratchRuntimeConformanceProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchRuntimeConformanceProviderError'; + } +} diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index 74e5a975..4cf93033 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -264,9 +264,9 @@ function confirmation(): GraphModelMigrationFinalizationConfirmation { }); } -function runtimeConformance( +async function runtimeConformance( scratchWriteResult: GraphModelMigrationScratchWriteResult, -): GraphModelMigrationRuntimeConformanceResult | null { +): Promise { if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { return null; } diff --git a/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts b/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts new file mode 100644 index 00000000..5e262e66 --- /dev/null +++ b/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts @@ -0,0 +1,147 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { createGraphModelMigrationScratchRuntimeConformanceProvider } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts'; +import { writeGraphModelMigrationScratchHistory } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import { runMigrationGit } + from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; + +const execFileAsync = promisify(execFile); +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; + +describe('v18 scratch runtime conformance provider', () => { + it('passes when scratch history reads back at the expected head', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-conformance-pass-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:a', 'node:a')]), + }); + const provider = createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED); + expect(result?.allowsFinalization()).toBe(true); + expect(result?.scratchHead).toBe(writeResult.scratchHead); + expect(result?.witness).toBe('git-warp-v18-scratch-operation-readback-v1 facts=1'); + }); + + it('fails closed when the scratch ref is no longer readable', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-conformance-missing-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:a', 'node:a')]), + }); + await gitOk(repositoryPath, ['update-ref', '-d', SCRATCH_REF], null); + const provider = createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.allowsFinalization()).toBe(false); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_SCRATCH_REF_UNREADABLE', + ]); + }); + + it('fails closed when scratch operation payloads cannot be read back', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-conformance-corrupt-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:a', 'node:a')]), + }); + const badHead = await writeBadScratchCommit(repositoryPath); + await gitOk(repositoryPath, ['update-ref', SCRATCH_REF, badHead], null); + const provider = createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath, + }); + + const result = await provider(new GraphModelMigrationScratchWriteResult({ + scratchRef: writeResult.scratchRef, + scratchHead: badHead, + writtenPatches: writeResult.writtenPatches, + warnings: [], + fatalErrors: [], + })); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.allowsFinalization()).toBe(false); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_CONFORMANCE_SCRATCH_HISTORY_UNREADABLE', + ]); + }); +}); + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: 'v17-golden-graph', + basisId: 'basis:scratch', + }), + operations, + }); +} + +function operation( + kind: 'node-record', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); +} + +async function writeBadScratchCommit(repositoryPath: string): Promise { + const blobOid = await gitOk(repositoryPath, ['hash-object', '-w', '--stdin'], 'not a scratch payload\n'); + const treeOid = await gitOk( + repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\tmigration-operation.txt\n`, + ); + return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); +} + +async function gitOk( + repositoryPath: string, + args: readonly string[], + input: string | null, +): Promise { + const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); + expect(result.ok()).toBe(true); + return result.stdout.trim(); +} From cee5ec4c09d3773218cb60e1be9f54b53cc05ae7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:45:57 -0700 Subject: [PATCH 15/23] Test: Prove v18 command provider finalization --- CHANGELOG.md | 3 ++ docs/BEARING.md | 5 +++ .../v18-command-provider-finalization.md | 34 +++++++++++++++++++ .../INFRA_graph-model-migration-tool.md | 2 ++ .../scripts/v18-migration-command.test.ts | 22 ++++++++---- 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e5ef92..f9cb429b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration now includes an adapter-level scratch runtime conformance provider that verifies scratch refs still point at their expected heads and can be read back into genesis evidence. +- V18 graph-model migration command finalization is now covered with + command-owned reading providers and scratch runtime conformance instead of + test-supplied finalization proof. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 0d1f85eb..2fe8374b 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -141,6 +141,9 @@ The current v18 graph-model posture is: - Scratch migration runtime conformance now has an adapter-level provider that verifies the scratch ref still points at the expected head and reads operation commits back into genesis evidence. +- Command finalization is now covered with command-owned legacy/scratch reading + providers plus scratch operation readback conformance, not only supplied test + proof values. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -464,3 +467,5 @@ and concrete checks live in `docs/invariants/`. [0206](design/0206-v18-command-reading-providers/v18-command-reading-providers.md). - [x] 59. Add a scratch runtime conformance provider: [0207](design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md). +- [x] 60. Prove command finalization with providers: + [0208](design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md). diff --git a/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md b/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md new file mode 100644 index 00000000..50ff87ee --- /dev/null +++ b/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md @@ -0,0 +1,34 @@ +--- +cycle: 0208 +task_id: V18_command_provider_finalization +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 60 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Command Provider Finalization + +## Hill + +Prove the command can finalize with command-owned readings and real scratch +operation readback evidence instead of test-supplied finalization proof. + +## Closeout + +Slice 60 changed the command finalization regression to run with a legacy +reading provider, a scratch reading provider, and the scratch runtime +conformance provider. The live ref moves only after those providers produce +passing equivalence and matching scratch runtime evidence. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 97cae374..537a393a 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -64,6 +64,8 @@ Remaining migration-tool work is intentionally ordered as: - slice 58: add command reading providers (complete). - slice 59: add a scratch runtime conformance provider (operation-history readback complete; production runtime replay still release-critical). +- slice 60: prove command finalization with command-owned readings and scratch + runtime conformance (complete). ## Starting points diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index 4cf93033..969357f3 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -10,6 +10,8 @@ import { } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts'; import { buildGraphModelMigrationScratchReading } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; +import { createGraphModelMigrationScratchRuntimeConformanceProvider } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts'; import DryRunGraphModelMigrationPlanRequest from '../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; import GenesisEquivalenceBoundary @@ -72,24 +74,32 @@ describe('v18 graph-model migration command', () => { expect(await refExists(repository, ARCHIVE_REF)).toBe(false); }); - it('finalizes when explicit finalization options and the equivalence gate pass', async () => { + it('finalizes with command-owned readings and scratch runtime conformance', async () => { const repository = await repositoryWithLiveRef(); - const fixture = nodeLifecycleFixture(); const result = await runGraphModelMigrationCommand({ repositoryPath: repository.path, dryRunRequest: dryRunRequest(), scratchRefName: SCRATCH_REF, equivalenceBasis: basis(), - legacyReading: fixture.legacyReading, - scratchReading: fixture.migratedReading, - readingProviders: null, + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => legacyNodeReading(), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath: repository.path, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:finalization-provider', + }), + }, finalization: { liveRefName: LIVE_REF, expectedLiveHead: repository.liveHead, archiveRefName: ARCHIVE_REF, confirmation: confirmation(), - runtimeConformance: runtimeConformance, + runtimeConformance: createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath: repository.path, + }), }, }); From 08b86df11380856da2a4187b153b04912b07af92 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:47:49 -0700 Subject: [PATCH 16/23] Test: Cover v18 provider divergence blocking --- CHANGELOG.md | 2 + docs/BEARING.md | 5 ++ .../v18-provider-divergence-coverage.md | 34 +++++++++++ .../INFRA_graph-model-migration-tool.md | 2 + .../TRUST_genesis-replay-equivalence.md | 4 ++ .../scripts/v18-migration-command.test.ts | 57 +++++++++++++++++++ 6 files changed, 104 insertions(+) create mode 100644 docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f9cb429b..b43a49b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration command finalization is now covered with command-owned reading providers and scratch runtime conformance instead of test-supplied finalization proof. +- V18 graph-model migration command coverage now proves provider-built scratch + readings still block finalization when legacy and migrated facts diverge. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 2fe8374b..1f83910f 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -144,6 +144,9 @@ The current v18 graph-model posture is: - Command finalization is now covered with command-owned legacy/scratch reading providers plus scratch operation readback conformance, not only supplied test proof values. +- Provider-built scratch readings now have a divergence regression proving + finalization remains blocked when scratch history is readable but not + equivalent. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -469,3 +472,5 @@ and concrete checks live in `docs/invariants/`. [0207](design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md). - [x] 60. Prove command finalization with providers: [0208](design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md). +- [x] 61. Add provider-built divergence coverage: + [0209](design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md). diff --git a/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md b/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md new file mode 100644 index 00000000..804a33c5 --- /dev/null +++ b/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md @@ -0,0 +1,34 @@ +--- +cycle: 0209 +task_id: V18_provider_divergence_coverage +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 61 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Provider Divergence Coverage + +## Hill + +Prove that provider-built scratch readings still block finalization when they +diverge from the legacy reading. + +## Closeout + +Slice 61 added command coverage where scratch history is written, read back +from Git through the scratch reading provider, and proven readable by the +runtime conformance provider, but finalization still refuses promotion because +the legacy reading disagrees with the scratch reading. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 537a393a..35ab1f5c 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -66,6 +66,8 @@ Remaining migration-tool work is intentionally ordered as: readback complete; production runtime replay still release-critical). - slice 60: prove command finalization with command-owned readings and scratch runtime conformance (complete). +- slice 61: prove provider-built scratch readings still block finalization on + divergence (complete). ## Starting points diff --git a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md index 34dc1baf..e0a12b0c 100644 --- a/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +++ b/docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md @@ -58,6 +58,10 @@ Slice 57 added a scratch reading builder over migration-operation commits. It constructs equivalence facts from scratch Git history, but remains operation-derived rather than normal runtime replay. +Slices 59 through 61 added operation-history readback conformance and command +coverage proving that readable scratch output still cannot finalize when the +legacy and scratch readings diverge. + ## Starting points - `test/` diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index 969357f3..e85deb32 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -140,6 +140,44 @@ describe('v18 graph-model migration command', () => { expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); }); + it('blocks finalization when provider-built scratch readings diverge from legacy', async () => { + const repository = await repositoryWithLiveRef(); + + const result = await runGraphModelMigrationCommand({ + repositoryPath: repository.path, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => divergentLegacyNodeReading(), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath: repository.path, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:divergent-provider', + }), + }, + finalization: { + liveRefName: LIVE_REF, + expectedLiveHead: repository.liveHead, + archiveRefName: ARCHIVE_REF, + confirmation: confirmation(), + runtimeConformance: createGraphModelMigrationScratchRuntimeConformanceProvider({ + repositoryPath: repository.path, + }), + }, + }); + + expect(result.gateResult?.allowsPromotion()).toBe(false); + expect(result.finalizationResult?.finalized()).toBe(false); + expect(result.finalizationResult?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_EQUIVALENCE_GATE_NOT_PASSED', + ]); + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); + it('can construct readings through command-owned providers after scratch writing', async () => { const repository = await initializedRepository('git-warp-v18-command-providers-'); @@ -228,6 +266,25 @@ function legacyNodeReading(): GenesisEquivalenceReading { }); } +function divergentLegacyNodeReading(): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ + readingId: 'legacy:provider-divergent', + facts: [ + new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: 'node:article', + fieldPath: 'visibility', + value: 'removed', + boundary: new GenesisEquivalenceBoundary({ + writerId: 'alice', + patchId: 'patch:alice:0', + operationIndex: 0, + }), + }), + ], + }); +} + function sourceInventory(): GraphModelMigrationSourceInventory { return new GraphModelMigrationSourceInventory({ graphId: 'v17-golden-graph', From 8062703588baf0858e237ca71e4ea76ba2a57fdc Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:50:49 -0700 Subject: [PATCH 17/23] Feat: Add v18 migration command report --- CHANGELOG.md | 2 + docs/BEARING.md | 4 + .../v18-migration-command-report.md | 33 +++++ .../INFRA_graph-model-migration-tool.md | 2 + .../GraphModelMigrationCommandReport.ts | 113 ++++++++++++++++++ .../scripts/v18-migration-command.test.ts | 21 ++++ 6 files changed, 175 insertions(+) create mode 100644 docs/design/0210-v18-migration-command-report/v18-migration-command-report.md create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b43a49b2..5d1cb114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 test-supplied finalization proof. - V18 graph-model migration command coverage now proves provider-built scratch readings still block finalization when legacy and migrated facts diverge. +- V18 graph-model migration command output now includes a deterministic report + formatter for planning, scratch, equivalence, and finalization evidence. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 1f83910f..25bfc607 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -147,6 +147,8 @@ The current v18 graph-model posture is: - Provider-built scratch readings now have a divergence regression proving finalization remains blocked when scratch history is readable but not equivalent. +- The migration command now has deterministic operator report formatting for + planning, scratch, equivalence, and finalization evidence. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -474,3 +476,5 @@ and concrete checks live in `docs/invariants/`. [0208](design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md). - [x] 61. Add provider-built divergence coverage: [0209](design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md). +- [x] 62. Add migration command report output: + [0210](design/0210-v18-migration-command-report/v18-migration-command-report.md). diff --git a/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md b/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md new file mode 100644 index 00000000..46d86f2d --- /dev/null +++ b/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md @@ -0,0 +1,33 @@ +--- +cycle: 0210 +task_id: V18_migration_command_report +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 62 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Command Report + +## Hill + +Give operators deterministic text output for the migration command's planning, +scratch, equivalence, and finalization evidence. + +## Closeout + +Slice 62 added `formatGraphModelMigrationCommandReport`. The report emits +stage status, operation counts, scratch ref/head evidence, equivalence fact +counts, finalization ref evidence, and fatal notice codes/messages. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-command.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 35ab1f5c..f2819d47 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -68,6 +68,8 @@ Remaining migration-tool work is intentionally ordered as: runtime conformance (complete). - slice 61: prove provider-built scratch readings still block finalization on divergence (complete). +- slice 62: add deterministic operator report output for migration command + evidence (complete). ## Starting points diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts new file mode 100644 index 00000000..3fd79e3e --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts @@ -0,0 +1,113 @@ +import { GraphModelMigrationCommandResult } + from './GraphModelMigrationCommand.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; + +/** Formats a deterministic operator report for the v18 graph-model migration command. */ +export function formatGraphModelMigrationCommandReport( + result: GraphModelMigrationCommandResult, +): string { + const checkedResult = requireCommandResult(result); + return [ + 'git-warp v18 graph-model migration report', + ...dryRunLines(checkedResult), + ...loweringLines(checkedResult), + ...scratchLines(checkedResult), + ...equivalenceLines(checkedResult), + ...finalizationLines(checkedResult), + ].join('\n'); +} + +function dryRunLines(result: GraphModelMigrationCommandResult): readonly string[] { + return Object.freeze([ + `dryRun: ${result.dryRunPlan.hasFatalErrors() ? 'blocked' : 'passed'}`, + `plannedOperations: ${result.dryRunPlan.plannedOperations.length}`, + ]); +} + +function loweringLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.loweringResult.patchPlan === null) { + return Object.freeze([ + `lowering: ${result.loweringResult.hasFatalErrors() ? 'blocked' : 'missing'}`, + 'loweredOperations: 0', + ]); + } + return Object.freeze([ + `lowering: ${result.loweringResult.hasFatalErrors() ? 'blocked' : 'passed'}`, + `loweredOperations: ${result.loweringResult.patchPlan.operations.length}`, + ]); +} + +function scratchLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.scratchWriteResult === null) { + return Object.freeze(['scratch: skipped']); + } + return Object.freeze([ + `scratch: ${result.scratchWriteResult.hasFatalErrors() ? 'blocked' : 'written'}`, + `scratchRef: ${displayNullable(result.scratchWriteResult.scratchRef?.refName ?? null)}`, + `scratchHead: ${displayNullable(result.scratchWriteResult.scratchHead)}`, + `scratchPatches: ${result.scratchWriteResult.writtenPatches.length}`, + ]); +} + +function equivalenceLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.gateResult === null) { + return Object.freeze(['equivalence: skipped']); + } + return Object.freeze([ + `equivalence: ${result.gateResult.allowsPromotion() ? 'passed' : 'blocked'}`, + `mismatches: ${result.gateResult.proofResult.summary.mismatchCount}`, + `legacyFacts: ${result.gateResult.proofResult.summary.legacyFactCount}`, + `migratedFacts: ${result.gateResult.proofResult.summary.migratedFactCount}`, + ]); +} + +function finalizationLines(result: GraphModelMigrationCommandResult): readonly string[] { + if (result.finalizationResult === null) { + return Object.freeze(['finalization: skipped']); + } + if (result.finalizationResult.fatalErrors.length > 0) { + return Object.freeze([ + `finalization: ${result.finalizationResult.status}`, + ...fatalNoticeLines(result.finalizationResult.fatalErrors), + ]); + } + return Object.freeze([ + `finalization: ${result.finalizationResult.status}`, + `liveRef: ${result.finalizationResult.liveRefName}`, + `archiveRef: ${displayNullable(result.finalizationResult.archiveRefName)}`, + `previousLiveHead: ${displayNullable(result.finalizationResult.previousLiveHead)}`, + `finalizedLiveHead: ${displayNullable(result.finalizationResult.finalizedLiveHead)}`, + ]); +} + +function fatalNoticeLines(fatalErrors: readonly GraphModelMigrationNotice[]): readonly string[] { + const lines = ['fatalErrors:']; + for (const notice of fatalErrors) { + lines.push(`- ${notice.code}: ${notice.message}`); + } + return Object.freeze(lines); +} + +function displayNullable(value: string | null): string { + if (value === null) { + return '(none)'; + } + return value; +} + +function requireCommandResult( + result: GraphModelMigrationCommandResult, +): GraphModelMigrationCommandResult { + if (!(result instanceof GraphModelMigrationCommandResult)) { + throw new GraphModelMigrationCommandReportError('result must be a GraphModelMigrationCommandResult'); + } + return result; +} + +export class GraphModelMigrationCommandReportError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationCommandReportError'; + } +} diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index e85deb32..58a5615e 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -8,6 +8,8 @@ import { describe, expect, it } from 'vitest'; import { runGraphModelMigrationCommand, } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts'; +import { formatGraphModelMigrationCommandReport } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts'; import { buildGraphModelMigrationScratchReading } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; import { createGraphModelMigrationScratchRuntimeConformanceProvider } @@ -108,6 +110,13 @@ describe('v18 graph-model migration command', () => { expect(await gitText(repository.path, ['rev-parse', ARCHIVE_REF])).toBe(repository.liveHead); expect(await gitText(repository.path, ['rev-parse', LIVE_REF])) .toBe(result.scratchWriteResult?.scratchHead); + expect(formatGraphModelMigrationCommandReport(result)).toContain([ + 'finalization: completed', + `liveRef: ${LIVE_REF}`, + `archiveRef: ${ARCHIVE_REF}`, + `previousLiveHead: ${repository.liveHead}`, + `finalizedLiveHead: ${result.scratchWriteResult?.scratchHead}`, + ].join('\n')); }); it('blocks finalization when supplied scratch readings diverge', async () => { @@ -138,6 +147,18 @@ describe('v18 graph-model migration command', () => { ]); expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + const report = formatGraphModelMigrationCommandReport(result); + expect(report).toContain([ + 'equivalence: blocked', + 'mismatches: 1', + 'legacyFacts: 1', + 'migratedFacts: 1', + ].join('\n')); + expect(report).toContain([ + 'finalization: blocked', + 'fatalErrors:', + '- E_EQUIVALENCE_GATE_NOT_PASSED: migration finalization requires a passed scratch equivalence gate', + ].join('\n')); }); it('blocks finalization when provider-built scratch readings diverge from legacy', async () => { From 9c68bd3f13716e4f208a9913eb7ff407e49c9349 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:55:18 -0700 Subject: [PATCH 18/23] Feat: Add v18 migration command CLI wrapper --- CHANGELOG.md | 3 + docs/BEARING.md | 5 + .../v18-migration-command-cli-wrapper.md | 35 +++ .../INFRA_graph-model-migration-tool.md | 2 + .../GraphModelMigrationCommandCli.ts | 259 ++++++++++++++++++ .../v18.0.0/migrations/graph-model/migrate.ts | 27 ++ ...-graph-model-migration-command-cli.test.ts | 101 +++++++ 7 files changed, 432 insertions(+) create mode 100644 docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts create mode 100644 scripts/v18.0.0/migrations/graph-model/migrate.ts create mode 100644 test/unit/scripts/v18-graph-model-migration-command-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1cb114..d0d75809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 readings still block finalization when legacy and migrated facts diverge. - V18 graph-model migration command output now includes a deterministic report formatter for planning, scratch, equivalence, and finalization evidence. +- V18 graph-model migration now includes a non-finalizing command CLI wrapper + that writes scratch history, builds command-owned readings, emits the command + report, and refuses live-ref finalization flags. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 25bfc607..643e5e30 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -149,6 +149,9 @@ The current v18 graph-model posture is: equivalent. - The migration command now has deterministic operator report formatting for planning, scratch, equivalence, and finalization evidence. +- A non-finalizing migration command CLI wrapper now writes scratch history, + builds command-owned readings, emits the command report, and refuses live-ref + finalization flags. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -478,3 +481,5 @@ and concrete checks live in `docs/invariants/`. [0209](design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md). - [x] 62. Add migration command report output: [0210](design/0210-v18-migration-command-report/v18-migration-command-report.md). +- [x] 63. Add a migration command CLI wrapper: + [0211](design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md). diff --git a/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md b/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md new file mode 100644 index 00000000..c60a30ca --- /dev/null +++ b/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md @@ -0,0 +1,35 @@ +--- +cycle: 0211 +task_id: V18_migration_command_cli_wrapper +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 63 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Command CLI Wrapper + +## Hill + +Expose the wired migration command through a narrow operator CLI without +opening live-ref finalization from shell flags. + +## Closeout + +Slice 63 added `migrate.ts` and `GraphModelMigrationCommandCli`. The wrapper +requires an explicit repository, request JSON, v17 fixture manifest, and +scratch ref. It writes scratch history, constructs command-owned legacy and +scratch readings, emits the deterministic command report, and refuses +finalization flags until live-ref CLI finalization has its own design. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-graph-model-migration-command-cli.test.ts --reporter=verbose +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index f2819d47..70917165 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -70,6 +70,8 @@ Remaining migration-tool work is intentionally ordered as: divergence (complete). - slice 62: add deterministic operator report output for migration command evidence (complete). +- slice 63: add a non-finalizing migration command CLI wrapper that writes + scratch history and refuses live-ref finalization flags (complete). ## Starting points diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts new file mode 100644 index 00000000..b25b9e47 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts @@ -0,0 +1,259 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import V17GoldenGraphFixtureGenesisReading + from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import { parseGraphModelMigrationDryRunRequest } + from '../../../../src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; +import { runGraphModelMigrationCommand } from './GraphModelMigrationCommand.ts'; +import { formatGraphModelMigrationCommandReport } from './GraphModelMigrationCommandReport.ts'; +import { buildGraphModelMigrationScratchReading } from './GraphModelMigrationScratchReadingBuilder.ts'; +import type DryRunGraphModelMigrationPlan + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; +import type GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; + +const FINALIZATION_FLAGS = Object.freeze(new Set([ + '--finalize', + '--live-ref', + '--archive-ref', + '--expected-live-head', + '--confirmation', +])); + +export class GraphModelMigrationCommandCliArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationCommandCliArgumentError'; + } +} + +export class GraphModelMigrationCommandCliArgs { + readonly repositoryPath: string | null; + readonly requestPath: string | null; + readonly legacyFixtureManifestPath: string | null; + readonly scratchRefName: string | null; + readonly reportOutPath: string | null; + readonly helpRequested: boolean; + + constructor(options: { + readonly repositoryPath: string | null; + readonly requestPath: string | null; + readonly legacyFixtureManifestPath: string | null; + readonly scratchRefName: string | null; + readonly reportOutPath: string | null; + readonly helpRequested: boolean; + }) { + this.repositoryPath = options.repositoryPath; + this.requestPath = options.requestPath; + this.legacyFixtureManifestPath = options.legacyFixtureManifestPath; + this.scratchRefName = options.scratchRefName; + this.reportOutPath = options.reportOutPath; + this.helpRequested = options.helpRequested; + Object.freeze(this); + } +} + +export class GraphModelMigrationCommandCliResult { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + Object.freeze(this); + } +} + +/** Returns CLI usage for the v18 graph-model migration command wrapper. */ +export function graphModelMigrationCommandUsage(): string { + return [ + 'Usage:', + [ + ' node scripts/v18.0.0/migrations/graph-model/migrate.ts', + '--repo ', + '--request ', + '--legacy-fixture-manifest ', + '--scratch-ref ', + '[--report-out ]', + ].join(' '), + '', + 'Options:', + ' --repo Git repository to receive scratch migration history.', + ' --request JSON migration request to validate and execute.', + ' --legacy-fixture-manifest V17 fixture manifest used for legacy equivalence reading.', + ' --scratch-ref refs/warp-migration-scratch/* target for scratch output.', + ' --report-out Also write the deterministic command report to this path.', + ' --help Show this help.', + '', + 'Finalization flags are intentionally refused by this wrapper until live-ref CLI finalization is designed.', + ].join('\n'); +} + +/** Parses command CLI arguments without reading or writing files. */ +export function parseGraphModelMigrationCommandCliArgs( + argv: readonly string[], +): GraphModelMigrationCommandCliArgs { + let repositoryPath: string | null = null; + let requestPath: string | null = null; + let legacyFixtureManifestPath: string | null = null; + let scratchRefName: string | null = null; + let reportOutPath: string | null = null; + let helpRequested = false; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--repo') { + repositoryPath = readArgValue(argv, index, '--repo'); + index++; + continue; + } + if (arg === '--request') { + requestPath = readArgValue(argv, index, '--request'); + index++; + continue; + } + if (arg === '--legacy-fixture-manifest') { + legacyFixtureManifestPath = readArgValue(argv, index, '--legacy-fixture-manifest'); + index++; + continue; + } + if (arg === '--scratch-ref') { + scratchRefName = readArgValue(argv, index, '--scratch-ref'); + index++; + continue; + } + if (arg === '--report-out') { + reportOutPath = readArgValue(argv, index, '--report-out'); + index++; + continue; + } + if (arg === '--help' || arg === '-h') { + helpRequested = true; + continue; + } + if (arg !== undefined && FINALIZATION_FLAGS.has(arg)) { + throw new GraphModelMigrationCommandCliArgumentError( + 'finalization is not supported by this CLI wrapper yet', + ); + } + throw new GraphModelMigrationCommandCliArgumentError(`Unknown argument: ${arg ?? ''}`); + } + + return new GraphModelMigrationCommandCliArgs({ + repositoryPath, + requestPath, + legacyFixtureManifestPath, + scratchRefName, + reportOutPath, + helpRequested, + }); +} + +/** Runs the v18 graph-model migration command wrapper. */ +export async function runGraphModelMigrationCommandCli( + argv: readonly string[], +): Promise { + const args = parseGraphModelMigrationCommandCliArgs(argv); + if (args.helpRequested) { + return new GraphModelMigrationCommandCliResult(0, `${graphModelMigrationCommandUsage()}\n`, ''); + } + requireCommandArgs(args); + + const requestText = await readFile(requireString(args.requestPath, '--request'), 'utf8'); + const legacyManifestText = await readFile( + requireString(args.legacyFixtureManifestPath, '--legacy-fixture-manifest'), + 'utf8', + ); + const dryRunRequest = parseGraphModelMigrationDryRunRequest(requestText); + const legacyManifest = parseV17GoldenGraphFixtureManifestJson(legacyManifestText); + const preflightPlan = new DryRunGraphModelMigrationPlanner().plan(dryRunRequest); + if (preflightPlan.hasFatalErrors() || preflightPlan.manifest === null) { + return new GraphModelMigrationCommandCliResult(1, preflightFailureReport(preflightPlan), ''); + } + + const repositoryPath = requireString(args.repositoryPath, '--repo'); + const scratchRefName = requireString(args.scratchRefName, '--scratch-ref'); + const result = await runGraphModelMigrationCommand({ + repositoryPath, + dryRunRequest, + scratchRefName, + equivalenceBasis: new GenesisEquivalenceComparisonBasis({ + legacyBasis: preflightPlan.manifest.sourceBasis, + migratedBasis: preflightPlan.manifest.targetBasis, + }), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => new V17GoldenGraphFixtureGenesisReading().build(legacyManifest), + scratchReading: async () => await buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName, + readingId: 'scratch:command-cli', + }), + }, + finalization: null, + }); + const report = formatGraphModelMigrationCommandReport(result); + if (args.reportOutPath !== null) { + await writeFile(args.reportOutPath, report, 'utf8'); + } + return new GraphModelMigrationCommandCliResult(commandExitCode(result), report, ''); +} + +function commandExitCode(result: Awaited>): number { + if ( + !result.dryRunPlan.hasFatalErrors() + && !result.loweringResult.hasFatalErrors() + && result.scratchWriteResult !== null + && !result.scratchWriteResult.hasFatalErrors() + && result.gateResult !== null + && result.gateResult.allowsPromotion() + ) { + return 0; + } + return 1; +} + +function preflightFailureReport(plan: DryRunGraphModelMigrationPlan): string { + return [ + 'git-warp v18 graph-model migration report', + 'dryRun: blocked', + `plannedOperations: ${plan.plannedOperations.length}`, + ...fatalNoticeLines(plan.fatalErrors), + ].join('\n'); +} + +function fatalNoticeLines(fatalErrors: readonly GraphModelMigrationNotice[]): readonly string[] { + const lines = ['fatalErrors:']; + for (const notice of fatalErrors) { + lines.push(`- ${notice.code}: ${notice.message}`); + } + return Object.freeze(lines); +} + +function requireCommandArgs(args: GraphModelMigrationCommandCliArgs): void { + requireString(args.repositoryPath, '--repo'); + requireString(args.requestPath, '--request'); + requireString(args.legacyFixtureManifestPath, '--legacy-fixture-manifest'); + requireString(args.scratchRefName, '--scratch-ref'); +} + +function requireString(value: string | null, flag: string): string { + if (value === null) { + throw new GraphModelMigrationCommandCliArgumentError(`${flag} is required`); + } + return value; +} + +function readArgValue(argv: readonly string[], index: number, flag: string): string { + const value = argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith('--')) { + throw new GraphModelMigrationCommandCliArgumentError(`${flag} requires a value`); + } + return value; +} diff --git a/scripts/v18.0.0/migrations/graph-model/migrate.ts b/scripts/v18.0.0/migrations/graph-model/migrate.ts new file mode 100644 index 00000000..b5e49234 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/migrate.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import process from 'node:process'; + +import { + graphModelMigrationCommandUsage, + runGraphModelMigrationCommandCli, +} from './GraphModelMigrationCommandCli.ts'; + +function errorMessage(error: Error | string): string { + if (error instanceof Error) { + return error.message; + } + return error; +} + +runGraphModelMigrationCommandCli(process.argv.slice(2)) + .then((result) => { + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + process.exitCode = result.exitCode; + }) + .catch((error) => { + const message = error instanceof Error ? errorMessage(error) : 'unexpected migration command failure'; + process.stderr.write(`${message}\n\n${graphModelMigrationCommandUsage()}\n`); + process.exitCode = 1; + }); diff --git a/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts b/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts new file mode 100644 index 00000000..e532407e --- /dev/null +++ b/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts @@ -0,0 +1,101 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + parseGraphModelMigrationCommandCliArgs, + runGraphModelMigrationCommandCli, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts'; + +const execFileAsync = promisify(execFile); +const FIXTURE_MANIFEST = 'fixtures/v17/graph-model-golden/manifest.json'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/cli'; + +describe('v18 graph-model migration command CLI', () => { + it('prints usage when help is requested', async () => { + const result = await runGraphModelMigrationCommandCli(['--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Usage:'); + expect(result.stdout).toContain('--legacy-fixture-manifest '); + expect(result.stderr).toBe(''); + }); + + it('refuses finalization flags until live-ref CLI finalization is designed', () => { + expect(() => parseGraphModelMigrationCommandCliArgs(['--finalize'])) + .toThrow(/finalization is not supported/); + }); + + it('writes scratch history and emits a deterministic command report', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-')); + const repositoryPath = join(directory, 'repo'); + const requestPath = join(directory, 'request.json'); + const reportPath = join(directory, 'report.txt'); + await execFileAsync('git', ['init', '-q', repositoryPath]); + await writeFile(requestPath, completeRequestJson(), 'utf8'); + + const result = await runGraphModelMigrationCommandCli([ + '--repo', + repositoryPath, + '--request', + requestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + '--report-out', + reportPath, + ]); + const report = await readFile(reportPath, 'utf8'); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(report); + expect(report).toContain('scratch: written'); + expect(report).toContain(`scratchRef: ${SCRATCH_REF}`); + expect(report).toContain('equivalence: blocked'); + expect(report).toContain('finalization: skipped'); + }); +}); + +function completeRequestJson(): string { + return `{ + "inventory": { + "graphId": "v17-golden-graph", + "sourceBasis": { "graphId": "v17-golden-graph", "basisId": "basis:source" }, + "writerChains": [ + { "writerId": "alice", "patchIds": ["patch:alice:0"] } + ], + "patchDescriptors": [ + { "patchId": "patch:alice:0", "writerId": "alice", "writerSequence": 0 } + ], + "stateSnapshot": { "snapshotId": "snapshot:source" }, + "contentSources": [ + { "legacyContentKey": "node:alpha:_content", "contentOid": "oid:content:alpha" } + ], + "warnings": [], + "fatalErrors": [] + }, + "requiredContentKeys": ["node:alpha:_content"], + "nodeMappings": [ + { "legacyNodeId": "node:alpha", "targetNodeId": "node:alpha" } + ], + "edgeMappings": [ + { + "legacyEdgeId": "node:alpha->node:beta:relates", + "targetEdgeId": "node:alpha->node:beta:relates" + } + ], + "propertyMappings": [ + { + "legacyOwnerId": "node:alpha", + "legacyPropertyKey": "title", + "targetOwnerId": "node:alpha", + "targetPropertyKey": "title" + } + ] +} +`; +} From 35869c38c0cfac2d46b4700e65eb7d2fb4ad457e Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:56:53 -0700 Subject: [PATCH 19/23] Docs: Record v18 public release blockers --- CHANGELOG.md | 3 ++ docs/BEARING.md | 5 ++ .../v18-public-release-blockers.md | 34 +++++++++++++ .../INFRA_graph-model-migration-tool.md | 2 + docs/method/backlog/v18.0.0/README.md | 4 ++ .../RELEASE_v18-public-release-blockers.md | 49 +++++++++++++++++++ 6 files changed, 97 insertions(+) create mode 100644 docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md create mode 100644 docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d75809..2165124e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 graph-model migration now includes a non-finalizing command CLI wrapper that writes scratch history, builds command-owned readings, emits the command report, and refuses live-ref finalization flags. +- V18 release planning now records explicit public-release blockers for + production-runtime replay, live finalization CLI design, wet-run fixture + harnessing, Continuum contract tie-back, and operator release notes. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index 643e5e30..ce121a5c 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -152,6 +152,9 @@ The current v18 graph-model posture is: - A non-finalizing migration command CLI wrapper now writes scratch history, builds command-owned readings, emits the command report, and refuses live-ref finalization flags. +- V18 public-release blockers are now explicit: production-runtime scratch + replay, live finalization CLI design, wet-run fixture harnessing, generated + Continuum contract tie-back, and operator release notes. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -483,3 +486,5 @@ and concrete checks live in `docs/invariants/`. [0210](design/0210-v18-migration-command-report/v18-migration-command-report.md). - [x] 63. Add a migration command CLI wrapper: [0211](design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md). +- [x] 64. Record v18 public release blockers: + [0212](design/0212-v18-public-release-blockers/v18-public-release-blockers.md). diff --git a/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md b/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md new file mode 100644 index 00000000..8ffb7d45 --- /dev/null +++ b/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md @@ -0,0 +1,34 @@ +--- +cycle: 0212 +task_id: V18_public_release_blockers +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 64 +promotes_backlog: + - docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md +--- + +# V18 Public Release Blockers + +## Hill + +Make the remaining public-release blockers explicit before the migration +command looks more complete than its evidence. + +## Closeout + +Slice 64 added a v18 release-blocker backlog note. The blockers call out +production-runtime scratch replay, live finalization CLI design, wet-run +fixture harnessing, generated Continuum contract tie-back, and release notes +that preserve the sibling-participant doctrine. + +## Verification + +```text +npm run lint:md +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 70917165..7a426605 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -72,6 +72,8 @@ Remaining migration-tool work is intentionally ordered as: evidence (complete). - slice 63: add a non-finalizing migration command CLI wrapper that writes scratch history and refuses live-ref finalization flags (complete). +- slice 64: record v18 public release blockers before widening release claims + (complete). ## Starting points diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 47aa75ab..0bd4e166 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -109,3 +109,7 @@ non-destructive but now has persisted-history evidence: an explicit release blocker instead of an implicit assumption; - raw content/property compatibility boundaries are now enumerated by an executable closeout audit so new raw boundaries require deliberate review. +- public-release blockers are now explicit in + [`RELEASE_v18-public-release-blockers.md`](RELEASE_v18-public-release-blockers.md), + including production-runtime replay, live finalization CLI design, wet-run + fixture harnessing, Continuum contract tie-back, and operator release notes. diff --git a/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md b/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md new file mode 100644 index 00000000..43cb8571 --- /dev/null +++ b/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md @@ -0,0 +1,49 @@ +--- +id: RELEASE_v18-public-release-blockers +blocked_by: + - INFRA_graph-model-migration-tool + - TRUST_genesis-replay-equivalence +blocks: [] +feature: graph-model-substrate +--- + +# v18 public release blockers + +## Why + +The v18 migration path now has enough operator surface area that the release +line needs explicit blockers. A public release must not imply stronger +migration safety than the repository can prove. + +## Done looks like + +- scratch output is opened through the production graph runtime, not only + operation-history readback +- live-ref finalization from the CLI has its own confirmation design, + drift checks, archive evidence, and report output +- the v17 golden graph fixture has a wet-run migration path that restores the + fixture, writes scratch history, runs equivalence, and captures the operator + report +- Continuum/WARP Optic contract evidence is tied back to generated artifacts, + not only handwritten compatibility prose +- release notes clearly distinguish v18 graph-model convergence from later + Continuum admission shells + +## Current blockers + +| Blocker | Why it blocks public release | Evidence now | +|---------|------------------------------|--------------| +| Production-runtime scratch replay | Operation-history readback proves the scratch commits are parseable, but not that the normal graph runtime can open the migrated history. | `GraphModelMigrationScratchRuntimeConformanceProvider` is intentionally operation-derived. | +| Live finalization CLI design | The command can finalize through the API, but the shell wrapper correctly refuses live-ref finalization flags until operator confirmation semantics are designed. | `GraphModelMigrationCommandCli` rejects `--finalize` and related flags. | +| Wet-run fixture harness | The v17 fixture and scratch writer exist separately; the release gate needs one reproducible wet run that restores the fixture and executes the wrapper. | Fixture restore, source inventory, scratch writer, command wrapper, and report formatter exist. | +| Continuum contract tie-back | v18 is aimed at WARP Optic compatibility, so release claims need generated contract evidence from Wesley/Continuum artifacts. | Earlier slices recorded readiness and source facts, but graph-model migration work is still mostly git-warp-local. | +| Operator release notes | Users need plain release guidance on what v18 migrates, what it does not migrate, and why Echo and git-warp remain sibling participants. | BEARING has the doctrine; release notes are not yet cut. | + +## Next pull candidates + +- Design and implement production-runtime scratch replay conformance. +- Design live-ref finalization CLI confirmation and report behavior. +- Add a fixture wet-run command or documented harness around the current + restore plus command CLI path. +- Attach generated Continuum/WARP Optic contract evidence to the v18 release + gate. From a8e184b9480a12d3ba5c8a38b2d742a5a6eb34a9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 11:58:57 -0700 Subject: [PATCH 20/23] Docs: Replan v18 after command CLI --- CHANGELOG.md | 3 + docs/BEARING.md | 76 ++++++++++++++++--- .../v18-replan-after-command-cli.md | 47 ++++++++++++ .../INFRA_graph-model-migration-tool.md | 2 + 4 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2165124e..25932faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 release planning now records explicit public-release blockers for production-runtime replay, live finalization CLI design, wet-run fixture harnessing, Continuum contract tie-back, and operator release notes. +- V18 planning now records the evidence-backed post-command-CLI replan toward + production-runtime scratch replay, wet-run fixture harnessing, live + finalization CLI design, and generated Continuum contract tie-back. - V18 graph-model migration dry-run work now includes a non-destructive CLI runner and request JSON adapter that validate source facts, invoke the pure planner, emit deterministic manifest output, and refuse write/apply modes. diff --git a/docs/BEARING.md b/docs/BEARING.md index ce121a5c..f4634047 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -46,9 +46,9 @@ Current branch state at this boundary: evidence - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0199-v18-v17-golden-graph-fixtures` -- Current work: PR E has started. Slice 46 is complete on this branch, and - slices 47 through 55 are the current drift-check batch. + `0213-v18-replan-after-command-cli` +- Current work: PR E now contains slices 46 through 65. The branch is ready + for PR review after final local verification and push. - Cleanup checkpoint: `main` has been fast-forwarded to `origin/main` after PR #103 merged; this branch starts from that merge commit. @@ -320,6 +320,44 @@ enumerates every current `src/domain` file that still touches raw legacy content/property compatibility patterns and fails if that set drifts without review. +Slice 56 is complete on this branch. The v17 golden fixture manifest can now +be projected into genesis-equivalence readings with deterministic +patch-boundary evidence. + +Slice 57 is complete on this branch. Scratch migration operation commits can +now be read back from Git and projected into genesis-equivalence facts with +scratch commit boundary evidence. + +Slice 58 is complete on this branch. The migration command can now construct +legacy and scratch readings through providers after scratch writing. + +Slice 59 is complete on this branch. Scratch runtime conformance now has an +adapter-level operation-history readback provider tied to the expected scratch +ref and head. + +Slice 60 is complete on this branch. Command finalization is covered with +command-owned reading providers plus scratch operation readback conformance. + +Slice 61 is complete on this branch. Provider-built scratch readings now have +a divergence regression proving finalization remains blocked when scratch +history is readable but not equivalent. + +Slice 62 is complete on this branch. The command now has deterministic +operator report formatting for planning, scratch, equivalence, and +finalization evidence. + +Slice 63 is complete on this branch. A non-finalizing migration command CLI +wrapper writes scratch history, builds command-owned readings, emits the +command report, and refuses live-ref finalization flags. + +Slice 64 is complete on this branch. V18 public-release blockers are explicit: +production-runtime scratch replay, live finalization CLI design, wet-run +fixture harnessing, generated Continuum contract tie-back, and release notes. + +Slice 65 is complete on this branch. Evidence-backed replanning moves the next +goalpost from scratch operation readback to production-runtime scratch replay +and wet-run fixture harnessing. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -335,15 +373,14 @@ review. - The v18 migration tool can now write scratch history and derive scratch operation readings, but it does not yet open scratch output through the full production graph runtime. -- Genesis equivalence is a gate vocabulary now, but not yet a full real-history - ship gate wired through finalization. -- Compact equivalence fixtures are not enough by themselves. The golden v17 - fixture now restores Git refs and source inventory consumes those refs, but - the command still needs real-history reading construction from migrated Git - output. -- The next migration work must wire real-history reading and runtime - conformance providers through finalization, then broaden the evidence beyond - scratch operation readback where the production runtime needs it. +- Legacy readings from the v17 golden fixture are manifest-derived, not yet + produced by replaying the restored v17 graph through the public read path. +- The command wrapper writes scratch history and reports evidence, but live-ref + finalization from the CLI is intentionally refused until confirmation and + operator-report semantics are designed. +- Continuum/WARP Optic release claims still need generated contract evidence + tied to Wesley/Continuum artifacts, not just handwritten compatibility + doctrine. ## Where We Are Heading @@ -359,6 +396,14 @@ Suggested implementation batches: writing, scratch equivalence gating, finalization safety, finalization implementation, end-to-end command wiring, post-migration runtime conformance, and content/property closeout audit. +- PR E extension, slices 56 through 65: legacy and scratch reading + construction, command reading providers, scratch operation readback + conformance, provider-backed finalization and divergence coverage, command + reporting, a non-finalizing command CLI wrapper, release blockers, and this + replan. +- Next goalpost, slices 66 through 70: production-runtime scratch replay, + wet-run fixture harnessing, live finalization CLI design, and generated + Continuum contract tie-back. ## Invariants @@ -488,3 +533,10 @@ and concrete checks live in `docs/invariants/`. [0211](design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md). - [x] 64. Record v18 public release blockers: [0212](design/0212-v18-public-release-blockers/v18-public-release-blockers.md). +- [x] 65. Replan after the command CLI: + [0213](design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md). +- [ ] 66. Design production-runtime scratch replay conformance. +- [ ] 67. Implement production-runtime scratch replay provider. +- [ ] 68. Add a v17 fixture wet-run migration harness. +- [ ] 69. Design live finalization CLI confirmation and reporting. +- [ ] 70. Tie v18 release claims to generated Continuum contract evidence. diff --git a/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md b/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md new file mode 100644 index 00000000..f8e2636f --- /dev/null +++ b/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md @@ -0,0 +1,47 @@ +--- +cycle: 0213 +task_id: V18_replan_after_command_cli +status: Completed +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 65 +promotes_backlog: + - docs/BEARING.md + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Replan After Command CLI + +## Hill + +Use the evidence from slices 56 through 64 to reset the next v18 goalpost +before opening the PR. + +## Evidence + +- The branch was clean before this replan edit. +- `git rev-list --left-right --count origin/main...HEAD` reported `0 19`. +- The branch now contains legacy fixture readings, scratch readings, command + reading providers, scratch operation readback conformance, provider-backed + finalization coverage, divergence coverage, command reporting, a + non-finalizing command CLI wrapper, and public release blocker docs. + +## Closeout + +Slice 65 updates `BEARING` and the migration backlog. The next goalpost is no +longer "can we write and inspect scratch history"; it is "can we prove scratch +history through the production runtime and run a wet migration harness without +touching live refs." + +## Verification + +```text +git status --short +git log --oneline --decorate --max-count=16 origin/main..HEAD +git diff --stat origin/main...HEAD +git rev-list --left-right --count origin/main...HEAD +``` diff --git a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md index 7a426605..f6f54511 100644 --- a/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +++ b/docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md @@ -74,6 +74,8 @@ Remaining migration-tool work is intentionally ordered as: scratch history and refuses live-ref finalization flags (complete). - slice 64: record v18 public release blockers before widening release claims (complete). +- slice 65: replan with command-CLI evidence in hand and set the next + production-runtime replay goalpost (complete). ## Starting points From 6b96629507081a836fe854a1e589e28601c1179e Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 12:11:01 -0700 Subject: [PATCH 21/23] Test: Restore v18 migration coverage ratchet --- ...aphModelMigrationConstructorGuards.test.ts | 292 ++++++++++++++++++ vitest.config.ts | 2 +- 2 files changed, 293 insertions(+), 1 deletion(-) diff --git a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts index 2baf26c7..67d7e931 100644 --- a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts @@ -4,6 +4,8 @@ import DryRunGraphModelMigrationPlan from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; import DryRunGraphModelMigrationPlanRequest from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GraphModelMigrationArchiveRef + from '../../../../src/domain/migrations/GraphModelMigrationArchiveRef.ts'; import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; import GraphModelMigrationContentMapping from '../../../../src/domain/migrations/GraphModelMigrationContentMapping.ts'; @@ -15,6 +17,14 @@ import GraphModelMigrationHistoryPatchInput from '../../../../src/domain/migrations/GraphModelMigrationHistoryPatchInput.ts'; import GraphModelMigrationHistorySegment from '../../../../src/domain/migrations/GraphModelMigrationHistorySegment.ts'; +import GraphModelMigrationFinalizationResult, { + GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationResult.ts'; +import GraphModelMigrationLoweredOperation + from '../../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; import GraphModelMigrationManifest from '../../../../src/domain/migrations/GraphModelMigrationManifest.ts'; import GraphModelMigrationManifestVersion @@ -30,14 +40,31 @@ import GraphModelMigrationPatchOperationFact from '../../../../src/domain/migrations/GraphModelMigrationPatchOperationFact.ts'; import GraphModelMigrationPlannedGraphOperation from '../../../../src/domain/migrations/GraphModelMigrationPlannedGraphOperation.ts'; +import GraphModelMigrationOperationLoweringResult + from '../../../../src/domain/migrations/GraphModelMigrationOperationLoweringResult.ts'; import GraphModelMigrationPropertyMapping from '../../../../src/domain/migrations/GraphModelMigrationPropertyMapping.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import GraphModelMigrationScratchWrittenPatch + from '../../../../src/domain/migrations/GraphModelMigrationScratchWrittenPatch.ts'; import GraphModelMigrationSourceInventory from '../../../../src/domain/migrations/GraphModelMigrationSourceInventory.ts'; import GraphModelMigrationStateSnapshotReference from '../../../../src/domain/migrations/GraphModelMigrationStateSnapshotReference.ts'; import GraphModelMigrationWriterChainDescriptor from '../../../../src/domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, + v17GoldenGraphFixtureFactKindFromString, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; describe('graph model migration constructor guards', () => { it('rejects invalid scalar fields on leaf nouns', () => { @@ -298,6 +325,210 @@ describe('graph model migration constructor guards', () => { fatalErrors: [], })).toThrow(/uncollected patch/); }); + + it('rejects invalid archive refs and scratch write results', () => { + expect(new GraphModelMigrationArchiveRef({ + refName: 'refs/warp-migration-archive/graph/writers/alice', + }).refName).toBe('refs/warp-migration-archive/graph/writers/alice'); + expect(GraphModelMigrationArchiveRef.validateRefName(null)?.code) + .toBe('E_MISSING_ARCHIVE_REF'); + expect(GraphModelMigrationArchiveRef.validateRefName('refs/warp/graph/writers/alice')?.code) + .toBe('E_LIVE_ARCHIVE_REF_TARGET'); + expect(GraphModelMigrationArchiveRef.validateRefName('refs/not-archive/graph')?.code) + .toBe('E_INVALID_ARCHIVE_REF'); + expect(GraphModelMigrationArchiveRef.validateRefName('refs/warp-migration-archive/bad~name')?.code) + .toBe('E_INVALID_ARCHIVE_REF'); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationArchiveRef(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + writtenPatches: [writtenPatch(0), writtenPatch(1)], + warnings: [], + fatalErrors: [], + })).toThrow(/duplicate scratch written operation/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + writtenPatches: [], + warnings: [], + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/fatal scratch write results/); + expect(() => new GraphModelMigrationScratchWriteResult({ + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/graph', + scratchHead: null, + writtenPatches: [], + warnings: [], + fatalErrors: [], + })).toThrow(/scratchRef/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: '', + writtenPatches: [], + warnings: [], + fatalErrors: [], + })).toThrow(/scratchHead/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + // @ts-expect-error exercising runtime validation + writtenPatches: 'nope', + warnings: [], + fatalErrors: [], + })).toThrow(/writtenPatches/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + // @ts-expect-error exercising runtime validation + writtenPatches: [{ commitId: 'commit', operation: loweredOperation(), sequence: 0 }], + warnings: [], + fatalErrors: [], + })).toThrow(/written patches/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + writtenPatches: [], + warnings: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + fatalErrors: [], + })).toThrow(/warnings/); + expect(() => new GraphModelMigrationScratchWriteResult({ + scratchRef: null, + scratchHead: null, + writtenPatches: [], + warnings: [], + fatalErrors: [GraphModelMigrationNotice.warning('W_WARNING', 'warning')], + })).toThrow(/fatalErrors/); + }); + + it('rejects invalid finalization and runtime conformance evidence', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationFinalizationResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationFinalizationResult({ + // @ts-expect-error exercising runtime validation + status: 'done', + liveRefName: 'refs/warp/graph', + archiveRefName: null, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/status/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName: '', + archiveRefName: null, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/liveRefName/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName: 'refs/warp/graph', + archiveRefName: '', + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/archiveRefName/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_BLOCKED, + liveRefName: 'refs/warp/graph', + archiveRefName: null, + previousLiveHead: null, + finalizedLiveHead: null, + fatalErrors: [], + })).toThrow(/non-completed/); + expect(() => new GraphModelMigrationFinalizationResult({ + status: GRAPH_MODEL_MIGRATION_FINALIZATION_COMPLETED, + liveRefName: 'refs/warp/graph', + archiveRefName: null, + previousLiveHead: 'old', + finalizedLiveHead: 'new', + fatalErrors: [], + })).toThrow(/archive and head/); + expect(() => new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, + witness: 'witness', + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/passed runtime conformance/); + expect(() => new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: scratchRef(), + scratchHead: 'scratch-head', + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: 'witness', + fatalErrors: [], + })).toThrow(/failed runtime conformance/); + expect(() => new GraphModelMigrationRuntimeConformanceResult({ + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/graph', + scratchHead: 'scratch-head', + status: GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: 'witness', + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/scratchRef/); + }); + + it('rejects invalid lowering result and fixture manifest evidence', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationOperationLoweringResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + // @ts-expect-error exercising runtime validation + patchPlan: { operations: [] }, + warnings: [], + fatalErrors: [], + })).toThrow(/patchPlan/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + fatalErrors: [], + })).toThrow(/warnings/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: [], + fatalErrors: [GraphModelMigrationNotice.warning('W_WARNING', 'warning')], + })).toThrow(/fatalErrors/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: loweredPatchPlan(), + warnings: [], + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/fatal lowering/); + expect(() => new GraphModelMigrationOperationLoweringResult({ + patchPlan: null, + warnings: [], + fatalErrors: [], + })).toThrow(/successful lowering/); + expect(() => v17GoldenGraphFixtureFactKindFromString('not-a-fact')) + .toThrow(/fact kind/); + expect(() => { + // @ts-expect-error exercising runtime validation + new V17GoldenGraphFixtureManifest(null); + }).toThrow(/fields/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice'), fixtureWriter('alice')], + visibleFacts: completeFixtureFacts(), + })).toThrow(/duplicates writer/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: [fixtureFact('node', 'node:a')], + })).toThrow(/visibleFacts must include edge/); + }); }); type InventoryOverrides = { @@ -381,3 +612,64 @@ function historyPatch( ], }); } + +function scratchRef(): GraphModelMigrationScratchRef { + return new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/graph/migration', + }); +} + +function loweredOperation(): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ + kind: 'node-record', + sourceKey: 'node:a', + targetKey: 'node:a', + }); +} + +function loweredPatchPlan(): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: sourceBasis(), + targetBasis: targetBasis(), + operations: [loweredOperation()], + }); +} + +function writtenPatch(sequence: number): GraphModelMigrationScratchWrittenPatch { + return new GraphModelMigrationScratchWrittenPatch({ + commitId: `commit:${sequence}`, + operation: loweredOperation(), + sequence, + }); +} + +function fixtureWriter(writerId: string): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId, + refName: `refs/warp/graph/writers/${writerId}`, + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }); +} + +function fixtureFact( + kind: 'node' | 'edge' | 'property' | 'content' | 'removal' | 'multi-writer', + key: string, +): V17GoldenGraphFixtureVisibleFact { + return new V17GoldenGraphFixtureVisibleFact({ + kind, + key, + description: `${kind}:${key}`, + }); +} + +function completeFixtureFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + fixtureFact('node', 'node:a'), + fixtureFact('edge', 'edge:a'), + fixtureFact('property', 'property:a'), + fixtureFact('content', 'content:a'), + fixtureFact('removal', 'node:removed'), + fixtureFact('multi-writer', 'writers:a+b'), + ]); +} diff --git a/vitest.config.ts b/vitest.config.ts index 4051fa8b..47066e98 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/ports/**/*.ts', 'src/**/*.d.ts'], thresholds: { - lines: 91.90, + lines: 91.92, autoUpdate: shouldAutoUpdateCoverageRatchet(), }, }, From fba28271902b197357ac0174e922477cfeb95766 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 12:29:40 -0700 Subject: [PATCH 22/23] Fix: Resolve v18 migration PR feedback --- CHANGELOG.md | 5 + .../v18-scratch-migration-writer.md | 2 +- .../v18-scratch-equivalence-gate.md | 2 +- .../v18-migration-finalization-safety.md | 2 +- ...8-migration-finalization-implementation.md | 2 +- .../v18-migration-command-wiring.md | 2 +- .../v18-post-migration-runtime-conformance.md | 2 +- .../v18-content-property-closeout-audit.md | 2 +- ...v18-legacy-fixture-reading-construction.md | 2 +- ...-scratch-operation-reading-construction.md | 2 +- .../v18-command-reading-providers.md | 2 +- ...18-scratch-runtime-conformance-provider.md | 2 +- .../v18-command-provider-finalization.md | 2 +- .../v18-provider-divergence-coverage.md | 2 +- .../v18-migration-command-report.md | 2 +- .../v18-migration-command-cli-wrapper.md | 2 +- .../v18-public-release-blockers.md | 2 +- .../v18-replan-after-command-cli.md | 2 +- .../graph-model/GitMigrationCommandRunner.ts | 5 +- .../graph-model/GraphModelMigrationCommand.ts | 13 +- .../GraphModelMigrationFinalizer.ts | 2 +- ...raphModelMigrationScratchReadingBuilder.ts | 5 +- ...hModelMigrationSourceInventoryCollector.ts | 9 +- .../V17GoldenGraphFixtureRestore.ts | 15 +- .../GraphModelMigrationArchiveRef.ts | 4 +- .../GraphModelMigrationScratchRef.ts | 4 +- .../V17GoldenGraphFixtureGenesisReading.ts | 24 +- .../V17GoldenGraphFixtureManifest.ts | 71 ++++++ ...17GoldenGraphFixtureManifestJsonAdapter.ts | 72 +++++- ...aphModelMigrationConstructorGuards.test.ts | 15 ++ ...lMigrationDryRunRequestJsonAdapter.test.ts | 225 ++++++++++++++++++ ...18-content-property-closeout-audit.test.ts | 6 +- ...h-model-source-inventory-collector.test.ts | 7 + .../scripts/v18-migration-command.test.ts | 34 +++ .../scripts/v18-migration-finalizer.test.ts | 12 + .../v18-scratch-reading-builder.test.ts | 41 ++++ .../v18-v17-golden-graph-fixtures.test.ts | 146 ++++++++++++ vitest.config.ts | 2 +- 38 files changed, 695 insertions(+), 56 deletions(-) create mode 100644 test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25932faa..e0748ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 graph-model migration review follow-up now preserves parent Git command + environment variables under deterministic identities, validates scratch and + finalization boundaries before Git work, rejects malformed scratch payload + hex bytes, restores runtime-backed fixture fact dispatch, and raises adapter + guard coverage for the CI ratchet. - V18 graph-model migration dry-run review follow-up now removes boolean-trap notice validation helpers, encodes planned target property keys with a named length-prefixed format, and raises focused constructor-guard coverage for the diff --git a/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md index 49a38661..ca3e40ae 100644 --- a/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md +++ b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md @@ -1,7 +1,7 @@ --- cycle: 0196 task_id: V18_scratch_migration_writer -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md index d90d45fc..a8b140a9 100644 --- a/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md +++ b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md @@ -1,7 +1,7 @@ --- cycle: 0197 task_id: V18_scratch_equivalence_gate -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md index 834c8a4e..35b90e1b 100644 --- a/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md +++ b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md @@ -1,7 +1,7 @@ --- cycle: 0198 task_id: V18_migration_finalization_safety -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md b/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md index aa1394bd..220dcff2 100644 --- a/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md +++ b/docs/design/0200-v18-migration-finalization-implementation/v18-migration-finalization-implementation.md @@ -1,7 +1,7 @@ --- cycle: 0200 task_id: V18_migration_finalization_implementation -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md b/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md index 0c5111dc..337397bb 100644 --- a/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md +++ b/docs/design/0201-v18-migration-command-wiring/v18-migration-command-wiring.md @@ -1,7 +1,7 @@ --- cycle: 0201 task_id: V18_migration_command_wiring -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md b/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md index cfc2c0c5..d93066bf 100644 --- a/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md +++ b/docs/design/0202-v18-post-migration-runtime-conformance/v18-post-migration-runtime-conformance.md @@ -1,7 +1,7 @@ --- cycle: 0202 task_id: V18_post_migration_runtime_conformance -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md index 241072d4..23f735b1 100644 --- a/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md +++ b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md @@ -1,7 +1,7 @@ --- cycle: 0203 task_id: V18_content_property_closeout_audit -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md b/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md index fb7fea6e..de27435d 100644 --- a/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md +++ b/docs/design/0204-v18-legacy-fixture-reading-construction/v18-legacy-fixture-reading-construction.md @@ -1,7 +1,7 @@ --- cycle: 0204 task_id: V18_legacy_fixture_reading_construction -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md b/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md index 6fb5e638..30390cd7 100644 --- a/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md +++ b/docs/design/0205-v18-scratch-operation-reading-construction/v18-scratch-operation-reading-construction.md @@ -1,7 +1,7 @@ --- cycle: 0205 task_id: V18_scratch_operation_reading_construction -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md b/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md index aa864b5c..b40ad44c 100644 --- a/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md +++ b/docs/design/0206-v18-command-reading-providers/v18-command-reading-providers.md @@ -1,7 +1,7 @@ --- cycle: 0206 task_id: V18_command_reading_providers -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md b/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md index 867d4c57..2d90a014 100644 --- a/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md +++ b/docs/design/0207-v18-scratch-runtime-conformance-provider/v18-scratch-runtime-conformance-provider.md @@ -1,7 +1,7 @@ --- cycle: 0207 task_id: V18_scratch_runtime_conformance_provider -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md b/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md index 50ff87ee..6eb50c29 100644 --- a/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md +++ b/docs/design/0208-v18-command-provider-finalization/v18-command-provider-finalization.md @@ -1,7 +1,7 @@ --- cycle: 0208 task_id: V18_command_provider_finalization -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md b/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md index 804a33c5..268cbef2 100644 --- a/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md +++ b/docs/design/0209-v18-provider-divergence-coverage/v18-provider-divergence-coverage.md @@ -1,7 +1,7 @@ --- cycle: 0209 task_id: V18_provider_divergence_coverage -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md b/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md index 46d86f2d..80963088 100644 --- a/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md +++ b/docs/design/0210-v18-migration-command-report/v18-migration-command-report.md @@ -1,7 +1,7 @@ --- cycle: 0210 task_id: V18_migration_command_report -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md b/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md index c60a30ca..9cfd54fe 100644 --- a/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md +++ b/docs/design/0211-v18-migration-command-cli-wrapper/v18-migration-command-cli-wrapper.md @@ -1,7 +1,7 @@ --- cycle: 0211 task_id: V18_migration_command_cli_wrapper -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md b/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md index 8ffb7d45..f8ca996f 100644 --- a/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md +++ b/docs/design/0212-v18-public-release-blockers/v18-public-release-blockers.md @@ -1,7 +1,7 @@ --- cycle: 0212 task_id: V18_public_release_blockers -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md b/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md index f8e2636f..d549f616 100644 --- a/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md +++ b/docs/design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md @@ -1,7 +1,7 @@ --- cycle: 0213 task_id: V18_replan_after_command_cli -status: Completed +status: Complete sponsors: human: James agent: Codex diff --git a/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts b/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts index 1870b09e..11098ac7 100644 --- a/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts +++ b/scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts @@ -66,7 +66,10 @@ function spawnGit( if (options.deterministicIdentity) { return spawn('git', args, { cwd, - env: MIGRATION_GIT_IDENTITY, + env: { + ...process.env, + ...MIGRATION_GIT_IDENTITY, + }, }); } return spawn('git', args, { cwd }); diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts index 0b274ddf..efa9abde 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -86,6 +86,7 @@ export async function runGraphModelMigrationCommand( options: GraphModelMigrationCommandOptions, ): Promise { const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const scratchRefName = requireNonEmptyString(options.scratchRefName, 'scratchRefName'); const dryRunRequest = requireDryRunRequest(options.dryRunRequest); const dryRunPlan = new DryRunGraphModelMigrationPlanner().plan(dryRunRequest); const loweringResult = new GraphModelMigrationOperationLowerer().lower(dryRunPlan); @@ -95,7 +96,7 @@ export async function runGraphModelMigrationCommand( const scratchWriteResult = await writeGraphModelMigrationScratchHistory({ repositoryPath, - scratchRefName: options.scratchRefName, + scratchRefName, patchPlan: loweringResult.patchPlan, }); if (scratchWriteResult.hasFatalErrors()) { @@ -142,8 +143,14 @@ async function resolveReadings( }> { if (options.readingProviders !== null) { return Object.freeze({ - legacyReading: await options.readingProviders.legacyReading(), - scratchReading: await options.readingProviders.scratchReading(scratchWriteResult), + legacyReading: requireReading( + await options.readingProviders.legacyReading(), + 'legacyReading', + ), + scratchReading: requireReading( + await options.readingProviders.scratchReading(scratchWriteResult), + 'scratchReading', + ), }); } return Object.freeze({ diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts index 1adbc25b..b0b54ad2 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizer.ts @@ -132,7 +132,7 @@ function requireSafetyResult( } function requireFinalizationString(value: string | null, name: string): string { - if (value === null) { + if (value === null || value.trim().length === 0) { throw new GraphModelMigrationFinalizerError(`${name} must be present after safety approval`); } return value; diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts index 3dd1e8c2..601842ab 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts @@ -178,11 +178,10 @@ function utf8FromHex(hex: string): string { } function parseHexByte(hex: string): number { - const value = Number.parseInt(hex, 16); - if (!Number.isInteger(value)) { + if (!/^[0-9a-f]{2}$/iu.test(hex)) { throw new GraphModelMigrationScratchReadingBuilderError(`invalid hex byte ${hex}`); } - return value; + return Number.parseInt(hex, 16); } async function gitLines(cwd: string, args: readonly string[]): Promise { diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts index 4e1cc802..0e1d6f29 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts @@ -34,8 +34,9 @@ export type GraphModelMigrationSourceInventoryCollectorOptions = { export async function collectGraphModelMigrationSourceInventory( options: GraphModelMigrationSourceInventoryCollectorOptions, ): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); const graphId = requireNonEmptyString(options.graphId, 'graphId'); - const refNames = await listWriterRefs(options.repositoryPath, graphId); + const refNames = await listWriterRefs(repositoryPath, graphId); if (refNames.length === 0) { return emptyInventory(graphId, NO_WRITER_REFS_CODE, `no writer refs found for graph ${graphId}`); } @@ -47,10 +48,10 @@ export async function collectGraphModelMigrationSourceInventory( for (const refName of refNames) { const writerId = writerIdFromRef(refName, graphId); - const patchIds = await gitLines(options.repositoryPath, ['rev-list', '--reverse', refName]); + const patchIds = await gitLines(repositoryPath, ['rev-list', '--reverse', refName]); const expectedWriter = writerId; await collectPatchDescriptors({ - repositoryPath: options.repositoryPath, + repositoryPath, graphId, writerId: expectedWriter, patchIds, @@ -61,7 +62,7 @@ export async function collectGraphModelMigrationSourceInventory( writerId, patchIds, })); - const head = await gitText(options.repositoryPath, ['rev-parse', '--verify', refName]); + const head = await gitText(repositoryPath, ['rev-parse', '--verify', refName]); basisParts.push(`${refName}@${head}`); } diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts index 881e7a23..ab2215db 100644 --- a/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts @@ -30,9 +30,11 @@ export type V17GoldenGraphFixtureRestoreResult = { export async function restoreV17GoldenGraphFixture( options: V17GoldenGraphFixtureRestoreOptions, ): Promise { - const manifest = await readManifest(options.manifestPath); - const repositoryPath = resolve(options.targetDirectory); - const bundlePath = resolve(dirname(options.manifestPath), manifest.bundlePath); + const manifestPath = requireNonEmptyString(options.manifestPath, 'manifestPath'); + const targetDirectory = requireNonEmptyString(options.targetDirectory, 'targetDirectory'); + const manifest = await readManifest(manifestPath); + const repositoryPath = resolve(targetDirectory); + const bundlePath = resolve(dirname(manifestPath), manifest.bundlePath); await mkdir(repositoryPath, { recursive: true }); await runGit(repositoryPath, ['init', '-q']); @@ -53,6 +55,13 @@ async function readManifest(path: string): Promise V17GoldenGraphFixtureVisibleFact; +}; + +const VISIBLE_FACT_FACTORIES: readonly VisibleFactFactory[] = Object.freeze([ + Object.freeze({ + kind: V17_GOLDEN_NODE_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenNodeFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_EDGE_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenEdgeFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_PROPERTY_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenPropertyFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_CONTENT_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenContentFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_REMOVAL_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenRemovalFact(fields), + }), + Object.freeze({ + kind: V17_GOLDEN_MULTI_WRITER_FACT, + create: (fields: V17GoldenGraphFixtureTypedFactFields) => new V17GoldenMultiWriterFact(fields), + }), +]); + /** Parses a v17 golden graph-history fixture manifest from JSON. */ export function parseV17GoldenGraphFixtureManifestJson( raw: string, @@ -63,14 +108,29 @@ function readVisibleFacts(source: JsonObject): readonly V17GoldenGraphFixtureVis return readObjectArray(source, 'visibleFacts').map((fact, index) => { const label = `visibleFacts[${index}]`; rejectUnknownKeys(fact, ['kind', 'key', 'description'], label); - return new V17GoldenGraphFixtureVisibleFact({ - kind: readFactKind(fact, `${label}.kind`, 'kind'), - key: readRequiredString(fact, `${label}.key`, 'key'), - description: readRequiredString(fact, `${label}.description`, 'description'), - }); + return readVisibleFact( + readFactKind(fact, `${label}.kind`, 'kind'), + readRequiredString(fact, `${label}.key`, 'key'), + readRequiredString(fact, `${label}.description`, 'description'), + ); }); } +function readVisibleFact( + kind: V17GoldenGraphFixtureFactKind, + key: string, + description: string, +): V17GoldenGraphFixtureVisibleFact { + for (const factory of VISIBLE_FACT_FACTORIES) { + if (factory.kind === kind) { + return factory.create({ key, description }); + } + } + throw new AdapterValidationError( + 'V17 golden graph fixture manifest field "visibleFacts.kind" must be a supported fact kind', + ); +} + function readObjectArray(source: JsonObject, key: string): readonly JsonObject[] { const value = readRequiredValue(source, key); if (!Array.isArray(value)) { diff --git a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts index 67d7e931..0efb2ef6 100644 --- a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts @@ -332,6 +332,8 @@ describe('graph model migration constructor guards', () => { }).refName).toBe('refs/warp-migration-archive/graph/writers/alice'); expect(GraphModelMigrationArchiveRef.validateRefName(null)?.code) .toBe('E_MISSING_ARCHIVE_REF'); + expect(GraphModelMigrationArchiveRef.validateRefName(undefined)?.code) + .toBe('E_MISSING_ARCHIVE_REF'); expect(GraphModelMigrationArchiveRef.validateRefName('refs/warp/graph/writers/alice')?.code) .toBe('E_LIVE_ARCHIVE_REF_TARGET'); expect(GraphModelMigrationArchiveRef.validateRefName('refs/not-archive/graph')?.code) @@ -342,6 +344,19 @@ describe('graph model migration constructor guards', () => { // @ts-expect-error exercising runtime validation new GraphModelMigrationArchiveRef(null); }).toThrow(/fields/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationArchiveRef({}); + }).toThrow(/archive ref target/); + expect(new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/graph/migration', + }).refName).toBe('refs/warp-migration-scratch/graph/migration'); + expect(GraphModelMigrationScratchRef.validateRefName(undefined)?.code) + .toBe('E_MISSING_SCRATCH_REF'); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationScratchRef({}); + }).toThrow(/scratch ref target/); expect(() => new GraphModelMigrationScratchWriteResult({ scratchRef: scratchRef(), scratchHead: 'scratch-head', diff --git a/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts b/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts new file mode 100644 index 00000000..78a3d355 --- /dev/null +++ b/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import { + parseGraphModelMigrationDryRunRequest, +} from '../../../../src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts'; + +type FixtureJsonValue = + | string + | number + | boolean + | null + | readonly FixtureJsonValue[] + | { readonly [key: string]: FixtureJsonValue }; + +type RequestOverrides = { + readonly inventory?: FixtureJsonValue; + readonly requiredContentKeys?: FixtureJsonValue; + readonly nodeMappings?: FixtureJsonValue; + readonly edgeMappings?: FixtureJsonValue; + readonly propertyMappings?: FixtureJsonValue; + readonly extraRoot?: boolean; +}; + +type InventoryOverrides = { + readonly graphId?: FixtureJsonValue; + readonly sourceBasis?: FixtureJsonValue; + readonly writerChains?: FixtureJsonValue; + readonly patchDescriptors?: FixtureJsonValue; + readonly stateSnapshot?: FixtureJsonValue; + readonly contentSources?: FixtureJsonValue; + readonly warnings?: FixtureJsonValue; + readonly fatalErrors?: FixtureJsonValue; + readonly extraInventory?: boolean; +}; + +describe('GraphModelMigrationDryRunRequestJsonAdapter', () => { + it('parses a complete dry-run request into runtime-backed migration nouns', () => { + const request = parseGraphModelMigrationDryRunRequest(requestJson()); + + expect(request).toBeInstanceOf(DryRunGraphModelMigrationPlanRequest); + expect(request.inventory.graphId).toBe('v17-golden-graph'); + expect(request.inventory.stateSnapshot?.snapshotId).toBe('snapshot:source'); + expect(request.inventory.warnings.map((notice) => notice.kind)).toEqual(['warning']); + expect(request.inventory.fatalErrors.map((notice) => notice.kind)).toEqual(['fatal']); + expect(request.requiredContentKeys).toEqual(['node:alpha:_content']); + }); + + it('rejects malformed JSON without leaking a platform SyntaxError', () => { + expect(() => parseGraphModelMigrationDryRunRequest('{')).toThrow(/valid JSON/); + }); + + it('rejects malformed request envelopes at the JSON boundary', () => { + const cases = Object.freeze([ + { + raw: requestJson({ extraRoot: true }), + message: /dryRunRequest\.extra/, + }, + { + raw: requestJson({ inventory: [] }), + message: /inventory.*object/, + }, + { + raw: requestJson({ requiredContentKeys: 'node:alpha:_content' }), + message: /requiredContentKeys.*array/, + }, + { + raw: requestJson({ requiredContentKeys: ['node:alpha:_content', ''] }), + message: /requiredContentKeys\[1\]/, + }, + { + raw: requestJson({ nodeMappings: [null] }), + message: /nodeMappings\[0\].*object/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseGraphModelMigrationDryRunRequest(candidate.raw)) + .toThrow(candidate.message); + } + }); + + it('rejects malformed inventory payloads at the JSON boundary', () => { + const cases = Object.freeze([ + { + raw: requestJson({ inventory: inventoryJson({ extraInventory: true }) }), + message: /inventory\.extra/, + }, + { + raw: requestJson({ inventory: inventoryJson({ graphId: '' }) }), + message: /inventory\.graphId/, + }, + { + raw: requestJson({ inventory: inventoryJson({ writerChains: 'alice' }) }), + message: /writerChains.*array/, + }, + { + raw: requestJson({ inventory: inventoryJson({ patchDescriptors: [null] }) }), + message: /patchDescriptors\[0\].*object/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + patchDescriptors: [ + { + patchId: 'patch:alice:0', + writerId: 'alice', + writerSequence: '0', + }, + ], + }), + }), + message: /writerSequence.*finite number/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + warnings: [ + { + kind: 'info', + code: 'W_SOURCE', + message: 'unsupported notice kind', + }, + ], + }), + }), + message: /warnings\[0\]\.kind.*warning or fatal/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + contentSources: [ + { + legacyContentKey: 'node:alpha:_content', + }, + ], + }), + }), + message: /contentOid.*required/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseGraphModelMigrationDryRunRequest(candidate.raw)) + .toThrow(candidate.message); + } + }); +}); + +function requestJson(overrides: RequestOverrides = {}): string { + const request = { + inventory: overrides.inventory ?? inventoryJson(), + requiredContentKeys: overrides.requiredContentKeys ?? ['node:alpha:_content'], + nodeMappings: overrides.nodeMappings ?? [ + { + legacyNodeId: 'node:alpha', + targetNodeId: 'node:alpha', + }, + ], + edgeMappings: overrides.edgeMappings ?? [ + { + legacyEdgeId: 'edge:alpha-beta', + targetEdgeId: 'edge:alpha-beta', + }, + ], + propertyMappings: overrides.propertyMappings ?? [ + { + legacyOwnerId: 'node:alpha', + legacyPropertyKey: 'title', + targetOwnerId: 'node:alpha', + targetPropertyKey: 'title', + }, + ], + ...(overrides.extraRoot === true ? { extra: true } : {}), + }; + return JSON.stringify(request); +} + +function inventoryJson(overrides: InventoryOverrides = {}) { + return { + graphId: overrides.graphId ?? 'v17-golden-graph', + sourceBasis: overrides.sourceBasis ?? { + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }, + writerChains: overrides.writerChains ?? [ + { + writerId: 'alice', + patchIds: ['patch:alice:0'], + }, + ], + patchDescriptors: overrides.patchDescriptors ?? [ + { + patchId: 'patch:alice:0', + writerId: 'alice', + writerSequence: 0, + }, + ], + stateSnapshot: overrides.stateSnapshot ?? { + snapshotId: 'snapshot:source', + }, + contentSources: overrides.contentSources ?? [ + { + legacyContentKey: 'node:alpha:_content', + contentOid: 'fixture-content:node:alpha:_content', + }, + ], + warnings: overrides.warnings ?? [ + { + kind: 'warning', + code: 'W_SOURCE', + message: 'source warning', + }, + ], + fatalErrors: overrides.fatalErrors ?? [ + { + kind: 'fatal', + code: 'E_SOURCE', + message: 'source fatal', + }, + ], + ...(overrides.extraInventory === true ? { extra: true } : {}), + }; +} diff --git a/test/unit/scripts/v18-content-property-closeout-audit.test.ts b/test/unit/scripts/v18-content-property-closeout-audit.test.ts index e69b595d..3c5ddc97 100644 --- a/test/unit/scripts/v18-content-property-closeout-audit.test.ts +++ b/test/unit/scripts/v18-content-property-closeout-audit.test.ts @@ -71,8 +71,12 @@ async function collectTypeScriptFiles(directory: string): Promise { expect(inventory.fatalErrors.map((notice) => notice.code)).toContain('E_NO_WRITER_REFS'); expect(inventory.fatalErrors.map((notice) => notice.code)).toContain('E_MISSING_SOURCE_BASIS'); }); + + it('rejects an empty repository path before invoking Git', async () => { + await expect(collectGraphModelMigrationSourceInventory({ + repositoryPath: '', + graphId: 'v17-golden-graph', + })).rejects.toThrow(/repositoryPath/); + }); }); diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index 58a5615e..3fd2f8b3 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -224,6 +224,40 @@ describe('v18 graph-model migration command', () => { expect(result.gateResult?.proofResult.summary.legacyFactCount).toBe(1); expect(result.gateResult?.proofResult.summary.migratedFactCount).toBe(1); }); + + it('rejects an empty scratch ref name at the command boundary', async () => { + const repository = await initializedRepository('git-warp-v18-command-invalid-ref-'); + + await expect(runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: '', + equivalenceBasis: basis(), + legacyReading: legacyNodeReading(), + scratchReading: legacyNodeReading(), + readingProviders: null, + finalization: null, + })).rejects.toThrow(/scratchRefName/); + }); + + it('rejects malformed provider readings before gate evaluation', async () => { + const repository = await initializedRepository('git-warp-v18-command-invalid-provider-'); + + await expect(runGraphModelMigrationCommand({ + repositoryPath: repository, + dryRunRequest: dryRunRequest(), + scratchRefName: SCRATCH_REF, + equivalenceBasis: basis(), + legacyReading: null, + scratchReading: null, + readingProviders: { + // @ts-expect-error exercising runtime validation + legacyReading: async () => null, + scratchReading: async () => legacyNodeReading(), + }, + finalization: null, + })).rejects.toThrow(/legacyReading/); + }); }); type CommandFixtureRepository = { diff --git a/test/unit/scripts/v18-migration-finalizer.test.ts b/test/unit/scripts/v18-migration-finalizer.test.ts index 6a651ec8..5d9be553 100644 --- a/test/unit/scripts/v18-migration-finalizer.test.ts +++ b/test/unit/scripts/v18-migration-finalizer.test.ts @@ -97,6 +97,18 @@ describe('v18 migration finalizer', () => { expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(driftHead); }); + + it('rejects blank approved finalization strings before Git ref updates', async () => { + const repository = await repositoryWithLiveAndScratchRefs(); + + await expect(finalizeGraphModelMigration({ + repositoryPath: repository.path, + safetyResult: passedSafetyResult(' ', repository.scratchHead), + })).rejects.toThrow(/expectedLiveHead/); + + expect(await refExists(repository.path, ARCHIVE_REF)).toBe(false); + expect(await gitText(repository.path, ['rev-parse', LIVE_REF])).toBe(repository.liveHead); + }); }); type FinalizerFixtureRepository = { diff --git a/test/unit/scripts/v18-scratch-reading-builder.test.ts b/test/unit/scripts/v18-scratch-reading-builder.test.ts index 8e109efd..af423fd3 100644 --- a/test/unit/scripts/v18-scratch-reading-builder.test.ts +++ b/test/unit/scripts/v18-scratch-reading-builder.test.ts @@ -9,6 +9,8 @@ import { buildGraphModelMigrationScratchReading } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; import { writeGraphModelMigrationScratchHistory } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import { runMigrationGit } + from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; import GraphModelMigrationLoweredOperation from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; @@ -46,6 +48,25 @@ describe('v18 scratch reading builder', () => { ]); expect(reading.facts.every((fact) => fact.boundary?.writerId === 'scratch-migration')).toBe(true); }); + + it('rejects malformed hex bytes instead of partially parsing them', async () => { + const repositoryPath = await initializedRepository(); + const commitId = await writeScratchPayload(repositoryPath, [ + 'git-warp-v18-migration-operation-v1', + 'sequence 0', + 'kind node-record', + 'source-key-utf8-hex 0g', + 'target-key-utf8-hex 6e6f64653a61', + '', + ].join('\n')); + await execFileAsync('git', ['update-ref', SCRATCH_REF, commitId], { cwd: repositoryPath }); + + await expect(buildGraphModelMigrationScratchReading({ + repositoryPath, + scratchRefName: SCRATCH_REF, + readingId: 'scratch:bad-hex', + })).rejects.toThrow(/invalid hex byte 0g/); + }); }); async function initializedRepository(): Promise { @@ -77,3 +98,23 @@ function operation( ): GraphModelMigrationLoweredOperation { return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); } + +async function writeScratchPayload(repositoryPath: string, payload: string): Promise { + const blobOid = await gitOk(repositoryPath, ['hash-object', '-w', '--stdin'], payload); + const treeOid = await gitOk( + repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\tmigration-operation.txt\n`, + ); + return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); +} + +async function gitOk( + repositoryPath: string, + args: readonly string[], + input: string, +): Promise { + const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); + expect(result.ok()).toBe(true); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts b/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts index a1caa69e..b77b6b6c 100644 --- a/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts +++ b/test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts @@ -9,6 +9,14 @@ import { import { parseV17GoldenGraphFixtureManifestJson, } from '../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; +import { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, +} from '../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); @@ -26,6 +34,12 @@ describe('v18 v17 golden graph-history fixtures', () => { expect(manifest.hasVisibleFactKind('content')).toBe(true); expect(manifest.hasVisibleFactKind('removal')).toBe(true); expect(manifest.hasVisibleFactKind('multi-writer')).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenContentFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenEdgeFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenNodeFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenRemovalFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenPropertyFact)).toBe(true); + expect(manifest.visibleFacts.some((fact) => fact instanceof V17GoldenMultiWriterFact)).toBe(true); }); it('restores the bundle into an isolated repository and verifies writer heads', async () => { @@ -74,4 +88,136 @@ describe('v18 v17 golden graph-history fixtures', () => { targetDirectory, })).rejects.toThrow('expected aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); }); + + it('rejects malformed manifest JSON at the adapter boundary', () => { + const cases = Object.freeze([ + { + raw: '{', + message: /valid JSON/, + }, + { + raw: '[]', + message: /manifest.*object/, + }, + { + raw: manifestJson({ extraRoot: true }), + message: /manifest\.extra/, + }, + { + raw: manifestJson({ writerChains: 'alice' }), + message: /writerChains.*array/, + }, + { + raw: manifestJson({ writerChains: [null] }), + message: /writerChains\[0\].*object/, + }, + { + raw: manifestJson({ + writerChains: [ + { + writerId: '', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }, + ], + }), + message: /writerId.*non-empty string/, + }, + { + raw: manifestJson({ + writerChains: [ + { + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: '1', + }, + ], + }), + message: /patchCount.*finite number/, + }, + { + raw: manifestJson({ + visibleFacts: [ + { + kind: 7, + key: 'node:alpha', + description: 'bad kind', + }, + ], + }), + message: /kind.*supported fact kind/, + }, + { + raw: manifestJson({ + visibleFacts: [ + { + kind: 'node', + key: 'node:alpha', + }, + ], + }), + message: /description.*required/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseV17GoldenGraphFixtureManifestJson(candidate.raw)) + .toThrow(candidate.message); + } + }); + + it('rejects empty restore paths before file-system or Git work', async () => { + await expect(restoreV17GoldenGraphFixture({ + manifestPath: '', + targetDirectory: 'target', + })).rejects.toThrow(/manifestPath/); + await expect(restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory: '', + })).rejects.toThrow(/targetDirectory/); + }); }); + +type ManifestJsonValue = + | string + | number + | boolean + | null + | readonly ManifestJsonValue[] + | { readonly [key: string]: ManifestJsonValue }; + +type ManifestOverrides = { + readonly writerChains?: ManifestJsonValue; + readonly visibleFacts?: ManifestJsonValue; + readonly extraRoot?: boolean; +}; + +function manifestJson(overrides: ManifestOverrides = {}): string { + const manifest = { + fixtureId: 'fixture:unit', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: overrides.writerChains ?? [ + { + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }, + ], + visibleFacts: overrides.visibleFacts ?? [ + { kind: 'node', key: 'node:alpha', description: 'node' }, + { kind: 'edge', key: 'edge:alpha-beta', description: 'edge' }, + { kind: 'property', key: 'node:alpha:title', description: 'title' }, + { kind: 'content', key: 'node:alpha:_content', description: 'content' }, + { kind: 'removal', key: 'node:removed', description: 'removed' }, + { kind: 'multi-writer', key: 'writers:alice+bob', description: 'multi' }, + ], + ...(overrides.extraRoot === true ? { extra: true } : {}), + }; + return JSON.stringify(manifest); +} diff --git a/vitest.config.ts b/vitest.config.ts index 47066e98..5f9326e5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/ports/**/*.ts', 'src/**/*.d.ts'], thresholds: { - lines: 91.92, + lines: 92.05, autoUpdate: shouldAutoUpdateCoverageRatchet(), }, }, From 45003df93b38f24ecb3bb4176ca3a1ea805d4ff5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 12:47:17 -0700 Subject: [PATCH 23/23] Test: Add CI-stable migration guard coverage --- ...hModelMigrationReviewGuardCoverage.test.ts | 261 ++++++++++++++++++ ...17GoldenGraphFixtureGenesisReading.test.ts | 89 ++++++ 2 files changed, 350 insertions(+) create mode 100644 test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts diff --git a/test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts b/test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts new file mode 100644 index 00000000..6588f5c2 --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationReviewGuardCoverage.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafetyResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; + +describe('graph model migration review guard coverage', () => { + it('covers scratch ref validation branches without native TypeError escapes', () => { + const scratchRef = new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/graph/migration', + }); + + expect(scratchRef.toString()).toBe('refs/warp-migration-scratch/graph/migration'); + expect(GraphModelMigrationScratchRef.validateRefName(null)?.code) + .toBe('E_MISSING_SCRATCH_REF'); + expect(GraphModelMigrationScratchRef.validateRefName('refs/warp/graph/writers/alice')?.code) + .toBe('E_LIVE_REF_TARGET'); + expect(GraphModelMigrationScratchRef.validateRefName('refs/not-scratch/graph')?.code) + .toBe('E_INVALID_SCRATCH_REF'); + expect(GraphModelMigrationScratchRef.validateRefName('refs/warp-migration-scratch/bad~name')?.code) + .toBe('E_INVALID_SCRATCH_REF'); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationScratchRef(null); + }).toThrow(/fields/); + }); + + it('covers finalization request and safety result malformed envelopes', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationFinalizationRequest(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: '', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + })).toThrow(/liveRefName/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: '', + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + })).toThrow(/expectedLiveHead/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/graph', + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + })).toThrow(/scratchRef/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + // @ts-expect-error exercising runtime validation + confirmation: 'confirm', + gateResult: null, + runtimeConformance: null, + })).toThrow(/confirmation/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + // @ts-expect-error exercising runtime validation + gateResult: 'passed', + runtimeConformance: null, + })).toThrow(/gateResult/); + expect(() => new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + // @ts-expect-error exercising runtime validation + runtimeConformance: 'passed', + })).toThrow(/runtimeConformance/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationFinalizationSafetyResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationFinalizationSafetyResult({ + // @ts-expect-error exercising runtime validation + request: 'request', + fatalErrors: [], + })).toThrow(/request/); + expect(() => new GraphModelMigrationFinalizationSafetyResult({ + request: finalizationRequest(), + // @ts-expect-error exercising runtime validation + fatalErrors: 'fatal', + })).toThrow(/fatalErrors/); + expect(() => new GraphModelMigrationFinalizationSafetyResult({ + request: finalizationRequest(), + fatalErrors: [GraphModelMigrationNotice.warning('W_WARNING', 'warning')], + })).toThrow(/fatalErrors/); + }); + + it('covers v17 golden fixture manifest malformed envelopes', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new V17GoldenGraphFixtureWriterChain(null); + }).toThrow(/fields/); + expect(() => { + // @ts-expect-error exercising runtime validation + new V17GoldenGraphFixtureVisibleFact(null); + }).toThrow(/fields/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: '', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: completeFixtureFacts(), + })).toThrow(/fixtureId/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: '/bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: completeFixtureFacts(), + })).toThrow(/relative fixture path/); + expect(() => new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/not-warp/graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + })).toThrow('refName must be under refs/warp/'); + expect(() => new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/graph/writers/alice', + expectedHead: 'not-an-oid', + patchCount: 1, + })).toThrow(/object id/); + expect(() => new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 0, + })).toThrow(/positive safe integer/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + // @ts-expect-error exercising runtime validation + writerChains: 'alice', + visibleFacts: completeFixtureFacts(), + })).toThrow(/writerChains/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + // @ts-expect-error exercising runtime validation + visibleFacts: 'node', + })).toThrow(/visibleFacts/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + // @ts-expect-error exercising runtime validation + writerChains: [{ writerId: 'alice' }], + visibleFacts: completeFixtureFacts(), + })).toThrow(/writerChains/); + expect(() => new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture', + graphId: 'graph', + sourceVersion: '17.0.1', + generator: 'test', + bundlePath: 'bundle', + writerChains: [fixtureWriter('alice')], + visibleFacts: [{ kind: 'node', key: 'node:a', description: 'node' }], + })).toThrow(/visibleFacts/); + }); +}); + +function finalizationRequest(): GraphModelMigrationFinalizationRequest { + return new GraphModelMigrationFinalizationRequest({ + liveRefName: 'refs/warp/graph', + expectedLiveHead: null, + observedLiveHead: null, + scratchRef: null, + scratchHead: null, + archiveRefName: null, + confirmation: null, + gateResult: null, + runtimeConformance: null, + }); +} + +function fixtureWriter(writerId: string): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId, + refName: `refs/warp/graph/writers/${writerId}`, + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }); +} + +function completeFixtureFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + fixtureFact('node', 'node:a'), + fixtureFact('edge', 'edge:a'), + fixtureFact('property', 'property:a'), + fixtureFact('content', 'content:a'), + fixtureFact('removal', 'node:removed'), + fixtureFact('multi-writer', 'writers:a+b'), + ]); +} + +function fixtureFact( + kind: 'node' | 'edge' | 'property' | 'content' | 'removal' | 'multi-writer', + key: string, +): V17GoldenGraphFixtureVisibleFact { + return new V17GoldenGraphFixtureVisibleFact({ + kind, + key, + description: `${kind}:${key}`, + }); +} diff --git a/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts index f0ee7953..cf9653a1 100644 --- a/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts +++ b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts @@ -4,6 +4,16 @@ import { describe, expect, it } from 'vitest'; import V17GoldenGraphFixtureGenesisReading from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenGraphFixtureVisibleFact, + V17GoldenGraphFixtureWriterChain, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; import { parseV17GoldenGraphFixtureManifestJson } from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; @@ -35,4 +45,83 @@ describe('V17GoldenGraphFixtureGenesisReading', () => { 'alice', ]); }); + + it('rejects malformed genesis reading inputs through domain errors', () => { + const builder = new V17GoldenGraphFixtureGenesisReading(); + + expect(() => { + // @ts-expect-error exercising runtime validation + builder.build(null); + }).toThrow(/manifest/); + expect(() => builder.build(manifestWithBaseVisibleFacts())) + .toThrow(/unsupported v17 fixture visible fact kind/); + expect(() => builder.build(manifestWithoutWriterChains())) + .toThrow(/writer chain evidence/); + }); }); + +function manifestWithBaseVisibleFacts(): V17GoldenGraphFixtureManifest { + return new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture:base-facts', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: [writerChain()], + visibleFacts: visibleFacts(), + }); +} + +function manifestWithoutWriterChains(): V17GoldenGraphFixtureManifest { + return new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture:no-writers', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: [], + visibleFacts: typedVisibleFacts(), + }); +} + +function writerChain(): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '1111111111111111111111111111111111111111', + patchCount: 1, + }); +} + +function visibleFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + visibleFact('node', 'node:alpha'), + visibleFact('edge', 'edge:alpha-beta'), + visibleFact('property', 'node:alpha:title'), + visibleFact('content', 'node:alpha:_content'), + visibleFact('removal', 'node:removed'), + visibleFact('multi-writer', 'writers:alice+bob'), + ]); +} + +function typedVisibleFacts(): readonly V17GoldenGraphFixtureVisibleFact[] { + return Object.freeze([ + new V17GoldenNodeFact({ key: 'node:alpha', description: 'node' }), + new V17GoldenEdgeFact({ key: 'edge:alpha-beta', description: 'edge' }), + new V17GoldenPropertyFact({ key: 'node:alpha:title', description: 'title' }), + new V17GoldenContentFact({ key: 'node:alpha:_content', description: 'content' }), + new V17GoldenRemovalFact({ key: 'node:removed', description: 'removed' }), + new V17GoldenMultiWriterFact({ key: 'writers:alice+bob', description: 'multi' }), + ]); +} + +function visibleFact( + kind: 'node' | 'edge' | 'property' | 'content' | 'removal' | 'multi-writer', + key: string, +): V17GoldenGraphFixtureVisibleFact { + return new V17GoldenGraphFixtureVisibleFact({ + kind, + key, + description: `${kind}:${key}`, + }); +}