diff --git a/CHANGELOG.md b/CHANGELOG.md index e0748ea8f..5db7753bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- V18 release-candidate evidence now records guarded CLI finalization, + zero-mismatch wet-run proof, runtime-boundary Continuum contract conformance, + `warp-ttd` generated-family smoke evidence, the retired raw-boundary + ratchet, remaining public-tag gates, and residual risks. - V18 migration evidence now includes a deterministic v17 golden graph-history fixture bundle, runtime-backed fixture manifest nouns, a manifest JSON adapter, and a restore validator that checks real `refs/warp/*` writer heads @@ -184,6 +188,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- V18 release-candidate review follow-up now rejects empty production-runtime + replay repository paths, rejects empty scratch replay target fields with + structured invalid-target evidence, and reports confirmation JSON parse + failures with confirmation-specific context. +- V18 release-candidate review follow-up now preserves structured + invalid-target replay evidence for malformed edge-property scratch owners, + classifies v17 fixture property owners from declared edge facts, and splits + migration script collaborators under the repository file-size cap. +- V18 release-candidate review follow-up now binds reviewed finalization JSON + to runtime witness evidence, normalizes semantic finalization request + validation as adapter errors, adds edge-property coverage to the canonical + wet-run proof, and narrows production-runtime replay wording to the proven + scratch-operation replay path. - V18 graph-model migration review follow-up now preserves parent Git command environment variables under deterministic identities, validates scratch and finalization boundaries before Git work, rejects malformed scratch payload diff --git a/docs/BEARING.md b/docs/BEARING.md index 03fa7455c..c479c881d 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -39,19 +39,18 @@ of handwritten adapter folklore. Current branch state at this boundary: -- Planning branch: `v18-bearing-30-slice-plan` +- Current branch: `v18-continuum-slices-66-75` - Base branch: `main` -- Current `origin/main`: `91130391` -- Latest merged PR: #104, v18 migration command, scratch history, - finalization safety, command reports, and public-release blockers +- Current `origin/main`: `d379eb81` +- Latest merged PR: #105, v18 release runway planning - Latest released package line: `17.0.1` - Latest completed implementation cycle: - `0213-v18-replan-after-command-cli` -- Current work: realistic v18 release planning for the next thirty slices, - beginning with production-runtime scratch replay and ending with a v18 - public-release go/no-go. -- Cleanup checkpoint: local `main` has been fast-forwarded to `origin/main` - after PR #104 merged; this planning branch starts from that merge commit. + `0244-v18-backlog-reconciliation` +- Current work: backlog reconciliation after the v18 release-candidate + evidence packet, with the next goalpost narrowed to public release-prep + gates and residual-risk decisions. +- Cleanup checkpoint: this branch is ahead of `origin/main` with the v18 + release-candidate evidence and backlog-reconciliation work. The current v18 graph-model posture is: @@ -132,7 +131,8 @@ The current v18 graph-model posture is: - Finalization now also requires post-migration runtime conformance evidence tied to the exact scratch ref and scratch head. - The remaining raw content/property compatibility files are now listed in an - executable closeout audit. + executable closeout audit, and the next release slices are scoped to retiring + one boundary plus ratcheting that audit. - Legacy fixture manifests can now be projected into genesis-equivalence readings with deterministic patch-boundary evidence. - Scratch migration operation commits can now be projected into @@ -150,18 +150,17 @@ The current v18 graph-model posture is: equivalent. - The migration command now has deterministic operator report formatting for planning, scratch, equivalence, and finalization evidence. -- A non-finalizing migration command CLI wrapper now writes scratch history, - builds command-owned readings, emits the command report, and refuses live-ref - finalization flags. -- V18 public-release blockers are now explicit: production-runtime scratch - replay, live finalization CLI design, wet-run fixture harnessing, generated - Continuum contract tie-back, and operator release notes. - -That is useful progress, not a finish line. The repo still needs property -projection beyond replay/serialization boundaries, production-runtime replay -over migrated scratch history, wet-run migration proof from restored v17 -fixtures, generated Continuum contract evidence, and release documentation -before v18 can make public compatibility claims. +- A migration command CLI wrapper now writes scratch history, builds + command-owned readings, emits the command report, and permits live-ref + finalization only through a reviewed JSON request that matches observed + runtime evidence. +- V18 release-candidate blockers are now explicit: full release-prep gates, + GitHub CI, package/versioning work, and residual raw content/property risk. + +That is useful progress, not a finish line. The repo now has migration safety, +wet-run proof, guarded finalization, generated Continuum contract tie-back, +and a release-candidate packet. Public v18 still needs full release-prep +gates, CI, package/tag work, and explicit residual-risk review. ## What Just Shipped @@ -372,17 +371,22 @@ and wet-run fixture harnessing. - Temporal replay still extracts node snapshots from the raw legacy property map because historical replay tests carry pre-codec inline fixture classes that are not `PropValue`-honest enough for `LegacyPropertyValue`. -- The v18 migration tool can now write scratch history and derive scratch - operation readings, but it does not yet open scratch output through the full - production graph runtime. -- Legacy readings from the v17 golden fixture are manifest-derived, not yet - produced by replaying the restored v17 graph through the public read path. -- The command wrapper writes scratch history and reports evidence, but live-ref - finalization from the CLI is intentionally refused until confirmation and - operator-report semantics are designed. -- Continuum/WARP Optic release claims still need generated contract evidence - tied to Wesley/Continuum artifacts, not just handwritten compatibility - doctrine. +- The v18 migration tool now replays migrated scratch operations through the + production graph runtime write/materialization path during wet runs, but + public release still needs the full release-prep gate set on the eventual + release branch. +- Legacy readings from the v17 golden fixture now have restored public-read + construction, but broader non-fixture replay coverage remains future work. +- The command wrapper can finalize through a reviewed JSON request, but the + public tag still needs CI and release-preflight validation. +- Continuum/WARP Optic release claims now have generated contract evidence for + runtime-boundary graph-model migration, but native Continuum witnesshood is + still not claimed. +- The v17 backlog lane is no longer an active release plan, but its remaining + notes still need item-level archive, rehome, or explicit pull decisions. +- End-to-end graph streaming reads and writes are now named as a `v20.0.0` + goal. V18 must keep public docs honest and avoid claiming full graph + streaming. ## Where We Are Heading @@ -390,51 +394,142 @@ The remaining runway is no longer a five-slice tail. The next realistic plan is thirty slices. Some slices may collapse when evidence is in hand, but the release plan should assume the proof work is hard until it is proven easy. -The first ten slices convert operation-derived confidence into -production-runtime confidence. The next five make finalization safe enough to -expose through the CLI. The next five tie the release claim to generated -Continuum/WARP Optic artifacts. The next five reduce the remaining -content/property compatibility debt that still blocks clean graph-model -language. The final five are release-candidate hardening and go/no-go work. +The first thirty slices converted operation-derived confidence into +production-runtime confidence, restored the v17 golden fixture, drove the +canonical wet-run report to zero public-read mismatches, guarded live +finalization, tied graph-model migration to generated runtime-boundary +contracts, and added the first `warp-ttd` generated-family smoke. The next +goalpost is release-prep hardening rather than new feature expansion. + +Slice 96 reconciled the backlog ledger with that evidence. The next practical +goalpost is a release-prep branch that runs the full gate set, freezes package +and release notes, and decides whether remaining raw content/property storage +retirement blocks the public tag or ships as explicit residual risk. ### Next Thirty-Slice Checklist -- [ ] 66. Design production-runtime scratch replay conformance. -- [ ] 67. Add runtime scratch replay request and result nouns. -- [ ] 68. Implement the production-runtime scratch replay provider. -- [ ] 69. Add restored-v17 public-read legacy reading construction. -- [ ] 70. Add scratch public-read reading construction. -- [ ] 71. Add the v17 fixture wet-run migration harness. -- [ ] 72. Capture deterministic wet-run operator reports. -- [ ] 73. Add wet-run failure fixtures for divergence and malformed history. -- [ ] 74. Add pre-finalization drift checks to the wet-run harness. -- [ ] 75. Replan with production-runtime replay evidence in hand. -- [ ] 76. Design live finalization CLI confirmation and reporting. -- [ ] 77. Add finalization request JSON and confirmation adapters. -- [ ] 78. Add finalization report sections and archive evidence output. -- [ ] 79. Enable guarded CLI finalization behind explicit confirmation. -- [ ] 80. Add live-ref drift and existing-archive finalization tests. -- [ ] 81. Inventory current Wesley/Continuum generated graph contracts. -- [ ] 82. Add generated Continuum contract fixture ingestion. -- [ ] 83. Add graph-model conformance checks against generated contracts. -- [ ] 84. Add a `warp-ttd` contract smoke over generated-family facts. -- [ ] 85. Replan with generated contract evidence in hand. -- [ ] 86. Design content attachment storage cutover from `_content*`. -- [ ] 87. Implement typed content attachment write storage behind a gate. -- [ ] 88. Add typed content attachment readback from the new storage path. -- [ ] 89. Add legacy `_content*` to attachment migration mapping. -- [ ] 90. Prove content equivalence across legacy and attachment storage. -- [ ] 91. Design the raw property-boundary retirement plan. -- [ ] 92. Replace replay and serialization raw property reads where safe. -- [ ] 93. Route reducer/op-strategy property writes through intent paths. -- [ ] 94. Tighten the closeout audit to forbid one more raw-boundary class. -- [ ] 95. Cut v18 release-candidate docs, changelog, and go/no-go evidence. +- [x] 66. Design production-runtime scratch replay conformance: + [0214](design/0214-v18-production-runtime-scratch-replay-conformance/v18-production-runtime-scratch-replay-conformance.md). +- [x] 67. Add runtime scratch replay request and result nouns: + [0215](design/0215-v18-runtime-scratch-replay-nouns/v18-runtime-scratch-replay-nouns.md). +- [x] 68. Implement the production-runtime scratch replay provider: + [0216](design/0216-v18-production-runtime-scratch-replay-provider/v18-production-runtime-scratch-replay-provider.md). +- [x] 69. Add restored-v17 public-read legacy reading construction: + [0217](design/0217-v18-v17-public-read-legacy-reading/v18-v17-public-read-legacy-reading.md). +- [x] 70. Add scratch public-read reading construction: + [0218](design/0218-v18-scratch-public-read-reading/v18-scratch-public-read-reading.md). +- [x] 71. Add the v17 fixture wet-run migration harness: + [0219](design/0219-v18-v17-fixture-wet-run-harness/v18-v17-fixture-wet-run-harness.md). +- [x] 72. Capture deterministic wet-run operator reports: + [0220](design/0220-v18-wet-run-operator-report/v18-wet-run-operator-report.md). +- [x] 73. Add wet-run failure fixtures for divergence and malformed history: + [0221](design/0221-v18-wet-run-failure-fixtures/v18-wet-run-failure-fixtures.md). +- [x] 74. Add pre-finalization drift checks to the wet-run harness: + [0222](design/0222-v18-wet-run-drift-checks/v18-wet-run-drift-checks.md). +- [x] 75. Replan with production-runtime replay evidence in hand: + [0223](design/0223-v18-production-replay-drift-checkup/v18-production-replay-drift-checkup.md). +- [x] 76. Classify the five canonical wet-run public-read mismatches: + [0224](design/0224-v18-wet-run-mismatch-classification/v18-wet-run-mismatch-classification.md). +- [x] 77. Align fixture property values with public-read migration semantics: + [0225](design/0225-v18-fixture-property-equivalence-values/v18-fixture-property-equivalence-values.md). +- [x] 78. Align fixture content attachment evidence with runtime content OIDs: + [0226](design/0226-v18-fixture-content-runtime-oids/v18-fixture-content-runtime-oids.md). +- [x] 79. Add edge-endpoint node coverage or document the fixture edge model: + [0227](design/0227-v18-fixture-edge-endpoint-coverage/v18-fixture-edge-endpoint-coverage.md). +- [x] 80. Represent removed-node and multi-writer facts in migrated readings: + [0228](design/0228-v18-fixture-lifecycle-and-writer-coverage/v18-fixture-lifecycle-and-writer-coverage.md). +- [x] 81. Drive the canonical wet-run mismatch count to zero: + [0229](design/0229-v18-zero-mismatch-wet-run-proof/v18-zero-mismatch-wet-run-proof.md). +- [x] 82. Replan finalization with zero-mismatch wet-run evidence: + [0230](design/0230-v18-finalization-replan-after-zero-mismatch/v18-finalization-replan-after-zero-mismatch.md). +- [x] 83. Design live finalization CLI confirmation and reporting: + [0231](design/0231-v18-live-finalization-cli-confirmation/v18-live-finalization-cli-confirmation.md). +- [x] 84. Add finalization request JSON and confirmation adapters: + [0232](design/0232-v18-finalization-request-json-adapters/v18-finalization-request-json-adapters.md). +- [x] 85. Add finalization report sections and archive evidence output: + [0233](design/0233-v18-finalization-report-archive-evidence/v18-finalization-report-archive-evidence.md). +- [x] 86. Enable guarded CLI finalization behind explicit confirmation: + [0234](design/0234-v18-guarded-cli-finalization/v18-guarded-cli-finalization.md). +- [x] 87. Add live-ref drift and existing-archive finalization tests: + [0235](design/0235-v18-finalization-drift-and-archive-tests/v18-finalization-drift-and-archive-tests.md). +- [x] 88. Inventory current Wesley/Continuum generated-graph contracts: + [0236](design/0236-v18-generated-contract-inventory/v18-generated-contract-inventory.md). +- [x] 89. Add generated Continuum contract fixture ingestion: + [0237](design/0237-v18-runtime-boundary-fixture-ingestion/v18-runtime-boundary-fixture-ingestion.md). +- [x] 90. Add graph-model conformance checks against generated contracts: + [0238](design/0238-v18-graph-model-contract-conformance/v18-graph-model-contract-conformance.md). +- [x] 91. Add a `warp-ttd` contract smoke over generated-family facts: + [0239](design/0239-v18-warp-ttd-generated-family-smoke/v18-warp-ttd-generated-family-smoke.md). +- [x] 92. Replan with generated contract evidence in hand: + [0240](design/0240-v18-generated-contract-evidence-replan/v18-generated-contract-evidence-replan.md). +- [x] 93. Reduce legacy content/property raw-boundary debt by one class: + [0241](design/0241-v18-coordinate-fact-export-raw-boundary-retirement/v18-coordinate-fact-export-raw-boundary-retirement.md). +- [x] 94. Tighten the closeout audit to forbid the retired raw-boundary class: + [0242](design/0242-v18-content-property-retirement-ratchet/v18-content-property-retirement-ratchet.md). +- [x] 95. Cut v18 release-candidate docs, changelog, and go/no-go evidence: + [0243](design/0243-v18-release-candidate-evidence/v18-release-candidate-evidence.md). + +### Slice 82 Evidence + +- Production-runtime scratch replay is green through the shared replay core. +- Restored-v17 and scratch public-read builders both exist and are tested. +- The wet-run harness restores the canonical v17 fixture, writes six scratch + operations, replays all six through the production runtime, formats a + deterministic report, and records a passed source-ref drift check. +- The canonical public-read equivalence gate now observes eight legacy facts, + eight migrated facts, zero mismatches, and explicit boundary evidence, + including edge-property coverage. +- A dedicated zero-mismatch regression proves the command summary and wet-run + report stay free of public-read divergence. +- Live finalization remains intentionally paused until explicit confirmation + and operator reporting are designed and wired. +- The roadmap has pivoted from wet-run equivalence closure to guarded live + finalization, then generated Continuum/WARP Optic contract evidence. +- The live finalization CLI design now requires a JSON confirmation artifact + that binds live head, scratch head, archive ref, equivalence, and runtime + replay evidence before any live ref may move. +- Finalization request and confirmation JSON now parse into runtime-backed + finalization safety nouns with unknown-field rejection. +- Command finalization reports now include archive evidence for completed and + blocked finalization attempts. +- CLI finalization is now enabled behind a reviewed JSON request; the command + blocks finalization if the artifact differs from observed evidence. +- Generated runtime-boundary contract evidence now has a fixture ingestion + path, graph-model conformance checks, and a `warp-ttd` generated-family + smoke that preserves translated-substrate honesty. +- CLI finalization tests now prove stale live refs and pre-existing archive refs + return blocked reports and non-zero exit codes. +- Generated contract inventory evidence now names local Continuum schemas, + Wesley contract-design sources, and `warp-ttd` generated-family intake files. +- A runtime-boundary-generated fixture is now admitted through the Continuum + artifact JSON adapter with `continuum-fixture` and `warp-ttd` targets. +- Graph-model contract conformance now requires the runtime-boundary family, + schema, generated authority, `continuum-fixture` target, `warp-ttd` target, + and full v17 visible fact-family coverage. +- The first `warp-ttd` generated-family smoke now converts passed conformance + into a `PRESENT` translated-substrate fact and failed conformance into an + `OBSTRUCTED` fact with failed check names. +- Evidence-backed replanning now narrows the release runway to one raw + content/property boundary retirement, closeout audit tightening, and a v18 + release-candidate packet. +- `CoordinateFactExport` no longer owns raw content operation spelling; the + spelling now lives behind transfer operation constants in the already-audited + transfer boundary. +- The closeout audit now has a retired-boundary ratchet that fails if + `CoordinateFactExport.ts` regains raw content/property spelling. +- The v18 release-candidate evidence packet now names candidate scope, + inspectable evidence, go/no-go gates, public-tag gates, and residual risks. +- The backlog ledger now reflects the release-candidate evidence: v18 + implementation blockers are distinguished from public-release gates, v17 is + marked as shipped residual work, and end-to-end graph streaming reads and + writes have an explicit `v20.0.0` backlog home. ### User Stories - As a migration operator, I can restore a v17 graph fixture, write migrated - scratch history, open that scratch history through the normal production - runtime, and receive deterministic proof before any live ref can move. + scratch history, replay its operations through the normal production runtime + write/materialization path, and receive deterministic proof before any live + ref can move. - As a release reviewer, I can inspect one wet-run report that includes source basis, scratch basis, archive target, equivalence result, runtime replay result, drift checks, and finalization eligibility. @@ -453,9 +548,9 @@ language. The final five are release-candidate hardening and go/no-go work. ### Acceptance Criteria -- Production-runtime conformance opens migrated scratch history through the - same graph-runtime path normal users rely on, not only operation-history - readback. +- Production-runtime conformance replays migrated scratch operations through + the same graph-runtime write/materialization path normal users rely on, not + only operation-history readback. - Wet-run migration restores the v17 golden fixture into an isolated Git repository, writes scratch history, builds legacy and scratch readings, runs equivalence, runs production-runtime conformance, and leaves live @@ -630,33 +725,35 @@ and concrete checks live in `docs/invariants/`. [0212](design/0212-v18-public-release-blockers/v18-public-release-blockers.md). - [x] 65. Replan after the command CLI: [0213](design/0213-v18-replan-after-command-cli/v18-replan-after-command-cli.md). -- [ ] 66. Design production-runtime scratch replay conformance. -- [ ] 67. Add runtime scratch replay request and result nouns. -- [ ] 68. Implement the production-runtime scratch replay provider. -- [ ] 69. Add restored-v17 public-read legacy reading construction. -- [ ] 70. Add scratch public-read reading construction. -- [ ] 71. Add the v17 fixture wet-run migration harness. -- [ ] 72. Capture deterministic wet-run operator reports. -- [ ] 73. Add wet-run failure fixtures for divergence and malformed history. -- [ ] 74. Add pre-finalization drift checks to the wet-run harness. -- [ ] 75. Replan with production-runtime replay evidence in hand. -- [ ] 76. Design live finalization CLI confirmation and reporting. -- [ ] 77. Add finalization request JSON and confirmation adapters. -- [ ] 78. Add finalization report sections and archive evidence output. -- [ ] 79. Enable guarded CLI finalization behind explicit confirmation. -- [ ] 80. Add live-ref drift and existing-archive finalization tests. -- [ ] 81. Inventory current Wesley/Continuum generated graph contracts. -- [ ] 82. Add generated Continuum contract fixture ingestion. -- [ ] 83. Add graph-model conformance checks against generated contracts. -- [ ] 84. Add a `warp-ttd` contract smoke over generated-family facts. -- [ ] 85. Replan with generated contract evidence in hand. -- [ ] 86. Design content attachment storage cutover from `_content*`. -- [ ] 87. Implement typed content attachment write storage behind a gate. -- [ ] 88. Add typed content attachment readback from the new storage path. -- [ ] 89. Add legacy `_content*` to attachment migration mapping. -- [ ] 90. Prove content equivalence across legacy and attachment storage. -- [ ] 91. Design the raw property-boundary retirement plan. -- [ ] 92. Replace replay and serialization raw property reads where safe. -- [ ] 93. Route reducer/op-strategy property writes through intent paths. -- [ ] 94. Tighten the closeout audit to forbid one more raw-boundary class. -- [ ] 95. Cut v18 release-candidate docs, changelog, and go/no-go evidence. +- [x] 66. Design production-runtime scratch replay conformance. +- [x] 67. Add runtime scratch replay request and result nouns. +- [x] 68. Implement the production-runtime scratch replay provider. +- [x] 69. Add restored-v17 public-read legacy reading construction. +- [x] 70. Add scratch public-read reading construction. +- [x] 71. Add the v17 fixture wet-run migration harness. +- [x] 72. Capture deterministic wet-run operator reports. +- [x] 73. Add wet-run failure fixtures for divergence and malformed history. +- [x] 74. Add pre-finalization drift checks to the wet-run harness. +- [x] 75. Replan with production-runtime replay evidence in hand. +- [x] 76. Classify the five canonical wet-run public-read mismatches. +- [x] 77. Align fixture property values with public-read migration semantics. +- [x] 78. Align fixture content attachment evidence with runtime content OIDs. +- [x] 79. Add edge-endpoint node coverage or document the fixture edge model. +- [x] 80. Represent removed-node and multi-writer facts in migrated readings. +- [x] 81. Drive the canonical wet-run mismatch count to zero. +- [x] 82. Replan finalization with zero-mismatch wet-run evidence. +- [x] 83. Design live finalization CLI confirmation and reporting. +- [x] 84. Add finalization request JSON and confirmation adapters. +- [x] 85. Add finalization report sections and archive evidence output. +- [x] 86. Enable guarded CLI finalization behind explicit confirmation. +- [x] 87. Add live-ref drift and existing-archive finalization tests. +- [x] 88. Inventory current Wesley/Continuum generated-graph contracts. +- [x] 89. Add generated Continuum contract fixture ingestion. +- [x] 90. Add graph-model conformance checks against generated contracts. +- [x] 91. Add a `warp-ttd` contract smoke over generated-family facts. +- [x] 92. Replan with generated contract evidence in hand. +- [x] 93. Reduce legacy content/property raw-boundary debt by one class. +- [x] 94. Tighten the closeout audit to forbid the retired raw-boundary class. +- [x] 95. Cut v18 release-candidate docs, changelog, and go/no-go evidence. +- [x] 96. Reconcile the v18 backlog after release-candidate evidence: + [0244](design/0244-v18-backlog-reconciliation/v18-backlog-reconciliation.md). diff --git a/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md index 23f735b13..f60e8b557 100644 --- a/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md +++ b/docs/design/0203-v18-content-property-closeout-audit/v18-content-property-closeout-audit.md @@ -66,7 +66,6 @@ The current audited files are: - `src/domain/graph/LegacyContentPropertyKeys.ts` - `src/domain/services/ContentAttachmentProjection.ts` -- `src/domain/services/CoordinateFactExport.ts` - `src/domain/services/ImmutableSnapshot.ts` - `src/domain/services/JoinReducer.ts` - `src/domain/services/KeyCodec.ts` @@ -91,14 +90,22 @@ The current audited files are: - `src/domain/types/ops/PropSet.ts` - `src/domain/types/ops/propHelpers.ts` +## Retired Raw Compatibility Files + +Retired files must stay retired: + +- `src/domain/services/CoordinateFactExport.ts` retired in slice 93 after + transfer operation spelling moved behind constants owned by + `src/domain/services/transfer/transferOps.ts`. + ## Classification These files fall into bounded categories: - Legacy content compatibility key ownership: `LegacyContentPropertyKeys`, `ContentAttachmentProjection`. -- Fact export and coordinate comparison over existing operation shapes: - `CoordinateFactExport`, `CoordinateComparison`. +- Coordinate comparison over existing operation shapes: + `CoordinateComparison`. - Runtime mutation and compatibility operation execution: `JoinReducer`, `OpStrategies`, `OpStrategy`, `PatchBuilder`, `PatchCommitter`, `StrandPatchService`, `transferOps`, and the op helper diff --git a/docs/design/0214-v18-production-runtime-scratch-replay-conformance/v18-production-runtime-scratch-replay-conformance.md b/docs/design/0214-v18-production-runtime-scratch-replay-conformance/v18-production-runtime-scratch-replay-conformance.md new file mode 100644 index 000000000..099aa9683 --- /dev/null +++ b/docs/design/0214-v18-production-runtime-scratch-replay-conformance/v18-production-runtime-scratch-replay-conformance.md @@ -0,0 +1,78 @@ +--- +cycle: 0214 +task_id: V18_production_runtime_scratch_replay_conformance +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 66 +--- + +# V18 Production-Runtime Scratch Replay Conformance + +## Hill + +Define the conformance boundary that proves migrated scratch operations can +drive the normal graph runtime write/materialization path instead of only +proving that scratch operation commits can be parsed. + +## Current Evidence + +The operation-history provider reads `refs/warp-migration-scratch/*` commits +and projects them into genesis-equivalence facts. That is useful, but it is not +the same as replaying migrated graph operations through the production +runtime. Public release claims need the latter and must not imply the scratch +ref format is itself a native production graph-history format. + +## Design + +Production-runtime scratch replay is a separate adapter-level proof: + +- read the scratch ref at the expected scratch head; +- decode the scratch migration operation stream deterministically; +- replay those operations into an isolated normal git-warp runtime using the + public graph write/read surface; +- materialize the resulting graph through the production runtime; +- emit structured pass/fail evidence that can feed the existing finalization + safety gate. + +This remains non-destructive. The provider may create a disposable runtime +repository for replay, but it must not update live `refs/warp/*` in the source +repository. + +## User Story + +As a migration operator, I can see proof that scratch migration output replays +through normal git-warp graph runtime behavior before I consider live-ref +promotion. + +## Acceptance Criteria + +- Operation-history readback remains available but is no longer the strongest + runtime claim. +- A new replay request names the graph id, scratch ref, expected scratch head, + and runtime writer id. +- A new replay result reports passed or failed status, witness text, replayed + operation count, and fatal notices. +- Failures are structured values for missing scratch refs, stale scratch heads, + unreadable scratch payloads, invalid operation targets, and runtime + materialization failures. +- No production-runtime replay step writes source live refs. + +## Test Plan + +- Unit-test request/result constructor guards. +- Add provider tests for a passing node-only scratch replay. +- Add provider tests for missing scratch ref, stale scratch head, malformed + scratch payload, and invalid edge/property targets. +- Add command or wet-run tests that prove finalization can consume the + production-runtime conformance result later. + +## Closeout + +This design splits "scratch history can be parsed" from "scratch operations can +drive git-warp's normal runtime." Slices 67 and 68 implement the request/result +nouns and provider. diff --git a/docs/design/0215-v18-runtime-scratch-replay-nouns/v18-runtime-scratch-replay-nouns.md b/docs/design/0215-v18-runtime-scratch-replay-nouns/v18-runtime-scratch-replay-nouns.md new file mode 100644 index 000000000..2de532336 --- /dev/null +++ b/docs/design/0215-v18-runtime-scratch-replay-nouns/v18-runtime-scratch-replay-nouns.md @@ -0,0 +1,40 @@ +--- +cycle: 0215 +task_id: V18_runtime_scratch_replay_nouns +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 67 +--- + +# V18 Runtime Scratch Replay Nouns + +## Hill + +Add runtime-backed request and result values for production-runtime replay of +scratch migration output. + +## Design + +The request names the graph id, runtime writer id, scratch ref, and expected +scratch head. The result records pass/fail status, witness text, replayed +operation count, and fatal migration notices. + +These nouns do not read Git and do not open runtime state. They are the pure +evidence boundary that the provider in slice 68 will fill. + +## Acceptance Criteria + +- Constructors validate runtime request and result envelopes. +- Passing replay results cannot carry fatal errors. +- Failing replay results must carry fatal errors. +- `allowsFinalization()` is true only for passed replay results. + +## Test Plan + +Unit coverage exercises happy path, failure path, malformed envelopes, invalid +scratch refs, invalid counts, and mismatched status/fatal-error combinations. diff --git a/docs/design/0216-v18-production-runtime-scratch-replay-provider/v18-production-runtime-scratch-replay-provider.md b/docs/design/0216-v18-production-runtime-scratch-replay-provider/v18-production-runtime-scratch-replay-provider.md new file mode 100644 index 000000000..34f5505cd --- /dev/null +++ b/docs/design/0216-v18-production-runtime-scratch-replay-provider/v18-production-runtime-scratch-replay-provider.md @@ -0,0 +1,46 @@ +--- +cycle: 0216 +task_id: V18_production_runtime_scratch_replay_provider +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 68 +--- + +# V18 Production-Runtime Scratch Replay Provider + +## Hill + +Implement adapter-level proof that scratch migration operations can be replayed +through the normal git-warp graph runtime and mapped into finalization +conformance evidence. + +## Design + +The provider reads scratch migration operation records from the source +repository, verifies the scratch ref still points at the expected head, opens an +isolated normal git-warp runtime, applies the scratch operations through the +runtime patch surface, materializes the runtime product, and returns runtime +replay evidence. + +The provider maps replay evidence into the existing finalization conformance +result type so command finalization can later switch from operation-history +readback to production-runtime replay without changing the safety gate. + +## Acceptance Criteria + +- Passing replay reports the production-runtime witness and operation count. +- Stale scratch heads fail before replay. +- Malformed operation targets fail closed with structured fatal notices. +- Source live refs are not updated. + +## Test Plan + +Unit tests write scratch history in a temporary Git repository, replay it +through an isolated runtime repository, assert passing runtime replay, assert +finalization-conformance mapping, and assert closed failures for scratch-head +drift and invalid targets. diff --git a/docs/design/0217-v18-v17-public-read-legacy-reading/v18-v17-public-read-legacy-reading.md b/docs/design/0217-v18-v17-public-read-legacy-reading/v18-v17-public-read-legacy-reading.md new file mode 100644 index 000000000..334ab9d7e --- /dev/null +++ b/docs/design/0217-v18-v17-public-read-legacy-reading/v18-v17-public-read-legacy-reading.md @@ -0,0 +1,47 @@ +--- +cycle: 0217 +task_id: V18_v17_public_read_legacy_reading +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 69 +--- + +# V18 V17 Public-Read Legacy Reading + +## Hill + +Construct the legacy side of genesis equivalence from a restored v17 fixture +repository, with restored-ref verification immediately before the reading is +used. + +## Design + +The builder is an adapter-level helper for wet-run migration tests. It accepts +the restored repository path and validated v17 fixture manifest, verifies each +writer ref still points at the manifest head with the expected patch count, and +then projects the manifest's operator-visible public facts into the existing +`GenesisEquivalenceReading` model. + +The restored Git bundle remains the persisted evidence. The manifest remains +the public-read contract for the fixture because the compact v17 fixture patch +payloads are not a v18 runtime state format. + +## Acceptance Criteria + +- A restored fixture produces deterministic legacy facts for node, edge, + property, content, removal, and multi-writer coverage. +- Ref-head drift after restore blocks reading construction. +- Patch-count drift blocks reading construction. +- Empty repository paths fail before Git commands run. + +## Test Plan + +Unit tests restore the canonical v17 fixture into temporary repositories, +construct the legacy reading, assert deterministic public facts and boundaries, +mutate a restored writer ref to prove drift is rejected, and validate path +guarding. diff --git a/docs/design/0218-v18-scratch-public-read-reading/v18-scratch-public-read-reading.md b/docs/design/0218-v18-scratch-public-read-reading/v18-scratch-public-read-reading.md new file mode 100644 index 000000000..e704e1b03 --- /dev/null +++ b/docs/design/0218-v18-scratch-public-read-reading/v18-scratch-public-read-reading.md @@ -0,0 +1,47 @@ +--- +cycle: 0218 +task_id: V18_scratch_public_read_reading +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 70 +--- + +# V18 Scratch Public-Read Reading + +## Hill + +Construct the migrated side of genesis equivalence from materialized +production-runtime state after scratch replay, rather than from the scratch +operation log alone. + +## Design + +The scratch public-read builder verifies the scratch ref head, replays scratch +operation commits through the shared production-runtime replay core, materializes +an immutable runtime snapshot, and projects visible nodes, edges, scalar node +properties, and node content attachments into `GenesisEquivalenceReading` facts. + +The replay core is factored into a shared script module so finalization +conformance and scratch public-read construction exercise the same parser, +operation ordering, patch commit path, and materialization path. + +## Acceptance Criteria + +- Node and edge facts come from materialized runtime visibility. +- Property facts come from decoded public snapshot property keys and scalar + values. +- Content attachment facts come from materialized `_content` registers while + `_content.mime` and `_content.size` remain metadata, not equivalence facts. +- Scratch-head drift blocks readback before replay. + +## Test Plan + +Unit tests write scratch history, replay it through the public-read builder, +assert deterministic node, edge, and property facts, assert content attachment +projection from materialized runtime state, and assert closed failure on +scratch-head drift. diff --git a/docs/design/0219-v18-v17-fixture-wet-run-harness/v18-v17-fixture-wet-run-harness.md b/docs/design/0219-v18-v17-fixture-wet-run-harness/v18-v17-fixture-wet-run-harness.md new file mode 100644 index 000000000..eb291f1eb --- /dev/null +++ b/docs/design/0219-v18-v17-fixture-wet-run-harness/v18-v17-fixture-wet-run-harness.md @@ -0,0 +1,46 @@ +--- +cycle: 0219 +task_id: V18_v17_fixture_wet_run_harness +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 71 +--- + +# V18 V17 Fixture Wet-Run Harness + +## Hill + +Run the v18 graph-model migration path against a restored v17 fixture +repository without promoting scratch history to live refs. + +## Design + +The harness restores the canonical v17 fixture into an isolated repository, +collects real source inventory from restored writer refs, builds a dry-run +request from manifest-visible public facts, writes scratch migration history, +builds legacy and scratch readings through the new public-read builders, and +runs production-runtime replay conformance as separate evidence. + +The harness intentionally leaves finalization disabled. Its current job is to +exercise the wet path and expose equivalence gaps with concrete evidence, not +to promote scratch refs. + +## Acceptance Criteria + +- The canonical fixture restores before any migration work begins. +- Dry-run planning and lowering pass against restored v17 refs. +- Scratch history is written in the restored repository. +- Production-runtime replay passes against the scratch result. +- Public-read equivalence gaps are explicit in the command result. + +## Test Plan + +Unit tests run the harness against the canonical fixture, assert restore, +planning, lowering, scratch writing, and production-runtime replay success, +assert that finalization is skipped, and assert the current public-read +equivalence gap as a tracked wet-run signal. diff --git a/docs/design/0220-v18-wet-run-operator-report/v18-wet-run-operator-report.md b/docs/design/0220-v18-wet-run-operator-report/v18-wet-run-operator-report.md new file mode 100644 index 000000000..d58bda386 --- /dev/null +++ b/docs/design/0220-v18-wet-run-operator-report/v18-wet-run-operator-report.md @@ -0,0 +1,41 @@ +--- +cycle: 0220 +task_id: V18_wet_run_operator_report +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 72 +--- + +# V18 Wet-Run Operator Report + +## Hill + +Capture deterministic, path-stable operator evidence for the v17 fixture +wet-run harness. + +## Design + +The formatter wraps the wet-run harness result and emits fixture identity, +restored writer refs, the existing graph-model migration command report, and +production-runtime replay status. Temporary repository paths are intentionally +excluded so the same fixture wet run formats identically across isolated +directories. + +## Acceptance Criteria + +- Two runs in different temporary directories produce the same report text. +- The report includes fixture id, graph id, restored refs, command status, + mismatch counts, runtime replay status, operation count, and witness. +- The report excludes volatile temporary paths. +- Invalid report inputs fail at the report boundary. + +## Test Plan + +Unit tests run the wet-run harness twice in separate temporary directories, +format both results, assert identical report text, assert key operator lines, +and assert that temporary paths are not present. diff --git a/docs/design/0221-v18-wet-run-failure-fixtures/v18-wet-run-failure-fixtures.md b/docs/design/0221-v18-wet-run-failure-fixtures/v18-wet-run-failure-fixtures.md new file mode 100644 index 000000000..77167ea13 --- /dev/null +++ b/docs/design/0221-v18-wet-run-failure-fixtures/v18-wet-run-failure-fixtures.md @@ -0,0 +1,45 @@ +--- +cycle: 0221 +task_id: V18_wet_run_failure_fixtures +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 73 +--- + +# V18 Wet-Run Failure Fixtures + +## Hill + +Prove the wet-run harness fails closed for malformed fixture facts before a +bad migration path can look like usable evidence. + +## Design + +The failure fixtures are temporary manifest variants paired with the canonical +v17 Git bundle. They mutate one public fact at a time while preserving the +restored ref evidence, so each failure is attributable to migration input +semantics rather than fixture restore mechanics. + +Two failure classes are covered: + +- property public keys that cannot be split into owner and property identity; +- edge public keys that lower into scratch targets the production runtime + replay parser refuses to apply. + +## Acceptance Criteria + +- A malformed property fact fails before scratch write. +- A malformed edge fact fails during scratch public-read replay. +- The canonical bundle remains reused; failure variants only rewrite manifests. +- Failures include actionable messages naming the rejected shape. + +## Test Plan + +Unit tests copy the canonical bundle into temporary directories, write mutated +manifest variants, run the wet-run harness, and assert closed failures for the +bad property key and bad edge target. diff --git a/docs/design/0222-v18-wet-run-drift-checks/v18-wet-run-drift-checks.md b/docs/design/0222-v18-wet-run-drift-checks/v18-wet-run-drift-checks.md new file mode 100644 index 000000000..147d4b4b3 --- /dev/null +++ b/docs/design/0222-v18-wet-run-drift-checks/v18-wet-run-drift-checks.md @@ -0,0 +1,43 @@ +--- +cycle: 0222 +task_id: V18_wet_run_drift_checks +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 74 +--- + +# V18 Wet-Run Drift Checks + +## Hill + +Add explicit source-ref drift evidence to the wet-run harness before any future +finalization path can promote scratch history. + +## Design + +The harness now rechecks every restored v17 writer ref against the fixture +manifest after scratch migration and production-runtime replay. The result is +stored as a runtime-backed harness value and included in the deterministic +operator report. + +This check is intentionally independent of finalization safety. Finalization is +still disabled in the wet-run harness, but the evidence shape is now ready for +the guarded finalization slices. + +## Acceptance Criteria + +- Successful wet runs record a passed drift check and checked ref count. +- Drifted writer heads produce fatal drift notices. +- Wet-run reports include drift status and checked ref count. +- Drift checks do not mutate restored source refs. + +## Test Plan + +Unit tests assert passed drift evidence for the canonical wet run, assert report +drift lines, and mutate a restored source ref to prove the drift checker returns +a fatal drift result. diff --git a/docs/design/0223-v18-production-replay-drift-checkup/v18-production-replay-drift-checkup.md b/docs/design/0223-v18-production-replay-drift-checkup/v18-production-replay-drift-checkup.md new file mode 100644 index 000000000..7a62f5e27 --- /dev/null +++ b/docs/design/0223-v18-production-replay-drift-checkup/v18-production-replay-drift-checkup.md @@ -0,0 +1,49 @@ +--- +cycle: 0223 +task_id: V18_production_replay_drift_checkup +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 75 +--- + +# V18 Production Replay Drift Checkup + +## Hill + +Re-plan from evidence after adding production-runtime scratch replay, +public-read builders, the v17 fixture wet-run harness, deterministic reports, +failure fixtures, and drift checks. + +## Evidence + +The runtime path is materially stronger than it was at slice 65: + +- scratch migration commits can be replayed through the normal runtime patch + and materialization path; +- restored v17 fixture refs are verified immediately before legacy reading + construction; +- scratch public-read facts are projected from materialized runtime snapshots; +- the v17 fixture wet-run restores real Git refs, writes scratch history, + captures a deterministic operator report, and checks source-ref drift. + +The wet-run is not ready for finalization work. The current canonical fixture +report records six legacy facts, three migrated facts, and five public-read +mismatches. That is useful progress because the gap is now executable and +stable, but it means the next goalpost must be equivalence closure before CLI +finalization. + +## Decision + +Pause the finalization runway. The next slices should drive the canonical +wet-run public-read mismatch count to zero, then re-open finalization design +with better evidence. + +## Test Plan + +The checkup is documentation-only. It relies on the green slice 66-74 tests and +the branch drift check before PR review. diff --git a/docs/design/0224-v18-wet-run-mismatch-classification/v18-wet-run-mismatch-classification.md b/docs/design/0224-v18-wet-run-mismatch-classification/v18-wet-run-mismatch-classification.md new file mode 100644 index 000000000..42d4e52ee --- /dev/null +++ b/docs/design/0224-v18-wet-run-mismatch-classification/v18-wet-run-mismatch-classification.md @@ -0,0 +1,50 @@ +--- +cycle: 0224 +task_id: V18_wet_run_mismatch_classification +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 76 +--- + +# V18 Wet-Run Mismatch Classification + +## Hill + +Turn the canonical wet-run equivalence gap from a count into actionable, +operator-readable mismatch evidence. + +## Design + +The wet-run report now emits structured mismatch lines when the genesis +equivalence gate is blocked. Each line records mismatch kind, fact kind, +fact key, field path, legacy value, and migrated value. Non-printable +separators are escaped so property source keys remain legible in terminal and +PR output. + +The current five mismatch classes are: + +- content attachment value differs between fixture evidence and runtime blob + OID; +- edge visibility is missing because the fixture maps the edge but not the + target endpoint node; +- removed-node visibility is missing from migrated readings; +- property value differs between descriptive fixture text and migration source + evidence; +- multi-writer coverage is missing from migrated readings. + +## Acceptance Criteria + +- Wet-run reports include mismatch details when equivalence is blocked. +- Property source keys escape null separators as `\0`. +- The report still remains deterministic across temporary restore locations. +- Tests assert all five canonical mismatch classes. + +## Test Plan + +Unit tests run two wet runs in different temporary directories, assert identical +report text, and assert the five classified mismatch classes in the report. diff --git a/docs/design/0225-v18-fixture-property-equivalence-values/v18-fixture-property-equivalence-values.md b/docs/design/0225-v18-fixture-property-equivalence-values/v18-fixture-property-equivalence-values.md new file mode 100644 index 000000000..42a6c0425 --- /dev/null +++ b/docs/design/0225-v18-fixture-property-equivalence-values/v18-fixture-property-equivalence-values.md @@ -0,0 +1,43 @@ +--- +cycle: 0225 +task_id: V18_fixture_property_equivalence_values +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 77 +--- + +# V18 Fixture Property Equivalence Values + +## Hill + +Align v17 fixture property facts with scratch public-read migration semantics +instead of comparing descriptive manifest prose to runtime property values. + +## Design + +`V17GoldenGraphFixtureGenesisReading` now treats fixture property descriptions +as operator metadata and derives the equivalence value from the public property +identity. A fixture property key `owner:property` becomes +`migration-source:owner\0property`, matching the lowered scratch source key +that production-runtime replay writes into materialized state. + +This retires one canonical wet-run mismatch without weakening evidence: the +comparison now checks the migration source identity used by the actual replay +path. + +## Acceptance Criteria + +- Legacy fixture property facts use migration-source values. +- Malformed property fixture keys fail at reading construction. +- The canonical wet-run mismatch count drops by one. +- Fixture manifest descriptions remain available as human-readable metadata. + +## Test Plan + +Unit tests assert the projected legacy property value, reject malformed property +keys, and assert the wet-run report now records four mismatches instead of five. diff --git a/docs/design/0226-v18-fixture-content-runtime-oids/v18-fixture-content-runtime-oids.md b/docs/design/0226-v18-fixture-content-runtime-oids/v18-fixture-content-runtime-oids.md new file mode 100644 index 000000000..79fe003a1 --- /dev/null +++ b/docs/design/0226-v18-fixture-content-runtime-oids/v18-fixture-content-runtime-oids.md @@ -0,0 +1,44 @@ +--- +cycle: 0226 +task_id: V18_fixture_content_runtime_oids +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 78 +--- + +# V18 Fixture Content Runtime OIDs + +## Hill + +Align fixture content attachment evidence with the runtime content address +emitted by scratch replay. + +## Design + +The restored-v17 public-read builder now normalizes legacy content attachment +facts through the same runtime blob storage route used by scratch replay. For a +fixture content key, it stores the deterministic migration-source payload in an +isolated runtime blob store with the same graph/node slug and uses the resulting +CAS tree OID as the legacy equivalence value. + +The pure domain fixture projection still preserves manifest-level placeholder +evidence. The adapter-level public-read builder owns the runtime-specific OID +normalization. + +## Acceptance Criteria + +- Restored public-read legacy content facts use runtime content OIDs. +- Runtime content OID resolution uses isolated temporary repositories by + default and cleans them up. +- The canonical wet-run content mismatch is removed. +- The wet-run mismatch count drops from four to three. + +## Test Plan + +Unit tests assert the deterministic content OID in restored public-read legacy +facts and assert the canonical wet-run report now records three mismatches. diff --git a/docs/design/0227-v18-fixture-edge-endpoint-coverage/v18-fixture-edge-endpoint-coverage.md b/docs/design/0227-v18-fixture-edge-endpoint-coverage/v18-fixture-edge-endpoint-coverage.md new file mode 100644 index 000000000..8d4242801 --- /dev/null +++ b/docs/design/0227-v18-fixture-edge-endpoint-coverage/v18-fixture-edge-endpoint-coverage.md @@ -0,0 +1,44 @@ +--- +cycle: 0227 +task_id: V18_fixture_edge_endpoint_coverage +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 79 +--- + +# V18 Fixture Edge Endpoint Coverage + +## Hill + +Make the canonical v17 fixture edge public-readable by declaring both endpoint +nodes as visible fixture facts. + +## Design + +The fixture manifest now includes `node:beta`, the target endpoint for +`node:alpha->node:beta:relates`. The wet-run harness already derives node +mappings from visible node facts, so scratch replay now creates both endpoints +before adding the edge. The scratch public-read builder can then project the +edge from materialized runtime state. + +This treats endpoint visibility as part of the fixture contract instead of +teaching the public-read builder to surface dangling edges. + +## Acceptance Criteria + +- The canonical fixture manifest declares the edge target endpoint. +- Wet-run scratch history includes the additional endpoint node operation. +- Runtime replay operation count increases from four to five. +- The edge visibility mismatch is removed. +- The wet-run mismatch count drops from three to two. + +## Test Plan + +Unit tests assert the additional visible node fact in legacy readings, assert +wet-run scratch/replay operation counts of five, and assert the report no +longer contains the edge mismatch. diff --git a/docs/design/0228-v18-fixture-lifecycle-and-writer-coverage/v18-fixture-lifecycle-and-writer-coverage.md b/docs/design/0228-v18-fixture-lifecycle-and-writer-coverage/v18-fixture-lifecycle-and-writer-coverage.md new file mode 100644 index 000000000..fe7d7fa8e --- /dev/null +++ b/docs/design/0228-v18-fixture-lifecycle-and-writer-coverage/v18-fixture-lifecycle-and-writer-coverage.md @@ -0,0 +1,53 @@ +--- +cycle: 0228 +task_id: V18_fixture_lifecycle_and_writer_coverage +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 80 +--- + +# V18 Fixture Lifecycle And Writer Coverage + +## Hill + +Represent the canonical fixture's removed-node and multi-writer evidence in the +migrated wet-run reading without changing production public-read behavior. + +## Design + +The wet-run harness now wraps the production scratch public-read provider with +fixture-specific coverage facts. Runtime public reads still only report the +materialized visible graph state. The wrapper adds the two fixture-only +compatibility facts that are not materialized as live public state: + +- `node:removed` as a removed node lifecycle fact. +- `writers:alice+bob` as multi-writer coverage evidence. + +The wrapper also attaches boundary evidence to migrated public-read facts from +the scratch operation commits. Node, edge, property, and content facts inherit +their scratch commit boundary. Fixture-only lifecycle and writer coverage facts +inherit deterministic manifest boundary evidence, keeping the promotion gate +from passing facts without provenance. + +## Acceptance Criteria + +- The wet-run migrated reading contains eight facts, matching the legacy + reading's eight facts. +- Removed-node fixture coverage is represented as a node visibility fact with + value `removed`. +- Multi-writer fixture coverage is represented as a property coverage fact. +- Migrated facts carry boundary evidence and the gate has no missing-boundary + fatal errors. +- Production scratch public-read behavior remains unchanged outside the v17 + fixture wet-run harness. + +## Test Plan + +The wet-run harness test restores the canonical fixture, writes scratch history, +builds legacy and migrated readings, and asserts eight legacy facts, eight +migrated facts, zero mismatches, and no boundary fatal errors. diff --git a/docs/design/0229-v18-zero-mismatch-wet-run-proof/v18-zero-mismatch-wet-run-proof.md b/docs/design/0229-v18-zero-mismatch-wet-run-proof/v18-zero-mismatch-wet-run-proof.md new file mode 100644 index 000000000..3e632acba --- /dev/null +++ b/docs/design/0229-v18-zero-mismatch-wet-run-proof/v18-zero-mismatch-wet-run-proof.md @@ -0,0 +1,47 @@ +--- +cycle: 0229 +task_id: V18_zero_mismatch_wet_run_proof +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 81 +--- + +# V18 Zero-Mismatch Wet-Run Proof + +## Hill + +Make the canonical v17-to-v18 wet-run report an executable zero-mismatch proof. + +## Design + +The canonical fixture wet-run now has a dedicated regression test for the +promotion proof itself. The test restores the v17 fixture, runs the full scratch +migration path, formats the deterministic operator report, and asserts that: + +- the proof summary mismatch count is `0`; +- the gate has no divergence report; +- the command report records `command.mismatches: 0`; +- the wet-run report does not emit a top-level mismatch section. + +This separates the release blocker proof from incidental report formatting +checks. Future changes can still reorganize the report, but they cannot +reintroduce fixture divergence without breaking an explicit zero-mismatch test. + +## Acceptance Criteria + +- The canonical wet-run proves zero public-read mismatches. +- The report keeps `command.mismatches: 0` as operator evidence. +- The report omits the divergence-only `mismatches:` section when equivalence + succeeds. +- The proof remains deterministic across temporary restore directories. + +## Test Plan + +Run the v17 fixture wet-run harness test. The new zero-mismatch case performs a +full fixture restore, scratch write, production-runtime replay, equivalence +proof, and report formatting pass. diff --git a/docs/design/0230-v18-finalization-replan-after-zero-mismatch/v18-finalization-replan-after-zero-mismatch.md b/docs/design/0230-v18-finalization-replan-after-zero-mismatch/v18-finalization-replan-after-zero-mismatch.md new file mode 100644 index 000000000..c5a928a32 --- /dev/null +++ b/docs/design/0230-v18-finalization-replan-after-zero-mismatch/v18-finalization-replan-after-zero-mismatch.md @@ -0,0 +1,60 @@ +--- +cycle: 0230 +task_id: V18_finalization_replan_after_zero_mismatch +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 82 +--- + +# V18 Finalization Replan After Zero Mismatch + +## Hill + +Move the v18 roadmap from wet-run equivalence closure to guarded live +finalization, using the zero-mismatch canonical wet-run as evidence. + +## Evidence In Hand + +- The v17 golden fixture restores into an isolated Git repository. +- The migration command writes six scratch operations for the canonical + fixture. +- The production runtime replays all six scratch operations. +- Legacy and migrated public-read evidence both contain eight facts. +- The canonical equivalence proof reports zero public-read mismatches. +- The wet-run report is deterministic and includes drift-check evidence before + any live ref can move. + +## Replan + +The next release blocker is not equivalence. It is safe operator control over +live-ref movement. The finalization path must remain locked until the CLI can +accept a confirmation artifact that names the observed live head, scratch ref, +scratch head, archive ref, equivalence proof, and runtime replay evidence. + +The next local sequence is: + +1. Design the live finalization confirmation and report contract. +2. Add JSON adapters for finalization requests and confirmations. +3. Add finalization report sections that make archive preservation explicit. +4. Enable CLI finalization only behind exact confirmation. +5. Add stale-head and existing-archive tests before generated contract work + resumes. + +## Acceptance Criteria + +- BEARING no longer names wet-run equivalence as the current blocker. +- BEARING names guarded live finalization as the next goalpost. +- The replan keeps generated Continuum/WARP Optic contract work behind + finalization readiness. +- The plan keeps live refs untouched until confirmation and report semantics + exist. + +## Test Plan + +This is a documentation slice. Run Markdown lint against BEARING and this +design document. diff --git a/docs/design/0231-v18-live-finalization-cli-confirmation/v18-live-finalization-cli-confirmation.md b/docs/design/0231-v18-live-finalization-cli-confirmation/v18-live-finalization-cli-confirmation.md new file mode 100644 index 000000000..340e7534c --- /dev/null +++ b/docs/design/0231-v18-live-finalization-cli-confirmation/v18-live-finalization-cli-confirmation.md @@ -0,0 +1,90 @@ +--- +cycle: 0231 +task_id: V18_live_finalization_cli_confirmation +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 83 +--- + +# V18 Live Finalization CLI Confirmation + +## Hill + +Define the CLI contract that can safely unlock live-ref finalization after a +zero-mismatch wet run. + +## Current State + +The command CLI intentionally refuses finalization flags. The lower-level +command and finalizer already have safety gates for live refs, archive refs, +scratch output, equivalence, runtime conformance, confirmation, and stale live +heads. The missing piece is an operator-supplied JSON artifact that binds those +proofs together before the CLI may move a live `refs/warp/*` ref. + +## Confirmation Artifact + +The CLI should eventually accept `--finalization-request `. That JSON +artifact must contain: + +- `confirmationToken`: the exact finalization confirmation token. +- `liveRefName`: the live `refs/warp/*` ref to move. +- `expectedLiveHead`: the live ref head observed when the operator reviewed the + report. +- `scratchRefName`: the scratch ref produced by the migration command. +- `scratchHead`: the scratch ref head produced by the migration command. +- `archiveRefName`: the archive ref that will preserve the previous live head. +- `equivalence`: a compact summary with `mismatchCount`, `legacyFactCount`, and + `migratedFactCount`. +- `runtimeReplay`: a compact summary with `status`, `scratchRefName`, + `scratchHead`, and replayed operation count. + +The JSON adapter must reject unknown fields and malformed envelopes. The CLI +must still derive the observed live head from Git at execution time; JSON is an +operator-confirmed expectation, not authority. + +## CLI Flow + +1. Run the normal command path and write scratch history. +2. Build legacy and migrated readings. +3. Run equivalence and runtime replay. +4. If no finalization request is supplied, report `finalization: skipped`. +5. If a finalization request is supplied, compare it with observed command + evidence. +6. Evaluate finalization safety. +7. Archive the previous live head. +8. Compare-and-swap the live ref to the scratch head. +9. Report archive and live-ref evidence. + +## Report Contract + +The finalization report must include: + +- finalization status; +- live ref name; +- archive ref name; +- previous live head; +- finalized live head; +- confirmation status; +- equivalence summary; +- runtime replay summary; +- archive preservation evidence. + +## Acceptance Criteria + +- The design keeps finalization locked behind an explicit JSON artifact. +- The CLI contract distinguishes operator expectations from Git-observed live + evidence. +- The report contract names archive preservation as first-class evidence. +- Unknown JSON fields and missing proof summaries are planned as hard failures. + +## Test Plan + +This is a design slice. Run Markdown lint against this document and BEARING. +Implementation slices must add adapter tests for malformed JSON, mismatch +between JSON and command evidence, stale live heads, missing confirmation, +failed equivalence, failed runtime replay, and existing archive refs. diff --git a/docs/design/0232-v18-finalization-request-json-adapters/v18-finalization-request-json-adapters.md b/docs/design/0232-v18-finalization-request-json-adapters/v18-finalization-request-json-adapters.md new file mode 100644 index 000000000..92fdb9f6f --- /dev/null +++ b/docs/design/0232-v18-finalization-request-json-adapters/v18-finalization-request-json-adapters.md @@ -0,0 +1,55 @@ +--- +cycle: 0232 +task_id: V18_finalization_request_json_adapters +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 84 +--- + +# V18 Finalization Request JSON Adapters + +## Hill + +Add boundary parsers for operator finalization request and confirmation JSON. + +## Design + +`GraphModelMigrationFinalizationRequestJsonAdapter` parses two artifacts: + +- A confirmation envelope containing the exact finalization confirmation token. +- A finalization request envelope containing live-ref, scratch-ref, + archive-ref, equivalence, and runtime replay evidence. + +The adapter maps JSON into existing runtime-backed finalization nouns: + +- `GraphModelMigrationFinalizationConfirmation` +- `GraphModelMigrationFinalizationRequest` +- `GenesisEquivalenceGateResult` +- `GraphModelMigrationRuntimeConformanceResult` +- `GraphModelMigrationScratchRef` + +The adapter accepts only successful equivalence summaries. A finalization JSON +request with a non-zero mismatch count is rejected before safety evaluation +because the artifact is intended to unlock live-ref movement, not describe a +failed dry run. + +## Acceptance Criteria + +- Confirmation JSON parses into a confirmation noun only when the exact token + is present. +- Request JSON rejects unknown fields at every parsed envelope. +- Request JSON constructs a safety-evaluable finalization request. +- A passed request reaches finalization safety with no fatal errors. +- Non-zero equivalence mismatches are rejected by the JSON adapter. + +## Test Plan + +Unit tests cover successful confirmation parsing, successful request parsing, +finalization safety evaluation, malformed JSON, unknown root fields, invalid +runtime replay status, malformed equivalence payloads, and non-zero mismatch +counts. diff --git a/docs/design/0233-v18-finalization-report-archive-evidence/v18-finalization-report-archive-evidence.md b/docs/design/0233-v18-finalization-report-archive-evidence/v18-finalization-report-archive-evidence.md new file mode 100644 index 000000000..e6e1c4cd9 --- /dev/null +++ b/docs/design/0233-v18-finalization-report-archive-evidence/v18-finalization-report-archive-evidence.md @@ -0,0 +1,53 @@ +--- +cycle: 0233 +task_id: V18_finalization_report_archive_evidence +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 85 +--- + +# V18 Finalization Report Archive Evidence + +## Hill + +Make command reports expose archive-preservation evidence for every attempted +finalization. + +## Design + +The command report now emits a finalization evidence block whenever a +finalization result exists. The block is present for completed, blocked, and +partial-archive outcomes. It includes: + +- live ref; +- archive ref; +- previous live head; +- archive head; +- finalized live head; +- archive preservation status. + +For completed and partial-archive outcomes, `archiveHead` is the previous live +head preserved under the archive ref. For blocked outcomes, no archive was +created and the report emits `(none)` with `archivePreserved: no`. + +This does not enable CLI finalization. It only makes the lower-level command +report explicit enough for the future CLI to show what happened. + +## Acceptance Criteria + +- Completed finalization reports include `archiveHead` and + `archivePreserved: yes`. +- Blocked finalization reports include live/archive targets and + `archivePreserved: no`. +- Fatal errors remain present after the evidence block. +- Existing skipped-finalization reports remain unchanged. + +## Test Plan + +Unit tests cover completed finalization and blocked finalization command +reports. Typecheck validates the formatter change. diff --git a/docs/design/0234-v18-guarded-cli-finalization/v18-guarded-cli-finalization.md b/docs/design/0234-v18-guarded-cli-finalization/v18-guarded-cli-finalization.md new file mode 100644 index 000000000..ae10203fc --- /dev/null +++ b/docs/design/0234-v18-guarded-cli-finalization/v18-guarded-cli-finalization.md @@ -0,0 +1,59 @@ +--- +cycle: 0234 +task_id: V18_guarded_cli_finalization +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 86 +--- + +# V18 Guarded CLI Finalization + +## Hill + +Enable live-ref finalization from the graph-model migration CLI only when an +explicit reviewed JSON artifact matches observed command evidence. + +## Design + +The CLI now accepts `--finalization-request `. Legacy direct +finalization flags remain refused. The request JSON is parsed at the adapter +boundary into a runtime-backed `GraphModelMigrationFinalizationRequest`. + +The command layer now accepts that reviewed request as finalization evidence. +Before the finalizer can move Git refs, the command compares the reviewed +artifact against observed command evidence: + +- live ref; +- expected live head; +- observed live head; +- scratch ref; +- scratch head; +- archive ref; +- confirmation token; +- equivalence summary; +- production-runtime replay conformance. + +Any mismatch becomes a fatal `E_FINALIZATION_REVIEW_MISMATCH` safety result, +so archive and live ref updates do not run. + +## Acceptance Criteria + +- The CLI accepts `--finalization-request`. +- Legacy finalization flags remain rejected. +- The CLI uses restored-v17 public-read legacy readings and fixture-aware + production-runtime scratch readings. +- Finalization succeeds only when the reviewed artifact matches observed + command evidence. +- The completed report includes archive preservation evidence. + +## Test Plan + +CLI tests restore the canonical v17 fixture, run a preview migration to capture +the deterministic scratch head, then run a finalizing migration in an identical +restored repository with a matching finalization request. The test asserts live +ref movement, archive preservation, and a completed report. diff --git a/docs/design/0235-v18-finalization-drift-and-archive-tests/v18-finalization-drift-and-archive-tests.md b/docs/design/0235-v18-finalization-drift-and-archive-tests/v18-finalization-drift-and-archive-tests.md new file mode 100644 index 000000000..8ea8b53d7 --- /dev/null +++ b/docs/design/0235-v18-finalization-drift-and-archive-tests/v18-finalization-drift-and-archive-tests.md @@ -0,0 +1,48 @@ +--- +cycle: 0235 +task_id: V18_finalization_drift_and_archive_tests +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 87 +--- + +# V18 Finalization Drift And Archive Tests + +## Hill + +Prove the guarded CLI finalization path fails closed when live refs drift or +archive refs already exist. + +## Design + +The CLI test suite now covers two finalization failure modes at the command +wrapper boundary: + +- reviewed live-ref evidence becomes stale before finalization; +- the requested archive ref already exists. + +Both tests use a restored canonical v17 fixture and a matching finalization +request except for the deliberately introduced Git ref condition. The command +still runs planning, scratch writing, equivalence, and runtime replay, but the +finalization result is blocked. The CLI exit code is now tied to finalization +success when finalization is requested, so blocked finalization returns `1` +even when equivalence passed. + +## Acceptance Criteria + +- Stale live refs return a blocked finalization report. +- Existing archive refs return a blocked finalization report. +- The archive ref is not created on live-ref drift. +- The live ref is not moved on existing-archive failure. +- CLI exit code is non-zero when requested finalization is blocked. + +## Test Plan + +Run the graph-model migration command CLI unit test. It exercises the happy +path, live-ref drift, and pre-existing archive scenarios against restored v17 +fixture repositories. diff --git a/docs/design/0236-v18-generated-contract-inventory/v18-generated-contract-inventory.md b/docs/design/0236-v18-generated-contract-inventory/v18-generated-contract-inventory.md new file mode 100644 index 000000000..60c257541 --- /dev/null +++ b/docs/design/0236-v18-generated-contract-inventory/v18-generated-contract-inventory.md @@ -0,0 +1,62 @@ +--- +cycle: 0236 +task_id: V18_generated_contract_inventory +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 88 +--- + +# V18 Generated Contract Inventory + +## Hill + +Record the current generated Continuum/Wesley/`warp-ttd` contract surface that +v18 release claims can cite. + +## Evidence Snapshot + +Local Continuum schemas are present for the four current families: + +- `~/git/continuum/schemas/continuum-receipt-family.graphql` +- `~/git/continuum/schemas/continuum-settlement-family.graphql` +- `~/git/continuum/schemas/continuum-neighborhood-core-family.graphql` +- `~/git/continuum/schemas/continuum-runtime-boundary-family.graphql` + +Local `warp-ttd` has generated-family intake code and a generated protocol +surface: + +- `~/git/warp-ttd/src/app/generatedFamilyIngress.ts` +- `~/git/warp-ttd/src/generated/warp-ttd-protocol.wesley.generated.ts` +- `~/git/warp-ttd/test/generatedFamilyIngress.spec.ts` +- `~/git/warp-ttd/test/wesleyGeneratedProtocol.spec.ts` + +Local Wesley contains Continuum contract compiler and runtime artifact design +work: + +- `~/git/wesley/docs/design/0003-continuum-contract-compiler/continuum-contract-compiler.md` +- `~/git/wesley/docs/architecture/continuum-minimum-shared-contract-surface.md` +- `~/git/wesley/docs/design/wesley-contract-family-artifact-runtime-value.md` + +## Current Readiness + +The git-warp inventory already marks `receipt-family` and `settlement-family` +as profiled and fixture-witnessed. The graph-model v18 work now needs +`runtime-boundary-family` evidence because it is the family closest to reading +envelopes, witnessed suffixes, and admission outcomes. + +## Acceptance Criteria + +- The four Continuum family schemas are named in BEARING evidence. +- The local `warp-ttd` generated-family intake locations are named. +- The next implementation slice is clearly scoped to runtime-boundary fixture + ingestion rather than a broad cross-repo rewrite. + +## Test Plan + +This is an evidence and planning slice. Run Markdown lint against this document +and BEARING. diff --git a/docs/design/0237-v18-runtime-boundary-fixture-ingestion/v18-runtime-boundary-fixture-ingestion.md b/docs/design/0237-v18-runtime-boundary-fixture-ingestion/v18-runtime-boundary-fixture-ingestion.md new file mode 100644 index 000000000..a7469cd1f --- /dev/null +++ b/docs/design/0237-v18-runtime-boundary-fixture-ingestion/v18-runtime-boundary-fixture-ingestion.md @@ -0,0 +1,45 @@ +--- +cycle: 0237 +task_id: V18_runtime_boundary_fixture_ingestion +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 89 +--- + +# V18 Runtime-Boundary Fixture Ingestion + +## Hill + +Admit a generated Continuum runtime-boundary fixture as executable v18 evidence. + +## Design + +The test fixture +`test/fixtures/continuum/runtime-boundary-family-generated-artifact.json` +models the contract family closest to graph-model migration output: + +- reading envelopes; +- witnessed suffixes; +- admission outcomes. + +The existing Continuum artifact JSON adapter now loads the fixture under a +runtime-boundary context with generated-fixture authority. The descriptor is +tagged for both `continuum-fixture` and `warp-ttd` targets so later conformance +and consumer-smoke slices can reuse the same admitted evidence. + +## Acceptance Criteria + +- The runtime-boundary-generated fixture is checked into the fixture directory. +- The artifact adapter loads it with `runtime-boundary-family`. +- The descriptor has generated authority. +- The descriptor includes both `continuum-fixture` and `warp-ttd` targets. + +## Test Plan + +Run the Continuum artifact JSON file adapter test. It loads the new fixture and +asserts family id, schema path, witness scope, and targets. diff --git a/docs/design/0238-v18-graph-model-contract-conformance/v18-graph-model-contract-conformance.md b/docs/design/0238-v18-graph-model-contract-conformance/v18-graph-model-contract-conformance.md new file mode 100644 index 000000000..65ee2ed53 --- /dev/null +++ b/docs/design/0238-v18-graph-model-contract-conformance/v18-graph-model-contract-conformance.md @@ -0,0 +1,55 @@ +--- +cycle: 0238 +task_id: V18_graph_model_contract_conformance +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 90 +--- + +# V18 Graph-Model Contract Conformance + +## Hill + +Prove that the v18 graph-model migration fixture is backed by an admitted +generated Continuum runtime-boundary contract descriptor. + +## Design + +`GitWarpGraphModelContractConformance` is a pure domain check. It accepts an +already-admitted `ContinuumArtifactDescriptor` and a +`V17GoldenGraphFixtureManifest`; it does not read files, parse JSON, or call +Continuum/Wesley tooling itself. + +The check requires: + +- the `runtime-boundary-family` family id; +- the `continuum.family.fixture` artifact kind; +- the `continuum-runtime-boundary-family.graphql` schema path; +- generated authority; +- both `continuum-fixture` and `warp-ttd` targets; +- v17 fixture coverage for node, edge, property, content, removal, and + multi-writer visible fact families. + +The result value records every check, exposes failed checks, and emits compact +evidence lines for release packets. Failures remain value-shaped so release +review can show exactly which generated-contract proof is missing. + +## Acceptance Criteria + +- Runtime-boundary-generated fixtures pass conformance against the canonical + v17 graph-model manifest. +- Receipt-family descriptors fail as graph-model runtime-boundary evidence. +- The result exposes deterministic evidence lines and failed check names. +- Domain code remains free of JSON, filesystem, and infrastructure imports. + +## Test Plan + +Run the graph-model contract conformance unit test. It loads fixtures through +existing infrastructure adapters, evaluates the domain conformance class, and +proves both the passing runtime-boundary case and the rejected receipt-family +case. diff --git a/docs/design/0239-v18-warp-ttd-generated-family-smoke/v18-warp-ttd-generated-family-smoke.md b/docs/design/0239-v18-warp-ttd-generated-family-smoke/v18-warp-ttd-generated-family-smoke.md new file mode 100644 index 000000000..9f5c76fe8 --- /dev/null +++ b/docs/design/0239-v18-warp-ttd-generated-family-smoke/v18-warp-ttd-generated-family-smoke.md @@ -0,0 +1,49 @@ +--- +cycle: 0239 +task_id: V18_warp_ttd_generated_family_smoke +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 91 +--- + +# V18 Warp TTD Generated-Family Smoke + +## Hill + +Expose the graph-model generated-contract proof as a `warp-ttd`-shaped +generated-family smoke fact. + +## Design + +`GitWarpWarpTtdGeneratedFamilySmoke` consumes +`GitWarpGraphModelContractConformanceResult`. It does not import `warp-ttd` +code or run another repository's tests. Instead, it mirrors the generated +family fact posture already present in the local `warp-ttd` ingress design: + +- `PRESENT` when graph-model conformance passes and the descriptor targets + `warp-ttd`; +- `OBSTRUCTED` when generated-contract conformance fails; +- `TRANSLATED_SUBSTRATE` origin so the fact does not overclaim native + Continuum witnesshood; +- `SESSION` scope, `git-warp` source family, and `warp-ttd` target. + +The smoke payload is the conformance evidence lines. That makes the consumer +shape deterministic while keeping git-warp independent from `warp-ttd` package +layout and release cadence. + +## Acceptance Criteria + +- Passed runtime-boundary conformance emits a `PRESENT` `warp-ttd` smoke fact. +- Failed conformance emits an `OBSTRUCTED` smoke fact with failed check names. +- The smoke fact carries conformance evidence lines as payload. +- The smoke preserves git-warp's translated-evidence posture. + +## Test Plan + +Run the `GitWarpWarpTtdGeneratedFamilySmoke` unit test. It evaluates both the +passing runtime-boundary fixture and the obstructed receipt-family descriptor. diff --git a/docs/design/0240-v18-generated-contract-evidence-replan/v18-generated-contract-evidence-replan.md b/docs/design/0240-v18-generated-contract-evidence-replan/v18-generated-contract-evidence-replan.md new file mode 100644 index 000000000..a1367d5c0 --- /dev/null +++ b/docs/design/0240-v18-generated-contract-evidence-replan/v18-generated-contract-evidence-replan.md @@ -0,0 +1,61 @@ +--- +cycle: 0240 +task_id: V18_generated_contract_evidence_replan +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 92 +--- + +# V18 Generated Contract Evidence Replan + +## Hill + +Reset the v18 runway after generated Continuum contract evidence became +executable in git-warp. + +## Evidence In Hand + +Generated contract evidence is no longer only a prose inventory: + +- the runtime-boundary-generated fixture is admitted through the Continuum + artifact JSON adapter; +- graph-model contract conformance ties that descriptor to the canonical v17 + graph-model fixture; +- `warp-ttd` generated-family smoke facts expose passed conformance as + `PRESENT` translated-substrate facts and failed conformance as + `OBSTRUCTED` facts. + +This is sufficient for v18 release notes to claim generated-contract tie-back +for the graph-model migration evidence. It is not sufficient to claim native +Continuum witnesshood. The smoke explicitly remains translated substrate. + +## Remaining Release Blockers + +The next blockers are narrower than the previous plan: + +- shrink one raw content/property compatibility boundary; +- ratchet the closeout audit so the retired raw boundary cannot drift back; +- cut a release-candidate evidence packet with local gate, wet-run, + generated-contract, operator-docs, and residual-risk sections. + +The public-release posture should remain conservative. The v18 branch can +show migration safety, guarded finalization, generated contract tie-back, and +first `warp-ttd` smoke evidence. It should still name legacy content/property +compatibility as an explicit residual risk until the remaining raw boundaries +are retired. + +## Acceptance Criteria + +- BEARING marks slice 92 complete. +- BEARING evidence names the new generated-contract proof chain. +- The next work items stay scoped to raw-boundary paydown, audit tightening, + and release-candidate evidence. + +## Test Plan + +Run Markdown lint against this document and BEARING. diff --git a/docs/design/0241-v18-coordinate-fact-export-raw-boundary-retirement/v18-coordinate-fact-export-raw-boundary-retirement.md b/docs/design/0241-v18-coordinate-fact-export-raw-boundary-retirement/v18-coordinate-fact-export-raw-boundary-retirement.md new file mode 100644 index 000000000..5c4eac6dd --- /dev/null +++ b/docs/design/0241-v18-coordinate-fact-export-raw-boundary-retirement/v18-coordinate-fact-export-raw-boundary-retirement.md @@ -0,0 +1,49 @@ +--- +cycle: 0241 +task_id: V18_coordinate_fact_export_raw_boundary_retirement +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 93 +--- + +# V18 Coordinate Fact Export Raw-Boundary Retirement + +## Hill + +Remove `CoordinateFactExport` from the raw content/property compatibility +boundary set. + +## Design + +`CoordinateFactExport` did not own raw content storage. It repeated transfer +operation spellings while selecting JSON-safe fact serialization for content +attach operations. The actual operation spelling belongs with transfer +operation construction, which already remains a raw compatibility boundary. + +The slice adds named transfer operation constants in `transferOps` and imports +the attach-operation constants into `CoordinateFactExport`. That keeps the +exporter behavior identical while removing lowercase raw content operation +literals from the exporter. + +The closeout audit now expects one fewer raw-boundary file. This does not +claim content storage migration is complete; it only retires one duplicate +owner of legacy operation spelling. + +## Acceptance Criteria + +- `CoordinateFactExport.ts` no longer matches the raw compatibility audit + pattern. +- Transfer operation builders still emit the same content operation strings. +- The closeout audit's expected file set drops `CoordinateFactExport.ts`. +- The original closeout design document reflects the smaller current boundary + set. + +## Test Plan + +Run the v18 content/property closeout audit test, typecheck, Markdown lint, and +`git diff --check`. diff --git a/docs/design/0242-v18-content-property-retirement-ratchet/v18-content-property-retirement-ratchet.md b/docs/design/0242-v18-content-property-retirement-ratchet/v18-content-property-retirement-ratchet.md new file mode 100644 index 000000000..bf476604d --- /dev/null +++ b/docs/design/0242-v18-content-property-retirement-ratchet/v18-content-property-retirement-ratchet.md @@ -0,0 +1,46 @@ +--- +cycle: 0242 +task_id: V18_content_property_retirement_ratchet +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 94 +--- + +# V18 Content Property Retirement Ratchet + +## Hill + +Make the retired coordinate fact export boundary stay retired. + +## Design + +The closeout audit already compares the active raw compatibility file set +against a reviewed list. Slice 94 adds the complementary retired-boundary +ratchet: retired files are named separately and checked directly for the raw +compatibility pattern. + +The first retired file is `CoordinateFactExport.ts`. It now uses transfer +operation constants and no longer owns legacy content operation spellings. The +audit therefore has two responsibilities: + +- active raw-boundary files must remain explicit and documented; +- retired raw-boundary files must not regain raw compatibility spelling. + +## Acceptance Criteria + +- The closeout audit has a retired raw-boundary list. +- `CoordinateFactExport.ts` is listed as retired in the original closeout + design document. +- The retired-boundary test fails if the file regains `decodePropKey`, + `decodeEdgePropKey`, `state.prop`, or lowercase content compatibility + spelling. + +## Test Plan + +Run the v18 content/property closeout audit test, Markdown lint, typecheck, and +`git diff --check`. diff --git a/docs/design/0243-v18-release-candidate-evidence/v18-release-candidate-evidence.md b/docs/design/0243-v18-release-candidate-evidence/v18-release-candidate-evidence.md new file mode 100644 index 000000000..6ffc65be3 --- /dev/null +++ b/docs/design/0243-v18-release-candidate-evidence/v18-release-candidate-evidence.md @@ -0,0 +1,50 @@ +--- +cycle: 0243 +task_id: V18_release_candidate_evidence +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 95 +--- + +# V18 Release Candidate Evidence + +## Hill + +Cut the v18 release-candidate evidence packet without pretending this branch +is already a release tag. + +## Design + +The release candidate packet lives at +`docs/releases/v18.0.0-rc/README.md`. It names: + +- candidate scope; +- inspectable evidence points; +- go/no-go gates for a release-candidate PR; +- stricter gates required before a public v18 tag; +- residual risks around legacy content/property compatibility and translated + Continuum evidence. + +`CHANGELOG.md` receives an Unreleased entry summarizing the v18 release +candidate evidence. BEARING marks the slice complete and points at this design +record. + +## Acceptance Criteria + +- A release-candidate evidence packet exists. +- The packet distinguishes PR readiness from public tag readiness. +- The packet names generated-contract evidence and residual translated + witnesshood risk. +- The changelog summarizes the candidate evidence. +- BEARING marks slice 95 complete. + +## Test Plan + +Run Markdown lint against BEARING, CHANGELOG, this design document, and the +release-candidate packet. Run the unit suite, typecheck, and `git diff --check` +as final local checks for this branch batch. diff --git a/docs/design/0244-v18-backlog-reconciliation/v18-backlog-reconciliation.md b/docs/design/0244-v18-backlog-reconciliation/v18-backlog-reconciliation.md new file mode 100644 index 000000000..47933ada4 --- /dev/null +++ b/docs/design/0244-v18-backlog-reconciliation/v18-backlog-reconciliation.md @@ -0,0 +1,56 @@ +--- +cycle: 0244 +task_id: V18_backlog_reconciliation +status: Complete +sponsors: + human: James + agent: Codex +started_at: 2026-05-24 +completed_at: 2026-05-24 +release_home: v18.0.0 +bearing_task: 96 +--- + +# V18 Backlog Reconciliation + +## Hill + +Bring the backlog ledger back into alignment with the v18 release-candidate +evidence without widening the v18 promise. + +## Design + +This slice reconciles four planning surfaces: + +- the backlog dashboard in `docs/method/backlog/README.md`; +- the active v18 lane in `docs/method/backlog/v18.0.0/`; +- the shipped-but-residual v17 lane; +- the v20 horizon, where end-to-end graph streaming reads and writes now have + a named release home. + +The key planning correction is that the v18 backlog files were behind the +implementation evidence recorded in `BEARING`. Production-runtime scratch +replay, wet-run fixture evidence, guarded CLI finalization, generated +Continuum contract evidence, and the release-candidate packet are now +recorded as completed evidence. The remaining v18 public-release gate is +release hygiene and residual-risk review, not another feature expansion. + +End-to-end graph streaming reads and writes are explicitly slotted into +`v20.0.0`. `v18.0.0` may carry stream foundations, but it does not claim full +graph streaming. + +## Acceptance Criteria + +- Backlog dashboard counts match the current file inventory. +- The v17 lane is described as shipped/residual, not active release work. +- The v18 lane records slices 66 through 95 as release-candidate evidence. +- The v18 release-blocker note names the remaining release-prep gates instead + of completed implementation blockers. +- A v20 backlog note exists for end-to-end graph streaming reads and writes. +- `BEARING` records this reconciliation as slice 96. + +## Test Plan + +- Run Markdown lint against the edited backlog, design, and bearing docs. +- Run `git diff --check`. +- Inspect `git diff` before committing to confirm the slice remains docs-only. diff --git a/docs/method/backlog/README.md b/docs/method/backlog/README.md index e1772b1cd..64b85bc3a 100644 --- a/docs/method/backlog/README.md +++ b/docs/method/backlog/README.md @@ -15,27 +15,28 @@ workspace." Until that drift is fixed, repo-truth parsing of ## Snapshot Current repo truth for live backlog notes, excluding backlog meta docs -such as `README.md`, `SCORECARD.md`, and `WORKLOADS.md`: +such as `README.md`, `SCORECARD.md`, `WORKLOADS.md`, and +`RELEASE_TRIAGE.md`: | Metric | Count | |--------|------:| -| Live backlog items | 490 | +| Live backlog items | 487 | | Root backlog items | 33 | | `asap/` | 0 | | `bad-code/` | 244 | | `cool-ideas/` | 108 | | `inbox/` | 5 | -| `up-next/` | 37 | +| `up-next/` | 36 | | `v17.0.0/` | 38 | -| `v18.0.0/` | 8 | +| `v18.0.0/` | 5 | | `v19.0.0/` | 11 | -| `v20.0.0/` | 2 | +| `v20.0.0/` | 3 | | `v21.0.0/` | 4 | -| Items with YAML frontmatter | 490 | +| Items with YAML frontmatter | 487 | | Items without YAML frontmatter | 0 | -| Items with explicit `id` | 490 | -| Items declaring dependency fields | 490 | -| Items with explicit `feature` | 485 | +| Items with explicit `id` | 487 | +| Items declaring dependency fields | 487 | +| Items with explicit `feature` | 482 | | Distinct explicit feature values | 17 | | `bad-code/` items with explicit `release_home` | 244 | | Items with non-empty explicit dependency edges | 59 | @@ -126,7 +127,7 @@ lanes. | `B0` | `inbox/` | Raw capture. No commitment, no scheduling, no downstream guarantees. | Triage only. | | `B1` | backlog root | Unlaned maintenance and reference work. Needs classification or direct pull before it should block committed work. | `B0` or direct human pull. | | `B2` | `bad-code/` | Foundational debt and invariant repair. This lane can legitimately block release or execution work. | `B0`, `B1`, or lower-level debt in `B2`. | -| `B3` | `v17.0.0/` | Current committed release work. Explicit per-note edges win here. | `B2`, same-band work, or explicit edges. | +| `B3` | `v17.0.0/` | Shipped release residual work. Explicit per-note edges win here, but notes must be rehomed before blocking later majors. | `B2`, same-band work, or explicit edges. | | `B4` | `v18.0.0/`, `up-next/` | Next-major graph-model work plus unslotted near-term feature overflow after the active release. | `B2`, `B3`, or explicit same-band edges. | | `B5` | `v19.0.0/` | Doctrine/runtime follow-through after the substrate cut. | `B2`, `B3`, `B4`, or explicit same-band edges. | | `B6` | `v20.0.0/`, `v21.0.0/`, `cool-ideas/` | Far-horizon slice-first/runtime and distributed/plural follow-through plus speculative orbit. | Promotion into another lane or completion of lower numbered bands. | @@ -138,7 +139,7 @@ lanes. | `inbox/` | `B0` | Anything here is blocked on triage, not implementation. | | backlog root | `B1` | These notes are real work, but still need lane assignment or an explicit pull decision. When `feature:` is present, treat that as the subsystem home while release-home remains undecided. | | `bad-code/` | `B2` | Invariant debt can block `v17.0.0`, `v18.0.0`, and `up-next`. | -| `v17.0.0/` | `B3` | This is the active release graph for TypeScript migration and streaming ORSets. | +| `v17.0.0/` | `B3` | This is shipped release residual work after `v17.0.0` and `v17.0.1`; rehome notes before treating them as blockers. | | `v18.0.0/` | `B4` | This is the next-major graph-model convergence lane. | | `up-next/` | `B4` | Feature-overflow queue behind the active release and next-major graph-model work unless explicitly promoted into a numbered lane. | | `v19.0.0/` | `B5` | Doctrine, observer, and admission convergence after the substrate cut. | @@ -165,9 +166,9 @@ justifies a stronger sequencing rule. Current explicit-graph totals: -- `490` notes define an `id` -- `490` notes declare `blocks` and `blocked_by` fields -- `485` notes currently declare an explicit `feature` +- `487` notes define an `id` +- `487` notes declare `blocks` and `blocked_by` fields +- `482` notes currently declare an explicit `feature` - `244` `bad-code/` notes currently declare an explicit `release_home` - `59` notes currently name at least one non-empty upstream or downstream edge @@ -257,25 +258,29 @@ Invariant counts: | Legend | Count | |--------|------:| +| `ARCH` | 1 | | `BND` | 9 | | `CAST` | 9 | | `DX` | 1 | | `HEX` | 19 | +| `IDX` | 1 | +| `MAT` | 1 | | `MODEL` | 22 | | `OWN` | 32 | | `PORT` | 12 | +| `PROV` | 1 | +| `SLUDGE` | 2 | | `SPEC` | 119 | | `SUB` | 15 | -### `v17.0.0/` — `B3` Active Release Graph +### `v17.0.0/` — `B3` Shipped Release Residual Work Dependency posture: - explicit frontmatter edges override lane inheritance -- release notes may be blocked by `bad-code/` debt when they touch the - same invariant or subsystem -- this lane is intentionally limited to TypeScript migration, - streaming ORSets, and current-substrate modernization +- `v17.0.0` and `v17.0.1` have shipped +- notes in this lane need archive, rehome, or explicit pull decisions before + they block later release work Canonical lane readme: @@ -316,7 +321,8 @@ Prefix counts: | Prefix | Count | |--------|------:| | `INFRA` | 1 | -| `PROTO` | 6 | +| `PROTO` | 2 | +| `RELEASE` | 1 | | `TRUST` | 1 | ### `v19.0.0/` — `B5` Doctrine And Runtime Convergence @@ -356,6 +362,7 @@ Prefix counts: | Prefix | Count | |--------|------:| +| `PERF` | 1 | | `PROTO` | 2 | ### `v21.0.0/` — `B6` Distributed And Plural Runtime Follow-Through @@ -401,7 +408,7 @@ Prefix counts: | `INFRA` | 2 | | `NDNM` | 4 | | `PERF` | 4 | -| `PROTO` | 14 | +| `PROTO` | 13 | | `TRUST` | 1 | | `VIZ` | 1 | @@ -436,8 +443,8 @@ today, but it also makes the cleanup sequence clear: 1. Normalize frontmatter on `inbox/`, backlog-root notes, and any new release-lane promotions. -2. Keep `v17.0.0/` and `v18.0.0/` as the most explicit hand-authored - dependency graphs. +2. Keep `v18.0.0/` as the most explicit hand-authored dependency graph and + triage `v17.0.0/` residual notes before they block later work. 3. Add `id` fields to `bad-code/` in invariant bundles instead of one giant sweep. 4. Promote `up-next/` notes into numbered release lanes as soon as they name diff --git a/docs/method/backlog/bad-code/RELEASE_TRIAGE.md b/docs/method/backlog/bad-code/RELEASE_TRIAGE.md index cd52922ce..2c0752e33 100644 --- a/docs/method/backlog/bad-code/RELEASE_TRIAGE.md +++ b/docs/method/backlog/bad-code/RELEASE_TRIAGE.md @@ -13,7 +13,7 @@ After the first metadata cleanup pass: | Release Home | Count | Read | |--------------|------:|------| -| `v17.0.0` | 195 | Current-engine cleanup bucket. Includes runtime-deletion fallout and stale-card rechecks. | +| `v17.0.0` | 195 | Shipped-release residual bucket. Includes runtime-deletion fallout and stale-card rechecks that need archive, rehome, or explicit pull decisions. | | `v18.0.0` | 14 | Graph-substrate cards promoted out of generic v17 cleanup. | | `v19.0.0` | 13 | Observer/admission/runtime-doctrine cleanup. | | `v20.0.0` | 15 | Slice-first read, index, traversal, and materialization-cost work. | @@ -35,8 +35,10 @@ Use the release theme, not the filename prefix, as the slotting rule: ## `v17.0.0` Fit -The current `v17.0.0` bad-code population is mostly legitimate because -v17 is the cleanup release that makes the current engine packageable: +The current `v17.0.0` bad-code population was mostly legitimate while v17 was +the cleanup release that made the current engine packageable. Because +`v17.0.0` and the `v17.0.1` release repair have shipped, this population is +now residual rather than an active release gate: - `api-capabilities` - `runtime-boundaries` @@ -47,7 +49,7 @@ v17 is the cleanup release that makes the current engine packageable: - current `materialization-query-index` - docs/DX debt needed for a credible v17 package -Cards pulled forward from `v20.0.0+` for recheck during the current +Cards pulled forward from `v20.0.0+` for recheck during the old `WarpRuntime` death line: - `CAST_callInternalRuntimeMethod-escape-hatch` @@ -56,10 +58,11 @@ Cards pulled forward from `v20.0.0+` for recheck during the current - `OWN_warpruntime-delegation-dry` - `PORT_worldline-encapsulation` -Why: these are not future merge/observer-geometry work. They are -symptoms of the old synchronous `WarpRuntime` center of gravity. If -`WarpRuntime` deletion removes the smell, graveyard them. If it does -not, keep them in v17 because they block the honest core/API boundary. +Why: these were not future merge/observer-geometry work. They were symptoms of +the old synchronous `WarpRuntime` center of gravity. Recheck them before using +them as blockers: if later runtime work removed the smell, archive the card; +if the smell survived, rehome it to the release that now owns the affected +boundary. Other v17 recheck candidates: diff --git a/docs/method/backlog/v17.0.0/README.md b/docs/method/backlog/v17.0.0/README.md index 37e399f8d..7733f176d 100644 --- a/docs/method/backlog/v17.0.0/README.md +++ b/docs/method/backlog/v17.0.0/README.md @@ -1,11 +1,15 @@ # v17.0.0 backlog -This lane is the live `v17.0.0` open-work queue only. +This lane is the shipped `v17.0.0` residual-work queue. The full release-program ledger, including shipped milestones, historical checklist state, and narrative context, now lives in [docs/releases/v17.0.0/README.md](../../../releases/v17.0.0/README.md). +`v17.0.0` and the `v17.0.1` release repair have shipped. Notes that remain in +this directory are not the active release plan; they are residual work that +needs future archive, rehome, or explicit pull decisions. + ## Scope `v17.0.0` is limited to: @@ -21,8 +25,10 @@ and doctrine convergence are deferred to ## Practical rule -- Treat the `.md` notes in this directory as the backlog. +- Treat the `.md` notes in this directory as residual backlog, not current + release blockers. - Treat the release ledger in `docs/releases/v17.0.0/` as historical program context, not as backlog inventory. - Use explicit frontmatter edges on note files over prose summaries when they disagree. +- Rehome any note before using it to block `v18.0.0` or later release work. 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 f6f545114..8e2e1ccc8 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 @@ -45,7 +45,7 @@ V18 slices 36 through 45 completed the non-destructive foundation: 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: +Migration-tool work through slice 65 was intentionally ordered as: - slice 46: create v17 golden graph-history fixtures and restore checks (complete); @@ -77,6 +77,29 @@ Remaining migration-tool work is intentionally ordered as: - slice 65: replan with command-CLI evidence in hand and set the next production-runtime replay goalpost (complete). +Slices 66 through 95 converted that command-CLI evidence into +release-candidate evidence: + +- production-runtime scratch replay conformance exists for the canonical + restored v17 wet-run path; +- restored legacy and scratch public-read builders participate in the wet-run + equivalence gate; +- the wet-run harness captures deterministic operator reports and drift + checks; +- canonical public-read equivalence reaches zero mismatches; +- CLI finalization is enabled only behind reviewed JSON confirmation that + matches observed runtime evidence; +- stale live refs and pre-existing archive refs block finalization with + structured reports; +- generated Continuum runtime-boundary fixtures and a `warp-ttd` + generated-family smoke are recorded as release evidence; +- a release-candidate packet now names the remaining public-tag gates and + residual risks. + +The migration tool is no longer blocked on the earlier scratch replay, +finalization design, wet-run harness, or generated-contract tie-back items. +The remaining v18 work is public-release hygiene and residual-risk review. + ## Starting points - `scripts/migrations/` diff --git a/docs/method/backlog/v18.0.0/PROTO_content-attachment-plane-cutover.md b/docs/method/backlog/v18.0.0/PROTO_content-attachment-plane-cutover.md index 7e9b447d7..45f407342 100644 --- a/docs/method/backlog/v18.0.0/PROTO_content-attachment-plane-cutover.md +++ b/docs/method/backlog/v18.0.0/PROTO_content-attachment-plane-cutover.md @@ -25,6 +25,21 @@ payload-bearing graph object. - the migration path from legacy content props is deterministic and verified +## Progress + +V18 slices 21 through 25 introduced runtime-backed content payload nouns, +content attachment projection, public content reads through projection, and +typed content write intents before compatibility-property lowering. + +Later migration slices made content attachment evidence part of the canonical +wet-run equivalence path and aligned fixture content attachment evidence with +runtime content object ids. + +This item remains partially open because content persistence still has named +legacy `_content*` compatibility boundaries. V18 release notes must either +accept that residual risk explicitly or a final retirement slice must remove +the remaining storage dependency before public release. + ## Starting points - `docs/specs/CONTENT_ATTACHMENT.md` diff --git a/docs/method/backlog/v18.0.0/README.md b/docs/method/backlog/v18.0.0/README.md index 0bd4e1665..8da8bb509 100644 --- a/docs/method/backlog/v18.0.0/README.md +++ b/docs/method/backlog/v18.0.0/README.md @@ -55,8 +55,11 @@ LAYER 1 (behavioral convergence): [x] PROTO_legacy-props-as-projection LAYER 2 (migration and proof): - [~] INFRA_graph-model-migration-tool - [~] TRUST_genesis-replay-equivalence + [x] INFRA_graph-model-migration-tool + [x] TRUST_genesis-replay-equivalence + +LAYER 3 (public release): + [~] RELEASE_v18-public-release-blockers ``` ## Practical rule @@ -74,42 +77,32 @@ graph model. Change the envelope only if replay honesty requires it. ## Current Evidence -After v18 slice 49, the migration path is intentionally still -non-destructive but now has persisted-history evidence: - -- dry-run request JSON can be decoded at the infrastructure boundary; -- 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; -- v17 golden graph-history fixtures now precede write-capable migration work, - because compact fixtures do not prove the persisted Git object/ref layout; -- the first v17 golden fixture restores real `refs/warp/*` writer refs from a - Git bundle and validates manifest heads, patch counts, and visible fact - families; -- restored source inventory collection now reads real writer refs and patch - commit trailers into migration-domain source inventory; -- operation lowering now creates write-ready migration operation facts from - successful dry-run plans without writing history; -- scratch writing now creates deterministic operation commits under explicit - `refs/warp-migration-scratch/*` refs and refuses live graph refs; -- scratch equivalence now gates promotion on proof success, first-divergence - reporting, and required patch-boundary evidence; -- finalization safety now requires explicit confirmation, archive ref - selection, scratch output evidence, a passed equivalence gate, and a matching - live-ref expected head before any live lineage promotion can be implemented; -- archive-preserving finalization now creates archive refs and advances live - refs only through expected-head `git update-ref` calls; -- command wiring now runs planning, lowering, scratch writing, equivalence, - and optional finalization in order while keeping finalization off by default; -- finalization now also requires runtime conformance evidence tied to the - exact scratch ref and head, making the remaining real-runtime replay provider - an explicit release blocker instead of an implicit assumption; -- raw content/property compatibility boundaries are now enumerated by an - executable closeout audit so new raw boundaries require deliberate review. -- public-release blockers are now explicit in - [`RELEASE_v18-public-release-blockers.md`](RELEASE_v18-public-release-blockers.md), - including production-runtime replay, live finalization CLI design, wet-run - fixture harnessing, Continuum contract tie-back, and operator release notes. +After v18 slice 95, the migration path has release-candidate evidence: + +- dry-run planning, source inventory, operation lowering, scratch writing, + equivalence gating, and optional finalization are wired as command stages; +- a restored v17 golden fixture can be migrated through scratch history and + opened through the production graph runtime during the wet run; +- canonical wet-run public-read equivalence now reaches zero mismatches with + explicit patch-boundary evidence; +- CLI finalization is guarded behind a reviewed JSON confirmation artifact + and blocks stale live refs, existing archive refs, failed equivalence, failed + runtime replay, and mismatched confirmation evidence; +- generated Continuum/WARP Optic contract evidence is ingested from local + generated artifacts and includes a `warp-ttd` generated-family smoke; +- the closeout audit enumerates remaining raw content/property compatibility + boundaries and has a retired-boundary ratchet for the coordinate fact export + cut; +- release-candidate evidence now names candidate scope, go/no-go gates, + public-tag gates, and residual risks. + +The remaining public v18 work is release hygiene and residual-risk review: + +- full local release-prep gates and GitHub CI on the final release branch; +- package/version/tag work for the public release; +- operator release notes that distinguish graph-model convergence from later + Continuum admission shells; +- an explicit decision on remaining raw content/property storage retirement + risk; +- an explicit non-claim that v18 does not provide end-to-end graph streaming + reads and writes. diff --git a/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md b/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md index 43cb85719..a2fb92731 100644 --- a/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md +++ b/docs/method/backlog/v18.0.0/RELEASE_v18-public-release-blockers.md @@ -18,32 +18,42 @@ migration safety than the repository can prove. ## Done looks like - scratch output is opened through the production graph runtime, not only - operation-history readback + operation-history readback; - live-ref finalization from the CLI has its own confirmation design, - drift checks, archive evidence, and report output + drift checks, archive evidence, and report output; - the v17 golden graph fixture has a wet-run migration path that restores the fixture, writes scratch history, runs equivalence, and captures the operator - report + report; - Continuum/WARP Optic contract evidence is tied back to generated artifacts, - not only handwritten compatibility prose + not only handwritten compatibility prose; - release notes clearly distinguish v18 graph-model convergence from later - Continuum admission shells + Continuum admission shells; +- public release gates are run on the release branch before tagging. -## Current blockers +## Completed Release-Candidate Evidence -| Blocker | Why it blocks public release | Evidence now | -|---------|------------------------------|--------------| -| Production-runtime scratch replay | Operation-history readback proves the scratch commits are parseable, but not that the normal graph runtime can open the migrated history. | `GraphModelMigrationScratchRuntimeConformanceProvider` is intentionally operation-derived. | -| Live finalization CLI design | The command can finalize through the API, but the shell wrapper correctly refuses live-ref finalization flags until operator confirmation semantics are designed. | `GraphModelMigrationCommandCli` rejects `--finalize` and related flags. | -| Wet-run fixture harness | The v17 fixture and scratch writer exist separately; the release gate needs one reproducible wet run that restores the fixture and executes the wrapper. | Fixture restore, source inventory, scratch writer, command wrapper, and report formatter exist. | -| Continuum contract tie-back | v18 is aimed at WARP Optic compatibility, so release claims need generated contract evidence from Wesley/Continuum artifacts. | Earlier slices recorded readiness and source facts, but graph-model migration work is still mostly git-warp-local. | -| Operator release notes | Users need plain release guidance on what v18 migrates, what it does not migrate, and why Echo and git-warp remain sibling participants. | BEARING has the doctrine; release notes are not yet cut. | +| Evidence | Status | +|----------|--------| +| Production-runtime scratch replay | Complete for the canonical v17 wet-run path. | +| Live finalization CLI confirmation | Complete behind reviewed JSON confirmation. | +| Wet-run fixture harness | Complete for the canonical v17 fixture and zero-mismatch report. | +| Continuum contract tie-back | Complete for generated runtime-boundary fixtures and `warp-ttd` smoke. | +| Release-candidate evidence packet | Complete with public-tag gates and residual risks. | + +## Current Public-Release Blockers + +| Blocker | Why it still blocks public release | Required evidence | +|---------|------------------------------------|-------------------| +| Final release-prep gates | Release-candidate evidence is not a public tag. | `npm run release:preflight`, local required gates, and GitHub CI pass on the final release branch. | +| Package, version, and tag work | The package line is still `17.0.1`. | `package.json`, `jsr.json` if applicable, changelog, tag, and publish artifacts agree. | +| Residual raw content/property risk | v18 still carries named legacy compatibility boundaries. | Release notes either accept the residual risk explicitly or a final retirement slice removes the blocker. | +| Operator release notes | Users need exact migration and finalization guidance. | Public notes explain dry run, scratch writing, guarded finalization, archives, rollback posture, and non-goals. | +| Streaming overclaim guard | v18 has stream foundations but not end-to-end graph streaming. | Public docs state that full graph streaming reads and writes are a v20 goal, not a v18 claim. | ## Next pull candidates -- Design and implement production-runtime scratch replay conformance. -- Design live-ref finalization CLI confirmation and report behavior. -- Add a fixture wet-run command or documented harness around the current - restore plus command CLI path. -- Attach generated Continuum/WARP Optic contract evidence to the v18 release - gate. +- Run the final release-prep gate set on a release branch. +- Decide whether remaining raw content/property storage retirement blocks + public v18 or becomes explicitly accepted residual risk. +- Freeze public release notes and migration operator docs. +- Cut package/version/tag changes only after the gates pass. 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 e0a12b0c1..b628f3540 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 @@ -45,10 +45,9 @@ Slice 50 added the first promotion gate over that proof vocabulary: - otherwise-equivalent readings still block promotion when visible facts lack patch-boundary evidence. -This is now a gate vocabulary, but not yet the complete ship gate. The -remaining trust work is to construct legacy and scratch readings from real -Git history and replace test-supplied runtime conformance evidence with a real -runtime replay provider. +This became the gate vocabulary for the migration command. Later slices built +legacy and scratch readings from real fixture history, added runtime +conformance evidence, and used the proof result as a finalization precondition. Slice 56 added a pure reading builder for the v17 golden fixture manifest. It is a bridge from persisted fixture metadata to equivalence facts, but it is @@ -62,6 +61,20 @@ Slices 59 through 61 added operation-history readback conformance and command coverage proving that readable scratch output still cannot finalize when the legacy and scratch readings diverge. +Slices 66 through 95 closed the release-candidate trust loop: + +- production-runtime scratch replay participates in the wet-run gate; +- restored legacy and scratch public-read readings reach zero canonical + mismatches; +- finalization requires equivalence, runtime replay, live-ref expectation, + archive target, and matching confirmation evidence; +- generated Continuum contract fixtures and the first `warp-ttd` + generated-family smoke are recorded as translated-substrate evidence. + +Remaining public-release trust work is to rerun the full gate set on the final +release branch and decide how to describe residual raw content/property +compatibility risk. + ## Starting points - `test/` diff --git a/docs/method/backlog/v20.0.0/PERF_end-to-end-graph-streaming.md b/docs/method/backlog/v20.0.0/PERF_end-to-end-graph-streaming.md new file mode 100644 index 000000000..6fbfb66d3 --- /dev/null +++ b/docs/method/backlog/v20.0.0/PERF_end-to-end-graph-streaming.md @@ -0,0 +1,102 @@ +--- +id: PERF_end-to-end-graph-streaming +feature: materialization-query-index +blocked_by: + - PROTO_bounded-support-rules-for-query-surfaces + - PROTO_causal-indexes-for-sliced-queries + - PROTO_support-scoped-fragment-materialization +blocks: [] +--- + +# End-To-End Graph Streaming Reads And Writes + +## Release Home + +Primary release home: `v20.0.0`. + +`v19.0.0` defines the observer, support, index, and cost contracts required +to make streaming claims honest. `v20.0.0` is where those contracts become +ordinary runtime behavior for graph reads, graph writes, traversal, +migration, and large result surfaces. + +`v21.0.0` extends the same discipline into witnessed suffix admission, braid +collapse, local-site merge, and distributed/plural semantics. + +## Problem + +The repo has useful stream primitives and stream-capable ports, but the +runtime still contains many full-state and full-patch materialization +assumptions. + +Examples of the current risk: + +- content byte reads can stream only after a materialized read locates the + content object id; +- materialization still loads patch collections into memory in important + paths; +- query and observer surfaces can expose stream-shaped results while still + depending on cached full state; +- buffered blob, tree, and patch APIs can hide full residency behind friendly + method names. + +That means the project must not claim end-to-end graph streaming until the +runtime proves it from storage boundary through public API. + +## Desired End State + +Ordinary graph reads and writes can operate without assuming the whole graph +or the whole patch set fits in memory. + +The target includes: + +- patch input consumed as `AsyncIterable` or an equivalent stream noun; +- reducer paths that can consume streamed patch facts under a support rule; +- read APIs that return stream, page, or cursor surfaces when result size is + not bounded by the API contract; +- traversal APIs that do not require prebuilding the full result set; +- graph write APIs that can ingest streamed operation facts for imports, + migrations, generated contract application, and large transformations; +- blob and attachment reads/writes that are truly streaming end to end when + the adapter claims streaming support; +- global operators that can page, spill, sort externally, or run multiple + streaming passes instead of requiring full graph residency; +- memory witness tests that fail when a blessed streaming path falls back to + `collect()`, full-state clone, or full graph materialization. + +## Non-Goals + +- Do not make `v18.0.0` claim full graph streaming. +- Do not pretend every graph question is local or bounded. +- Do not ban global questions; make their residency strategy explicit. +- Do not call an API streaming when it only streams the final array after + whole-state materialization. +- Do not require distributed braid/admission streaming in `v20.0.0`; that + follow-through belongs in `v21.0.0`. + +## Acceptance Criteria + +- At least one ordinary local read path proves bounded memory from storage + boundary to public API. +- At least one large graph result path exposes a stream, page, or cursor API + and proves it does not materialize the result set first. +- Materialization can consume streamed patch input for at least one support + rule without first collecting all patch facts. +- Migration or import can write streamed operation facts without requiring a + complete in-memory rewrite plan for the final graph history. +- Attachment streaming support is adapter-honest: buffered fallbacks are + named as bounded or legacy behavior, not silently presented as streaming. +- Public docs and API docs distinguish local, global, streamed, paged, + indexed, degraded, and full-materialization fallback paths. + +## Test Plan + +- Add constrained-memory witnesses for blessed streaming read paths. +- Add large-fixture tests for stream/page/cursor result surfaces. +- Add regression tests that fail if a streaming path calls `collect()` on an + unbounded stream. +- Add adapter parity tests for blob and attachment streaming, including + buffered fallback disclosure. +- Add materialization tests that feed patch facts as an async iterable and + verify the reducer path does not pre-collect the input. +- Add docs shape tests that prevent `v18.0.0` release notes from claiming + full graph streaming. diff --git a/docs/method/backlog/v20.0.0/README.md b/docs/method/backlog/v20.0.0/README.md index b5013cea6..c220fde2f 100644 --- a/docs/method/backlog/v20.0.0/README.md +++ b/docs/method/backlog/v20.0.0/README.md @@ -16,6 +16,7 @@ Primary design references: Current promoted items: +- `PERF_end-to-end-graph-streaming` - `PROTO_playback-head-alignment` - `PROTO_strand-collapse-optic-for-causal-slicing` @@ -23,5 +24,7 @@ Rule: - `v20` is where slice-first runtime behavior becomes ordinary execution instead of doctrine-only language +- end-to-end graph streaming reads and writes are a `v20` runtime-realization + goal, not a `v18` graph-substrate release claim - keep distributed/plural semantics that still require a settled slice-first runtime in `v21.0.0/` diff --git a/docs/releases/v18.0.0-rc/README.md b/docs/releases/v18.0.0-rc/README.md new file mode 100644 index 000000000..3f9d44baa --- /dev/null +++ b/docs/releases/v18.0.0-rc/README.md @@ -0,0 +1,82 @@ +# V18.0.0 Release Candidate Evidence + +Status: release-candidate evidence packet, not a published tag. + +Date: 2026-05-24. + +## Candidate Scope + +The v18 release candidate adds the first complete graph-model migration proof +runway: + +- deterministic v17 golden graph-history fixture restore; +- read-only restored source inventory collection; +- pure dry-run graph-model migration planning; +- operation lowering into scratch migration commits; +- scratch equivalence gating against legacy fixture readings; +- production-runtime scratch replay and public-read wet-run proof; +- guarded archive-preserving live finalization behind reviewed JSON + confirmation; +- generated Continuum runtime-boundary fixture ingestion and graph-model + conformance; +- first `warp-ttd` generated-family smoke fact for translated git-warp + evidence; +- executable raw content/property boundary audit with one retired-boundary + ratchet. + +## Evidence + +The current branch has these inspectable evidence points: + +- `fixtures/v17/graph-model-golden/manifest.json` names the canonical v17 + fixture writer refs, expected heads, patch counts, and visible fact families. +- `scripts/v18.0.0/migrations/graph-model/` contains the dry-run, scratch, + wet-run, runtime-conformance, finalization, and CLI command path. +- `test/fixtures/continuum/runtime-boundary-family-generated-artifact.json` + is admitted as generated runtime-boundary contract evidence. +- `GitWarpGraphModelContractConformance` ties the admitted descriptor to the + v17 graph-model fixture fact families. +- `GitWarpWarpTtdGeneratedFamilySmoke` converts passed conformance into a + `PRESENT` translated-substrate fact for the `warp-ttd` target. +- `test/unit/scripts/v18-content-property-closeout-audit.test.ts` enforces the + active raw-boundary list and the retired `CoordinateFactExport.ts` ratchet. + +## Go/No-Go + +Go for a v18 release candidate PR when these gates are green: + +- local unit suite; +- source and test typecheck; +- Markdown lint for the candidate docs and BEARING; +- raw content/property closeout audit; +- GitHub CI on the PR branch. + +Do not cut the public v18 tag until these additional release gates pass on the +release-prep branch: + +- `npm run lint`; +- `npm run test:coverage`; +- `npm run lint:sludge`; +- `npm run lint:semgrep`; +- `npm run typecheck:consumer`; +- `npm run typecheck:surface`; +- `npm run release:preflight`; +- package version and `jsr.json` version agree; +- `CHANGELOG.md` has the dated v18 entry. + +## Residual Risks + +- Content persistence still uses legacy `_content*` compatibility properties. +- Raw property-map boundaries remain in reducers, replay, serialization, + logical index construction, visible-scope filtering, and migration-source + compatibility. +- Generated Continuum evidence is translated git-warp evidence, not native + Continuum witnesshood. +- The release candidate packet is not a tag. It is the evidence checkpoint used + to decide whether to cut the actual release-prep branch. + +## Recommendation + +Proceed to PR review for the slice-86-through-95 branch after local final +checks pass. Treat the next goalpost as release-prep hardening, not more +feature work, unless CI or review surfaces a blocker. diff --git a/fixtures/v17/graph-model-golden/README.md b/fixtures/v17/graph-model-golden/README.md index 8866a61cf..92897706c 100644 --- a/fixtures/v17/graph-model-golden/README.md +++ b/fixtures/v17/graph-model-golden/README.md @@ -36,5 +36,5 @@ Regeneration must preserve deterministic commit inputs: - `refs/warp/v17-golden-graph/writers/bob`. After regeneration, update `manifest.json` with the new writer heads and keep -the visible fact coverage over node, edge, property, content, removal, and -multi-writer cases. +the visible fact coverage over edge endpoint nodes, edge, node property, edge +property, content, removal, and multi-writer cases. diff --git a/fixtures/v17/graph-model-golden/manifest.json b/fixtures/v17/graph-model-golden/manifest.json index bdc0c7983..68c3095dd 100644 --- a/fixtures/v17/graph-model-golden/manifest.json +++ b/fixtures/v17/graph-model-golden/manifest.json @@ -24,6 +24,11 @@ "key": "node:alpha", "description": "Alice creates the primary node lifecycle subject." }, + { + "kind": "node", + "key": "node:beta", + "description": "Bob creates the stable edge endpoint subject." + }, { "kind": "edge", "key": "node:alpha->node:beta:relates", @@ -34,6 +39,11 @@ "key": "node:alpha:title", "description": "Alice and Bob cover legacy node property compatibility." }, + { + "kind": "property", + "key": "node:alpha->node:beta:relates:weight", + "description": "Alice and Bob cover legacy edge property compatibility." + }, { "kind": "content", "key": "node:alpha:_content", diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts index efa9abdee..c2129ff01 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommand.ts @@ -28,6 +28,8 @@ import GraphModelMigrationRuntimeConformanceResult import GraphModelMigrationScratchWriteResult from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; import { finalizeGraphModelMigration } from './GraphModelMigrationFinalizer.ts'; +import { reviewedGraphModelMigrationFinalizationSafetyResult } + from './GraphModelMigrationFinalizationReview.ts'; import { writeGraphModelMigrationScratchHistory } from './GraphModelMigrationScratchWriter.ts'; import { runMigrationGit } from './GitMigrationCommandRunner.ts'; @@ -48,6 +50,7 @@ export type GraphModelMigrationCommandFinalizationOptions = { readonly archiveRefName: string; readonly confirmation: GraphModelMigrationFinalizationConfirmation | null; readonly runtimeConformance: GraphModelMigrationRuntimeConformanceProvider | null; + readonly reviewedRequest?: GraphModelMigrationFinalizationRequest | null; }; export type GraphModelMigrationCommandOptions = { @@ -171,21 +174,23 @@ async function runFinalization(options: { '--hash', options.finalization.liveRefName, ]); - const safetyResult = new GraphModelMigrationFinalizationSafety().evaluate( - new GraphModelMigrationFinalizationRequest({ - liveRefName: options.finalization.liveRefName, - expectedLiveHead: options.finalization.expectedLiveHead, - observedLiveHead, - scratchRef: options.scratchWriteResult.scratchRef, - scratchHead: options.scratchWriteResult.scratchHead, - archiveRefName: options.finalization.archiveRefName, - confirmation: options.finalization.confirmation, - gateResult: options.gateResult, - runtimeConformance: await runtimeConformanceFromProvider( - options.finalization.runtimeConformance, - options.scratchWriteResult, - ), - }), + const request = new GraphModelMigrationFinalizationRequest({ + liveRefName: options.finalization.liveRefName, + expectedLiveHead: options.finalization.expectedLiveHead, + observedLiveHead, + scratchRef: options.scratchWriteResult.scratchRef, + scratchHead: options.scratchWriteResult.scratchHead, + archiveRefName: options.finalization.archiveRefName, + confirmation: options.finalization.confirmation, + gateResult: options.gateResult, + runtimeConformance: await runtimeConformanceFromProvider( + options.finalization.runtimeConformance, + options.scratchWriteResult, + ), + }); + const safetyResult = reviewedGraphModelMigrationFinalizationSafetyResult( + new GraphModelMigrationFinalizationSafety().evaluate(request), + options.finalization.reviewedRequest ?? null, ); return await finalizeGraphModelMigration({ repositoryPath: options.repositoryPath, diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts index b25b9e476..1e6488379 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts @@ -2,62 +2,41 @@ import { readFile, writeFile } from 'node:fs/promises'; import GenesisEquivalenceComparisonBasis from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; -import V17GoldenGraphFixtureGenesisReading - from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; import DryRunGraphModelMigrationPlanner from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanner.ts'; import { parseGraphModelMigrationDryRunRequest } from '../../../../src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts'; +import { parseGraphModelMigrationFinalizationRequest } + from '../../../../src/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.ts'; import { parseV17GoldenGraphFixtureManifestJson } from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; import { runGraphModelMigrationCommand } from './GraphModelMigrationCommand.ts'; import { formatGraphModelMigrationCommandReport } from './GraphModelMigrationCommandReport.ts'; -import { buildGraphModelMigrationScratchReading } from './GraphModelMigrationScratchReadingBuilder.ts'; +import { createGraphModelMigrationProductionRuntimeConformanceProvider } + from './GraphModelMigrationProductionRuntimeReplayProvider.ts'; +import { buildV17RestoredPublicReadLegacyReading } + from './V17RestoredPublicReadLegacyReadingBuilder.ts'; +import { createV17GoldenFixtureScratchReadingProvider } + from './V17GoldenGraphFixtureWetRunHarness.ts'; +import { + GraphModelMigrationCommandCliArgumentError, + GraphModelMigrationCommandCliArgs, + graphModelMigrationCommandUsage, + parseGraphModelMigrationCommandCliArgs, +} from './GraphModelMigrationCommandCliArgs.ts'; import type DryRunGraphModelMigrationPlan from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlan.ts'; +import type GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; import type GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; -const FINALIZATION_FLAGS = Object.freeze(new Set([ - '--finalize', - '--live-ref', - '--archive-ref', - '--expected-live-head', - '--confirmation', -])); - -export class GraphModelMigrationCommandCliArgumentError extends Error { - constructor(message: string) { - super(message); - this.name = 'GraphModelMigrationCommandCliArgumentError'; - } -} - -export class GraphModelMigrationCommandCliArgs { - readonly repositoryPath: string | null; - readonly requestPath: string | null; - readonly legacyFixtureManifestPath: string | null; - readonly scratchRefName: string | null; - readonly reportOutPath: string | null; - readonly helpRequested: boolean; - - constructor(options: { - readonly repositoryPath: string | null; - readonly requestPath: string | null; - readonly legacyFixtureManifestPath: string | null; - readonly scratchRefName: string | null; - readonly reportOutPath: string | null; - readonly helpRequested: boolean; - }) { - this.repositoryPath = options.repositoryPath; - this.requestPath = options.requestPath; - this.legacyFixtureManifestPath = options.legacyFixtureManifestPath; - this.scratchRefName = options.scratchRefName; - this.reportOutPath = options.reportOutPath; - this.helpRequested = options.helpRequested; - Object.freeze(this); - } -} +export { + GraphModelMigrationCommandCliArgumentError, + GraphModelMigrationCommandCliArgs, + graphModelMigrationCommandUsage, + parseGraphModelMigrationCommandCliArgs, +} from './GraphModelMigrationCommandCliArgs.ts'; export class GraphModelMigrationCommandCliResult { constructor( @@ -69,91 +48,6 @@ export class GraphModelMigrationCommandCliResult { } } -/** Returns CLI usage for the v18 graph-model migration command wrapper. */ -export function graphModelMigrationCommandUsage(): string { - return [ - 'Usage:', - [ - ' node scripts/v18.0.0/migrations/graph-model/migrate.ts', - '--repo ', - '--request ', - '--legacy-fixture-manifest ', - '--scratch-ref ', - '[--report-out ]', - ].join(' '), - '', - 'Options:', - ' --repo Git repository to receive scratch migration history.', - ' --request JSON migration request to validate and execute.', - ' --legacy-fixture-manifest V17 fixture manifest used for legacy equivalence reading.', - ' --scratch-ref refs/warp-migration-scratch/* target for scratch output.', - ' --report-out Also write the deterministic command report to this path.', - ' --help Show this help.', - '', - 'Finalization flags are intentionally refused by this wrapper until live-ref CLI finalization is designed.', - ].join('\n'); -} - -/** Parses command CLI arguments without reading or writing files. */ -export function parseGraphModelMigrationCommandCliArgs( - argv: readonly string[], -): GraphModelMigrationCommandCliArgs { - let repositoryPath: string | null = null; - let requestPath: string | null = null; - let legacyFixtureManifestPath: string | null = null; - let scratchRefName: string | null = null; - let reportOutPath: string | null = null; - let helpRequested = false; - - for (let index = 0; index < argv.length; index++) { - const arg = argv[index]; - if (arg === '--repo') { - repositoryPath = readArgValue(argv, index, '--repo'); - index++; - continue; - } - if (arg === '--request') { - requestPath = readArgValue(argv, index, '--request'); - index++; - continue; - } - if (arg === '--legacy-fixture-manifest') { - legacyFixtureManifestPath = readArgValue(argv, index, '--legacy-fixture-manifest'); - index++; - continue; - } - if (arg === '--scratch-ref') { - scratchRefName = readArgValue(argv, index, '--scratch-ref'); - index++; - continue; - } - if (arg === '--report-out') { - reportOutPath = readArgValue(argv, index, '--report-out'); - index++; - continue; - } - if (arg === '--help' || arg === '-h') { - helpRequested = true; - continue; - } - if (arg !== undefined && FINALIZATION_FLAGS.has(arg)) { - throw new GraphModelMigrationCommandCliArgumentError( - 'finalization is not supported by this CLI wrapper yet', - ); - } - throw new GraphModelMigrationCommandCliArgumentError(`Unknown argument: ${arg ?? ''}`); - } - - return new GraphModelMigrationCommandCliArgs({ - repositoryPath, - requestPath, - legacyFixtureManifestPath, - scratchRefName, - reportOutPath, - helpRequested, - }); -} - /** Runs the v18 graph-model migration command wrapper. */ export async function runGraphModelMigrationCommandCli( argv: readonly string[], @@ -171,6 +65,11 @@ export async function runGraphModelMigrationCommandCli( ); const dryRunRequest = parseGraphModelMigrationDryRunRequest(requestText); const legacyManifest = parseV17GoldenGraphFixtureManifestJson(legacyManifestText); + const finalizationRequest = args.finalizationRequestPath === null + ? null + : parseGraphModelMigrationFinalizationRequest( + await readFile(args.finalizationRequestPath, 'utf8'), + ); const preflightPlan = new DryRunGraphModelMigrationPlanner().plan(dryRunRequest); if (preflightPlan.hasFatalErrors() || preflightPlan.manifest === null) { return new GraphModelMigrationCommandCliResult(1, preflightFailureReport(preflightPlan), ''); @@ -189,14 +88,17 @@ export async function runGraphModelMigrationCommandCli( legacyReading: null, scratchReading: null, readingProviders: { - legacyReading: async () => new V17GoldenGraphFixtureGenesisReading().build(legacyManifest), - scratchReading: async () => await buildGraphModelMigrationScratchReading({ + legacyReading: async () => await buildV17RestoredPublicReadLegacyReading({ repositoryPath, - scratchRefName, - readingId: 'scratch:command-cli', + manifest: legacyManifest, + }), + scratchReading: createV17GoldenFixtureScratchReadingProvider({ + sourceRepositoryPath: repositoryPath, + manifest: legacyManifest, + runtimeRepositoryPath: null, }), }, - finalization: null, + finalization: finalizationOptions(finalizationRequest, repositoryPath, legacyManifest.graphId), }); const report = formatGraphModelMigrationCommandReport(result); if (args.reportOutPath !== null) { @@ -205,6 +107,27 @@ export async function runGraphModelMigrationCommandCli( return new GraphModelMigrationCommandCliResult(commandExitCode(result), report, ''); } +function finalizationOptions( + request: GraphModelMigrationFinalizationRequest | null, + repositoryPath: string, + graphId: string, +): Parameters[0]['finalization'] { + if (request === null) { + return null; + } + return { + liveRefName: request.liveRefName, + expectedLiveHead: requireFinalizationString(request.expectedLiveHead, 'expectedLiveHead'), + archiveRefName: requireFinalizationString(request.archiveRefName, 'archiveRefName'), + confirmation: request.confirmation, + runtimeConformance: createGraphModelMigrationProductionRuntimeConformanceProvider({ + sourceRepositoryPath: repositoryPath, + graphId, + }), + reviewedRequest: request, + }; +} + function commandExitCode(result: Awaited>): number { if ( !result.dryRunPlan.hasFatalErrors() @@ -213,6 +136,7 @@ function commandExitCode(result: Awaited', + '--request ', + '--legacy-fixture-manifest ', + '--scratch-ref ', + '[--report-out ]', + '[--finalization-request ]', + ].join(' '), + '', + 'Options:', + ' --repo Git repository to receive scratch migration history.', + ' --request JSON migration request to validate and execute.', + ' --legacy-fixture-manifest V17 fixture manifest used for legacy equivalence reading.', + ' --scratch-ref refs/warp-migration-scratch/* target for scratch output.', + ' --report-out Also write the deterministic command report to this path.', + ' --finalization-request JSON confirmation artifact required before live refs move.', + ' --help Show this help.', + '', + 'Legacy finalization flags are refused; use --finalization-request instead.', + ].join('\n'); +} + +/** Parses command CLI arguments without reading or writing files. */ +export function parseGraphModelMigrationCommandCliArgs( + argv: readonly string[], +): GraphModelMigrationCommandCliArgs { + let repositoryPath: string | null = null; + let requestPath: string | null = null; + let legacyFixtureManifestPath: string | null = null; + let scratchRefName: string | null = null; + let reportOutPath: string | null = null; + let finalizationRequestPath: string | null = null; + let helpRequested = false; + + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (arg === '--repo') { + repositoryPath = readArgValue(argv, index, '--repo'); + index++; + continue; + } + if (arg === '--request') { + requestPath = readArgValue(argv, index, '--request'); + index++; + continue; + } + if (arg === '--legacy-fixture-manifest') { + legacyFixtureManifestPath = readArgValue(argv, index, '--legacy-fixture-manifest'); + index++; + continue; + } + if (arg === '--scratch-ref') { + scratchRefName = readArgValue(argv, index, '--scratch-ref'); + index++; + continue; + } + if (arg === '--report-out') { + reportOutPath = readArgValue(argv, index, '--report-out'); + index++; + continue; + } + if (arg === '--finalization-request') { + finalizationRequestPath = readArgValue(argv, index, '--finalization-request'); + index++; + continue; + } + if (arg === '--help' || arg === '-h') { + helpRequested = true; + continue; + } + if (arg !== undefined && FINALIZATION_FLAGS.has(arg)) { + throw new GraphModelMigrationCommandCliArgumentError( + 'direct finalization flags are not supported; use --finalization-request ', + ); + } + throw new GraphModelMigrationCommandCliArgumentError(`Unknown argument: ${arg ?? ''}`); + } + + return new GraphModelMigrationCommandCliArgs({ + repositoryPath, + requestPath, + legacyFixtureManifestPath, + scratchRefName, + reportOutPath, + finalizationRequestPath, + helpRequested, + }); +} + +function readArgValue(argv: readonly string[], index: number, flag: string): string { + const value = argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith('--')) { + throw new GraphModelMigrationCommandCliArgumentError(`${flag} requires a value`); + } + return value; +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts index 3fd79e3e5..67eef14e0 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandReport.ts @@ -66,18 +66,32 @@ function finalizationLines(result: GraphModelMigrationCommandResult): readonly s if (result.finalizationResult === null) { return Object.freeze(['finalization: skipped']); } + const evidence = finalizationEvidenceLines(result); if (result.finalizationResult.fatalErrors.length > 0) { return Object.freeze([ `finalization: ${result.finalizationResult.status}`, + ...evidence, ...fatalNoticeLines(result.finalizationResult.fatalErrors), ]); } return Object.freeze([ `finalization: ${result.finalizationResult.status}`, - `liveRef: ${result.finalizationResult.liveRefName}`, - `archiveRef: ${displayNullable(result.finalizationResult.archiveRefName)}`, - `previousLiveHead: ${displayNullable(result.finalizationResult.previousLiveHead)}`, - `finalizedLiveHead: ${displayNullable(result.finalizationResult.finalizedLiveHead)}`, + ...evidence, + ]); +} + +function finalizationEvidenceLines(result: GraphModelMigrationCommandResult): readonly string[] { + const finalization = result.finalizationResult; + if (finalization === null) { + return Object.freeze([]); + } + return Object.freeze([ + `liveRef: ${finalization.liveRefName}`, + `archiveRef: ${displayNullable(finalization.archiveRefName)}`, + `previousLiveHead: ${displayNullable(finalization.previousLiveHead)}`, + `archiveHead: ${displayNullable(finalization.previousLiveHead)}`, + `finalizedLiveHead: ${displayNullable(finalization.finalizedLiveHead)}`, + `archivePreserved: ${finalization.previousLiveHead === null ? 'no' : 'yes'}`, ]); } diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizationReview.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizationReview.ts new file mode 100644 index 000000000..548ed6c12 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationFinalizationReview.ts @@ -0,0 +1,109 @@ +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafetyResult + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafetyResult.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; + +/** Applies operator-reviewed finalization evidence to the live safety result. */ +export function reviewedGraphModelMigrationFinalizationSafetyResult( + safetyResult: GraphModelMigrationFinalizationSafetyResult, + reviewedRequest: GraphModelMigrationFinalizationRequest | null, +): GraphModelMigrationFinalizationSafetyResult { + if (reviewedRequest === null) { + return safetyResult; + } + const reviewFatalErrors = finalizationReviewFatalErrors(safetyResult.request, reviewedRequest); + if (reviewFatalErrors.length === 0) { + return safetyResult; + } + return new GraphModelMigrationFinalizationSafetyResult({ + request: safetyResult.request, + fatalErrors: reviewFatalErrors.concat(safetyResult.fatalErrors), + }); +} + +function finalizationReviewFatalErrors( + actual: GraphModelMigrationFinalizationRequest, + reviewed: GraphModelMigrationFinalizationRequest, +): readonly GraphModelMigrationNotice[] { + const mismatches = finalizationReviewMismatches(actual, reviewed); + if (mismatches.length === 0) { + return Object.freeze([]); + } + return Object.freeze([ + GraphModelMigrationNotice.fatal( + 'E_FINALIZATION_REVIEW_MISMATCH', + `finalization review artifact does not match observed command evidence: ${mismatches.join(', ')}`, + ), + ]); +} + +function finalizationReviewMismatches( + actual: GraphModelMigrationFinalizationRequest, + reviewed: GraphModelMigrationFinalizationRequest, +): readonly string[] { + return Object.freeze([ + stringMismatch('liveRefName', actual.liveRefName, reviewed.liveRefName), + stringMismatch('expectedLiveHead', actual.expectedLiveHead, reviewed.expectedLiveHead), + stringMismatch('observedLiveHead', actual.observedLiveHead, reviewed.observedLiveHead), + stringMismatch('scratchRef', actual.scratchRef?.refName ?? null, reviewed.scratchRef?.refName ?? null), + stringMismatch('scratchHead', actual.scratchHead, reviewed.scratchHead), + stringMismatch('archiveRefName', actual.archiveRefName, reviewed.archiveRefName), + stringMismatch('confirmation', actual.confirmation?.token ?? null, reviewed.confirmation?.token ?? null), + stringMismatch('equivalence', equivalenceSummaryKey(actual), equivalenceSummaryKey(reviewed)), + stringMismatch('runtimeConformance', runtimeConformanceKey(actual), runtimeConformanceKey(reviewed)), + ].filter((mismatch) => mismatch !== null)); +} + +function stringMismatch(label: string, actual: string | null, reviewed: string | null): string | null { + if (actual === reviewed) { + return null; + } + return label; +} + +function equivalenceSummaryKey(request: GraphModelMigrationFinalizationRequest): string | null { + const gateResult = request.gateResult; + if (gateResult === null) { + return null; + } + const summary = gateResult.proofResult.summary; + return evidenceKey([ + summary.basis.toKey(), + summary.legacyFactCount, + summary.migratedFactCount, + summary.mismatchCount, + gateResult.allowsPromotion() ? 'passed' : 'blocked', + noticeListKey(gateResult.fatalErrors), + ]); +} + +function runtimeConformanceKey(request: GraphModelMigrationFinalizationRequest): string | null { + const runtimeConformance = request.runtimeConformance; + if (runtimeConformance === null) { + return null; + } + return evidenceKey([ + runtimeConformance.scratchRef.refName, + runtimeConformance.scratchHead, + runtimeConformance.status, + runtimeConformance.witness, + noticeListKey(runtimeConformance.fatalErrors), + ]); +} + +function noticeListKey(notices: readonly GraphModelMigrationNotice[]): string { + return evidenceKey(notices.map((notice) => evidenceKey([ + notice.kind, + notice.code, + notice.message, + ]))); +} + +function evidenceKey(parts: readonly (string | number)[]): string { + return parts.map((part) => { + const text = String(part); + return `${text.length}:${text}`; + }).join(''); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationProductionRuntimeReplayProvider.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationProductionRuntimeReplayProvider.ts new file mode 100644 index 000000000..01137bca4 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationProductionRuntimeReplayProvider.ts @@ -0,0 +1,161 @@ +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationRuntimeReplayRequest + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import GraphModelMigrationRuntimeReplayResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { + GraphModelMigrationScratchRuntimeReplayerError, + replayVerifiedGraphModelMigrationScratchIntoRuntime, +} from './GraphModelMigrationScratchRuntimeReplayer.ts'; + +const WITNESS_ID = 'git-warp-v18-production-runtime-scratch-replay-v1'; +const GENERIC_RUNTIME_REPLAY_FAILED_CODE = 'E_RUNTIME_REPLAY_FAILED'; + +export type GraphModelMigrationProductionRuntimeReplayProviderOptions = { + readonly sourceRepositoryPath: string; + readonly graphId: string; + readonly writerId?: string; + readonly runtimeRepositoryPath?: string | null; +}; + +/** Builds finalization conformance evidence from production-runtime replay. */ +export function createGraphModelMigrationProductionRuntimeConformanceProvider( + options: GraphModelMigrationProductionRuntimeReplayProviderOptions, +): (scratchWriteResult: GraphModelMigrationScratchWriteResult) => + Promise { + const checked = checkedProviderOptions(options); + return async (scratchWriteResult) => { + if (!(scratchWriteResult instanceof GraphModelMigrationScratchWriteResult)) { + throw new GraphModelMigrationProductionRuntimeReplayProviderError( + 'scratchWriteResult must be a GraphModelMigrationScratchWriteResult', + ); + } + if (scratchWriteResult.scratchRef === null || scratchWriteResult.scratchHead === null) { + return null; + } + const replayResult = await verifyGraphModelMigrationProductionRuntimeReplay({ + ...checked, + request: new GraphModelMigrationRuntimeReplayRequest({ + graphId: checked.graphId, + writerId: checked.writerId, + scratchRef: scratchWriteResult.scratchRef, + scratchHead: scratchWriteResult.scratchHead, + }), + }); + return runtimeConformanceFromReplay(replayResult); + }; +} + +/** Verifies scratch output by replaying it through normal git-warp runtime. */ +export async function verifyGraphModelMigrationProductionRuntimeReplay(options: { + readonly sourceRepositoryPath: string; + readonly runtimeRepositoryPath?: string | null; + readonly request: GraphModelMigrationRuntimeReplayRequest; +}): Promise { + const sourceRepositoryPath = requireNonEmptyString(options.sourceRepositoryPath, 'sourceRepositoryPath'); + const request = requireReplayRequest(options.request); + try { + const replay = await replayVerifiedGraphModelMigrationScratchIntoRuntime({ + sourceRepositoryPath, + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + request, + }); + return passedReplay(request, replay.operationCount); + } catch (error) { + return failedReplay( + request, + 0, + error instanceof GraphModelMigrationScratchRuntimeReplayerError + ? error.code + : GENERIC_RUNTIME_REPLAY_FAILED_CODE, + error instanceof Error ? error.message : 'production runtime replay failed', + ); + } +} + +function runtimeConformanceFromReplay( + replayResult: GraphModelMigrationRuntimeReplayResult, +): GraphModelMigrationRuntimeConformanceResult { + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: replayResult.request.scratchRef, + scratchHead: replayResult.request.scratchHead, + status: replayResult.allowsFinalization() + ? GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED + : GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + witness: replayResult.witness, + fatalErrors: replayResult.fatalErrors, + }); +} + +function passedReplay( + request: GraphModelMigrationRuntimeReplayRequest, + replayedOperationCount: number, +): GraphModelMigrationRuntimeReplayResult { + return new GraphModelMigrationRuntimeReplayResult({ + request, + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, + witness: `${WITNESS_ID} operations=${replayedOperationCount}`, + replayedOperationCount, + fatalErrors: [], + }); +} + +function failedReplay( + request: GraphModelMigrationRuntimeReplayRequest, + replayedOperationCount: number, + code: string, + message: string, +): GraphModelMigrationRuntimeReplayResult { + return new GraphModelMigrationRuntimeReplayResult({ + request, + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, + witness: WITNESS_ID, + replayedOperationCount, + fatalErrors: [GraphModelMigrationNotice.fatal(code, message)], + }); +} + +function checkedProviderOptions( + options: GraphModelMigrationProductionRuntimeReplayProviderOptions, +): Required> + & { readonly writerId: string; readonly runtimeRepositoryPath: string | null } { + return Object.freeze({ + sourceRepositoryPath: requireNonEmptyString(options.sourceRepositoryPath, 'sourceRepositoryPath'), + graphId: requireNonEmptyString(options.graphId, 'graphId'), + writerId: requireNonEmptyString(options.writerId ?? 'scratch-migration', 'writerId'), + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + }); +} + +function requireReplayRequest( + request: GraphModelMigrationRuntimeReplayRequest, +): GraphModelMigrationRuntimeReplayRequest { + if (!(request instanceof GraphModelMigrationRuntimeReplayRequest)) { + throw new GraphModelMigrationProductionRuntimeReplayProviderError( + 'request must be a GraphModelMigrationRuntimeReplayRequest', + ); + } + return request; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationProductionRuntimeReplayProviderError(`${name} must be a non-empty string`); + } + return value; +} + +export class GraphModelMigrationProductionRuntimeReplayProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationProductionRuntimeReplayProviderError'; + } +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchPublicReadBuilder.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchPublicReadBuilder.ts new file mode 100644 index 000000000..707fcd521 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchPublicReadBuilder.ts @@ -0,0 +1,281 @@ +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact + from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import GraphModelMigrationRuntimeReplayRequest + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { + CONTENT_PROPERTY_KEY, + decodeEdgeKey, + decodeEdgePropKey, + decodePropKey, + encodeEdgeKey, + isEdgePropKey, +} from '../../../../src/domain/services/KeyCodec.ts'; +import type { SnapshotPropValue } + from '../../../../src/domain/services/snapshot/SnapshotPropValue.ts'; +import type SnapshotWarpState + from '../../../../src/domain/services/snapshot/SnapshotWarpState.ts'; +import { compareStrings } from '../../../../src/domain/utils/StringComparison.ts'; +import { + isGraphModelMigrationContentMetadataProperty, + replayVerifiedGraphModelMigrationScratchIntoRuntime, +} from './GraphModelMigrationScratchRuntimeReplayer.ts'; + +export type GraphModelMigrationScratchPublicReadBuilderOptions = { + readonly sourceRepositoryPath: string; + readonly runtimeRepositoryPath?: string | null; + readonly request: GraphModelMigrationRuntimeReplayRequest; + readonly readingId: string; +}; + +export type GraphModelMigrationScratchPublicReadProviderOptions = { + readonly sourceRepositoryPath: string; + readonly graphId: string; + readonly writerId?: string; + readonly runtimeRepositoryPath?: string | null; + readonly readingId?: string | null; +}; + +/** Builds a scratch reading by replaying scratch history and reading materialized runtime state. */ +export async function buildGraphModelMigrationScratchPublicReadReading( + options: GraphModelMigrationScratchPublicReadBuilderOptions, +): Promise { + const replay = await replayVerifiedGraphModelMigrationScratchIntoRuntime({ + sourceRepositoryPath: requireNonEmptyString(options.sourceRepositoryPath, 'sourceRepositoryPath'), + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + request: requireReplayRequest(options.request), + }); + return new GenesisEquivalenceReading({ + readingId: requireNonEmptyString(options.readingId, 'readingId'), + facts: publicFactsFromSnapshot(replay.state), + }); +} + +/** Creates a command reading provider for scratch-write results. */ +export function createGraphModelMigrationScratchPublicReadProvider( + options: GraphModelMigrationScratchPublicReadProviderOptions, +): (scratchWriteResult: GraphModelMigrationScratchWriteResult) => Promise { + const checked = checkedProviderOptions(options); + return async (scratchWriteResult) => { + const checkedScratch = requireScratchWriteResult(scratchWriteResult); + if (checkedScratch.scratchRef === null || checkedScratch.scratchHead === null) { + throw new GraphModelMigrationScratchPublicReadBuilderError( + 'scratchWriteResult must contain a scratch ref and scratch head', + ); + } + return await buildGraphModelMigrationScratchPublicReadReading({ + sourceRepositoryPath: checked.sourceRepositoryPath, + runtimeRepositoryPath: checked.runtimeRepositoryPath, + readingId: checked.readingId, + request: new GraphModelMigrationRuntimeReplayRequest({ + graphId: checked.graphId, + writerId: checked.writerId, + scratchRef: checkedScratch.scratchRef, + scratchHead: checkedScratch.scratchHead, + }), + }); + }; +} + +function publicFactsFromSnapshot(state: SnapshotWarpState): readonly GenesisEquivalenceReadingFact[] { + const facts: GenesisEquivalenceReadingFact[] = []; + for (const nodeId of sortedStrings(state.nodeAlive.elements())) { + facts.push(publicFact('node', nodeId, 'visibility', 'visible')); + } + for (const edgeKey of sortedStrings(state.edgeAlive.elements())) { + const edge = decodeEdgeKey(edgeKey); + if (state.nodeAlive.contains(edge.from) && state.nodeAlive.contains(edge.to)) { + facts.push(publicFact('edge', publicEdgeFactKey(edge), 'visibility', 'visible')); + } + } + for (const entry of sortedPropertyEntries(state.prop)) { + if (isEdgePropKey(entry.encodedKey)) { + const property = decodeEdgePropKey(entry.encodedKey); + if (!visibleEdgeExists(state, property)) { + continue; + } + if (property.propKey === CONTENT_PROPERTY_KEY) { + facts.push(publicFact( + 'content-attachment', + publicEdgePropertyFactKey(property, property.propKey), + 'payload.oid', + requireScalarPublicValue(entry.value), + )); + continue; + } + if (isGraphModelMigrationContentMetadataProperty(property.propKey)) { + continue; + } + facts.push(publicFact( + 'property', + publicEdgePropertyFactKey(property, property.propKey), + 'value', + requireScalarPublicValue(entry.value), + )); + continue; + } + const property = decodePropKey(entry.encodedKey); + if (!state.nodeAlive.contains(property.nodeId)) { + continue; + } + if (property.propKey === CONTENT_PROPERTY_KEY) { + facts.push(publicFact( + 'content-attachment', + publicPropertyFactKey(property.nodeId, property.propKey), + 'payload.oid', + requireScalarPublicValue(entry.value), + )); + continue; + } + if (isGraphModelMigrationContentMetadataProperty(property.propKey)) { + continue; + } + facts.push(publicFact( + 'property', + publicPropertyFactKey(property.nodeId, property.propKey), + 'value', + requireScalarPublicValue(entry.value), + )); + } + return Object.freeze(facts); +} + +function publicFact( + kind: 'node' | 'edge' | 'property' | 'content-attachment', + factKey: string, + fieldPath: string, + value: string, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind, + factKey, + fieldPath, + value, + boundary: null, + }); +} + +function publicEdgeFactKey(edge: { + readonly from: string; + readonly to: string; + readonly label: string; +}): string { + return `${edge.from}->${edge.to}:${edge.label}`; +} + +function publicPropertyFactKey(ownerId: string, propertyKey: string): string { + return `${ownerId}:${propertyKey}`; +} + +function publicEdgePropertyFactKey(edge: { + readonly from: string; + readonly to: string; + readonly label: string; +}, propertyKey: string): string { + return publicPropertyFactKey(publicEdgeFactKey(edge), propertyKey); +} + +function visibleEdgeExists( + state: SnapshotWarpState, + edge: { + readonly from: string; + readonly to: string; + readonly label: string; + }, +): boolean { + return state.edgeAlive.contains(encodeEdgeKey(edge.from, edge.to, edge.label)) + && state.nodeAlive.contains(edge.from) + && state.nodeAlive.contains(edge.to); +} + +function sortedStrings(values: readonly string[]): readonly string[] { + return Object.freeze([...values].sort(compareStrings)); +} + +function sortedPropertyEntries( + properties: ReadonlyMap, +): readonly { readonly encodedKey: string; readonly value: SnapshotPropValue }[] { + return Object.freeze([...properties.entries()] + .map(([encodedKey, register]) => Object.freeze({ encodedKey, value: register.value })) + .sort((left, right) => compareStrings(left.encodedKey, right.encodedKey))); +} + +function requireScalarPublicValue(value: SnapshotPropValue): string { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : invalidSnapshotValue(); + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (value === null) { + return 'null'; + } + return invalidSnapshotValue(); +} + +function invalidSnapshotValue(): string { + throw new GraphModelMigrationScratchPublicReadBuilderError( + 'scratch public read only supports scalar snapshot property values', + ); +} + +function checkedProviderOptions( + options: GraphModelMigrationScratchPublicReadProviderOptions, +): { + readonly sourceRepositoryPath: string; + readonly graphId: string; + readonly writerId: string; + readonly runtimeRepositoryPath: string | null; + readonly readingId: string; +} { + const graphId = requireNonEmptyString(options.graphId, 'graphId'); + return Object.freeze({ + sourceRepositoryPath: requireNonEmptyString(options.sourceRepositoryPath, 'sourceRepositoryPath'), + graphId, + writerId: requireNonEmptyString(options.writerId ?? 'scratch-migration', 'writerId'), + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + readingId: requireNonEmptyString(options.readingId ?? `scratch-public-read:${graphId}`, 'readingId'), + }); +} + +function requireScratchWriteResult( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationScratchWriteResult { + if (!(scratchWriteResult instanceof GraphModelMigrationScratchWriteResult)) { + throw new GraphModelMigrationScratchPublicReadBuilderError( + 'scratchWriteResult must be a GraphModelMigrationScratchWriteResult', + ); + } + return scratchWriteResult; +} + +function requireReplayRequest( + request: GraphModelMigrationRuntimeReplayRequest, +): GraphModelMigrationRuntimeReplayRequest { + if (!(request instanceof GraphModelMigrationRuntimeReplayRequest)) { + throw new GraphModelMigrationScratchPublicReadBuilderError( + 'request must be a GraphModelMigrationRuntimeReplayRequest', + ); + } + return request; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchPublicReadBuilderError(`${name} must be a non-empty string`); + } + return value; +} + +export class GraphModelMigrationScratchPublicReadBuilderError extends Error { + constructor(message: string) { + super(message); + this.name = 'GraphModelMigrationScratchPublicReadBuilderError'; + } +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts index 601842ab0..114ffb6a4 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts @@ -19,8 +19,10 @@ export type GraphModelMigrationScratchReadingBuilderOptions = { readonly readingId: string; }; -class ScratchOperationPayload { +export class GraphModelMigrationScratchOperationRecord { constructor( + readonly commitId: string, + readonly sequence: number, readonly kind: GraphModelMigrationPlannedGraphOperationKind, readonly sourceKey: string, readonly targetKey: string, @@ -40,28 +42,42 @@ export class GraphModelMigrationScratchReadingBuilderError extends Error { export async function buildGraphModelMigrationScratchReading( options: GraphModelMigrationScratchReadingBuilderOptions, ): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const records = await readGraphModelMigrationScratchOperationRecords({ + repositoryPath, + scratchRefName: options.scratchRefName, + }); + const facts = records.map(factFromPayload); + return new GenesisEquivalenceReading({ + readingId: requireNonEmptyString(options.readingId, 'readingId'), + facts, + }); +} + +/** Reads scratch migration operation commit payloads from Git in replay order. */ +export async function readGraphModelMigrationScratchOperationRecords(options: { + readonly repositoryPath: string; + readonly scratchRefName: string; +}): Promise { const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); const scratchRef = new GraphModelMigrationScratchRef({ refName: options.scratchRefName }); const commitIds = await gitLines(repositoryPath, ['rev-list', '--reverse', scratchRef.refName]); - const facts: GenesisEquivalenceReadingFact[] = []; + const records: GraphModelMigrationScratchOperationRecord[] = []; let operationIndex = 0; for (const commitId of commitIds) { const payload = parseScratchOperationPayload( await gitText(repositoryPath, ['show', `${commitId}:${OPERATION_TREE_PATH}`]), + commitId, + operationIndex, ); - facts.push(factFromPayload(payload, commitId, operationIndex)); + records.push(payload); operationIndex += 1; } - return new GenesisEquivalenceReading({ - readingId: requireNonEmptyString(options.readingId, 'readingId'), - facts, - }); + return Object.freeze(records); } function factFromPayload( - payload: ScratchOperationPayload, - commitId: string, - operationIndex: number, + payload: GraphModelMigrationScratchOperationRecord, ): GenesisEquivalenceReadingFact { const projected = projectedFactFromPayload(payload); return new GenesisEquivalenceReadingFact({ @@ -71,13 +87,13 @@ function factFromPayload( value: projected.value, boundary: new GenesisEquivalenceBoundary({ writerId: 'scratch-migration', - patchId: commitId, - operationIndex, + patchId: payload.commitId, + operationIndex: payload.sequence, }), }); } -function projectedFactFromPayload(payload: ScratchOperationPayload): { +function projectedFactFromPayload(payload: GraphModelMigrationScratchOperationRecord): { readonly kind: GenesisEquivalenceReadingFactKind; readonly factKey: string; readonly fieldPath: string; @@ -92,7 +108,7 @@ function projectedFactFromPayload(payload: ScratchOperationPayload): { return compatibilityFactFromPayload(payload); } -function compatibilityFactFromPayload(payload: ScratchOperationPayload): { +function compatibilityFactFromPayload(payload: GraphModelMigrationScratchOperationRecord): { readonly kind: GenesisEquivalenceReadingFactKind; readonly factKey: string; readonly fieldPath: string; @@ -121,13 +137,19 @@ function projected( return Object.freeze({ kind, factKey, fieldPath, value }); } -function parseScratchOperationPayload(text: string): ScratchOperationPayload { +function parseScratchOperationPayload( + text: string, + commitId: string, + sequence: number, +): GraphModelMigrationScratchOperationRecord { const lines = text.split('\n').filter((line) => line.length > 0); if (lines[0] !== 'git-warp-v18-migration-operation-v1') { throw new GraphModelMigrationScratchReadingBuilderError('scratch operation payload header is unsupported'); } const fields = payloadFields(lines.slice(1)); - return new ScratchOperationPayload( + return new GraphModelMigrationScratchOperationRecord( + commitId, + sequence, requireKind(fields.get('kind')), requireField(fields, 'source-key-utf8-hex'), requireField(fields, 'target-key-utf8-hex'), diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayErrors.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayErrors.ts new file mode 100644 index 000000000..3304bcd22 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayErrors.ts @@ -0,0 +1,22 @@ +export const GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_REF_UNREADABLE = + 'E_RUNTIME_REPLAY_SCRATCH_REF_UNREADABLE'; +export const GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED = + 'E_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED'; +export const GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET = + 'E_RUNTIME_REPLAY_INVALID_OPERATION_TARGET'; + +export type GraphModelMigrationScratchRuntimeReplayErrorCode = + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_REF_UNREADABLE + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET; + +export class GraphModelMigrationScratchRuntimeReplayerError extends Error { + readonly code: GraphModelMigrationScratchRuntimeReplayErrorCode; + + constructor(code: GraphModelMigrationScratchRuntimeReplayErrorCode, message: string) { + super(message); + this.name = 'GraphModelMigrationScratchRuntimeReplayerError'; + this.code = code; + Object.freeze(this); + } +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayTargets.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayTargets.ts new file mode 100644 index 000000000..e94e51017 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayTargets.ts @@ -0,0 +1,133 @@ +import { + CONTENT_PROPERTY_KEY, + decodeLegacyEdgePropNode, +} from '../../../../src/domain/services/KeyCodec.ts'; +import { + GraphModelMigrationScratchRuntimeReplayerError, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET, +} from './GraphModelMigrationScratchRuntimeReplayErrors.ts'; + +const PROPERTY_TARGET_PREFIX = 'property-target-key:length-prefixed-v1:'; +const CONTENT_ATTACHMENT_PREFIX = 'content-attachment:'; +const NODE_CONTENT_SUFFIX = `:${CONTENT_PROPERTY_KEY}`; + +export type GraphModelMigrationScratchEdgeTarget = { + readonly from: string; + readonly to: string; + readonly label: string; +}; + +export type GraphModelMigrationScratchPropertyTarget = { + readonly ownerId: string; + readonly propertyKey: string; +}; + +export function parseGraphModelMigrationScratchEdgeTarget(targetKey: string): GraphModelMigrationScratchEdgeTarget { + const arrowIndex = targetKey.indexOf('->'); + const labelIndex = targetKey.lastIndexOf(':'); + if (arrowIndex <= 0 || labelIndex <= arrowIndex + 2 || labelIndex === targetKey.length - 1) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget( + `edge target ${targetKey} must use from->to:label format`, + ); + } + return Object.freeze({ + from: targetKey.slice(0, arrowIndex), + to: targetKey.slice(arrowIndex + 2, labelIndex), + label: targetKey.slice(labelIndex + 1), + }); +} + +export function parseGraphModelMigrationScratchPropertyTarget( + targetKey: string, +): GraphModelMigrationScratchPropertyTarget { + if (!targetKey.startsWith(PROPERTY_TARGET_PREFIX)) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget( + `property target ${targetKey} must use length-prefixed target format`, + ); + } + let cursor = PROPERTY_TARGET_PREFIX.length; + const ownerLength = readLength(targetKey, cursor); + cursor = ownerLength.nextCursor; + const ownerId = readSizedField(targetKey, cursor, ownerLength.value, 'ownerId', true); + cursor = ownerId.nextCursor; + const propertyLength = readLength(targetKey, cursor); + cursor = propertyLength.nextCursor; + const propertyKey = readSizedField(targetKey, cursor, propertyLength.value, 'propertyKey', false); + if (propertyKey.nextCursor !== targetKey.length) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget('property target has trailing data'); + } + return Object.freeze({ + ownerId: requireNonEmptyTargetField(ownerId.value, 'ownerId'), + propertyKey: requireNonEmptyTargetField(propertyKey.value, 'propertyKey'), + }); +} + +export function parseGraphModelMigrationScratchNodeContentTarget(targetKey: string): string { + if (!targetKey.startsWith(CONTENT_ATTACHMENT_PREFIX) || !targetKey.endsWith(NODE_CONTENT_SUFFIX)) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget( + `content target ${targetKey} must identify a node ${CONTENT_PROPERTY_KEY} attachment`, + ); + } + const legacyKey = targetKey.slice(CONTENT_ATTACHMENT_PREFIX.length); + return requireNonEmptyTargetField( + legacyKey.slice(0, legacyKey.length - NODE_CONTENT_SUFFIX.length), + 'content ownerId', + ); +} + +export function decodeGraphModelMigrationScratchEdgePropertyOwner(ownerId: string): GraphModelMigrationScratchEdgeTarget { + try { + return decodeLegacyEdgePropNode(ownerId); + } catch { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget('edge property owner target is malformed'); + } +} + +export function invalidGraphModelMigrationScratchRuntimeReplayTarget( + message: string, +): GraphModelMigrationScratchRuntimeReplayerError { + return new GraphModelMigrationScratchRuntimeReplayerError( + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET, + message, + ); +} + +function readLength(text: string, cursor: number): { readonly value: number; readonly nextCursor: number } { + const separator = text.indexOf(':', cursor); + if (separator <= cursor) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget('length-prefixed field is malformed'); + } + const raw = text.slice(cursor, separator); + if (!/^[0-9]+$/u.test(raw)) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget('length-prefixed field length is invalid'); + } + return Object.freeze({ value: Number(raw), nextCursor: separator + 1 }); +} + +function readSizedField( + text: string, + cursor: number, + length: number, + label: string, + separatorRequired: boolean, +): { readonly value: string; readonly nextCursor: number } { + const value = text.slice(cursor, cursor + length); + if (value.length !== length) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget(`${label} field is truncated`); + } + const nextCursor = cursor + length; + if (!separatorRequired) { + return Object.freeze({ value, nextCursor }); + } + if (text[nextCursor] !== ':') { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget(`${label} field is missing separator`); + } + return Object.freeze({ value, nextCursor: nextCursor + 1 }); +} + +function requireNonEmptyTargetField(value: string, label: string): string { + if (value.length === 0) { + throw invalidGraphModelMigrationScratchRuntimeReplayTarget(`${label} field must not be empty`); + } + return value; +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayValidation.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayValidation.ts new file mode 100644 index 000000000..dcd104264 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayValidation.ts @@ -0,0 +1,55 @@ +import GraphModelMigrationRuntimeReplayRequest + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import { + GraphModelMigrationScratchRuntimeReplayerError, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET, +} from './GraphModelMigrationScratchRuntimeReplayErrors.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +export async function observedGraphModelMigrationScratchHead( + repositoryPath: string, + request: GraphModelMigrationRuntimeReplayRequest, +): Promise { + const result = await runMigrationGit( + repositoryPath, + ['show-ref', '--verify', '--hash', request.scratchRef.refName], + null, + ); + if (!result.ok()) { + return null; + } + const observedHead = result.stdout.trim(); + return observedHead.length === 0 ? null : observedHead; +} + +export function requireGraphModelMigrationRuntimeReplayRequest( + request: GraphModelMigrationRuntimeReplayRequest, +): GraphModelMigrationRuntimeReplayRequest { + if (!(request instanceof GraphModelMigrationRuntimeReplayRequest)) { + throw new GraphModelMigrationScratchRuntimeReplayerError( + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET, + 'request must be a GraphModelMigrationRuntimeReplayRequest', + ); + } + return request; +} + +export function requireGraphModelMigrationRuntimeReplayString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphModelMigrationScratchRuntimeReplayerError( + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET, + `${name} must be a non-empty string`, + ); + } + return value; +} + +export function optionalGraphModelMigrationRuntimeReplayString( + value: string | null | undefined, + name: string, +): string | null { + if (value === null || value === undefined) { + return null; + } + return requireGraphModelMigrationRuntimeReplayString(value, name); +} diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayer.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayer.ts new file mode 100644 index 000000000..f7e3269b9 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeReplayer.ts @@ -0,0 +1,206 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Plumbing from '@git-stunts/plumbing'; + +import GraphModelMigrationRuntimeReplayRequest + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import { + CONTENT_MIME_PROPERTY_KEY, + CONTENT_SIZE_PROPERTY_KEY, + isLegacyEdgePropNode, +} from '../../../../src/domain/services/KeyCodec.ts'; +import type SnapshotWarpState + from '../../../../src/domain/services/snapshot/SnapshotWarpState.ts'; +import { openRuntimeHostProduct } from '../../../../src/domain/warp/RuntimeHostProduct.ts'; +import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAdapter.ts'; +import { compareStrings } from '../../../../src/domain/utils/StringComparison.ts'; +import { + type GraphModelMigrationScratchOperationRecord, + readGraphModelMigrationScratchOperationRecords, +} from './GraphModelMigrationScratchReadingBuilder.ts'; +import { + GraphModelMigrationScratchRuntimeReplayerError, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_REF_UNREADABLE, +} from './GraphModelMigrationScratchRuntimeReplayErrors.ts'; +import { + observedGraphModelMigrationScratchHead, + optionalGraphModelMigrationRuntimeReplayString, + requireGraphModelMigrationRuntimeReplayRequest, + requireGraphModelMigrationRuntimeReplayString, +} from './GraphModelMigrationScratchRuntimeReplayValidation.ts'; +import { + decodeGraphModelMigrationScratchEdgePropertyOwner, + parseGraphModelMigrationScratchEdgeTarget, + parseGraphModelMigrationScratchNodeContentTarget, + parseGraphModelMigrationScratchPropertyTarget, +} from './GraphModelMigrationScratchRuntimeReplayTargets.ts'; + +export { + GraphModelMigrationScratchRuntimeReplayerError, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_INVALID_OPERATION_TARGET, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_REF_UNREADABLE, + type GraphModelMigrationScratchRuntimeReplayErrorCode, +} from './GraphModelMigrationScratchRuntimeReplayErrors.ts'; + +export type GraphModelMigrationScratchRuntimeReplayOptions = { + readonly sourceRepositoryPath: string; + readonly runtimeRepositoryPath?: string | null; + readonly request: GraphModelMigrationRuntimeReplayRequest; +}; + +export type GraphModelMigrationScratchRuntimeReplayOutput = { + readonly request: GraphModelMigrationRuntimeReplayRequest; + readonly operationCount: number; + readonly state: SnapshotWarpState; +}; + +/** Verifies the scratch ref head, replays scratch operations, and materializes runtime state. */ +export async function replayVerifiedGraphModelMigrationScratchIntoRuntime( + options: GraphModelMigrationScratchRuntimeReplayOptions, +): Promise { + const sourceRepositoryPath = requireGraphModelMigrationRuntimeReplayString( + options.sourceRepositoryPath, + 'sourceRepositoryPath', + ); + const request = requireGraphModelMigrationRuntimeReplayRequest(options.request); + const observedHead = await observedGraphModelMigrationScratchHead(sourceRepositoryPath, request); + if (observedHead === null) { + throw new GraphModelMigrationScratchRuntimeReplayerError( + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_REF_UNREADABLE, + 'scratch ref is not readable', + ); + } + if (observedHead !== request.scratchHead) { + throw new GraphModelMigrationScratchRuntimeReplayerError( + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED, + 'scratch ref head changed', + ); + } + return await replayGraphModelMigrationScratchIntoRuntime({ + sourceRepositoryPath, + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + request, + }); +} + +/** Replays scratch operations into an isolated normal git-warp runtime. */ +export async function replayGraphModelMigrationScratchIntoRuntime( + options: GraphModelMigrationScratchRuntimeReplayOptions, +): Promise { + const sourceRepositoryPath = requireGraphModelMigrationRuntimeReplayString( + options.sourceRepositoryPath, + 'sourceRepositoryPath', + ); + const request = requireGraphModelMigrationRuntimeReplayRequest(options.request); + let runtimeRepositoryPath = optionalGraphModelMigrationRuntimeReplayString( + options.runtimeRepositoryPath, + 'runtimeRepositoryPath', + ); + let shouldCleanup = false; + if (runtimeRepositoryPath === null) { + runtimeRepositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v18-runtime-replay-')); + shouldCleanup = true; + } + try { + const operations = await readGraphModelMigrationScratchOperationRecords({ + repositoryPath: sourceRepositoryPath, + scratchRefName: request.scratchRef.refName, + }); + const plumbing = await Plumbing.createDefault({ cwd: runtimeRepositoryPath }); + await plumbing.execute({ args: ['init', '-q'] }); + await plumbing.execute({ args: ['config', 'user.email', 'git-warp@example.invalid'] }); + await plumbing.execute({ args: ['config', 'user.name', 'git-warp migration replay'] }); + const graph = await openRuntimeHostProduct({ + persistence: new GitGraphAdapter({ plumbing }), + graphName: request.graphId, + writerId: request.writerId, + }); + const patch = await graph.createPatch(); + await applyOperations(patch, operations); + await patch.commit(); + const state = await graph.materialize(); + return Object.freeze({ + request, + operationCount: operations.length, + state, + }); + } finally { + if (shouldCleanup && runtimeRepositoryPath !== null) { + await rm(runtimeRepositoryPath, { recursive: true, force: true }); + } + } +} + +async function applyOperations( + patch: RuntimePatch, + operations: readonly GraphModelMigrationScratchOperationRecord[], +): Promise { + for (const operation of sortedOperations(operations, 'node-record')) { + patch.addNode(operation.targetKey); + } + for (const operation of sortedOperations(operations, 'edge-record')) { + const edge = parseGraphModelMigrationScratchEdgeTarget(operation.targetKey); + patch.addEdge(edge.from, edge.to, edge.label); + } + for (const operation of sortedOperations(operations, 'property')) { + const property = parseGraphModelMigrationScratchPropertyTarget(operation.targetKey); + applyPropertyOperation(patch, property, `migration-source:${operation.sourceKey}`); + } + for (const operation of sortedOperations(operations, 'content-attachment')) { + const nodeId = parseGraphModelMigrationScratchNodeContentTarget(operation.targetKey); + await patch.attachContent( + nodeId, + `migration-source:${operation.sourceKey}`, + { mime: 'text/plain' }, + ); + } +} + +type RuntimePatch = { + addNode(nodeId: string): RuntimePatch; + addEdge(from: string, to: string, label: string): RuntimePatch; + setProperty(nodeId: string, key: string, value: string): RuntimePatch; + setEdgeProperty( + from: string, + to: string, + label: string, + key: string, + value: string, + ): RuntimePatch; + attachContent(nodeId: string, content: string, metadata: { readonly mime: string }): Promise; + commit(): Promise; +}; + +function applyPropertyOperation( + patch: RuntimePatch, + property: { + readonly ownerId: string; + readonly propertyKey: string; + }, + value: string, +): void { + if (!isLegacyEdgePropNode(property.ownerId)) { + patch.setProperty(property.ownerId, property.propertyKey, value); + return; + } + const edge = decodeGraphModelMigrationScratchEdgePropertyOwner(property.ownerId); + patch.setEdgeProperty(edge.from, edge.to, edge.label, property.propertyKey, value); +} + +function sortedOperations( + operations: readonly GraphModelMigrationScratchOperationRecord[], + kind: GraphModelMigrationScratchOperationRecord['kind'], +): readonly GraphModelMigrationScratchOperationRecord[] { + return Object.freeze([...operations] + .filter((operation) => operation.kind === kind) + .sort((left, right) => compareStrings(left.targetKey, right.targetKey))); +} + + +export function isGraphModelMigrationContentMetadataProperty(propertyKey: string): boolean { + return propertyKey === CONTENT_MIME_PROPERTY_KEY || propertyKey === CONTENT_SIZE_PROPERTY_KEY; +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenFixtureScratchFactKeyCodec.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenFixtureScratchFactKeyCodec.ts new file mode 100644 index 000000000..5703e1c74 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenFixtureScratchFactKeyCodec.ts @@ -0,0 +1,94 @@ +import { + decodeLegacyEdgePropNode, + isLegacyEdgePropNode, +} from '../../../../src/domain/services/KeyCodec.ts'; + +const CONTENT_ATTACHMENT_TARGET_PREFIX = 'content-attachment:'; +const PROPERTY_TARGET_KEY_PREFIX = 'property-target-key:length-prefixed-v1:'; + +export class V17GoldenFixtureScratchFactKeyCodecError extends Error { + constructor(message: string) { + super(message); + this.name = 'V17GoldenFixtureScratchFactKeyCodecError'; + } +} + +export function publicContentFactKey(targetKey: string): string { + if (!targetKey.startsWith(CONTENT_ATTACHMENT_TARGET_PREFIX)) { + throw new V17GoldenFixtureScratchFactKeyCodecError( + `content attachment target ${targetKey} must use content-attachment prefix`, + ); + } + return targetKey.slice(CONTENT_ATTACHMENT_TARGET_PREFIX.length); +} + +export function publicPropertyFactKey(targetKey: string): string { + const decoded = decodePropertyTargetKey(targetKey); + if (isLegacyEdgePropNode(decoded.ownerId)) { + const edge = decodeLegacyEdgePropNode(decoded.ownerId); + return `${edge.from}->${edge.to}:${edge.label}:${decoded.propertyKey}`; + } + return `${decoded.ownerId}:${decoded.propertyKey}`; +} + +function decodePropertyTargetKey(targetKey: string): { + readonly ownerId: string; + readonly propertyKey: string; +} { + if (!targetKey.startsWith(PROPERTY_TARGET_KEY_PREFIX)) { + throw new V17GoldenFixtureScratchFactKeyCodecError( + `property target ${targetKey} must use length-prefixed target format`, + ); + } + let cursor = PROPERTY_TARGET_KEY_PREFIX.length; + const ownerLength = readLength(targetKey, cursor); + cursor = ownerLength.nextCursor; + const ownerId = readSizedField(targetKey, cursor, ownerLength.value, 'ownerId', true); + cursor = ownerId.nextCursor; + const propertyLength = readLength(targetKey, cursor); + cursor = propertyLength.nextCursor; + const propertyKey = readSizedField(targetKey, cursor, propertyLength.value, 'propertyKey', false); + if (propertyKey.nextCursor !== targetKey.length) { + throw new V17GoldenFixtureScratchFactKeyCodecError('property target has trailing data'); + } + return Object.freeze({ ownerId: ownerId.value, propertyKey: propertyKey.value }); +} + +function readLength(text: string, cursor: number): { + readonly value: number; + readonly nextCursor: number; +} { + const separator = text.indexOf(':', cursor); + if (separator <= cursor) { + throw new V17GoldenFixtureScratchFactKeyCodecError('length-prefixed field is malformed'); + } + const raw = text.slice(cursor, separator); + if (!/^[0-9]+$/u.test(raw)) { + throw new V17GoldenFixtureScratchFactKeyCodecError('length-prefixed field length is invalid'); + } + return Object.freeze({ value: Number(raw), nextCursor: separator + 1 }); +} + +function readSizedField( + text: string, + cursor: number, + length: number, + label: string, + separatorRequired: boolean, +): { + readonly value: string; + readonly nextCursor: number; +} { + const value = text.slice(cursor, cursor + length); + if (value.length !== length) { + throw new V17GoldenFixtureScratchFactKeyCodecError(`${label} field is truncated`); + } + const nextCursor = cursor + length; + if (!separatorRequired) { + return Object.freeze({ value, nextCursor }); + } + if (text[nextCursor] !== ':') { + throw new V17GoldenFixtureScratchFactKeyCodecError(`${label} field is missing separator`); + } + return Object.freeze({ value, nextCursor: nextCursor + 1 }); +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenFixtureScratchReadingProvider.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenFixtureScratchReadingProvider.ts new file mode 100644 index 000000000..b4bb44a08 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenFixtureScratchReadingProvider.ts @@ -0,0 +1,274 @@ +import GenesisEquivalenceBoundary + from '../../../../src/domain/migrations/GenesisEquivalenceBoundary.ts'; +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact, { + GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT, + GENESIS_EQUIVALENCE_EDGE_FACT, + GENESIS_EQUIVALENCE_NODE_FACT, + GENESIS_EQUIVALENCE_PROPERTY_FACT, + type GenesisEquivalenceReadingFactKind, +} from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import type { GraphModelMigrationPlannedGraphOperationKind } + from '../../../../src/domain/migrations/GraphModelMigrationPlannedGraphOperation.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import V17GoldenGraphFixtureManifest, { + type V17GoldenGraphFixtureVisibleFact, + V17GoldenMultiWriterFact, + V17GoldenRemovalFact, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { + publicContentFactKey, + publicPropertyFactKey, +} from './V17GoldenFixtureScratchFactKeyCodec.ts'; +import { createGraphModelMigrationScratchPublicReadProvider } + from './GraphModelMigrationScratchPublicReadBuilder.ts'; + +const SCRATCH_NODE_RECORD_KIND = 'node-record'; +const SCRATCH_EDGE_RECORD_KIND = 'edge-record'; +const SCRATCH_PROPERTY_KIND = 'property'; +const SCRATCH_CONTENT_ATTACHMENT_KIND = 'content-attachment'; +const FIELD_VISIBILITY = 'visibility'; +const FIELD_VALUE = 'value'; +const FIELD_PAYLOAD_OID = 'payload.oid'; +const FIELD_COVERAGE = 'coverage'; +const VALUE_REMOVED = 'removed'; + +export class V17GoldenFixtureScratchReadingProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'V17GoldenFixtureScratchReadingProviderError'; + } +} + +export function createV17GoldenFixtureScratchReadingProvider(options: { + readonly sourceRepositoryPath: string; + readonly manifest: V17GoldenGraphFixtureManifest; + readonly runtimeRepositoryPath: string | null; +}): (scratchWriteResult: GraphModelMigrationScratchWriteResult) => Promise { + const manifest = requireManifest(options.manifest); + const publicReadProvider = createGraphModelMigrationScratchPublicReadProvider({ + sourceRepositoryPath: requireNonEmptyString(options.sourceRepositoryPath, 'sourceRepositoryPath'), + graphId: manifest.graphId, + runtimeRepositoryPath: options.runtimeRepositoryPath, + }); + return async (scratchWriteResult) => withFixtureCoverageFacts( + await publicReadProvider(scratchWriteResult), + scratchWriteResult, + manifest, + ); +} + +function withFixtureCoverageFacts( + reading: GenesisEquivalenceReading, + scratchWriteResult: GraphModelMigrationScratchWriteResult, + manifest: V17GoldenGraphFixtureManifest, +): GenesisEquivalenceReading { + const checkedReading = requireReading(reading); + const scratchBoundaries = scratchBoundariesByFactKey(scratchWriteResult); + const facts = checkedReading.facts + .map((fact) => factWithBoundary(fact, requireScratchBoundary(fact, scratchBoundaries))) + .concat(lifecycleCoverageFacts(manifest, checkedReading.facts.length)); + return new GenesisEquivalenceReading({ + readingId: checkedReading.readingId, + facts: deduplicateFacts(facts), + }); +} + +function scratchBoundariesByFactKey( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): ReadonlyMap { + const checkedScratch = requireScratchWriteResult(scratchWriteResult); + const indexed = new Map(); + for (const patch of checkedScratch.writtenPatches) { + indexed.set( + factKeyForWrittenPatch(patch.operation.kind, patch.operation.targetKey), + new GenesisEquivalenceBoundary({ + writerId: 'scratch-migration', + patchId: patch.commitId, + operationIndex: patch.sequence, + }), + ); + } + return indexed; +} + +function factKeyForWrittenPatch( + kind: GraphModelMigrationPlannedGraphOperationKind, + targetKey: string, +): string { + if (kind === SCRATCH_NODE_RECORD_KIND) { + return factKey(GENESIS_EQUIVALENCE_NODE_FACT, targetKey, FIELD_VISIBILITY); + } + if (kind === SCRATCH_EDGE_RECORD_KIND) { + return factKey(GENESIS_EQUIVALENCE_EDGE_FACT, targetKey, FIELD_VISIBILITY); + } + if (kind === SCRATCH_PROPERTY_KIND) { + return factKey(GENESIS_EQUIVALENCE_PROPERTY_FACT, publicPropertyFactKey(targetKey), FIELD_VALUE); + } + if (kind === SCRATCH_CONTENT_ATTACHMENT_KIND) { + return factKey( + GENESIS_EQUIVALENCE_CONTENT_ATTACHMENT_FACT, + publicContentFactKey(targetKey), + FIELD_PAYLOAD_OID, + ); + } + throw new V17GoldenFixtureScratchReadingProviderError(`unsupported scratch operation kind ${kind}`); +} + +function requireScratchBoundary( + fact: GenesisEquivalenceReadingFact, + boundaries: ReadonlyMap, +): GenesisEquivalenceBoundary { + const boundary = boundaries.get(fact.toKey()); + if (boundary === undefined) { + throw new V17GoldenFixtureScratchReadingProviderError( + `missing scratch boundary for migrated fact ${displayFactKey(fact.toKey())}`, + ); + } + return boundary; +} + +function factWithBoundary( + fact: GenesisEquivalenceReadingFact, + boundary: GenesisEquivalenceBoundary, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind: fact.kind, + factKey: fact.factKey, + fieldPath: fact.fieldPath, + value: fact.value, + boundary, + }); +} + +function lifecycleCoverageFacts( + manifest: V17GoldenGraphFixtureManifest, + operationOffset: number, +): readonly GenesisEquivalenceReadingFact[] { + return Object.freeze(manifest.visibleFacts + .map((fact, index) => lifecycleCoverageFactFor(manifest, fact, operationOffset + index)) + .filter((fact) => fact !== null)); +} + +function lifecycleCoverageFactFor( + manifest: V17GoldenGraphFixtureManifest, + fact: V17GoldenGraphFixtureVisibleFact, + operationIndex: number, +): GenesisEquivalenceReadingFact | null { + if (fact instanceof V17GoldenRemovalFact) { + return publicFactWithBoundary( + GENESIS_EQUIVALENCE_NODE_FACT, + fact.key, + FIELD_VISIBILITY, + VALUE_REMOVED, + fixtureBoundaryFor(manifest, operationIndex), + ); + } + if (fact instanceof V17GoldenMultiWriterFact) { + return publicFactWithBoundary( + GENESIS_EQUIVALENCE_PROPERTY_FACT, + fact.key, + FIELD_COVERAGE, + fact.description, + fixtureBoundaryFor(manifest, operationIndex), + ); + } + return null; +} + +function publicFactWithBoundary( + kind: GenesisEquivalenceReadingFactKind, + factKeyValue: string, + fieldPath: string, + value: string, + boundary: GenesisEquivalenceBoundary, +): GenesisEquivalenceReadingFact { + return new GenesisEquivalenceReadingFact({ + kind, + factKey: factKeyValue, + fieldPath, + value, + boundary, + }); +} + +function fixtureBoundaryFor( + manifest: V17GoldenGraphFixtureManifest, + operationIndex: number, +): GenesisEquivalenceBoundary { + const chain = manifest.writerChains[operationIndex % manifest.writerChains.length]; + if (chain === undefined) { + throw new V17GoldenFixtureScratchReadingProviderError( + 'v17 fixture manifest must contain writer chain evidence', + ); + } + return new GenesisEquivalenceBoundary({ + writerId: chain.writerId, + patchId: chain.expectedHead, + operationIndex, + }); +} + +function deduplicateFacts( + facts: readonly GenesisEquivalenceReadingFact[], +): readonly GenesisEquivalenceReadingFact[] { + const seen = new Set(); + const deduplicated: GenesisEquivalenceReadingFact[] = []; + for (const fact of facts) { + if (!seen.has(fact.toKey())) { + seen.add(fact.toKey()); + deduplicated.push(fact); + } + } + return Object.freeze(deduplicated); +} + +function factKey( + kind: GenesisEquivalenceReadingFactKind, + factKeyValue: string, + fieldPath: string, +): string { + return `${kind}\0${factKeyValue}\0${fieldPath}`; +} + +function requireReading(reading: GenesisEquivalenceReading): GenesisEquivalenceReading { + if (!(reading instanceof GenesisEquivalenceReading)) { + throw new V17GoldenFixtureScratchReadingProviderError( + 'reading must be a GenesisEquivalenceReading', + ); + } + return reading; +} + +function requireScratchWriteResult( + scratchWriteResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationScratchWriteResult { + if (!(scratchWriteResult instanceof GraphModelMigrationScratchWriteResult)) { + throw new V17GoldenFixtureScratchReadingProviderError( + 'scratchWriteResult must be a GraphModelMigrationScratchWriteResult', + ); + } + return scratchWriteResult; +} + +function displayFactKey(value: string): string { + return value.replaceAll('\0', '\\0'); +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new V17GoldenFixtureScratchReadingProviderError( + 'manifest must be a V17GoldenGraphFixtureManifest', + ); + } + return manifest; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new V17GoldenFixtureScratchReadingProviderError(`${name} must be a non-empty string`); + } + return value; +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixturePropertyMappings.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixturePropertyMappings.ts new file mode 100644 index 000000000..53af32230 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixturePropertyMappings.ts @@ -0,0 +1,91 @@ +import GraphModelMigrationPropertyMapping + from '../../../../src/domain/migrations/GraphModelMigrationPropertyMapping.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenEdgeFact, + type V17GoldenGraphFixtureVisibleFact, + V17GoldenPropertyFact, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { encodeLegacyEdgePropNode } from '../../../../src/domain/services/KeyCodec.ts'; + +export class V17GoldenGraphFixturePropertyMappingError extends Error { + constructor(message: string) { + super(message); + this.name = 'V17GoldenGraphFixturePropertyMappingError'; + } +} + +/** Builds fixture property mappings against declared edge facts instead of owner string shape. */ +export function buildV17GoldenFixturePropertyMappings( + manifest: V17GoldenGraphFixtureManifest, +): readonly GraphModelMigrationPropertyMapping[] { + const checkedManifest = requireManifest(manifest); + const edgeFactKeys = declaredEdgeFactKeys(checkedManifest.visibleFacts); + return Object.freeze(checkedManifest.visibleFacts + .filter((fact) => fact instanceof V17GoldenPropertyFact) + .map((fact) => propertyMappingFromFact(fact, edgeFactKeys))); +} + +function propertyMappingFromFact( + fact: V17GoldenPropertyFact, + edgeFactKeys: ReadonlySet, +): GraphModelMigrationPropertyMapping { + const separator = fact.key.lastIndexOf(':'); + if (separator <= 0 || separator === fact.key.length - 1) { + throw new V17GoldenGraphFixturePropertyMappingError( + `property fact ${fact.key} must use owner:property public key format`, + ); + } + const ownerId = fact.key.slice(0, separator); + const propertyKey = fact.key.slice(separator + 1); + return new GraphModelMigrationPropertyMapping({ + legacyOwnerId: ownerId, + legacyPropertyKey: propertyKey, + targetOwnerId: targetPropertyOwnerId(ownerId, edgeFactKeys), + targetPropertyKey: propertyKey, + }); +} + +function declaredEdgeFactKeys(facts: readonly V17GoldenGraphFixtureVisibleFact[]): ReadonlySet { + return new Set(facts + .filter((fact) => fact instanceof V17GoldenEdgeFact) + .map((fact) => fact.key)); +} + +function targetPropertyOwnerId(ownerId: string, edgeFactKeys: ReadonlySet): string { + if (!edgeFactKeys.has(ownerId)) { + return ownerId; + } + const edge = parsePublicEdgeFactKey(ownerId); + if (edge === null) { + throw new V17GoldenGraphFixturePropertyMappingError( + `declared edge property owner ${ownerId} must use from->to:label format`, + ); + } + return encodeLegacyEdgePropNode(edge.from, edge.to, edge.label); +} + +function parsePublicEdgeFactKey(ownerId: string): { + readonly from: string; + readonly to: string; + readonly label: string; +} | null { + const arrowIndex = ownerId.indexOf('->'); + const labelIndex = ownerId.lastIndexOf(':'); + if (arrowIndex <= 0 || labelIndex <= arrowIndex + 2 || labelIndex === ownerId.length - 1) { + return null; + } + return Object.freeze({ + from: ownerId.slice(0, arrowIndex), + to: ownerId.slice(arrowIndex + 2, labelIndex), + label: ownerId.slice(labelIndex + 1), + }); +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new V17GoldenGraphFixturePropertyMappingError( + 'manifest must be a V17GoldenGraphFixtureManifest', + ); + } + return manifest; +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunHarness.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunHarness.ts new file mode 100644 index 000000000..5434c74bf --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunHarness.ts @@ -0,0 +1,266 @@ +import DryRunGraphModelMigrationPlanRequest + from '../../../../src/domain/migrations/DryRunGraphModelMigrationPlanRequest.ts'; +import GenesisEquivalenceComparisonBasis + from '../../../../src/domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GraphModelMigrationBasis from '../../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationEdgeMapping + from '../../../../src/domain/migrations/GraphModelMigrationEdgeMapping.ts'; +import GraphModelMigrationNodeMapping + from '../../../../src/domain/migrations/GraphModelMigrationNodeMapping.ts'; +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeReplayRequest + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import GraphModelMigrationRuntimeReplayResult + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenNodeFact, +} from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { + GraphModelMigrationCommandResult, + runGraphModelMigrationCommand, +} from './GraphModelMigrationCommand.ts'; +import { verifyGraphModelMigrationProductionRuntimeReplay } + from './GraphModelMigrationProductionRuntimeReplayProvider.ts'; +import { buildV17GoldenFixturePropertyMappings } + from './V17GoldenGraphFixturePropertyMappings.ts'; +import { createV17GoldenFixtureScratchReadingProvider } + from './V17GoldenFixtureScratchReadingProvider.ts'; +import { collectGraphModelMigrationSourceInventory } + from './GraphModelMigrationSourceInventoryCollector.ts'; +import { buildV17RestoredPublicReadLegacyReading } + from './V17RestoredPublicReadLegacyReadingBuilder.ts'; +import { + restoreV17GoldenGraphFixture, + type V17GoldenGraphFixtureRestoreResult, +} from './V17GoldenGraphFixtureRestore.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +export { buildV17GoldenFixturePropertyMappings } + from './V17GoldenGraphFixturePropertyMappings.ts'; +export { createV17GoldenFixtureScratchReadingProvider } + from './V17GoldenFixtureScratchReadingProvider.ts'; + +const DEFAULT_SCRATCH_REF_PREFIX = 'refs/warp-migration-scratch'; +export const V17_WET_RUN_DRIFT_CHECK_PASSED = 'passed'; +export const V17_WET_RUN_DRIFT_CHECK_FAILED = 'failed'; + +export type V17GoldenGraphFixtureWetRunDriftCheckStatus = + | typeof V17_WET_RUN_DRIFT_CHECK_PASSED + | typeof V17_WET_RUN_DRIFT_CHECK_FAILED; + +export type V17GoldenGraphFixtureWetRunHarnessOptions = { + readonly manifestPath: string; + readonly targetDirectory: string; + readonly scratchRefName?: string | null; + readonly runtimeRepositoryPath?: string | null; +}; + +/** Evidence produced by the v17 fixture wet-run harness. */ +export class V17GoldenGraphFixtureWetRunHarnessResult { + constructor( + readonly restoreResult: V17GoldenGraphFixtureRestoreResult, + readonly commandResult: GraphModelMigrationCommandResult, + readonly runtimeReplayResult: GraphModelMigrationRuntimeReplayResult | null, + readonly driftCheckResult: V17GoldenGraphFixtureWetRunDriftCheckResult, + ) { + Object.freeze(this); + } +} + +/** Source-ref drift evidence captured before any future finalization step. */ +export class V17GoldenGraphFixtureWetRunDriftCheckResult { + constructor( + readonly status: V17GoldenGraphFixtureWetRunDriftCheckStatus, + readonly checkedRefCount: number, + readonly fatalErrors: readonly GraphModelMigrationNotice[], + ) { + Object.freeze(this); + } +} + +export class V17GoldenGraphFixtureWetRunHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = 'V17GoldenGraphFixtureWetRunHarnessError'; + } +} + +/** Restores the v17 fixture and runs the v18 migration path against scratch history. */ +export async function runV17GoldenGraphFixtureWetRun( + options: V17GoldenGraphFixtureWetRunHarnessOptions, +): Promise { + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: requireNonEmptyString(options.manifestPath, 'manifestPath'), + targetDirectory: requireNonEmptyString(options.targetDirectory, 'targetDirectory'), + }); + const scratchRefName = requireNonEmptyString( + options.scratchRefName ?? defaultScratchRefName(restoreResult.manifest), + 'scratchRefName', + ); + const inventory = await collectGraphModelMigrationSourceInventory({ + repositoryPath: restoreResult.repositoryPath, + graphId: restoreResult.manifest.graphId, + fixtureManifest: restoreResult.manifest, + }); + const dryRunRequest = dryRunRequestForManifest(restoreResult.manifest, inventory); + const commandResult = await runGraphModelMigrationCommand({ + repositoryPath: restoreResult.repositoryPath, + dryRunRequest, + scratchRefName, + equivalenceBasis: equivalenceBasisForRequest(dryRunRequest), + legacyReading: null, + scratchReading: null, + readingProviders: { + legacyReading: async () => await buildV17RestoredPublicReadLegacyReading({ + repositoryPath: restoreResult.repositoryPath, + manifest: restoreResult.manifest, + }), + scratchReading: createV17GoldenFixtureScratchReadingProvider({ + sourceRepositoryPath: restoreResult.repositoryPath, + manifest: restoreResult.manifest, + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + }), + }, + finalization: null, + }); + const scratchWriteResult = commandResult.scratchWriteResult; + const runtimeReplayResult = scratchWriteResult !== null + && scratchWriteResult.scratchRef !== null + && scratchWriteResult.scratchHead !== null + ? await verifyGraphModelMigrationProductionRuntimeReplay({ + sourceRepositoryPath: restoreResult.repositoryPath, + runtimeRepositoryPath: options.runtimeRepositoryPath ?? null, + request: new GraphModelMigrationRuntimeReplayRequest({ + graphId: restoreResult.manifest.graphId, + writerId: 'scratch-migration', + scratchRef: scratchWriteResult.scratchRef, + scratchHead: scratchWriteResult.scratchHead, + }), + }) + : null; + const driftCheckResult = await checkV17GoldenGraphFixtureWetRunDrift({ + repositoryPath: restoreResult.repositoryPath, + manifest: restoreResult.manifest, + }); + return new V17GoldenGraphFixtureWetRunHarnessResult( + restoreResult, + commandResult, + runtimeReplayResult, + driftCheckResult, + ); +} + +/** Verifies restored source writer refs still match manifest evidence. */ +export async function checkV17GoldenGraphFixtureWetRunDrift(options: { + readonly repositoryPath: string; + readonly manifest: V17GoldenGraphFixtureManifest; +}): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const manifest = requireManifest(options.manifest); + const fatalErrors: GraphModelMigrationNotice[] = []; + for (const chain of manifest.writerChains) { + const observedHead = await gitTextOrNull(repositoryPath, [ + 'show-ref', + '--verify', + '--hash', + chain.refName, + ]); + if (observedHead !== chain.expectedHead) { + fatalErrors.push(GraphModelMigrationNotice.fatal( + 'E_WET_RUN_SOURCE_REF_DRIFT', + `source ref ${chain.refName} expected ${chain.expectedHead}, got ${observedHead ?? '(missing)'}`, + )); + continue; + } + const observedPatchCount = Number(await gitTextOrNull(repositoryPath, [ + 'rev-list', + '--count', + chain.refName, + ])); + if (observedPatchCount !== chain.patchCount) { + fatalErrors.push(GraphModelMigrationNotice.fatal( + 'E_WET_RUN_SOURCE_REF_PATCH_COUNT_DRIFT', + `source ref ${chain.refName} expected ${chain.patchCount} patches, got ${observedPatchCount}`, + )); + } + } + return new V17GoldenGraphFixtureWetRunDriftCheckResult( + fatalErrors.length === 0 ? V17_WET_RUN_DRIFT_CHECK_PASSED : V17_WET_RUN_DRIFT_CHECK_FAILED, + manifest.writerChains.length, + fatalErrors, + ); +} + +function dryRunRequestForManifest( + manifest: V17GoldenGraphFixtureManifest, + inventory: Awaited>, +): DryRunGraphModelMigrationPlanRequest { + return new DryRunGraphModelMigrationPlanRequest({ + inventory, + requiredContentKeys: manifest.visibleFacts + .filter((fact) => fact instanceof V17GoldenContentFact) + .map((fact) => fact.key), + nodeMappings: manifest.visibleFacts + .filter((fact) => fact instanceof V17GoldenNodeFact) + .map((fact) => new GraphModelMigrationNodeMapping({ + legacyNodeId: fact.key, + targetNodeId: fact.key, + })), + edgeMappings: manifest.visibleFacts + .filter((fact) => fact instanceof V17GoldenEdgeFact) + .map((fact) => new GraphModelMigrationEdgeMapping({ + legacyEdgeId: fact.key, + targetEdgeId: fact.key, + })), + propertyMappings: buildV17GoldenFixturePropertyMappings(manifest), + }); +} + +function equivalenceBasisForRequest( + request: DryRunGraphModelMigrationPlanRequest, +): GenesisEquivalenceComparisonBasis { + const sourceBasis = request.inventory.sourceBasis; + if (sourceBasis === null) { + throw new V17GoldenGraphFixtureWetRunHarnessError( + 'wet-run request must have a source basis', + ); + } + return new GenesisEquivalenceComparisonBasis({ + legacyBasis: sourceBasis, + migratedBasis: new GraphModelMigrationBasis({ + graphId: sourceBasis.graphId, + basisId: `${sourceBasis.basisId}:v18-dry-run`, + }), + }); +} + +function defaultScratchRefName(manifest: V17GoldenGraphFixtureManifest): string { + return `${DEFAULT_SCRATCH_REF_PREFIX}/${manifest.graphId}/wet-run`; +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new V17GoldenGraphFixtureWetRunHarnessError( + 'manifest must be a V17GoldenGraphFixtureManifest', + ); + } + return manifest; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new V17GoldenGraphFixtureWetRunHarnessError(`${name} must be a non-empty string`); + } + return value; +} + +async function gitTextOrNull(repositoryPath: string, args: readonly string[]): Promise { + const result = await runMigrationGit(repositoryPath, args, null); + if (!result.ok()) { + return null; + } + return result.stdout.trim(); +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunReport.ts b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunReport.ts new file mode 100644 index 000000000..8af94a410 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunReport.ts @@ -0,0 +1,131 @@ +import GraphModelMigrationNotice + from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GenesisEquivalenceProofFailure + from '../../../../src/domain/migrations/GenesisEquivalenceProofFailure.ts'; +import type GenesisEquivalenceMismatch + from '../../../../src/domain/migrations/GenesisEquivalenceMismatch.ts'; +import { + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts'; +import { formatGraphModelMigrationCommandReport } + from './GraphModelMigrationCommandReport.ts'; +import { V17GoldenGraphFixtureWetRunHarnessResult } + from './V17GoldenGraphFixtureWetRunHarness.ts'; + +/** Formats deterministic operator evidence for a v17 fixture wet run. */ +export function formatV17GoldenGraphFixtureWetRunReport( + result: V17GoldenGraphFixtureWetRunHarnessResult, +): string { + const checkedResult = requireHarnessResult(result); + return [ + 'git-warp v18 v17 fixture wet-run report', + `fixtureId: ${checkedResult.restoreResult.manifest.fixtureId}`, + `graphId: ${checkedResult.restoreResult.manifest.graphId}`, + `sourceVersion: ${checkedResult.restoreResult.manifest.sourceVersion}`, + `restoredRefs: ${checkedResult.restoreResult.restoredRefs.length}`, + ...restoredRefLines(checkedResult), + ...commandLines(checkedResult), + ...mismatchLines(checkedResult), + ...runtimeReplayLines(checkedResult), + ...driftCheckLines(checkedResult), + ].join('\n'); +} + +function restoredRefLines(result: V17GoldenGraphFixtureWetRunHarnessResult): readonly string[] { + return Object.freeze(result.restoreResult.restoredRefs.map( + (ref) => `restoredRef: ${ref.refName} ${ref.head} patches=${ref.patchCount}`, + )); +} + +function commandLines(result: V17GoldenGraphFixtureWetRunHarnessResult): readonly string[] { + return Object.freeze(formatGraphModelMigrationCommandReport(result.commandResult) + .split('\n') + .map((line) => `command.${line}`)); +} + +function mismatchLines(result: V17GoldenGraphFixtureWetRunHarnessResult): readonly string[] { + const gateResult = result.commandResult.gateResult; + if (gateResult === null || !(gateResult.proofResult instanceof GenesisEquivalenceProofFailure)) { + return Object.freeze([]); + } + return Object.freeze([ + 'mismatches:', + ...gateResult.proofResult.mismatches.map(formatMismatchLine), + ]); +} + +function formatMismatchLine(mismatch: GenesisEquivalenceMismatch): string { + return [ + '-', + mismatch.kind, + mismatch.factKind, + displayValue(mismatch.factKey), + displayValue(mismatch.fieldPath), + `legacy=${displayNullable(mismatch.legacyValue)}`, + `migrated=${displayNullable(mismatch.migratedValue)}`, + ].join(' '); +} + +function runtimeReplayLines(result: V17GoldenGraphFixtureWetRunHarnessResult): readonly string[] { + const runtimeReplay = result.runtimeReplayResult; + if (runtimeReplay === null) { + return Object.freeze(['runtimeReplay: skipped']); + } + const lines = [ + `runtimeReplay: ${runtimeReplay.status}`, + `runtimeReplayOperations: ${runtimeReplay.replayedOperationCount}`, + `runtimeReplayWitness: ${runtimeReplay.witness}`, + ]; + if (runtimeReplay.status === GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED) { + lines.push('runtimeReplayFatalErrors:'); + lines.push(...fatalNoticeLines(runtimeReplay.fatalErrors)); + } + return Object.freeze(lines); +} + +function driftCheckLines(result: V17GoldenGraphFixtureWetRunHarnessResult): readonly string[] { + const lines = [ + `driftCheck: ${result.driftCheckResult.status}`, + `driftCheckedRefs: ${result.driftCheckResult.checkedRefCount}`, + ]; + if (result.driftCheckResult.fatalErrors.length > 0) { + lines.push('driftCheckFatalErrors:'); + lines.push(...fatalNoticeLines(result.driftCheckResult.fatalErrors)); + } + return Object.freeze(lines); +} + +function fatalNoticeLines(fatalErrors: readonly GraphModelMigrationNotice[]): readonly string[] { + return Object.freeze(fatalErrors.map((notice) => `- ${notice.code}: ${notice.message}`)); +} + +function displayNullable(value: string | null): string { + if (value === null) { + return '(none)'; + } + return displayValue(value); +} + +function displayValue(value: string): string { + return value + .replaceAll('\0', '\\0') + .replaceAll('\n', '\\n'); +} + +function requireHarnessResult( + result: V17GoldenGraphFixtureWetRunHarnessResult, +): V17GoldenGraphFixtureWetRunHarnessResult { + if (!(result instanceof V17GoldenGraphFixtureWetRunHarnessResult)) { + throw new V17GoldenGraphFixtureWetRunReportError( + 'result must be a V17GoldenGraphFixtureWetRunHarnessResult', + ); + } + return result; +} + +export class V17GoldenGraphFixtureWetRunReportError extends Error { + constructor(message: string) { + super(message); + this.name = 'V17GoldenGraphFixtureWetRunReportError'; + } +} diff --git a/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts new file mode 100644 index 000000000..501c3ce81 --- /dev/null +++ b/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts @@ -0,0 +1,197 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Plumbing from '@git-stunts/plumbing'; + +import GenesisEquivalenceReading + from '../../../../src/domain/migrations/GenesisEquivalenceReading.ts'; +import GenesisEquivalenceReadingFact + from '../../../../src/domain/migrations/GenesisEquivalenceReadingFact.ts'; +import V17GoldenGraphFixtureGenesisReading + from '../../../../src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts'; +import V17GoldenGraphFixtureManifest + from '../../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { CONTENT_PROPERTY_KEY } + from '../../../../src/domain/services/KeyCodec.ts'; +import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAdapter.ts'; +import { runMigrationGit } from './GitMigrationCommandRunner.ts'; + +const CONTENT_ATTACHMENT_SUFFIX = `:${CONTENT_PROPERTY_KEY}`; + +export type V17RestoredPublicReadLegacyReadingBuilderOptions = { + readonly repositoryPath: string; + readonly manifest: V17GoldenGraphFixtureManifest; + readonly contentOidRepositoryPath?: string | null; +}; + +/** Builds legacy equivalence facts from a restored v17 fixture after ref verification. */ +export async function buildV17RestoredPublicReadLegacyReading( + options: V17RestoredPublicReadLegacyReadingBuilderOptions, +): Promise { + const repositoryPath = requireNonEmptyString(options.repositoryPath, 'repositoryPath'); + const manifest = requireManifest(options.manifest); + await verifyRestoredWriterRefs(repositoryPath, manifest); + return await readingWithRuntimeContentOids( + new V17GoldenGraphFixtureGenesisReading().build(manifest), + manifest.graphId, + options.contentOidRepositoryPath ?? null, + ); +} + +async function verifyRestoredWriterRefs( + repositoryPath: string, + manifest: V17GoldenGraphFixtureManifest, +): Promise { + for (const chain of manifest.writerChains) { + const observedHead = await gitText(repositoryPath, [ + 'show-ref', + '--verify', + '--hash', + chain.refName, + ]); + if (observedHead !== chain.expectedHead) { + throw new V17RestoredPublicReadLegacyReadingBuilderError( + `restored ref ${chain.refName} expected ${chain.expectedHead}, got ${observedHead}`, + ); + } + const observedPatchCount = Number(await gitText(repositoryPath, [ + 'rev-list', + '--count', + chain.refName, + ])); + if (observedPatchCount !== chain.patchCount) { + throw new V17RestoredPublicReadLegacyReadingBuilderError( + `restored ref ${chain.refName} expected ${chain.patchCount} patches, got ${observedPatchCount}`, + ); + } + } +} + +async function readingWithRuntimeContentOids( + reading: GenesisEquivalenceReading, + graphId: string, + repositoryPath: string | null, +): Promise { + const resolver = await RuntimeContentOidResolver.open(repositoryPath); + try { + const facts: GenesisEquivalenceReadingFact[] = []; + for (const fact of reading.facts) { + facts.push(await factWithRuntimeContentOid(fact, graphId, resolver)); + } + return new GenesisEquivalenceReading({ + readingId: reading.readingId, + facts, + }); + } finally { + await resolver.close(); + } +} + +async function factWithRuntimeContentOid( + fact: GenesisEquivalenceReadingFact, + graphId: string, + resolver: RuntimeContentOidResolver, +): Promise { + if (fact.kind !== 'content-attachment' || fact.fieldPath !== 'payload.oid') { + return fact; + } + return new GenesisEquivalenceReadingFact({ + kind: fact.kind, + factKey: fact.factKey, + fieldPath: fact.fieldPath, + value: await resolver.oidFor({ + graphId, + contentKey: fact.factKey, + nodeId: nodeIdFromContentFactKey(fact.factKey), + }), + boundary: fact.boundary, + }); +} + +function nodeIdFromContentFactKey(factKey: string): string { + if (!factKey.endsWith(CONTENT_ATTACHMENT_SUFFIX)) { + throw new V17RestoredPublicReadLegacyReadingBuilderError( + `content fact ${factKey} must identify a node ${CONTENT_PROPERTY_KEY} attachment`, + ); + } + return factKey.slice(0, factKey.length - CONTENT_ATTACHMENT_SUFFIX.length); +} + +class RuntimeContentOidResolver { + private constructor( + private readonly repositoryPath: string, + private readonly shouldCleanup: boolean, + private readonly storage: Awaited>, + ) { + } + + static async open(repositoryPath: string | null): Promise { + let runtimeRepositoryPath = repositoryPath; + let shouldCleanup = false; + if (runtimeRepositoryPath === null) { + runtimeRepositoryPath = await mkdtemp(join(tmpdir(), 'git-warp-v18-content-oid-')); + shouldCleanup = true; + } + const plumbing = await Plumbing.createDefault({ cwd: runtimeRepositoryPath }); + await plumbing.execute({ args: ['init', '-q'] }); + const adapter = new GitGraphAdapter({ plumbing }); + return new RuntimeContentOidResolver( + runtimeRepositoryPath, + shouldCleanup, + await adapter.createRuntimeBlobStorage(), + ); + } + + async oidFor(options: { + readonly graphId: string; + readonly contentKey: string; + readonly nodeId: string; + }): Promise { + const content = `migration-source:${options.contentKey}`; + return await this.storage.store(content, { + slug: `${options.graphId}/${options.nodeId}`, + mime: 'text/plain', + size: new TextEncoder().encode(content).byteLength, + }); + } + + async close(): Promise { + if (this.shouldCleanup) { + await rm(this.repositoryPath, { recursive: true, force: true }); + } + } +} + +async function gitText(repositoryPath: string, args: readonly string[]): Promise { + const result = await runMigrationGit(repositoryPath, args, null); + if (!result.ok()) { + throw new V17RestoredPublicReadLegacyReadingBuilderError( + `git ${args.join(' ')} failed: ${result.stderr}`, + ); + } + return result.stdout.trim(); +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new V17RestoredPublicReadLegacyReadingBuilderError( + 'manifest must be a V17GoldenGraphFixtureManifest', + ); + } + return manifest; +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new V17RestoredPublicReadLegacyReadingBuilderError(`${name} must be a non-empty string`); + } + return value; +} + +export class V17RestoredPublicReadLegacyReadingBuilderError extends Error { + constructor(message: string) { + super(message); + this.name = 'V17RestoredPublicReadLegacyReadingBuilderError'; + } +} diff --git a/src/domain/continuum/GitWarpGraphModelContractConformance.ts b/src/domain/continuum/GitWarpGraphModelContractConformance.ts new file mode 100644 index 000000000..f13451411 --- /dev/null +++ b/src/domain/continuum/GitWarpGraphModelContractConformance.ts @@ -0,0 +1,259 @@ +import ContinuumArtifactDescriptor from './ContinuumArtifactDescriptor.ts'; +import V17GoldenGraphFixtureManifest, { + V17_GOLDEN_CONTENT_FACT, + V17_GOLDEN_EDGE_FACT, + V17_GOLDEN_MULTI_WRITER_FACT, + V17_GOLDEN_NODE_FACT, + V17_GOLDEN_PROPERTY_FACT, + V17_GOLDEN_REMOVAL_FACT, + type V17GoldenGraphFixtureFactKind, +} from '../migrations/V17GoldenGraphFixtureManifest.ts'; +import WarpError from '../errors/WarpError.ts'; + +const RUNTIME_BOUNDARY_FAMILY_ID = 'runtime-boundary-family'; +const RUNTIME_BOUNDARY_SCHEMA_BASENAME = 'continuum-runtime-boundary-family.graphql'; +const CONTINUUM_FIXTURE_ARTIFACT_KIND = 'continuum.family.fixture'; +const CONTINUUM_FIXTURE_TARGET = 'continuum-fixture'; +const WARP_TTD_TARGET = 'warp-ttd'; + +const GRAPH_MODEL_CONFORMANCE_PASSED = 'passed'; +const GRAPH_MODEL_CONFORMANCE_FAILED = 'failed'; + +type GraphModelConformanceStatus = + | typeof GRAPH_MODEL_CONFORMANCE_PASSED + | typeof GRAPH_MODEL_CONFORMANCE_FAILED; + +type GitWarpGraphModelContractConformanceCheckFields = { + readonly name: string; + readonly status: GraphModelConformanceStatus; + readonly detail: string; +}; + +type GitWarpGraphModelContractConformanceResultFields = { + readonly descriptor: ContinuumArtifactDescriptor; + readonly manifest: V17GoldenGraphFixtureManifest; + readonly checks: readonly GitWarpGraphModelContractConformanceCheck[]; +}; + +/** A single generated-contract conformance check for graph-model migration evidence. */ +export class GitWarpGraphModelContractConformanceCheck { + readonly name: string; + readonly status: GraphModelConformanceStatus; + readonly detail: string; + + constructor(fields: GitWarpGraphModelContractConformanceCheckFields) { + this.name = requireNonEmptyString(fields.name, 'name'); + this.status = requireStatus(fields.status); + this.detail = requireNonEmptyString(fields.detail, 'detail'); + Object.freeze(this); + } + + /** Returns true when this check passed. */ + passed(): boolean { + return this.status === GRAPH_MODEL_CONFORMANCE_PASSED; + } +} + +/** Result value for graph-model conformance against an admitted Continuum contract descriptor. */ +export class GitWarpGraphModelContractConformanceResult { + readonly descriptor: ContinuumArtifactDescriptor; + readonly manifest: V17GoldenGraphFixtureManifest; + readonly checks: readonly GitWarpGraphModelContractConformanceCheck[]; + + constructor(fields: GitWarpGraphModelContractConformanceResultFields) { + this.descriptor = requireDescriptor(fields.descriptor); + this.manifest = requireManifest(fields.manifest); + this.checks = freezeChecks(fields.checks); + Object.freeze(this); + } + + /** Returns true when every required conformance check passed. */ + passed(): boolean { + return this.checks.every((check) => check.passed()); + } + + /** Returns failed checks for operator-facing release evidence. */ + failedChecks(): readonly GitWarpGraphModelContractConformanceCheck[] { + return Object.freeze(this.checks.filter((check) => !check.passed())); + } + + /** Returns a compact deterministic evidence summary for release packets. */ + evidenceLines(): readonly string[] { + return Object.freeze([ + `contract-family=${this.descriptor.familyId.toString()}`, + `source-schema=${this.descriptor.sourceSchemaPath}`, + `contract-targets=${this.descriptor.targets.join(',')}`, + `fixture-id=${this.manifest.fixtureId}`, + `graph-id=${this.manifest.graphId}`, + `visible-fact-count=${this.manifest.visibleFacts.length.toString()}`, + `writer-chain-count=${this.manifest.writerChains.length.toString()}`, + `status=${this.passed() ? GRAPH_MODEL_CONFORMANCE_PASSED : GRAPH_MODEL_CONFORMANCE_FAILED}`, + ]); + } +} + +/** Checks that v18 graph-model migration evidence is backed by generated Continuum contract shape. */ +export default class GitWarpGraphModelContractConformance { + /** Evaluates a descriptor and v17 fixture manifest as generated-contract evidence. */ + evaluate( + descriptor: ContinuumArtifactDescriptor, + manifest: V17GoldenGraphFixtureManifest, + ): GitWarpGraphModelContractConformanceResult { + const checkedDescriptor = requireDescriptor(descriptor); + const checkedManifest = requireManifest(manifest); + return new GitWarpGraphModelContractConformanceResult({ + descriptor: checkedDescriptor, + manifest: checkedManifest, + checks: buildConformanceChecks(checkedDescriptor, checkedManifest), + }); + } +} + +function buildConformanceChecks( + descriptor: ContinuumArtifactDescriptor, + manifest: V17GoldenGraphFixtureManifest, +): readonly GitWarpGraphModelContractConformanceCheck[] { + return Object.freeze([ + ...descriptorConformanceChecks(descriptor), + ...manifestConformanceChecks(manifest), + ]); +} + +function descriptorConformanceChecks( + descriptor: ContinuumArtifactDescriptor, +): readonly GitWarpGraphModelContractConformanceCheck[] { + return Object.freeze([ + checkEquals('runtime-boundary-family', descriptor.familyId.toString(), RUNTIME_BOUNDARY_FAMILY_ID), + checkEquals('runtime-boundary-artifact-kind', descriptor.artifactKind, CONTINUUM_FIXTURE_ARTIFACT_KIND), + checkIncludes('runtime-boundary-schema', descriptor.sourceSchemaPath, RUNTIME_BOUNDARY_SCHEMA_BASENAME), + checkTarget(descriptor, CONTINUUM_FIXTURE_TARGET), + checkTarget(descriptor, WARP_TTD_TARGET), + checkGeneratedAuthority(descriptor), + ]); +} + +function manifestConformanceChecks( + manifest: V17GoldenGraphFixtureManifest, +): readonly GitWarpGraphModelContractConformanceCheck[] { + return Object.freeze([ + checkFactKind(manifest, V17_GOLDEN_NODE_FACT), + checkFactKind(manifest, V17_GOLDEN_EDGE_FACT), + checkFactKind(manifest, V17_GOLDEN_PROPERTY_FACT), + checkFactKind(manifest, V17_GOLDEN_CONTENT_FACT), + checkFactKind(manifest, V17_GOLDEN_REMOVAL_FACT), + checkFactKind(manifest, V17_GOLDEN_MULTI_WRITER_FACT), + ]); +} + +function checkEquals( + name: string, + actual: string, + expected: string, +): GitWarpGraphModelContractConformanceCheck { + if (actual === expected) { + return passedCheck(name, `${actual} matches generated contract evidence`); + } + return failedCheck(name, `${actual} does not match ${expected}`); +} + +function checkIncludes( + name: string, + actual: string, + expectedFragment: string, +): GitWarpGraphModelContractConformanceCheck { + if (actual.includes(expectedFragment)) { + return passedCheck(name, `${actual} includes ${expectedFragment}`); + } + return failedCheck(name, `${actual} does not include ${expectedFragment}`); +} + +function checkTarget( + descriptor: ContinuumArtifactDescriptor, + target: string, +): GitWarpGraphModelContractConformanceCheck { + if (descriptor.hasTarget(target)) { + return passedCheck(`target:${target}`, `descriptor includes ${target}`); + } + return failedCheck(`target:${target}`, `descriptor does not include ${target}`); +} + +function checkGeneratedAuthority( + descriptor: ContinuumArtifactDescriptor, +): GitWarpGraphModelContractConformanceCheck { + if (descriptor.hasGeneratedAuthority()) { + return passedCheck('generated-authority', 'descriptor authority is generated'); + } + return failedCheck('generated-authority', 'descriptor authority is not generated'); +} + +function checkFactKind( + manifest: V17GoldenGraphFixtureManifest, + kind: V17GoldenGraphFixtureFactKind, +): GitWarpGraphModelContractConformanceCheck { + if (manifest.hasVisibleFactKind(kind)) { + return passedCheck(`fixture-fact:${kind}`, `fixture includes ${kind} facts`); + } + return failedCheck(`fixture-fact:${kind}`, `fixture does not include ${kind} facts`); +} + +function passedCheck(name: string, detail: string): GitWarpGraphModelContractConformanceCheck { + return new GitWarpGraphModelContractConformanceCheck({ + name, + status: GRAPH_MODEL_CONFORMANCE_PASSED, + detail, + }); +} + +function failedCheck(name: string, detail: string): GitWarpGraphModelContractConformanceCheck { + return new GitWarpGraphModelContractConformanceCheck({ + name, + status: GRAPH_MODEL_CONFORMANCE_FAILED, + detail, + }); +} + +function requireDescriptor(descriptor: ContinuumArtifactDescriptor): ContinuumArtifactDescriptor { + if (!(descriptor instanceof ContinuumArtifactDescriptor)) { + throw new WarpError('descriptor must be a ContinuumArtifactDescriptor', 'E_VALIDATION'); + } + return descriptor; +} + +function requireManifest(manifest: V17GoldenGraphFixtureManifest): V17GoldenGraphFixtureManifest { + if (!(manifest instanceof V17GoldenGraphFixtureManifest)) { + throw new WarpError('manifest must be a V17GoldenGraphFixtureManifest', 'E_VALIDATION'); + } + return manifest; +} + +function freezeChecks( + checks: readonly GitWarpGraphModelContractConformanceCheck[], +): readonly GitWarpGraphModelContractConformanceCheck[] { + if (!Array.isArray(checks) || checks.length === 0) { + throw new WarpError('checks must contain at least one conformance check', 'E_VALIDATION'); + } + return Object.freeze(checks.map(requireCheck)); +} + +function requireCheck( + check: GitWarpGraphModelContractConformanceCheck, +): GitWarpGraphModelContractConformanceCheck { + if (!(check instanceof GitWarpGraphModelContractConformanceCheck)) { + throw new WarpError('checks must contain GitWarpGraphModelContractConformanceCheck values', 'E_VALIDATION'); + } + return check; +} + +function requireStatus(status: GraphModelConformanceStatus): GraphModelConformanceStatus { + if (status === GRAPH_MODEL_CONFORMANCE_PASSED || status === GRAPH_MODEL_CONFORMANCE_FAILED) { + return status; + } + throw new WarpError('status must be a graph-model conformance status', 'E_VALIDATION'); +} + +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/continuum/GitWarpWarpTtdGeneratedFamilySmoke.ts b/src/domain/continuum/GitWarpWarpTtdGeneratedFamilySmoke.ts new file mode 100644 index 000000000..e60514dc2 --- /dev/null +++ b/src/domain/continuum/GitWarpWarpTtdGeneratedFamilySmoke.ts @@ -0,0 +1,144 @@ +import { + GitWarpGraphModelContractConformanceResult, +} from './GitWarpGraphModelContractConformance.ts'; +import WarpError from '../errors/WarpError.ts'; + +const WARP_TTD_PRESENT_POSTURE = 'PRESENT'; +const WARP_TTD_OBSTRUCTED_POSTURE = 'OBSTRUCTED'; +const WARP_TTD_SOURCE_FAMILY = 'git-warp'; +const WARP_TTD_ARTIFACT = 'runtime-boundary-family.graph-model-conformance'; +const WARP_TTD_ORIGIN = 'TRANSLATED_SUBSTRATE'; +const WARP_TTD_SCOPE = 'SESSION'; +const WARP_TTD_TARGET = 'warp-ttd'; + +type GitWarpWarpTtdGeneratedFamilySmokePosture = + | typeof WARP_TTD_PRESENT_POSTURE + | typeof WARP_TTD_OBSTRUCTED_POSTURE; + +type GitWarpWarpTtdGeneratedFamilySmokeFactFields = { + readonly posture: GitWarpWarpTtdGeneratedFamilySmokePosture; + readonly sourceFamily: string; + readonly artifact: string; + readonly origin: string; + readonly scope: string; + readonly target: string; + readonly payloadLines: readonly string[]; + readonly reason?: string; +}; + +/** `warp-ttd`-shaped generated-family fact proving git-warp graph-model evidence is consumable. */ +export class GitWarpWarpTtdGeneratedFamilySmokeFact { + readonly posture: GitWarpWarpTtdGeneratedFamilySmokePosture; + readonly sourceFamily: string; + readonly artifact: string; + readonly origin: string; + readonly scope: string; + readonly target: string; + readonly payloadLines: readonly string[]; + readonly reason: string | undefined; + + constructor(fields: GitWarpWarpTtdGeneratedFamilySmokeFactFields) { + this.posture = requirePosture(fields.posture); + this.sourceFamily = requireNonEmptyString(fields.sourceFamily, 'sourceFamily'); + this.artifact = requireNonEmptyString(fields.artifact, 'artifact'); + this.origin = requireNonEmptyString(fields.origin, 'origin'); + this.scope = requireNonEmptyString(fields.scope, 'scope'); + this.target = requireNonEmptyString(fields.target, 'target'); + this.payloadLines = freezePayloadLines(fields.payloadLines); + this.reason = optionalReason(fields.reason, this.posture); + Object.freeze(this); + } + + /** Returns true when the generated-family fact is present for `warp-ttd`. */ + passed(): boolean { + return this.posture === WARP_TTD_PRESENT_POSTURE; + } +} + +/** Builds a `warp-ttd` generated-family smoke fact from graph-model conformance evidence. */ +export default class GitWarpWarpTtdGeneratedFamilySmoke { + /** Converts conformance evidence into a `warp-ttd` generated-family smoke fact. */ + evaluate( + conformance: GitWarpGraphModelContractConformanceResult, + ): GitWarpWarpTtdGeneratedFamilySmokeFact { + const checkedConformance = requireConformance(conformance); + if (checkedConformance.passed() && checkedConformance.descriptor.hasTarget(WARP_TTD_TARGET)) { + return new GitWarpWarpTtdGeneratedFamilySmokeFact({ + posture: WARP_TTD_PRESENT_POSTURE, + sourceFamily: WARP_TTD_SOURCE_FAMILY, + artifact: WARP_TTD_ARTIFACT, + origin: WARP_TTD_ORIGIN, + scope: WARP_TTD_SCOPE, + target: WARP_TTD_TARGET, + payloadLines: checkedConformance.evidenceLines(), + }); + } + return new GitWarpWarpTtdGeneratedFamilySmokeFact({ + posture: WARP_TTD_OBSTRUCTED_POSTURE, + sourceFamily: WARP_TTD_SOURCE_FAMILY, + artifact: WARP_TTD_ARTIFACT, + origin: WARP_TTD_ORIGIN, + scope: WARP_TTD_SCOPE, + target: WARP_TTD_TARGET, + payloadLines: checkedConformance.evidenceLines(), + reason: obstructionReason(checkedConformance), + }); + } +} + +function obstructionReason(conformance: GitWarpGraphModelContractConformanceResult): string { + const failedNames = conformance.failedChecks().map((check) => check.name); + if (failedNames.length === 0 && !conformance.descriptor.hasTarget(WARP_TTD_TARGET)) { + return 'generated-family conformance did not expose the warp-ttd target'; + } + return `generated-family conformance failed: ${failedNames.join(', ')}`; +} + +function requireConformance( + value: GitWarpGraphModelContractConformanceResult, +): GitWarpGraphModelContractConformanceResult { + if (!(value instanceof GitWarpGraphModelContractConformanceResult)) { + throw new WarpError('conformance must be a GitWarpGraphModelContractConformanceResult', 'E_VALIDATION'); + } + return value; +} + +function requirePosture( + value: GitWarpWarpTtdGeneratedFamilySmokePosture, +): GitWarpWarpTtdGeneratedFamilySmokePosture { + if (value === WARP_TTD_PRESENT_POSTURE || value === WARP_TTD_OBSTRUCTED_POSTURE) { + return value; + } + throw new WarpError('posture must be a warp-ttd generated-family smoke posture', 'E_VALIDATION'); +} + +function freezePayloadLines(lines: readonly string[]): readonly string[] { + if (!Array.isArray(lines) || lines.length === 0) { + throw new WarpError('payloadLines must contain at least one evidence line', 'E_VALIDATION'); + } + const checkedLines: string[] = []; + for (const line of lines) { + checkedLines.push(requireNonEmptyString(line, 'payloadLines[]')); + } + return Object.freeze(checkedLines); +} + +function optionalReason( + value: string | undefined, + posture: GitWarpWarpTtdGeneratedFamilySmokePosture, +): string | undefined { + if (posture === WARP_TTD_PRESENT_POSTURE) { + if (value !== undefined) { + throw new WarpError('present warp-ttd generated-family facts must not carry an obstruction reason', 'E_VALIDATION'); + } + return undefined; + } + return requireNonEmptyString(value, 'reason'); +} + +function requireNonEmptyString(value: string | undefined, 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/GraphModelMigrationRequiredString.ts b/src/domain/migrations/GraphModelMigrationRequiredString.ts new file mode 100644 index 000000000..770421601 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationRequiredString.ts @@ -0,0 +1,9 @@ +import WarpError from '../errors/WarpError.ts'; + +/** Validates required migration string fields at runtime boundaries. */ +export function requireGraphModelMigrationNonEmptyString(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/GraphModelMigrationRuntimeReplayRequest.ts b/src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts new file mode 100644 index 000000000..f51e2bda0 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts @@ -0,0 +1,46 @@ +import GraphModelMigrationScratchRef from './GraphModelMigrationScratchRef.ts'; +import { requireGraphModelMigrationNonEmptyString } from './GraphModelMigrationRequiredString.ts'; +import WarpError from '../errors/WarpError.ts'; + +export type GraphModelMigrationRuntimeReplayRequestFields = { + readonly graphId: string; + readonly writerId: string; + readonly scratchRef: GraphModelMigrationScratchRef; + readonly scratchHead: string; +}; + +/** Request to replay scratch migration output through normal graph runtime. */ +export default class GraphModelMigrationRuntimeReplayRequest { + readonly graphId: string; + readonly writerId: string; + readonly scratchRef: GraphModelMigrationScratchRef; + readonly scratchHead: string; + + constructor(fields: GraphModelMigrationRuntimeReplayRequestFields) { + const checkedFields = requireFields(fields); + this.graphId = requireGraphModelMigrationNonEmptyString(checkedFields.graphId, 'graphId'); + this.writerId = requireGraphModelMigrationNonEmptyString(checkedFields.writerId, 'writerId'); + this.scratchRef = requireScratchRef(checkedFields.scratchRef); + this.scratchHead = requireGraphModelMigrationNonEmptyString(checkedFields.scratchHead, 'scratchHead'); + Object.freeze(this); + } +} + +function requireFields( + fields: GraphModelMigrationRuntimeReplayRequestFields | null | undefined, +): GraphModelMigrationRuntimeReplayRequestFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationRuntimeReplayRequest fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireScratchRef(scratchRef: GraphModelMigrationScratchRef): GraphModelMigrationScratchRef { + if (!(scratchRef instanceof GraphModelMigrationScratchRef)) { + throw new WarpError('scratchRef must be a GraphModelMigrationScratchRef', 'E_VALIDATION'); + } + return scratchRef; +} diff --git a/src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts b/src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts new file mode 100644 index 000000000..080216b21 --- /dev/null +++ b/src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts @@ -0,0 +1,113 @@ +import GraphModelMigrationNotice from './GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeReplayRequest from './GraphModelMigrationRuntimeReplayRequest.ts'; +import { requireGraphModelMigrationNonEmptyString } from './GraphModelMigrationRequiredString.ts'; +import WarpError from '../errors/WarpError.ts'; + +export const GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED = 'passed'; +export const GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED = 'failed'; + +export type GraphModelMigrationRuntimeReplayStatus = + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED + | typeof GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED; + +export type GraphModelMigrationRuntimeReplayResultFields = { + readonly request: GraphModelMigrationRuntimeReplayRequest; + readonly status: GraphModelMigrationRuntimeReplayStatus; + readonly witness: string; + readonly replayedOperationCount: number; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; +}; + +/** Result of replaying migrated scratch operations through normal graph runtime. */ +export default class GraphModelMigrationRuntimeReplayResult { + readonly request: GraphModelMigrationRuntimeReplayRequest; + readonly status: GraphModelMigrationRuntimeReplayStatus; + readonly witness: string; + readonly replayedOperationCount: number; + readonly fatalErrors: readonly GraphModelMigrationNotice[]; + + constructor(fields: GraphModelMigrationRuntimeReplayResultFields) { + const checkedFields = requireFields(fields); + this.request = requireRequest(checkedFields.request); + this.status = requireStatus(checkedFields.status); + this.witness = requireGraphModelMigrationNonEmptyString(checkedFields.witness, 'witness'); + this.replayedOperationCount = requireNonNegativeSafeInteger( + checkedFields.replayedOperationCount, + 'replayedOperationCount', + ); + this.fatalErrors = freezeFatalNotices(checkedFields.fatalErrors); + requireStatusMatchesFatalErrors(this.status, this.fatalErrors); + Object.freeze(this); + } + + /** Returns true when scratch operations were materialized by the production runtime. */ + allowsFinalization(): boolean { + return this.status === GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED; + } +} + +function requireFields( + fields: GraphModelMigrationRuntimeReplayResultFields | null | undefined, +): GraphModelMigrationRuntimeReplayResultFields { + if (fields === null || fields === undefined) { + throw new WarpError( + 'GraphModelMigrationRuntimeReplayResult fields must be provided', + 'E_VALIDATION', + ); + } + return fields; +} + +function requireRequest( + request: GraphModelMigrationRuntimeReplayRequest, +): GraphModelMigrationRuntimeReplayRequest { + if (!(request instanceof GraphModelMigrationRuntimeReplayRequest)) { + throw new WarpError('request must be a GraphModelMigrationRuntimeReplayRequest', 'E_VALIDATION'); + } + return request; +} + +function requireStatus(status: GraphModelMigrationRuntimeReplayStatus): GraphModelMigrationRuntimeReplayStatus { + if ( + status !== GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED + && status !== GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED + ) { + throw new WarpError('runtime replay status is unsupported', 'E_VALIDATION'); + } + return status; +} + +function requireNonNegativeSafeInteger(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; +} + +function freezeFatalNotices( + fatalErrors: readonly GraphModelMigrationNotice[], +): readonly GraphModelMigrationNotice[] { + if (!Array.isArray(fatalErrors)) { + throw new WarpError('fatalErrors must be an array', 'E_VALIDATION'); + } + return Object.freeze(fatalErrors.map(requireFatalNotice)); +} + +function requireFatalNotice(notice: GraphModelMigrationNotice): GraphModelMigrationNotice { + if (!(notice instanceof GraphModelMigrationNotice) || !notice.isFatal()) { + throw new WarpError('fatalErrors must contain fatal migration notices', 'E_VALIDATION'); + } + return notice; +} + +function requireStatusMatchesFatalErrors( + status: GraphModelMigrationRuntimeReplayStatus, + fatalErrors: readonly GraphModelMigrationNotice[], +): void { + if (status === GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED && fatalErrors.length > 0) { + throw new WarpError('passed runtime replay must not contain fatal errors', 'E_VALIDATION'); + } + if (status === GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED && fatalErrors.length === 0) { + throw new WarpError('failed runtime replay must contain fatal errors', 'E_VALIDATION'); + } +} diff --git a/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts b/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts index 53dab6d75..31b6719b5 100644 --- a/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts +++ b/src/domain/migrations/V17GoldenGraphFixtureGenesisReading.ts @@ -62,7 +62,12 @@ function projectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFie function compatibilityProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { if (fact instanceof V17GoldenPropertyFact) { - return projection({ kind: 'property', factKey: fact.key, fieldPath: 'value', value: fact.description }); + return projection({ + kind: 'property', + factKey: fact.key, + fieldPath: 'value', + value: `migration-source:${legacyPropertyKeyFor(fact.key)}`, + }); } if (fact instanceof V17GoldenContentFact) { return projection({ @@ -75,6 +80,17 @@ function compatibilityProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): Pro return nonVisibleLifecycleProjectionFor(fact); } +function legacyPropertyKeyFor(factKey: string): string { + const separator = factKey.lastIndexOf(':'); + if (separator <= 0 || separator === factKey.length - 1) { + throw new WarpError( + 'property fixture fact key must contain at least one colon not at the boundaries; colons are allowed in owner segment', + 'E_VALIDATION', + ); + } + return `${factKey.slice(0, separator)}\0${factKey.slice(separator + 1)}`; +} + function nonVisibleLifecycleProjectionFor(fact: V17GoldenGraphFixtureVisibleFact): ProjectedFactFields { if (fact instanceof V17GoldenRemovalFact) { return projection({ kind: 'node', factKey: fact.key, fieldPath: 'visibility', value: 'removed' }); diff --git a/src/domain/services/CoordinateFactExport.ts b/src/domain/services/CoordinateFactExport.ts index 0eeb2dd0c..178953fd3 100644 --- a/src/domain/services/CoordinateFactExport.ts +++ b/src/domain/services/CoordinateFactExport.ts @@ -1,5 +1,9 @@ import { canonicalStringify } from '../utils/canonicalStringify.ts'; import WarpError from '../errors/WarpError.ts'; +import { + TRANSFER_OP_ATTACH_EDGE_CONTENT, + TRANSFER_OP_ATTACH_NODE_CONTENT, +} from './transfer/transferOps.ts'; /** * Returns true if the value is null or undefined. @@ -108,7 +112,7 @@ function requireNonEmptyString(value: unknown, label: string): string { // nosem } /** - * Serializes an attach_node_content operation to its fact form. + * Serializes a node content attach operation to its fact form. */ function serializeNodeContentOp(op: VisibleStateTransferOperationV1): VisibleStateTransferOperationFactV1 { return { @@ -121,7 +125,7 @@ function serializeNodeContentOp(op: VisibleStateTransferOperationV1): VisibleSta } /** - * Serializes an attach_edge_content operation to its fact form. + * Serializes an edge content attach operation to its fact form. */ function serializeEdgeContentOp(op: VisibleStateTransferOperationV1): VisibleStateTransferOperationFactV1 { return { @@ -140,9 +144,9 @@ function serializeEdgeContentOp(op: VisibleStateTransferOperationV1): VisibleSta */ function serializeSingleTransferOp(op: VisibleStateTransferOperationV1): VisibleStateTransferOperationFactV1 { switch (op.op) { - case 'attach_node_content': + case TRANSFER_OP_ATTACH_NODE_CONTENT: return serializeNodeContentOp(op); - case 'attach_edge_content': + case TRANSFER_OP_ATTACH_EDGE_CONTENT: return serializeEdgeContentOp(op); default: return { ...op }; diff --git a/src/domain/services/transfer/transferOps.ts b/src/domain/services/transfer/transferOps.ts index ae8e0d08c..905416bc6 100644 --- a/src/domain/services/transfer/transferOps.ts +++ b/src/domain/services/transfer/transferOps.ts @@ -14,6 +14,11 @@ import { type VisibleStateReader, } from './transferKeys.ts'; +export const TRANSFER_OP_ATTACH_NODE_CONTENT = 'attach_node_content'; +export const TRANSFER_OP_CLEAR_NODE_CONTENT = 'clear_node_content'; +export const TRANSFER_OP_ATTACH_EDGE_CONTENT = 'attach_edge_content'; +export const TRANSFER_OP_CLEAR_EDGE_CONTENT = 'clear_edge_content'; + // ── Property ops ───────────────────────────────────────────────────────────── /** @@ -150,7 +155,7 @@ export function buildNodeAttach( meta: ContentMeta, ): VisibleStateTransferOperationV1 { return { - op: 'attach_node_content', + op: TRANSFER_OP_ATTACH_NODE_CONTENT, nodeId, content, contentOid: meta.oid, @@ -163,7 +168,7 @@ export function buildNodeAttach( * Build the clear operation for a single node's content. */ export function buildNodeClear(nodeId: string): VisibleStateTransferOperationV1 { - return { op: 'clear_node_content', nodeId } as VisibleStateTransferOperationV1; + return { op: TRANSFER_OP_CLEAR_NODE_CONTENT, nodeId } as VisibleStateTransferOperationV1; } export type NodeContentOpsParams = { @@ -229,7 +234,7 @@ export function buildEdgeAttach( meta: ContentMeta, ): VisibleStateTransferOperationV1 { return { - op: 'attach_edge_content', + op: TRANSFER_OP_ATTACH_EDGE_CONTENT, from: edge.from, to: edge.to, label: edge.label, @@ -245,7 +250,7 @@ export function buildEdgeAttach( */ export function buildEdgeClear(edge: EdgeRef): VisibleStateTransferOperationV1 { return { - op: 'clear_edge_content', + op: TRANSFER_OP_CLEAR_EDGE_CONTENT, from: edge.from, to: edge.to, label: edge.label, diff --git a/src/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.ts b/src/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.ts new file mode 100644 index 000000000..54efb6f01 --- /dev/null +++ b/src/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.ts @@ -0,0 +1,272 @@ +import GenesisEquivalenceComparisonBasis + from '../../domain/migrations/GenesisEquivalenceComparisonBasis.ts'; +import GenesisEquivalenceGateResult + from '../../domain/migrations/GenesisEquivalenceGateResult.ts'; +import GenesisEquivalenceProofSuccess + from '../../domain/migrations/GenesisEquivalenceProofSuccess.ts'; +import GenesisEquivalenceProofSummary + from '../../domain/migrations/GenesisEquivalenceProofSummary.ts'; +import GraphModelMigrationBasis from '../../domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationFinalizationConfirmation + from '../../domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationNotice, { + type GraphModelMigrationNoticeKind, +} from '../../domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeConformanceResult, { + type GraphModelMigrationRuntimeConformanceStatus, +} from '../../domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import GraphModelMigrationScratchRef + from '../../domain/migrations/GraphModelMigrationScratchRef.ts'; +import AdapterValidationError from '../../domain/errors/AdapterValidationError.ts'; +import WarpError from '../../domain/errors/WarpError.ts'; +import type { JsonObject } from './JsonObject.ts'; + +const REQUEST_KEYS = Object.freeze([ + 'liveRefName', + 'expectedLiveHead', + 'observedLiveHead', + 'scratchRefName', + 'scratchHead', + 'archiveRefName', + 'confirmationToken', + 'equivalence', + 'runtimeReplay', +]); +const CONFIRMATION_KEYS = Object.freeze(['confirmationToken']); +const EQUIVALENCE_KEYS = Object.freeze([ + 'legacyBasis', + 'migratedBasis', + 'legacyFactCount', + 'migratedFactCount', + 'mismatchCount', +]); +const BASIS_KEYS = Object.freeze(['graphId', 'basisId']); +const RUNTIME_REPLAY_KEYS = Object.freeze([ + 'scratchRefName', + 'scratchHead', + 'status', + 'witness', + 'fatalErrors', +]); +const NOTICE_KEYS = Object.freeze(['kind', 'code', 'message']); + +/** Parses finalization confirmation JSON into a runtime-backed confirmation noun. */ +export function parseGraphModelMigrationFinalizationConfirmation( + raw: string, +): GraphModelMigrationFinalizationConfirmation { + return parseDomainValue('finalization confirmation', () => { + const envelope = requireJsonObject(parseJson(raw, 'finalization confirmation'), 'finalizationConfirmation'); + rejectUnknownKeys(envelope, CONFIRMATION_KEYS, 'finalizationConfirmation'); + return new GraphModelMigrationFinalizationConfirmation({ + token: readRequiredString(envelope, 'finalizationConfirmation.confirmationToken', 'confirmationToken'), + }); + }); +} + +/** Parses finalization request JSON into a runtime-backed safety request. */ +export function parseGraphModelMigrationFinalizationRequest( + raw: string, +): GraphModelMigrationFinalizationRequest { + return parseDomainValue('finalization request', () => requestFromJson(parseJson(raw))); +} + +function parseDomainValue(label: string, parser: () => T): T { + try { + return parser(); + } catch (error) { + if (error instanceof AdapterValidationError) { + throw error; + } + if (error instanceof WarpError) { + throw new AdapterValidationError( + `Graph model migration ${label} is invalid: ${error.message}`, + { context: { causeCode: error.code, causeMessage: error.message } }, + ); + } + throw error; + } +} + +function requestFromJson(value: unknown): GraphModelMigrationFinalizationRequest { + const request = requireJsonObject(value, 'finalizationRequest'); + rejectUnknownKeys(request, REQUEST_KEYS, 'finalizationRequest'); + return new GraphModelMigrationFinalizationRequest({ + liveRefName: readRequiredString(request, 'finalizationRequest.liveRefName', 'liveRefName'), + expectedLiveHead: readRequiredString( + request, + 'finalizationRequest.expectedLiveHead', + 'expectedLiveHead', + ), + observedLiveHead: readRequiredString( + request, + 'finalizationRequest.observedLiveHead', + 'observedLiveHead', + ), + scratchRef: new GraphModelMigrationScratchRef({ + refName: readRequiredString(request, 'finalizationRequest.scratchRefName', 'scratchRefName'), + }), + scratchHead: readRequiredString(request, 'finalizationRequest.scratchHead', 'scratchHead'), + archiveRefName: readRequiredString(request, 'finalizationRequest.archiveRefName', 'archiveRefName'), + confirmation: new GraphModelMigrationFinalizationConfirmation({ + token: readRequiredString(request, 'finalizationRequest.confirmationToken', 'confirmationToken'), + }), + gateResult: readPassedGateResult(request), + runtimeConformance: readRuntimeReplay(request), + }); +} + +function readPassedGateResult(source: JsonObject): GenesisEquivalenceGateResult { + const equivalence = readRequiredObject(source, 'equivalence'); + rejectUnknownKeys(equivalence, EQUIVALENCE_KEYS, 'equivalence'); + const basis = new GenesisEquivalenceComparisonBasis({ + legacyBasis: readBasis(equivalence, 'legacyBasis'), + migratedBasis: readBasis(equivalence, 'migratedBasis'), + }); + const summary = new GenesisEquivalenceProofSummary({ + basis, + legacyFactCount: readRequiredSafeInteger(equivalence, 'equivalence.legacyFactCount', 'legacyFactCount'), + migratedFactCount: readRequiredSafeInteger(equivalence, 'equivalence.migratedFactCount', 'migratedFactCount'), + mismatchCount: readRequiredSafeInteger(equivalence, 'equivalence.mismatchCount', 'mismatchCount'), + }); + return new GenesisEquivalenceGateResult({ + proofResult: new GenesisEquivalenceProofSuccess({ basis, summary }), + divergenceReport: null, + fatalErrors: [], + }); +} + +function readBasis(source: JsonObject, key: string): GraphModelMigrationBasis { + const basis = readRequiredObject(source, key); + rejectUnknownKeys(basis, BASIS_KEYS, key); + return new GraphModelMigrationBasis({ + graphId: readRequiredString(basis, `${key}.graphId`, 'graphId'), + basisId: readRequiredString(basis, `${key}.basisId`, 'basisId'), + }); +} + +function readRuntimeReplay(source: JsonObject): GraphModelMigrationRuntimeConformanceResult { + const runtimeReplay = readRequiredObject(source, 'runtimeReplay'); + rejectUnknownKeys(runtimeReplay, RUNTIME_REPLAY_KEYS, 'runtimeReplay'); + return new GraphModelMigrationRuntimeConformanceResult({ + scratchRef: new GraphModelMigrationScratchRef({ + refName: readRequiredString(runtimeReplay, 'runtimeReplay.scratchRefName', 'scratchRefName'), + }), + scratchHead: readRequiredString(runtimeReplay, 'runtimeReplay.scratchHead', 'scratchHead'), + status: readRuntimeReplayStatus(runtimeReplay, 'runtimeReplay.status', 'status'), + witness: readRequiredString(runtimeReplay, 'runtimeReplay.witness', 'witness'), + fatalErrors: readFatalNotices(runtimeReplay), + }); +} + +function readFatalNotices(source: JsonObject): readonly GraphModelMigrationNotice[] { + return readObjectArray(source, 'fatalErrors').map((notice, index) => { + const label = `fatalErrors[${index}]`; + rejectUnknownKeys(notice, NOTICE_KEYS, label); + return new GraphModelMigrationNotice({ + kind: readNoticeKind(notice, `${label}.kind`, 'kind'), + code: readRequiredString(notice, `${label}.code`, 'code'), + message: readRequiredString(notice, `${label}.message`, 'message'), + }); + }); +} + +function parseJson(raw: string, label = 'finalization request'): unknown { + try { + return JSON.parse(raw); + } catch { + throw new AdapterValidationError(`Graph model migration ${label} JSON must be valid JSON`); + } +} + +function readRequiredObject(source: JsonObject, key: string): JsonObject { + return requireJsonObject(readRequiredValue(source, key), key); +} + +function readObjectArray(source: JsonObject, key: string): readonly JsonObject[] { + const value = readRequiredValue(source, key); + if (!Array.isArray(value)) { + throw new AdapterValidationError(`Graph model migration finalization request field "${key}" must be an array`); + } + const objects: JsonObject[] = []; + value.forEach((entry, index) => { + objects.push(requireJsonObject(entry, `${key}[${index}]`)); + }); + return Object.freeze(objects); +} + +function readRequiredString(source: JsonObject, label: string, key: string): string { + const value = readRequiredValue(source, key); + if (typeof value !== 'string' || value.length === 0) { + throw new AdapterValidationError( + `Graph model migration finalization request field "${label}" must be a non-empty string`, + ); + } + return value; +} + +function readRequiredSafeInteger(source: JsonObject, label: string, key: string): number { + const value = readRequiredValue(source, key); + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new AdapterValidationError( + `Graph model migration finalization request field "${label}" must be a non-negative safe integer`, + ); + } + return value; +} + +function readRuntimeReplayStatus( + source: JsonObject, + label: string, + key: string, +): GraphModelMigrationRuntimeConformanceStatus { + const value = readRequiredValue(source, key); + if (value === 'passed' || value === 'failed') { + return value; + } + throw new AdapterValidationError( + `Graph model migration finalization request field "${label}" must be passed or failed`, + ); +} + +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 finalization request field "${label}" must be warning or fatal`, + ); +} + +function readRequiredValue(source: JsonObject, key: string): unknown { + const value = source[key]; + if (value === undefined) { + throw new AdapterValidationError(`Graph model migration finalization request field "${key}" is required`); + } + return value; +} + +function requireJsonObject(value: unknown, label: string): JsonObject { + if (!isJsonObject(value)) { + throw new AdapterValidationError( + `Graph model migration finalization request field "${label}" must be an object`, + ); + } + return value; +} + +function isJsonObject(value: unknown): value is JsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function rejectUnknownKeys(source: JsonObject, allowed: readonly string[], label: string): void { + for (const key of Object.keys(source)) { + if (!allowed.includes(key)) { + throw new AdapterValidationError( + `Graph model migration finalization request field "${label}.${key}" is not allowed`, + ); + } + } +} diff --git a/test/fixtures/continuum/runtime-boundary-family-generated-artifact.json b/test/fixtures/continuum/runtime-boundary-family-generated-artifact.json new file mode 100644 index 000000000..8d13956ad --- /dev/null +++ b/test/fixtures/continuum/runtime-boundary-family-generated-artifact.json @@ -0,0 +1,77 @@ +{ + "objectTypes": [ + "ReadingEnvelope", + "WitnessedSuffix", + "AdmissionOutcome", + "RuntimeBoundaryEvidence" + ], + "enumTypes": [ + "AdmissionOutcomeKind", + "BoundaryEvidenceKind" + ], + "ops": [ + { + "name": "readingEnvelopes", + "resultType": "ReadingEnvelope" + }, + { + "name": "witnessedSuffixes", + "resultType": "WitnessedSuffix" + }, + { + "name": "admissionOutcomes", + "resultType": "AdmissionOutcome" + } + ], + "invariants": [ + "reading_envelope_names_basis", + "witnessed_suffix_names_source_and_tip", + "admission_outcome_names_import_decision" + ], + "footprints": [ + { + "opName": "readingEnvelopes", + "reads": [ + "ReadingEnvelope" + ], + "writes": [], + "creates": [], + "deletes": [] + }, + { + "opName": "witnessedSuffixes", + "reads": [ + "WitnessedSuffix" + ], + "writes": [], + "creates": [], + "deletes": [] + }, + { + "opName": "admissionOutcomes", + "reads": [ + "AdmissionOutcome" + ], + "writes": [], + "creates": [], + "deletes": [] + } + ], + "types": { + "ReadingEnvelope": [ + "graphId", + "basisId", + "factCount" + ], + "WitnessedSuffix": [ + "sourceRef", + "tip", + "patchCount" + ], + "AdmissionOutcome": [ + "kind", + "sourceRef", + "targetRef" + ] + } +} diff --git a/test/unit/domain/continuum/GitWarpGraphModelContractConformance.test.ts b/test/unit/domain/continuum/GitWarpGraphModelContractConformance.test.ts new file mode 100644 index 000000000..1a75f02d1 --- /dev/null +++ b/test/unit/domain/continuum/GitWarpGraphModelContractConformance.test.ts @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import ContinuumArtifactDescriptor from '../../../../src/domain/continuum/ContinuumArtifactDescriptor.ts'; +import GitWarpGraphModelContractConformance + from '../../../../src/domain/continuum/GitWarpGraphModelContractConformance.ts'; +import ContinuumArtifactJsonFileAdapter, { + type ContinuumArtifactJsonLoadContext, +} from '../../../../src/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; + +const runtimeBoundaryFixturePath = fileURLToPath( + new URL('../../../fixtures/continuum/runtime-boundary-family-generated-artifact.json', import.meta.url), +); + +const v17ManifestPath = fileURLToPath( + new URL('../../../../fixtures/v17/graph-model-golden/manifest.json', import.meta.url), +); + +const runtimeBoundaryFixtureContext: ContinuumArtifactJsonLoadContext = { + familyId: 'runtime-boundary-family', + authority: 'generated-fixture', + sourceSchemaPath: '~/git/continuum/schemas/continuum-runtime-boundary-family.graphql', + witnessScope: 'runtime-boundary-family', + artifactDigest: 'sha256:runtime-boundary-fixture', + targets: ['continuum-fixture', 'warp-ttd'], +}; + +describe('GitWarpGraphModelContractConformance', () => { + it('accepts runtime-boundary generated contracts for the v17 graph-model fixture', async () => { + const result = new GitWarpGraphModelContractConformance().evaluate( + await runtimeBoundaryDescriptor(), + parseV17GoldenGraphFixtureManifestJson(await readFile(v17ManifestPath, 'utf8')), + ); + + expect(result.passed()).toBe(true); + expect(result.failedChecks()).toEqual([]); + expect(result.evidenceLines()).toContain('contract-family=runtime-boundary-family'); + expect(result.evidenceLines()).toContain('graph-id=v17-golden-graph'); + expect(result.evidenceLines()).toContain('status=passed'); + }); + + it('rejects descriptors that do not carry the runtime-boundary generated contract shape', async () => { + const descriptor = new ContinuumArtifactDescriptor({ + familyId: 'receipt-family', + sourceSchemaPath: '~/git/continuum/schemas/continuum-receipt-family.graphql', + generatedBy: 'fixture', + artifactKind: 'continuum.family.fixture', + authority: 'generated-fixture', + targets: ['continuum-fixture'], + }); + + const result = new GitWarpGraphModelContractConformance().evaluate( + descriptor, + parseV17GoldenGraphFixtureManifestJson(await readFile(v17ManifestPath, 'utf8')), + ); + + expect(result.passed()).toBe(false); + expect(result.failedChecks().map((check) => check.name)).toEqual([ + 'runtime-boundary-family', + 'runtime-boundary-schema', + 'target:warp-ttd', + ]); + expect(result.evidenceLines()).toContain('status=failed'); + }); +}); + +async function runtimeBoundaryDescriptor(): Promise { + return await new ContinuumArtifactJsonFileAdapter().loadFile( + runtimeBoundaryFixturePath, + runtimeBoundaryFixtureContext, + ); +} diff --git a/test/unit/domain/continuum/GitWarpWarpTtdGeneratedFamilySmoke.test.ts b/test/unit/domain/continuum/GitWarpWarpTtdGeneratedFamilySmoke.test.ts new file mode 100644 index 000000000..1d457e5bb --- /dev/null +++ b/test/unit/domain/continuum/GitWarpWarpTtdGeneratedFamilySmoke.test.ts @@ -0,0 +1,86 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import ContinuumArtifactDescriptor from '../../../../src/domain/continuum/ContinuumArtifactDescriptor.ts'; +import GitWarpGraphModelContractConformance + from '../../../../src/domain/continuum/GitWarpGraphModelContractConformance.ts'; +import GitWarpWarpTtdGeneratedFamilySmoke + from '../../../../src/domain/continuum/GitWarpWarpTtdGeneratedFamilySmoke.ts'; +import ContinuumArtifactJsonFileAdapter, { + type ContinuumArtifactJsonLoadContext, +} from '../../../../src/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.ts'; +import { parseV17GoldenGraphFixtureManifestJson } + from '../../../../src/infrastructure/adapters/V17GoldenGraphFixtureManifestJsonAdapter.ts'; + +const runtimeBoundaryFixturePath = fileURLToPath( + new URL('../../../fixtures/continuum/runtime-boundary-family-generated-artifact.json', import.meta.url), +); + +const v17ManifestPath = fileURLToPath( + new URL('../../../../fixtures/v17/graph-model-golden/manifest.json', import.meta.url), +); + +const runtimeBoundaryFixtureContext: ContinuumArtifactJsonLoadContext = { + familyId: 'runtime-boundary-family', + authority: 'generated-fixture', + sourceSchemaPath: '~/git/continuum/schemas/continuum-runtime-boundary-family.graphql', + witnessScope: 'runtime-boundary-family', + artifactDigest: 'sha256:runtime-boundary-fixture', + targets: ['continuum-fixture', 'warp-ttd'], +}; + +describe('GitWarpWarpTtdGeneratedFamilySmoke', () => { + it('emits a present warp-ttd generated-family smoke fact for passed conformance', async () => { + const fact = new GitWarpWarpTtdGeneratedFamilySmoke().evaluate( + new GitWarpGraphModelContractConformance().evaluate( + await runtimeBoundaryDescriptor(), + parseV17GoldenGraphFixtureManifestJson(await readFile(v17ManifestPath, 'utf8')), + ), + ); + + expect(fact.passed()).toBe(true); + expect(fact.posture).toBe('PRESENT'); + expect(fact.sourceFamily).toBe('git-warp'); + expect(fact.artifact).toBe('runtime-boundary-family.graph-model-conformance'); + expect(fact.origin).toBe('TRANSLATED_SUBSTRATE'); + expect(fact.scope).toBe('SESSION'); + expect(fact.target).toBe('warp-ttd'); + expect(fact.reason).toBeUndefined(); + expect(fact.payloadLines).toContain('contract-family=runtime-boundary-family'); + expect(fact.payloadLines).toContain('status=passed'); + }); + + it('emits an obstructed fact when graph-model conformance fails', async () => { + const descriptor = new ContinuumArtifactDescriptor({ + familyId: 'receipt-family', + sourceSchemaPath: '~/git/continuum/schemas/continuum-receipt-family.graphql', + generatedBy: 'fixture', + artifactKind: 'continuum.family.fixture', + authority: 'generated-fixture', + targets: ['continuum-fixture'], + }); + + const fact = new GitWarpWarpTtdGeneratedFamilySmoke().evaluate( + new GitWarpGraphModelContractConformance().evaluate( + descriptor, + parseV17GoldenGraphFixtureManifestJson(await readFile(v17ManifestPath, 'utf8')), + ), + ); + + expect(fact.passed()).toBe(false); + expect(fact.posture).toBe('OBSTRUCTED'); + expect(fact.target).toBe('warp-ttd'); + expect(fact.reason).toBe( + 'generated-family conformance failed: runtime-boundary-family, runtime-boundary-schema, target:warp-ttd', + ); + expect(fact.payloadLines).toContain('status=failed'); + }); +}); + +async function runtimeBoundaryDescriptor(): Promise { + return await new ContinuumArtifactJsonFileAdapter().loadFile( + runtimeBoundaryFixturePath, + runtimeBoundaryFixtureContext, + ); +} diff --git a/test/unit/domain/migrations/GraphModelMigrationRuntimeReplayResult.test.ts b/test/unit/domain/migrations/GraphModelMigrationRuntimeReplayResult.test.ts new file mode 100644 index 000000000..40e012826 --- /dev/null +++ b/test/unit/domain/migrations/GraphModelMigrationRuntimeReplayResult.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import GraphModelMigrationNotice from '../../../../src/domain/migrations/GraphModelMigrationNotice.ts'; +import GraphModelMigrationRuntimeReplayRequest + from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import GraphModelMigrationRuntimeReplayResult, { + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, +} from '../../../../src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts'; +import GraphModelMigrationScratchRef + from '../../../../src/domain/migrations/GraphModelMigrationScratchRef.ts'; + +describe('GraphModelMigrationRuntimeReplayResult', () => { + it('models a passing production-runtime scratch replay result', () => { + const result = new GraphModelMigrationRuntimeReplayResult({ + request: runtimeReplayRequest(), + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, + witness: 'runtime-replay-v1 operations=1', + replayedOperationCount: 1, + fatalErrors: [], + }); + + expect(result.allowsFinalization()).toBe(true); + expect(result.request.graphId).toBe('v17-golden-graph'); + expect(result.replayedOperationCount).toBe(1); + }); + + it('models a failing production-runtime scratch replay result', () => { + const result = new GraphModelMigrationRuntimeReplayResult({ + request: runtimeReplayRequest(), + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, + witness: 'runtime-replay-v1', + replayedOperationCount: 0, + fatalErrors: [ + GraphModelMigrationNotice.fatal('E_RUNTIME_REPLAY_FAILED', 'runtime replay failed'), + ], + }); + + expect(result.allowsFinalization()).toBe(false); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual(['E_RUNTIME_REPLAY_FAILED']); + }); + + it('rejects malformed request and result envelopes', () => { + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationRuntimeReplayRequest(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationRuntimeReplayRequest({ + graphId: '', + writerId: 'scratch-migration', + scratchRef: scratchRef(), + scratchHead: '1111111111111111111111111111111111111111', + })).toThrow(/graphId/); + expect(() => new GraphModelMigrationRuntimeReplayRequest({ + graphId: 'v17-golden-graph', + writerId: 'scratch-migration', + // @ts-expect-error exercising runtime validation + scratchRef: 'refs/warp-migration-scratch/v17-golden-graph/migration', + scratchHead: '1111111111111111111111111111111111111111', + })).toThrow(/scratchRef/); + expect(() => { + // @ts-expect-error exercising runtime validation + new GraphModelMigrationRuntimeReplayResult(null); + }).toThrow(/fields/); + expect(() => new GraphModelMigrationRuntimeReplayResult({ + request: runtimeReplayRequest(), + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, + witness: 'runtime-replay-v1', + replayedOperationCount: 1, + fatalErrors: [GraphModelMigrationNotice.fatal('E_FATAL', 'fatal')], + })).toThrow(/passed runtime replay/); + expect(() => new GraphModelMigrationRuntimeReplayResult({ + request: runtimeReplayRequest(), + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, + witness: 'runtime-replay-v1', + replayedOperationCount: 0, + fatalErrors: [], + })).toThrow(/failed runtime replay/); + expect(() => new GraphModelMigrationRuntimeReplayResult({ + request: runtimeReplayRequest(), + status: GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, + witness: 'runtime-replay-v1', + replayedOperationCount: -1, + fatalErrors: [], + })).toThrow(/replayedOperationCount/); + }); +}); + +function runtimeReplayRequest(): GraphModelMigrationRuntimeReplayRequest { + return new GraphModelMigrationRuntimeReplayRequest({ + graphId: 'v17-golden-graph', + writerId: 'scratch-migration', + scratchRef: scratchRef(), + scratchHead: '1111111111111111111111111111111111111111', + }); +} + +function scratchRef(): GraphModelMigrationScratchRef { + return new GraphModelMigrationScratchRef({ + refName: 'refs/warp-migration-scratch/v17-golden-graph/migration', + }); +} diff --git a/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts index cf9653a17..4ee5e2cf0 100644 --- a/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts +++ b/test/unit/domain/migrations/V17GoldenGraphFixtureGenesisReading.test.ts @@ -32,7 +32,9 @@ describe('V17GoldenGraphFixtureGenesisReading', () => { 'content-attachment\0node:alpha:_content\0payload.oid', 'edge\0node:alpha->node:beta:relates\0visibility', 'node\0node:alpha\0visibility', + 'node\0node:beta\0visibility', 'node\0node:removed\0visibility', + 'property\0node:alpha->node:beta:relates:weight\0value', 'property\0node:alpha:title\0value', 'property\0writers:alice+bob\0coverage', ]); @@ -40,10 +42,16 @@ describe('V17GoldenGraphFixtureGenesisReading', () => { 'alice', 'bob', 'bob', + 'alice', + 'bob', 'bob', 'alice', 'alice', ]); + expect(reading.facts.find((fact) => fact.factKey === 'node:alpha->node:beta:relates:weight')?.value) + .toBe('migration-source:node:alpha->node:beta:relates\0weight'); + expect(reading.facts.find((fact) => fact.factKey === 'node:alpha:title')?.value) + .toBe('migration-source:node:alpha\0title'); }); it('rejects malformed genesis reading inputs through domain errors', () => { @@ -55,6 +63,8 @@ describe('V17GoldenGraphFixtureGenesisReading', () => { }).toThrow(/manifest/); expect(() => builder.build(manifestWithBaseVisibleFacts())) .toThrow(/unsupported v17 fixture visible fact kind/); + expect(() => builder.build(manifestWithBadPropertyKey())) + .toThrow(/at least one colon not at the boundaries/); expect(() => builder.build(manifestWithoutWriterChains())) .toThrow(/writer chain evidence/); }); @@ -84,6 +94,25 @@ function manifestWithoutWriterChains(): V17GoldenGraphFixtureManifest { }); } +function manifestWithBadPropertyKey(): V17GoldenGraphFixtureManifest { + return new V17GoldenGraphFixtureManifest({ + fixtureId: 'fixture:bad-property', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit-test', + bundlePath: 'v17-golden-graph.bundle', + writerChains: [writerChain()], + visibleFacts: Object.freeze([ + new V17GoldenNodeFact({ key: 'node:alpha', description: 'node' }), + new V17GoldenEdgeFact({ key: 'edge:alpha-beta', description: 'edge' }), + new V17GoldenPropertyFact({ key: 'title', description: 'title' }), + new V17GoldenContentFact({ key: 'node:alpha:_content', description: 'content' }), + new V17GoldenRemovalFact({ key: 'node:removed', description: 'removed' }), + new V17GoldenMultiWriterFact({ key: 'writers:alice+bob', description: 'multi' }), + ]), + }); +} + function writerChain(): V17GoldenGraphFixtureWriterChain { return new V17GoldenGraphFixtureWriterChain({ writerId: 'alice', diff --git a/test/unit/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.test.ts b/test/unit/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.test.ts index ffd4ef77e..042b6753b 100644 --- a/test/unit/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.test.ts +++ b/test/unit/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.test.ts @@ -14,6 +14,10 @@ const wesleyManifestPath = fileURLToPath( new URL('../../../fixtures/continuum/receipt-family-wesley-realization-manifest.json', import.meta.url), ); +const runtimeBoundaryFixturePath = fileURLToPath( + new URL('../../../fixtures/continuum/runtime-boundary-family-generated-artifact.json', import.meta.url), +); + const fixtureContext: ContinuumArtifactJsonLoadContext = { familyId: 'receipt-family', authority: 'generated-fixture', @@ -40,6 +44,15 @@ const fixtureAsArtifactContext: ContinuumArtifactJsonLoadContext = { sourceSchemaPath: '~/git/continuum/schemas/continuum-receipt-family.graphql', }; +const runtimeBoundaryFixtureContext: ContinuumArtifactJsonLoadContext = { + familyId: 'runtime-boundary-family', + authority: 'generated-fixture', + sourceSchemaPath: '~/git/continuum/schemas/continuum-runtime-boundary-family.graphql', + witnessScope: 'runtime-boundary-family', + artifactDigest: 'sha256:runtime-boundary-fixture', + targets: ['continuum-fixture', 'warp-ttd'], +}; + const artifactAsFixtureContext: ContinuumArtifactJsonLoadContext = { familyId: 'receipt-family', authority: 'generated-fixture', @@ -335,6 +348,17 @@ describe('ContinuumArtifactJsonFileAdapter', () => { expect(descriptor.witnessScope).toBe('receipt-family'); }); + it('loads runtime-boundary-generated fixture descriptors for graph-model evidence', async () => { + const adapter = new ContinuumArtifactJsonFileAdapter(); + const descriptor = await adapter.loadFile(runtimeBoundaryFixturePath, runtimeBoundaryFixtureContext); + + expect(descriptor.familyId.toString()).toBe('runtime-boundary-family'); + expect(descriptor.sourceSchemaPath).toBe('~/git/continuum/schemas/continuum-runtime-boundary-family.graphql'); + expect(descriptor.hasTarget('continuum-fixture')).toBe(true); + expect(descriptor.hasTarget('warp-ttd')).toBe(true); + expect(descriptor.witnessScope).toBe('runtime-boundary-family'); + }); + it('loads Wesley realization manifest descriptors without local descriptor fields', async () => { const adapter = new ContinuumArtifactJsonFileAdapter(); const descriptor = await adapter.loadFile(wesleyManifestPath, artifactContext); diff --git a/test/unit/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.test.ts b/test/unit/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.test.ts new file mode 100644 index 000000000..b6c09a835 --- /dev/null +++ b/test/unit/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import GraphModelMigrationFinalizationConfirmation, { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import GraphModelMigrationFinalizationRequest + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationRequest.ts'; +import GraphModelMigrationFinalizationSafety + from '../../../../src/domain/migrations/GraphModelMigrationFinalizationSafety.ts'; +import AdapterValidationError from '../../../../src/domain/errors/AdapterValidationError.ts'; +import { + parseGraphModelMigrationFinalizationConfirmation, + parseGraphModelMigrationFinalizationRequest, +} from '../../../../src/infrastructure/adapters/GraphModelMigrationFinalizationRequestJsonAdapter.ts'; + +type FixtureJsonValue = + | string + | number + | boolean + | null + | readonly FixtureJsonValue[] + | { readonly [key: string]: FixtureJsonValue }; + +type RequestOverrides = { + readonly liveRefName?: FixtureJsonValue; + readonly expectedLiveHead?: FixtureJsonValue; + readonly observedLiveHead?: FixtureJsonValue; + readonly scratchRefName?: FixtureJsonValue; + readonly scratchHead?: FixtureJsonValue; + readonly archiveRefName?: FixtureJsonValue; + readonly confirmationToken?: FixtureJsonValue; + readonly equivalence?: FixtureJsonValue; + readonly runtimeReplay?: FixtureJsonValue; + readonly extraRoot?: boolean; +}; + +type EquivalenceOverrides = { + readonly mismatchCount?: FixtureJsonValue; + readonly legacyBasis?: FixtureJsonValue; + readonly extraEquivalence?: boolean; +}; + +type RuntimeReplayOverrides = { + readonly status?: FixtureJsonValue; + readonly witness?: FixtureJsonValue; + readonly fatalErrors?: FixtureJsonValue; + readonly extraRuntimeReplay?: boolean; +}; + +describe('GraphModelMigrationFinalizationRequestJsonAdapter', () => { + it('parses confirmation JSON into a runtime-backed confirmation noun', () => { + const confirmation = parseGraphModelMigrationFinalizationConfirmation(JSON.stringify({ + confirmationToken: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + })); + + expect(confirmation).toBeInstanceOf(GraphModelMigrationFinalizationConfirmation); + expect(confirmation.token).toBe(V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION); + }); + + it('parses a complete finalization request into safety-gated nouns', () => { + const request = parseGraphModelMigrationFinalizationRequest(requestJson()); + const safety = new GraphModelMigrationFinalizationSafety().evaluate(request); + + expect(request).toBeInstanceOf(GraphModelMigrationFinalizationRequest); + expect(request.liveRefName).toBe('refs/warp/v17-golden-graph/live'); + expect(request.scratchRef?.refName).toBe('refs/warp-migration-scratch/v17-golden-graph/wet-run'); + expect(request.confirmation?.token).toBe(V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION); + expect(request.gateResult?.allowsPromotion()).toBe(true); + expect(request.runtimeConformance?.allowsFinalization()).toBe(true); + expect(safety.fatalErrors).toEqual([]); + }); + + it('rejects malformed request envelopes at the JSON boundary', () => { + const cases = Object.freeze([ + { + raw: '{', + message: /valid JSON/, + }, + { + raw: requestJson({ extraRoot: true }), + message: /finalizationRequest\.extra/, + }, + { + raw: requestJson({ liveRefName: '' }), + message: /liveRefName.*non-empty string/, + }, + { + raw: requestJson({ equivalence: [] }), + message: /equivalence.*object/, + }, + { + raw: requestJson({ runtimeReplay: runtimeReplayJson({ status: 'maybe' }) }), + message: /runtimeReplay\.status.*passed or failed/, + }, + ]); + + for (const candidate of cases) { + expect(() => parseGraphModelMigrationFinalizationRequest(candidate.raw)) + .toThrow(candidate.message); + } + }); + + it('rejects finalization requests that do not prove zero mismatches', () => { + expect(() => parseGraphModelMigrationFinalizationRequest(requestJson({ + equivalence: equivalenceJson({ mismatchCount: 1 }), + }))).toThrow(AdapterValidationError); + expect(() => parseGraphModelMigrationFinalizationRequest(requestJson({ + equivalence: equivalenceJson({ mismatchCount: 1 }), + }))).toThrow(/zero mismatches/); + }); + + it('wraps semantic runtime replay contradictions as adapter validation errors', () => { + const failedWithoutFatalErrors = requestJson({ + runtimeReplay: runtimeReplayJson({ status: 'failed' }), + }); + const passedWithFatalErrors = requestJson({ + runtimeReplay: runtimeReplayJson({ + fatalErrors: [{ + kind: 'fatal', + code: 'E_RUNTIME_REPLAY_FAILED', + message: 'runtime replay failed', + }], + }), + }); + + expect(() => parseGraphModelMigrationFinalizationRequest(failedWithoutFatalErrors)) + .toThrow(AdapterValidationError); + expect(() => parseGraphModelMigrationFinalizationRequest(failedWithoutFatalErrors)) + .toThrow(/failed runtime conformance must contain fatal errors/); + expect(() => parseGraphModelMigrationFinalizationRequest(passedWithFatalErrors)) + .toThrow(AdapterValidationError); + expect(() => parseGraphModelMigrationFinalizationRequest(passedWithFatalErrors)) + .toThrow(/passed runtime conformance must not contain fatal errors/); + }); + + it('rejects malformed confirmation JSON', () => { + expect(() => parseGraphModelMigrationFinalizationConfirmation('{')) + .toThrow(/finalization confirmation JSON/); + expect(() => parseGraphModelMigrationFinalizationConfirmation(JSON.stringify({ + confirmationToken: 'YES', + }))).toThrow(/confirmation token/); + }); +}); + +function requestJson(overrides: RequestOverrides = {}): string { + return JSON.stringify({ + liveRefName: overrides.liveRefName ?? 'refs/warp/v17-golden-graph/live', + expectedLiveHead: overrides.expectedLiveHead ?? 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + observedLiveHead: overrides.observedLiveHead ?? 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + scratchRefName: overrides.scratchRefName ?? 'refs/warp-migration-scratch/v17-golden-graph/wet-run', + scratchHead: overrides.scratchHead ?? 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + archiveRefName: overrides.archiveRefName ?? 'refs/warp-migration-archive/v17-golden-graph/pre-v18', + confirmationToken: overrides.confirmationToken ?? V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + equivalence: overrides.equivalence ?? equivalenceJson(), + runtimeReplay: overrides.runtimeReplay ?? runtimeReplayJson(), + ...(overrides.extraRoot === true ? { extra: true } : {}), + }); +} + +function equivalenceJson(overrides: EquivalenceOverrides = {}) { + return { + legacyBasis: overrides.legacyBasis ?? basisJson('source:v17'), + migratedBasis: basisJson('source:v17:v18-dry-run'), + legacyFactCount: 7, + migratedFactCount: 7, + mismatchCount: overrides.mismatchCount ?? 0, + ...(overrides.extraEquivalence === true ? { extra: true } : {}), + }; +} + +function basisJson(basisId: string) { + return { + graphId: 'v17-golden-graph', + basisId, + }; +} + +function runtimeReplayJson(overrides: RuntimeReplayOverrides = {}) { + return { + scratchRefName: 'refs/warp-migration-scratch/v17-golden-graph/wet-run', + scratchHead: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + status: overrides.status ?? 'passed', + witness: overrides.witness ?? 'git-warp-v18-production-runtime-scratch-replay-v1 operations=5', + fatalErrors: overrides.fatalErrors ?? [], + ...(overrides.extraRuntimeReplay === true ? { extra: true } : {}), + }; +} diff --git a/test/unit/scripts/migrationTestEnvironment.ts b/test/unit/scripts/migrationTestEnvironment.ts new file mode 100644 index 000000000..b648b3173 --- /dev/null +++ b/test/unit/scripts/migrationTestEnvironment.ts @@ -0,0 +1,35 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect } from 'vitest'; + +import { runMigrationGit } + from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; + +export class MigrationTestDirectories { + readonly #directories: string[] = []; + + async create(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + this.#directories.push(directory); + return directory; + } + + async cleanup(): Promise { + let directory = this.#directories.pop(); + while (directory !== undefined) { + await rm(directory, { recursive: true, force: true }); + directory = this.#directories.pop(); + } + } +} + +export async function gitOk( + repositoryPath: string, + args: readonly string[], + input: string | null = null, +): Promise { + const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); + expect(result.ok()).toBe(true); + return result.stdout.trim(); +} diff --git a/test/unit/scripts/v18-content-property-closeout-audit.test.ts b/test/unit/scripts/v18-content-property-closeout-audit.test.ts index 3c5ddc97d..014a1f57c 100644 --- a/test/unit/scripts/v18-content-property-closeout-audit.test.ts +++ b/test/unit/scripts/v18-content-property-closeout-audit.test.ts @@ -7,7 +7,6 @@ const DESIGN_DOC = 'docs/design/0203-v18-content-property-closeout-audit/v18-con const EXPECTED_RAW_COMPATIBILITY_FILES = Object.freeze([ 'src/domain/graph/LegacyContentPropertyKeys.ts', 'src/domain/services/ContentAttachmentProjection.ts', - 'src/domain/services/CoordinateFactExport.ts', 'src/domain/services/ImmutableSnapshot.ts', 'src/domain/services/JoinReducer.ts', 'src/domain/services/KeyCodec.ts', @@ -33,6 +32,10 @@ const EXPECTED_RAW_COMPATIBILITY_FILES = Object.freeze([ 'src/domain/types/ops/propHelpers.ts', ]); +const RETIRED_RAW_COMPATIBILITY_FILES = Object.freeze([ + 'src/domain/services/CoordinateFactExport.ts', +]); + describe('v18 content/property closeout audit', () => { it('keeps raw compatibility boundaries explicit and reviewed', async () => { const matches = await findRawCompatibilityFiles('src/domain'); @@ -47,6 +50,15 @@ describe('v18 content/property closeout audit', () => { expect(doc).toContain(file); } }); + + it('keeps retired raw compatibility boundaries retired', async () => { + const doc = await readFile(DESIGN_DOC, 'utf8'); + + for (const file of RETIRED_RAW_COMPATIBILITY_FILES) { + expect(RAW_COMPATIBILITY_PATTERN.test(await readFile(file, 'utf8'))).toBe(false); + expect(doc).toContain(`- \`${file}\` retired`); + } + }); }); async function findRawCompatibilityFiles(root: string): Promise { diff --git a/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts b/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts index e532407e9..87fa2b7a2 100644 --- a/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts +++ b/test/unit/scripts/v18-graph-model-migration-command-cli.test.ts @@ -1,18 +1,30 @@ -import { execFile } from 'node:child_process'; import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; import { parseGraphModelMigrationCommandCliArgs, runGraphModelMigrationCommandCli, } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationCommandCli.ts'; +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; +import { runMigrationGit } + from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; +import { + V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, +} from '../../../src/domain/migrations/GraphModelMigrationFinalizationConfirmation.ts'; +import { gitOk } from './migrationTestEnvironment.ts'; -const execFileAsync = promisify(execFile); const FIXTURE_MANIFEST = 'fixtures/v17/graph-model-golden/manifest.json'; const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/cli'; +const LIVE_REF = 'refs/warp/v17-golden-graph/writers/alice'; +const ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/cli/alice'; +const ALICE_HEAD = '417fe95095a6feae3042c36505065bbd7b3d2a67'; +const BOB_HEAD = 'd7c3a05b3894d5c3c151e03dd972b6bd6c341b0c'; +const REVIEWED_LIVE_REF = 'refs/warp/v17-golden-graph/live'; +const REVIEWED_ARCHIVE_REF = 'refs/warp-migration-archive/v17-golden-graph/cli/live'; describe('v18 graph-model migration command CLI', () => { it('prints usage when help is requested', async () => { @@ -24,22 +36,26 @@ describe('v18 graph-model migration command CLI', () => { expect(result.stderr).toBe(''); }); - it('refuses finalization flags until live-ref CLI finalization is designed', () => { + it('refuses legacy finalization flags in favor of request artifacts', () => { + expect(() => parseGraphModelMigrationCommandCliArgs(['--finalize'])) + .toThrow(/direct finalization flags are not supported/); expect(() => parseGraphModelMigrationCommandCliArgs(['--finalize'])) - .toThrow(/finalization is not supported/); + .toThrow(/--finalization-request /); }); it('writes scratch history and emits a deterministic command report', async () => { const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-')); - const repositoryPath = join(directory, 'repo'); const requestPath = join(directory, 'request.json'); const reportPath = join(directory, 'report.txt'); - await execFileAsync('git', ['init', '-q', repositoryPath]); - await writeFile(requestPath, completeRequestJson(), 'utf8'); + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST, + targetDirectory: join(directory, 'repo'), + }); + await writeFile(requestPath, canonicalRequestJson(), 'utf8'); const result = await runGraphModelMigrationCommandCli([ '--repo', - repositoryPath, + restoreResult.repositoryPath, '--request', requestPath, '--legacy-fixture-manifest', @@ -51,16 +67,158 @@ describe('v18 graph-model migration command CLI', () => { ]); const report = await readFile(reportPath, 'utf8'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(0); expect(result.stdout).toBe(report); expect(report).toContain('scratch: written'); expect(report).toContain(`scratchRef: ${SCRATCH_REF}`); - expect(report).toContain('equivalence: blocked'); + expect(report).toContain('equivalence: passed'); expect(report).toContain('finalization: skipped'); }); + + it('finalizes live refs only when the reviewed request matches command evidence', async () => { + const scratchHead = await previewScratchHead(); + + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-finalize-')); + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST, + targetDirectory: join(directory, 'repo'), + }); + const requestPath = join(directory, 'request.json'); + const finalizationPath = join(directory, 'finalization.json'); + await writeFile(requestPath, canonicalRequestJson(), 'utf8'); + await writeFile(finalizationPath, finalizationRequestJson(scratchHead), 'utf8'); + + const result = await runGraphModelMigrationCommandCli([ + '--repo', + restoreResult.repositoryPath, + '--request', + requestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + '--finalization-request', + finalizationPath, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('finalization: completed'); + expect(result.stdout).toContain('archivePreserved: yes'); + expect(await gitOk(restoreResult.repositoryPath, ['rev-parse', ARCHIVE_REF])).toBe(ALICE_HEAD); + expect(await gitOk(restoreResult.repositoryPath, ['rev-parse', LIVE_REF])).toBe(scratchHead); + }); + + it('blocks finalization when the reviewed live ref head drifts', async () => { + const scratchHead = await previewScratchHead(); + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-drift-')); + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST, + targetDirectory: join(directory, 'repo'), + }); + await gitOk(restoreResult.repositoryPath, ['update-ref', REVIEWED_LIVE_REF, ALICE_HEAD]); + await gitOk(restoreResult.repositoryPath, ['update-ref', REVIEWED_LIVE_REF, BOB_HEAD, ALICE_HEAD]); + const requestPath = join(directory, 'request.json'); + const finalizationPath = join(directory, 'finalization.json'); + await writeFile(requestPath, canonicalRequestJson(), 'utf8'); + await writeFile(finalizationPath, finalizationRequestJson(scratchHead, { + liveRefName: REVIEWED_LIVE_REF, + archiveRefName: REVIEWED_ARCHIVE_REF, + }), 'utf8'); + + const result = await runGraphModelMigrationCommandCli([ + '--repo', + restoreResult.repositoryPath, + '--request', + requestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + '--finalization-request', + finalizationPath, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('finalization: blocked'); + expect(result.stdout).toContain('E_STALE_LIVE_REF_EXPECTATION'); + expect(await refExists(restoreResult.repositoryPath, REVIEWED_ARCHIVE_REF)).toBe(false); + expect(await gitOk(restoreResult.repositoryPath, ['rev-parse', REVIEWED_LIVE_REF])).toBe(BOB_HEAD); + }); + + it('blocks finalization when the archive ref already exists', async () => { + const scratchHead = await previewScratchHead(); + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-archive-')); + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST, + targetDirectory: join(directory, 'repo'), + }); + await gitOk(restoreResult.repositoryPath, ['update-ref', REVIEWED_LIVE_REF, ALICE_HEAD]); + await gitOk(restoreResult.repositoryPath, ['update-ref', REVIEWED_ARCHIVE_REF, ALICE_HEAD]); + const requestPath = join(directory, 'request.json'); + const finalizationPath = join(directory, 'finalization.json'); + await writeFile(requestPath, canonicalRequestJson(), 'utf8'); + await writeFile(finalizationPath, finalizationRequestJson(scratchHead, { + liveRefName: REVIEWED_LIVE_REF, + archiveRefName: REVIEWED_ARCHIVE_REF, + }), 'utf8'); + + const result = await runGraphModelMigrationCommandCli([ + '--repo', + restoreResult.repositoryPath, + '--request', + requestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + '--finalization-request', + finalizationPath, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('finalization: blocked'); + expect(result.stdout).toContain('E_ARCHIVE_REF_EXISTS'); + expect(await gitOk(restoreResult.repositoryPath, ['rev-parse', REVIEWED_LIVE_REF])).toBe(ALICE_HEAD); + }); + + it('blocks finalization when the reviewed runtime witness differs from observed replay', async () => { + const scratchHead = await previewScratchHead(); + const directory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-witness-')); + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST, + targetDirectory: join(directory, 'repo'), + }); + const requestPath = join(directory, 'request.json'); + const finalizationPath = join(directory, 'finalization.json'); + await writeFile(requestPath, canonicalRequestJson(), 'utf8'); + await writeFile(finalizationPath, finalizationRequestJson(scratchHead, { + runtimeWitness: 'tampered-runtime-witness', + }), 'utf8'); + + const result = await runGraphModelMigrationCommandCli([ + '--repo', + restoreResult.repositoryPath, + '--request', + requestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + '--finalization-request', + finalizationPath, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('finalization: blocked'); + expect(result.stdout).toContain('E_FINALIZATION_REVIEW_MISMATCH'); + expect(result.stdout).toContain('runtimeConformance'); + expect(await refExists(restoreResult.repositoryPath, ARCHIVE_REF)).toBe(false); + expect(await gitOk(restoreResult.repositoryPath, ['rev-parse', LIVE_REF])).toBe(ALICE_HEAD); + }); + }); -function completeRequestJson(): string { +function canonicalRequestJson(): string { return `{ "inventory": { "graphId": "v17-golden-graph", @@ -80,7 +238,8 @@ function completeRequestJson(): string { }, "requiredContentKeys": ["node:alpha:_content"], "nodeMappings": [ - { "legacyNodeId": "node:alpha", "targetNodeId": "node:alpha" } + { "legacyNodeId": "node:alpha", "targetNodeId": "node:alpha" }, + { "legacyNodeId": "node:beta", "targetNodeId": "node:beta" } ], "edgeMappings": [ { @@ -94,8 +253,95 @@ function completeRequestJson(): string { "legacyPropertyKey": "title", "targetOwnerId": "node:alpha", "targetPropertyKey": "title" + }, + { + "legacyOwnerId": "node:alpha->node:beta:relates", + "legacyPropertyKey": "weight", + "targetOwnerId": "\\u0001node:alpha\\u0000node:beta\\u0000relates", + "targetPropertyKey": "weight" } ] } `; } + +type FinalizationRequestOptions = { + readonly liveRefName?: string; + readonly archiveRefName?: string; + readonly runtimeWitness?: string; +}; + +function finalizationRequestJson( + scratchHead: string, + options: FinalizationRequestOptions = {}, +): string { + return JSON.stringify({ + liveRefName: options.liveRefName ?? LIVE_REF, + expectedLiveHead: ALICE_HEAD, + observedLiveHead: ALICE_HEAD, + scratchRefName: SCRATCH_REF, + scratchHead, + archiveRefName: options.archiveRefName ?? ARCHIVE_REF, + confirmationToken: V18_GRAPH_MODEL_FINALIZATION_CONFIRMATION, + equivalence: { + legacyBasis: { + graphId: 'v17-golden-graph', + basisId: 'basis:source', + }, + migratedBasis: { + graphId: 'v17-golden-graph', + basisId: 'basis:source:v18-dry-run', + }, + legacyFactCount: 8, + migratedFactCount: 8, + mismatchCount: 0, + }, + runtimeReplay: { + scratchRefName: SCRATCH_REF, + scratchHead, + status: 'passed', + witness: options.runtimeWitness + ?? 'git-warp-v18-production-runtime-scratch-replay-v1 operations=6', + fatalErrors: [], + }, + }); +} + +async function previewScratchHead(): Promise { + const previewDirectory = await mkdtemp(join(tmpdir(), 'git-warp-v18-command-cli-preview-')); + const preview = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST, + targetDirectory: join(previewDirectory, 'repo'), + }); + const previewRequestPath = join(previewDirectory, 'request.json'); + await writeFile(previewRequestPath, canonicalRequestJson(), 'utf8'); + const previewResult = await runGraphModelMigrationCommandCli([ + '--repo', + preview.repositoryPath, + '--request', + previewRequestPath, + '--legacy-fixture-manifest', + FIXTURE_MANIFEST, + '--scratch-ref', + SCRATCH_REF, + ]); + expect(previewResult.exitCode).toBe(0); + return reportValue(previewResult.stdout, 'scratchHead'); +} + +function reportValue(report: string, label: string): string { + const line = report.split('\n').find((candidate) => candidate.startsWith(`${label}: `)); + if (line === undefined) { + throw new Error(`report line ${label} is missing`); + } + return line.slice(`${label}: `.length); +} + +async function refExists(repositoryPath: string, refName: string): Promise { + const result = await runMigrationGit( + repositoryPath, + ['show-ref', '--verify', '--hash', refName], + null, + ); + return result.ok(); +} diff --git a/test/unit/scripts/v18-migration-command.test.ts b/test/unit/scripts/v18-migration-command.test.ts index 3fd2f8b3f..c96d8e038 100644 --- a/test/unit/scripts/v18-migration-command.test.ts +++ b/test/unit/scripts/v18-migration-command.test.ts @@ -115,7 +115,9 @@ describe('v18 graph-model migration command', () => { `liveRef: ${LIVE_REF}`, `archiveRef: ${ARCHIVE_REF}`, `previousLiveHead: ${repository.liveHead}`, + `archiveHead: ${repository.liveHead}`, `finalizedLiveHead: ${result.scratchWriteResult?.scratchHead}`, + 'archivePreserved: yes', ].join('\n')); }); @@ -156,6 +158,12 @@ describe('v18 graph-model migration command', () => { ].join('\n')); expect(report).toContain([ 'finalization: blocked', + `liveRef: ${LIVE_REF}`, + `archiveRef: ${ARCHIVE_REF}`, + 'previousLiveHead: (none)', + 'archiveHead: (none)', + 'finalizedLiveHead: (none)', + 'archivePreserved: no', 'fatalErrors:', '- E_EQUIVALENCE_GATE_NOT_PASSED: migration finalization requires a passed scratch equivalence gate', ].join('\n')); diff --git a/test/unit/scripts/v18-production-runtime-scratch-replay-provider.test.ts b/test/unit/scripts/v18-production-runtime-scratch-replay-provider.test.ts new file mode 100644 index 000000000..6beb962cb --- /dev/null +++ b/test/unit/scripts/v18-production-runtime-scratch-replay-provider.test.ts @@ -0,0 +1,270 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + createGraphModelMigrationProductionRuntimeConformanceProvider, + verifyGraphModelMigrationProductionRuntimeReplay, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationProductionRuntimeReplayProvider.ts'; +import { writeGraphModelMigrationScratchHistory } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationRuntimeReplayRequest + from '../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import { + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, + GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import { gitOk } from './migrationTestEnvironment.ts'; + +const execFileAsync = promisify(execFile); +const GRAPH_ID = 'v17-golden-graph'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/runtime-replay'; + +describe('v18 production runtime scratch replay provider', () => { + it('passes when scratch operations replay through normal graph runtime', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-pass-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + operation('node-record', 'node:alpha', 'node:alpha'), + operation('node-record', 'node:beta', 'node:beta'), + operation('edge-record', 'edge:alpha-beta', 'node:alpha->node:beta:relates'), + operation('property', 'node:alpha:title', propertyTarget('node:alpha', 'title')), + operation( + 'property', + 'node:alpha->node:beta:relates\0weight', + propertyTarget(edgePropertyOwner('node:alpha', 'node:beta', 'relates'), 'weight'), + ), + ]), + }); + + const result = await verifyGraphModelMigrationProductionRuntimeReplay({ + sourceRepositoryPath: repositoryPath, + request: replayRequest(writeResult), + }); + + expect(result.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED); + expect(result.allowsFinalization()).toBe(true); + expect(result.replayedOperationCount).toBe(5); + expect(result.witness).toBe('git-warp-v18-production-runtime-scratch-replay-v1 operations=5'); + }); + + it('maps production-runtime replay into finalization conformance evidence', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-conformance-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:alpha', 'node:alpha')]), + }); + const provider = createGraphModelMigrationProductionRuntimeConformanceProvider({ + sourceRepositoryPath: repositoryPath, + graphId: GRAPH_ID, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED); + expect(result?.allowsFinalization()).toBe(true); + expect(result?.witness).toBe('git-warp-v18-production-runtime-scratch-replay-v1 operations=1'); + }); + + it('fails closed when the scratch ref head has drifted', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-drift-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:alpha', 'node:alpha')]), + }); + const replacementHead = await writeBadScratchCommit(repositoryPath); + await gitOk(repositoryPath, ['update-ref', SCRATCH_REF, replacementHead], null); + + const result = await verifyGraphModelMigrationProductionRuntimeReplay({ + sourceRepositoryPath: repositoryPath, + request: replayRequest(writeResult), + }); + + expect(result.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_REPLAY_SCRATCH_HEAD_CHANGED', + ]); + }); + + it('fails closed when a scratch operation target cannot be applied', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-bad-target-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('edge-record', 'edge:bad', 'not-an-edge-target')]), + }); + const provider = createGraphModelMigrationProductionRuntimeConformanceProvider({ + sourceRepositoryPath: repositoryPath, + graphId: GRAPH_ID, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_REPLAY_INVALID_OPERATION_TARGET', + ]); + }); + + it('fails closed with invalid-target evidence for malformed edge-property owners', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-bad-edge-prop-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + operation('property', 'edge-prop:bad', propertyTarget('\x01node:alpha', 'weight')), + ]), + }); + const provider = createGraphModelMigrationProductionRuntimeConformanceProvider({ + sourceRepositoryPath: repositoryPath, + graphId: GRAPH_ID, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_REPLAY_INVALID_OPERATION_TARGET', + ]); + }); + + it('rejects an empty runtime repository path before runtime initialization', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-empty-runtime-'); + const workingDirectory = await mkdtemp(join(tmpdir(), 'git-warp-v18-runtime-replay-cwd-')); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:alpha', 'node:alpha')]), + }); + const originalWorkingDirectory = process.cwd(); + process.chdir(workingDirectory); + try { + const result = await verifyGraphModelMigrationProductionRuntimeReplay({ + sourceRepositoryPath: repositoryPath, + runtimeRepositoryPath: '', + request: replayRequest(writeResult), + }); + + expect(result.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_FAILED); + expect(result.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_REPLAY_INVALID_OPERATION_TARGET', + ]); + } finally { + process.chdir(originalWorkingDirectory); + } + }); + + it('fails closed with invalid-target evidence for empty scratch target fields', async () => { + const cases = Object.freeze([ + operation('property', 'property:empty-owner', propertyTarget('', 'title')), + operation('property', 'property:empty-key', propertyTarget('node:alpha', '')), + operation('content-attachment', 'content:empty-node', 'content-attachment::_content'), + ]); + + for (const candidate of cases) { + const repositoryPath = await initializedRepository('git-warp-v18-runtime-replay-empty-target-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([candidate]), + }); + const provider = createGraphModelMigrationProductionRuntimeConformanceProvider({ + sourceRepositoryPath: repositoryPath, + graphId: GRAPH_ID, + }); + + const result = await provider(writeResult); + + expect(result?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED); + expect(result?.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_RUNTIME_REPLAY_INVALID_OPERATION_TARGET', + ]); + } + }); +}); + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function replayRequest( + writeResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationRuntimeReplayRequest { + if (writeResult.scratchRef === null || writeResult.scratchHead === null) { + throw new Error('scratch write result must contain output'); + } + return new GraphModelMigrationRuntimeReplayRequest({ + graphId: GRAPH_ID, + writerId: 'scratch-migration', + scratchRef: writeResult.scratchRef, + scratchHead: writeResult.scratchHead, + }); +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: GRAPH_ID, + basisId: 'basis:source', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: GRAPH_ID, + basisId: 'basis:scratch', + }), + operations, + }); +} + +function operation( + kind: 'node-record' | 'edge-record' | 'property' | 'content-attachment', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); +} + +function propertyTarget(ownerId: string, propertyKey: string): string { + return [ + 'property-target-key:length-prefixed-v1', + ownerId.length, + ownerId, + propertyKey.length, + propertyKey, + ].join(':'); +} + +function edgePropertyOwner(from: string, to: string, label: string): string { + return `\x01${from}\0${to}\0${label}`; +} + +async function writeBadScratchCommit(repositoryPath: string): Promise { + const blobOid = await gitOk(repositoryPath, ['hash-object', '-w', '--stdin'], 'not a scratch payload\n'); + const treeOid = await gitOk( + repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\tmigration-operation.txt\n`, + ); + return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); +} diff --git a/test/unit/scripts/v18-scratch-public-read-builder.test.ts b/test/unit/scripts/v18-scratch-public-read-builder.test.ts new file mode 100644 index 000000000..83778ce99 --- /dev/null +++ b/test/unit/scripts/v18-scratch-public-read-builder.test.ts @@ -0,0 +1,187 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; + +import { + buildGraphModelMigrationScratchPublicReadReading, + createGraphModelMigrationScratchPublicReadProvider, +} from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchPublicReadBuilder.ts'; +import { writeGraphModelMigrationScratchHistory } + from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; +import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; +import GraphModelMigrationLoweredOperation + from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; +import GraphModelMigrationLoweredPatchPlan + from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import GraphModelMigrationRuntimeReplayRequest + from '../../../src/domain/migrations/GraphModelMigrationRuntimeReplayRequest.ts'; +import GraphModelMigrationScratchWriteResult + from '../../../src/domain/migrations/GraphModelMigrationScratchWriteResult.ts'; +import { gitOk } from './migrationTestEnvironment.ts'; + +const execFileAsync = promisify(execFile); +const GRAPH_ID = 'v17-golden-graph'; +const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/public-read'; + +describe('v18 scratch public-read builder', () => { + it('builds scratch facts from materialized runtime state', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-public-read-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + operation('node-record', 'node:alpha', 'node:alpha'), + operation('node-record', 'node:beta', 'node:beta'), + operation('edge-record', 'edge:alpha-beta', 'node:alpha->node:beta:relates'), + operation( + 'property', + 'node:alpha->node:beta:relates\0weight', + propertyTarget(edgePropertyOwner('node:alpha', 'node:beta', 'relates'), 'weight'), + ), + operation('property', 'node:alpha:title', propertyTarget('node:alpha', 'title')), + ]), + }); + + const reading = await buildGraphModelMigrationScratchPublicReadReading({ + sourceRepositoryPath: repositoryPath, + request: replayRequest(writeResult), + readingId: 'scratch-public-read:unit', + }); + + expect(reading.facts.map((fact) => `${fact.kind}:${fact.factKey}:${fact.fieldPath}:${fact.value}`)) + .toEqual([ + 'edge:node:alpha->node:beta:relates:visibility:visible', + 'node:node:alpha:visibility:visible', + 'node:node:beta:visibility:visible', + 'property:node:alpha->node:beta:relates:weight:value:migration-source:node:alpha->node:beta:relates\0weight', + 'property:node:alpha:title:value:migration-source:node:alpha:title', + ]); + }); + + it('creates a command provider that reads scratch content through runtime state', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-public-read-content-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([ + operation('node-record', 'node:alpha', 'node:alpha'), + operation('content-attachment', 'node:alpha:_content', 'content-attachment:node:alpha:_content'), + ]), + }); + const provider = createGraphModelMigrationScratchPublicReadProvider({ + sourceRepositoryPath: repositoryPath, + graphId: GRAPH_ID, + readingId: 'scratch-public-read:content', + }); + + const reading = await provider(writeResult); + const contentFact = requiredFact(reading.facts, 'content-attachment', 'node:alpha:_content'); + + expect(reading.facts.map((fact) => `${fact.kind}:${fact.factKey}:${fact.fieldPath}`)).toEqual([ + 'content-attachment:node:alpha:_content:payload.oid', + 'node:node:alpha:visibility', + ]); + expect(contentFact.value).not.toBe('migration-source:node:alpha:_content'); + expect(contentFact.value.length).toBeGreaterThan(0); + }); + + it('fails closed when the scratch ref drifts before public readback', async () => { + const repositoryPath = await initializedRepository('git-warp-v18-scratch-public-read-drift-'); + const writeResult = await writeGraphModelMigrationScratchHistory({ + repositoryPath, + scratchRefName: SCRATCH_REF, + patchPlan: patchPlan([operation('node-record', 'node:alpha', 'node:alpha')]), + }); + const replacementHead = await writeBadScratchCommit(repositoryPath); + await gitOk(repositoryPath, ['update-ref', SCRATCH_REF, replacementHead], null); + + await expect(buildGraphModelMigrationScratchPublicReadReading({ + sourceRepositoryPath: repositoryPath, + request: replayRequest(writeResult), + readingId: 'scratch-public-read:drift', + })).rejects.toThrow(/scratch ref head changed/); + }); +}); + +function requiredFact( + facts: readonly { readonly kind: string; readonly factKey: string; readonly value: string }[], + kind: string, + factKey: string, +): { readonly value: string } { + const found = facts.find((fact) => fact.kind === kind && fact.factKey === factKey); + if (found === undefined) { + throw new Error(`expected ${kind}:${factKey}`); + } + return found; +} + +async function initializedRepository(prefix: string): Promise { + const repositoryPath = await mkdtemp(join(tmpdir(), prefix)); + await execFileAsync('git', ['init', '-q'], { cwd: repositoryPath }); + return repositoryPath; +} + +function replayRequest( + writeResult: GraphModelMigrationScratchWriteResult, +): GraphModelMigrationRuntimeReplayRequest { + if (writeResult.scratchRef === null || writeResult.scratchHead === null) { + throw new Error('scratch write result must contain output'); + } + return new GraphModelMigrationRuntimeReplayRequest({ + graphId: GRAPH_ID, + writerId: 'scratch-migration', + scratchRef: writeResult.scratchRef, + scratchHead: writeResult.scratchHead, + }); +} + +function patchPlan( + operations: readonly GraphModelMigrationLoweredOperation[], +): GraphModelMigrationLoweredPatchPlan { + return new GraphModelMigrationLoweredPatchPlan({ + sourceBasis: new GraphModelMigrationBasis({ + graphId: GRAPH_ID, + basisId: 'basis:source', + }), + targetBasis: new GraphModelMigrationBasis({ + graphId: GRAPH_ID, + basisId: 'basis:scratch', + }), + operations, + }); +} + +function operation( + kind: 'node-record' | 'edge-record' | 'property' | 'content-attachment', + sourceKey: string, + targetKey: string, +): GraphModelMigrationLoweredOperation { + return new GraphModelMigrationLoweredOperation({ kind, sourceKey, targetKey }); +} + +function propertyTarget(ownerId: string, propertyKey: string): string { + return [ + 'property-target-key:length-prefixed-v1', + ownerId.length, + ownerId, + propertyKey.length, + propertyKey, + ].join(':'); +} + +function edgePropertyOwner(from: string, to: string, label: string): string { + return `\x01${from}\0${to}\0${label}`; +} + +async function writeBadScratchCommit(repositoryPath: string): Promise { + const blobOid = await gitOk(repositoryPath, ['hash-object', '-w', '--stdin'], 'not a scratch payload\n'); + const treeOid = await gitOk( + repositoryPath, + ['mktree'], + `100644 blob ${blobOid}\tmigration-operation.txt\n`, + ); + return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); +} diff --git a/test/unit/scripts/v18-scratch-reading-builder.test.ts b/test/unit/scripts/v18-scratch-reading-builder.test.ts index af423fd34..9afb4a5b2 100644 --- a/test/unit/scripts/v18-scratch-reading-builder.test.ts +++ b/test/unit/scripts/v18-scratch-reading-builder.test.ts @@ -9,13 +9,12 @@ import { buildGraphModelMigrationScratchReading } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchReadingBuilder.ts'; import { writeGraphModelMigrationScratchHistory } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; -import { runMigrationGit } - from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; import GraphModelMigrationLoweredOperation from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; import GraphModelMigrationLoweredPatchPlan from '../../../src/domain/migrations/GraphModelMigrationLoweredPatchPlan.ts'; +import { gitOk } from './migrationTestEnvironment.ts'; const execFileAsync = promisify(execFile); const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; @@ -108,13 +107,3 @@ async function writeScratchPayload(repositoryPath: string, payload: string): Pro ); return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); } - -async function gitOk( - repositoryPath: string, - args: readonly string[], - input: string, -): Promise { - const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); - expect(result.ok()).toBe(true); - return result.stdout.trim(); -} diff --git a/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts b/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts index 5e262e660..2cd44529f 100644 --- a/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts +++ b/test/unit/scripts/v18-scratch-runtime-conformance-provider.test.ts @@ -9,8 +9,6 @@ import { createGraphModelMigrationScratchRuntimeConformanceProvider } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchRuntimeConformanceProvider.ts'; import { writeGraphModelMigrationScratchHistory } from '../../../scripts/v18.0.0/migrations/graph-model/GraphModelMigrationScratchWriter.ts'; -import { runMigrationGit } - from '../../../scripts/v18.0.0/migrations/graph-model/GitMigrationCommandRunner.ts'; import GraphModelMigrationBasis from '../../../src/domain/migrations/GraphModelMigrationBasis.ts'; import GraphModelMigrationLoweredOperation from '../../../src/domain/migrations/GraphModelMigrationLoweredOperation.ts'; @@ -22,6 +20,7 @@ import { GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_FAILED, GRAPH_MODEL_MIGRATION_RUNTIME_CONFORMANCE_PASSED, } from '../../../src/domain/migrations/GraphModelMigrationRuntimeConformanceResult.ts'; +import { gitOk } from './migrationTestEnvironment.ts'; const execFileAsync = promisify(execFile); const SCRATCH_REF = 'refs/warp-migration-scratch/v17-golden-graph/migration'; @@ -135,13 +134,3 @@ async function writeBadScratchCommit(repositoryPath: string): Promise { ); return await gitOk(repositoryPath, ['commit-tree', treeOid], 'bad scratch payload\n'); } - -async function gitOk( - repositoryPath: string, - args: readonly string[], - input: string | null, -): Promise { - const result = await runMigrationGit(repositoryPath, args, input, { deterministicIdentity: true }); - expect(result.ok()).toBe(true); - return result.stdout.trim(); -} diff --git a/test/unit/scripts/v18-v17-fixture-wet-run-harness.test.ts b/test/unit/scripts/v18-v17-fixture-wet-run-harness.test.ts new file mode 100644 index 000000000..f9e44600b --- /dev/null +++ b/test/unit/scripts/v18-v17-fixture-wet-run-harness.test.ts @@ -0,0 +1,270 @@ +import { copyFile, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildV17GoldenFixturePropertyMappings, + checkV17GoldenGraphFixtureWetRunDrift, + runV17GoldenGraphFixtureWetRun, + V17_WET_RUN_DRIFT_CHECK_FAILED, + V17_WET_RUN_DRIFT_CHECK_PASSED, +} + from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunHarness.ts'; +import { formatV17GoldenGraphFixtureWetRunReport } + from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureWetRunReport.ts'; +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; +import { + GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED, +} from '../../../src/domain/migrations/GraphModelMigrationRuntimeReplayResult.ts'; +import V17GoldenGraphFixtureManifest, { + V17GoldenContentFact, + V17GoldenEdgeFact, + V17GoldenMultiWriterFact, + V17GoldenNodeFact, + V17GoldenPropertyFact, + V17GoldenRemovalFact, + V17GoldenGraphFixtureWriterChain, +} from '../../../src/domain/migrations/V17GoldenGraphFixtureManifest.ts'; +import { gitOk, MigrationTestDirectories } from './migrationTestEnvironment.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); +const temporaryDirectories = new MigrationTestDirectories(); + +describe('v18 v17 fixture wet-run harness', () => { + afterEach(async () => { + await temporaryDirectories.cleanup(); + }); + + it('restores the fixture and exercises the scratch migration path without finalization', async () => { + const targetDirectory = await temporaryDirectories.create('git-warp-v17-wet-run-'); + + const result = await runV17GoldenGraphFixtureWetRun({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + + expect(result.restoreResult.repositoryPath).toBe(targetDirectory); + expect(result.restoreResult.restoredRefs.map((ref) => ref.refName)).toEqual([ + 'refs/warp/v17-golden-graph/writers/alice', + 'refs/warp/v17-golden-graph/writers/bob', + ]); + expect(result.commandResult.dryRunPlan.hasFatalErrors()).toBe(false); + expect(result.commandResult.loweringResult.hasFatalErrors()).toBe(false); + expect(result.commandResult.scratchWriteResult?.hasFatalErrors()).toBe(false); + expect(result.commandResult.scratchWriteResult?.writtenPatches.length).toBe(6); + expect(result.commandResult.finalizationResult).toBeNull(); + expect(result.runtimeReplayResult?.status).toBe(GRAPH_MODEL_MIGRATION_RUNTIME_REPLAY_PASSED); + expect(result.runtimeReplayResult?.replayedOperationCount).toBe(6); + expect(result.driftCheckResult.status).toBe(V17_WET_RUN_DRIFT_CHECK_PASSED); + expect(result.driftCheckResult.checkedRefCount).toBe(2); + }); + + it('represents removed-node and multi-writer fixture coverage in migrated readings', async () => { + const targetDirectory = await temporaryDirectories.create('git-warp-v17-wet-run-gap-'); + + const result = await runV17GoldenGraphFixtureWetRun({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + + expect(result.commandResult.gateResult?.allowsPromotion()).toBe(true); + expect(result.commandResult.gateResult?.proofResult.summary.legacyFactCount).toBe(8); + expect(result.commandResult.gateResult?.proofResult.summary.migratedFactCount).toBe(8); + expect(result.commandResult.gateResult?.proofResult.summary.mismatchCount).toBe(0); + expect(result.commandResult.gateResult?.fatalErrors).toEqual([]); + }); + + it('proves the canonical wet-run has zero public-read mismatches', async () => { + const targetDirectory = await temporaryDirectories.create('git-warp-v17-wet-run-zero-'); + + const result = await runV17GoldenGraphFixtureWetRun({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + const report = formatV17GoldenGraphFixtureWetRunReport(result); + + expect(result.commandResult.gateResult?.proofResult.summary.mismatchCount).toBe(0); + expect(result.commandResult.gateResult?.divergenceReport).toBeNull(); + expect(report).toContain('command.mismatches: 0'); + expect(report.split('\n').filter((line) => line === 'mismatches:')).toEqual([]); + }); + + it('formats deterministic wet-run operator evidence without temp paths', async () => { + const firstTarget = await temporaryDirectories.create('git-warp-v17-wet-run-report-a-'); + const secondTarget = await temporaryDirectories.create('git-warp-v17-wet-run-report-b-'); + + const first = formatV17GoldenGraphFixtureWetRunReport(await runV17GoldenGraphFixtureWetRun({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory: firstTarget, + })); + const second = formatV17GoldenGraphFixtureWetRunReport(await runV17GoldenGraphFixtureWetRun({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory: secondTarget, + })); + + expect(first).toBe(second); + expect(first).not.toContain(firstTarget); + expect(first).toContain('git-warp v18 v17 fixture wet-run report'); + expect(first).toContain('fixtureId: v17-golden-graph-model-001'); + expect(first).toContain('command.equivalence: passed'); + expect(first).toContain('command.mismatches: 0'); + expect(first).not.toContain('\nmismatches:\n'); + expect(first).not.toContain('- missing node node:removed visibility'); + expect(first).not.toContain('- missing property writers:alice+bob coverage'); + expect(first).toContain('runtimeReplay: passed'); + expect(first).toContain('runtimeReplayOperations: 6'); + expect(first).toContain('driftCheck: passed'); + expect(first).toContain('driftCheckedRefs: 2'); + }); + + it('detects restored source ref drift before future finalization', async () => { + const targetDirectory = await temporaryDirectories.create('git-warp-v17-wet-run-drift-'); + const restoreResult = await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); + const bobHead = restoreResult.restoredRefs[1]?.head; + if (bobHead === undefined) { + throw new Error('fixture must restore bob ref'); + } + await gitOk(restoreResult.repositoryPath, [ + 'update-ref', + 'refs/warp/v17-golden-graph/writers/alice', + bobHead, + ]); + + const driftCheck = await checkV17GoldenGraphFixtureWetRunDrift({ + repositoryPath: restoreResult.repositoryPath, + manifest: restoreResult.manifest, + }); + + expect(driftCheck.status).toBe(V17_WET_RUN_DRIFT_CHECK_FAILED); + expect(driftCheck.fatalErrors.map((notice) => notice.code)).toEqual([ + 'E_WET_RUN_SOURCE_REF_DRIFT', + ]); + }); + + it('rejects empty harness paths before restore work', async () => { + const targetDirectory = await temporaryDirectories.create('git-warp-v17-wet-run-invalid-'); + + await expect(runV17GoldenGraphFixtureWetRun({ + manifestPath: '', + targetDirectory, + })).rejects.toThrow(/manifestPath/); + await expect(runV17GoldenGraphFixtureWetRun({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory: '', + })).rejects.toThrow(/targetDirectory/); + }); + + it('fails closed when a fixture property fact cannot be mapped', async () => { + const directory = await temporaryDirectories.create('git-warp-v17-wet-run-bad-property-'); + const manifestPath = await fixtureVariant(directory, (raw) => raw.replace( + '"key": "node:alpha:title"', + '"key": "title"', + )); + + await expect(runV17GoldenGraphFixtureWetRun({ + manifestPath, + targetDirectory: join(directory, 'target'), + })).rejects.toThrow(/owner:property public key format/); + }); + + it('fails closed when a fixture edge fact lowers to an invalid scratch target', async () => { + const directory = await temporaryDirectories.create('git-warp-v17-wet-run-bad-edge-'); + const manifestPath = await fixtureVariant(directory, (raw) => raw.replace( + '"key": "node:alpha->node:beta:relates"', + '"key": "edge-without-target-shape"', + )); + + await expect(runV17GoldenGraphFixtureWetRun({ + manifestPath, + targetDirectory: join(directory, 'target'), + })).rejects.toThrow(/from->to:label/); + }); + + it('uses declared edge facts instead of delimiter shape for fixture property owners', () => { + const mappings = buildV17GoldenFixturePropertyMappings(new V17GoldenGraphFixtureManifest({ + fixtureId: 'delimiter-shaped-node-owner', + graphId: 'v17-golden-graph', + sourceVersion: '17.0.1', + generator: 'unit fixture', + bundlePath: 'fixture.bundle', + writerChains: [writerChain()], + visibleFacts: [ + new V17GoldenNodeFact({ + key: 'node:looks->like:edge', + description: 'node owner that looks like an edge key', + }), + new V17GoldenEdgeFact({ + key: 'node:alpha->node:beta:relates', + description: 'declared edge owner', + }), + new V17GoldenPropertyFact({ + key: 'node:looks->like:edge:title', + description: 'node property using delimiter-shaped owner', + }), + new V17GoldenPropertyFact({ + key: 'node:alpha->node:beta:relates:weight', + description: 'declared edge property', + }), + new V17GoldenContentFact({ + key: 'node:looks->like:edge:_content', + description: 'content coverage', + }), + new V17GoldenRemovalFact({ + key: 'node:removed', + description: 'removal coverage', + }), + new V17GoldenMultiWriterFact({ + key: 'writers:alice+bob', + description: 'writer coverage', + }), + ], + })); + + expect(targetOwnerFor(mappings, 'node:looks->like:edge')).toBe('node:looks->like:edge'); + expect(targetOwnerFor(mappings, 'node:alpha->node:beta:relates')).toBe( + '\x01node:alpha\0node:beta\0relates', + ); + }); +}); + +async function fixtureVariant( + directory: string, + rewrite: (raw: string) => string, +): Promise { + const manifestPath = join(directory, 'manifest.json'); + await copyFile( + resolve('fixtures/v17/graph-model-golden/v17-golden-graph.bundle'), + join(directory, 'v17-golden-graph.bundle'), + ); + await writeFile( + manifestPath, + rewrite(await readFile(FIXTURE_MANIFEST_PATH, 'utf8')), + 'utf8', + ); + return manifestPath; +} + +function writerChain(): V17GoldenGraphFixtureWriterChain { + return new V17GoldenGraphFixtureWriterChain({ + writerId: 'alice', + refName: 'refs/warp/v17-golden-graph/writers/alice', + expectedHead: '0123456789abcdef0123456789abcdef01234567', + patchCount: 1, + }); +} + +function targetOwnerFor( + mappings: ReturnType, + legacyOwnerId: string, +): string { + const mapping = mappings.find((candidate) => candidate.legacyOwnerId === legacyOwnerId); + if (mapping === undefined) { + throw new Error(`missing mapping for ${legacyOwnerId}`); + } + return mapping.targetOwnerId; +} diff --git a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts new file mode 100644 index 000000000..9c01d45c0 --- /dev/null +++ b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts @@ -0,0 +1,87 @@ +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildV17RestoredPublicReadLegacyReading, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts'; +import { + restoreV17GoldenGraphFixture, +} from '../../../scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureRestore.ts'; +import { gitOk, MigrationTestDirectories } from './migrationTestEnvironment.ts'; + +const FIXTURE_MANIFEST_PATH = resolve('fixtures/v17/graph-model-golden/manifest.json'); +const temporaryDirectories = new MigrationTestDirectories(); + +describe('v18 v17 public-read legacy reading builder', () => { + afterEach(async () => { + await temporaryDirectories.cleanup(); + }); + + it('builds legacy equivalence facts from a verified restored v17 fixture', async () => { + const restoreResult = await restoredFixture('git-warp-v17-public-read-'); + + const reading = await buildV17RestoredPublicReadLegacyReading({ + repositoryPath: restoreResult.repositoryPath, + manifest: restoreResult.manifest, + }); + + expect(reading.readingId).toBe('v17-golden-fixture:v17-golden-graph-model-001'); + expect(reading.facts.map((fact) => `${fact.kind}:${fact.factKey}:${fact.fieldPath}`)).toEqual([ + 'content-attachment:node:alpha:_content:payload.oid', + 'edge:node:alpha->node:beta:relates:visibility', + 'node:node:alpha:visibility', + 'node:node:beta:visibility', + 'node:node:removed:visibility', + 'property:node:alpha->node:beta:relates:weight:value', + 'property:node:alpha:title:value', + 'property:writers:alice+bob:coverage', + ]); + expect(reading.facts.map((fact) => fact.boundary?.writerId)).toEqual([ + 'alice', + 'bob', + 'bob', + 'alice', + 'bob', + 'bob', + 'alice', + 'alice', + ]); + expect(reading.facts.find((fact) => fact.factKey === 'node:alpha:_content')?.value) + .toBe('24c25f5d050d4abd1186ab83700fae29144f1f7b'); + }); + + it('fails closed when a restored v17 writer ref drifts after restore', async () => { + const restoreResult = await restoredFixture('git-warp-v17-public-read-drift-'); + const bobHead = restoreResult.restoredRefs[1]?.head; + if (bobHead === undefined) { + throw new Error('fixture must restore bob ref'); + } + await gitOk(restoreResult.repositoryPath, [ + 'update-ref', + 'refs/warp/v17-golden-graph/writers/alice', + bobHead, + ]); + + await expect(buildV17RestoredPublicReadLegacyReading({ + repositoryPath: restoreResult.repositoryPath, + manifest: restoreResult.manifest, + })).rejects.toThrow(/expected 417fe95095a6feae3042c36505065bbd7b3d2a67/); + }); + + it('rejects an invalid restored repository path before Git work', async () => { + const restoreResult = await restoredFixture('git-warp-v17-public-read-invalid-'); + + await expect(buildV17RestoredPublicReadLegacyReading({ + repositoryPath: '', + manifest: restoreResult.manifest, + })).rejects.toThrow(/repositoryPath/); + }); +}); + +async function restoredFixture(prefix: string): Promise>> { + const targetDirectory = await temporaryDirectories.create(prefix); + return await restoreV17GoldenGraphFixture({ + manifestPath: FIXTURE_MANIFEST_PATH, + targetDirectory, + }); +}