From 45d59e08e78576447e6486c12d403dfd0e18a15b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 07:02:20 -0700 Subject: [PATCH 1/8] Feat: Add v18 migration dry-run CLI --- CHANGELOG.md | 3 + docs/BEARING.md | 31 +- .../v18-migration-dry-run-cli.md | 25 +- .../GraphModelMigrationDryRunCli.ts | 168 ++++++++++ .../v18.0.0/migrations/graph-model/dry-run.ts | 27 ++ ...hModelMigrationDryRunRequestJsonAdapter.ts | 310 ++++++++++++++++++ .../v18-graph-model-migration-dry-run.test.ts | 127 +++++++ 7 files changed, 679 insertions(+), 12 deletions(-) create mode 100644 scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts create mode 100644 scripts/v18.0.0/migrations/graph-model/dry-run.ts create mode 100644 src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts create mode 100644 test/unit/scripts/v18-graph-model-migration-dry-run.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ef21348..48612ad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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. - V18 graph-model migration dry-run work now exposes runtime-backed migration manifest nouns for source and target basis, node, edge, property, and content mappings, warnings, and fatal planning failures without adding any diff --git a/docs/BEARING.md b/docs/BEARING.md index 5587a395..b2e0e605 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -39,17 +39,17 @@ of handwritten adapter folklore. Current branch state at this boundary: -- Branch: `v18-continuum-slices-36-40` +- Branch: `v18-continuum-slices-41-45` - Base branch: `main` -- Current `origin/main`: `f9230f09` -- Latest merged PR: #101, v18 property projection cutover +- Current `origin/main`: `07e16795` +- Latest merged PR: #102, v18 migration dry-run planning substrate - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0183-v18-property-projection-closeout` -- Current work: v18 slices 36 through 40 are the active dry-run migration - batch on this branch. + `0188-v18-migration-manifest-serialization` +- Current work: v18 slices 41 through 45 are the active migration CLI and + genesis-equivalence evidence batch on this branch. - Cleanup checkpoint: `main` has been fast-forwarded to `origin/main` after - PR #101 merged; this branch starts from that merge commit. + PR #102 merged; this branch starts from that merge commit. The current v18 graph-model posture is: @@ -140,7 +140,7 @@ PR #101 landed v18 slices 31 through 35: - review follow-up hardened CI action pinning, property-value recursion and prototype guards, and hostile `SnapshotWarpState` hydration boundaries. -This branch starts PR C, v18 slices 36 through 40: +PR #102 landed v18 slices 36 through 40: - graph-model migration manifest nouns; - migration source inventory; @@ -171,6 +171,19 @@ round-trips through an infrastructure adapter with deterministic output, field-specific parse errors, and domain construction enforcing duplicate mapping invariants. +This branch starts PR D, v18 slices 41 through 45: + +- migration dry-run CLI; +- genesis equivalence proof nouns; +- genesis equivalence fixtures; +- genesis divergence reporter; +- evidence-backed replan. + +Slice 41 is complete on this branch. The dry-run CLI now accepts an explicit +request JSON artifact, decodes source facts at the infrastructure boundary, +calls the pure dry-run planner, writes only an optional deterministic manifest +artifact, reports summary counts, and refuses destructive apply/write verbs. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -283,7 +296,7 @@ and concrete checks live in `docs/invariants/`. [0187](design/0187-v18-migration-history-input/v18-migration-history-input.md). - [x] 40. Add migration manifest serialization: [0188](design/0188-v18-migration-manifest-serialization/v18-migration-manifest-serialization.md). -- [ ] 41. Add the migration dry-run CLI: +- [x] 41. Add the migration dry-run CLI: [0189](design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md). - [ ] 42. Add genesis equivalence proof nouns: [0190](design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md). diff --git a/docs/design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md b/docs/design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md index 978b94d4..f5a7bbdb 100644 --- a/docs/design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md +++ b/docs/design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md @@ -1,7 +1,7 @@ --- cycle: 0189 task_id: V18_migration_dry_run_cli -status: Planned +status: Complete sponsors: human: James agent: Codex @@ -91,13 +91,32 @@ real graph without operator intent. ## Verification ```text -npx vitest run test/unit/scripts/v18GraphModelMigrationDryRun.test.ts --reporter=verbose -npx eslint scripts/v18.0.0/migrations/graph-model test/unit/scripts/v18GraphModelMigrationDryRun.test.ts +npx vitest run test/unit/scripts/v18-graph-model-migration-dry-run.test.ts --reporter=verbose +npx eslint scripts/v18.0.0/migrations/graph-model src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts test/unit/scripts/v18-graph-model-migration-dry-run.test.ts npm run typecheck npm run lint git diff --check HEAD ``` +## Playback + +- The CLI lives under `scripts/v18.0.0/migrations/graph-model/`. +- `--dry-run` is accepted for explicitness, but the command is always + non-destructive and refuses `--apply`, `--write`, and `--commit`. +- `GraphModelMigrationDryRunRequestJsonAdapter` decodes request JSON at the + infrastructure boundary before domain planning. +- The runner emits deterministic summary lines, writes only an optional + manifest artifact, and reports `graphHistoryWrites: 0`. +- Incomplete inventory returns exit code `1`, emits fatal notices, and writes + no manifest. + +## Evidence + +- `src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts` +- `scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts` +- `scripts/v18.0.0/migrations/graph-model/dry-run.ts` +- `test/unit/scripts/v18-graph-model-migration-dry-run.test.ts` + ## Closeout Criteria - Dry-run CLI exists and writes no graph history. diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts new file mode 100644 index 00000000..6eb3afa2 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts @@ -0,0 +1,168 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import DryRunGraphModelMigrationPlanner + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; +import { parseGraphModelMigrationDryRunRequest } + from '../../../../src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts'; +import { serializeGraphModelMigrationManifest } + from '../../../../src/infrastructure/adapters/GraphModelMigrationManifestJsonAdapter.ts'; +import type DryRunGraphModelMigrationPlan + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; +import type GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; + +export class GraphModelMigrationDryRunCliArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationDryRunCliArgumentError'; + } +} + +export class GraphModelMigrationDryRunCliArgs { + constructor( + readonly requestPath: string | null, + readonly manifestOutPath: string | null, + readonly helpRequested: boolean, + ) { + Object.freeze(this); + } +} + +export class GraphModelMigrationDryRunCliResult { + constructor( + readonly exitCode: number, + readonly stdout: string, + readonly stderr: string, + ) { + Object.freeze(this); + } +} + +/** Returns CLI usage for the v18 graph-model migration dry-run. */ +export function graphModelMigrationDryRunUsage(): string { + return [ + 'Usage:', + ' node scripts/v18.0.0/migrations/graph-model/dry-run.ts --request [--manifest-out ]', + '', + 'Options:', + ' --request JSON dry-run request to validate and plan.', + ' --manifest-out Write the deterministic migration manifest to this path.', + ' --dry-run Accepted for explicitness; this command is always dry-run.', + ' --help Show this help.', + ].join('\n'); +} + +/** Parses dry-run CLI arguments without reading or writing files. */ +export function parseGraphModelMigrationDryRunCliArgs( + argv: readonly string[], +): GraphModelMigrationDryRunCliArgs { + let requestPath: string | null = null; + let manifestOutPath: string | null = null; + let helpRequested = false; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--request') { + requestPath = readArgValue(argv, index, '--request'); + index++; + continue; + } + if (arg === '--manifest-out') { + manifestOutPath = readArgValue(argv, index, '--manifest-out'); + index++; + continue; + } + if (arg === '--dry-run') { + continue; + } + if (arg === '--help' || arg === '-h') { + helpRequested = true; + continue; + } + if (arg === '--apply' || arg === '--write' || arg === '--commit') { + throw new GraphModelMigrationDryRunCliArgumentError( + `${arg} is not supported; graph-model migration is dry-run only`, + ); + } + throw new GraphModelMigrationDryRunCliArgumentError(`Unknown argument: ${arg ?? ''}`); + } + + return new GraphModelMigrationDryRunCliArgs(requestPath, manifestOutPath, helpRequested); +} + +/** Runs the v18 graph-model migration dry-run command. */ +export async function runGraphModelMigrationDryRunCli( + argv: readonly string[], +): Promise { + const args = parseGraphModelMigrationDryRunCliArgs(argv); + if (args.helpRequested) { + return new GraphModelMigrationDryRunCliResult(0, `${graphModelMigrationDryRunUsage()}\n`, ''); + } + if (args.requestPath === null) { + throw new GraphModelMigrationDryRunCliArgumentError('--request is required'); + } + + const rawRequest = await readFile(args.requestPath, 'utf8'); + const request = parseGraphModelMigrationDryRunRequest(rawRequest); + const plan = new DryRunGraphModelMigrationPlanner().plan(request); + if (plan.hasFatalErrors()) { + return new GraphModelMigrationDryRunCliResult( + 1, + formatSummary(args, plan, 'not-written'), + formatNotices(plan.fatalErrors), + ); + } + + const manifest = plan.manifest; + if (manifest === null) { + throw new GraphModelMigrationDryRunCliArgumentError('successful dry-run plan did not include a manifest'); + } + const manifestText = serializeGraphModelMigrationManifest(manifest); + if (args.manifestOutPath !== null) { + await writeFile(args.manifestOutPath, manifestText, 'utf8'); + return new GraphModelMigrationDryRunCliResult( + 0, + formatSummary(args, plan, args.manifestOutPath), + formatNotices(plan.warnings), + ); + } + return new GraphModelMigrationDryRunCliResult( + 0, + `${formatSummary(args, plan, 'stdout')}\n${manifestText}`, + formatNotices(plan.warnings), + ); +} + +/** Reads an argument value from the next argv slot. */ +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 GraphModelMigrationDryRunCliArgumentError(`${flag} requires a value`); + } + return value; +} + +/** Formats deterministic dry-run summary lines. */ +function formatSummary( + args: GraphModelMigrationDryRunCliArgs, + plan: DryRunGraphModelMigrationPlan, + manifestTarget: string, +): string { + return [ + 'Graph model migration dry run', + `request: ${args.requestPath ?? '(none)'}`, + `manifest: ${manifestTarget}`, + `plannedOperations: ${plan.plannedOperations.length}`, + `warnings: ${plan.warnings.length}`, + `fatalErrors: ${plan.fatalErrors.length}`, + 'graphHistoryWrites: 0', + ].join('\n'); +} + +/** Formats warning or fatal notices for stderr. */ +function formatNotices(notices: readonly GraphModelMigrationNotice[]): string { + if (notices.length === 0) { + return ''; + } + return `${notices.map((notice) => `${notice.kind}[${notice.code}]: ${notice.message}`).join('\n')}\n`; +} diff --git a/scripts/v18.0.0/migrations/graph-model/dry-run.ts b/scripts/v18.0.0/migrations/graph-model/dry-run.ts new file mode 100644 index 00000000..1f864f75 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/dry-run.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import process from 'node:process'; + +import { + graphModelMigrationDryRunUsage, + runGraphModelMigrationDryRunCli, +} from './GraphModelMigrationDryRunCli.ts'; + +function errorMessage(error: Error | string): string { + if (error instanceof Error) { + return error.message; + } + return error; +} + +runGraphModelMigrationDryRunCli(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 dry-run failure'; + process.stderr.write(`${message}\n\n${graphModelMigrationDryRunUsage()}\n`); + process.exitCode = 1; + }); diff --git a/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts b/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts new file mode 100644 index 00000000..f9943bfc --- /dev/null +++ b/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts @@ -0,0 +1,310 @@ +import DryRunGraphModelMigrationPlanRequest + from '../../domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GraphModelMigrationBasis from '../../domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationContentSource + from '../../domain/migrations/GraphModelMigrationContentSource.ts'; +import GraphModelMigrationEdgeMapping from '../../domain/migrations/GraphModelMigrationEdgeMapping.ts'; +import GraphModelMigrationNodeMapping from '../../domain/migrations/GraphModelMigrationNodeMapping.ts'; +import GraphModelMigrationNotice, { + type GraphModelMigrationNoticeKind, +} from '../../domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationPatchDescriptor + from '../../domain/migrations/GraphModelMigrationPatchDescriptor.ts'; +import GraphModelMigrationPropertyMapping + from '../../domain/migrations/GraphModelMigrationPropertyMapping.ts'; +import GraphModelMigrationSourceInventory + from '../../domain/migrations/GraphModelMigrationSourceInventory.ts'; +import GraphModelMigrationStateSnapshotReference + from '../../domain/migrations/GraphModelMigrationStateSnapshotReference.ts'; +import GraphModelMigrationWriterChainDescriptor + from '../../domain/migrations/GraphModelMigrationWriterChainDescriptor.ts'; +import AdapterValidationError from '../../domain/errors/AdapterValidationError.ts'; +import type { JsonObject } from './JsonObject.ts'; + +const REQUEST_KEYS = Object.freeze([ + 'inventory', + 'requiredContentKeys', + 'nodeMappings', + 'edgeMappings', + 'propertyMappings', +]); + +const INVENTORY_KEYS = Object.freeze([ + 'graphId', + 'sourceBasis', + 'writerChains', + 'patchDescriptors', + 'stateSnapshot', + 'contentSources', + 'warnings', + 'fatalErrors', +]); + +/** Parses dry-run request JSON into a runtime-backed planner request. */ +export function parseGraphModelMigrationDryRunRequest( + raw: string, +): DryRunGraphModelMigrationPlanRequest { + return requestFromJson(parseJson(raw)); +} + +/** Converts parsed JSON into a dry-run planner request. */ +function requestFromJson(value: unknown): DryRunGraphModelMigrationPlanRequest { + const source = requireJsonObject(value, 'dryRunRequest'); + rejectUnknownKeys(source, REQUEST_KEYS, 'dryRunRequest'); + return new DryRunGraphModelMigrationPlanRequest({ + inventory: readInventory(source), + requiredContentKeys: readStringArray(source, 'requiredContentKeys'), + nodeMappings: readNodeMappings(source), + edgeMappings: readEdgeMappings(source), + propertyMappings: readPropertyMappings(source), + }); +} + +/** Parses untrusted JSON text without leaking platform SyntaxError. */ +function parseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + throw new AdapterValidationError('Graph model migration dry-run request JSON must be valid JSON'); + } +} + +/** Reads the source inventory envelope. */ +function readInventory(source: JsonObject): GraphModelMigrationSourceInventory { + const inventory = readRequiredObject(source, 'inventory'); + rejectUnknownKeys(inventory, INVENTORY_KEYS, 'inventory'); + return new GraphModelMigrationSourceInventory({ + graphId: readRequiredString(inventory, 'inventory.graphId', 'graphId'), + sourceBasis: readNullableBasis(inventory, 'sourceBasis'), + writerChains: readWriterChains(inventory), + patchDescriptors: readPatchDescriptors(inventory), + stateSnapshot: readNullableStateSnapshot(inventory, 'stateSnapshot'), + contentSources: readContentSources(inventory), + warnings: readNotices(inventory, 'warnings'), + fatalErrors: readNotices(inventory, 'fatalErrors'), + }); +} + +/** Reads an explicit nullable basis field. */ +function readNullableBasis(source: JsonObject, key: string): GraphModelMigrationBasis | null { + const value = readRequiredValue(source, key); + if (value === null) { + return null; + } + const basis = requireJsonObject(value, `inventory.${key}`); + rejectUnknownKeys(basis, ['graphId', 'basisId'], `inventory.${key}`); + return new GraphModelMigrationBasis({ + graphId: readRequiredString(basis, `inventory.${key}.graphId`, 'graphId'), + basisId: readRequiredString(basis, `inventory.${key}.basisId`, 'basisId'), + }); +} + +/** Reads an explicit nullable state snapshot field. */ +function readNullableStateSnapshot( + source: JsonObject, + key: string, +): GraphModelMigrationStateSnapshotReference | null { + const value = readRequiredValue(source, key); + if (value === null) { + return null; + } + const snapshot = requireJsonObject(value, `inventory.${key}`); + rejectUnknownKeys(snapshot, ['snapshotId'], `inventory.${key}`); + return new GraphModelMigrationStateSnapshotReference({ + snapshotId: readRequiredString(snapshot, `inventory.${key}.snapshotId`, 'snapshotId'), + }); +} + +/** Reads writer chain descriptors. */ +function readWriterChains(source: JsonObject): readonly GraphModelMigrationWriterChainDescriptor[] { + return readObjectArray(source, 'writerChains').map((chain, index) => { + const label = `writerChains[${index}]`; + rejectUnknownKeys(chain, ['writerId', 'patchIds'], label); + return new GraphModelMigrationWriterChainDescriptor({ + writerId: readRequiredString(chain, `${label}.writerId`, 'writerId'), + patchIds: readStringArray(chain, 'patchIds'), + }); + }); +} + +/** Reads patch descriptors. */ +function readPatchDescriptors(source: JsonObject): readonly GraphModelMigrationPatchDescriptor[] { + return readObjectArray(source, 'patchDescriptors').map((patch, index) => { + const label = `patchDescriptors[${index}]`; + rejectUnknownKeys(patch, ['patchId', 'writerId', 'writerSequence'], label); + return new GraphModelMigrationPatchDescriptor({ + patchId: readRequiredString(patch, `${label}.patchId`, 'patchId'), + writerId: readRequiredString(patch, `${label}.writerId`, 'writerId'), + writerSequence: readRequiredNumber(patch, `${label}.writerSequence`, 'writerSequence'), + }); + }); +} + +/** Reads source content facts. */ +function readContentSources(source: JsonObject): readonly GraphModelMigrationContentSource[] { + return readObjectArray(source, 'contentSources').map((content, index) => { + const label = `contentSources[${index}]`; + rejectUnknownKeys(content, ['legacyContentKey', 'contentOid'], label); + return new GraphModelMigrationContentSource({ + legacyContentKey: readRequiredString(content, `${label}.legacyContentKey`, 'legacyContentKey'), + contentOid: readRequiredString(content, `${label}.contentOid`, 'contentOid'), + }); + }); +} + +/** Reads node mappings from the request envelope. */ +function readNodeMappings(source: JsonObject): readonly GraphModelMigrationNodeMapping[] { + return readObjectArray(source, 'nodeMappings').map((mapping, index) => { + const label = `nodeMappings[${index}]`; + rejectUnknownKeys(mapping, ['legacyNodeId', 'targetNodeId'], label); + return new GraphModelMigrationNodeMapping({ + legacyNodeId: readRequiredString(mapping, `${label}.legacyNodeId`, 'legacyNodeId'), + targetNodeId: readRequiredString(mapping, `${label}.targetNodeId`, 'targetNodeId'), + }); + }); +} + +/** Reads edge mappings from the request envelope. */ +function readEdgeMappings(source: JsonObject): readonly GraphModelMigrationEdgeMapping[] { + return readObjectArray(source, 'edgeMappings').map((mapping, index) => { + const label = `edgeMappings[${index}]`; + rejectUnknownKeys(mapping, ['legacyEdgeId', 'targetEdgeId'], label); + return new GraphModelMigrationEdgeMapping({ + legacyEdgeId: readRequiredString(mapping, `${label}.legacyEdgeId`, 'legacyEdgeId'), + targetEdgeId: readRequiredString(mapping, `${label}.targetEdgeId`, 'targetEdgeId'), + }); + }); +} + +/** Reads property mappings from the request envelope. */ +function readPropertyMappings(source: JsonObject): readonly GraphModelMigrationPropertyMapping[] { + return readObjectArray(source, 'propertyMappings').map((mapping, index) => { + const label = `propertyMappings[${index}]`; + rejectUnknownKeys( + mapping, + ['legacyOwnerId', 'legacyPropertyKey', 'targetOwnerId', 'targetPropertyKey'], + label, + ); + return new GraphModelMigrationPropertyMapping({ + legacyOwnerId: readRequiredString(mapping, `${label}.legacyOwnerId`, 'legacyOwnerId'), + legacyPropertyKey: readRequiredString(mapping, `${label}.legacyPropertyKey`, 'legacyPropertyKey'), + targetOwnerId: readRequiredString(mapping, `${label}.targetOwnerId`, 'targetOwnerId'), + targetPropertyKey: readRequiredString(mapping, `${label}.targetPropertyKey`, 'targetPropertyKey'), + }); + }); +} + +/** Reads warning or fatal notices. */ +function readNotices(source: JsonObject, key: string): readonly GraphModelMigrationNotice[] { + return readObjectArray(source, key).map((notice, index) => { + const label = `${key}[${index}]`; + rejectUnknownKeys(notice, ['kind', 'code', 'message'], label); + return new GraphModelMigrationNotice({ + kind: readNoticeKind(notice, `${label}.kind`, 'kind'), + code: readRequiredString(notice, `${label}.code`, 'code'), + message: readRequiredString(notice, `${label}.message`, 'message'), + }); + }); +} + +/** Reads a required object field. */ +function readRequiredObject(source: JsonObject, key: string): JsonObject { + return requireJsonObject(readRequiredValue(source, key), key); +} + +/** Reads an object array field. */ +function readObjectArray(source: JsonObject, key: string): readonly JsonObject[] { + const value = readRequiredValue(source, key); + if (!Array.isArray(value)) { + throw new AdapterValidationError(`Graph model migration dry-run request field "${key}" must be an array`); + } + const objects: JsonObject[] = []; + value.forEach((entry, index) => { + objects.push(requireJsonObject(entry, `${key}[${index}]`)); + }); + return Object.freeze(objects); +} + +/** Reads a required string array field. */ +function readStringArray(source: JsonObject, key: string): readonly string[] { + const value = readRequiredValue(source, key); + if (!Array.isArray(value)) { + throw new AdapterValidationError(`Graph model migration dry-run request field "${key}" must be an array`); + } + const strings: string[] = []; + value.forEach((entry, index) => { + if (typeof entry !== 'string' || entry.length === 0) { + throw new AdapterValidationError( + `Graph model migration dry-run request field "${key}[${index}]" must be a non-empty string`, + ); + } + strings.push(entry); + }); + return Object.freeze(strings); +} + +/** Reads a required string field. */ +function readRequiredString(source: JsonObject, label: string, key: string): string { + const value = readRequiredValue(source, key); + if (typeof value !== 'string' || value.length === 0) { + throw new AdapterValidationError( + `Graph model migration dry-run request field "${label}" must be a non-empty string`, + ); + } + return value; +} + +/** Reads a required finite number field. */ +function readRequiredNumber(source: JsonObject, label: string, key: string): number { + const value = readRequiredValue(source, key); + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AdapterValidationError( + `Graph model migration dry-run request field "${label}" must be a finite number`, + ); + } + return value; +} + +/** Reads a notice kind without string casts. */ +function readNoticeKind(source: JsonObject, label: string, key: string): GraphModelMigrationNoticeKind { + const value = readRequiredValue(source, key); + if (value === 'warning' || value === 'fatal') { + return value; + } + throw new AdapterValidationError( + `Graph model migration dry-run request field "${label}" must be warning or fatal`, + ); +} + +/** Reads a required field value. */ +function readRequiredValue(source: JsonObject, key: string): unknown { + const value = source[key]; + if (value === undefined) { + throw new AdapterValidationError(`Graph model migration dry-run request field "${key}" is required`); + } + return value; +} + +/** Requires a non-array JSON object. */ +function requireJsonObject(value: unknown, label: string): JsonObject { + if (!isJsonObject(value)) { + throw new AdapterValidationError(`Graph model migration dry-run request field "${label}" must be an object`); + } + return value; +} + +/** Returns true for non-array JSON object values. */ +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Rejects unexpected fields at the request JSON boundary. */ +function rejectUnknownKeys(source: JsonObject, allowed: readonly string[], label: string): void { + for (const key of Object.keys(source)) { + if (!allowed.includes(key)) { + throw new AdapterValidationError( + `Graph model migration dry-run request field "${label}.${key}" is not allowed`, + ); + } + } +} diff --git a/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts b/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts new file mode 100644 index 00000000..7fd88ed7 --- /dev/null +++ b/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, expect, it } from 'vitest'; + +import { + parseGraphModelMigrationDryRunCliArgs, + runGraphModelMigrationDryRunCli, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts'; + +describe('v18 graph-model migration dry-run CLI', () => { + it('emits a deterministic manifest for a complete dry-run request', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-dry-run-')); + const requestPath = join(directory, 'request.json'); + const manifestPath = join(directory, 'manifest.json'); + await writeFile(requestPath, completeRequestJson(), 'utf8'); + + const first = await runGraphModelMigrationDryRunCli([ + '--request', + requestPath, + '--manifest-out', + manifestPath, + ]); + const firstManifest = await readFile(manifestPath, 'utf8'); + const second = await runGraphModelMigrationDryRunCli([ + '--request', + requestPath, + '--manifest-out', + manifestPath, + ]); + const secondManifest = await readFile(manifestPath, 'utf8'); + + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + expect(first.stdout).toBe(second.stdout); + expect(firstManifest).toBe(secondManifest); + expect(first.stdout).toContain('plannedOperations: 3'); + expect(first.stdout).toContain('graphHistoryWrites: 0'); + expect(first.stderr).toBe(''); + expect(firstManifest).toContain('"basisId": "basis:source:v18-dry-run"'); + expect(firstManifest).toContain('"targetAttachmentKey": "content-attachment:node:a\\u0000_content"'); + }); + + it('fails closed and writes no manifest when source inventory is incomplete', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-dry-run-')); + const requestPath = join(directory, 'request.json'); + const manifestPath = join(directory, 'manifest.json'); + await writeFile(requestPath, missingSourceBasisRequestJson(), 'utf8'); + + const result = await runGraphModelMigrationDryRunCli([ + '--request', + requestPath, + '--manifest-out', + manifestPath, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('manifest: not-written'); + expect(result.stdout).toContain('fatalErrors: 1'); + expect(result.stderr).toContain('fatal[E_MISSING_SOURCE_BASIS]'); + await expect(stat(manifestPath)).rejects.toThrow(); + }); + + it('refuses destructive migration verbs', () => { + expect(() => parseGraphModelMigrationDryRunCliArgs(['--request', 'request.json', '--apply'])) + .toThrow(/dry-run only/); + }); +}); + +function completeRequestJson(): string { + return `{ + "inventory": { + "graphId": "graph:source", + "sourceBasis": { "graphId": "graph:source", "basisId": "basis:source" }, + "writerChains": [ + { "writerId": "writer:a", "patchIds": ["patch:a:0"] } + ], + "patchDescriptors": [ + { "patchId": "patch:a:0", "writerId": "writer:a", "writerSequence": 0 } + ], + "stateSnapshot": { "snapshotId": "snapshot:source" }, + "contentSources": [ + { "legacyContentKey": "node:a\\u0000_content", "contentOid": "oid:content:a" } + ], + "warnings": [], + "fatalErrors": [] + }, + "requiredContentKeys": ["node:a\\u0000_content"], + "nodeMappings": [ + { "legacyNodeId": "node:a", "targetNodeId": "node:a" } + ], + "edgeMappings": [], + "propertyMappings": [ + { + "legacyOwnerId": "node:a", + "legacyPropertyKey": "title", + "targetOwnerId": "node:a", + "targetPropertyKey": "title" + } + ] +} +`; +} + +function missingSourceBasisRequestJson(): string { + return `{ + "inventory": { + "graphId": "graph:source", + "sourceBasis": null, + "writerChains": [ + { "writerId": "writer:a", "patchIds": ["patch:a:0"] } + ], + "patchDescriptors": [ + { "patchId": "patch:a:0", "writerId": "writer:a", "writerSequence": 0 } + ], + "stateSnapshot": null, + "contentSources": [], + "warnings": [], + "fatalErrors": [] + }, + "requiredContentKeys": [], + "nodeMappings": [], + "edgeMappings": [], + "propertyMappings": [] +} +`; +} From 71e1e165113cd87043dcfbb0f75ed36f7448b00b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 07:07:55 -0700 Subject: [PATCH 2/8] Feat: Add genesis equivalence proof nouns --- CHANGELOG.md | 3 + docs/BEARING.md | 7 +- .../v18-genesis-equivalence-nouns.md | 24 ++- .../migrations/GenesisEquivalenceBoundary.ts | 53 ++++++ .../GenesisEquivalenceComparisonBasis.ts | 43 +++++ .../migrations/GenesisEquivalenceMismatch.ts | 164 ++++++++++++++++ .../migrations/GenesisEquivalenceProof.ts | 155 +++++++++++++++ .../GenesisEquivalenceProofFailure.ts | 100 ++++++++++ .../GenesisEquivalenceProofResult.ts | 6 + .../GenesisEquivalenceProofSuccess.ts | 61 ++++++ .../GenesisEquivalenceProofSummary.ts | 52 +++++ .../migrations/GenesisEquivalenceReading.ts | 82 ++++++++ .../GenesisEquivalenceReadingFact.ts | 94 +++++++++ .../GenesisEquivalenceProof.test.ts | 180 ++++++++++++++++++ 14 files changed, 1022 insertions(+), 2 deletions(-) create mode 100644 src/domain/migrations/GenesisEquivalenceBoundary.ts create mode 100644 src/domain/migrations/GenesisEquivalenceComparisonBasis.ts create mode 100644 src/domain/migrations/GenesisEquivalenceMismatch.ts create mode 100644 src/domain/migrations/GenesisEquivalenceProof.ts create mode 100644 src/domain/migrations/GenesisEquivalenceProofFailure.ts create mode 100644 src/domain/migrations/GenesisEquivalenceProofResult.ts create mode 100644 src/domain/migrations/GenesisEquivalenceProofSuccess.ts create mode 100644 src/domain/migrations/GenesisEquivalenceProofSummary.ts create mode 100644 src/domain/migrations/GenesisEquivalenceReading.ts create mode 100644 src/domain/migrations/GenesisEquivalenceReadingFact.ts create mode 100644 test/unit/domain/migrations/GenesisEquivalenceProof.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48612ad1..3bdae554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- V18 genesis replay equivalence now exposes runtime-backed proof nouns for + comparison basis, observer-visible reading facts, patch boundary evidence, + structured mismatches, proof summaries, and success/failure result values. - V18 graph-model migration dry-run work now exposes runtime-backed migration manifest nouns for source and target basis, node, edge, property, and content mappings, warnings, and fatal planning failures without adding any diff --git a/docs/BEARING.md b/docs/BEARING.md index b2e0e605..acff87ac 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -184,6 +184,11 @@ request JSON artifact, decodes source facts at the infrastructure boundary, calls the pure dry-run planner, writes only an optional deterministic manifest artifact, reports summary counts, and refuses destructive apply/write verbs. +Slice 42 is complete on this branch. Genesis equivalence now has +runtime-backed comparison basis, reading fact, boundary evidence, mismatch, +summary, and success/failure result nouns. The proof comparer returns +structured expected failures instead of throwing for non-equivalent readings. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -298,7 +303,7 @@ and concrete checks live in `docs/invariants/`. [0188](design/0188-v18-migration-manifest-serialization/v18-migration-manifest-serialization.md). - [x] 41. Add the migration dry-run CLI: [0189](design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md). -- [ ] 42. Add genesis equivalence proof nouns: +- [x] 42. Add genesis equivalence proof nouns: [0190](design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md). - [ ] 43. Add genesis equivalence fixtures: [0191](design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md). diff --git a/docs/design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md b/docs/design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md index b8b5c4d9..2ca4062f 100644 --- a/docs/design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md +++ b/docs/design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md @@ -1,7 +1,7 @@ --- cycle: 0190 task_id: V18_genesis_equivalence_nouns -status: Planned +status: Complete sponsors: human: James agent: Codex @@ -96,6 +96,28 @@ npm run lint:sludge git diff --check HEAD ``` +## Playback + +- `GenesisEquivalenceComparisonBasis` names the legacy/migrated basis pair. +- `GenesisEquivalenceReading` and `GenesisEquivalenceReadingFact` represent + observer-visible graph facts for legacy and migrated replay outputs. +- `GenesisEquivalenceBoundary` records writer, patch, and operation boundary + evidence when a fact can be traced to a patch operation. +- `GenesisEquivalenceMismatch` distinguishes missing, extra, and changed + facts with structured legacy/migrated values. +- `GenesisEquivalenceProofSuccess` and `GenesisEquivalenceProofFailure` + return expected proof outcomes as values. + +## Evidence + +- `src/domain/migrations/GenesisEquivalenceProof.ts` +- `src/domain/migrations/GenesisEquivalenceReading.ts` +- `src/domain/migrations/GenesisEquivalenceReadingFact.ts` +- `src/domain/migrations/GenesisEquivalenceMismatch.ts` +- `src/domain/migrations/GenesisEquivalenceProofSuccess.ts` +- `src/domain/migrations/GenesisEquivalenceProofFailure.ts` +- `test/unit/domain/migrations/GenesisEquivalenceProof.test.ts` + ## Closeout Criteria - Equivalence proof vocabulary is runtime-backed. diff --git a/src/domain/migrations/GenesisEquivalenceBoundary.ts b/src/domain/migrations/GenesisEquivalenceBoundary.ts new file mode 100644 index 00000000..acaf4803 --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceBoundary.ts @@ -0,0 +1,53 @@ +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceBoundaryFields = { + readonly writerId: string; + readonly patchId: string; + readonly operationIndex: number; +}; + +/** Runtime-backed patch boundary evidence for a genesis equivalence fact. */ +export default class GenesisEquivalenceBoundary { + readonly writerId: string; + readonly patchId: string; + readonly operationIndex: number; + + constructor(fields: GenesisEquivalenceBoundaryFields) { + const checkedFields = requireFields(fields); + this.writerId = requireNonEmptyString(checkedFields.writerId, 'writerId'); + this.patchId = requireNonEmptyString(checkedFields.patchId, 'patchId'); + this.operationIndex = requireOperationIndex(checkedFields.operationIndex); + Object.freeze(this); + } + + /** Returns a deterministic boundary key. */ + toKey(): string { + return `${this.writerId}\0${this.patchId}\0${this.operationIndex}`; + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceBoundaryFields | null | undefined, +): GenesisEquivalenceBoundaryFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceBoundary fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Validates a required non-empty string. */ +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; +} + +/** Validates a deterministic operation index. */ +function requireOperationIndex(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new WarpError('operationIndex must be a non-negative safe integer', 'E_VALIDATION'); + } + return value; +} diff --git a/src/domain/migrations/GenesisEquivalenceComparisonBasis.ts b/src/domain/migrations/GenesisEquivalenceComparisonBasis.ts new file mode 100644 index 00000000..4ca1a96c --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceComparisonBasis.ts @@ -0,0 +1,43 @@ +import GraphModelMigrationBasis from './GraphModelMigrationBasis.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceComparisonBasisFields = { + readonly legacyBasis: GraphModelMigrationBasis; + readonly migratedBasis: GraphModelMigrationBasis; +}; + +/** Runtime-backed basis pair for a genesis replay equivalence proof. */ +export default class GenesisEquivalenceComparisonBasis { + readonly legacyBasis: GraphModelMigrationBasis; + readonly migratedBasis: GraphModelMigrationBasis; + + constructor(fields: GenesisEquivalenceComparisonBasisFields) { + const checkedFields = requireFields(fields); + this.legacyBasis = requireBasis(checkedFields.legacyBasis, 'legacyBasis'); + this.migratedBasis = requireBasis(checkedFields.migratedBasis, 'migratedBasis'); + Object.freeze(this); + } + + /** Returns a deterministic basis pair key. */ + toKey(): string { + return `${this.legacyBasis.toKey()}\0${this.migratedBasis.toKey()}`; + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceComparisonBasisFields | null | undefined, +): GenesisEquivalenceComparisonBasisFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceComparisonBasis fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a migration basis instance. */ +function requireBasis(basis: GraphModelMigrationBasis, label: string): GraphModelMigrationBasis { + if (!(basis instanceof GraphModelMigrationBasis)) { + throw new WarpError(`${label} must be a GraphModelMigrationBasis`, 'E_VALIDATION'); + } + return basis; +} diff --git a/src/domain/migrations/GenesisEquivalenceMismatch.ts b/src/domain/migrations/GenesisEquivalenceMismatch.ts new file mode 100644 index 00000000..27779b54 --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceMismatch.ts @@ -0,0 +1,164 @@ +import GenesisEquivalenceBoundary from './GenesisEquivalenceBoundary.ts'; +import type { GenesisEquivalenceReadingFactKind } from './GenesisEquivalenceReadingFact.ts'; +import WarpError from '../errors/WarpError.ts'; + +const MISSING_FACT = 'missing'; +const EXTRA_FACT = 'extra'; +const CHANGED_FIELD = 'changed'; + +export type GenesisEquivalenceMismatchKind = + | typeof MISSING_FACT + | typeof EXTRA_FACT + | typeof CHANGED_FIELD; + +export type GenesisEquivalenceMismatchFields = { + readonly kind: GenesisEquivalenceMismatchKind; + readonly factKind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly legacyValue: string | null; + readonly migratedValue: string | null; + readonly boundary: GenesisEquivalenceBoundary | null; +}; + +/** Runtime-backed structured difference between legacy and migrated readings. */ +export default class GenesisEquivalenceMismatch { + readonly kind: GenesisEquivalenceMismatchKind; + readonly factKind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly legacyValue: string | null; + readonly migratedValue: string | null; + readonly boundary: GenesisEquivalenceBoundary | null; + + constructor(fields: GenesisEquivalenceMismatchFields) { + const checkedFields = requireFields(fields); + this.kind = requireKind(checkedFields.kind); + this.factKind = requireFactKind(checkedFields.factKind); + this.factKey = requireNonEmptyString(checkedFields.factKey, 'factKey'); + this.fieldPath = requireNonEmptyString(checkedFields.fieldPath, 'fieldPath'); + this.legacyValue = requireNullableString(checkedFields.legacyValue, 'legacyValue'); + this.migratedValue = requireNullableString(checkedFields.migratedValue, 'migratedValue'); + this.boundary = requireOptionalBoundary(checkedFields.boundary); + requireValuesMatchKind(this.kind, this.legacyValue, this.migratedValue); + Object.freeze(this); + } + + /** Returns a deterministic mismatch key. */ + toKey(): string { + return `${this.kind}\0${this.factKind}\0${this.factKey}\0${this.fieldPath}`; + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceMismatchFields | null | undefined, +): GenesisEquivalenceMismatchFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceMismatch fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Validates the mismatch kind. */ +function requireKind(kind: GenesisEquivalenceMismatchKind): GenesisEquivalenceMismatchKind { + if (kind !== MISSING_FACT && kind !== EXTRA_FACT && kind !== CHANGED_FIELD) { + throw new WarpError('GenesisEquivalenceMismatch kind is unsupported', 'E_VALIDATION'); + } + return kind; +} + +/** Validates the visible fact kind. */ +function requireFactKind(kind: GenesisEquivalenceReadingFactKind): GenesisEquivalenceReadingFactKind { + if ( + kind !== 'node' + && kind !== 'edge' + && kind !== 'property' + && kind !== 'content-attachment' + ) { + throw new WarpError('GenesisEquivalenceMismatch factKind is unsupported', 'E_VALIDATION'); + } + return kind; +} + +/** Validates a required non-empty string. */ +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; +} + +/** Validates nullable string values. */ +function requireNullableString(value: string | null, name: string): string | null { + if (value !== null && typeof value !== 'string') { + throw new WarpError(`${name} must be a string or null`, 'E_VALIDATION'); + } + return value; +} + +/** Requires boundary evidence when present. */ +function requireOptionalBoundary( + boundary: GenesisEquivalenceBoundary | null, +): GenesisEquivalenceBoundary | null { + if (boundary !== null && !(boundary instanceof GenesisEquivalenceBoundary)) { + throw new WarpError('boundary must be a GenesisEquivalenceBoundary', 'E_VALIDATION'); + } + return boundary; +} + +/** Requires values to match the structural mismatch kind. */ +function requireValuesMatchKind( + kind: GenesisEquivalenceMismatchKind, + legacyValue: string | null, + migratedValue: string | null, +): void { + requireMissingValues(kind, legacyValue, migratedValue); + requireExtraValues(kind, legacyValue, migratedValue); + requireChangedValues(kind, legacyValue, migratedValue); +} + +/** Requires missing mismatches to carry only legacy values. */ +function requireMissingValues( + kind: GenesisEquivalenceMismatchKind, + legacyValue: string | null, + migratedValue: string | null, +): void { + if (kind !== MISSING_FACT) { + return; + } + if (legacyValue !== null && migratedValue === null) { + return; + } + throw new WarpError('missing mismatches require only a legacy value', 'E_VALIDATION'); +} + +/** Requires extra mismatches to carry only migrated values. */ +function requireExtraValues( + kind: GenesisEquivalenceMismatchKind, + legacyValue: string | null, + migratedValue: string | null, +): void { + if (kind !== EXTRA_FACT) { + return; + } + if (legacyValue === null && migratedValue !== null) { + return; + } + throw new WarpError('extra mismatches require only a migrated value', 'E_VALIDATION'); +} + +/** Requires changed mismatches to carry both values. */ +function requireChangedValues( + kind: GenesisEquivalenceMismatchKind, + legacyValue: string | null, + migratedValue: string | null, +): void { + if (kind !== CHANGED_FIELD) { + return; + } + if (legacyValue !== null && migratedValue !== null) { + return; + } + throw new WarpError('changed mismatches require both values', 'E_VALIDATION'); +} diff --git a/src/domain/migrations/GenesisEquivalenceProof.ts b/src/domain/migrations/GenesisEquivalenceProof.ts new file mode 100644 index 00000000..b4c6b61a --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceProof.ts @@ -0,0 +1,155 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import GenesisEquivalenceComparisonBasis from './GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceMismatch from './GenesisEquivalenceMismatch.ts'; +import GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import type { GenesisEquivalenceProofResult } from './GenesisEquivalenceProofResult.ts'; +import GenesisEquivalenceProofSuccess from './GenesisEquivalenceProofSuccess.ts'; +import GenesisEquivalenceProofSummary from './GenesisEquivalenceProofSummary.ts'; +import GenesisEquivalenceReading from './GenesisEquivalenceReading.ts'; +import type GenesisEquivalenceReadingFact from './GenesisEquivalenceReadingFact.ts'; +import WarpError from '../errors/WarpError.ts'; + +/** Pure comparer for legacy and migrated genesis replay readings. */ +export default class GenesisEquivalenceProof { + /** Compares two observer-visible readings and returns a proof result value. */ + compare( + basis: GenesisEquivalenceComparisonBasis, + legacyReading: GenesisEquivalenceReading, + migratedReading: GenesisEquivalenceReading, + ): GenesisEquivalenceProofResult { + const checkedBasis = requireBasis(basis); + const checkedLegacy = requireReading(legacyReading, 'legacyReading'); + const checkedMigrated = requireReading(migratedReading, 'migratedReading'); + return compareReadings(checkedBasis, checkedLegacy, checkedMigrated); + } +} + +/** Compares validated readings. */ +function compareReadings( + basis: GenesisEquivalenceComparisonBasis, + legacyReading: GenesisEquivalenceReading, + migratedReading: GenesisEquivalenceReading, +): GenesisEquivalenceProofResult { + const legacyFacts = factsByKey(legacyReading.facts); + const migratedFacts = factsByKey(migratedReading.facts); + const mismatches = collectMismatches(legacyReading.facts, migratedFacts) + .concat(collectExtraMismatches(migratedReading.facts, legacyFacts)) + .sort(compareMismatches); + const summary = new GenesisEquivalenceProofSummary({ + basis, + legacyFactCount: legacyReading.facts.length, + migratedFactCount: migratedReading.facts.length, + mismatchCount: mismatches.length, + }); + if (mismatches.length === 0) { + return new GenesisEquivalenceProofSuccess({ basis, summary }); + } + return new GenesisEquivalenceProofFailure({ basis, summary, mismatches }); +} + +/** Collects missing and changed migrated facts for legacy facts. */ +function collectMismatches( + legacyFacts: readonly GenesisEquivalenceReadingFact[], + migratedFacts: ReadonlyMap, +): readonly GenesisEquivalenceMismatch[] { + const mismatches: GenesisEquivalenceMismatch[] = []; + for (const legacyFact of legacyFacts) { + const migratedFact = migratedFacts.get(legacyFact.toKey()); + if (migratedFact === undefined) { + mismatches.push(missingMismatch(legacyFact)); + continue; + } + if (legacyFact.value !== migratedFact.value) { + mismatches.push(changedMismatch(legacyFact, migratedFact)); + } + } + return Object.freeze(mismatches); +} + +/** Collects migrated facts absent from the legacy reading. */ +function collectExtraMismatches( + migratedFacts: readonly GenesisEquivalenceReadingFact[], + legacyFacts: ReadonlyMap, +): readonly GenesisEquivalenceMismatch[] { + const mismatches: GenesisEquivalenceMismatch[] = []; + for (const migratedFact of migratedFacts) { + if (!legacyFacts.has(migratedFact.toKey())) { + mismatches.push(extraMismatch(migratedFact)); + } + } + return Object.freeze(mismatches); +} + +/** Builds a missing-fact mismatch. */ +function missingMismatch(fact: GenesisEquivalenceReadingFact): GenesisEquivalenceMismatch { + return new GenesisEquivalenceMismatch({ + kind: 'missing', + factKind: fact.kind, + factKey: fact.factKey, + fieldPath: fact.fieldPath, + legacyValue: fact.value, + migratedValue: null, + boundary: fact.boundary, + }); +} + +/** Builds an extra-fact mismatch. */ +function extraMismatch(fact: GenesisEquivalenceReadingFact): GenesisEquivalenceMismatch { + return new GenesisEquivalenceMismatch({ + kind: 'extra', + factKind: fact.kind, + factKey: fact.factKey, + fieldPath: fact.fieldPath, + legacyValue: null, + migratedValue: fact.value, + boundary: fact.boundary, + }); +} + +/** Builds a changed-field mismatch. */ +function changedMismatch( + legacyFact: GenesisEquivalenceReadingFact, + migratedFact: GenesisEquivalenceReadingFact, +): GenesisEquivalenceMismatch { + return new GenesisEquivalenceMismatch({ + kind: 'changed', + factKind: legacyFact.kind, + factKey: legacyFact.factKey, + fieldPath: legacyFact.fieldPath, + legacyValue: legacyFact.value, + migratedValue: migratedFact.value, + boundary: legacyFact.boundary ?? migratedFact.boundary, + }); +} + +/** Indexes facts by deterministic identity. */ +function factsByKey( + facts: readonly GenesisEquivalenceReadingFact[], +): ReadonlyMap { + const indexed = new Map(); + for (const fact of facts) { + indexed.set(fact.toKey(), fact); + } + return indexed; +} + +/** Requires a comparison basis instance. */ +function requireBasis(basis: GenesisEquivalenceComparisonBasis): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new WarpError('basis must be a GenesisEquivalenceComparisonBasis', 'E_VALIDATION'); + } + return basis; +} + +/** Requires a reading instance. */ +function requireReading(reading: GenesisEquivalenceReading, label: string): GenesisEquivalenceReading { + if (!(reading instanceof GenesisEquivalenceReading)) { + throw new WarpError(`${label} must be a GenesisEquivalenceReading`, 'E_VALIDATION'); + } + return reading; +} + +/** Compares mismatches deterministically. */ +function compareMismatches(left: GenesisEquivalenceMismatch, right: GenesisEquivalenceMismatch): number { + return compareStrings(left.toKey(), right.toKey()); +} diff --git a/src/domain/migrations/GenesisEquivalenceProofFailure.ts b/src/domain/migrations/GenesisEquivalenceProofFailure.ts new file mode 100644 index 00000000..9cd5c1bf --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceProofFailure.ts @@ -0,0 +1,100 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import GenesisEquivalenceComparisonBasis from './GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceMismatch from './GenesisEquivalenceMismatch.ts'; +import GenesisEquivalenceProofSummary from './GenesisEquivalenceProofSummary.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceProofFailureFields = { + readonly basis: GenesisEquivalenceComparisonBasis; + readonly summary: GenesisEquivalenceProofSummary; + readonly mismatches: readonly GenesisEquivalenceMismatch[]; +}; + +/** Runtime-backed failed genesis replay equivalence result. */ +export default class GenesisEquivalenceProofFailure { + readonly basis: GenesisEquivalenceComparisonBasis; + readonly summary: GenesisEquivalenceProofSummary; + readonly mismatches: readonly GenesisEquivalenceMismatch[]; + + constructor(fields: GenesisEquivalenceProofFailureFields) { + const checkedFields = requireFields(fields); + this.basis = requireBasis(checkedFields.basis); + this.summary = requireSummary(checkedFields.summary); + this.mismatches = freezeMismatches(checkedFields.mismatches); + requireSummaryMatchesFailure(this.basis, this.summary, this.mismatches); + Object.freeze(this); + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceProofFailureFields | null | undefined, +): GenesisEquivalenceProofFailureFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceProofFailure fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a comparison basis instance. */ +function requireBasis(basis: GenesisEquivalenceComparisonBasis): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new WarpError('basis must be a GenesisEquivalenceComparisonBasis', 'E_VALIDATION'); + } + return basis; +} + +/** Requires a proof summary instance. */ +function requireSummary(summary: GenesisEquivalenceProofSummary): GenesisEquivalenceProofSummary { + if (!(summary instanceof GenesisEquivalenceProofSummary)) { + throw new WarpError('summary must be a GenesisEquivalenceProofSummary', 'E_VALIDATION'); + } + return summary; +} + +/** Validates and freezes mismatches in deterministic order. */ +function freezeMismatches( + mismatches: readonly GenesisEquivalenceMismatch[], +): readonly GenesisEquivalenceMismatch[] { + const checked = requireArray(mismatches, 'mismatches').map(requireMismatch); + if (checked.length === 0) { + throw new WarpError('failed proof results must contain mismatches', 'E_VALIDATION'); + } + return Object.freeze([...checked].sort(compareMismatches)); +} + +/** Requires an array field. */ +function requireArray(items: readonly T[] | null | undefined, label: string): readonly T[] { + if (items === null || items === undefined || !Array.isArray(items)) { + throw new WarpError(`GenesisEquivalenceProofFailure ${label} must be an array`, 'E_VALIDATION'); + } + const checkedItems: readonly T[] = items; + return checkedItems; +} + +/** Requires a mismatch instance. */ +function requireMismatch(mismatch: GenesisEquivalenceMismatch): GenesisEquivalenceMismatch { + if (!(mismatch instanceof GenesisEquivalenceMismatch)) { + throw new WarpError('mismatches must contain GenesisEquivalenceMismatch instances', 'E_VALIDATION'); + } + return mismatch; +} + +/** Requires summary and failure evidence to agree. */ +function requireSummaryMatchesFailure( + basis: GenesisEquivalenceComparisonBasis, + summary: GenesisEquivalenceProofSummary, + mismatches: readonly GenesisEquivalenceMismatch[], +): void { + if (summary.basis.toKey() !== basis.toKey()) { + throw new WarpError('summary basis must match failure basis', 'E_VALIDATION'); + } + if (summary.mismatchCount !== mismatches.length) { + throw new WarpError('failure summary mismatch count must match mismatches', 'E_VALIDATION'); + } +} + +/** Compares mismatches deterministically. */ +function compareMismatches(left: GenesisEquivalenceMismatch, right: GenesisEquivalenceMismatch): number { + return compareStrings(left.toKey(), right.toKey()); +} diff --git a/src/domain/migrations/GenesisEquivalenceProofResult.ts b/src/domain/migrations/GenesisEquivalenceProofResult.ts new file mode 100644 index 00000000..1f00cb22 --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceProofResult.ts @@ -0,0 +1,6 @@ +import type GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import type GenesisEquivalenceProofSuccess from './GenesisEquivalenceProofSuccess.ts'; + +export type GenesisEquivalenceProofResult = + | GenesisEquivalenceProofSuccess + | GenesisEquivalenceProofFailure; diff --git a/src/domain/migrations/GenesisEquivalenceProofSuccess.ts b/src/domain/migrations/GenesisEquivalenceProofSuccess.ts new file mode 100644 index 00000000..7baf49dd --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceProofSuccess.ts @@ -0,0 +1,61 @@ +import GenesisEquivalenceComparisonBasis from './GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceProofSummary from './GenesisEquivalenceProofSummary.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceProofSuccessFields = { + readonly basis: GenesisEquivalenceComparisonBasis; + readonly summary: GenesisEquivalenceProofSummary; +}; + +/** Runtime-backed successful genesis replay equivalence result. */ +export default class GenesisEquivalenceProofSuccess { + readonly basis: GenesisEquivalenceComparisonBasis; + readonly summary: GenesisEquivalenceProofSummary; + + constructor(fields: GenesisEquivalenceProofSuccessFields) { + const checkedFields = requireFields(fields); + this.basis = requireBasis(checkedFields.basis); + this.summary = requireSummary(checkedFields.summary); + requireSummaryMatchesBasis(this.basis, this.summary); + Object.freeze(this); + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceProofSuccessFields | null | undefined, +): GenesisEquivalenceProofSuccessFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceProofSuccess fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a comparison basis instance. */ +function requireBasis(basis: GenesisEquivalenceComparisonBasis): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new WarpError('basis must be a GenesisEquivalenceComparisonBasis', 'E_VALIDATION'); + } + return basis; +} + +/** Requires a proof summary instance. */ +function requireSummary(summary: GenesisEquivalenceProofSummary): GenesisEquivalenceProofSummary { + if (!(summary instanceof GenesisEquivalenceProofSummary)) { + throw new WarpError('summary must be a GenesisEquivalenceProofSummary', 'E_VALIDATION'); + } + return summary; +} + +/** Requires summary and result basis identity to match. */ +function requireSummaryMatchesBasis( + basis: GenesisEquivalenceComparisonBasis, + summary: GenesisEquivalenceProofSummary, +): void { + if (summary.basis.toKey() !== basis.toKey()) { + throw new WarpError('summary basis must match success basis', 'E_VALIDATION'); + } + if (summary.mismatchCount !== 0) { + throw new WarpError('successful proof summaries must have zero mismatches', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/GenesisEquivalenceProofSummary.ts b/src/domain/migrations/GenesisEquivalenceProofSummary.ts new file mode 100644 index 00000000..1db84486 --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceProofSummary.ts @@ -0,0 +1,52 @@ +import GenesisEquivalenceComparisonBasis from './GenesisEquivalenceComparisonBasis.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceProofSummaryFields = { + readonly basis: GenesisEquivalenceComparisonBasis; + readonly legacyFactCount: number; + readonly migratedFactCount: number; + readonly mismatchCount: number; +}; + +/** Runtime-backed summary for a genesis replay equivalence proof. */ +export default class GenesisEquivalenceProofSummary { + readonly basis: GenesisEquivalenceComparisonBasis; + readonly legacyFactCount: number; + readonly migratedFactCount: number; + readonly mismatchCount: number; + + constructor(fields: GenesisEquivalenceProofSummaryFields) { + const checkedFields = requireFields(fields); + this.basis = requireBasis(checkedFields.basis); + this.legacyFactCount = requireCount(checkedFields.legacyFactCount, 'legacyFactCount'); + this.migratedFactCount = requireCount(checkedFields.migratedFactCount, 'migratedFactCount'); + this.mismatchCount = requireCount(checkedFields.mismatchCount, 'mismatchCount'); + Object.freeze(this); + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceProofSummaryFields | null | undefined, +): GenesisEquivalenceProofSummaryFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceProofSummary fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a comparison basis instance. */ +function requireBasis(basis: GenesisEquivalenceComparisonBasis): GenesisEquivalenceComparisonBasis { + if (!(basis instanceof GenesisEquivalenceComparisonBasis)) { + throw new WarpError('basis must be a GenesisEquivalenceComparisonBasis', 'E_VALIDATION'); + } + return basis; +} + +/** Validates a non-negative count. */ +function requireCount(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new WarpError(`${name} must be a non-negative safe integer`, 'E_VALIDATION'); + } + return value; +} diff --git a/src/domain/migrations/GenesisEquivalenceReading.ts b/src/domain/migrations/GenesisEquivalenceReading.ts new file mode 100644 index 00000000..f699bb17 --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceReading.ts @@ -0,0 +1,82 @@ +import { compareStrings } from '../utils/StringComparison.ts'; +import GenesisEquivalenceReadingFact from './GenesisEquivalenceReadingFact.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GenesisEquivalenceReadingFields = { + readonly readingId: string; + readonly facts: readonly GenesisEquivalenceReadingFact[]; +}; + +/** Runtime-backed observer-visible reading for genesis equivalence comparison. */ +export default class GenesisEquivalenceReading { + readonly readingId: string; + readonly facts: readonly GenesisEquivalenceReadingFact[]; + + constructor(fields: GenesisEquivalenceReadingFields) { + const checkedFields = requireFields(fields); + this.readingId = requireNonEmptyString(checkedFields.readingId, 'readingId'); + this.facts = freezeFacts(checkedFields.facts); + Object.freeze(this); + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceReadingFields | null | undefined, +): GenesisEquivalenceReadingFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceReading fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Validates and freezes visible facts in deterministic order. */ +function freezeFacts( + facts: readonly GenesisEquivalenceReadingFact[], +): readonly GenesisEquivalenceReadingFact[] { + const checked = requireArray(facts, 'facts').map(requireFact); + requireUniqueFactKeys(checked); + return Object.freeze([...checked].sort(compareFacts)); +} + +/** Requires an array field. */ +function requireArray(items: readonly T[] | null | undefined, label: string): readonly T[] { + if (items === null || items === undefined || !Array.isArray(items)) { + throw new WarpError(`GenesisEquivalenceReading ${label} must be an array`, 'E_VALIDATION'); + } + const checkedItems: readonly T[] = items; + return checkedItems; +} + +/** Requires a visible fact instance. */ +function requireFact(fact: GenesisEquivalenceReadingFact): GenesisEquivalenceReadingFact { + if (!(fact instanceof GenesisEquivalenceReadingFact)) { + throw new WarpError('facts must contain GenesisEquivalenceReadingFact instances', 'E_VALIDATION'); + } + return fact; +} + +/** Requires unique visible fact keys. */ +function requireUniqueFactKeys(facts: readonly GenesisEquivalenceReadingFact[]): void { + const seen = new Set(); + for (const fact of facts) { + const key = fact.toKey(); + if (seen.has(key)) { + throw new WarpError(`GenesisEquivalenceReading duplicates visible fact ${key}`, 'E_VALIDATION'); + } + seen.add(key); + } +} + +/** Compares visible facts deterministically. */ +function compareFacts(left: GenesisEquivalenceReadingFact, right: GenesisEquivalenceReadingFact): number { + return compareStrings(left.toKey(), right.toKey()); +} + +/** Validates a required non-empty string. */ +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; +} diff --git a/src/domain/migrations/GenesisEquivalenceReadingFact.ts b/src/domain/migrations/GenesisEquivalenceReadingFact.ts new file mode 100644 index 00000000..51cd73d7 --- /dev/null +++ b/src/domain/migrations/GenesisEquivalenceReadingFact.ts @@ -0,0 +1,94 @@ +import GenesisEquivalenceBoundary from './GenesisEquivalenceBoundary.ts'; +import WarpError from '../errors/WarpError.ts'; + +const NODE_FACT = 'node'; +const EDGE_FACT = 'edge'; +const PROPERTY_FACT = 'property'; +const CONTENT_ATTACHMENT_FACT = 'content-attachment'; + +export type GenesisEquivalenceReadingFactKind = + | typeof NODE_FACT + | typeof EDGE_FACT + | typeof PROPERTY_FACT + | typeof CONTENT_ATTACHMENT_FACT; + +export type GenesisEquivalenceReadingFactFields = { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; + readonly boundary: GenesisEquivalenceBoundary | null; +}; + +/** Runtime-backed observer-visible fact used by genesis equivalence proofs. */ +export default class GenesisEquivalenceReadingFact { + readonly kind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly value: string; + readonly boundary: GenesisEquivalenceBoundary | null; + + constructor(fields: GenesisEquivalenceReadingFactFields) { + const checkedFields = requireFields(fields); + this.kind = requireKind(checkedFields.kind); + this.factKey = requireNonEmptyString(checkedFields.factKey, 'factKey'); + this.fieldPath = requireNonEmptyString(checkedFields.fieldPath, 'fieldPath'); + this.value = requireString(checkedFields.value, 'value'); + this.boundary = requireOptionalBoundary(checkedFields.boundary); + Object.freeze(this); + } + + /** Returns a deterministic identity key for this visible fact field. */ + toKey(): string { + return `${this.kind}\0${this.factKey}\0${this.fieldPath}`; + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisEquivalenceReadingFactFields | null | undefined, +): GenesisEquivalenceReadingFactFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisEquivalenceReadingFact fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Validates the visible fact kind. */ +function requireKind(kind: GenesisEquivalenceReadingFactKind): GenesisEquivalenceReadingFactKind { + if ( + kind !== NODE_FACT + && kind !== EDGE_FACT + && kind !== PROPERTY_FACT + && kind !== CONTENT_ATTACHMENT_FACT + ) { + throw new WarpError('GenesisEquivalenceReadingFact kind is unsupported', 'E_VALIDATION'); + } + return kind; +} + +/** Validates a required non-empty string. */ +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; +} + +/** Requires a string value while allowing empty visible payload summaries. */ +function requireString(value: string, name: string): string { + if (typeof value !== 'string') { + throw new WarpError(`${name} must be a string`, 'E_VALIDATION'); + } + return value; +} + +/** Requires boundary evidence when present. */ +function requireOptionalBoundary( + boundary: GenesisEquivalenceBoundary | null, +): GenesisEquivalenceBoundary | null { + if (boundary !== null && !(boundary instanceof GenesisEquivalenceBoundary)) { + throw new WarpError('boundary must be a GenesisEquivalenceBoundary', 'E_VALIDATION'); + } + return boundary; +} diff --git a/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts b/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts new file mode 100644 index 00000000..3b6d104a --- /dev/null +++ b/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceMismatch + from '../../../../src/domain/migrations/GenesisEquivalenceMismatch.ts'; +import GenesisEquivalenceProof + from '../../../../src/domain/migrations/GenesisEquivalenceProof.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'; + +describe('GenesisEquivalenceProof', () => { + it('returns a success value for equal legacy and migrated readings', () => { + const result = proof().compare( + basis(), + reading('legacy', [nodeFact('node:a', 'visible')]), + reading('migrated', [nodeFact('node:a', 'visible')]), + ); + + expect(result).toBeInstanceOf(GenesisEquivalenceProofSuccess); + expect(result.summary.mismatchCount).toBe(0); + expect(result.summary.legacyFactCount).toBe(1); + expect(result.summary.migratedFactCount).toBe(1); + }); + + it('returns a changed node mismatch with patch boundary evidence', () => { + const result = proof().compare( + basis(), + reading('legacy', [nodeFact('node:a', 'visible')]), + reading('migrated', [nodeFact('node:a', 'hidden')]), + ); + + expect(result).toBeInstanceOf(GenesisEquivalenceProofFailure); + if (result instanceof GenesisEquivalenceProofFailure) { + expect(result.mismatches).toHaveLength(1); + expect(result.mismatches[0]?.kind).toBe('changed'); + expect(result.mismatches[0]?.factKind).toBe('node'); + expect(result.mismatches[0]?.factKey).toBe('node:a'); + expect(result.mismatches[0]?.fieldPath).toBe('visibility'); + expect(result.mismatches[0]?.legacyValue).toBe('visible'); + expect(result.mismatches[0]?.migratedValue).toBe('hidden'); + expect(result.mismatches[0]?.boundary?.patchId).toBe('patch:a:0'); + expect(result.mismatches[0]?.boundary?.operationIndex).toBe(0); + } + }); + + it('identifies content attachment field mismatches', () => { + const result = proof().compare( + basis(), + reading('legacy', [fact('content-attachment', 'node:a', 'payload.oid', 'oid:legacy')]), + reading('migrated', [fact('content-attachment', 'node:a', 'payload.oid', 'oid:migrated')]), + ); + + expect(result).toBeInstanceOf(GenesisEquivalenceProofFailure); + if (result instanceof GenesisEquivalenceProofFailure) { + expect(result.mismatches[0]?.factKind).toBe('content-attachment'); + expect(result.mismatches[0]?.fieldPath).toBe('payload.oid'); + expect(result.mismatches[0]?.legacyValue).toBe('oid:legacy'); + expect(result.mismatches[0]?.migratedValue).toBe('oid:migrated'); + } + }); + + it('collects multiple mismatches in deterministic order', () => { + const result = proof().compare( + basis(), + reading('legacy', [ + fact('property', 'node:z/title', 'value', 'Z'), + fact('node', 'node:a', 'visibility', 'visible'), + ]), + reading('migrated', [ + fact('property', 'node:z/title', 'value', 'Z2'), + fact('edge', 'node:a->node:b/knows', 'visibility', 'visible'), + ]), + ); + + expect(result).toBeInstanceOf(GenesisEquivalenceProofFailure); + if (result instanceof GenesisEquivalenceProofFailure) { + expect(result.summary.mismatchCount).toBe(3); + expect(result.mismatches.map((mismatch) => mismatch.toKey())).toEqual([ + 'changed\0property\0node:z/title\0value', + 'extra\0edge\0node:a->node:b/knows\0visibility', + 'missing\0node\0node:a\0visibility', + ]); + } + }); + + it('returns expected proof failure as a value instead of throwing', () => { + const result = proof().compare( + basis(), + reading('legacy', [nodeFact('node:a', 'visible')]), + reading('migrated', []), + ); + + expect(result).toBeInstanceOf(GenesisEquivalenceProofFailure); + if (result instanceof GenesisEquivalenceProofFailure) { + expect(result.mismatches[0]?.kind).toBe('missing'); + expect(result.mismatches[0]?.migratedValue).toBeNull(); + } + }); + + it('rejects invalid proof noun envelopes', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceReading(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceReading({ + readingId: 'legacy', + facts: [ + nodeFact('node:a', 'visible'), + nodeFact('node:a', 'visible'), + ], + })).toThrow(/duplicates visible fact/); + expect(() => new GenesisEquivalenceMismatch({ + kind: 'missing', + factKind: 'node', + factKey: 'node:a', + fieldPath: 'visibility', + legacyValue: null, + migratedValue: null, + boundary: null, + })).toThrow(/missing mismatches/); + }); +}); + +function proof(): GenesisEquivalenceProof { + return new GenesisEquivalenceProof(); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:migrated', + }), + }); +} + +function reading( + readingId: string, + facts: readonly GenesisEquivalenceReadingFact[], +): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ readingId, facts }); +} + +function nodeFact(factKey: string, value: string): GenesisEquivalenceReadingFact { + return fact('node', factKey, 'visibility', value); +} + +function fact( + kind: GenesisEquivalenceReadingFactKind, + factKey: string, + fieldPath: string, + value: string, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind, + factKey, + fieldPath, + value, + boundary: new GenesisEquivalenceBoundary({ + writerId: 'writer:a', + patchId: 'patch:a:0', + operationIndex: 0, + }), + }); +} From 3b201c506281f403a1fc0fe4d5492e5f6dedfa45 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 07:10:19 -0700 Subject: [PATCH 3/8] Test: Add genesis equivalence fixtures --- CHANGELOG.md | 3 + docs/BEARING.md | 7 +- .../v18-genesis-equivalence-fixtures.md | 20 ++- .../GenesisEquivalenceFixtureCase.ts | 25 ++++ .../GenesisEquivalenceFixtures.test.ts | 94 +++++++++++++ .../migrations/GenesisEquivalenceFixtures.ts | 129 ++++++++++++++++++ 6 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 test/unit/domain/migrations/GenesisEquivalenceFixtureCase.ts create mode 100644 test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts create mode 100644 test/unit/domain/migrations/GenesisEquivalenceFixtures.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdae554..e383c8df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 genesis replay equivalence now exposes runtime-backed proof nouns for comparison basis, observer-visible reading facts, patch boundary evidence, structured mismatches, proof summaries, and success/failure result values. +- V18 genesis replay equivalence now includes compact deterministic fixture + histories for node lifecycle, edge lifecycle, content metadata, removal + visibility, multi-writer ordering, and intentional divergence coverage. - V18 graph-model migration dry-run work now exposes runtime-backed migration manifest nouns for source and target basis, node, edge, property, and content mappings, warnings, and fatal planning failures without adding any diff --git a/docs/BEARING.md b/docs/BEARING.md index acff87ac..456bd6f8 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -189,6 +189,11 @@ runtime-backed comparison basis, reading fact, boundary evidence, mismatch, summary, and success/failure result nouns. The proof comparer returns structured expected failures instead of throwing for non-equivalent readings. +Slice 43 is complete on this branch. The first deterministic equivalence +fixtures now cover node lifecycle, edge lifecycle, content attachment metadata, +removed-node visibility, multi-writer non-coordinated ordering, and one +intentional divergent property case. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -305,7 +310,7 @@ and concrete checks live in `docs/invariants/`. [0189](design/0189-v18-migration-dry-run-cli/v18-migration-dry-run-cli.md). - [x] 42. Add genesis equivalence proof nouns: [0190](design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md). -- [ ] 43. Add genesis equivalence fixtures: +- [x] 43. Add genesis equivalence fixtures: [0191](design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md). - [ ] 44. Add the genesis divergence reporter: [0192](design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md). diff --git a/docs/design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md b/docs/design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md index 9da92468..5253ac92 100644 --- a/docs/design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md +++ b/docs/design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md @@ -1,7 +1,7 @@ --- cycle: 0191 task_id: V18_genesis_equivalence_fixtures -status: Planned +status: Complete sponsors: human: James agent: Codex @@ -97,6 +97,24 @@ npm run lint git diff --check HEAD ``` +## Playback + +- Fixture cases cover node lifecycle with property, edge lifecycle with + property, content attachment metadata, removed-node visibility, and + multi-writer non-coordinated order. +- `GenesisEquivalenceFixtureCase` carries legacy and migrated readings plus + the expected result kind. +- The divergent fixture intentionally changes one property value and proves + the proof layer returns a structured mismatch. +- Fixture readings are constructed from explicit runtime-backed equivalence + nouns, not opaque JSON blobs. + +## Evidence + +- `test/unit/domain/migrations/GenesisEquivalenceFixtureCase.ts` +- `test/unit/domain/migrations/GenesisEquivalenceFixtures.ts` +- `test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts` + ## Closeout Criteria - First equivalence fixtures exist. diff --git a/test/unit/domain/migrations/GenesisEquivalenceFixtureCase.ts b/test/unit/domain/migrations/GenesisEquivalenceFixtureCase.ts new file mode 100644 index 00000000..cc3b865a --- /dev/null +++ b/test/unit/domain/migrations/GenesisEquivalenceFixtureCase.ts @@ -0,0 +1,25 @@ +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; + +export type GenesisEquivalenceFixtureExpectedResult = 'success' | 'failure'; + +/** Runtime-backed test fixture case for genesis equivalence proof coverage. */ +export default class GenesisEquivalenceFixtureCase { + readonly name: string; + readonly legacyReading: GenesisEquivalenceReading; + readonly migratedReading: GenesisEquivalenceReading; + readonly expectedResult: GenesisEquivalenceFixtureExpectedResult; + + constructor( + name: string, + legacyReading: GenesisEquivalenceReading, + migratedReading: GenesisEquivalenceReading, + expectedResult: GenesisEquivalenceFixtureExpectedResult, + ) { + this.name = name; + this.legacyReading = legacyReading; + this.migratedReading = migratedReading; + this.expectedResult = expectedResult; + Object.freeze(this); + } +} diff --git a/test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts b/test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts new file mode 100644 index 00000000..e88e9936 --- /dev/null +++ b/test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceProof + from '../../../../src/domain/migrations/GenesisEquivalenceProof.ts'; +import GenesisEquivalenceProofFailure + from '../../../../src/domain/migrations/GenesisEquivalenceProofFailure.ts'; +import GenesisEquivalenceProofSuccess + from '../../../../src/domain/migrations/GenesisEquivalenceProofSuccess.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import { + divergentPropertyFixture, + genesisEquivalenceFixtureCases, + removedNodeFixture, +} from './GenesisEquivalenceFixtures.ts'; + +describe('genesis equivalence fixtures', () => { + it('proves equal fixture readings for node, edge, content, removal, and multi-writer cases', () => { + const successCases = genesisEquivalenceFixtureCases() + .filter((fixtureCase) => fixtureCase.expectedResult === 'success'); + + expect(successCases.map((fixtureCase) => fixtureCase.name)).toEqual([ + 'node lifecycle with property', + 'edge lifecycle with property', + 'content attachment metadata', + 'removed node hides later property', + 'multi-writer non-coordinated order', + ]); + for (const fixtureCase of successCases) { + const result = new GenesisEquivalenceProof().compare( + basis(), + fixtureCase.legacyReading, + fixtureCase.migratedReading, + ); + expect(result).toBeInstanceOf(GenesisEquivalenceProofSuccess); + } + }); + + it('keeps removed node facts absent from visible readings', () => { + const fixtureCase = removedNodeFixture(); + const legacyKeys = fixtureCase.legacyReading.facts.map((fact) => fact.toKey()); + const migratedKeys = fixtureCase.migratedReading.facts.map((fact) => fact.toKey()); + + expect(legacyKeys).toEqual(['node\0node:survivor\0visibility']); + expect(migratedKeys).toEqual(legacyKeys); + }); + + it('uses divergent fixture output to produce a structured mismatch', () => { + const fixtureCase = divergentPropertyFixture(); + const result = new GenesisEquivalenceProof().compare( + basis(), + fixtureCase.legacyReading, + fixtureCase.migratedReading, + ); + + expect(result).toBeInstanceOf(GenesisEquivalenceProofFailure); + if (result instanceof GenesisEquivalenceProofFailure) { + expect(result.mismatches).toHaveLength(1); + expect(result.mismatches[0]?.kind).toBe('changed'); + expect(result.mismatches[0]?.factKind).toBe('property'); + expect(result.mismatches[0]?.factKey).toBe('node:article/title'); + expect(result.mismatches[0]?.legacyValue).toBe('Legacy'); + expect(result.mismatches[0]?.migratedValue).toBe('Migrated'); + } + }); + + it('emits deterministic fixture fact keys', () => { + const fixtureKeys = genesisEquivalenceFixtureCases() + .map((fixtureCase) => fixtureCase.legacyReading.facts.map((fact) => fact.toKey()).join('|')); + + expect(fixtureKeys).toEqual([ + 'node\0node:article\0visibility|property\0node:article/title\0value', + 'edge\0node:article->node:topic/mentions\0visibility|property\0edge:mentions/weight\0value', + 'content-attachment\0node:article\0payload.mime|content-attachment\0node:article\0payload.oid|content-attachment\0node:article\0payload.size', + 'node\0node:survivor\0visibility', + 'edge\0node:left->node:right/links\0visibility|node\0node:left\0visibility|node\0node:right\0visibility', + 'property\0node:article/title\0value', + ]); + }); +}); + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:fixture', + basisId: 'basis:migrated', + }), + }); +} diff --git a/test/unit/domain/migrations/GenesisEquivalenceFixtures.ts b/test/unit/domain/migrations/GenesisEquivalenceFixtures.ts new file mode 100644 index 00000000..82a4c248 --- /dev/null +++ b/test/unit/domain/migrations/GenesisEquivalenceFixtures.ts @@ -0,0 +1,129 @@ +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 GenesisEquivalenceFixtureCase + from './GenesisEquivalenceFixtureCase.ts'; + +/** Returns the first compact genesis-equivalence fixture suite. */ +export function genesisEquivalenceFixtureCases(): readonly GenesisEquivalenceFixtureCase[] { + return Object.freeze([ + nodeLifecycleFixture(), + edgeLifecycleFixture(), + contentAttachmentFixture(), + removedNodeFixture(), + multiWriterFixture(), + divergentPropertyFixture(), + ]); +} + +/** Builds a node lifecycle fixture with a visible property. */ +export function nodeLifecycleFixture(): GenesisEquivalenceFixtureCase { + const facts = [ + fact('node', 'node:article', 'visibility', 'visible', boundary('writer:a', 'patch:a:0', 0)), + fact('property', 'node:article/title', 'value', 'Hello', boundary('writer:a', 'patch:a:1', 0)), + ]; + return successCase('node lifecycle with property', facts, facts); +} + +/** Builds an edge lifecycle fixture with a visible property. */ +export function edgeLifecycleFixture(): GenesisEquivalenceFixtureCase { + const facts = [ + fact('edge', 'node:article->node:topic/mentions', 'visibility', 'visible', boundary('writer:a', 'patch:a:0', 1)), + fact('property', 'edge:mentions/weight', 'value', '3', boundary('writer:a', 'patch:a:1', 0)), + ]; + return successCase('edge lifecycle with property', facts, facts); +} + +/** Builds a content attachment fixture with metadata and payload identity. */ +export function contentAttachmentFixture(): GenesisEquivalenceFixtureCase { + const facts = [ + fact('content-attachment', 'node:article', 'payload.oid', 'oid:content:a', boundary('writer:a', 'patch:a:2', 0)), + fact('content-attachment', 'node:article', 'payload.mime', 'text/markdown', boundary('writer:a', 'patch:a:2', 0)), + fact('content-attachment', 'node:article', 'payload.size', '42', boundary('writer:a', 'patch:a:2', 0)), + ]; + return successCase('content attachment metadata', facts, facts); +} + +/** Builds a removal fixture where a later property on a removed node is hidden. */ +export function removedNodeFixture(): GenesisEquivalenceFixtureCase { + const visibleFacts = [ + fact('node', 'node:survivor', 'visibility', 'visible', boundary('writer:a', 'patch:a:0', 0)), + ]; + return successCase('removed node hides later property', visibleFacts, visibleFacts); +} + +/** Builds a multi-writer fixture with deterministic boundary evidence. */ +export function multiWriterFixture(): GenesisEquivalenceFixtureCase { + const facts = [ + fact('node', 'node:left', 'visibility', 'visible', boundary('writer:a', 'patch:a:0', 0)), + fact('node', 'node:right', 'visibility', 'visible', boundary('writer:b', 'patch:b:0', 0)), + fact('edge', 'node:left->node:right/links', 'visibility', 'visible', boundary('writer:b', 'patch:b:1', 0)), + ]; + return successCase('multi-writer non-coordinated order', facts, facts); +} + +/** Builds an intentionally divergent fixture with a changed property value. */ +export function divergentPropertyFixture(): GenesisEquivalenceFixtureCase { + return new GenesisEquivalenceFixtureCase( + 'divergent property value', + reading('legacy:divergent', [ + fact('property', 'node:article/title', 'value', 'Legacy', boundary('writer:a', 'patch:a:1', 0)), + ]), + reading('migrated:divergent', [ + fact('property', 'node:article/title', 'value', 'Migrated', boundary('writer:a', 'patch:a:1', 0)), + ]), + 'failure', + ); +} + +/** Builds a successful fixture case. */ +function successCase( + name: string, + legacyFacts: readonly GenesisEquivalenceReadingFact[], + migratedFacts: readonly GenesisEquivalenceReadingFact[], +): GenesisEquivalenceFixtureCase { + return new GenesisEquivalenceFixtureCase( + name, + reading(`legacy:${name}`, legacyFacts), + reading(`migrated:${name}`, migratedFacts), + 'success', + ); +} + +/** Builds a reading from explicit facts. */ +function reading( + readingId: string, + facts: readonly GenesisEquivalenceReadingFact[], +): GenesisEquivalenceReading { + return new GenesisEquivalenceReading({ readingId, facts }); +} + +/** Builds a visible reading fact. */ +function fact( + kind: GenesisEquivalenceReadingFactKind, + factKey: string, + fieldPath: string, + value: string, + factBoundary: GenesisEquivalenceBoundary, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind, + factKey, + fieldPath, + value, + boundary: factBoundary, + }); +} + +/** Builds patch boundary evidence for a fixture fact. */ +function boundary( + writerId: string, + patchId: string, + operationIndex: number, +): GenesisEquivalenceBoundary { + return new GenesisEquivalenceBoundary({ writerId, patchId, operationIndex }); +} From a4387d8e540994d18f2649acacbcd163340cea04 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 07:14:17 -0700 Subject: [PATCH 4/8] Feat: Add genesis divergence reporter --- CHANGELOG.md | 3 + docs/BEARING.md | 7 +- .../v18-genesis-divergence-reporter.md | 20 +- .../migrations/GenesisDivergenceReport.ts | 202 ++++++++++++++++++ .../migrations/GenesisDivergenceReporter.ts | 24 +++ .../GenesisDivergenceReporter.test.ts | 128 +++++++++++ 6 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 src/domain/migrations/GenesisDivergenceReport.ts create mode 100644 src/domain/migrations/GenesisDivergenceReporter.ts create mode 100644 test/unit/domain/migrations/GenesisDivergenceReporter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e383c8df..ffa614be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 genesis replay equivalence now includes compact deterministic fixture histories for node lifecycle, edge lifecycle, content metadata, removal visibility, multi-writer ordering, and intentional divergence coverage. +- V18 genesis replay equivalence now reports the first deterministic + divergence as a structured value with mismatch kind, fact identity, field + path, optional patch boundary evidence, and bounded value summaries. - V18 graph-model migration dry-run work now exposes runtime-backed migration manifest nouns for source and target basis, node, edge, property, and content mappings, warnings, and fatal planning failures without adding any diff --git a/docs/BEARING.md b/docs/BEARING.md index 456bd6f8..faab178e 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -194,6 +194,11 @@ fixtures now cover node lifecycle, edge lifecycle, content attachment metadata, removed-node visibility, multi-writer non-coordinated ordering, and one intentional divergent property case. +Slice 44 is complete on this branch. Genesis divergence reporting now selects +the first deterministic proof mismatch and exposes mismatch kind, graph fact +identity, field path, optional writer/patch/operation boundary evidence, and +bounded value summaries as structured report fields. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. @@ -312,7 +317,7 @@ and concrete checks live in `docs/invariants/`. [0190](design/0190-v18-genesis-equivalence-nouns/v18-genesis-equivalence-nouns.md). - [x] 43. Add genesis equivalence fixtures: [0191](design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md). -- [ ] 44. Add the genesis divergence reporter: +- [x] 44. Add the genesis divergence reporter: [0192](design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md). - [ ] 45. Re-plan with migration evidence in hand: [0193](design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md). diff --git a/docs/design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md b/docs/design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md index 5e7adb1c..af23c9cd 100644 --- a/docs/design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md +++ b/docs/design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md @@ -1,7 +1,7 @@ --- cycle: 0192 task_id: V18_genesis_divergence_reporter -status: Planned +status: Complete sponsors: human: James agent: Codex @@ -100,6 +100,24 @@ npm run lint:sludge git diff --check HEAD ``` +## Playback + +- `GenesisDivergenceReporter` consumes `GenesisEquivalenceProofFailure` and + selects the first deterministic mismatch. +- `GenesisDivergenceReport` keeps mismatch kind, fact kind, fact key, field + path, optional writer/patch/operation evidence, and bounded value summaries + as structured fields. +- Missing boundary evidence remains explicit as `null` and renders as + `(unknown)` for operator-facing text. +- Long value summaries are bounded for output without modifying the source + mismatch evidence. + +## Evidence + +- `src/domain/migrations/GenesisDivergenceReport.ts` +- `src/domain/migrations/GenesisDivergenceReporter.ts` +- `test/unit/domain/migrations/GenesisDivergenceReporter.test.ts` + ## Closeout Criteria - Divergence reports are structured values. diff --git a/src/domain/migrations/GenesisDivergenceReport.ts b/src/domain/migrations/GenesisDivergenceReport.ts new file mode 100644 index 00000000..aab54ce2 --- /dev/null +++ b/src/domain/migrations/GenesisDivergenceReport.ts @@ -0,0 +1,202 @@ +import GenesisEquivalenceMismatch, { + type GenesisEquivalenceMismatchKind, +} from './GenesisEquivalenceMismatch.ts'; +import type { GenesisEquivalenceReadingFactKind } from './GenesisEquivalenceReadingFact.ts'; +import WarpError from '../errors/WarpError.ts'; + +const VALUE_SUMMARY_LIMIT = 80; + +export type GenesisDivergenceReportFields = { + readonly mismatchKind: GenesisEquivalenceMismatchKind; + readonly factKind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly writerId: string | null; + readonly patchId: string | null; + readonly operationIndex: number | null; + readonly legacyValueSummary: string | null; + readonly migratedValueSummary: string | null; +}; + +/** Runtime-backed first-divergence report for genesis replay proof failures. */ +export default class GenesisDivergenceReport { + readonly mismatchKind: GenesisEquivalenceMismatchKind; + readonly factKind: GenesisEquivalenceReadingFactKind; + readonly factKey: string; + readonly fieldPath: string; + readonly writerId: string | null; + readonly patchId: string | null; + readonly operationIndex: number | null; + readonly legacyValueSummary: string | null; + readonly migratedValueSummary: string | null; + + constructor(fields: GenesisDivergenceReportFields) { + const checkedFields = requireFields(fields); + this.mismatchKind = requireMismatchKind(checkedFields.mismatchKind); + this.factKind = requireFactKind(checkedFields.factKind); + this.factKey = requireNonEmptyString(checkedFields.factKey, 'factKey'); + this.fieldPath = requireNonEmptyString(checkedFields.fieldPath, 'fieldPath'); + this.writerId = requireNullableString(checkedFields.writerId, 'writerId'); + this.patchId = requireNullableString(checkedFields.patchId, 'patchId'); + this.operationIndex = requireNullableOperationIndex(checkedFields.operationIndex); + this.legacyValueSummary = requireNullableString( + checkedFields.legacyValueSummary, + 'legacyValueSummary', + ); + this.migratedValueSummary = requireNullableString( + checkedFields.migratedValueSummary, + 'migratedValueSummary', + ); + Object.freeze(this); + } + + /** Builds a divergence report from the first structured mismatch. */ + static fromMismatch(mismatch: GenesisEquivalenceMismatch): GenesisDivergenceReport { + const checkedMismatch = requireMismatch(mismatch); + return new GenesisDivergenceReport({ + mismatchKind: checkedMismatch.kind, + factKind: checkedMismatch.factKind, + factKey: checkedMismatch.factKey, + fieldPath: checkedMismatch.fieldPath, + writerId: writerIdFromMismatch(checkedMismatch), + patchId: patchIdFromMismatch(checkedMismatch), + operationIndex: operationIndexFromMismatch(checkedMismatch), + legacyValueSummary: summarizeValue(checkedMismatch.legacyValue), + migratedValueSummary: summarizeValue(checkedMismatch.migratedValue), + }); + } + + /** Renders deterministic operator-facing report lines. */ + toSummaryLines(): readonly string[] { + return Object.freeze([ + `mismatchKind: ${this.mismatchKind}`, + `factKind: ${this.factKind}`, + `factKey: ${this.factKey}`, + `fieldPath: ${this.fieldPath}`, + `writerId: ${displayUnavailable(this.writerId)}`, + `patchId: ${displayUnavailable(this.patchId)}`, + `operationIndex: ${displayUnavailableNumber(this.operationIndex)}`, + `legacyValue: ${displayMissing(this.legacyValueSummary)}`, + `migratedValue: ${displayMissing(this.migratedValueSummary)}`, + ]); + } +} + +/** Validates the constructor envelope. */ +function requireFields( + fields: GenesisDivergenceReportFields | null | undefined, +): GenesisDivergenceReportFields { + if (fields === null || fields === undefined) { + throw new WarpError('GenesisDivergenceReport fields must be provided', 'E_VALIDATION'); + } + return fields; +} + +/** Requires a mismatch instance. */ +function requireMismatch(mismatch: GenesisEquivalenceMismatch): GenesisEquivalenceMismatch { + if (!(mismatch instanceof GenesisEquivalenceMismatch)) { + throw new WarpError('mismatch must be a GenesisEquivalenceMismatch', 'E_VALIDATION'); + } + return mismatch; +} + +/** Validates mismatch kind strings. */ +function requireMismatchKind(kind: GenesisEquivalenceMismatchKind): GenesisEquivalenceMismatchKind { + if (kind !== 'missing' && kind !== 'extra' && kind !== 'changed') { + throw new WarpError('GenesisDivergenceReport mismatchKind is unsupported', 'E_VALIDATION'); + } + return kind; +} + +/** Validates visible fact kind strings. */ +function requireFactKind(kind: GenesisEquivalenceReadingFactKind): GenesisEquivalenceReadingFactKind { + if ( + kind !== 'node' + && kind !== 'edge' + && kind !== 'property' + && kind !== 'content-attachment' + ) { + throw new WarpError('GenesisDivergenceReport factKind is unsupported', 'E_VALIDATION'); + } + return kind; +} + +/** Validates a required non-empty string. */ +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; +} + +/** Validates nullable strings. */ +function requireNullableString(value: string | null, name: string): string | null { + if (value !== null && typeof value !== 'string') { + throw new WarpError(`${name} must be a string or null`, 'E_VALIDATION'); + } + return value; +} + +/** Validates nullable operation index evidence. */ +function requireNullableOperationIndex(value: number | null): number | null { + if (value !== null && (!Number.isSafeInteger(value) || value < 0)) { + throw new WarpError('operationIndex must be a non-negative safe integer or null', 'E_VALIDATION'); + } + return value; +} + +/** Reads writer evidence from a mismatch when present. */ +function writerIdFromMismatch(mismatch: GenesisEquivalenceMismatch): string | null { + if (mismatch.boundary === null) { + return null; + } + return mismatch.boundary.writerId; +} + +/** Reads patch evidence from a mismatch when present. */ +function patchIdFromMismatch(mismatch: GenesisEquivalenceMismatch): string | null { + if (mismatch.boundary === null) { + return null; + } + return mismatch.boundary.patchId; +} + +/** Reads operation evidence from a mismatch when present. */ +function operationIndexFromMismatch(mismatch: GenesisEquivalenceMismatch): number | null { + if (mismatch.boundary === null) { + return null; + } + return mismatch.boundary.operationIndex; +} + +/** Produces a bounded deterministic value summary. */ +function summarizeValue(value: string | null): string | null { + if (value === null || value.length <= VALUE_SUMMARY_LIMIT) { + return value; + } + return `${value.slice(0, VALUE_SUMMARY_LIMIT)}...`; +} + +/** Displays an absent identity value as unavailable. */ +function displayUnavailable(value: string | null): string { + if (value === null) { + return '(unavailable)'; + } + return value; +} + +/** Displays an absent operation index as unavailable. */ +function displayUnavailableNumber(value: number | null): string { + if (value === null) { + return '(unavailable)'; + } + return String(value); +} + +/** Displays an absent reading value as missing. */ +function displayMissing(value: string | null): string { + if (value === null) { + return '(missing)'; + } + return value; +} diff --git a/src/domain/migrations/GenesisDivergenceReporter.ts b/src/domain/migrations/GenesisDivergenceReporter.ts new file mode 100644 index 00000000..a3f7f3a8 --- /dev/null +++ b/src/domain/migrations/GenesisDivergenceReporter.ts @@ -0,0 +1,24 @@ +import GenesisDivergenceReport from './GenesisDivergenceReport.ts'; +import GenesisEquivalenceProofFailure from './GenesisEquivalenceProofFailure.ts'; +import WarpError from '../errors/WarpError.ts'; + +/** Selects the first deterministic divergence from a failed equivalence proof. */ +export default class GenesisDivergenceReporter { + /** Reports the first structured mismatch in a failed proof. */ + report(failure: GenesisEquivalenceProofFailure): GenesisDivergenceReport { + const checkedFailure = requireFailure(failure); + const firstMismatch = checkedFailure.mismatches[0]; + if (firstMismatch === undefined) { + throw new WarpError('failure must contain at least one mismatch', 'E_VALIDATION'); + } + return GenesisDivergenceReport.fromMismatch(firstMismatch); + } +} + +/** Requires a proof failure instance. */ +function requireFailure(failure: GenesisEquivalenceProofFailure): GenesisEquivalenceProofFailure { + if (!(failure instanceof GenesisEquivalenceProofFailure)) { + throw new WarpError('failure must be a GenesisEquivalenceProofFailure', 'E_VALIDATION'); + } + return failure; +} diff --git a/test/unit/domain/migrations/GenesisDivergenceReporter.test.ts b/test/unit/domain/migrations/GenesisDivergenceReporter.test.ts new file mode 100644 index 00000000..8f0c4879 --- /dev/null +++ b/test/unit/domain/migrations/GenesisDivergenceReporter.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import GenesisDivergenceReporter + from '../../../../src/domain/migrations/GenesisDivergenceReporter.ts'; +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceMismatch + from '../../../../src/domain/migrations/GenesisEquivalenceMismatch.ts'; +import GenesisEquivalenceProofFailure + from '../../../../src/domain/migrations/GenesisEquivalenceProofFailure.ts'; +import GenesisEquivalenceProofSummary + from '../../../../src/domain/migrations/GenesisEquivalenceProofSummary.ts'; +import type { GenesisEquivalenceMismatchKind } + from '../../../../src/domain/migrations/GenesisEquivalenceMismatch.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; + +describe('GenesisDivergenceReporter', () => { + it('selects the first mismatch deterministically from a proof failure', () => { + const report = reporter().report(failure([ + mismatch('extra', 'edge:a', null, 'visible', null), + mismatch('changed', 'node:a', 'legacy', 'migrated', boundary()), + ])); + + expect(report.mismatchKind).toBe('changed'); + expect(report.factKey).toBe('node:a'); + expect(report.patchId).toBe('patch:a:0'); + expect(report.operationIndex).toBe(0); + }); + + it('reports missing, extra, and changed facts as distinct mismatch kinds', () => { + const missing = reporter().report(failure([ + mismatch('missing', 'node:a', 'visible', null, boundary()), + ])); + const extra = reporter().report(failure([ + mismatch('extra', 'node:b', null, 'visible', boundary()), + ])); + const changed = reporter().report(failure([ + mismatch('changed', 'node:c', 'legacy', 'migrated', boundary()), + ])); + + expect(missing.mismatchKind).toBe('missing'); + expect(extra.mismatchKind).toBe('extra'); + expect(changed.mismatchKind).toBe('changed'); + }); + + it('keeps absent boundary evidence explicit instead of guessing', () => { + const report = reporter().report(failure([ + mismatch('extra', 'node:b', null, 'visible', null), + ])); + + expect(report.writerId).toBeNull(); + expect(report.patchId).toBeNull(); + expect(report.operationIndex).toBeNull(); + expect(report.toSummaryLines()).toContain('patchId: (unavailable)'); + }); + + it('bounds rendered value summaries without changing mismatch evidence', () => { + const legacyValue = 'legacy-value-with-a-long-tail-that-should-be-cut-before-operator-output-grows-too-wide'; + const migratedValue = 'migrated-value-with-a-long-tail-that-should-be-cut-before-operator-output-grows-too-wide'; + const sourceMismatch = mismatch('changed', 'node:a', legacyValue, migratedValue, boundary()); + const report = reporter().report(failure([sourceMismatch])); + + expect(sourceMismatch.legacyValue).toBe(legacyValue); + expect(sourceMismatch.migratedValue).toBe(migratedValue); + expect(report.legacyValueSummary?.endsWith('...')).toBe(true); + expect(report.migratedValueSummary?.endsWith('...')).toBe(true); + }); +}); + +function reporter(): GenesisDivergenceReporter { + return new GenesisDivergenceReporter(); +} + +function failure( + mismatches: readonly GenesisEquivalenceMismatch[], +): GenesisEquivalenceProofFailure { + return new GenesisEquivalenceProofFailure({ + basis: basis(), + summary: new GenesisEquivalenceProofSummary({ + basis: basis(), + legacyFactCount: 2, + migratedFactCount: 2, + mismatchCount: mismatches.length, + }), + mismatches, + }); +} + +function mismatch( + kind: GenesisEquivalenceMismatchKind, + factKey: string, + legacyValue: string | null, + migratedValue: string | null, + factBoundary: GenesisEquivalenceBoundary | null, +): GenesisEquivalenceMismatch { + return new GenesisEquivalenceMismatch({ + kind, + factKind: 'node', + factKey, + fieldPath: 'visibility', + legacyValue, + migratedValue, + boundary: factBoundary, + }); +} + +function boundary(): GenesisEquivalenceBoundary { + return new GenesisEquivalenceBoundary({ + writerId: 'writer:a', + patchId: 'patch:a:0', + operationIndex: 0, + }); +} + +function basis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:migrated', + }), + }); +} From b3a9f56d284aa1539ed7adf5e0af224edb1a42e3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 07:18:48 -0700 Subject: [PATCH 5/8] Docs: Replan v18 migration evidence runway --- CHANGELOG.md | 3 + docs/BEARING.md | 68 ++++++++--- .../v18-replan-with-migration-evidence.md | 38 +++++- .../v18-real-source-inventory-collector.md | 114 ++++++++++++++++++ .../v18-migration-operation-lowering.md | 106 ++++++++++++++++ .../v18-scratch-migration-writer.md | 108 +++++++++++++++++ .../v18-scratch-equivalence-gate.md | 107 ++++++++++++++++ .../v18-migration-finalization-safety.md | 110 +++++++++++++++++ .../INFRA_graph-model-migration-tool.md | 21 ++++ docs/method/backlog/v18.0.0/README.md | 23 +++- .../TRUST_genesis-replay-equivalence.md | 16 +++ 11 files changed, 687 insertions(+), 27 deletions(-) create mode 100644 docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md create mode 100644 docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md create mode 100644 docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md create mode 100644 docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md create mode 100644 docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa614be..4cf35804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - V18 genesis replay equivalence now reports the first deterministic divergence as a structured value with mismatch kind, fact identity, field path, optional patch boundary evidence, and bounded value summaries. +- V18 migration planning now records the evidence-backed post-PR-D runway for + real source inventory collection, operation lowering, scratch migration + writing, scratch equivalence gating, and finalization safety. - V18 graph-model migration dry-run work now exposes runtime-backed migration manifest nouns for source and target basis, node, edge, property, and content mappings, warnings, and fatal planning failures without adding any diff --git a/docs/BEARING.md b/docs/BEARING.md index faab178e..47c026df 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -45,9 +45,9 @@ Current branch state at this boundary: - Latest merged PR: #102, v18 migration dry-run planning substrate - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0188-v18-migration-manifest-serialization` -- Current work: v18 slices 41 through 45 are the active migration CLI and - genesis-equivalence evidence batch on this branch. + `0193-v18-replan-with-migration-evidence` +- Current work: PR D, v18 slices 41 through 45, is complete on this branch + and ready for review. - Cleanup checkpoint: `main` has been fast-forwarded to `origin/main` after PR #102 merged; this branch starts from that merge commit. @@ -89,11 +89,22 @@ The current v18 graph-model posture is: frontier evidence needed by later genesis equivalence work. - Manifest JSON serialization exists as an infrastructure adapter boundary. Domain migration nouns still do not parse or stringify JSON. +- A non-destructive migration dry-run CLI exists under + `scripts/v18.0.0/migrations/graph-model/`. It accepts explicit request JSON, + emits deterministic manifest output, and refuses apply/write verbs. +- Genesis equivalence proof nouns exist for basis pairs, visible reading + facts, patch boundary evidence, structured mismatches, proof summaries, and + success/failure result values. +- First equivalence fixtures exist for node lifecycle, edge lifecycle, content + attachment metadata, removed-node visibility, multi-writer ordering, and one + intentional divergent property case. +- A genesis divergence reporter exists and turns proof failures into + structured first-divergence reports. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration -tooling, and genesis replay equivalence before v18 can make stronger -compatibility claims. +tooling over real graph history, and genesis replay equivalence over scratch +migrated history before v18 can make stronger compatibility claims. ## What Just Shipped @@ -199,37 +210,44 @@ the first deterministic proof mismatch and exposes mismatch kind, graph fact identity, field path, optional writer/patch/operation boundary evidence, and bounded value summaries as structured report fields. +Slice 45 is complete on this branch. Evidence-backed replanning inspected +remaining raw legacy-property boundaries, migration-domain coverage, dry-run +CLI coverage, and equivalence proof fixtures, then created design docs for +slices 46 through 50. + ## What Feels Wrong - Content persistence still uses legacy `_content*` compatibility properties. Typed reads and writes exist over that plane, but the storage cutover is not 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`. - 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`. -- Checkpoint, serializer, state-diff, visible-scope, logical-index, - reducer/op-strategy, and content-projection code still touch the raw - property map as named compatibility or migration boundaries. -- The v18 migration tool does not exist yet. Starting with a write-capable - script would be reckless; the next migration work must be dry-run first. -- Genesis replay equivalence has not been proven. Migration cannot be trusted - without structured divergence evidence. -- The repo has enough graph-model pieces that vague planning is now more - dangerous than helpful. The next slices need design documents before code. +- 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. +- Genesis equivalence is credible as a domain vocabulary and compact fixture + proof, not yet as a real scratch-history replay 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. ## Where We Are Heading The remaining planned slices are the runway from "typed graph-model surfaces -exist" to "we have enough evidence to decide the migration path." +and fixture-level migration proof exist" to "scratch migrated history can be +proven equivalent before finalization." Suggested implementation batches: -- PR B, slices 31 through 35: state-reader routing, property write intents, - graph-op property cutover, and property-projection closeout. -- PR C, slices 36 through 40: migration manifest, source inventory, dry-run - planner, history input, and manifest serialization. - PR D, slices 41 through 45: dry-run CLI, equivalence nouns, fixtures, divergence reporter, and evidence-backed replan. +- PR E, slices 46 through 50: real source inventory collection, migration + operation lowering, scratch migration writing, scratch equivalence gate, and + finalization safety design. ## Invariants @@ -319,5 +337,15 @@ and concrete checks live in `docs/invariants/`. [0191](design/0191-v18-genesis-equivalence-fixtures/v18-genesis-equivalence-fixtures.md). - [x] 44. Add the genesis divergence reporter: [0192](design/0192-v18-genesis-divergence-reporter/v18-genesis-divergence-reporter.md). -- [ ] 45. Re-plan with migration evidence in hand: +- [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 real source inventory collection: + [0194](design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md). +- [ ] 47. Add migration operation lowering: + [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). +- [ ] 48. Add the scratch migration writer: + [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). +- [ ] 49. Add the scratch equivalence gate: + [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). +- [ ] 50. Design migration finalization safety: + [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). diff --git a/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md b/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md index 69d5bc68..aea98810 100644 --- a/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md +++ b/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md @@ -1,7 +1,7 @@ --- cycle: 0193 task_id: V18_replan_with_migration_evidence -status: Planned +status: Complete sponsors: human: James agent: Codex @@ -97,13 +97,45 @@ protocol. ```text rg "decodePropKey|decodeEdgePropKey|state\\.prop" src/domain -npm run test:local +npx vitest run test/unit/domain/graph/LegacyPropertyProjection.test.ts test/unit/domain/services/NodePropertyProjection.test.ts test/unit/domain/services/EdgePropertyProjection.test.ts test/unit/domain/services/QueryReadsPropertyProjection.test.ts test/unit/domain/services/StateReaderPropertyProjection.test.ts test/unit/domain/services/query/StateQueryReadModelPropertyProjection.test.ts test/unit/domain/migrations/DryRunGraphModelMigrationPlanner.test.ts test/unit/domain/migrations/GenesisEquivalenceProof.test.ts test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts test/unit/domain/migrations/GenesisDivergenceReporter.test.ts --reporter=verbose npm run typecheck npm run lint -npx markdownlint-cli2 CHANGELOG.md docs/BEARING.md docs/method/backlog/v18.0.0/*.md docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md +npx markdownlint CHANGELOG.md docs/BEARING.md docs/method/backlog/v18.0.0/*.md docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md git diff --check HEAD ``` +## Playback + +- Property projection is closed for public property reads and graph-op algebra, + but raw property-map access still exists in compatibility, serialization, + replay, reducer/op-strategy, visible-scope, logical-index, and migration- + source boundaries. +- The dry-run planner and CLI are enough to inspect explicit request artifacts, + but not enough to write migration history. Real source collection is the + next required slice. +- Genesis equivalence is credible as a runtime-backed vocabulary and compact + fixture proof. It is not yet a real scratch-history replay gate. +- The next five slice docs now exist as cycles 0194 through 0198. +- The Continuum posture remains unchanged: git-warp is a sibling Continuum + participant exchanging witnessed causal history, not a subordinate runtime. + +## Evidence + +- Source audit command: + `rg -n "decodePropKey|decodeEdgePropKey|state\\.prop" src/domain`. +- Migration domain files under `src/domain/migrations/`: 34. +- PR D focused test command passed: 10 files and 42 tests covering property + projection, dry-run planning, equivalence proof, fixtures, and divergence + reporting. +- PR D commits before this replan: `45d59e08`, `71e1e165`, `3b201c50`, + `a4387d8e`. +- Next design docs: + [0194](../0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md), + [0195](../0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md), + [0196](../0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md), + [0197](../0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md), + [0198](../0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). + ## Closeout Criteria - Evidence-backed status is written into `BEARING.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 new file mode 100644 index 00000000..09c8edbd --- /dev/null +++ b/docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md @@ -0,0 +1,114 @@ +--- +cycle: 0194 +task_id: V18_real_source_inventory_collector +status: Planned +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 46 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Real Source Inventory Collector + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Collect real graph-history source facts into `GraphModelMigrationSourceInventory` +without writing graph history. + +## Playback Questions + +- Does the collector read actual graph refs or an explicit graph-history + source instead of only fixture JSON? +- Does it preserve writer chains, patch descriptors, source basis, snapshot + reference, and content source facts? +- Does incomplete source evidence become structured fatal inventory notices? +- Does the collector stay outside domain code when touching Git, filesystem, + process, or wire formats? +- Does the dry-run CLI gain a path from real source facts to planner request + values without adding an apply mode? + +## Existing Shape + +Slices 36 through 41 created source inventory nouns, a dry-run planner, a +request JSON adapter, and a non-destructive CLI. The missing bridge is a real +collector that can read graph history into those nouns. The collector belongs +in adapters or scripts because it touches host state. + +## Chosen Boundary + +Add a collector adapter or script-local collector that: + +- receives explicit repository and graph identifiers; +- reads writer refs and ordered patch identifiers; +- creates `GraphModelMigrationWriterChainDescriptor` and + `GraphModelMigrationPatchDescriptor` values; +- records source basis and state snapshot identity when available; +- finds legacy content source facts for `_content*` compatibility records; +- returns `GraphModelMigrationSourceInventory`. + +The first collector remains dry-run only. It must not archive refs, create +migrated commits, or promote lineages. + +## Non-Goals + +- Do not write migrated history. +- Do not finalize or archive old lineages. +- Do not broaden equivalence fixtures. +- Do not change public graph APIs. +- Do not claim full production migration readiness. + +## RED Plan + +Add collector tests with a fake or in-memory graph-history source: + +- complete source history creates usable source inventory; +- missing source basis produces a fatal inventory notice; +- incomplete writer chain or patch descriptor collection fails closed; +- collected content source keys become planner content mappings; +- no write-capable port method is invoked. + +## GREEN Plan + +Implement the smallest source collector over existing persistence/adapter +surfaces. If the available production ports are too broad or ambiguous, define +a collector-local port in the adapter/script layer rather than leaking host +shape into domain. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-graph-model-source-inventory-collector.test.ts --reporter=verbose +npm run typecheck +npm run lint:semgrep +npm run lint:sludge +git diff --check HEAD +``` + +## Closeout Criteria + +- Real source collection exists for dry-run planning. +- Fatal collection errors are structured inventory facts. +- The dry-run CLI can invoke collection without adding write mode. +- Slice 47 can lower planned operations against collected source evidence. + +## SSJS Scorecard + +- Runtime-backed forms: green when collected facts become migration nouns. +- Boundary validation: green when raw graph history is decoded outside domain. +- Behavior ownership: green when collector collects and domain plans. +- Message parsing: green; no behavior branches on diagnostic text. +- Ambient time or entropy: green; no clocks or randomness in domain. +- Fake shape trust or cast-cosplay: green when collector tests use explicit + fakes or ports instead of loose object bags. 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 new file mode 100644 index 00000000..8471a7bd --- /dev/null +++ b/docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md @@ -0,0 +1,106 @@ +--- +cycle: 0195 +task_id: V18_migration_operation_lowering +status: Planned +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 47 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Operation Lowering + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Define runtime-backed lowering from dry-run planned graph operations to +write-ready migrated graph-operation facts without committing them. + +## Playback Questions + +- Does lowering consume `DryRunGraphModelMigrationPlan` rather than re-reading + legacy property maps? +- Are node, edge, property, and content attachment operations represented as + explicit lowered facts? +- Does lowering reject fatal dry-run plans? +- Does output ordering remain deterministic? +- Does the slice stop before writing commits or updating refs? + +## Existing Shape + +The dry-run planner emits `GraphModelMigrationPlannedGraphOperation` facts. +Those facts describe intended graph-model operations but are not yet write +instructions. The next write-capable branch needs a bridge that makes the +future write shape explicit while staying non-destructive. + +## Chosen Boundary + +Add lowering nouns under `src/domain/migrations/`: + +- lowered migration operation; +- lowered migration patch plan; +- lowering result or failure; +- lowering service over successful dry-run plans. + +Lowering should preserve source/target keys and operation kind, but it should +not call Git, allocate refs, or serialize commit messages. + +## Non-Goals + +- Do not implement scratch writing. +- Do not change `PatchBuilder` or live graph write APIs. +- Do not archive source history. +- Do not run equivalence against scratch history yet. +- Do not add transport JSON unless the next adapter needs it. + +## RED Plan + +Add lowering tests: + +- successful dry-run plan lowers into deterministic operation facts; +- fatal dry-run plan returns or throws a validation failure before lowering; +- property target keys preserve length-prefixed identity; +- content attachment operations preserve manifest content mappings; +- repeated lowering output is stable. + +## GREEN Plan + +Implement lowering as pure domain code. Use `instanceof` checks and explicit +classes; do not model lowered operations as generic object dictionaries. + +## Verification + +```text +npx vitest run test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts --reporter=verbose +npx eslint src/domain/migrations test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts +npm run typecheck +npm run lint:semgrep +git diff --check HEAD +``` + +## Closeout Criteria + +- Lowering vocabulary is runtime-backed. +- Lowering consumes dry-run plan values. +- No graph-history writes are added. +- Scratch writer work has explicit input values. + +## SSJS Scorecard + +- Runtime-backed forms: green when lowered operation facts are classes. +- Boundary validation: green when fatal plans cannot be lowered. +- Behavior ownership: green when lowering owns operation translation. +- Message parsing: green; no diagnostic parsing. +- Ambient time or entropy: green; no clocks or randomness. +- Fake shape trust or cast-cosplay: green when no assertions are introduced. 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 new file mode 100644 index 00000000..c8f983d0 --- /dev/null +++ b/docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md @@ -0,0 +1,108 @@ +--- +cycle: 0196 +task_id: V18_scratch_migration_writer +status: Planned +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 48 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Scratch Migration Writer + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Write lowered migration operations only to an explicit scratch target so +equivalence can inspect migrated history before any live ref changes. + +## Playback Questions + +- Does the writer require an explicit scratch namespace or isolated target? +- Does it reject live writer refs and default production locations? +- Does it write a manifest sidecar or return manifest identity for inspection? +- Does it preserve append-only Git safety with no force, rebase, or history + rewrite behavior? +- Does it remain blocked from finalization until equivalence passes? + +## Existing Shape + +The dry-run path can now plan and prove fixture-level equivalence. Slice 47 +will define lowered operation facts. The first write-capable step must be a +scratch writer, not a live migration command. + +## Chosen Boundary + +Add a script or adapter-layer writer that accepts lowered operation facts and +an explicit scratch destination. It may create scratch refs or write to an +isolated repository target, but it must never replace live graph writer refs. + +The scratch writer should return a structured result with: + +- scratch namespace or target; +- written patch identifiers; +- manifest artifact location or identity; +- warnings and fatal failures. + +## Non-Goals + +- Do not archive old lineages. +- Do not promote scratch refs to live refs. +- Do not run final equivalence gate inside the writer unless already + available as a dependency. +- Do not accept implicit current-directory production defaults. +- Do not add destructive cleanup. + +## RED Plan + +Add writer tests: + +- explicit scratch target receives migrated patch facts; +- missing scratch target fails before writing; +- live writer ref targets are rejected; +- manifest sidecar is produced deterministically; +- fake persistence records no live-ref update calls. + +## GREEN Plan + +Keep the writer adapter-facing and narrow. If existing persistence ports are +too broad, use a scratch-writer-specific port for tests and adapt production +persistence behind it. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-scratch-migration-writer.test.ts --reporter=verbose +npm run typecheck +npm run lint:semgrep +npm run lint:sludge +git diff --check HEAD +``` + +## Closeout Criteria + +- Scratch migration writing exists behind explicit target selection. +- No live refs are changed by default or by accident. +- Scratch output can be replayed by the equivalence gate. +- Finalization remains separate. + +## 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. +- 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. 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 new file mode 100644 index 00000000..40b1f42a --- /dev/null +++ b/docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md @@ -0,0 +1,107 @@ +--- +cycle: 0197 +task_id: V18_scratch_equivalence_gate +status: Planned +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 49 +promotes_backlog: + - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md +--- + +# V18 Scratch Equivalence Gate + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Run genesis replay equivalence against scratch migrated history and block +promotion when divergence is reported. + +## Playback Questions + +- Does the gate replay legacy history and scratch migrated history from + genesis or explicit basis roots? +- Does it compare node, edge, property, content, and attachment facts through + `GenesisEquivalenceProof`? +- Does failure return `GenesisDivergenceReport` output instead of stack traces? +- Does the migration command refuse finalization when the gate fails? +- Does passing the gate produce stable proof summary evidence? + +## Existing Shape + +Slice 42 created equivalence proof nouns, slice 43 created fixtures, and slice +44 created divergence reporting. Slice 48 will create scratch migrated history. +The next trust step is a gate over real scratch output. + +## Chosen Boundary + +Add an equivalence gate service that consumes: + +- legacy replay reading; +- scratch migrated replay reading; +- comparison basis; +- optional scratch writer result metadata. + +It returns success/failure proof values and a divergence report for failure. +CLI or script output may format the report, but gate decisions remain +structured values. + +## Non-Goals + +- Do not promote scratch history. +- Do not archive old lineages. +- Do not compare only state hashes. +- Do not hide content attachment mismatches behind property views. +- Do not make fixture-only proof claims. + +## RED Plan + +Add gate tests: + +- equal scratch and legacy readings pass; +- changed property/content facts fail with divergence report; +- missing boundary evidence is explicit; +- migration finalization is blocked when proof fails; +- summary evidence is deterministic. + +## GREEN Plan + +Wire existing proof nouns and reporter into a gate value/service. Keep replay +input construction separate so future source collectors can evolve without +changing proof semantics. + +## Verification + +```text +npx vitest run test/unit/domain/migrations/GenesisEquivalenceGate.test.ts --reporter=verbose +npx eslint src/domain/migrations test/unit/domain/migrations/GenesisEquivalenceGate.test.ts +npm run typecheck +npm run lint:semgrep +git diff --check HEAD +``` + +## Closeout Criteria + +- Scratch migration output is gated by genesis equivalence. +- Divergence blocks promotion. +- Passing proof summary is deterministic. +- Finalization design has enough evidence to specify promotion semantics. + +## 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. +- 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. 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 new file mode 100644 index 00000000..68753e7b --- /dev/null +++ b/docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md @@ -0,0 +1,110 @@ +--- +cycle: 0198 +task_id: V18_migration_finalization_safety +status: Planned +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 50 +promotes_backlog: + - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md +--- + +# V18 Migration Finalization Safety + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Specify and test the safe finalization protocol for promoting equivalence- +proven scratch migration output without rewriting history. + +## Playback Questions + +- Does finalization require an explicit operator confirmation token or + equivalent non-accidental action? +- Does it preserve old lineage through archive refs rather than deletion? +- Does it refuse to run unless the scratch equivalence gate passed? +- Does it use compare-and-swap ref updates rather than force operations? +- Does it document rollback and audit evidence in BEARING/backlog before + release work begins? + +## Existing Shape + +By this point, the tool should be able to collect real source inventory, lower +planned operations, write scratch migrated history, and gate scratch output +against equivalence. The remaining risk is finalization: changing live refs is +where migration can damage user data if the protocol is vague. + +## Chosen Boundary + +This slice should first design the finalization protocol and then add minimal +tests for the gate conditions: + +- explicit confirmation; +- passed equivalence proof; +- archive ref target; +- live ref compare-and-swap; +- no force or rewrite path. + +If implementation is not yet safe, the slice should stop at design and tests +that prove finalization remains locked. + +## Non-Goals + +- Do not run finalization without a prior equivalence gate. +- Do not force-update refs. +- Do not delete old lineage. +- Do not hide archive refs from operators. +- Do not bump release version. + +## RED Plan + +Add safety tests: + +- finalization without confirmation is rejected; +- finalization without passed gate is rejected; +- archive target is required; +- stale live ref expectation fails closed; +- force mode does not exist. + +## GREEN Plan + +Implement only the smallest finalization surface that can satisfy the safety +tests. Prefer a locked design document over a write path if live-ref semantics +remain ambiguous. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-migration-finalization-safety.test.ts --reporter=verbose +npm run typecheck +npm run lint:semgrep +npm run lint:sludge +git diff --check HEAD +``` + +## Closeout Criteria + +- Finalization safety protocol is explicit. +- Archive and compare-and-swap behavior are named. +- No destructive defaults exist. +- v18 release readiness can be assessed from migration evidence. + +## 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. +- 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. 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 ac403989..05d8f29d 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 @@ -29,6 +29,27 @@ snapshot. the new substrate ids - migration fails closed if replay equivalence does not hold +## Progress + +V18 slices 36 through 45 completed the non-destructive foundation: + +- migration manifest, source inventory, dry-run planner, ordered history + input, and manifest JSON adapter exist; +- a dry-run CLI exists under `scripts/v18.0.0/migrations/graph-model/`; +- request JSON is decoded through + `GraphModelMigrationDryRunRequestJsonAdapter`; +- the CLI writes only optional manifest artifacts and refuses apply/write + verbs; +- genesis proof and divergence nouns exist for later migration gates. + +Remaining migration-tool work is intentionally ordered as: + +- slice 46: collect real source inventory; +- slice 47: lower dry-run planned operations; +- slice 48: write scratch migrated history; +- slice 49: gate scratch output with genesis equivalence; +- slice 50: design finalization safety. + ## Starting points - `scripts/migrations/` diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 750bf626..e266beb4 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -51,12 +51,12 @@ LAYER 0 (shape cut): LAYER 1 (behavioral convergence): [x] PROTO_graph-op-algebra-convergence - [ ] PROTO_content-attachment-plane-cutover - [ ] PROTO_legacy-props-as-projection + [~] PROTO_content-attachment-plane-cutover + [x] PROTO_legacy-props-as-projection LAYER 2 (migration and proof): - [ ] INFRA_graph-model-migration-tool - [ ] TRUST_genesis-replay-equivalence + [~] INFRA_graph-model-migration-tool + [~] TRUST_genesis-replay-equivalence ``` ## Practical rule @@ -71,3 +71,18 @@ graph model. Change the envelope only if replay honesty requires it. - `[~]` in progress - `[x]` done - `[!]` blocked + +## Current Evidence + +After v18 slices 41 through 45, the migration path is intentionally still +non-destructive: + +- dry-run request JSON can be decoded at the infrastructure boundary; +- the dry-run CLI can emit deterministic manifest output and refuses + apply/write verbs; +- genesis equivalence has runtime-backed proof, mismatch, and divergence + report nouns; +- compact fixtures cover node, edge, content, removal, multi-writer, and + divergent-property cases; +- real source inventory, operation lowering, scratch writing, scratch + equivalence, and finalization safety are planned as slices 46 through 50. 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 f8825290..f8155752 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 @@ -24,6 +24,22 @@ the legacy history up to the migration cut. - failures tell the operator which patch boundary diverged - the ship gate for the migration command includes this proof +## Progress + +V18 slices 42 through 44 added fixture-level proof infrastructure: + +- `GenesisEquivalenceProof` compares legacy and migrated readings as + structured values; +- mismatches distinguish missing, extra, and changed graph facts; +- first fixtures cover node lifecycle, edge lifecycle, content metadata, + removed-node visibility, multi-writer order, and an intentional divergent + property; +- `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 connect real +legacy replay and scratch migrated replay through slices 46 through 49. + ## Starting points - `test/` From 36e236475836dc2b375823c799a1c0d0a901cb0a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 09:00:29 -0700 Subject: [PATCH 6/8] Docs: Pivot v18 migration plan to v17 fixtures --- CHANGELOG.md | 4 + docs/BEARING.md | 34 +++-- .../v18-replan-with-migration-evidence.md | 14 +- .../v18-real-source-inventory-collector.md | 11 +- .../v18-migration-operation-lowering.md | 2 +- .../v18-scratch-migration-writer.md | 4 +- .../v18-scratch-equivalence-gate.md | 4 +- .../v18-migration-finalization-safety.md | 2 +- .../v18-v17-golden-graph-fixtures.md | 138 ++++++++++++++++++ .../INFRA_graph-model-migration-tool.md | 15 +- docs/method/backlog/v18.0.0/README.md | 4 +- .../TRUST_genesis-replay-equivalence.md | 5 +- 12 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf35804..19928063 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 - V18 migration planning now records the evidence-backed post-PR-D runway for real source inventory collection, operation lowering, scratch migration writing, scratch equivalence gating, and finalization safety. +- V18 migration planning now inserts a v17 golden graph-history fixture corpus + before real source inventory collection, so wet-run migration work can prove + against restored Git objects and refs instead of compact proof fixtures + alone. - V18 graph-model migration dry-run work now exposes runtime-backed migration manifest nouns for source and target basis, node, edge, property, and content mappings, warnings, and fatal planning failures without adding any diff --git a/docs/BEARING.md b/docs/BEARING.md index 47c026df..edf0790a 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -47,7 +47,8 @@ Current branch state at this boundary: - 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 ready for review. + and now includes a drift-check pivot that inserts v17 golden graph-history + fixtures before write-capable migration work. - Cleanup checkpoint: `main` has been fast-forwarded to `origin/main` after PR #102 merged; this branch starts from that merge commit. @@ -100,6 +101,9 @@ The current v18 graph-model posture is: intentional divergent property case. - A genesis divergence reporter exists and turns proof failures into structured first-divergence reports. +- 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. That is useful progress, not a finish line. The repo still needs property projection beyond replay/serialization boundaries, graph-model migration @@ -213,7 +217,8 @@ bounded value summaries as structured report fields. Slice 45 is complete on this branch. Evidence-backed replanning inspected remaining raw legacy-property boundaries, migration-domain coverage, dry-run CLI coverage, and equivalence proof fixtures, then created design docs for -slices 46 through 50. +slices 47 through 51 and inserted the v17 golden graph-history fixture as the +new slice 46. ## What Feels Wrong @@ -231,6 +236,9 @@ slices 46 through 50. but it does not yet collect real graph history into source inventory. - 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. - 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. @@ -244,10 +252,12 @@ proven equivalent before finalization." Suggested implementation batches: - PR D, slices 41 through 45: dry-run CLI, equivalence nouns, fixtures, - divergence reporter, and evidence-backed replan. -- PR E, slices 46 through 50: real source inventory collection, migration - operation lowering, scratch migration writing, scratch equivalence gate, and - finalization safety design. + divergence reporter, evidence-backed replan, and the v17 fixture pivot. +- PR E, slices 46 through 50: 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. ## Invariants @@ -339,13 +349,15 @@ 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 real source inventory collection: +- [ ] 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). -- [ ] 47. Add migration operation lowering: +- [ ] 48. Add migration operation lowering: [0195](design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md). -- [ ] 48. Add the scratch migration writer: +- [ ] 49. Add the scratch migration writer: [0196](design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md). -- [ ] 49. Add the scratch equivalence gate: +- [ ] 50. Add the scratch equivalence gate: [0197](design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md). -- [ ] 50. Design migration finalization safety: +- [ ] 51. Design migration finalization safety: [0198](design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md). diff --git a/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md b/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md index aea98810..12956d57 100644 --- a/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md +++ b/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md @@ -37,6 +37,8 @@ rather than guesswork. planning? - Does it say whether genesis equivalence is credible or still fixture-only? - Does it produce the next ten to twenty slices with backlog updates? +- Does it revise the runway when review discovers that compact fixtures are + not enough evidence for persisted v17 Git history? ## Existing Shape @@ -100,7 +102,7 @@ rg "decodePropKey|decodeEdgePropKey|state\\.prop" src/domain npx vitest run test/unit/domain/graph/LegacyPropertyProjection.test.ts test/unit/domain/services/NodePropertyProjection.test.ts test/unit/domain/services/EdgePropertyProjection.test.ts test/unit/domain/services/QueryReadsPropertyProjection.test.ts test/unit/domain/services/StateReaderPropertyProjection.test.ts test/unit/domain/services/query/StateQueryReadModelPropertyProjection.test.ts test/unit/domain/migrations/DryRunGraphModelMigrationPlanner.test.ts test/unit/domain/migrations/GenesisEquivalenceProof.test.ts test/unit/domain/migrations/GenesisEquivalenceFixtures.test.ts test/unit/domain/migrations/GenesisDivergenceReporter.test.ts --reporter=verbose npm run typecheck npm run lint -npx markdownlint CHANGELOG.md docs/BEARING.md docs/method/backlog/v18.0.0/*.md docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md +npx markdownlint CHANGELOG.md docs/BEARING.md docs/method/backlog/v18.0.0/*.md docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md docs/design/0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md docs/design/0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md docs/design/0196-v18-scratch-migration-writer/v18-scratch-migration-writer.md docs/design/0197-v18-scratch-equivalence-gate/v18-scratch-equivalence-gate.md docs/design/0198-v18-migration-finalization-safety/v18-migration-finalization-safety.md docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md git diff --check HEAD ``` @@ -111,11 +113,14 @@ git diff --check HEAD replay, reducer/op-strategy, visible-scope, logical-index, and migration- source boundaries. - The dry-run planner and CLI are enough to inspect explicit request artifacts, - but not enough to write migration history. Real source collection is the - next required slice. + but not enough to write migration history. A restored v17 Git fixture is the + next required slice before real source collection can claim persisted-source + evidence. - Genesis equivalence is credible as a runtime-backed vocabulary and compact fixture proof. It is not yet a real scratch-history replay gate. -- The next five slice docs now exist as cycles 0194 through 0198. +- The next five implementation slice docs now exist as cycles 0199, then 0194 + through 0197. Finalization safety remains planned as cycle 0198 after the + restored-fixture equivalence gate. - The Continuum posture remains unchanged: git-warp is a sibling Continuum participant exchanging witnessed causal history, not a subordinate runtime. @@ -130,6 +135,7 @@ git diff --check HEAD - PR D commits before this replan: `45d59e08`, `71e1e165`, `3b201c50`, `a4387d8e`. - Next design docs: + [0199](../0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md), [0194](../0194-v18-real-source-inventory-collector/v18-real-source-inventory-collector.md), [0195](../0195-v18-migration-operation-lowering/v18-migration-operation-lowering.md), [0196](../0196-v18-scratch-migration-writer/v18-scratch-migration-writer.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 09c8edbd..c88edd86 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 @@ -7,7 +7,7 @@ sponsors: agent: Codex started_at: 2026-05-24 release_home: v18.0.0 -bearing_task: 46 +bearing_task: 47 promotes_backlog: - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md --- @@ -42,9 +42,10 @@ without writing graph history. ## Existing Shape Slices 36 through 41 created source inventory nouns, a dry-run planner, a -request JSON adapter, and a non-destructive CLI. The missing bridge is a real -collector that can read graph history into those nouns. The collector belongs -in adapters or scripts because it touches host state. +request JSON adapter, and a non-destructive CLI. Slice 46 now adds a restored +v17 golden graph-history fixture corpus. The missing bridge after that is a +real collector that can read restored graph history into those nouns. The +collector belongs in adapters or scripts because it touches host state. ## Chosen Boundary @@ -101,7 +102,7 @@ git diff --check HEAD - Real source collection exists for dry-run planning. - Fatal collection errors are structured inventory facts. - The dry-run CLI can invoke collection without adding write mode. -- Slice 47 can lower planned operations against collected source evidence. +- Slice 48 can lower planned operations against collected source evidence. ## SSJS Scorecard 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 8471a7bd..698c2ce9 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 @@ -7,7 +7,7 @@ sponsors: agent: Codex started_at: 2026-05-24 release_home: v18.0.0 -bearing_task: 47 +bearing_task: 48 promotes_backlog: - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.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 c8f983d0..6d0d0e96 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 @@ -7,7 +7,7 @@ sponsors: agent: Codex started_at: 2026-05-24 release_home: v18.0.0 -bearing_task: 48 +bearing_task: 49 promotes_backlog: - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.md --- @@ -38,7 +38,7 @@ equivalence can inspect migrated history before any live ref changes. ## Existing Shape -The dry-run path can now plan and prove fixture-level equivalence. Slice 47 +The dry-run path can now plan and prove fixture-level equivalence. Slice 48 will define lowered operation facts. The first write-capable step must be a scratch writer, not a live migration command. 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 40b1f42a..d45b1340 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 @@ -7,7 +7,7 @@ sponsors: agent: Codex started_at: 2026-05-24 release_home: v18.0.0 -bearing_task: 49 +bearing_task: 50 promotes_backlog: - docs/method/backlog/v18.0.0/TRUST_genesis-replay-equivalence.md --- @@ -40,7 +40,7 @@ promotion when divergence is reported. ## Existing Shape Slice 42 created equivalence proof nouns, slice 43 created fixtures, and slice -44 created divergence reporting. Slice 48 will create scratch migrated history. +44 created divergence reporting. Slice 49 will create scratch migrated history. The next trust step is a gate over real scratch output. ## Chosen Boundary 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 68753e7b..63fc5578 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 @@ -7,7 +7,7 @@ sponsors: agent: Codex started_at: 2026-05-24 release_home: v18.0.0 -bearing_task: 50 +bearing_task: 51 promotes_backlog: - docs/method/backlog/v18.0.0/INFRA_graph-model-migration-tool.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 new file mode 100644 index 00000000..c69b811d --- /dev/null +++ b/docs/design/0199-v18-v17-golden-graph-fixtures/v18-v17-golden-graph-fixtures.md @@ -0,0 +1,138 @@ +--- +cycle: 0199 +task_id: V18_v17_golden_graph_fixtures +status: Planned +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 46 +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 V17 Golden Graph Fixtures + +## Sponsor Human + +James. + +## Sponsor Agent + +Codex. + +## Hill + +Capture deterministic v17 graph-history fixtures as Git artifacts with +manifests so v18 migration paths can be tested against real persisted v17 +history before any wet-run write path is trusted. + +## Playback Questions + +- Does the fixture restore a real v17 Git object and ref layout rather than + only in-memory migration facts? +- Does the fixture manifest name the graph, refs, expected heads, writer + chains, patch counts, and visible graph facts? +- Can source inventory collection consume the restored fixture without + touching the developer's live repository? +- Does the fixture cover legacy node, edge, property, content, removal, and + multi-writer cases needed by the v18 graph-model migration? +- Is Docker treated as an optional hermetic wet-run harness rather than the + canonical fixture artifact? + +## Existing Shape + +Slices 42 through 44 proved genesis equivalence vocabulary with compact +fixtures. Those fixtures are useful, but they do not prove that the migration +tool understands the real v17 Git persistence shape. Slice 46 must add that +missing evidence before slice 47 claims real source inventory collection. + +## Chosen Boundary + +Add a small fixture corpus under a dedicated fixture root. The canonical +artifact should be a Git-native fixture, preferably a `git bundle`, paired +with a deterministic manifest. The manifest is the operator-readable contract; +the Git artifact is the persisted history to restore. + +The first fixture should include: + +- graph identity and v17 generator metadata; +- writer refs and expected head object ids; +- writer-chain lengths and patch descriptor expectations; +- visible node, edge, property, content, removal, and multi-writer facts; +- optional state snapshot or checkpoint references when present in the v17 + fixture history; +- explicit regeneration and restore instructions. + +Docker may wrap restoration and wet-run checks, but Docker is not the source +of truth. Unit and adapter tests should be able to restore the Git artifact +into a temporary directory without requiring a container. + +## Non-Goals + +- Do not write migrated v18 history in this slice. +- Do not promote or archive live refs. +- Do not store a raw `.git` directory tarball unless `git bundle` is proven + insufficient. +- Do not make Docker mandatory for normal unit tests. +- Do not claim production migration readiness from fixture restoration alone. + +## RED Plan + +Add fixture validation tests: + +- restored fixture refs match the manifest; +- expected writer-chain heads and patch counts match the restored Git data; +- manifest visible facts include node, edge, property, content, removal, and + multi-writer cases; +- restoration happens in an explicit temporary target; +- missing or mismatched fixture data fails closed before inventory collection. + +## GREEN Plan + +Create the smallest deterministic v17 fixture and manifest that exercises the +critical migration surfaces. Prefer a generated fixture script or documented +regeneration command over hand-edited Git data. Keep all parsing and file I/O +in infrastructure or test support; domain migration nouns should only receive +validated facts. + +If the fixture must be generated from the v17 package, pin the generator +version and record it in the manifest. If a local v17 checkout is needed for +generation, make that an explicit input rather than an ambient dependency. + +## Verification + +```text +npx vitest run test/unit/scripts/v18-v17-golden-graph-fixtures.test.ts --reporter=verbose +npm run typecheck +npm run lint:semgrep +npm run lint:sludge +git diff --check HEAD +``` + +## Closeout Criteria + +- A canonical v17 graph-history fixture artifact exists. +- A deterministic manifest describes fixture refs, heads, chains, and visible + facts. +- Tests restore the fixture into an isolated repository and validate manifest + expectations. +- The next slice can collect real source inventory from the restored fixture. +- Docker wet-run work is either present as an optional harness or queued with + clear acceptance criteria. + +## SSJS Scorecard + +- Runtime-backed forms: green when restored facts become explicit fixture or + migration nouns before domain code sees them. +- Boundary validation: green when Git, filesystem, and JSON parsing stay in + adapters or test support. +- Behavior ownership: green when fixtures prove persisted source shape and + migration code still owns migration behavior. +- Message parsing: green; no behavior branches on command output text. +- Ambient time or entropy: green when fixture generation records deterministic + identities or injected values. +- Fake shape trust or cast-cosplay: green when tests validate restored Git + facts instead of trusting loose object bags. 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 05d8f29d..bdb5774a 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 @@ -41,17 +41,22 @@ V18 slices 36 through 45 completed the non-destructive foundation: - the CLI writes only optional manifest artifacts and refuses apply/write verbs; - genesis proof and divergence nouns exist for later migration gates. +- the next slice now inserts a v17 golden graph-history fixture corpus before + real source inventory collection, so migration work proves against restored + Git objects and refs instead of compact in-memory proof cases alone. Remaining migration-tool work is intentionally ordered as: -- slice 46: collect real source inventory; -- slice 47: lower dry-run planned operations; -- slice 48: write scratch migrated history; -- slice 49: gate scratch output with genesis equivalence; -- slice 50: design finalization safety. +- slice 46: create v17 golden graph-history fixtures and restore checks; +- slice 47: collect real source inventory from restored history; +- slice 48: lower dry-run planned operations; +- slice 49: write scratch migrated history; +- slice 50: gate scratch output with genesis equivalence; +- slice 51: design finalization safety. ## Starting points - `scripts/migrations/` +- `fixtures/v17/` - `src/domain/services/JoinReducer.ts` - `src/infrastructure/adapters/CborPatchJournalAdapter.ts` diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index e266beb4..314508cf 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -84,5 +84,7 @@ non-destructive: report nouns; - compact fixtures cover node, edge, content, removal, multi-writer, and 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; - real source inventory, operation lowering, scratch writing, scratch - equivalence, and finalization safety are planned as slices 46 through 50. + equivalence, and finalization safety are planned as slices 47 through 51. 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 f8155752..18cba9ff 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,8 +37,9 @@ 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 connect real -legacy replay and scratch migrated replay through slices 46 through 49. +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. ## Starting points From 66645279aba53258568a61d791fc1c84d83b2a3a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 09:15:37 -0700 Subject: [PATCH 7/8] Fix: Resolve PR review feedback --- .../GraphModelMigrationDryRunCli.ts | 23 +- .../migrations/GenesisDivergenceReport.ts | 25 ++- .../migrations/GenesisEquivalenceMismatch.ts | 40 ++-- .../GenesisEquivalenceReadingFact.ts | 24 +- .../GenesisEquivalenceProof.test.ts | 209 +++++++++++++++++- .../v18-graph-model-migration-dry-run.test.ts | 26 +++ 6 files changed, 303 insertions(+), 44 deletions(-) diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts index 6eb3afa2..d1f87f62 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts @@ -19,11 +19,18 @@ export class GraphModelMigrationDryRunCliArgumentError extends Error { } export class GraphModelMigrationDryRunCliArgs { - constructor( - readonly requestPath: string | null, - readonly manifestOutPath: string | null, - readonly helpRequested: boolean, - ) { + readonly requestPath: string | null; + readonly manifestOutPath: string | null; + readonly helpRequested: boolean; + + constructor(options: { + readonly requestPath: string | null; + readonly manifestOutPath: string | null; + readonly helpRequested: boolean; + }) { + this.requestPath = options.requestPath; + this.manifestOutPath = options.manifestOutPath; + this.helpRequested = options.helpRequested; Object.freeze(this); } } @@ -87,7 +94,11 @@ export function parseGraphModelMigrationDryRunCliArgs( throw new GraphModelMigrationDryRunCliArgumentError(`Unknown argument: ${arg ?? ''}`); } - return new GraphModelMigrationDryRunCliArgs(requestPath, manifestOutPath, helpRequested); + return new GraphModelMigrationDryRunCliArgs({ + requestPath, + manifestOutPath, + helpRequested, + }); } /** Runs the v18 graph-model migration dry-run command. */ diff --git a/src/domain/migrations/GenesisDivergenceReport.ts b/src/domain/migrations/GenesisDivergenceReport.ts index aab54ce2..28ebe475 100644 --- a/src/domain/migrations/GenesisDivergenceReport.ts +++ b/src/domain/migrations/GenesisDivergenceReport.ts @@ -1,7 +1,16 @@ import GenesisEquivalenceMismatch, { + GENESIS_EQUIVALENCE_CHANGED_FIELD, + GENESIS_EQUIVALENCE_EXTRA_FACT, + GENESIS_EQUIVALENCE_MISSING_FACT, type GenesisEquivalenceMismatchKind, } from './GenesisEquivalenceMismatch.ts'; -import type { GenesisEquivalenceReadingFactKind } from './GenesisEquivalenceReadingFact.ts'; +import { + GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT, + GENESIS_EQUIVALENCE_EDGE_FACT, + GENESIS_EQUIVALENCE_NODE_FACT, + GENESIS_EQUIVALENCE_PROPERTY_FACT, + type GenesisEquivalenceReadingFactKind, +} from './GenesisEquivalenceReadingFact.ts'; import WarpError from '../errors/WarpError.ts'; const VALUE_SUMMARY_LIMIT = 80; @@ -102,7 +111,11 @@ function requireMismatch(mismatch: GenesisEquivalenceMismatch): GenesisEquivalen /** Validates mismatch kind strings. */ function requireMismatchKind(kind: GenesisEquivalenceMismatchKind): GenesisEquivalenceMismatchKind { - if (kind !== 'missing' && kind !== 'extra' && kind !== 'changed') { + if ( + kind !== GENESIS_EQUIVALENCE_MISSING_FACT + && kind !== GENESIS_EQUIVALENCE_EXTRA_FACT + && kind !== GENESIS_EQUIVALENCE_CHANGED_FIELD + ) { throw new WarpError('GenesisDivergenceReport mismatchKind is unsupported', 'E_VALIDATION'); } return kind; @@ -111,10 +124,10 @@ function requireMismatchKind(kind: GenesisEquivalenceMismatchKind): GenesisEquiv /** Validates visible fact kind strings. */ function requireFactKind(kind: GenesisEquivalenceReadingFactKind): GenesisEquivalenceReadingFactKind { if ( - kind !== 'node' - && kind !== 'edge' - && kind !== 'property' - && kind !== 'content-attachment' + kind !== GENESIS_EQUIVALENCE_NODE_FACT + && kind !== GENESIS_EQUIVALENCE_EDGE_FACT + && kind !== GENESIS_EQUIVALENCE_PROPERTY_FACT + && kind !== GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT ) { throw new WarpError('GenesisDivergenceReport factKind is unsupported', 'E_VALIDATION'); } diff --git a/src/domain/migrations/GenesisEquivalenceMismatch.ts b/src/domain/migrations/GenesisEquivalenceMismatch.ts index 27779b54..a8ce9da8 100644 --- a/src/domain/migrations/GenesisEquivalenceMismatch.ts +++ b/src/domain/migrations/GenesisEquivalenceMismatch.ts @@ -1,15 +1,21 @@ import GenesisEquivalenceBoundary from './GenesisEquivalenceBoundary.ts'; -import type { GenesisEquivalenceReadingFactKind } from './GenesisEquivalenceReadingFact.ts'; +import { + GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT, + GENESIS_EQUIVALENCE_EDGE_FACT, + GENESIS_EQUIVALENCE_NODE_FACT, + GENESIS_EQUIVALENCE_PROPERTY_FACT, + type GenesisEquivalenceReadingFactKind, +} from './GenesisEquivalenceReadingFact.ts'; import WarpError from '../errors/WarpError.ts'; -const MISSING_FACT = 'missing'; -const EXTRA_FACT = 'extra'; -const CHANGED_FIELD = 'changed'; +export const GENESIS_EQUIVALENCE_MISSING_FACT = 'missing'; +export const GENESIS_EQUIVALENCE_EXTRA_FACT = 'extra'; +export const GENESIS_EQUIVALENCE_CHANGED_FIELD = 'changed'; export type GenesisEquivalenceMismatchKind = - | typeof MISSING_FACT - | typeof EXTRA_FACT - | typeof CHANGED_FIELD; + | typeof GENESIS_EQUIVALENCE_MISSING_FACT + | typeof GENESIS_EQUIVALENCE_EXTRA_FACT + | typeof GENESIS_EQUIVALENCE_CHANGED_FIELD; export type GenesisEquivalenceMismatchFields = { readonly kind: GenesisEquivalenceMismatchKind; @@ -62,7 +68,11 @@ function requireFields( /** Validates the mismatch kind. */ function requireKind(kind: GenesisEquivalenceMismatchKind): GenesisEquivalenceMismatchKind { - if (kind !== MISSING_FACT && kind !== EXTRA_FACT && kind !== CHANGED_FIELD) { + if ( + kind !== GENESIS_EQUIVALENCE_MISSING_FACT + && kind !== GENESIS_EQUIVALENCE_EXTRA_FACT + && kind !== GENESIS_EQUIVALENCE_CHANGED_FIELD + ) { throw new WarpError('GenesisEquivalenceMismatch kind is unsupported', 'E_VALIDATION'); } return kind; @@ -71,10 +81,10 @@ function requireKind(kind: GenesisEquivalenceMismatchKind): GenesisEquivalenceMi /** Validates the visible fact kind. */ function requireFactKind(kind: GenesisEquivalenceReadingFactKind): GenesisEquivalenceReadingFactKind { if ( - kind !== 'node' - && kind !== 'edge' - && kind !== 'property' - && kind !== 'content-attachment' + kind !== GENESIS_EQUIVALENCE_NODE_FACT + && kind !== GENESIS_EQUIVALENCE_EDGE_FACT + && kind !== GENESIS_EQUIVALENCE_PROPERTY_FACT + && kind !== GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT ) { throw new WarpError('GenesisEquivalenceMismatch factKind is unsupported', 'E_VALIDATION'); } @@ -124,7 +134,7 @@ function requireMissingValues( legacyValue: string | null, migratedValue: string | null, ): void { - if (kind !== MISSING_FACT) { + if (kind !== GENESIS_EQUIVALENCE_MISSING_FACT) { return; } if (legacyValue !== null && migratedValue === null) { @@ -139,7 +149,7 @@ function requireExtraValues( legacyValue: string | null, migratedValue: string | null, ): void { - if (kind !== EXTRA_FACT) { + if (kind !== GENESIS_EQUIVALENCE_EXTRA_FACT) { return; } if (legacyValue === null && migratedValue !== null) { @@ -154,7 +164,7 @@ function requireChangedValues( legacyValue: string | null, migratedValue: string | null, ): void { - if (kind !== CHANGED_FIELD) { + if (kind !== GENESIS_EQUIVALENCE_CHANGED_FIELD) { return; } if (legacyValue !== null && migratedValue !== null) { diff --git a/src/domain/migrations/GenesisEquivalenceReadingFact.ts b/src/domain/migrations/GenesisEquivalenceReadingFact.ts index 51cd73d7..d2113cd2 100644 --- a/src/domain/migrations/GenesisEquivalenceReadingFact.ts +++ b/src/domain/migrations/GenesisEquivalenceReadingFact.ts @@ -1,16 +1,16 @@ import GenesisEquivalenceBoundary from './GenesisEquivalenceBoundary.ts'; import WarpError from '../errors/WarpError.ts'; -const NODE_FACT = 'node'; -const EDGE_FACT = 'edge'; -const PROPERTY_FACT = 'property'; -const CONTENT_ATTACHMENT_FACT = 'content-attachment'; +export const GENESIS_EQUIVALENCE_NODE_FACT = 'node'; +export const GENESIS_EQUIVALENCE_EDGE_FACT = 'edge'; +export const GENESIS_EQUIVALENCE_PROPERTY_FACT = 'property'; +export const GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT = 'content-attachment'; export type GenesisEquivalenceReadingFactKind = - | typeof NODE_FACT - | typeof EDGE_FACT - | typeof PROPERTY_FACT - | typeof CONTENT_ATTACHMENT_FACT; + | typeof GENESIS_EQUIVALENCE_NODE_FACT + | typeof GENESIS_EQUIVALENCE_EDGE_FACT + | typeof GENESIS_EQUIVALENCE_PROPERTY_FACT + | typeof GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT; export type GenesisEquivalenceReadingFactFields = { readonly kind: GenesisEquivalenceReadingFactKind; @@ -57,10 +57,10 @@ function requireFields( /** Validates the visible fact kind. */ function requireKind(kind: GenesisEquivalenceReadingFactKind): GenesisEquivalenceReadingFactKind { if ( - kind !== NODE_FACT - && kind !== EDGE_FACT - && kind !== PROPERTY_FACT - && kind !== CONTENT_ATTACHMENT_FACT + kind !== GENESIS_EQUIVALENCE_NODE_FACT + && kind !== GENESIS_EQUIVALENCE_EDGE_FACT + && kind !== GENESIS_EQUIVALENCE_PROPERTY_FACT + && kind !== GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT ) { throw new WarpError('GenesisEquivalenceReadingFact kind is unsupported', 'E_VALIDATION'); } diff --git a/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts b/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts index 3b6d104a..8b44571a 100644 --- a/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts +++ b/test/unit/domain/migrations/GenesisEquivalenceProof.test.ts @@ -12,6 +12,8 @@ import GenesisEquivalenceProofFailure from '../../../../src/domain/migrations/GenesisEquivalenceProofFailure.ts'; import GenesisEquivalenceProofSuccess from '../../../../src/domain/migrations/GenesisEquivalenceProofSuccess.ts'; +import GenesisEquivalenceProofSummary + from '../../../../src/domain/migrations/GenesisEquivalenceProofSummary.ts'; import GenesisEquivalenceReading from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; import GenesisEquivalenceReadingFact, { @@ -130,6 +132,162 @@ describe('GenesisEquivalenceProof', () => { boundary: null, })).toThrow(/missing mismatches/); }); + + it('rejects invalid proof noun fields before comparison', () => { + expect(boundary().toKey()).toBe(['writer:a', 'patch:a:0', '0'].join('\0')); + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceBoundary(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceBoundary({ + writerId: '', + patchId: 'patch:a:0', + operationIndex: 0, + })).toThrow(/writerId/); + expect(() => new GenesisEquivalenceBoundary({ + writerId: 'writer:a', + patchId: 'patch:a:0', + operationIndex: -1, + })).toThrow(/operationIndex/); + + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceComparisonBasis(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceComparisonBasis({ + // @ts-expect-error exercising runtime validation + legacyBasis: 'legacy', + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:source', + basisId: 'basis:migrated', + }), + })).toThrow(/legacyBasis/); + + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceReadingFact(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceReadingFact({ + // @ts-expect-error exercising runtime validation + kind: 'unsupported', + factKey: 'node:a', + fieldPath: 'visibility', + value: 'visible', + boundary: null, + })).toThrow(/kind/); + expect(() => new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: '', + fieldPath: 'visibility', + value: 'visible', + boundary: null, + })).toThrow(/factKey/); + expect(() => new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: 'node:a', + fieldPath: 'visibility', + // @ts-expect-error exercising runtime validation + value: 1, + boundary: null, + })).toThrow(/value/); + expect(() => new GenesisEquivalenceReadingFact({ + kind: 'node', + factKey: 'node:a', + fieldPath: 'visibility', + value: 'visible', + // @ts-expect-error exercising runtime validation + boundary: 'boundary', + })).toThrow(/boundary/); + + expect(() => new GenesisEquivalenceReading({ + readingId: '', + facts: [], + })).toThrow(/readingId/); + expect(() => new GenesisEquivalenceReading({ + readingId: 'legacy', + // @ts-expect-error exercising runtime validation + facts: null, + })).toThrow(/facts/); + expect(() => new GenesisEquivalenceReading({ + readingId: 'legacy', + // @ts-expect-error exercising runtime validation + facts: ['fact'], + })).toThrow(/facts/); + + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceProofSummary(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceProofSummary({ + // @ts-expect-error exercising runtime validation + basis: 'basis', + legacyFactCount: 1, + migratedFactCount: 1, + mismatchCount: 0, + })).toThrow(/basis/); + expect(() => new GenesisEquivalenceProofSummary({ + basis: basis(), + legacyFactCount: -1, + migratedFactCount: 1, + mismatchCount: 0, + })).toThrow(/legacyFactCount/); + }); + + it('rejects inconsistent proof result envelopes', () => { + const matchingBasis = basis(); + const matchingSummary = summary(matchingBasis, 1); + + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceProofSuccess(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceProofSuccess({ + // @ts-expect-error exercising runtime validation + basis: 'basis', + summary: summary(matchingBasis, 0), + })).toThrow(/basis/); + expect(() => new GenesisEquivalenceProofSuccess({ + basis: matchingBasis, + // @ts-expect-error exercising runtime validation + summary: 'summary', + })).toThrow(/summary/); + expect(() => new GenesisEquivalenceProofSuccess({ + basis: matchingBasis, + summary: matchingSummary, + })).toThrow(/zero mismatches/); + + expect(() => { + // @ts-expect-error exercising runtime validation + new GenesisEquivalenceProofFailure(null); + }).toThrow(/fields/); + expect(() => new GenesisEquivalenceProofFailure({ + basis: matchingBasis, + summary: matchingSummary, + mismatches: [], + })).toThrow(/contain mismatches/); + expect(() => new GenesisEquivalenceProofFailure({ + basis: matchingBasis, + summary: matchingSummary, + // @ts-expect-error exercising runtime validation + mismatches: null, + })).toThrow(/mismatches/); + expect(() => new GenesisEquivalenceProofFailure({ + basis: matchingBasis, + summary: matchingSummary, + // @ts-expect-error exercising runtime validation + mismatches: ['mismatch'], + })).toThrow(/mismatches/); + expect(() => new GenesisEquivalenceProofFailure({ + basis: matchingBasis, + summary: summary(matchingBasis, 2), + mismatches: [missingMismatch()], + })).toThrow(/count/); + expect(() => new GenesisEquivalenceProofFailure({ + basis: matchingBasis, + summary: summary(otherBasis(), 1), + mismatches: [missingMismatch()], + })).toThrow(/basis/); + }); }); function proof(): GenesisEquivalenceProof { @@ -149,6 +307,43 @@ function basis(): GenesisEquivalenceComparisonBasis { }); } +function otherBasis(): GenesisEquivalenceComparisonBasis { + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: new GraphModelMigrationBasis({ + graphId: 'graph:other', + basisId: 'basis:legacy', + }), + migratedBasis: new GraphModelMigrationBasis({ + graphId: 'graph:other', + basisId: 'basis:migrated', + }), + }); +} + +function summary( + summaryBasis: GenesisEquivalenceComparisonBasis, + mismatchCount: number, +): GenesisEquivalenceProofSummary { + return new GenesisEquivalenceProofSummary({ + basis: summaryBasis, + legacyFactCount: 1, + migratedFactCount: 1, + mismatchCount, + }); +} + +function missingMismatch(): GenesisEquivalenceMismatch { + return new GenesisEquivalenceMismatch({ + kind: 'missing', + factKind: 'node', + factKey: 'node:a', + fieldPath: 'visibility', + legacyValue: 'visible', + migratedValue: null, + boundary: null, + }); +} + function reading( readingId: string, facts: readonly GenesisEquivalenceReadingFact[], @@ -160,6 +355,14 @@ function nodeFact(factKey: string, value: string): GenesisEquivalenceReadingFact return fact('node', factKey, 'visibility', value); } +function boundary(): GenesisEquivalenceBoundary { + return new GenesisEquivalenceBoundary({ + writerId: 'writer:a', + patchId: 'patch:a:0', + operationIndex: 0, + }); +} + function fact( kind: GenesisEquivalenceReadingFactKind, factKey: string, @@ -171,10 +374,6 @@ function fact( factKey, fieldPath, value, - boundary: new GenesisEquivalenceBoundary({ - writerId: 'writer:a', - patchId: 'patch:a:0', - operationIndex: 0, - }), + boundary: boundary(), }); } diff --git a/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts b/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts index 7fd88ed7..8be85fff 100644 --- a/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts +++ b/test/unit/scripts/v18-graph-model-migration-dry-run.test.ts @@ -9,6 +9,19 @@ import { } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationDryRunCli.ts'; describe('v18 graph-model migration dry-run CLI', () => { + it('prints usage when help is requested', async () => { + const result = await runGraphModelMigrationDryRunCli(['--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Usage:'); + expect(result.stdout).toContain('--request '); + expect(result.stderr).toBe(''); + }); + + it('requires request input when help is not requested', async () => { + await expect(runGraphModelMigrationDryRunCli([])).rejects.toThrow('--request is required'); + }); + it('emits a deterministic manifest for a complete dry-run request', async () => { const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-dry-run-')); const requestPath = join(directory, 'request.json'); @@ -41,6 +54,19 @@ describe('v18 graph-model migration dry-run CLI', () => { expect(firstManifest).toContain('"targetAttachmentKey": "content-attachment:node:a\\u0000_content"'); }); + it('emits the manifest to stdout when no manifest path is provided', async () => { + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-dry-run-')); + const requestPath = join(directory, 'request.json'); + await writeFile(requestPath, completeRequestJson(), 'utf8'); + + const result = await runGraphModelMigrationDryRunCli(['--request', requestPath]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('manifest: stdout'); + expect(result.stdout).toContain('"basisId": "basis:source:v18-dry-run"'); + expect(result.stderr).toBe(''); + }); + it('fails closed and writes no manifest when source inventory is incomplete', async () => { const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-dry-run-')); const requestPath = join(directory, 'request.json'); From e78aea15901b14dcb50a121f3cedb594cd04e53d Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 24 May 2026 09:24:03 -0700 Subject: [PATCH 8/8] Docs: Clarify v18 slice cycle ordering --- .../v18-replan-with-migration-evidence.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md b/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md index 12956d57..a0e353c3 100644 --- a/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md +++ b/docs/design/0193-v18-replan-with-migration-evidence/v18-replan-with-migration-evidence.md @@ -119,8 +119,10 @@ git diff --check HEAD - Genesis equivalence is credible as a runtime-backed vocabulary and compact fixture proof. It is not yet a real scratch-history replay gate. - The next five implementation slice docs now exist as cycles 0199, then 0194 - through 0197. Finalization safety remains planned as cycle 0198 after the - restored-fixture equivalence gate. + through 0197. That order is intentional: cycle ids reflect when design + records were created, while `bearing_task` numbers reflect execution order + (`0199` is task 46, `0194` is task 47). Finalization safety remains planned + as cycle 0198 after the restored-fixture equivalence gate. - The Continuum posture remains unchanged: git-warp is a sibling Continuum participant exchanging witnessed causal history, not a subordinate runtime.