diff --git a/docs/design/0149-graph-backed-rope-runtime-discovery.md b/docs/design/0149-graph-backed-rope-runtime-discovery.md index 8f5f5d0b..4e95059e 100644 --- a/docs/design/0149-graph-backed-rope-runtime-discovery.md +++ b/docs/design/0149-graph-backed-rope-runtime-discovery.md @@ -84,8 +84,9 @@ In that model: - `replaceRangeAsTick` is an Echo intent that reads a base head, range-closes over touched rope nodes, creates the new local rope facts, emits rewrite and diff evidence, and advances the worldline head. -- `RopeRewrite`, `RopeDiff`, tick receipts, checkpoints, anchors, strands, and - admissions are retained causal evidence. +- `RopeRewrite`, `RopeDiff`, application tick evidence, checkpoint declarations, + and anchor associations are retained jedit evidence; Echo owns causal-anchor + and admission evidence. - Materialized strings are readings or projections. They are allowed for UI rendering, export, save, tests, and caches, but they are not the source of editor truth. @@ -466,12 +467,13 @@ interface RopeStructuralMaintenanceFact { } ``` -The full design must also define facts for: +The full design must also define jedit facts for: -- anchors; +- associations between rope checkpoints and opaque Echo anchor evidence; - strands, braids, and admissions when their implementation slice begins. -Echo remains generic. jedit owns these fact shapes and text-specific witnesses. +Echo remains generic and owns causal-anchor admission. jedit owns rope facts, +domain associations, and text-specific witnesses. Runtime construction and validation are part of the contract. The branded types above are compile-time helpers only; decoded runtime payloads must pass through @@ -504,7 +506,7 @@ type RopeAdmittedFact = | TickReceiptFact | RopeStructuralMaintenanceFact | RopeCheckpointFact - | EchoCausalAnchorFact; + | RopeCheckpointAnchoredFact; interface RopeFactReadModel { getFact(id: string): RopeAdmittedFact | null; @@ -817,44 +819,18 @@ Initial balance invariant for the tiny graph-backed runtime: - rebalancing emits structural-maintenance evidence linked to the semantic rewrite that made maintenance necessary. -Checkpoint semantics also need precision. A checkpoint is not new text truth. It -is a durable named basis for efficient future reads, retention, or export. Echo -owns the generic causal anchor. jedit owns the rope checkpoint that says which -rope head the anchor names in text-domain terms. +Checkpoint semantics also need precision. A checkpoint is not new text truth. +It is a jedit declaration that a named rope head matters for a domain reason. +That declaration does not necessarily request special Echo retention. Echo owns +generic causal-anchor admission, while jedit may separately associate an +admitted Echo anchor with a checkpoint for durable recovery, retention, save, +or export policy. ```typescript type CheckpointId = string & { readonly __brand: "CheckpointId" }; type CausalAnchorId = string & { readonly __brand: "CausalAnchorId" }; - -interface EchoCausalAnchorFact { - readonly kind: "echo.causal.Anchor"; - readonly schemaVersion: 1; - readonly anchorId: CausalAnchorId; - readonly subject: { - readonly appId: "jedit"; - readonly subjectKind: "BufferWorldline"; - readonly subjectId: WorldlineId; - }; - readonly basisFrontierDigest: Hash; - readonly retainedRoots: readonly [{ - readonly kind: "AppSubjectRoot"; - readonly appId: "jedit"; - readonly subjectKind: "RopeHead"; - readonly id: RopeHeadId; - readonly role: "authority"; - }]; - readonly materializationRoots: readonly unknown[]; - readonly purpose: - | "recovery" - | "retention" - | "export" - | "user-save" - | "autosave" - | "debug" - | "cache-warm"; - readonly admittedByReceiptId: Hash; - readonly anchorDigest: Hash; -} +type CausalAnchorFactId = string & { readonly __brand: "CausalAnchorFactId" }; +type CausalAnchorReceiptId = string & { readonly __brand: "CausalAnchorReceiptId" }; interface RopeCheckpointFact { readonly kind: "jedit.text.RopeCheckpoint"; @@ -862,7 +838,6 @@ interface RopeCheckpointFact { readonly checkpointId: CheckpointId; readonly worldlineId: WorldlineId; readonly headId: RopeHeadId; - readonly causalAnchorId: CausalAnchorId; readonly reason: | "manual-save" | "autosave" @@ -871,13 +846,36 @@ interface RopeCheckpointFact { | "import" | "test-fixture"; } + +interface RopeCheckpointAnchoredFact { + readonly kind: "jedit.text.RopeCheckpointAnchored"; + readonly schemaVersion: 1; + readonly associationId: string; + readonly checkpointId: CheckpointId; + readonly causalAnchorId: CausalAnchorId; + readonly causalAnchorFactId: CausalAnchorFactId; + readonly causalAnchorReceiptId: CausalAnchorReceiptId; +} ``` Save/export should read from a head or checkpoint. It should not mutate text -authority unless the product explicitly records a checkpoint. Creating a -checkpoint admits an anchor and a checkpoint fact; it does not create a -`RopeHead`, `RopeRewrite`, `RopeDiff`, replacement blob, or text mutation -receipt. +authority unless the product explicitly records a checkpoint. Declaring a +checkpoint creates only `RopeCheckpointFact`. A separate, explicitly injected +Echo capability may admit a causal anchor and return opaque anchor, fact, and +receipt identities; jedit then records `RopeCheckpointAnchoredFact`. Neither +operation creates a `RopeHead`, `RopeRewrite`, `RopeDiff`, replacement blob, or +text mutation receipt. + +The transitional TypeScript port must not derive Echo basis frontiers, IDs, +digests, receipts, WAL evidence, or retention policy. It validates jedit-owned +checkpoint and materialization semantics before invocation, fails closed when +no Echo capability is available, and treats all returned Echo identities as +opaque references. Echo's witnessed causal history remains authoritative after +restart; registries and lookup maps are disposable projections over that +history. Replace this port with a generated Edict client once Echo can install +and invoke the corresponding verified operation natively. The port is a +short-lived seam for this one capability, not a Jim/Echo protocol: do not add +new handwritten operations or give it long-term compatibility guarantees. ### 12. Make `:why` An Acceptance Target diff --git a/spec/design-cycle-policy.spec.mjs b/spec/design-cycle-policy.spec.mjs index a4610da9..8121edae 100644 --- a/spec/design-cycle-policy.spec.mjs +++ b/spec/design-cycle-policy.spec.mjs @@ -153,12 +153,12 @@ test('HT-0149 checkpoint fact carries schema version', () => { test('HT-0149 checkpoint facts are validated rather than deferred', () => { const discovery = readRepoFile(GRAPH_RUNTIME_DISCOVERY_PATH); - const deferredFacts = sectionBetween(discovery, 'The full design must also define facts for:', 'Echo remains generic.'); + const deferredFacts = sectionBetween(discovery, 'The full design must also define jedit facts for:', 'Echo remains generic'); const admittedFacts = sectionBetween(discovery, 'type RopeAdmittedFact =', 'interface RopeFactReadModel'); assert.match(admittedFacts, /\| TickReceiptFact/); assert.match(admittedFacts, /\| RopeCheckpointFact\b/); - assert.match(admittedFacts, /\| EchoCausalAnchorFact;/); + assert.match(admittedFacts, /\| RopeCheckpointAnchoredFact;/); assert.match(discovery, /\): FactValidationResult;/); assert.doesNotMatch(deferredFacts, /`RopeCheckpoint`/); }); diff --git a/spec/graph-rope-checkpoint-runtime.spec.mjs b/spec/graph-rope-checkpoint-runtime.spec.mjs new file mode 100644 index 00000000..c6e9a8de --- /dev/null +++ b/spec/graph-rope-checkpoint-runtime.spec.mjs @@ -0,0 +1,498 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTestEchoCausalAnchorAdmissionPort } from './support/test-echo-causal-anchor-admission.mjs'; +import { UTF8_ENCODER, assertOk, byteRange, createHashPort, loadModules } from './support/graph-rope-runtime-test-kit.mjs'; + +const OBSTRUCTION_INVALID_FACT = 'invalid-fact'; +const OBSTRUCTION_MISSING_CHECKPOINT = 'missing-checkpoint'; +const OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE = 'causal-anchor-unavailable'; +const OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED = 'causal-anchor-admission-failed'; + +test('graph runtime declares a checkpoint without requiring Echo admission', async () => { + const { runtime, contract } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint', + initialText: 'alpha beta', + })); + const replaced = assertOk(graph.replaceRangeAsTick({ + basisHeadId: created.head.headId, + range: byteRange(contract, 6, 10), + replacementText: 'BETA', + })); + const before = assertOk(graph.debugRopeShape(replaced.nextHead.headId)); + + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint', + headId: replaced.nextHead.headId, + reason: 'manual-save', + })); + const after = assertOk(graph.debugRopeShape(replaced.nextHead.headId)); + const reading = assertOk(graph.textWindow({ + basisHeadId: replaced.nextHead.headId, + byteRange: byteRange(contract, 0, UTF8_ENCODER.encode('alpha BETA').length), + })); + + assert.equal(checkpointed.head.headId, replaced.nextHead.headId); + assert.equal(checkpointed.checkpoint.headId, replaced.nextHead.headId); + assert.equal(checkpointed.checkpoint.reason, 'manual-save'); + assert.equal('causalAnchorId' in checkpointed.checkpoint, false); + assert.equal('causalAnchor' in checkpointed, false); + assert.equal('causalAnchorReceipt' in checkpointed, false); + assert.equal('rewrite' in checkpointed, false); + assert.equal('diff' in checkpointed, false); + assert.equal('receipt' in checkpointed, false); + assert.deepEqual(after, before); + assert.equal(reading.text, 'alpha BETA'); +}); + +test('graph runtime fails closed when checkpoint anchoring has no Echo adapter', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-no-echo', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-no-echo', + headId: created.head.headId, + reason: 'manual-save', + })); + + assert.deepEqual(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + }), { + ok: false, + code: OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE, + }); +}); + +test('graph runtime associates explicitly injected opaque Echo evidence with a checkpoint', async () => { + const { runtime } = await loadModules(); + const requests = []; + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ requests }), + }); + const materializationRoot = { + id: 'cas:checkpoint-flat-text', + role: 'materialization', + }; + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-materialization', + initialText: 'alpha', + })); + const before = assertOk(graph.debugRopeShape(created.head.headId)); + + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-materialization', + headId: created.head.headId, + reason: 'export', + })); + const anchored = assertOk(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + materializationRoots: [materializationRoot], + })); + const after = assertOk(graph.debugRopeShape(created.head.headId)); + + assert.deepEqual(requests, [{ + checkpointId: checkpointed.checkpoint.checkpointId, + worldlineId: 'worldline:checkpoint-materialization', + headId: created.head.headId, + reason: 'export', + materializationRoots: [materializationRoot], + }]); + assert.deepEqual(anchored.echoEvidence, { + anchorId: 'test-only-anchor:1', + anchorFactId: 'test-only-anchor-fact:1', + receiptId: 'test-only-anchor-receipt:1', + }); + assert.equal(anchored.association.checkpointId, checkpointed.checkpoint.checkpointId); + assert.equal(anchored.association.causalAnchorId, anchored.echoEvidence.anchorId); + assert.equal(anchored.association.causalAnchorFactId, anchored.echoEvidence.anchorFactId); + assert.equal(anchored.association.causalAnchorReceiptId, anchored.echoEvidence.receiptId); + assert.equal('authority' in anchored.echoEvidence, false); + assert.deepEqual(after, before); +}); + +test('graph runtime validates materialization roots before invoking Echo', async () => { + const { runtime } = await loadModules(); + const requests = []; + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ requests }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-invalid-materialization', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-invalid-materialization', + headId: created.head.headId, + reason: 'export', + })); + + const invalidRootSets = [ + null, + {}, + { map() { return null; } }, + [null], + [{ id: '', role: 'materialization' }], + [{ id: 'cas:authority', role: 'authority' }], + [ + { id: 'cas:duplicate', role: 'materialization' }, + { id: 'cas:duplicate', role: 'materialization' }, + ], + ]; + + for (const materializationRoots of invalidRootSets) { + assert.deepEqual(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + materializationRoots, + }), { + ok: false, + code: OBSTRUCTION_INVALID_FACT, + }); + } + assert.equal(requests.length, 0); +}); + +test('graph runtime snapshots materialization roots before validation and Echo invocation', async () => { + const { runtime } = await loadModules(); + const requests = []; + const reads = { id: 0, role: 0 }; + const materializationRoot = Object.defineProperties({}, { + id: { + get() { + reads.id += 1; + return 'cas:stable'; + }, + }, + role: { + get() { + reads.role += 1; + return 'materialization'; + }, + }, + }); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ requests }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-request-snapshot', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-request-snapshot', + headId: created.head.headId, + reason: 'export', + })); + + assertOk(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + materializationRoots: [materializationRoot], + })); + + assert.deepEqual(reads, { id: 1, role: 1 }); + assert.deepEqual(requests[0].materializationRoots, [{ + id: 'cas:stable', + role: 'materialization', + }]); +}); + +test('graph runtime turns Echo adapter failures into typed obstructions', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ + admit() { + return { ok: false, obstructionId: 'test-only-echo-offline' }; + }, + }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-echo-obstructed', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-echo-obstructed', + headId: created.head.headId, + reason: 'manual-save', + })); + + assert.deepEqual(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + }), { + ok: false, + code: OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED, + }); +}); + +test('graph runtime fails closed on malformed Echo evidence', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ + admit() { + return { + ok: true, + evidence: { + anchorId: 'test-only-anchor:malformed', + anchorFactId: '', + receiptId: 'test-only-anchor-receipt:malformed', + }, + }; + }, + }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-malformed-evidence', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-malformed-evidence', + headId: created.head.headId, + reason: 'manual-save', + })); + + assert.deepEqual(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + }), { + ok: false, + code: OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED, + }); +}); + +test('graph runtime snapshots opaque Echo evidence before admitting its association', async () => { + const { runtime } = await loadModules(); + const reads = { anchorId: 0, anchorFactId: 0, receiptId: 0 }; + const evidence = Object.defineProperties({}, { + anchorId: { + get() { + reads.anchorId += 1; + return 'test-only-anchor:stable'; + }, + }, + anchorFactId: { + get() { + reads.anchorFactId += 1; + return 'test-only-anchor-fact:stable'; + }, + }, + receiptId: { + get() { + reads.receiptId += 1; + return 'test-only-anchor-receipt:stable'; + }, + }, + }); + evidence.authority = 'echo'; + evidence.nonCloneable = () => 'adapter-owned'; + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ + admit() { + return { ok: true, evidence }; + }, + }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-evidence-snapshot', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-evidence-snapshot', + headId: created.head.headId, + reason: 'manual-save', + })); + + const anchored = assertOk(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + })); + + assert.deepEqual(reads, { anchorId: 1, anchorFactId: 1, receiptId: 1 }); + assert.equal('authority' in anchored.echoEvidence, false); + assert.equal('nonCloneable' in anchored.echoEvidence, false); + assert.equal(anchored.association.causalAnchorId, anchored.echoEvidence.anchorId); + assert.equal(anchored.association.causalAnchorFactId, anchored.echoEvidence.anchorFactId); + assert.equal(anchored.association.causalAnchorReceiptId, anchored.echoEvidence.receiptId); +}); + +test('graph runtime requires a boolean success tag from the Echo adapter', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ + admit() { + return { + ok: 'false', + evidence: { + anchorId: 'test-only-anchor:invalid-tag', + anchorFactId: 'test-only-anchor-fact:invalid-tag', + receiptId: 'test-only-anchor-receipt:invalid-tag', + }, + }; + }, + }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-invalid-success-tag', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-invalid-success-tag', + headId: created.head.headId, + reason: 'manual-save', + })); + + assert.deepEqual(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + }), { + ok: false, + code: OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED, + }); +}); + +test('graph runtime fails closed when the Echo adapter throws', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ + admit() { + throw new Error('test-only Echo adapter failure'); + }, + }), + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-echo-throws', + initialText: 'alpha', + })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-echo-throws', + headId: created.head.headId, + reason: 'manual-save', + })); + + assert.deepEqual(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + }), { + ok: false, + code: OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED, + }); +}); + +test('graph runtime treats repeated checkpoint declarations as idempotent', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-repeat', + initialText: 'alpha beta', + })); + const before = assertOk(graph.debugRopeShape(created.head.headId)); + + const first = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-repeat', + headId: created.head.headId, + reason: 'manual-save', + })); + const second = assertOk(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-repeat', + headId: created.head.headId, + reason: 'manual-save', + })); + const after = assertOk(graph.debugRopeShape(created.head.headId)); + + assert.equal(first.head.headId, created.head.headId); + assert.equal(second.head.headId, created.head.headId); + assert.equal(first.checkpoint.checkpointId, second.checkpoint.checkpointId); + assert.equal('rewrite' in first, false); + assert.equal('diff' in first, false); + assert.equal('receipt' in first, false); + assert.deepEqual(after, before); +}); + +test('checkpoint declarations do not perturb later text tick identity', async () => { + const { runtime, contract } = await loadModules(); + const hash = createHashPort(); + const checkpointedGraph = runtime.createGraphRopeRuntime({ hash }); + const controlGraph = runtime.createGraphRopeRuntime({ hash }); + const checkpointedCreated = assertOk(checkpointedGraph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-text-sequence', + initialText: 'alpha', + })); + const controlCreated = assertOk(controlGraph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-text-sequence', + initialText: 'alpha', + })); + assertOk(checkpointedGraph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-text-sequence', + headId: checkpointedCreated.head.headId, + reason: 'manual-save', + })); + + const checkpointedEdit = assertOk(checkpointedGraph.replaceRangeAsTick({ + basisHeadId: checkpointedCreated.head.headId, + range: byteRange(contract, 0, 1), + replacementText: 'A', + })); + const controlEdit = assertOk(controlGraph.replaceRangeAsTick({ + basisHeadId: controlCreated.head.headId, + range: byteRange(contract, 0, 1), + replacementText: 'A', + })); + + assert.deepEqual(checkpointedEdit.receipt, controlEdit.receipt); + assert.deepEqual(checkpointedEdit.nextHead, controlEdit.nextHead); +}); + +test('graph runtime rejects anchoring an unknown checkpoint', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort(), + }); + + assert.deepEqual(graph.anchorCheckpoint({ checkpointId: 'rope-checkpoint:missing' }), { + ok: false, + code: OBSTRUCTION_MISSING_CHECKPOINT, + }); +}); + +test('graph runtime rejects checkpoints for a different worldline', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-mismatch', + initialText: 'alpha', + })); + + assert.deepEqual(graph.createCheckpoint({ + worldlineId: 'worldline:other', + headId: created.head.headId, + reason: 'manual-save', + }), { + ok: false, + code: OBSTRUCTION_INVALID_FACT, + }); +}); + +test('graph runtime rejects invalid checkpoint reasons before deriving identity', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-reason', + initialText: 'alpha', + })); + + for (const reason of ['pretend-save', 1n]) { + assert.deepEqual(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-reason', + headId: created.head.headId, + reason, + }), { + ok: false, + code: OBSTRUCTION_INVALID_FACT, + }); + } +}); diff --git a/spec/graph-rope-contract.spec.mjs b/spec/graph-rope-contract.spec.mjs index df8886ec..1d717a92 100644 --- a/spec/graph-rope-contract.spec.mjs +++ b/spec/graph-rope-contract.spec.mjs @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { importDist } from './dist-helpers.mjs'; +import { createTestEchoCausalAnchorAdmissionPort } from './support/test-echo-causal-anchor-admission.mjs'; const UTF8_ENCODER = new TextEncoder(); const FACT_VALIDATION_ERROR_HASH_MISMATCH = 'hash-mismatch'; +const FACT_VALIDATION_ERROR_INVALID_ID = 'invalid-id'; const FACT_VALIDATION_ERROR_INVALID_KIND = 'invalid-kind'; const FACT_VALIDATION_ERROR_INVALID_REFERENCE = 'invalid-reference'; @@ -12,12 +14,11 @@ async function loadContract() { } async function loadModules() { - const [contract, runtime, anchorDigest] = await Promise.all([ + const [contract, runtime] = await Promise.all([ importDist('domain', 'graph-rope-contract.js'), importDist('domain', 'graph-rope-runtime.js'), - importDist('domain', 'graph-rope-causal-anchor-digest.js'), ]); - return { anchorDigest, contract, runtime }; + return { contract, runtime }; } function createHashPort() { @@ -185,7 +186,7 @@ test('buffer worldline validation requires its initial head to belong to the wor }); }); -test('rope checkpoint validation references a causal anchor instead of a tick receipt', async () => { +test('rope checkpoint validation admits a Jim declaration without Echo evidence', async () => { const facts = await checkpointFixture('worldline:checkpoint'); const { contract } = facts; @@ -194,114 +195,95 @@ test('rope checkpoint validation references a causal anchor instead of a tick re createValidationContext(contract, facts.writeSet), ).ok, true); assert.equal('createdByTickId' in facts.checkpoint, false); - - assert.deepEqual(contract.validateRopeFact( - { ...facts.checkpoint, causalAnchorId: 'tick:initial' }, - createValidationContext(contract, facts.writeSet), - ), { - ok: false, - code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, - }); + assert.equal('causalAnchorId' in facts.checkpoint, false); }); -test('rope checkpoint validation recomputes checkpoint identity and anchor frontier', async () => { +test('rope checkpoint validation recomputes Jim-owned declaration identity', async () => { const facts = await checkpointFixture('worldline:checkpoint-identity'); - const { anchorDigest, contract } = facts; + const { contract } = facts; const forgedCheckpoint = { ...facts.checkpoint, checkpointId: 'rope-checkpoint:forged', }; - const frontierForgedAnchor = rekeyAnchor({ - ...facts.anchor, - basisFrontierDigest: 'frontier:forged', - }, anchorDigest, createHashPort()); - const frontierForgedCheckpoint = { - ...facts.checkpoint, - causalAnchorId: frontierForgedAnchor.anchorId, - }; assert.deepEqual(contract.validateRopeFact( forgedCheckpoint, - createValidationContext(contract, [...facts.baseFacts, facts.anchor, forgedCheckpoint]), + createValidationContext(contract, [...facts.baseFacts, forgedCheckpoint]), ), { ok: false, code: FACT_VALIDATION_ERROR_HASH_MISMATCH, }); - assert.deepEqual(contract.validateRopeFact( - frontierForgedCheckpoint, - createValidationContext(contract, [...facts.baseFacts, frontierForgedAnchor, frontierForgedCheckpoint]), - ), { - ok: false, - code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, - }); }); -test('rope checkpoints require a matching authority root on their causal anchor', async () => { - const facts = await checkpointFixture('worldline:checkpoint'); - const otherFacts = await checkpointFixture('worldline:other-checkpoint'); +test('checkpoint anchor associations retain opaque Echo identities as Jim facts', async () => { + const facts = await checkpointAnchorFixture('worldline:checkpoint-anchor'); const { contract } = facts; - const weakCheckpoint = { - ...facts.checkpoint, - checkpointId: 'rope-checkpoint:weak', - causalAnchorId: otherFacts.anchor.anchorId, - }; assert.equal(contract.validateRopeFact( - otherFacts.anchor, - createValidationContext(contract, otherFacts.writeSet), + facts.association, + createValidationContext(contract, facts.writeSet), ).ok, true); - assert.deepEqual(contract.validateRopeFact( - weakCheckpoint, - createValidationContext(contract, [...facts.baseFacts, otherFacts.anchor, weakCheckpoint]), - ), { - ok: false, - code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, - }); -}); - -test('causal anchor validation rejects authority roots as materializations', async () => { - const facts = await checkpointFixture('worldline:projection-anchor'); - const { contract } = facts; - const projectionAuthorityAnchor = { - ...facts.anchor, - anchorId: 'causal-anchor:projection-authority', - materializationRoots: [facts.anchor.retainedRoots[0]], - }; + assert.equal(facts.association.causalAnchorId, 'test-only-anchor:1'); + assert.equal(facts.association.causalAnchorFactId, 'test-only-anchor-fact:1'); + assert.equal(facts.association.causalAnchorReceiptId, 'test-only-anchor-receipt:1'); + assert.equal('authority' in facts.association, false); assert.deepEqual(contract.validateRopeFact( - projectionAuthorityAnchor, - createValidationContext(contract, [...facts.baseFacts, projectionAuthorityAnchor]), + { ...facts.association, checkpointId: 'rope-checkpoint:missing' }, + createValidationContext(contract, facts.writeSet), ), { ok: false, code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, }); -}); - -test('causal anchor validation rejects forged digests ids and purposes', async () => { - const facts = await checkpointFixture('worldline:forged-anchor'); - const { contract } = facts; - assert.deepEqual(contract.validateRopeFact( - { ...facts.anchor, anchorDigest: 'anchor-digest:forged' }, + { ...facts.association, associationId: 'rope-checkpoint-anchor:forged' }, createValidationContext(contract, facts.writeSet), ), { ok: false, code: FACT_VALIDATION_ERROR_HASH_MISMATCH, }); assert.deepEqual(contract.validateRopeFact( - { ...facts.anchor, anchorId: 'causal-anchor:forged' }, + { ...facts.association, causalAnchorFactId: '' }, createValidationContext(contract, facts.writeSet), ), { ok: false, - code: FACT_VALIDATION_ERROR_HASH_MISMATCH, + code: FACT_VALIDATION_ERROR_INVALID_ID, }); - assert.deepEqual(contract.validateRopeFact( - { ...facts.anchor, purpose: 'pretend-save' }, - createValidationContext(contract, facts.writeSet), - ), { - ok: false, - code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, +}); + +test('checkpoint identities do not collide when opaque fields contain separators', async () => { + const identity = await importDist('domain', 'graph-rope-checkpoint-identity.js'); + const hash = createHashPort(); + + const firstCheckpointId = identity.ropeCheckpointIdFor({ + worldlineId: 'worldline:a', + headId: 'b', + reason: 'manual-save', + hash, + }); + const secondCheckpointId = identity.ropeCheckpointIdFor({ + worldlineId: 'worldline', + headId: 'a:b', + reason: 'manual-save', + hash, + }); + const firstAssociationId = identity.ropeCheckpointAnchorAssociationIdFor({ + checkpointId: 'rope-checkpoint:a', + causalAnchorId: 'b', + causalAnchorFactId: 'c', + causalAnchorReceiptId: 'd', + hash, }); + const secondAssociationId = identity.ropeCheckpointAnchorAssociationIdFor({ + checkpointId: 'rope-checkpoint', + causalAnchorId: 'a:b', + causalAnchorFactId: 'c', + causalAnchorReceiptId: 'd', + hash, + }); + + assert.notEqual(secondCheckpointId, firstCheckpointId); + assert.notEqual(secondAssociationId, firstAssociationId); }); test('graph rope validation rejects forged diff span and rewrite evidence', async () => { @@ -430,14 +412,13 @@ test('graph rope validation rejects inconsistent rewrite diff and receipt links' }); async function graphCreateFixture(worldlineId, initialText) { - const { anchorDigest, contract, runtime } = await loadModules(); + const { contract, runtime } = await loadModules(); const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); const created = assertOk(graph.createBufferWorldline({ worldlineId, initialText })); const leaf = created.nodes.find((node) => node.kind === contract.ROPE_LEAF_FACT_KIND); assert.notEqual(leaf, undefined); return { - anchorDigest, contract, runtime, graph, @@ -482,19 +463,33 @@ async function checkpointFixture(worldlineId) { const baseFacts = facts.writeSet; return { ...facts, - anchor: checkpointed.causalAnchor, checkpoint: checkpointed.checkpoint, baseFacts, - writeSet: [...baseFacts, checkpointed.causalAnchor, checkpointed.checkpoint], + writeSet: [...baseFacts, checkpointed.checkpoint], }; } -function rekeyAnchor(anchor, anchorDigest, hash) { - const anchorDigestValue = anchorDigest.causalAnchorDigestFor(anchor, hash); +async function checkpointAnchorFixture(worldlineId) { + const { contract, runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ + hash: createHashPort(), + causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort(), + }); + const created = assertOk(graph.createBufferWorldline({ worldlineId, initialText: 'checkpoint text' })); + const checkpointed = assertOk(graph.createCheckpoint({ + worldlineId, + headId: created.head.headId, + reason: 'manual-save', + })); + const anchored = assertOk(graph.anchorCheckpoint({ + checkpointId: checkpointed.checkpoint.checkpointId, + })); + const baseFacts = [created.blob, ...created.nodes, created.head, created.worldline]; return { - ...anchor, - anchorDigest: anchorDigestValue, - anchorId: anchorDigest.causalAnchorIdForDigest(anchorDigestValue, hash), + contract, + association: anchored.association, + checkpoint: checkpointed.checkpoint, + writeSet: [...baseFacts, checkpointed.checkpoint, anchored.association], }; } diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index c081d1dc..79922cca 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -1,8 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { importDist } from './dist-helpers.mjs'; +import { + UTF8_ENCODER, + assertOk, + byteRange, + createHashPort, + loadModules, +} from './support/graph-rope-runtime-test-kit.mjs'; -const UTF8_ENCODER = new TextEncoder(); const WINDOW_CACHE_STATUS_UNCACHED = 'uncached-materialization'; const ROPE_LEAF_FACT_KIND = 'jedit.text.RopeLeaf'; const OBSTRUCTION_MISSING_HEAD = 'missing-head'; @@ -10,34 +15,6 @@ const OBSTRUCTION_INVALID_BYTE_RANGE = 'invalid-byte-range'; const OBSTRUCTION_INVALID_UTF8_BOUNDARY = 'invalid-utf8-boundary'; const OBSTRUCTION_INVALID_FACT = 'invalid-fact'; -async function loadModules() { - const [runtime, contract] = await Promise.all([ - importDist('domain', 'graph-rope-runtime.js'), - importDist('domain', 'graph-rope-contract.js'), - ]); - return { runtime, contract }; -} - -function createHashPort() { - return { - sha256Hex(value) { - return `hash(${value})`; - }, - }; -} - -function assertOk(result) { - assert.equal(result.ok, true); - return result.value ?? result.fact ?? result; -} - -function byteRange(contract, start, end) { - return assertOk(contract.makeTextByteRange( - assertOk(contract.makeByteOffset(start)), - assertOk(contract.makeByteOffset(end)), - )); -} - test('graph runtime creates a worldline from UTF-8 bytes and reads a named head window', async () => { const { runtime, contract } = await loadModules(); const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); @@ -175,104 +152,6 @@ test('graph runtime no-op replacement does not mint text authority facts', async assert.equal(replaced.receipt, null); }); -test('graph runtime checkpoints a head through a non-mutating causal anchor', async () => { - const { runtime, contract } = await loadModules(); - const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); - const created = assertOk(graph.createBufferWorldline({ - worldlineId: 'worldline:checkpoint', - initialText: 'alpha beta', - })); - const replaced = assertOk(graph.replaceRangeAsTick({ - basisHeadId: created.head.headId, - range: byteRange(contract, 6, 10), - replacementText: 'BETA', - })); - const before = assertOk(graph.debugRopeShape(replaced.nextHead.headId)); - - const checkpointed = assertOk(graph.createCheckpoint({ - worldlineId: 'worldline:checkpoint', - headId: replaced.nextHead.headId, - reason: 'manual-save', - })); - const after = assertOk(graph.debugRopeShape(replaced.nextHead.headId)); - const reading = assertOk(graph.textWindow({ - basisHeadId: replaced.nextHead.headId, - byteRange: byteRange(contract, 0, UTF8_ENCODER.encode('alpha BETA').length), - })); - - assert.equal(checkpointed.head.headId, replaced.nextHead.headId); - assert.equal(checkpointed.checkpoint.headId, replaced.nextHead.headId); - assert.equal(checkpointed.checkpoint.causalAnchorId, checkpointed.causalAnchor.anchorId); - assert.equal(checkpointed.causalAnchor.subject.appId, contract.JEDIT_CAUSAL_ANCHOR_APP_ID); - assert.equal(checkpointed.causalAnchor.subject.subjectKind, contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE); - assert.equal(checkpointed.causalAnchor.subject.subjectId, 'worldline:checkpoint'); - assert.equal(checkpointed.causalAnchor.purpose, 'user-save'); - assert.deepEqual(checkpointed.causalAnchor.retainedRoots, [{ - kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT, - appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD, - id: replaced.nextHead.headId, - role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, - }]); - assert.equal(checkpointed.causalAnchor.materializationRoots.length, 0); - assert.equal('rewrite' in checkpointed, false); - assert.equal('diff' in checkpointed, false); - assert.equal('receipt' in checkpointed, false); - assert.deepEqual(after, before); - assert.equal(reading.text, 'alpha BETA'); -}); - -test('graph runtime treats repeated checkpoints as distinct causal admissions', async () => { - const { runtime } = await loadModules(); - const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); - const created = assertOk(graph.createBufferWorldline({ - worldlineId: 'worldline:checkpoint-repeat', - initialText: 'alpha beta', - })); - const before = assertOk(graph.debugRopeShape(created.head.headId)); - - const first = assertOk(graph.createCheckpoint({ - worldlineId: 'worldline:checkpoint-repeat', - headId: created.head.headId, - reason: 'manual-save', - })); - const second = assertOk(graph.createCheckpoint({ - worldlineId: 'worldline:checkpoint-repeat', - headId: created.head.headId, - reason: 'manual-save', - })); - const after = assertOk(graph.debugRopeShape(created.head.headId)); - - assert.equal(first.head.headId, created.head.headId); - assert.equal(second.head.headId, created.head.headId); - assert.notEqual(first.causalAnchor.admittedByReceiptId, second.causalAnchor.admittedByReceiptId); - assert.notEqual(first.causalAnchor.anchorDigest, second.causalAnchor.anchorDigest); - assert.notEqual(first.causalAnchor.anchorId, second.causalAnchor.anchorId); - assert.notEqual(first.checkpoint.checkpointId, second.checkpoint.checkpointId); - assert.equal('rewrite' in first, false); - assert.equal('diff' in first, false); - assert.equal('receipt' in first, false); - assert.deepEqual(after, before); -}); - -test('graph runtime rejects checkpoints for a different worldline', async () => { - const { runtime } = await loadModules(); - const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); - const created = assertOk(graph.createBufferWorldline({ - worldlineId: 'worldline:checkpoint-mismatch', - initialText: 'alpha', - })); - - assert.deepEqual(graph.createCheckpoint({ - worldlineId: 'worldline:other', - headId: created.head.headId, - reason: 'manual-save', - }), { - ok: false, - code: OBSTRUCTION_INVALID_FACT, - }); -}); - test('graph runtime preserves untouched leaf identity across narrow replacements', async () => { const { runtime, contract } = await loadModules(); const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); diff --git a/spec/support/graph-rope-runtime-test-kit.mjs b/spec/support/graph-rope-runtime-test-kit.mjs new file mode 100644 index 00000000..0200dcf8 --- /dev/null +++ b/spec/support/graph-rope-runtime-test-kit.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { importDist } from '../dist-helpers.mjs'; + +export const UTF8_ENCODER = new TextEncoder(); + +export async function loadModules() { + const [runtime, contract] = await Promise.all([ + importDist('domain', 'graph-rope-runtime.js'), + importDist('domain', 'graph-rope-contract.js'), + ]); + return { runtime, contract }; +} + +export function createHashPort() { + return { + sha256Hex(value) { + return `hash(${value})`; + }, + }; +} + +export function assertOk(result) { + assert.equal(result.ok, true); + return result.value ?? result.fact ?? result; +} + +export function byteRange(contract, start, end) { + return assertOk(contract.makeTextByteRange( + assertOk(contract.makeByteOffset(start)), + assertOk(contract.makeByteOffset(end)), + )); +} diff --git a/spec/support/test-echo-causal-anchor-admission.mjs b/spec/support/test-echo-causal-anchor-admission.mjs new file mode 100644 index 00000000..c567113b --- /dev/null +++ b/spec/support/test-echo-causal-anchor-admission.mjs @@ -0,0 +1,28 @@ +const INITIAL_ADMISSION_SEQUENCE = 1; +const SEQUENCE_INCREMENT = 1; + +export function createTestEchoCausalAnchorAdmissionPort(input = {}) { + const requests = input.requests ?? []; + const admit = input.admit ?? defaultAdmission; + let sequence = INITIAL_ADMISSION_SEQUENCE; + + return { + admitCheckpointAnchor(request) { + requests.push(structuredClone(request)); + const result = admit(request, sequence); + sequence += SEQUENCE_INCREMENT; + return result; + }, + }; +} + +function defaultAdmission(_request, sequence) { + return { + ok: true, + evidence: { + anchorId: `test-only-anchor:${sequence}`, + anchorFactId: `test-only-anchor-fact:${sequence}`, + receiptId: `test-only-anchor-receipt:${sequence}`, + }, + }; +} diff --git a/src/domain/graph-rope-causal-anchor-digest.ts b/src/domain/graph-rope-causal-anchor-digest.ts deleted file mode 100644 index 1bc55dba..00000000 --- a/src/domain/graph-rope-causal-anchor-digest.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, - GRAPH_ROPE_SCHEMA_VERSION, - type EchoCausalAnchorFact, - type EchoCausalAnchorRoot, - type TextBlobHashPort, -} from './graph-rope-types.js'; - -const CAUSAL_ANCHOR_DIGEST_PREFIX = 'echo-causal-anchor:v1'; -const CAUSAL_ANCHOR_ID_PREFIX = 'causal-anchor:'; - -export function causalAnchorDigestFor( - anchor: Omit, - hash: TextBlobHashPort, -): string { - return hash.sha256Hex(causalAnchorDigestMaterial(anchor)); -} - -export function causalAnchorIdForDigest(anchorDigest: string, hash: TextBlobHashPort): string { - return `${CAUSAL_ANCHOR_ID_PREFIX}${hash.sha256Hex(anchorDigest)}`; -} - -export function causalAnchorDigestMaterial( - anchor: Omit, -): string { - return [ - CAUSAL_ANCHOR_DIGEST_PREFIX, - `schema=${String(GRAPH_ROPE_SCHEMA_VERSION)}`, - `subject=${anchor.subject.appId}:${anchor.subject.subjectKind}:${anchor.subject.subjectId}`, - `frontier=${anchor.basisFrontierDigest}`, - `purpose=${anchor.purpose}`, - `receipt=${anchor.admittedByReceiptId}`, - `retained=${canonicalRootSet(anchor.retainedRoots).join('|')}`, - `materialization=${canonicalRootSet(anchor.materializationRoots).join('|')}`, - ].join('\n'); -} - -function canonicalRootSet(roots: readonly EchoCausalAnchorRoot[]): readonly string[] { - return roots.map(canonicalRoot).sort(); -} - -function canonicalRoot(root: EchoCausalAnchorRoot): string { - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT) { - return `${root.kind}:${root.role}:${root.id}`; - } - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT) { - return `${root.kind}:${root.role}:${root.id}`; - } - return `${root.kind}:${root.role}:${root.appId}:${root.subjectKind}:${root.id}`; -} diff --git a/src/domain/graph-rope-causal-anchor-validation.ts b/src/domain/graph-rope-causal-anchor-validation.ts index d0ab7267..be423bc4 100644 --- a/src/domain/graph-rope-causal-anchor-validation.ts +++ b/src/domain/graph-rope-causal-anchor-validation.ts @@ -1,155 +1,47 @@ import { - ECHO_CAUSAL_ANCHOR_FACT_KIND, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_INDEX, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MANIFEST, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION, - FACT_VALIDATION_ERROR_INVALID_HASH, FACT_VALIDATION_ERROR_INVALID_ID, - FACT_VALIDATION_ERROR_INVALID_KIND, FACT_VALIDATION_ERROR_INVALID_REFERENCE, - FACT_VALIDATION_ERROR_HASH_MISMATCH, - JEDIT_CAUSAL_ANCHOR_APP_ID, - JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD, - ROPE_HEAD_FACT_KIND, - type EchoCausalAnchorFact, - type EchoCausalAnchorRoot, + ROPE_CHECKPOINT_MATERIALIZATION_ROLE_INDEX, + ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MANIFEST, + ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MATERIALIZATION, type FactValidationErrorCode, - type FactValidationResult, - type RopeAdmittedFact, - type RopeCheckpointFact, - type RopeFactValidationContext, - type RopeHeadFact, + type RopeCheckpointAnchorAdmissionRequest, + type RopeCheckpointMaterializationRoot, } from './graph-rope-types.js'; -import { - causalAnchorDigestFor, - causalAnchorIdForDigest, -} from './graph-rope-causal-anchor-digest.js'; -import { - basisFrontierDigestForRopeHead, - checkpointAnchorPurpose, -} from './graph-rope-checkpoint-identity.js'; const MIN_ID_LENGTH = 1; -const VALID_ANCHOR_PURPOSES = new Set([ - 'recovery', - 'retention', - 'export', - 'user-save', - 'autosave', - 'debug', - 'cache-warm', -]); -const CAS_MATERIALIZATION_ROOT_ROLES = new Set([ - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MANIFEST, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_INDEX, +const STRING_TYPE = 'string'; +const VALID_MATERIALIZATION_ROLES = new Set([ + ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MATERIALIZATION, + ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MANIFEST, + ROPE_CHECKPOINT_MATERIALIZATION_ROLE_INDEX, ]); -const GRAPH_ROOT_ROLES = new Set([ - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_INDEX, -]); -const APP_SUBJECT_ROOT_ROLES = new Set([ - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE, -]); - -export function validateEchoCausalAnchorFact( - fact: RopeAdmittedFact, - context: RopeFactValidationContext, -): FactValidationResult { - if (fact.kind !== ECHO_CAUSAL_ANCHOR_FACT_KIND) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_KIND); - } - if (isInvalidHash(fact.anchorDigest)) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_HASH); - } - if (!VALID_ANCHOR_PURPOSES.has(fact.purpose)) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); - } - const idResult = invalidIdIn([ - fact.anchorId, - fact.subject.appId, - fact.subject.subjectKind, - fact.subject.subjectId, - fact.basisFrontierDigest, - fact.admittedByReceiptId, - ]); - if (idResult !== null) { - return invalidFact(idResult); - } - const rootIssue = validateAnchorRootSets(fact.retainedRoots, fact.materializationRoots); - if (rootIssue !== null) { - return invalidFact(rootIssue); - } - if (fact.anchorDigest !== causalAnchorDigestFor(fact, context.hash)) { - return invalidFact(FACT_VALIDATION_ERROR_HASH_MISMATCH); - } - if (fact.anchorId !== causalAnchorIdForDigest(fact.anchorDigest, context.hash)) { - return invalidFact(FACT_VALIDATION_ERROR_HASH_MISMATCH); - } - return validFact(fact); -} - -export function checkpointReferencesSameWorldline( - fact: RopeCheckpointFact, - head: RopeAdmittedFact | null, -): boolean { - return isRopeHeadFact(head) && head.worldlineId === fact.worldlineId; -} - -export function checkpointAnchorMatches( - fact: RopeCheckpointFact, - head: RopeAdmittedFact | null, - anchor: RopeAdmittedFact | null, - context: RopeFactValidationContext, -): boolean { - if (!isRopeHeadFact(head) || !isEchoCausalAnchorFact(anchor)) { - return false; - } - return anchor.subject.appId === JEDIT_CAUSAL_ANCHOR_APP_ID - && anchor.subject.subjectKind === JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE - && anchor.subject.subjectId === fact.worldlineId - && anchor.basisFrontierDigest === basisFrontierDigestForRopeHead(head, context.hash) - && anchor.purpose === checkpointAnchorPurpose(fact.reason) - && anchor.retainedRoots.some((root) => isJeditRopeHeadAuthorityRoot(root, fact.headId)); -} -function validateAnchorRootSets( - retainedRoots: readonly EchoCausalAnchorRoot[], - materializationRoots: readonly EchoCausalAnchorRoot[], +export function validateCheckpointAnchorAdmissionRequest( + request: RopeCheckpointAnchorAdmissionRequest, ): FactValidationErrorCode | null { - if (retainedRoots.length < MIN_ID_LENGTH) { - return FACT_VALIDATION_ERROR_INVALID_REFERENCE; + const idIssue = invalidIdIn([ + request.checkpointId, + request.worldlineId, + request.headId, + request.reason, + ]); + if (idIssue !== null) { + return idIssue; } - return validateRetainedAnchorRootSet(retainedRoots) ?? validateMaterializationAnchorRootSet(materializationRoots); -} - -function validateRetainedAnchorRootSet(roots: readonly EchoCausalAnchorRoot[]): FactValidationErrorCode | null { - return validateAnchorRootSet(roots, anchorRootIsUsableRetainedRoot); -} - -function validateMaterializationAnchorRootSet(roots: readonly EchoCausalAnchorRoot[]): FactValidationErrorCode | null { - return validateAnchorRootSet(roots, anchorRootIsUsableMaterializationRoot); + return validateMaterializationRoots(request.materializationRoots); } -function validateAnchorRootSet( - roots: readonly EchoCausalAnchorRoot[], - rootPolicy: (root: EchoCausalAnchorRoot) => boolean, +function validateMaterializationRoots( + roots: readonly RopeCheckpointMaterializationRoot[], ): FactValidationErrorCode | null { const seen = new Set(); for (const root of roots) { - const rootIssue = validateAnchorRoot(root); - if (rootIssue !== null || !rootPolicy(root)) { - return rootIssue ?? FACT_VALIDATION_ERROR_INVALID_REFERENCE; + const issue = validateMaterializationRoot(root); + if (issue !== null) { + return issue; } - const key = stableAnchorRootKey(root); + const key = `${root.role}:${root.id}`; if (seen.has(key)) { return FACT_VALIDATION_ERROR_INVALID_REFERENCE; } @@ -158,72 +50,26 @@ function validateAnchorRootSet( return null; } -function validateAnchorRoot(root: EchoCausalAnchorRoot): FactValidationErrorCode | null { - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT) { - return invalidIdIn([root.id]) ?? invalidRole(root.role, CAS_MATERIALIZATION_ROOT_ROLES); - } - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT) { - return invalidIdIn([root.id]) ?? invalidRole(root.role, GRAPH_ROOT_ROLES); - } - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT) { - return invalidIdIn([root.appId, root.subjectKind, root.id]) ?? invalidRole(root.role, APP_SUBJECT_ROOT_ROLES); +function validateMaterializationRoot( + root: RopeCheckpointMaterializationRoot | null | undefined, +): FactValidationErrorCode | null { + if (root == null || !isNonEmptyString(root.id)) { + return FACT_VALIDATION_ERROR_INVALID_ID; } - return FACT_VALIDATION_ERROR_INVALID_KIND; -} - -function anchorRootIsUsableRetainedRoot(_root: EchoCausalAnchorRoot): boolean { - return true; -} - -function anchorRootIsUsableMaterializationRoot(root: EchoCausalAnchorRoot): boolean { - return !anchorRootIsAuthority(root); -} - -function invalidRole(role: string, allowedRoles: ReadonlySet): FactValidationErrorCode | null { - return allowedRoles.has(role) ? null : FACT_VALIDATION_ERROR_INVALID_REFERENCE; -} - -function anchorRootIsAuthority(root: EchoCausalAnchorRoot): boolean { - return root.kind !== ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT && root.role === ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY; -} - -function stableAnchorRootKey(root: EchoCausalAnchorRoot): string { - return JSON.stringify(root, Object.keys(root).sort()); -} - -function isJeditRopeHeadAuthorityRoot(root: EchoCausalAnchorRoot, headId: string): boolean { - return root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT - && root.appId === JEDIT_CAUSAL_ANCHOR_APP_ID - && root.subjectKind === JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD - && root.id === headId - && root.role === ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY; -} - -function isRopeHeadFact(fact: RopeAdmittedFact | null): fact is RopeHeadFact { - return fact?.kind === ROPE_HEAD_FACT_KIND; -} - -function isEchoCausalAnchorFact(fact: RopeAdmittedFact | null): fact is EchoCausalAnchorFact { - return fact?.kind === ECHO_CAUSAL_ANCHOR_FACT_KIND; + return typeof root.role === STRING_TYPE && VALID_MATERIALIZATION_ROLES.has(root.role) + ? null + : FACT_VALIDATION_ERROR_INVALID_REFERENCE; } function invalidIdIn(ids: readonly string[]): FactValidationErrorCode | null { for (const id of ids) { - if (id.length < MIN_ID_LENGTH) { + if (!isNonEmptyString(id)) { return FACT_VALIDATION_ERROR_INVALID_ID; } } return null; } -function isInvalidHash(contentHash: string): boolean { - return contentHash.length < MIN_ID_LENGTH; -} - -function validFact(fact: TFact): FactValidationResult { - return { ok: true, fact }; -} - -function invalidFact(code: FactValidationErrorCode): FactValidationResult { - return { ok: false, code }; +function isNonEmptyString(value: string): boolean { + return typeof value === STRING_TYPE && value.length >= MIN_ID_LENGTH; } diff --git a/src/domain/graph-rope-checkpoint-identity.ts b/src/domain/graph-rope-checkpoint-identity.ts index 47f2fe1c..945c51d9 100644 --- a/src/domain/graph-rope-checkpoint-identity.ts +++ b/src/domain/graph-rope-checkpoint-identity.ts @@ -1,57 +1,45 @@ import { - ROPE_CHECKPOINT_REASON_AUTOSAVE, - ROPE_CHECKPOINT_REASON_EXPORT, - ROPE_CHECKPOINT_REASON_IMPORT, - ROPE_CHECKPOINT_REASON_MANUAL_SAVE, - ROPE_CHECKPOINT_REASON_RETENTION_BOUNDARY, - ROPE_CHECKPOINT_REASON_TEST_FIXTURE, - type EchoCausalAnchorPurpose, type RopeCheckpointReason, - type RopeHeadFact, type TextBlobHashPort, } from './graph-rope-types.js'; -const RUNTIME_HASH_PREFIX_CAUSAL_FRONTIER = 'causal-frontier:'; const RUNTIME_HASH_PREFIX_CHECKPOINT_ID = 'rope-checkpoint:'; +const RUNTIME_HASH_PREFIX_CHECKPOINT_ANCHOR_ASSOCIATION_ID = 'rope-checkpoint-anchor:'; export interface RopeCheckpointIdInput { readonly worldlineId: string; readonly headId: string; readonly reason: RopeCheckpointReason; - readonly causalAnchorId: string; - readonly admittedByReceiptId: string; readonly hash: TextBlobHashPort; } -export function basisFrontierDigestForRopeHead(head: RopeHeadFact, hash: TextBlobHashPort): string { - return hash.sha256Hex( - `${RUNTIME_HASH_PREFIX_CAUSAL_FRONTIER}${head.worldlineId}:${head.headId}:${head.contentHash}`, - ); +export interface RopeCheckpointAnchorAssociationIdInput { + readonly checkpointId: string; + readonly causalAnchorId: string; + readonly causalAnchorFactId: string; + readonly causalAnchorReceiptId: string; + readonly hash: TextBlobHashPort; } export function ropeCheckpointIdFor(input: RopeCheckpointIdInput): string { - return `${RUNTIME_HASH_PREFIX_CHECKPOINT_ID}${input.hash.sha256Hex([ + return `${RUNTIME_HASH_PREFIX_CHECKPOINT_ID}${input.hash.sha256Hex(identityTuple([ input.worldlineId, input.headId, input.reason, + ]))}`; +} + +export function ropeCheckpointAnchorAssociationIdFor( + input: RopeCheckpointAnchorAssociationIdInput, +): string { + return `${RUNTIME_HASH_PREFIX_CHECKPOINT_ANCHOR_ASSOCIATION_ID}${input.hash.sha256Hex(identityTuple([ + input.checkpointId, input.causalAnchorId, - input.admittedByReceiptId, - ].join(':'))}`; + input.causalAnchorFactId, + input.causalAnchorReceiptId, + ]))}`; } -export function checkpointAnchorPurpose(reason: RopeCheckpointReason): EchoCausalAnchorPurpose { - switch (reason) { - case ROPE_CHECKPOINT_REASON_MANUAL_SAVE: - return 'user-save'; - case ROPE_CHECKPOINT_REASON_AUTOSAVE: - return 'autosave'; - case ROPE_CHECKPOINT_REASON_RETENTION_BOUNDARY: - return 'retention'; - case ROPE_CHECKPOINT_REASON_EXPORT: - return 'export'; - case ROPE_CHECKPOINT_REASON_IMPORT: - return 'recovery'; - case ROPE_CHECKPOINT_REASON_TEST_FIXTURE: - return 'debug'; - } +function identityTuple(fields: readonly string[]): string { + return JSON.stringify(fields); } diff --git a/src/domain/graph-rope-checkpoint-validation.ts b/src/domain/graph-rope-checkpoint-validation.ts new file mode 100644 index 00000000..eccd8051 --- /dev/null +++ b/src/domain/graph-rope-checkpoint-validation.ts @@ -0,0 +1,122 @@ +import { ropeFactId } from './graph-rope-fact-id.js'; +import { + ropeCheckpointAnchorAssociationIdFor, + ropeCheckpointIdFor, +} from './graph-rope-checkpoint-identity.js'; +import { + BUFFER_WORLDLINE_FACT_KIND, + FACT_VALIDATION_ERROR_HASH_MISMATCH, + FACT_VALIDATION_ERROR_INVALID_ID, + FACT_VALIDATION_ERROR_INVALID_KIND, + FACT_VALIDATION_ERROR_INVALID_REFERENCE, + ROPE_CHECKPOINT_ANCHORED_FACT_KIND, + ROPE_CHECKPOINT_FACT_KIND, + ROPE_CHECKPOINT_REASONS, + ROPE_HEAD_FACT_KIND, + type FactValidationErrorCode, + type FactValidationResult, + type RopeAdmittedFact, + type RopeCheckpointReason, + type RopeFactValidationContext, +} from './graph-rope-types.js'; + +const MIN_ID_LENGTH = 1; +const STRING_TYPE = 'string'; +const VALID_CHECKPOINT_REASONS = new Set(ROPE_CHECKPOINT_REASONS); + +export function validateRopeCheckpointFact( + fact: RopeAdmittedFact, + context: RopeFactValidationContext, +): FactValidationResult { + if (fact.kind !== ROPE_CHECKPOINT_FACT_KIND) { + return invalidFact(FACT_VALIDATION_ERROR_INVALID_KIND); + } + const idIssue = invalidIdIn([fact.checkpointId, fact.worldlineId, fact.headId, fact.reason]); + if (idIssue !== null) { + return invalidFact(idIssue); + } + if (!isRopeCheckpointReason(fact.reason)) { + return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); + } + const worldline = resolveFactById(context, fact.worldlineId); + const head = resolveFactById(context, fact.headId); + if (worldline?.kind !== BUFFER_WORLDLINE_FACT_KIND + || head?.kind !== ROPE_HEAD_FACT_KIND + || head.worldlineId !== fact.worldlineId) { + return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); + } + const expectedId = ropeCheckpointIdFor({ + worldlineId: fact.worldlineId, + headId: fact.headId, + reason: fact.reason, + hash: context.hash, + }); + return fact.checkpointId === expectedId + ? validFact(fact) + : invalidFact(FACT_VALIDATION_ERROR_HASH_MISMATCH); +} + +export function isRopeCheckpointReason(reason: RopeCheckpointReason): boolean { + return typeof reason === STRING_TYPE && VALID_CHECKPOINT_REASONS.has(reason); +} + +export function validateRopeCheckpointAnchoredFact( + fact: RopeAdmittedFact, + context: RopeFactValidationContext, +): FactValidationResult { + if (fact.kind !== ROPE_CHECKPOINT_ANCHORED_FACT_KIND) { + return invalidFact(FACT_VALIDATION_ERROR_INVALID_KIND); + } + const idIssue = invalidIdIn([ + fact.associationId, + fact.checkpointId, + fact.causalAnchorId, + fact.causalAnchorFactId, + fact.causalAnchorReceiptId, + ]); + if (idIssue !== null) { + return invalidFact(idIssue); + } + if (resolveFactById(context, fact.checkpointId)?.kind !== ROPE_CHECKPOINT_FACT_KIND) { + return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); + } + const expectedId = ropeCheckpointAnchorAssociationIdFor({ + checkpointId: fact.checkpointId, + causalAnchorId: fact.causalAnchorId, + causalAnchorFactId: fact.causalAnchorFactId, + causalAnchorReceiptId: fact.causalAnchorReceiptId, + hash: context.hash, + }); + return fact.associationId === expectedId + ? validFact(fact) + : invalidFact(FACT_VALIDATION_ERROR_HASH_MISMATCH); +} + +function resolveFactById( + context: RopeFactValidationContext, + id: string, +): RopeAdmittedFact | null { + for (const fact of context.writeSet) { + if (ropeFactId(fact) === id) { + return fact; + } + } + return context.admittedBasis.getFact(id); +} + +function invalidIdIn(ids: readonly string[]): FactValidationErrorCode | null { + for (const id of ids) { + if (typeof id !== STRING_TYPE || id.length < MIN_ID_LENGTH) { + return FACT_VALIDATION_ERROR_INVALID_ID; + } + } + return null; +} + +function validFact(fact: TFact): FactValidationResult { + return { ok: true, fact }; +} + +function invalidFact(code: FactValidationErrorCode): FactValidationResult { + return { ok: false, code }; +} diff --git a/src/domain/graph-rope-fact-id.ts b/src/domain/graph-rope-fact-id.ts index bae4b581..79c53cfe 100644 --- a/src/domain/graph-rope-fact-id.ts +++ b/src/domain/graph-rope-fact-id.ts @@ -1,7 +1,7 @@ import { BUFFER_WORLDLINE_FACT_KIND, - ECHO_CAUSAL_ANCHOR_FACT_KIND, ROPE_BRANCH_FACT_KIND, + ROPE_CHECKPOINT_ANCHORED_FACT_KIND, ROPE_CHECKPOINT_FACT_KIND, ROPE_DIFF_FACT_KIND, ROPE_HEAD_FACT_KIND, @@ -26,7 +26,7 @@ const ROPE_FACT_ID_READERS: ReadonlyMap = new Map([ [TICK_RECEIPT_FACT_KIND, tickReceiptFactId], [ROPE_STRUCTURAL_MAINTENANCE_FACT_KIND, structuralMaintenanceFactId], [ROPE_CHECKPOINT_FACT_KIND, ropeCheckpointFactId], - [ECHO_CAUSAL_ANCHOR_FACT_KIND, echoCausalAnchorFactId], + [ROPE_CHECKPOINT_ANCHORED_FACT_KIND, ropeCheckpointAnchoredFactId], ]); export function ropeFactId(fact: RopeAdmittedFact): string { @@ -76,6 +76,6 @@ function ropeCheckpointFactId(fact: RopeAdmittedFact): string { return fact.kind === ROPE_CHECKPOINT_FACT_KIND ? fact.checkpointId : ''; } -function echoCausalAnchorFactId(fact: RopeAdmittedFact): string { - return fact.kind === ECHO_CAUSAL_ANCHOR_FACT_KIND ? fact.anchorId : ''; +function ropeCheckpointAnchoredFactId(fact: RopeAdmittedFact): string { + return fact.kind === ROPE_CHECKPOINT_ANCHORED_FACT_KIND ? fact.associationId : ''; } diff --git a/src/domain/graph-rope-runtime-checkpoint.ts b/src/domain/graph-rope-runtime-checkpoint.ts index eb9c72d0..3f23b2a6 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -1,32 +1,21 @@ import { - ECHO_CAUSAL_ANCHOR_FACT_KIND, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT, - ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, GRAPH_ROPE_SCHEMA_VERSION, - JEDIT_CAUSAL_ANCHOR_APP_ID, - JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD, + ROPE_CHECKPOINT_ANCHORED_FACT_KIND, ROPE_CHECKPOINT_FACT_KIND, - type EchoCausalAnchorAppSubjectRoot, - type EchoCausalAnchorFact, - type EchoCausalAnchorPurpose, + type EchoCausalAnchorAdmissionEvidence, + type RopeCheckpointAnchorAdmissionRequest, + type RopeCheckpointAnchoredFact, type RopeCheckpointFact, + type RopeCheckpointMaterializationRoot, type RopeCheckpointReason, type RopeHeadFact, type TextBlobHashPort, } from './graph-rope-contract.js'; import { - causalAnchorDigestFor, - causalAnchorIdForDigest, -} from './graph-rope-causal-anchor-digest.js'; -import { - basisFrontierDigestForRopeHead, - checkpointAnchorPurpose, + ropeCheckpointAnchorAssociationIdFor, ropeCheckpointIdFor, } from './graph-rope-checkpoint-identity.js'; -const RUNTIME_HASH_PREFIX_CAUSAL_ANCHOR_RECEIPT = 'causal-anchor-receipt:'; - export interface GraphRopeCreateCheckpointInput { readonly worldlineId: string; readonly headId: string; @@ -35,83 +24,82 @@ export interface GraphRopeCreateCheckpointInput { export interface GraphRopeCreateCheckpointResult { readonly head: RopeHeadFact; - readonly causalAnchor: EchoCausalAnchorFact; readonly checkpoint: RopeCheckpointFact; } -export function createCheckpointFacts( +export interface GraphRopeAnchorCheckpointInput { + readonly checkpointId: string; + readonly materializationRoots?: readonly RopeCheckpointMaterializationRoot[]; +} + +export interface GraphRopeAnchorCheckpointResult { + readonly head: RopeHeadFact; + readonly checkpoint: RopeCheckpointFact; + readonly echoEvidence: EchoCausalAnchorAdmissionEvidence; + readonly association: RopeCheckpointAnchoredFact; +} + +export function createCheckpointFact( head: RopeHeadFact, reason: RopeCheckpointReason, hash: TextBlobHashPort, - admissionSequence: number, -): GraphRopeCreateCheckpointResult { - const causalAnchor = causalAnchorForCheckpoint(head, reason, hash, admissionSequence); - const checkpoint: RopeCheckpointFact = { +): RopeCheckpointFact { + return { kind: ROPE_CHECKPOINT_FACT_KIND, schemaVersion: GRAPH_ROPE_SCHEMA_VERSION, checkpointId: ropeCheckpointIdFor({ worldlineId: head.worldlineId, headId: head.headId, reason, - causalAnchorId: causalAnchor.anchorId, - admittedByReceiptId: causalAnchor.admittedByReceiptId, hash, }), worldlineId: head.worldlineId, headId: head.headId, - causalAnchorId: causalAnchor.anchorId, reason, }; - return { head, causalAnchor, checkpoint }; } -function causalAnchorForCheckpoint( - head: RopeHeadFact, - reason: RopeCheckpointReason, - hash: TextBlobHashPort, - admissionSequence: number, -): EchoCausalAnchorFact { - const purpose = checkpointAnchorPurpose(reason); - const retainedRoot = retainedRopeHeadRoot(head.headId); - const basisFrontierDigest = basisFrontierDigestForRopeHead(head, hash); - const admittedByReceiptId = anchorReceiptIdFor(head, purpose, admissionSequence, hash); - const anchor: Omit = { - kind: ECHO_CAUSAL_ANCHOR_FACT_KIND, - schemaVersion: GRAPH_ROPE_SCHEMA_VERSION, - subject: { - appId: JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - subjectId: head.worldlineId, - }, - basisFrontierDigest, - retainedRoots: [retainedRoot], - materializationRoots: [], - purpose, - admittedByReceiptId, - }; - const anchorDigest = causalAnchorDigestFor(anchor, hash); - return { - ...anchor, - anchorId: causalAnchorIdForDigest(anchorDigest, hash), - anchorDigest, - }; +export function createCheckpointAnchorAdmissionRequest( + checkpoint: RopeCheckpointFact, + materializationRoots: readonly RopeCheckpointMaterializationRoot[] = [], +): RopeCheckpointAnchorAdmissionRequest | null { + if (!Array.isArray(materializationRoots)) { + return null; + } + try { + return { + checkpointId: checkpoint.checkpointId, + worldlineId: checkpoint.worldlineId, + headId: checkpoint.headId, + reason: checkpoint.reason, + materializationRoots: Array.from(materializationRoots, (root) => ({ + id: root.id, + role: root.role, + })), + }; + } catch { + return null; + } } -function retainedRopeHeadRoot(headId: string): EchoCausalAnchorAppSubjectRoot { +export function createCheckpointAnchorAssociation( + checkpoint: RopeCheckpointFact, + evidence: EchoCausalAnchorAdmissionEvidence, + hash: TextBlobHashPort, +): RopeCheckpointAnchoredFact { return { - kind: ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT, - appId: JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD, - id: headId, - role: ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, + kind: ROPE_CHECKPOINT_ANCHORED_FACT_KIND, + schemaVersion: GRAPH_ROPE_SCHEMA_VERSION, + associationId: ropeCheckpointAnchorAssociationIdFor({ + checkpointId: checkpoint.checkpointId, + causalAnchorId: evidence.anchorId, + causalAnchorFactId: evidence.anchorFactId, + causalAnchorReceiptId: evidence.receiptId, + hash, + }), + checkpointId: checkpoint.checkpointId, + causalAnchorId: evidence.anchorId, + causalAnchorFactId: evidence.anchorFactId, + causalAnchorReceiptId: evidence.receiptId, }; } - -function anchorReceiptIdFor( - head: RopeHeadFact, - purpose: EchoCausalAnchorPurpose, - admissionSequence: number, - hash: TextBlobHashPort, -): string { - return hash.sha256Hex(`${RUNTIME_HASH_PREFIX_CAUSAL_ANCHOR_RECEIPT}${head.worldlineId}:${head.headId}:${purpose}:${String(admissionSequence)}`); -} diff --git a/src/domain/graph-rope-runtime-echo-adapter.ts b/src/domain/graph-rope-runtime-echo-adapter.ts new file mode 100644 index 00000000..28a71e5d --- /dev/null +++ b/src/domain/graph-rope-runtime-echo-adapter.ts @@ -0,0 +1,63 @@ +import { + type EchoCausalAnchorAdmissionEvidence, + type EchoCausalAnchorAdmissionPort, + type RopeCheckpointAnchorAdmissionRequest, +} from './graph-rope-types.js'; + +const STRING_TYPE = 'string'; +const MIN_ID_LENGTH = 1; + +export function requestCheckpointAnchorAdmission( + port: EchoCausalAnchorAdmissionPort, + request: RopeCheckpointAnchorAdmissionRequest, +): EchoCausalAnchorAdmissionEvidence | null { + try { + const result = port.admitCheckpointAnchor(cloneRequest(request)); + if (result == null || result.ok !== true) { + return null; + } + return snapshotEvidence(result.evidence); + } catch { + return null; + } +} + +function cloneRequest(request: RopeCheckpointAnchorAdmissionRequest): RopeCheckpointAnchorAdmissionRequest { + return { + checkpointId: request.checkpointId, + worldlineId: request.worldlineId, + headId: request.headId, + reason: request.reason, + materializationRoots: request.materializationRoots.map((root) => ({ + id: root.id, + role: root.role, + })), + }; +} + +function snapshotEvidence( + evidence: EchoCausalAnchorAdmissionEvidence | null | undefined, +): EchoCausalAnchorAdmissionEvidence | null { + if (evidence == null) { + return null; + } + const snapshot = { + anchorId: evidence.anchorId, + anchorFactId: evidence.anchorFactId, + receiptId: evidence.receiptId, + }; + return isUsableEchoEvidence(snapshot) ? snapshot : null; +} + +function isUsableEchoEvidence( + evidence: EchoCausalAnchorAdmissionEvidence | null | undefined, +): evidence is EchoCausalAnchorAdmissionEvidence { + return evidence != null + && isNonEmptyString(evidence.anchorId) + && isNonEmptyString(evidence.anchorFactId) + && isNonEmptyString(evidence.receiptId); +} + +function isNonEmptyString(value: string): boolean { + return typeof value === STRING_TYPE && value.length >= MIN_ID_LENGTH; +} diff --git a/src/domain/graph-rope-runtime-issues.ts b/src/domain/graph-rope-runtime-issues.ts index c0d5ad2c..84a1e9d8 100644 --- a/src/domain/graph-rope-runtime-issues.ts +++ b/src/domain/graph-rope-runtime-issues.ts @@ -2,14 +2,20 @@ export const GRAPH_ROPE_TEXT_WINDOW_CACHE_STATUS_UNCACHED = 'uncached-materializ export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD = 'missing-head'; export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_NODE = 'missing-node'; export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_BLOB = 'missing-blob'; +export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_CHECKPOINT = 'missing-checkpoint'; export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_BYTE_RANGE = 'invalid-byte-range'; export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_UTF8_BOUNDARY = 'invalid-utf8-boundary'; export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT = 'invalid-fact'; +export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE = 'causal-anchor-unavailable'; +export const GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED = 'causal-anchor-admission-failed'; export type GraphRopeRuntimeObstructionCode = | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_NODE | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_BLOB + | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_CHECKPOINT | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_BYTE_RANGE | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_UTF8_BOUNDARY - | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT; + | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT + | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE + | typeof GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED; diff --git a/src/domain/graph-rope-runtime.ts b/src/domain/graph-rope-runtime.ts index 9646113a..6823146e 100644 --- a/src/domain/graph-rope-runtime.ts +++ b/src/domain/graph-rope-runtime.ts @@ -1,12 +1,15 @@ import { BUFFER_WORLDLINE_FACT_KIND, GRAPH_ROPE_SCHEMA_VERSION, + ROPE_CHECKPOINT_FACT_KIND, ROPE_HEAD_FACT_KIND, makeTextBlobFact, ropeFactId, validateRopeFact, type BufferWorldlineFact, + type EchoCausalAnchorAdmissionPort, type RopeAdmittedFact, + type RopeCheckpointFact, type RopeDiffFact, type RopeFactValidationContext, type RopeHeadFact, @@ -16,14 +19,24 @@ import { type TextByteRange, type TickReceiptFact, } from './graph-rope-contract.js'; +import { validateCheckpointAnchorAdmissionRequest } from './graph-rope-causal-anchor-validation.js'; +import { isRopeCheckpointReason } from './graph-rope-checkpoint-validation.js'; import { - createCheckpointFacts, + createCheckpointAnchorAdmissionRequest, + createCheckpointAnchorAssociation, + createCheckpointFact, + type GraphRopeAnchorCheckpointInput, + type GraphRopeAnchorCheckpointResult, type GraphRopeCreateCheckpointInput, type GraphRopeCreateCheckpointResult, } from './graph-rope-runtime-checkpoint.js'; +import { requestCheckpointAnchorAdmission } from './graph-rope-runtime-echo-adapter.js'; import { + GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED, + GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE, GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT, GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_UTF8_BOUNDARY, + GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_CHECKPOINT, GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD, GRAPH_ROPE_TEXT_WINDOW_CACHE_STATUS_UNCACHED, type GraphRopeRuntimeObstructionCode, @@ -45,12 +58,17 @@ export { GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT, GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_UTF8_BOUNDARY, GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_BLOB, + GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_CHECKPOINT, GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD, GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_NODE, + GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED, + GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE, GRAPH_ROPE_TEXT_WINDOW_CACHE_STATUS_UNCACHED, } from './graph-rope-runtime-issues.js'; export type { GraphRopeRuntimeObstructionCode } from './graph-rope-runtime-issues.js'; export type { + GraphRopeAnchorCheckpointInput, + GraphRopeAnchorCheckpointResult, GraphRopeCreateCheckpointInput, GraphRopeCreateCheckpointResult, } from './graph-rope-runtime-checkpoint.js'; @@ -66,6 +84,7 @@ export type GraphRopeRuntimeResult = export interface CreateGraphRopeRuntimeInput { readonly hash: TextBlobHashPort; + readonly causalAnchorAdmission?: EchoCausalAnchorAdmissionPort; } export interface CreateBufferWorldlineInput { @@ -140,12 +159,14 @@ export interface GraphRopeRuntime { createBufferWorldline(input: CreateBufferWorldlineInput): GraphRopeRuntimeResult; replaceRangeAsTick(input: GraphRopeReplaceRangeInput): GraphRopeRuntimeResult; createCheckpoint(input: GraphRopeCreateCheckpointInput): GraphRopeRuntimeResult; + anchorCheckpoint(input: GraphRopeAnchorCheckpointInput): GraphRopeRuntimeResult; textWindow(input: GraphRopeTextWindowInput): GraphRopeRuntimeResult; debugRopeShape(headId: string): GraphRopeRuntimeResult; } interface GraphRopeRuntimeState extends GraphRopeRuntimeFactReader { readonly hash: TextBlobHashPort; + readonly causalAnchorAdmission: EchoCausalAnchorAdmissionPort | null; readonly factsById: Map; readonly currentHeadByWorldlineId: Map; nextAdmissionSequence: number; @@ -159,6 +180,7 @@ export function createGraphRopeRuntime(input: CreateGraphRopeRuntimeInput): Grap const factsById = new Map(); const state: GraphRopeRuntimeState = { hash: input.hash, + causalAnchorAdmission: input.causalAnchorAdmission ?? null, factsById, currentHeadByWorldlineId: new Map(), nextAdmissionSequence: INITIAL_ADMISSION_SEQUENCE, @@ -177,6 +199,9 @@ export function createGraphRopeRuntime(input: CreateGraphRopeRuntimeInput): Grap createCheckpoint(checkpointInput) { return createCheckpoint(state, checkpointInput); }, + anchorCheckpoint(anchorInput) { + return anchorCheckpoint(state, anchorInput); + }, textWindow(readInput) { return textWindow(state, readInput); }, @@ -230,6 +255,9 @@ function createCheckpoint( state: GraphRopeRuntimeState, input: GraphRopeCreateCheckpointInput, ): GraphRopeRuntimeResult { + if (!isRopeCheckpointReason(input.reason)) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; + } const head = headById(state, input.headId); if (head === null) { return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD }; @@ -237,13 +265,46 @@ function createCheckpoint( if (head.worldlineId !== input.worldlineId) { return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; } - const checkpointFacts = createCheckpointFacts(head, input.reason, state.hash, state.nextAdmissionSequence); - const admissionIssue = admitFacts(state, [checkpointFacts.causalAnchor, checkpointFacts.checkpoint]); + const checkpoint = createCheckpointFact(head, input.reason, state.hash); + const admissionIssue = admitFacts(state, [checkpoint]); if (admissionIssue !== null) { return { ok: false, code: admissionIssue }; } - state.nextAdmissionSequence += ONE_VALUE; - return { ok: true, value: cloneCheckpointResult(checkpointFacts) }; + return { ok: true, value: cloneCheckpointResult({ head, checkpoint }) }; +} + +function anchorCheckpoint( + state: GraphRopeRuntimeState, + input: GraphRopeAnchorCheckpointInput, +): GraphRopeRuntimeResult { + const checkpoint = checkpointById(state, input.checkpointId); + if (checkpoint === null) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_CHECKPOINT }; + } + const head = headById(state, checkpoint.headId); + if (head === null) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD }; + } + const request = createCheckpointAnchorAdmissionRequest(checkpoint, input.materializationRoots); + if (request === null || validateCheckpointAnchorAdmissionRequest(request) !== null) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; + } + if (state.causalAnchorAdmission === null) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE }; + } + const evidence = requestCheckpointAnchorAdmission(state.causalAnchorAdmission, request); + if (evidence === null) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_CAUSAL_ANCHOR_ADMISSION_FAILED }; + } + const association = createCheckpointAnchorAssociation(checkpoint, evidence, state.hash); + const admissionIssue = admitFacts(state, [association]); + if (admissionIssue !== null) { + return { ok: false, code: admissionIssue }; + } + return { + ok: true, + value: cloneAnchorCheckpointResult({ head, checkpoint, echoEvidence: evidence, association }), + }; } function textWindow( @@ -372,11 +433,19 @@ function cloneReplaceResult(result: GraphRopeReplaceRangeResult): GraphRopeRepla function cloneCheckpointResult(result: GraphRopeCreateCheckpointResult): GraphRopeCreateCheckpointResult { return { head: cloneFact(result.head), - causalAnchor: cloneFact(result.causalAnchor), checkpoint: cloneFact(result.checkpoint), }; } +function cloneAnchorCheckpointResult(result: GraphRopeAnchorCheckpointResult): GraphRopeAnchorCheckpointResult { + return { + head: cloneFact(result.head), + checkpoint: cloneFact(result.checkpoint), + echoEvidence: cloneFact(result.echoEvidence), + association: cloneFact(result.association), + }; +} + function cloneFact(fact: TFact): TFact { return structuredClone(fact); } @@ -410,6 +479,11 @@ function headById(state: GraphRopeRuntimeState, headId: string): RopeHeadFact | return fact?.kind === ROPE_HEAD_FACT_KIND ? fact : null; } +function checkpointById(state: GraphRopeRuntimeState, checkpointId: string): RopeCheckpointFact | null { + const fact = state.factsById.get(checkpointId); + return fact?.kind === ROPE_CHECKPOINT_FACT_KIND ? fact : null; +} + function tickIdFor(worldlineId: string, contentHash: string, hash: TextBlobHashPort): string { return `${RUNTIME_HASH_PREFIX_TICK}${hash.sha256Hex(`${worldlineId}:${contentHash}`)}`; } diff --git a/src/domain/graph-rope-types.ts b/src/domain/graph-rope-types.ts index aa6397c8..b3b5d91e 100644 --- a/src/domain/graph-rope-types.ts +++ b/src/domain/graph-rope-types.ts @@ -19,20 +19,10 @@ export const ROPE_DIFF_FACT_KIND = 'jedit.text.RopeDiff'; export const TICK_RECEIPT_FACT_KIND = 'jedit.text.TickReceipt'; export const ROPE_STRUCTURAL_MAINTENANCE_FACT_KIND = 'jedit.text.RopeStructuralMaintenance'; export const ROPE_CHECKPOINT_FACT_KIND = 'jedit.text.RopeCheckpoint'; -export const ECHO_CAUSAL_ANCHOR_FACT_KIND = 'echo.causal.Anchor'; - -export const JEDIT_CAUSAL_ANCHOR_APP_ID = 'jedit'; -export const JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE = 'BufferWorldline'; -export const JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD = 'RopeHead'; - -export const ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT = 'CasObject'; -export const ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT = 'GraphFact'; -export const ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT = 'AppSubjectRoot'; -export const ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY = 'authority'; -export const ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE = 'evidence'; -export const ECHO_CAUSAL_ANCHOR_ROOT_ROLE_INDEX = 'index'; -export const ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION = 'materialization'; -export const ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MANIFEST = 'manifest'; +export const ROPE_CHECKPOINT_ANCHORED_FACT_KIND = 'jedit.text.RopeCheckpointAnchored'; +export const ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MATERIALIZATION = 'materialization'; +export const ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MANIFEST = 'manifest'; +export const ROPE_CHECKPOINT_MATERIALIZATION_ROLE_INDEX = 'index'; export const ROPE_DIFF_SPAN_EQUAL_KIND = 'equal'; export const ROPE_DIFF_SPAN_DELETE_KIND = 'delete'; @@ -259,6 +249,14 @@ export const ROPE_CHECKPOINT_REASON_RETENTION_BOUNDARY = 'retention-boundary'; export const ROPE_CHECKPOINT_REASON_EXPORT = 'export'; export const ROPE_CHECKPOINT_REASON_IMPORT = 'import'; export const ROPE_CHECKPOINT_REASON_TEST_FIXTURE = 'test-fixture'; +export const ROPE_CHECKPOINT_REASONS: readonly RopeCheckpointReason[] = Object.freeze([ + ROPE_CHECKPOINT_REASON_MANUAL_SAVE, + ROPE_CHECKPOINT_REASON_AUTOSAVE, + ROPE_CHECKPOINT_REASON_RETENTION_BOUNDARY, + ROPE_CHECKPOINT_REASON_EXPORT, + ROPE_CHECKPOINT_REASON_IMPORT, + ROPE_CHECKPOINT_REASON_TEST_FIXTURE, +]); export interface RopeCheckpointFact { readonly kind: typeof ROPE_CHECKPOINT_FACT_KIND; @@ -266,69 +264,49 @@ export interface RopeCheckpointFact { readonly checkpointId: string; readonly worldlineId: string; readonly headId: string; - readonly causalAnchorId: string; readonly reason: RopeCheckpointReason; } -export type EchoCausalAnchorPurpose = - | 'recovery' - | 'retention' - | 'export' - | 'user-save' - | 'autosave' - | 'debug' - | 'cache-warm'; - -export interface EchoCausalAnchorSubject { - readonly appId: string; - readonly subjectKind: string; - readonly subjectId: string; -} +export type RopeCheckpointMaterializationRole = + | typeof ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MATERIALIZATION + | typeof ROPE_CHECKPOINT_MATERIALIZATION_ROLE_MANIFEST + | typeof ROPE_CHECKPOINT_MATERIALIZATION_ROLE_INDEX; -export interface EchoCausalAnchorCasObjectRoot { - readonly kind: typeof ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT; +export interface RopeCheckpointMaterializationRoot { readonly id: string; - readonly role: - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MANIFEST - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_INDEX; + readonly role: RopeCheckpointMaterializationRole; } -export interface EchoCausalAnchorGraphFactRoot { - readonly kind: typeof ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT; - readonly id: string; - readonly role: - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_INDEX; +export interface RopeCheckpointAnchorAdmissionRequest { + readonly checkpointId: string; + readonly worldlineId: string; + readonly headId: string; + readonly reason: RopeCheckpointReason; + readonly materializationRoots: readonly RopeCheckpointMaterializationRoot[]; } -export interface EchoCausalAnchorAppSubjectRoot { - readonly kind: typeof ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT; - readonly appId: string; - readonly subjectKind: string; - readonly id: string; - readonly role: - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY - | typeof ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE; +export interface EchoCausalAnchorAdmissionEvidence { + readonly anchorId: string; + readonly anchorFactId: string; + readonly receiptId: string; } -export type EchoCausalAnchorRoot = - | EchoCausalAnchorCasObjectRoot - | EchoCausalAnchorGraphFactRoot - | EchoCausalAnchorAppSubjectRoot; +export type EchoCausalAnchorAdmissionResult = + | { readonly ok: true; readonly evidence: EchoCausalAnchorAdmissionEvidence } + | { readonly ok: false; readonly obstructionId: string }; + +export interface EchoCausalAnchorAdmissionPort { + admitCheckpointAnchor(request: RopeCheckpointAnchorAdmissionRequest): EchoCausalAnchorAdmissionResult; +} -export interface EchoCausalAnchorFact { - readonly kind: typeof ECHO_CAUSAL_ANCHOR_FACT_KIND; +export interface RopeCheckpointAnchoredFact { + readonly kind: typeof ROPE_CHECKPOINT_ANCHORED_FACT_KIND; readonly schemaVersion: typeof GRAPH_ROPE_SCHEMA_VERSION; - readonly anchorId: string; - readonly subject: EchoCausalAnchorSubject; - readonly basisFrontierDigest: string; - readonly retainedRoots: readonly EchoCausalAnchorRoot[]; - readonly materializationRoots: readonly EchoCausalAnchorRoot[]; - readonly purpose: EchoCausalAnchorPurpose; - readonly admittedByReceiptId: string; - readonly anchorDigest: string; + readonly associationId: string; + readonly checkpointId: string; + readonly causalAnchorId: string; + readonly causalAnchorFactId: string; + readonly causalAnchorReceiptId: string; } export type RopeAdmittedFact = @@ -342,7 +320,7 @@ export type RopeAdmittedFact = | TickReceiptFact | RopeStructuralMaintenanceFact | RopeCheckpointFact - | EchoCausalAnchorFact; + | RopeCheckpointAnchoredFact; export interface RopeFactReadModel { getFact(id: string): RopeAdmittedFact | null; diff --git a/src/domain/graph-rope-validation.ts b/src/domain/graph-rope-validation.ts index caa1fb36..b0acdc0e 100644 --- a/src/domain/graph-rope-validation.ts +++ b/src/domain/graph-rope-validation.ts @@ -1,9 +1,8 @@ import { ropeFactId } from './graph-rope-fact-id.js'; import { - checkpointAnchorMatches, - checkpointReferencesSameWorldline, - validateEchoCausalAnchorFact, -} from './graph-rope-causal-anchor-validation.js'; + validateRopeCheckpointAnchoredFact, + validateRopeCheckpointFact, +} from './graph-rope-checkpoint-validation.js'; import { validateDiffSpanHash, validateRopeBranchConsistency, @@ -16,14 +15,9 @@ import { import { validateTextBlobFact, } from './graph-rope-text-blob-validation.js'; -import { - ropeCheckpointIdFor, -} from './graph-rope-checkpoint-identity.js'; import { BUFFER_WORLDLINE_FACT_KIND, BYTE_OFFSET_COORDINATE_KIND, - ECHO_CAUSAL_ANCHOR_FACT_KIND, - FACT_VALIDATION_ERROR_HASH_MISMATCH, FACT_VALIDATION_ERROR_INVALID_HASH, FACT_VALIDATION_ERROR_INVALID_ID, FACT_VALIDATION_ERROR_INVALID_KIND, @@ -32,6 +26,7 @@ import { FACT_VALIDATION_ERROR_INVALID_SCHEMA_VERSION, GRAPH_ROPE_SCHEMA_VERSION, ROPE_BRANCH_FACT_KIND, + ROPE_CHECKPOINT_ANCHORED_FACT_KIND, ROPE_CHECKPOINT_FACT_KIND, ROPE_DIFF_FACT_KIND, ROPE_DIFF_SPAN_DELETE_KIND, @@ -44,7 +39,6 @@ import { TEXT_BLOB_FACT_KIND, TICK_RECEIPT_FACT_KIND, type BufferWorldlineFact, - type EchoCausalAnchorFact, type FactValidationErrorCode, type FactValidationResult, type RopeAdmittedFact, @@ -73,7 +67,7 @@ const ROPE_FACT_VALIDATORS: ReadonlyMap = new Map([ [TICK_RECEIPT_FACT_KIND, validateTickReceiptFact], [ROPE_STRUCTURAL_MAINTENANCE_FACT_KIND, validateStructuralMaintenanceFact], [ROPE_CHECKPOINT_FACT_KIND, validateRopeCheckpointFact], - [ECHO_CAUSAL_ANCHOR_FACT_KIND, validateEchoCausalAnchorFact], + [ROPE_CHECKPOINT_ANCHORED_FACT_KIND, validateRopeCheckpointAnchoredFact], ]); export function validateRopeFact( @@ -263,32 +257,6 @@ function validateStructuralMaintenanceFact( return validateIdsAfterReferences(refResult, fact, ids); } -function validateRopeCheckpointFact( - fact: RopeAdmittedFact, - context: RopeFactValidationContext, -): FactValidationResult { - if (fact.kind !== ROPE_CHECKPOINT_FACT_KIND) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_KIND); - } - const refResult = requireReferences(context, [ - [fact.worldlineId, BUFFER_WORLDLINE_FACT_KIND], - [fact.headId, ROPE_HEAD_FACT_KIND], - [fact.causalAnchorId, ECHO_CAUSAL_ANCHOR_FACT_KIND], - ]); - if (refResult !== null) { - return invalidFact(refResult); - } - const head = resolveFactById(context, fact.headId); - const anchor = resolveFactById(context, fact.causalAnchorId); - if (!checkpointReferencesSameWorldline(fact, head) || !checkpointAnchorMatches(fact, head, anchor, context)) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); - } - if (!checkpointIdentityMatches(fact, anchor, context)) { - return invalidFact(FACT_VALIDATION_ERROR_HASH_MISMATCH); - } - return validateIds(fact, [fact.checkpointId]); -} - function optionalReference( context: RopeFactValidationContext, id: string | undefined, @@ -326,7 +294,6 @@ function validateDiffSpan(span: RopeDiffSpan, context: RopeFactValidationContext return FACT_VALIDATION_ERROR_INVALID_KIND; } -type CheckpointFact = Extract; type EqualDiffSpan = Extract; type DeleteDiffSpan = Extract; type InsertDiffSpan = Extract; @@ -343,30 +310,10 @@ function bufferWorldlineMatchesInitialHead( && fact.createdAtTick === tickIdFor(fact.worldlineId, head.contentHash, context.hash); } -function checkpointIdentityMatches( - fact: CheckpointFact, - anchor: RopeAdmittedFact | null, - context: RopeFactValidationContext, -): anchor is EchoCausalAnchorFact { - return isEchoCausalAnchorFact(anchor) - && fact.checkpointId === ropeCheckpointIdFor({ - worldlineId: fact.worldlineId, - headId: fact.headId, - reason: fact.reason, - causalAnchorId: fact.causalAnchorId, - admittedByReceiptId: anchor.admittedByReceiptId, - hash: context.hash, - }); -} - function isRopeHeadFact(fact: RopeAdmittedFact | null): fact is RopeHeadFact { return fact?.kind === ROPE_HEAD_FACT_KIND; } -function isEchoCausalAnchorFact(fact: RopeAdmittedFact | null): fact is EchoCausalAnchorFact { - return fact?.kind === ECHO_CAUSAL_ANCHOR_FACT_KIND; -} - function tickIdFor(worldlineId: string, contentHash: string, hash: RopeFactValidationContext['hash']): string { return `tick:${hash.sha256Hex(`${worldlineId}:${contentHash}`)}`; }