From 2a18a04f8df30d00a103e7dc8186eae9b2502a3a Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 5 Jul 2026 21:27:37 -0700 Subject: [PATCH 01/10] Define Echo causal anchor admission boundary --- spec/graph-rope-contract.spec.mjs | 59 +++++++++++++++ spec/graph-rope-runtime.spec.mjs | 3 + .../graph-rope-causal-anchor-admission.ts | 75 +++++++++++++++++++ src/domain/graph-rope-causal-anchor-digest.ts | 1 + .../graph-rope-causal-anchor-validation.ts | 6 ++ src/domain/graph-rope-checkpoint-identity.ts | 5 ++ src/domain/graph-rope-contract.ts | 1 + src/domain/graph-rope-runtime-checkpoint.ts | 4 + src/domain/graph-rope-types.ts | 62 +++++++++++++-- 9 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 src/domain/graph-rope-causal-anchor-admission.ts diff --git a/spec/graph-rope-contract.spec.mjs b/spec/graph-rope-contract.spec.mjs index df8886e..b7ab748 100644 --- a/spec/graph-rope-contract.spec.mjs +++ b/spec/graph-rope-contract.spec.mjs @@ -204,6 +204,43 @@ test('rope checkpoint validation references a causal anchor instead of a tick re }); }); +test('causal anchor admission request is generic Echo authority without checkpoint receipts', async () => { + const contract = await loadContract(); + const subject = { + appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, + subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, + subjectId: 'worldline:anchor-boundary', + }; + const retainedRoot = { + 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: 'rope-head:anchor-boundary', + role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, + }; + + const request = contract.makeEchoCausalAnchorAdmissionRequest({ + subject, + basisFrontierDigest: 'frontier:anchor-boundary', + retainedRoots: [retainedRoot], + purpose: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + retention: { + retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + }, + }); + + assert.deepEqual(request.subject, subject); + assert.deepEqual(request.retainedRoots, [retainedRoot]); + assert.deepEqual(request.materializationRoots, []); + assert.deepEqual(request.retention, { + retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + }); + assert.equal('admittedByReceiptId' in request, false); + assert.equal('checkpointId' in request, false); + assert.equal('headId' in request, false); + assert.equal('reason' in request, false); +}); + test('rope checkpoint validation recomputes checkpoint identity and anchor frontier', async () => { const facts = await checkpointFixture('worldline:checkpoint-identity'); const { anchorDigest, contract } = facts; @@ -219,6 +256,14 @@ test('rope checkpoint validation recomputes checkpoint identity and anchor front ...facts.checkpoint, causalAnchorId: frontierForgedAnchor.anchorId, }; + const retentionForgedAnchor = rekeyAnchor({ + ...facts.anchor, + retention: { retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG }, + }, anchorDigest, createHashPort()); + const retentionForgedCheckpoint = { + ...facts.checkpoint, + causalAnchorId: retentionForgedAnchor.anchorId, + }; assert.deepEqual(contract.validateRopeFact( forgedCheckpoint, @@ -234,6 +279,13 @@ test('rope checkpoint validation recomputes checkpoint identity and anchor front ok: false, code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, }); + assert.deepEqual(contract.validateRopeFact( + retentionForgedCheckpoint, + createValidationContext(contract, [...facts.baseFacts, retentionForgedAnchor, retentionForgedCheckpoint]), + ), { + ok: false, + code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, + }); }); test('rope checkpoints require a matching authority root on their causal anchor', async () => { @@ -302,6 +354,13 @@ test('causal anchor validation rejects forged digests ids and purposes', async ( ok: false, code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, }); + assert.deepEqual(contract.validateRopeFact( + { ...facts.anchor, retention: { retentionClass: 'pretend-save' } }, + createValidationContext(contract, facts.writeSet), + ), { + ok: false, + code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, + }); }); test('graph rope validation rejects forged diff span and rewrite evidence', async () => { diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index c081d1d..23b3bb2 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -207,6 +207,9 @@ test('graph runtime checkpoints a head through a non-mutating causal anchor', as 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.retention, { + retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + }); assert.deepEqual(checkpointed.causalAnchor.retainedRoots, [{ kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT, appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, diff --git a/src/domain/graph-rope-causal-anchor-admission.ts b/src/domain/graph-rope-causal-anchor-admission.ts new file mode 100644 index 0000000..323850f --- /dev/null +++ b/src/domain/graph-rope-causal-anchor-admission.ts @@ -0,0 +1,75 @@ +import { + ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, + ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, + type EchoCausalAnchorAdmissionRequest, + type EchoCausalAnchorAdmissionRequestInput, + type EchoCausalAnchorAppSubjectRoot, + type EchoCausalAnchorCasObjectRoot, + type EchoCausalAnchorGraphFactRoot, + type EchoCausalAnchorRetentionMetadata, + type EchoCausalAnchorRoot, + type EchoCausalAnchorSubject, +} from './graph-rope-types.js'; + +export function makeEchoCausalAnchorAdmissionRequest( + input: EchoCausalAnchorAdmissionRequestInput, +): EchoCausalAnchorAdmissionRequest { + return { + subject: cloneAnchorSubject(input.subject), + basisFrontierDigest: input.basisFrontierDigest, + retainedRoots: input.retainedRoots.map(cloneAnchorRoot), + materializationRoots: (input.materializationRoots ?? []).map(cloneAnchorRoot), + purpose: input.purpose, + retention: cloneRetentionMetadata(input.retention), + }; +} + +function cloneAnchorSubject(subject: EchoCausalAnchorSubject): EchoCausalAnchorSubject { + return { + appId: subject.appId, + subjectKind: subject.subjectKind, + subjectId: subject.subjectId, + }; +} + +function cloneRetentionMetadata(retention: EchoCausalAnchorRetentionMetadata): EchoCausalAnchorRetentionMetadata { + return { + retentionClass: retention.retentionClass, + }; +} + +function cloneAnchorRoot(root: EchoCausalAnchorRoot): EchoCausalAnchorRoot { + if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT) { + return cloneCasObjectRoot(root); + } + if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT) { + return cloneGraphFactRoot(root); + } + return cloneAppSubjectRoot(root); +} + +function cloneCasObjectRoot(root: EchoCausalAnchorCasObjectRoot): EchoCausalAnchorCasObjectRoot { + return { + kind: root.kind, + id: root.id, + role: root.role, + }; +} + +function cloneGraphFactRoot(root: EchoCausalAnchorGraphFactRoot): EchoCausalAnchorGraphFactRoot { + return { + kind: root.kind, + id: root.id, + role: root.role, + }; +} + +function cloneAppSubjectRoot(root: EchoCausalAnchorAppSubjectRoot): EchoCausalAnchorAppSubjectRoot { + return { + kind: root.kind, + appId: root.appId, + subjectKind: root.subjectKind, + id: root.id, + role: root.role, + }; +} diff --git a/src/domain/graph-rope-causal-anchor-digest.ts b/src/domain/graph-rope-causal-anchor-digest.ts index 1bc55db..7c88d59 100644 --- a/src/domain/graph-rope-causal-anchor-digest.ts +++ b/src/domain/graph-rope-causal-anchor-digest.ts @@ -30,6 +30,7 @@ export function causalAnchorDigestMaterial( `subject=${anchor.subject.appId}:${anchor.subject.subjectKind}:${anchor.subject.subjectId}`, `frontier=${anchor.basisFrontierDigest}`, `purpose=${anchor.purpose}`, + `retention=${anchor.retention.retentionClass}`, `receipt=${anchor.admittedByReceiptId}`, `retained=${canonicalRootSet(anchor.retainedRoots).join('|')}`, `materialization=${canonicalRootSet(anchor.materializationRoots).join('|')}`, diff --git a/src/domain/graph-rope-causal-anchor-validation.ts b/src/domain/graph-rope-causal-anchor-validation.ts index d0ab726..b00c1f6 100644 --- a/src/domain/graph-rope-causal-anchor-validation.ts +++ b/src/domain/graph-rope-causal-anchor-validation.ts @@ -32,6 +32,7 @@ import { } from './graph-rope-causal-anchor-digest.js'; import { basisFrontierDigestForRopeHead, + checkpointAnchorRetentionClass, checkpointAnchorPurpose, } from './graph-rope-checkpoint-identity.js'; @@ -73,12 +74,16 @@ export function validateEchoCausalAnchorFact( if (!VALID_ANCHOR_PURPOSES.has(fact.purpose)) { return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); } + if (fact.retention == null || !VALID_ANCHOR_PURPOSES.has(fact.retention.retentionClass)) { + return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); + } const idResult = invalidIdIn([ fact.anchorId, fact.subject.appId, fact.subject.subjectKind, fact.subject.subjectId, fact.basisFrontierDigest, + fact.retention.retentionClass, fact.admittedByReceiptId, ]); if (idResult !== null) { @@ -118,6 +123,7 @@ export function checkpointAnchorMatches( && anchor.subject.subjectId === fact.worldlineId && anchor.basisFrontierDigest === basisFrontierDigestForRopeHead(head, context.hash) && anchor.purpose === checkpointAnchorPurpose(fact.reason) + && anchor.retention.retentionClass === checkpointAnchorRetentionClass(fact.reason) && anchor.retainedRoots.some((root) => isJeditRopeHeadAuthorityRoot(root, fact.headId)); } diff --git a/src/domain/graph-rope-checkpoint-identity.ts b/src/domain/graph-rope-checkpoint-identity.ts index 47f2fe1..80a9469 100644 --- a/src/domain/graph-rope-checkpoint-identity.ts +++ b/src/domain/graph-rope-checkpoint-identity.ts @@ -5,6 +5,7 @@ import { ROPE_CHECKPOINT_REASON_MANUAL_SAVE, ROPE_CHECKPOINT_REASON_RETENTION_BOUNDARY, ROPE_CHECKPOINT_REASON_TEST_FIXTURE, + type EchoCausalAnchorRetentionClass, type EchoCausalAnchorPurpose, type RopeCheckpointReason, type RopeHeadFact, @@ -40,6 +41,10 @@ export function ropeCheckpointIdFor(input: RopeCheckpointIdInput): string { } export function checkpointAnchorPurpose(reason: RopeCheckpointReason): EchoCausalAnchorPurpose { + return checkpointAnchorRetentionClass(reason); +} + +export function checkpointAnchorRetentionClass(reason: RopeCheckpointReason): EchoCausalAnchorRetentionClass { switch (reason) { case ROPE_CHECKPOINT_REASON_MANUAL_SAVE: return 'user-save'; diff --git a/src/domain/graph-rope-contract.ts b/src/domain/graph-rope-contract.ts index b5887b0..2bd9e9d 100644 --- a/src/domain/graph-rope-contract.ts +++ b/src/domain/graph-rope-contract.ts @@ -1,5 +1,6 @@ export * from './graph-rope-coordinates.js'; export * from './graph-rope-fact-id.js'; export * from './graph-rope-types.js'; +export * from './graph-rope-causal-anchor-admission.js'; export * from './graph-rope-text-blob-validation.js'; export * from './graph-rope-validation.js'; diff --git a/src/domain/graph-rope-runtime-checkpoint.ts b/src/domain/graph-rope-runtime-checkpoint.ts index eb9c72d..17a2e3d 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -22,6 +22,7 @@ import { import { basisFrontierDigestForRopeHead, checkpointAnchorPurpose, + checkpointAnchorRetentionClass, ropeCheckpointIdFor, } from './graph-rope-checkpoint-identity.js'; @@ -87,6 +88,9 @@ function causalAnchorForCheckpoint( retainedRoots: [retainedRoot], materializationRoots: [], purpose, + retention: { + retentionClass: checkpointAnchorRetentionClass(reason), + }, admittedByReceiptId, }; const anchorDigest = causalAnchorDigestFor(anchor, hash); diff --git a/src/domain/graph-rope-types.ts b/src/domain/graph-rope-types.ts index aa6397c..010fe17 100644 --- a/src/domain/graph-rope-types.ts +++ b/src/domain/graph-rope-types.ts @@ -33,6 +33,14 @@ 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 ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RECOVERY = 'recovery'; +export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RETENTION = 'retention'; +export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT = 'export'; +export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE = 'user-save'; +export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_AUTOSAVE = 'autosave'; +export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG = 'debug'; +export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_CACHE_WARM = 'cache-warm'; +export const ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO = 'echo'; export const ROPE_DIFF_SPAN_EQUAL_KIND = 'equal'; export const ROPE_DIFF_SPAN_DELETE_KIND = 'delete'; @@ -271,13 +279,19 @@ export interface RopeCheckpointFact { } export type EchoCausalAnchorPurpose = - | 'recovery' - | 'retention' - | 'export' - | 'user-save' - | 'autosave' - | 'debug' - | 'cache-warm'; + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RECOVERY + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RETENTION + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_AUTOSAVE + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG + | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_CACHE_WARM; + +export type EchoCausalAnchorRetentionClass = EchoCausalAnchorPurpose; + +export interface EchoCausalAnchorRetentionMetadata { + readonly retentionClass: EchoCausalAnchorRetentionClass; +} export interface EchoCausalAnchorSubject { readonly appId: string; @@ -327,10 +341,44 @@ export interface EchoCausalAnchorFact { readonly retainedRoots: readonly EchoCausalAnchorRoot[]; readonly materializationRoots: readonly EchoCausalAnchorRoot[]; readonly purpose: EchoCausalAnchorPurpose; + readonly retention: EchoCausalAnchorRetentionMetadata; readonly admittedByReceiptId: string; readonly anchorDigest: string; } +export interface EchoCausalAnchorAdmissionRequest { + readonly subject: EchoCausalAnchorSubject; + readonly basisFrontierDigest: string; + readonly retainedRoots: readonly EchoCausalAnchorRoot[]; + readonly materializationRoots: readonly EchoCausalAnchorRoot[]; + readonly purpose: EchoCausalAnchorPurpose; + readonly retention: EchoCausalAnchorRetentionMetadata; +} + +export interface EchoCausalAnchorAdmissionRequestInput { + readonly subject: EchoCausalAnchorSubject; + readonly basisFrontierDigest: string; + readonly retainedRoots: readonly EchoCausalAnchorRoot[]; + readonly materializationRoots?: readonly EchoCausalAnchorRoot[]; + readonly purpose: EchoCausalAnchorPurpose; + readonly retention: EchoCausalAnchorRetentionMetadata; +} + +export interface EchoCausalAnchorAdmissionReceipt { + readonly authority: typeof ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO; + readonly receiptId: string; + readonly anchorId: string; +} + +export interface EchoCausalAnchorAdmissionResult { + readonly anchor: EchoCausalAnchorFact; + readonly receipt: EchoCausalAnchorAdmissionReceipt; +} + +export interface EchoCausalAnchorAdmissionPort { + admitCausalAnchor(request: EchoCausalAnchorAdmissionRequest): EchoCausalAnchorAdmissionResult; +} + export type RopeAdmittedFact = | BufferWorldlineFact | RopeHeadFact From fbb074a51b11bf9328cc297d297125e5801d37df Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 5 Jul 2026 21:31:40 -0700 Subject: [PATCH 02/10] Route rope checkpoints through Echo anchor admission --- spec/graph-rope-runtime.spec.mjs | 58 ++++++++++++- .../graph-rope-causal-anchor-admission.ts | 81 +++++++++++++++++ src/domain/graph-rope-runtime-checkpoint.ts | 87 +++++++------------ src/domain/graph-rope-runtime.ts | 23 ++++- 4 files changed, 191 insertions(+), 58 deletions(-) diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index 23b3bb2..2a89de8 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -177,7 +177,18 @@ test('graph runtime no-op replacement does not mint text authority facts', async 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 hash = createHashPort(); + const admittedRequests = []; + const echoAdmission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); + const graph = runtime.createGraphRopeRuntime({ + hash, + causalAnchorAdmission: { + admitCausalAnchor(request) { + admittedRequests.push(request); + return echoAdmission.admitCausalAnchor(request); + }, + }, + }); const created = assertOk(graph.createBufferWorldline({ worldlineId: 'worldline:checkpoint', initialText: 'alpha beta', @@ -203,6 +214,17 @@ test('graph runtime checkpoints a head through a non-mutating causal anchor', as 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(admittedRequests.length, 1); + assert.equal(admittedRequests[0].subject.appId, contract.JEDIT_CAUSAL_ANCHOR_APP_ID); + assert.equal(admittedRequests[0].subject.subjectKind, contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE); + assert.equal(admittedRequests[0].subject.subjectId, 'worldline:checkpoint'); + assert.equal(admittedRequests[0].basisFrontierDigest, checkpointed.causalAnchor.basisFrontierDigest); + assert.deepEqual(admittedRequests[0].retainedRoots, checkpointed.causalAnchor.retainedRoots); + assert.deepEqual(admittedRequests[0].materializationRoots, []); + assert.equal('admittedByReceiptId' in admittedRequests[0], false); + assert.equal(checkpointed.causalAnchorReceipt.authority, contract.ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO); + assert.equal(checkpointed.causalAnchorReceipt.anchorId, checkpointed.causalAnchor.anchorId); + assert.equal(checkpointed.causalAnchorReceipt.receiptId, checkpointed.causalAnchor.admittedByReceiptId); 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'); @@ -225,6 +247,40 @@ test('graph runtime checkpoints a head through a non-mutating causal anchor', as assert.equal(reading.text, 'alpha BETA'); }); +test('graph runtime rejects checkpoint admissions with mismatched Echo receipts', async () => { + const { runtime, contract } = await loadModules(); + const hash = createHashPort(); + const echoAdmission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); + const graph = runtime.createGraphRopeRuntime({ + hash, + causalAnchorAdmission: { + admitCausalAnchor(request) { + const admission = echoAdmission.admitCausalAnchor(request); + return { + ...admission, + receipt: { + ...admission.receipt, + anchorId: 'causal-anchor:forged', + }, + }; + }, + }, + }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-bad-receipt', + initialText: 'alpha', + })); + + assert.deepEqual(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-bad-receipt', + headId: created.head.headId, + reason: 'manual-save', + }), { + ok: false, + code: OBSTRUCTION_INVALID_FACT, + }); +}); + test('graph runtime treats repeated checkpoints as distinct causal admissions', async () => { const { runtime } = await loadModules(); const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); diff --git a/src/domain/graph-rope-causal-anchor-admission.ts b/src/domain/graph-rope-causal-anchor-admission.ts index 323850f..0a0ae9d 100644 --- a/src/domain/graph-rope-causal-anchor-admission.ts +++ b/src/domain/graph-rope-causal-anchor-admission.ts @@ -1,15 +1,32 @@ import { + ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO, + ECHO_CAUSAL_ANCHOR_FACT_KIND, ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, + GRAPH_ROPE_SCHEMA_VERSION, + type EchoCausalAnchorAdmissionPort, type EchoCausalAnchorAdmissionRequest, type EchoCausalAnchorAdmissionRequestInput, + type EchoCausalAnchorAdmissionResult, type EchoCausalAnchorAppSubjectRoot, type EchoCausalAnchorCasObjectRoot, + type EchoCausalAnchorFact, type EchoCausalAnchorGraphFactRoot, type EchoCausalAnchorRetentionMetadata, type EchoCausalAnchorRoot, type EchoCausalAnchorSubject, + type TextBlobHashPort, } from './graph-rope-types.js'; +import { + causalAnchorDigestFor, + causalAnchorIdForDigest, +} from './graph-rope-causal-anchor-digest.js'; + +const RUNTIME_HASH_PREFIX_CAUSAL_ANCHOR_RECEIPT = 'causal-anchor-receipt:'; + +export interface CreateDeterministicEchoCausalAnchorAdmissionPortInput { + readonly hash: TextBlobHashPort; +} export function makeEchoCausalAnchorAdmissionRequest( input: EchoCausalAnchorAdmissionRequestInput, @@ -24,6 +41,70 @@ export function makeEchoCausalAnchorAdmissionRequest( }; } +export function createDeterministicEchoCausalAnchorAdmissionPort( + input: CreateDeterministicEchoCausalAnchorAdmissionPortInput, +): EchoCausalAnchorAdmissionPort { + let nextAdmissionSequence = 1; + + return { + admitCausalAnchor(request) { + const sequence = nextAdmissionSequence; + nextAdmissionSequence += 1; + return admitCausalAnchor(request, input.hash, sequence); + }, + }; +} + +function admitCausalAnchor( + request: EchoCausalAnchorAdmissionRequest, + hash: TextBlobHashPort, + sequence: number, +): EchoCausalAnchorAdmissionResult { + const admittedByReceiptId = anchorReceiptIdFor(request, sequence, hash); + const anchorBasis: Omit = { + kind: ECHO_CAUSAL_ANCHOR_FACT_KIND, + schemaVersion: GRAPH_ROPE_SCHEMA_VERSION, + subject: cloneAnchorSubject(request.subject), + basisFrontierDigest: request.basisFrontierDigest, + retainedRoots: request.retainedRoots.map(cloneAnchorRoot), + materializationRoots: request.materializationRoots.map(cloneAnchorRoot), + purpose: request.purpose, + retention: cloneRetentionMetadata(request.retention), + admittedByReceiptId, + }; + const anchorDigest = causalAnchorDigestFor(anchorBasis, hash); + const anchorId = causalAnchorIdForDigest(anchorDigest, hash); + return { + anchor: { + ...anchorBasis, + anchorId, + anchorDigest, + }, + receipt: { + authority: ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO, + receiptId: admittedByReceiptId, + anchorId, + }, + }; +} + +function anchorReceiptIdFor( + request: EchoCausalAnchorAdmissionRequest, + sequence: number, + hash: TextBlobHashPort, +): string { + return hash.sha256Hex([ + RUNTIME_HASH_PREFIX_CAUSAL_ANCHOR_RECEIPT, + request.subject.appId, + request.subject.subjectKind, + request.subject.subjectId, + request.basisFrontierDigest, + request.purpose, + request.retention.retentionClass, + String(sequence), + ].join(':')); +} + function cloneAnchorSubject(subject: EchoCausalAnchorSubject): EchoCausalAnchorSubject { return { appId: subject.appId, diff --git a/src/domain/graph-rope-runtime-checkpoint.ts b/src/domain/graph-rope-runtime-checkpoint.ts index 17a2e3d..0a5788d 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -1,5 +1,4 @@ import { - ECHO_CAUSAL_ANCHOR_FACT_KIND, ECHO_CAUSAL_ANCHOR_ROOT_KIND_APP_SUBJECT, ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, GRAPH_ROPE_SCHEMA_VERSION, @@ -7,18 +6,15 @@ import { JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_ROPE_HEAD, ROPE_CHECKPOINT_FACT_KIND, + makeEchoCausalAnchorAdmissionRequest, + type EchoCausalAnchorAdmissionRequest, + type EchoCausalAnchorAdmissionResult, type EchoCausalAnchorAppSubjectRoot, - type EchoCausalAnchorFact, - type EchoCausalAnchorPurpose, type RopeCheckpointFact, type RopeCheckpointReason, type RopeHeadFact, type TextBlobHashPort, } from './graph-rope-contract.js'; -import { - causalAnchorDigestFor, - causalAnchorIdForDigest, -} from './graph-rope-causal-anchor-digest.js'; import { basisFrontierDigestForRopeHead, checkpointAnchorPurpose, @@ -26,8 +22,6 @@ import { 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; @@ -36,17 +30,39 @@ export interface GraphRopeCreateCheckpointInput { export interface GraphRopeCreateCheckpointResult { readonly head: RopeHeadFact; - readonly causalAnchor: EchoCausalAnchorFact; + readonly causalAnchor: EchoCausalAnchorAdmissionResult['anchor']; + readonly causalAnchorReceipt: EchoCausalAnchorAdmissionResult['receipt']; readonly checkpoint: RopeCheckpointFact; } +export function createCheckpointAnchorAdmissionRequest( + head: RopeHeadFact, + reason: RopeCheckpointReason, + hash: TextBlobHashPort, +): EchoCausalAnchorAdmissionRequest { + return makeEchoCausalAnchorAdmissionRequest({ + subject: { + appId: JEDIT_CAUSAL_ANCHOR_APP_ID, + subjectKind: JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, + subjectId: head.worldlineId, + }, + basisFrontierDigest: basisFrontierDigestForRopeHead(head, hash), + retainedRoots: [retainedRopeHeadRoot(head.headId)], + materializationRoots: [], + purpose: checkpointAnchorPurpose(reason), + retention: { + retentionClass: checkpointAnchorRetentionClass(reason), + }, + }); +} + export function createCheckpointFacts( head: RopeHeadFact, reason: RopeCheckpointReason, + anchorAdmission: EchoCausalAnchorAdmissionResult, hash: TextBlobHashPort, - admissionSequence: number, ): GraphRopeCreateCheckpointResult { - const causalAnchor = causalAnchorForCheckpoint(head, reason, hash, admissionSequence); + const causalAnchor = anchorAdmission.anchor; const checkpoint: RopeCheckpointFact = { kind: ROPE_CHECKPOINT_FACT_KIND, schemaVersion: GRAPH_ROPE_SCHEMA_VERSION, @@ -63,41 +79,11 @@ export function createCheckpointFacts( 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, - retention: { - retentionClass: checkpointAnchorRetentionClass(reason), - }, - admittedByReceiptId, - }; - const anchorDigest = causalAnchorDigestFor(anchor, hash); return { - ...anchor, - anchorId: causalAnchorIdForDigest(anchorDigest, hash), - anchorDigest, + head, + causalAnchor, + causalAnchorReceipt: anchorAdmission.receipt, + checkpoint, }; } @@ -110,12 +96,3 @@ function retainedRopeHeadRoot(headId: string): EchoCausalAnchorAppSubjectRoot { role: ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, }; } - -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.ts b/src/domain/graph-rope-runtime.ts index 9646113..8cf44c9 100644 --- a/src/domain/graph-rope-runtime.ts +++ b/src/domain/graph-rope-runtime.ts @@ -2,10 +2,13 @@ import { BUFFER_WORLDLINE_FACT_KIND, GRAPH_ROPE_SCHEMA_VERSION, ROPE_HEAD_FACT_KIND, + createDeterministicEchoCausalAnchorAdmissionPort, makeTextBlobFact, ropeFactId, validateRopeFact, type BufferWorldlineFact, + type EchoCausalAnchorAdmissionPort, + type EchoCausalAnchorAdmissionResult, type RopeAdmittedFact, type RopeDiffFact, type RopeFactValidationContext, @@ -17,6 +20,7 @@ import { type TickReceiptFact, } from './graph-rope-contract.js'; import { + createCheckpointAnchorAdmissionRequest, createCheckpointFacts, type GraphRopeCreateCheckpointInput, type GraphRopeCreateCheckpointResult, @@ -66,6 +70,7 @@ export type GraphRopeRuntimeResult = export interface CreateGraphRopeRuntimeInput { readonly hash: TextBlobHashPort; + readonly causalAnchorAdmission?: EchoCausalAnchorAdmissionPort; } export interface CreateBufferWorldlineInput { @@ -146,6 +151,7 @@ export interface GraphRopeRuntime { interface GraphRopeRuntimeState extends GraphRopeRuntimeFactReader { readonly hash: TextBlobHashPort; + readonly causalAnchorAdmission: EchoCausalAnchorAdmissionPort; readonly factsById: Map; readonly currentHeadByWorldlineId: Map; nextAdmissionSequence: number; @@ -159,6 +165,9 @@ export function createGraphRopeRuntime(input: CreateGraphRopeRuntimeInput): Grap const factsById = new Map(); const state: GraphRopeRuntimeState = { hash: input.hash, + causalAnchorAdmission: input.causalAnchorAdmission ?? createDeterministicEchoCausalAnchorAdmissionPort({ + hash: input.hash, + }), factsById, currentHeadByWorldlineId: new Map(), nextAdmissionSequence: INITIAL_ADMISSION_SEQUENCE, @@ -237,15 +246,24 @@ 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 anchorRequest = createCheckpointAnchorAdmissionRequest(head, input.reason, state.hash); + const anchorAdmission = state.causalAnchorAdmission.admitCausalAnchor(anchorRequest); + if (!anchorAdmissionMatches(anchorAdmission)) { + return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; + } + const checkpointFacts = createCheckpointFacts(head, input.reason, anchorAdmission, state.hash); const admissionIssue = admitFacts(state, [checkpointFacts.causalAnchor, checkpointFacts.checkpoint]); if (admissionIssue !== null) { return { ok: false, code: admissionIssue }; } - state.nextAdmissionSequence += ONE_VALUE; return { ok: true, value: cloneCheckpointResult(checkpointFacts) }; } +function anchorAdmissionMatches(anchorAdmission: EchoCausalAnchorAdmissionResult): boolean { + return anchorAdmission.receipt.anchorId === anchorAdmission.anchor.anchorId + && anchorAdmission.receipt.receiptId === anchorAdmission.anchor.admittedByReceiptId; +} + function textWindow( state: GraphRopeRuntimeState, input: GraphRopeTextWindowInput, @@ -373,6 +391,7 @@ function cloneCheckpointResult(result: GraphRopeCreateCheckpointResult): GraphRo return { head: cloneFact(result.head), causalAnchor: cloneFact(result.causalAnchor), + causalAnchorReceipt: cloneFact(result.causalAnchorReceipt), checkpoint: cloneFact(result.checkpoint), }; } From 65cac502e3728cc479af768d605c89658fb66997 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 5 Jul 2026 21:34:59 -0700 Subject: [PATCH 03/10] Add causal anchor retention witnesses --- spec/graph-rope-contract.spec.mjs | 82 +++++++++++++++++++++ spec/graph-rope-runtime.spec.mjs | 33 +++++++++ src/domain/graph-rope-runtime-checkpoint.ts | 5 +- src/domain/graph-rope-runtime.ts | 2 +- 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/spec/graph-rope-contract.spec.mjs b/spec/graph-rope-contract.spec.mjs index b7ab748..2391d32 100644 --- a/spec/graph-rope-contract.spec.mjs +++ b/spec/graph-rope-contract.spec.mjs @@ -329,6 +329,88 @@ test('causal anchor validation rejects authority roots as materializations', asy }); }); +test('causal anchor admission distinguishes authority roots from materialization artifacts', async () => { + const contract = await loadContract(); + const hash = createHashPort(); + const admission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); + const retainedRoot = { + 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: 'rope-head:authority', + role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, + }; + const materializationRoot = { + kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, + id: 'cas:flat-text-window', + role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION, + }; + const anchor = admission.admitCausalAnchor(contract.makeEchoCausalAnchorAdmissionRequest({ + subject: { + appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, + subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, + subjectId: 'worldline:authority-vs-cache', + }, + basisFrontierDigest: 'frontier:authority-vs-cache', + retainedRoots: [retainedRoot], + materializationRoots: [materializationRoot], + purpose: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + retention: { + retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + }, + })).anchor; + + assert.equal(contract.validateRopeFact( + anchor, + createValidationContext(contract, [anchor]), + ).ok, true); + assert.deepEqual(anchor.retainedRoots, [retainedRoot]); + assert.deepEqual(anchor.materializationRoots, [materializationRoot]); + assert.equal(anchor.retainedRoots[0].role, contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY); + assert.equal(anchor.materializationRoots[0].role, contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION); +}); + +test('causal anchor retention metadata distinguishes anchor policy classes', async () => { + const contract = await loadContract(); + const hash = createHashPort(); + const admission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); + const retentionClasses = [ + contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RECOVERY, + contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT, + contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, + contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_AUTOSAVE, + contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG, + contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_CACHE_WARM, + ]; + + const anchors = retentionClasses.map((retentionClass) => admission.admitCausalAnchor( + contract.makeEchoCausalAnchorAdmissionRequest({ + subject: { + appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, + subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, + subjectId: `worldline:${retentionClass}`, + }, + basisFrontierDigest: `frontier:${retentionClass}`, + retainedRoots: [{ + kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, + id: `fact:${retentionClass}`, + role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE, + }], + purpose: retentionClass, + retention: { retentionClass }, + }), + ).anchor); + + assert.deepEqual(anchors.map((anchor) => anchor.retention.retentionClass), retentionClasses); + assert.equal(new Set(anchors.map((anchor) => anchor.anchorId)).size, retentionClasses.length); + for (const anchor of anchors) { + assert.equal(contract.validateRopeFact( + anchor, + createValidationContext(contract, [anchor]), + ).ok, true); + } +}); + test('causal anchor validation rejects forged digests ids and purposes', async () => { const facts = await checkpointFixture('worldline:forged-anchor'); const { contract } = facts; diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index 2a89de8..6027f5c 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -281,6 +281,39 @@ test('graph runtime rejects checkpoint admissions with mismatched Echo receipts' }); }); +test('graph runtime checkpoint anchors optional materialization roots as cache artifacts', async () => { + const { runtime, contract } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const materializationRoot = { + kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, + id: 'cas:checkpoint-flat-text', + role: contract.ECHO_CAUSAL_ANCHOR_ROOT_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', + materializationRoots: [materializationRoot], + })); + const after = assertOk(graph.debugRopeShape(created.head.headId)); + + assert.deepEqual(checkpointed.causalAnchor.materializationRoots, [materializationRoot]); + assert.equal(checkpointed.causalAnchor.retainedRoots.length, 1); + assert.equal(checkpointed.causalAnchor.retainedRoots[0].role, contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY); + assert.equal(checkpointed.causalAnchor.purpose, contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT); + assert.deepEqual(checkpointed.causalAnchor.retention, { + retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT, + }); + assert.equal(checkpointed.checkpoint.causalAnchorId, checkpointed.causalAnchor.anchorId); + assert.deepEqual(after, before); +}); + test('graph runtime treats repeated checkpoints as distinct causal admissions', async () => { const { runtime } = await loadModules(); const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); diff --git a/src/domain/graph-rope-runtime-checkpoint.ts b/src/domain/graph-rope-runtime-checkpoint.ts index 0a5788d..ce864da 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -10,6 +10,7 @@ import { type EchoCausalAnchorAdmissionRequest, type EchoCausalAnchorAdmissionResult, type EchoCausalAnchorAppSubjectRoot, + type EchoCausalAnchorRoot, type RopeCheckpointFact, type RopeCheckpointReason, type RopeHeadFact, @@ -26,6 +27,7 @@ export interface GraphRopeCreateCheckpointInput { readonly worldlineId: string; readonly headId: string; readonly reason: RopeCheckpointReason; + readonly materializationRoots?: readonly EchoCausalAnchorRoot[]; } export interface GraphRopeCreateCheckpointResult { @@ -39,6 +41,7 @@ export function createCheckpointAnchorAdmissionRequest( head: RopeHeadFact, reason: RopeCheckpointReason, hash: TextBlobHashPort, + materializationRoots: readonly EchoCausalAnchorRoot[] = [], ): EchoCausalAnchorAdmissionRequest { return makeEchoCausalAnchorAdmissionRequest({ subject: { @@ -48,7 +51,7 @@ export function createCheckpointAnchorAdmissionRequest( }, basisFrontierDigest: basisFrontierDigestForRopeHead(head, hash), retainedRoots: [retainedRopeHeadRoot(head.headId)], - materializationRoots: [], + materializationRoots, purpose: checkpointAnchorPurpose(reason), retention: { retentionClass: checkpointAnchorRetentionClass(reason), diff --git a/src/domain/graph-rope-runtime.ts b/src/domain/graph-rope-runtime.ts index 8cf44c9..0cfc7c0 100644 --- a/src/domain/graph-rope-runtime.ts +++ b/src/domain/graph-rope-runtime.ts @@ -246,7 +246,7 @@ function createCheckpoint( if (head.worldlineId !== input.worldlineId) { return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; } - const anchorRequest = createCheckpointAnchorAdmissionRequest(head, input.reason, state.hash); + const anchorRequest = createCheckpointAnchorAdmissionRequest(head, input.reason, state.hash, input.materializationRoots); const anchorAdmission = state.causalAnchorAdmission.admitCausalAnchor(anchorRequest); if (!anchorAdmissionMatches(anchorAdmission)) { return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; From 1f7549ef30cd56a5ac54cbf06c2ee6ac6eefad85 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 5 Jul 2026 21:38:52 -0700 Subject: [PATCH 04/10] Fix causal anchor validation complexity --- .../graph-rope-causal-anchor-validation.ts | 89 ++++++++++++++----- 1 file changed, 66 insertions(+), 23 deletions(-) diff --git a/src/domain/graph-rope-causal-anchor-validation.ts b/src/domain/graph-rope-causal-anchor-validation.ts index b00c1f6..c7db6e8 100644 --- a/src/domain/graph-rope-causal-anchor-validation.ts +++ b/src/domain/graph-rope-causal-anchor-validation.ts @@ -68,16 +68,24 @@ export function validateEchoCausalAnchorFact( 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); + const issue = validateAnchorHeader(fact) + ?? validateAnchorRootSets(fact.retainedRoots, fact.materializationRoots) + ?? validateAnchorIdentity(fact, context); + if (issue !== null) { + return invalidFact(issue); } - if (!VALID_ANCHOR_PURPOSES.has(fact.purpose)) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); + return validFact(fact); +} + +function validateAnchorHeader(fact: EchoCausalAnchorFact): FactValidationErrorCode | null { + if (isInvalidHash(fact.anchorDigest)) { + return FACT_VALIDATION_ERROR_INVALID_HASH; } - if (fact.retention == null || !VALID_ANCHOR_PURPOSES.has(fact.retention.retentionClass)) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); + const policyIssue = validateAnchorPolicy(fact); + if (policyIssue !== null) { + return policyIssue; } - const idResult = invalidIdIn([ + return invalidIdIn([ fact.anchorId, fact.subject.appId, fact.subject.subjectKind, @@ -86,20 +94,35 @@ export function validateEchoCausalAnchorFact( fact.retention.retentionClass, fact.admittedByReceiptId, ]); - if (idResult !== null) { - return invalidFact(idResult); - } - const rootIssue = validateAnchorRootSets(fact.retainedRoots, fact.materializationRoots); - if (rootIssue !== null) { - return invalidFact(rootIssue); +} + +function validateAnchorPolicy(fact: EchoCausalAnchorFact): FactValidationErrorCode | null { + return validateAnchorPurpose(fact.purpose) ?? validateAnchorRetention(fact.retention); +} + +function validateAnchorPurpose(purpose: string): FactValidationErrorCode | null { + return VALID_ANCHOR_PURPOSES.has(purpose) ? null : FACT_VALIDATION_ERROR_INVALID_REFERENCE; +} + +function validateAnchorRetention( + retention: EchoCausalAnchorFact['retention'] | null | undefined, +): FactValidationErrorCode | null { + if (retention == null) { + return FACT_VALIDATION_ERROR_INVALID_REFERENCE; } + return VALID_ANCHOR_PURPOSES.has(retention.retentionClass) ? null : FACT_VALIDATION_ERROR_INVALID_REFERENCE; +} + +function validateAnchorIdentity( + fact: EchoCausalAnchorFact, + context: RopeFactValidationContext, +): FactValidationErrorCode | null { 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 FACT_VALIDATION_ERROR_HASH_MISMATCH; } - return validFact(fact); + return fact.anchorId === causalAnchorIdForDigest(fact.anchorDigest, context.hash) + ? null + : FACT_VALIDATION_ERROR_HASH_MISMATCH; } export function checkpointReferencesSameWorldline( @@ -118,13 +141,33 @@ export function checkpointAnchorMatches( if (!isRopeHeadFact(head) || !isEchoCausalAnchorFact(anchor)) { return false; } + return checkpointAnchorSubjectMatches(anchor, fact) + && checkpointAnchorFrontierMatches(anchor, head, context) + && checkpointAnchorPolicyMatches(anchor, fact) + && checkpointAnchorRetainsHead(anchor, fact); +} + +function checkpointAnchorSubjectMatches(anchor: EchoCausalAnchorFact, fact: RopeCheckpointFact): boolean { 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.retention.retentionClass === checkpointAnchorRetentionClass(fact.reason) - && anchor.retainedRoots.some((root) => isJeditRopeHeadAuthorityRoot(root, fact.headId)); + && anchor.subject.subjectId === fact.worldlineId; +} + +function checkpointAnchorFrontierMatches( + anchor: EchoCausalAnchorFact, + head: RopeHeadFact, + context: RopeFactValidationContext, +): boolean { + return anchor.basisFrontierDigest === basisFrontierDigestForRopeHead(head, context.hash); +} + +function checkpointAnchorPolicyMatches(anchor: EchoCausalAnchorFact, fact: RopeCheckpointFact): boolean { + return anchor.purpose === checkpointAnchorPurpose(fact.reason) + && anchor.retention.retentionClass === checkpointAnchorRetentionClass(fact.reason); +} + +function checkpointAnchorRetainsHead(anchor: EchoCausalAnchorFact, fact: RopeCheckpointFact): boolean { + return anchor.retainedRoots.some((root) => isJeditRopeHeadAuthorityRoot(root, fact.headId)); } function validateAnchorRootSets( From c386370b4976a5eb8d49b0acf4be9aed767e5a30 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 15 Jul 2026 03:53:36 -0700 Subject: [PATCH 05/10] Fix: separate rope checkpoints from Echo authority --- ...149-graph-backed-rope-runtime-discovery.md | 88 +++-- spec/design-cycle-policy.spec.mjs | 4 +- spec/graph-rope-contract.spec.mjs | 300 +++++----------- spec/graph-rope-runtime.spec.mjs | 326 +++++++++++++----- .../test-echo-causal-anchor-admission.mjs | 28 ++ .../graph-rope-causal-anchor-admission.ts | 156 --------- src/domain/graph-rope-causal-anchor-digest.ts | 52 --- .../graph-rope-causal-anchor-validation.ts | 279 ++------------- src/domain/graph-rope-checkpoint-identity.ts | 57 ++- .../graph-rope-checkpoint-validation.ts | 117 +++++++ src/domain/graph-rope-contract.ts | 1 - src/domain/graph-rope-fact-id.ts | 8 +- src/domain/graph-rope-runtime-checkpoint.ts | 102 +++--- src/domain/graph-rope-runtime-echo-adapter.ts | 49 +++ src/domain/graph-rope-runtime-issues.ts | 8 +- src/domain/graph-rope-runtime.ts | 91 +++-- src/domain/graph-rope-types.ts | 152 +++----- src/domain/graph-rope-validation.ts | 63 +--- 18 files changed, 795 insertions(+), 1086 deletions(-) create mode 100644 spec/support/test-echo-causal-anchor-admission.mjs delete mode 100644 src/domain/graph-rope-causal-anchor-admission.ts delete mode 100644 src/domain/graph-rope-causal-anchor-digest.ts create mode 100644 src/domain/graph-rope-checkpoint-validation.ts create mode 100644 src/domain/graph-rope-runtime-echo-adapter.ts diff --git a/docs/design/0149-graph-backed-rope-runtime-discovery.md b/docs/design/0149-graph-backed-rope-runtime-discovery.md index 8f5f5d0..4e95059 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 a4610da..8121eda 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-contract.spec.mjs b/spec/graph-rope-contract.spec.mjs index 2391d32..1d717a9 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,255 +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, - }); -}); - -test('causal anchor admission request is generic Echo authority without checkpoint receipts', async () => { - const contract = await loadContract(); - const subject = { - appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - subjectId: 'worldline:anchor-boundary', - }; - const retainedRoot = { - 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: 'rope-head:anchor-boundary', - role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, - }; - - const request = contract.makeEchoCausalAnchorAdmissionRequest({ - subject, - basisFrontierDigest: 'frontier:anchor-boundary', - retainedRoots: [retainedRoot], - purpose: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, - retention: { - retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, - }, - }); - - assert.deepEqual(request.subject, subject); - assert.deepEqual(request.retainedRoots, [retainedRoot]); - assert.deepEqual(request.materializationRoots, []); - assert.deepEqual(request.retention, { - retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, - }); - assert.equal('admittedByReceiptId' in request, false); - assert.equal('checkpointId' in request, false); - assert.equal('headId' in request, false); - assert.equal('reason' in request, false); + 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, - }; - const retentionForgedAnchor = rekeyAnchor({ - ...facts.anchor, - retention: { retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG }, - }, anchorDigest, createHashPort()); - const retentionForgedCheckpoint = { - ...facts.checkpoint, - causalAnchorId: retentionForgedAnchor.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, - }); - assert.deepEqual(contract.validateRopeFact( - retentionForgedCheckpoint, - createValidationContext(contract, [...facts.baseFacts, retentionForgedAnchor, retentionForgedCheckpoint]), - ), { - 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 admission distinguishes authority roots from materialization artifacts', async () => { - const contract = await loadContract(); - const hash = createHashPort(); - const admission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); - const retainedRoot = { - 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: 'rope-head:authority', - role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY, - }; - const materializationRoot = { - kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, - id: 'cas:flat-text-window', - role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION, - }; - const anchor = admission.admitCausalAnchor(contract.makeEchoCausalAnchorAdmissionRequest({ - subject: { - appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - subjectId: 'worldline:authority-vs-cache', - }, - basisFrontierDigest: 'frontier:authority-vs-cache', - retainedRoots: [retainedRoot], - materializationRoots: [materializationRoot], - purpose: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, - retention: { - retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, - }, - })).anchor; - - assert.equal(contract.validateRopeFact( - anchor, - createValidationContext(contract, [anchor]), - ).ok, true); - assert.deepEqual(anchor.retainedRoots, [retainedRoot]); - assert.deepEqual(anchor.materializationRoots, [materializationRoot]); - assert.equal(anchor.retainedRoots[0].role, contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY); - assert.equal(anchor.materializationRoots[0].role, contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION); -}); - -test('causal anchor retention metadata distinguishes anchor policy classes', async () => { - const contract = await loadContract(); - const hash = createHashPort(); - const admission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); - const retentionClasses = [ - contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RECOVERY, - contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT, - contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE, - contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_AUTOSAVE, - contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG, - contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_CACHE_WARM, - ]; - - const anchors = retentionClasses.map((retentionClass) => admission.admitCausalAnchor( - contract.makeEchoCausalAnchorAdmissionRequest({ - subject: { - appId: contract.JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - subjectId: `worldline:${retentionClass}`, - }, - basisFrontierDigest: `frontier:${retentionClass}`, - retainedRoots: [{ - kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, - id: `fact:${retentionClass}`, - role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_EVIDENCE, - }], - purpose: retentionClass, - retention: { retentionClass }, - }), - ).anchor); - - assert.deepEqual(anchors.map((anchor) => anchor.retention.retentionClass), retentionClasses); - assert.equal(new Set(anchors.map((anchor) => anchor.anchorId)).size, retentionClasses.length); - for (const anchor of anchors) { - assert.equal(contract.validateRopeFact( - anchor, - createValidationContext(contract, [anchor]), - ).ok, true); - } -}); - -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, }); - assert.deepEqual(contract.validateRopeFact( - { ...facts.anchor, retention: { retentionClass: 'pretend-save' } }, - createValidationContext(contract, facts.writeSet), - ), { - ok: false, - code: FACT_VALIDATION_ERROR_INVALID_REFERENCE, + 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 () => { @@ -571,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, @@ -623,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 6027f5c..a8e9bc6 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -1,6 +1,7 @@ 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 WINDOW_CACHE_STATUS_UNCACHED = 'uncached-materialization'; @@ -9,6 +10,9 @@ const OBSTRUCTION_MISSING_HEAD = 'missing-head'; const OBSTRUCTION_INVALID_BYTE_RANGE = 'invalid-byte-range'; const OBSTRUCTION_INVALID_UTF8_BOUNDARY = 'invalid-utf8-boundary'; 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'; async function loadModules() { const [runtime, contract] = await Promise.all([ @@ -175,20 +179,9 @@ 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 () => { +test('graph runtime declares a checkpoint without requiring Echo admission', async () => { const { runtime, contract } = await loadModules(); - const hash = createHashPort(); - const admittedRequests = []; - const echoAdmission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); - const graph = runtime.createGraphRopeRuntime({ - hash, - causalAnchorAdmission: { - admitCausalAnchor(request) { - admittedRequests.push(request); - return echoAdmission.admitCausalAnchor(request); - }, - }, - }); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); const created = assertOk(graph.createBufferWorldline({ worldlineId: 'worldline:checkpoint', initialText: 'alpha beta', @@ -213,33 +206,10 @@ test('graph runtime checkpoints a head through a non-mutating causal anchor', as 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(admittedRequests.length, 1); - assert.equal(admittedRequests[0].subject.appId, contract.JEDIT_CAUSAL_ANCHOR_APP_ID); - assert.equal(admittedRequests[0].subject.subjectKind, contract.JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE); - assert.equal(admittedRequests[0].subject.subjectId, 'worldline:checkpoint'); - assert.equal(admittedRequests[0].basisFrontierDigest, checkpointed.causalAnchor.basisFrontierDigest); - assert.deepEqual(admittedRequests[0].retainedRoots, checkpointed.causalAnchor.retainedRoots); - assert.deepEqual(admittedRequests[0].materializationRoots, []); - assert.equal('admittedByReceiptId' in admittedRequests[0], false); - assert.equal(checkpointed.causalAnchorReceipt.authority, contract.ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO); - assert.equal(checkpointed.causalAnchorReceipt.anchorId, checkpointed.causalAnchor.anchorId); - assert.equal(checkpointed.causalAnchorReceipt.receiptId, checkpointed.causalAnchor.admittedByReceiptId); - 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.retention, { - retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_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(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); @@ -247,47 +217,37 @@ test('graph runtime checkpoints a head through a non-mutating causal anchor', as assert.equal(reading.text, 'alpha BETA'); }); -test('graph runtime rejects checkpoint admissions with mismatched Echo receipts', async () => { - const { runtime, contract } = await loadModules(); - const hash = createHashPort(); - const echoAdmission = contract.createDeterministicEchoCausalAnchorAdmissionPort({ hash }); - const graph = runtime.createGraphRopeRuntime({ - hash, - causalAnchorAdmission: { - admitCausalAnchor(request) { - const admission = echoAdmission.admitCausalAnchor(request); - return { - ...admission, - receipt: { - ...admission.receipt, - anchorId: 'causal-anchor:forged', - }, - }; - }, - }, - }); +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-bad-receipt', + worldlineId: 'worldline:checkpoint-no-echo', initialText: 'alpha', })); - - assert.deepEqual(graph.createCheckpoint({ - worldlineId: 'worldline:checkpoint-bad-receipt', + 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_INVALID_FACT, + code: OBSTRUCTION_CAUSAL_ANCHOR_UNAVAILABLE, }); }); -test('graph runtime checkpoint anchors optional materialization roots as cache artifacts', async () => { - const { runtime, contract } = await loadModules(); - const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); +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 = { - kind: contract.ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, id: 'cas:checkpoint-flat-text', - role: contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_MATERIALIZATION, + role: 'materialization', }; const created = assertOk(graph.createBufferWorldline({ worldlineId: 'worldline:checkpoint-materialization', @@ -299,22 +259,164 @@ test('graph runtime checkpoint anchors optional materialization roots as cache a 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(checkpointed.causalAnchor.materializationRoots, [materializationRoot]); - assert.equal(checkpointed.causalAnchor.retainedRoots.length, 1); - assert.equal(checkpointed.causalAnchor.retainedRoots[0].role, contract.ECHO_CAUSAL_ANCHOR_ROOT_ROLE_AUTHORITY); - assert.equal(checkpointed.causalAnchor.purpose, contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT); - assert.deepEqual(checkpointed.causalAnchor.retention, { - retentionClass: contract.ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT, + 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(checkpointed.checkpoint.causalAnchorId, checkpointed.causalAnchor.anchorId); + 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 treats repeated checkpoints as distinct causal admissions', async () => { +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], + [{ 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 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 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({ @@ -337,16 +439,60 @@ test('graph runtime treats repeated checkpoints as distinct causal admissions', 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(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() }); @@ -365,6 +511,24 @@ test('graph runtime rejects checkpoints for a different worldline', async () => }); }); +test('graph runtime rejects unknown checkpoint reasons', async () => { + const { runtime } = await loadModules(); + const graph = runtime.createGraphRopeRuntime({ hash: createHashPort() }); + const created = assertOk(graph.createBufferWorldline({ + worldlineId: 'worldline:checkpoint-reason', + initialText: 'alpha', + })); + + assert.deepEqual(graph.createCheckpoint({ + worldlineId: 'worldline:checkpoint-reason', + headId: created.head.headId, + reason: 'pretend-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/test-echo-causal-anchor-admission.mjs b/spec/support/test-echo-causal-anchor-admission.mjs new file mode 100644 index 0000000..c567113 --- /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-admission.ts b/src/domain/graph-rope-causal-anchor-admission.ts deleted file mode 100644 index 0a0ae9d..0000000 --- a/src/domain/graph-rope-causal-anchor-admission.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO, - ECHO_CAUSAL_ANCHOR_FACT_KIND, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT, - ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT, - GRAPH_ROPE_SCHEMA_VERSION, - type EchoCausalAnchorAdmissionPort, - type EchoCausalAnchorAdmissionRequest, - type EchoCausalAnchorAdmissionRequestInput, - type EchoCausalAnchorAdmissionResult, - type EchoCausalAnchorAppSubjectRoot, - type EchoCausalAnchorCasObjectRoot, - type EchoCausalAnchorFact, - type EchoCausalAnchorGraphFactRoot, - type EchoCausalAnchorRetentionMetadata, - type EchoCausalAnchorRoot, - type EchoCausalAnchorSubject, - type TextBlobHashPort, -} from './graph-rope-types.js'; -import { - causalAnchorDigestFor, - causalAnchorIdForDigest, -} from './graph-rope-causal-anchor-digest.js'; - -const RUNTIME_HASH_PREFIX_CAUSAL_ANCHOR_RECEIPT = 'causal-anchor-receipt:'; - -export interface CreateDeterministicEchoCausalAnchorAdmissionPortInput { - readonly hash: TextBlobHashPort; -} - -export function makeEchoCausalAnchorAdmissionRequest( - input: EchoCausalAnchorAdmissionRequestInput, -): EchoCausalAnchorAdmissionRequest { - return { - subject: cloneAnchorSubject(input.subject), - basisFrontierDigest: input.basisFrontierDigest, - retainedRoots: input.retainedRoots.map(cloneAnchorRoot), - materializationRoots: (input.materializationRoots ?? []).map(cloneAnchorRoot), - purpose: input.purpose, - retention: cloneRetentionMetadata(input.retention), - }; -} - -export function createDeterministicEchoCausalAnchorAdmissionPort( - input: CreateDeterministicEchoCausalAnchorAdmissionPortInput, -): EchoCausalAnchorAdmissionPort { - let nextAdmissionSequence = 1; - - return { - admitCausalAnchor(request) { - const sequence = nextAdmissionSequence; - nextAdmissionSequence += 1; - return admitCausalAnchor(request, input.hash, sequence); - }, - }; -} - -function admitCausalAnchor( - request: EchoCausalAnchorAdmissionRequest, - hash: TextBlobHashPort, - sequence: number, -): EchoCausalAnchorAdmissionResult { - const admittedByReceiptId = anchorReceiptIdFor(request, sequence, hash); - const anchorBasis: Omit = { - kind: ECHO_CAUSAL_ANCHOR_FACT_KIND, - schemaVersion: GRAPH_ROPE_SCHEMA_VERSION, - subject: cloneAnchorSubject(request.subject), - basisFrontierDigest: request.basisFrontierDigest, - retainedRoots: request.retainedRoots.map(cloneAnchorRoot), - materializationRoots: request.materializationRoots.map(cloneAnchorRoot), - purpose: request.purpose, - retention: cloneRetentionMetadata(request.retention), - admittedByReceiptId, - }; - const anchorDigest = causalAnchorDigestFor(anchorBasis, hash); - const anchorId = causalAnchorIdForDigest(anchorDigest, hash); - return { - anchor: { - ...anchorBasis, - anchorId, - anchorDigest, - }, - receipt: { - authority: ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO, - receiptId: admittedByReceiptId, - anchorId, - }, - }; -} - -function anchorReceiptIdFor( - request: EchoCausalAnchorAdmissionRequest, - sequence: number, - hash: TextBlobHashPort, -): string { - return hash.sha256Hex([ - RUNTIME_HASH_PREFIX_CAUSAL_ANCHOR_RECEIPT, - request.subject.appId, - request.subject.subjectKind, - request.subject.subjectId, - request.basisFrontierDigest, - request.purpose, - request.retention.retentionClass, - String(sequence), - ].join(':')); -} - -function cloneAnchorSubject(subject: EchoCausalAnchorSubject): EchoCausalAnchorSubject { - return { - appId: subject.appId, - subjectKind: subject.subjectKind, - subjectId: subject.subjectId, - }; -} - -function cloneRetentionMetadata(retention: EchoCausalAnchorRetentionMetadata): EchoCausalAnchorRetentionMetadata { - return { - retentionClass: retention.retentionClass, - }; -} - -function cloneAnchorRoot(root: EchoCausalAnchorRoot): EchoCausalAnchorRoot { - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_CAS_OBJECT) { - return cloneCasObjectRoot(root); - } - if (root.kind === ECHO_CAUSAL_ANCHOR_ROOT_KIND_GRAPH_FACT) { - return cloneGraphFactRoot(root); - } - return cloneAppSubjectRoot(root); -} - -function cloneCasObjectRoot(root: EchoCausalAnchorCasObjectRoot): EchoCausalAnchorCasObjectRoot { - return { - kind: root.kind, - id: root.id, - role: root.role, - }; -} - -function cloneGraphFactRoot(root: EchoCausalAnchorGraphFactRoot): EchoCausalAnchorGraphFactRoot { - return { - kind: root.kind, - id: root.id, - role: root.role, - }; -} - -function cloneAppSubjectRoot(root: EchoCausalAnchorAppSubjectRoot): EchoCausalAnchorAppSubjectRoot { - return { - kind: root.kind, - appId: root.appId, - subjectKind: root.subjectKind, - id: root.id, - role: root.role, - }; -} 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 7c88d59..0000000 --- a/src/domain/graph-rope-causal-anchor-digest.ts +++ /dev/null @@ -1,52 +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}`, - `retention=${anchor.retention.retentionClass}`, - `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 c7db6e8..be423bc 100644 --- a/src/domain/graph-rope-causal-anchor-validation.ts +++ b/src/domain/graph-rope-causal-anchor-validation.ts @@ -1,204 +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, - checkpointAnchorRetentionClass, - 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 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, +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, ]); -export function validateEchoCausalAnchorFact( - fact: RopeAdmittedFact, - context: RopeFactValidationContext, -): FactValidationResult { - if (fact.kind !== ECHO_CAUSAL_ANCHOR_FACT_KIND) { - return invalidFact(FACT_VALIDATION_ERROR_INVALID_KIND); - } - const issue = validateAnchorHeader(fact) - ?? validateAnchorRootSets(fact.retainedRoots, fact.materializationRoots) - ?? validateAnchorIdentity(fact, context); - if (issue !== null) { - return invalidFact(issue); - } - return validFact(fact); -} - -function validateAnchorHeader(fact: EchoCausalAnchorFact): FactValidationErrorCode | null { - if (isInvalidHash(fact.anchorDigest)) { - return FACT_VALIDATION_ERROR_INVALID_HASH; - } - const policyIssue = validateAnchorPolicy(fact); - if (policyIssue !== null) { - return policyIssue; - } - return invalidIdIn([ - fact.anchorId, - fact.subject.appId, - fact.subject.subjectKind, - fact.subject.subjectId, - fact.basisFrontierDigest, - fact.retention.retentionClass, - fact.admittedByReceiptId, - ]); -} - -function validateAnchorPolicy(fact: EchoCausalAnchorFact): FactValidationErrorCode | null { - return validateAnchorPurpose(fact.purpose) ?? validateAnchorRetention(fact.retention); -} - -function validateAnchorPurpose(purpose: string): FactValidationErrorCode | null { - return VALID_ANCHOR_PURPOSES.has(purpose) ? null : FACT_VALIDATION_ERROR_INVALID_REFERENCE; -} - -function validateAnchorRetention( - retention: EchoCausalAnchorFact['retention'] | null | undefined, +export function validateCheckpointAnchorAdmissionRequest( + request: RopeCheckpointAnchorAdmissionRequest, ): FactValidationErrorCode | null { - if (retention == null) { - return FACT_VALIDATION_ERROR_INVALID_REFERENCE; - } - return VALID_ANCHOR_PURPOSES.has(retention.retentionClass) ? null : FACT_VALIDATION_ERROR_INVALID_REFERENCE; -} - -function validateAnchorIdentity( - fact: EchoCausalAnchorFact, - context: RopeFactValidationContext, -): FactValidationErrorCode | null { - if (fact.anchorDigest !== causalAnchorDigestFor(fact, context.hash)) { - return FACT_VALIDATION_ERROR_HASH_MISMATCH; - } - return fact.anchorId === causalAnchorIdForDigest(fact.anchorDigest, context.hash) - ? null - : FACT_VALIDATION_ERROR_HASH_MISMATCH; -} - -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 checkpointAnchorSubjectMatches(anchor, fact) - && checkpointAnchorFrontierMatches(anchor, head, context) - && checkpointAnchorPolicyMatches(anchor, fact) - && checkpointAnchorRetainsHead(anchor, fact); -} - -function checkpointAnchorSubjectMatches(anchor: EchoCausalAnchorFact, fact: RopeCheckpointFact): boolean { - return anchor.subject.appId === JEDIT_CAUSAL_ANCHOR_APP_ID - && anchor.subject.subjectKind === JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE - && anchor.subject.subjectId === fact.worldlineId; -} - -function checkpointAnchorFrontierMatches( - anchor: EchoCausalAnchorFact, - head: RopeHeadFact, - context: RopeFactValidationContext, -): boolean { - return anchor.basisFrontierDigest === basisFrontierDigestForRopeHead(head, context.hash); -} - -function checkpointAnchorPolicyMatches(anchor: EchoCausalAnchorFact, fact: RopeCheckpointFact): boolean { - return anchor.purpose === checkpointAnchorPurpose(fact.reason) - && anchor.retention.retentionClass === checkpointAnchorRetentionClass(fact.reason); -} - -function checkpointAnchorRetainsHead(anchor: EchoCausalAnchorFact, fact: RopeCheckpointFact): boolean { - return anchor.retainedRoots.some((root) => isJeditRopeHeadAuthorityRoot(root, fact.headId)); -} - -function validateAnchorRootSets( - retainedRoots: readonly EchoCausalAnchorRoot[], - materializationRoots: readonly EchoCausalAnchorRoot[], -): 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); + return validateMaterializationRoots(request.materializationRoots); } -function validateMaterializationAnchorRootSet(roots: readonly EchoCausalAnchorRoot[]): FactValidationErrorCode | null { - return validateAnchorRootSet(roots, anchorRootIsUsableMaterializationRoot); -} - -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; } @@ -207,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 80a9469..945c51d 100644 --- a/src/domain/graph-rope-checkpoint-identity.ts +++ b/src/domain/graph-rope-checkpoint-identity.ts @@ -1,62 +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 EchoCausalAnchorRetentionClass, - 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, - input.causalAnchorId, - input.admittedByReceiptId, - ].join(':'))}`; + ]))}`; } -export function checkpointAnchorPurpose(reason: RopeCheckpointReason): EchoCausalAnchorPurpose { - return checkpointAnchorRetentionClass(reason); +export function ropeCheckpointAnchorAssociationIdFor( + input: RopeCheckpointAnchorAssociationIdInput, +): string { + return `${RUNTIME_HASH_PREFIX_CHECKPOINT_ANCHOR_ASSOCIATION_ID}${input.hash.sha256Hex(identityTuple([ + input.checkpointId, + input.causalAnchorId, + input.causalAnchorFactId, + input.causalAnchorReceiptId, + ]))}`; } -export function checkpointAnchorRetentionClass(reason: RopeCheckpointReason): EchoCausalAnchorRetentionClass { - 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 0000000..9b91c34 --- /dev/null +++ b/src/domain/graph-rope-checkpoint-validation.ts @@ -0,0 +1,117 @@ +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 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 (!VALID_CHECKPOINT_REASONS.has(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 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-contract.ts b/src/domain/graph-rope-contract.ts index 2bd9e9d..b5887b0 100644 --- a/src/domain/graph-rope-contract.ts +++ b/src/domain/graph-rope-contract.ts @@ -1,6 +1,5 @@ export * from './graph-rope-coordinates.js'; export * from './graph-rope-fact-id.js'; export * from './graph-rope-types.js'; -export * from './graph-rope-causal-anchor-admission.js'; export * from './graph-rope-text-blob-validation.js'; export * from './graph-rope-validation.js'; diff --git a/src/domain/graph-rope-fact-id.ts b/src/domain/graph-rope-fact-id.ts index bae4b58..79c53cf 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 ce864da..efef858 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -1,25 +1,18 @@ import { - 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, - makeEchoCausalAnchorAdmissionRequest, - type EchoCausalAnchorAdmissionRequest, - type EchoCausalAnchorAdmissionResult, - type EchoCausalAnchorAppSubjectRoot, - type EchoCausalAnchorRoot, + type EchoCausalAnchorAdmissionEvidence, + type RopeCheckpointAnchorAdmissionRequest, + type RopeCheckpointAnchoredFact, type RopeCheckpointFact, + type RopeCheckpointMaterializationRoot, type RopeCheckpointReason, type RopeHeadFact, type TextBlobHashPort, } from './graph-rope-contract.js'; import { - basisFrontierDigestForRopeHead, - checkpointAnchorPurpose, - checkpointAnchorRetentionClass, + ropeCheckpointAnchorAssociationIdFor, ropeCheckpointIdFor, } from './graph-rope-checkpoint-identity.js'; @@ -27,75 +20,76 @@ export interface GraphRopeCreateCheckpointInput { readonly worldlineId: string; readonly headId: string; readonly reason: RopeCheckpointReason; - readonly materializationRoots?: readonly EchoCausalAnchorRoot[]; } export interface GraphRopeCreateCheckpointResult { readonly head: RopeHeadFact; - readonly causalAnchor: EchoCausalAnchorAdmissionResult['anchor']; - readonly causalAnchorReceipt: EchoCausalAnchorAdmissionResult['receipt']; readonly checkpoint: RopeCheckpointFact; } -export function createCheckpointAnchorAdmissionRequest( - head: RopeHeadFact, - reason: RopeCheckpointReason, - hash: TextBlobHashPort, - materializationRoots: readonly EchoCausalAnchorRoot[] = [], -): EchoCausalAnchorAdmissionRequest { - return makeEchoCausalAnchorAdmissionRequest({ - subject: { - appId: JEDIT_CAUSAL_ANCHOR_APP_ID, - subjectKind: JEDIT_CAUSAL_ANCHOR_SUBJECT_KIND_BUFFER_WORLDLINE, - subjectId: head.worldlineId, - }, - basisFrontierDigest: basisFrontierDigestForRopeHead(head, hash), - retainedRoots: [retainedRopeHeadRoot(head.headId)], - materializationRoots, - purpose: checkpointAnchorPurpose(reason), - retention: { - retentionClass: checkpointAnchorRetentionClass(reason), - }, - }); +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 createCheckpointFacts( +export function createCheckpointFact( head: RopeHeadFact, reason: RopeCheckpointReason, - anchorAdmission: EchoCausalAnchorAdmissionResult, hash: TextBlobHashPort, -): GraphRopeCreateCheckpointResult { - const causalAnchor = anchorAdmission.anchor; - 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, }; +} + +export function createCheckpointAnchorAdmissionRequest( + checkpoint: RopeCheckpointFact, + materializationRoots: readonly RopeCheckpointMaterializationRoot[] = [], +): RopeCheckpointAnchorAdmissionRequest { return { - head, - causalAnchor, - causalAnchorReceipt: anchorAdmission.receipt, - checkpoint, + checkpointId: checkpoint.checkpointId, + worldlineId: checkpoint.worldlineId, + headId: checkpoint.headId, + reason: checkpoint.reason, + materializationRoots: [...materializationRoots], }; } -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, }; } 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 0000000..1db4d7c --- /dev/null +++ b/src/domain/graph-rope-runtime-echo-adapter.ts @@ -0,0 +1,49 @@ +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 || !isUsableEchoEvidence(result.evidence)) { + return null; + } + return 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 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 c0d5ad2..84a1e9d 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 0cfc7c0..43018d9 100644 --- a/src/domain/graph-rope-runtime.ts +++ b/src/domain/graph-rope-runtime.ts @@ -1,15 +1,15 @@ import { BUFFER_WORLDLINE_FACT_KIND, GRAPH_ROPE_SCHEMA_VERSION, + ROPE_CHECKPOINT_FACT_KIND, ROPE_HEAD_FACT_KIND, - createDeterministicEchoCausalAnchorAdmissionPort, makeTextBlobFact, ropeFactId, validateRopeFact, type BufferWorldlineFact, type EchoCausalAnchorAdmissionPort, - type EchoCausalAnchorAdmissionResult, type RopeAdmittedFact, + type RopeCheckpointFact, type RopeDiffFact, type RopeFactValidationContext, type RopeHeadFact, @@ -19,15 +19,23 @@ import { type TextByteRange, type TickReceiptFact, } from './graph-rope-contract.js'; +import { validateCheckpointAnchorAdmissionRequest } from './graph-rope-causal-anchor-validation.js'; import { createCheckpointAnchorAdmissionRequest, - createCheckpointFacts, + 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, @@ -49,12 +57,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'; @@ -145,13 +158,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; + readonly causalAnchorAdmission: EchoCausalAnchorAdmissionPort | null; readonly factsById: Map; readonly currentHeadByWorldlineId: Map; nextAdmissionSequence: number; @@ -165,9 +179,7 @@ export function createGraphRopeRuntime(input: CreateGraphRopeRuntimeInput): Grap const factsById = new Map(); const state: GraphRopeRuntimeState = { hash: input.hash, - causalAnchorAdmission: input.causalAnchorAdmission ?? createDeterministicEchoCausalAnchorAdmissionPort({ - hash: input.hash, - }), + causalAnchorAdmission: input.causalAnchorAdmission ?? null, factsById, currentHeadByWorldlineId: new Map(), nextAdmissionSequence: INITIAL_ADMISSION_SEQUENCE, @@ -186,6 +198,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); }, @@ -246,22 +261,46 @@ function createCheckpoint( if (head.worldlineId !== input.worldlineId) { return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; } - const anchorRequest = createCheckpointAnchorAdmissionRequest(head, input.reason, state.hash, input.materializationRoots); - const anchorAdmission = state.causalAnchorAdmission.admitCausalAnchor(anchorRequest); - if (!anchorAdmissionMatches(anchorAdmission)) { - return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; - } - const checkpointFacts = createCheckpointFacts(head, input.reason, anchorAdmission, state.hash); - 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 }; } - return { ok: true, value: cloneCheckpointResult(checkpointFacts) }; + return { ok: true, value: cloneCheckpointResult({ head, checkpoint }) }; } -function anchorAdmissionMatches(anchorAdmission: EchoCausalAnchorAdmissionResult): boolean { - return anchorAdmission.receipt.anchorId === anchorAdmission.anchor.anchorId - && anchorAdmission.receipt.receiptId === anchorAdmission.anchor.admittedByReceiptId; +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 (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( @@ -390,12 +429,19 @@ function cloneReplaceResult(result: GraphRopeReplaceRangeResult): GraphRopeRepla function cloneCheckpointResult(result: GraphRopeCreateCheckpointResult): GraphRopeCreateCheckpointResult { return { head: cloneFact(result.head), - causalAnchor: cloneFact(result.causalAnchor), - causalAnchorReceipt: cloneFact(result.causalAnchorReceipt), 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); } @@ -429,6 +475,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 010fe17..b3b5d91 100644 --- a/src/domain/graph-rope-types.ts +++ b/src/domain/graph-rope-types.ts @@ -19,28 +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 ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RECOVERY = 'recovery'; -export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RETENTION = 'retention'; -export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT = 'export'; -export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE = 'user-save'; -export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_AUTOSAVE = 'autosave'; -export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG = 'debug'; -export const ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_CACHE_WARM = 'cache-warm'; -export const ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO = 'echo'; +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'; @@ -267,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; @@ -274,109 +264,49 @@ export interface RopeCheckpointFact { readonly checkpointId: string; readonly worldlineId: string; readonly headId: string; - readonly causalAnchorId: string; readonly reason: RopeCheckpointReason; } -export type EchoCausalAnchorPurpose = - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RECOVERY - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_RETENTION - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_EXPORT - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_USER_SAVE - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_AUTOSAVE - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_DEBUG - | typeof ECHO_CAUSAL_ANCHOR_RETENTION_CLASS_CACHE_WARM; - -export type EchoCausalAnchorRetentionClass = EchoCausalAnchorPurpose; - -export interface EchoCausalAnchorRetentionMetadata { - readonly retentionClass: EchoCausalAnchorRetentionClass; -} - -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 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 RopeCheckpointAnchorAdmissionRequest { + readonly checkpointId: string; + readonly worldlineId: string; + readonly headId: string; + readonly reason: RopeCheckpointReason; + readonly materializationRoots: readonly RopeCheckpointMaterializationRoot[]; } -export type EchoCausalAnchorRoot = - | EchoCausalAnchorCasObjectRoot - | EchoCausalAnchorGraphFactRoot - | EchoCausalAnchorAppSubjectRoot; - -export interface EchoCausalAnchorFact { - readonly kind: typeof ECHO_CAUSAL_ANCHOR_FACT_KIND; - readonly schemaVersion: typeof GRAPH_ROPE_SCHEMA_VERSION; +export interface EchoCausalAnchorAdmissionEvidence { readonly anchorId: string; - readonly subject: EchoCausalAnchorSubject; - readonly basisFrontierDigest: string; - readonly retainedRoots: readonly EchoCausalAnchorRoot[]; - readonly materializationRoots: readonly EchoCausalAnchorRoot[]; - readonly purpose: EchoCausalAnchorPurpose; - readonly retention: EchoCausalAnchorRetentionMetadata; - readonly admittedByReceiptId: string; - readonly anchorDigest: string; -} - -export interface EchoCausalAnchorAdmissionRequest { - readonly subject: EchoCausalAnchorSubject; - readonly basisFrontierDigest: string; - readonly retainedRoots: readonly EchoCausalAnchorRoot[]; - readonly materializationRoots: readonly EchoCausalAnchorRoot[]; - readonly purpose: EchoCausalAnchorPurpose; - readonly retention: EchoCausalAnchorRetentionMetadata; -} - -export interface EchoCausalAnchorAdmissionRequestInput { - readonly subject: EchoCausalAnchorSubject; - readonly basisFrontierDigest: string; - readonly retainedRoots: readonly EchoCausalAnchorRoot[]; - readonly materializationRoots?: readonly EchoCausalAnchorRoot[]; - readonly purpose: EchoCausalAnchorPurpose; - readonly retention: EchoCausalAnchorRetentionMetadata; -} - -export interface EchoCausalAnchorAdmissionReceipt { - readonly authority: typeof ECHO_CAUSAL_ANCHOR_ADMISSION_AUTHORITY_ECHO; + readonly anchorFactId: string; readonly receiptId: string; - readonly anchorId: string; } -export interface EchoCausalAnchorAdmissionResult { - readonly anchor: EchoCausalAnchorFact; - readonly receipt: EchoCausalAnchorAdmissionReceipt; -} +export type EchoCausalAnchorAdmissionResult = + | { readonly ok: true; readonly evidence: EchoCausalAnchorAdmissionEvidence } + | { readonly ok: false; readonly obstructionId: string }; export interface EchoCausalAnchorAdmissionPort { - admitCausalAnchor(request: EchoCausalAnchorAdmissionRequest): EchoCausalAnchorAdmissionResult; + admitCheckpointAnchor(request: RopeCheckpointAnchorAdmissionRequest): EchoCausalAnchorAdmissionResult; +} + +export interface RopeCheckpointAnchoredFact { + readonly kind: typeof ROPE_CHECKPOINT_ANCHORED_FACT_KIND; + readonly schemaVersion: typeof GRAPH_ROPE_SCHEMA_VERSION; + readonly associationId: string; + readonly checkpointId: string; + readonly causalAnchorId: string; + readonly causalAnchorFactId: string; + readonly causalAnchorReceiptId: string; } export type RopeAdmittedFact = @@ -390,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 caa1fb3..b0acdc0 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}`)}`; } From e37db4319a7c8e04fffd06d344b2784efbd5b470 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 15 Jul 2026 04:09:40 -0700 Subject: [PATCH 06/10] Fix: snapshot causal anchor boundary values --- spec/graph-rope-runtime.spec.mjs | 95 +++++++++++++++++++ src/domain/graph-rope-runtime-checkpoint.ts | 23 +++-- src/domain/graph-rope-runtime-echo-adapter.ts | 18 +++- src/domain/graph-rope-runtime.ts | 2 +- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index a8e9bc6..0a9b59c 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -325,6 +325,50 @@ test('graph runtime validates materialization roots before invoking Echo', async 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({ @@ -388,6 +432,57 @@ test('graph runtime fails closed on malformed Echo evidence', async () => { }); }); +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'; + }, + }, + }); + 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(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 fails closed when the Echo adapter throws', async () => { const { runtime } = await loadModules(); const graph = runtime.createGraphRopeRuntime({ diff --git a/src/domain/graph-rope-runtime-checkpoint.ts b/src/domain/graph-rope-runtime-checkpoint.ts index efef858..ea6aef7 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -62,14 +62,21 @@ export function createCheckpointFact( export function createCheckpointAnchorAdmissionRequest( checkpoint: RopeCheckpointFact, materializationRoots: readonly RopeCheckpointMaterializationRoot[] = [], -): RopeCheckpointAnchorAdmissionRequest { - return { - checkpointId: checkpoint.checkpointId, - worldlineId: checkpoint.worldlineId, - headId: checkpoint.headId, - reason: checkpoint.reason, - materializationRoots: [...materializationRoots], - }; +): RopeCheckpointAnchorAdmissionRequest | null { + try { + return { + checkpointId: checkpoint.checkpointId, + worldlineId: checkpoint.worldlineId, + headId: checkpoint.headId, + reason: checkpoint.reason, + materializationRoots: materializationRoots.map((root) => ({ + id: root.id, + role: root.role, + })), + }; + } catch { + return null; + } } export function createCheckpointAnchorAssociation( diff --git a/src/domain/graph-rope-runtime-echo-adapter.ts b/src/domain/graph-rope-runtime-echo-adapter.ts index 1db4d7c..4e6daff 100644 --- a/src/domain/graph-rope-runtime-echo-adapter.ts +++ b/src/domain/graph-rope-runtime-echo-adapter.ts @@ -13,10 +13,10 @@ export function requestCheckpointAnchorAdmission( ): EchoCausalAnchorAdmissionEvidence | null { try { const result = port.admitCheckpointAnchor(cloneRequest(request)); - if (result == null || !result.ok || !isUsableEchoEvidence(result.evidence)) { + if (result == null || !result.ok) { return null; } - return result.evidence; + return snapshotEvidence(result.evidence); } catch { return null; } @@ -35,6 +35,20 @@ function cloneRequest(request: RopeCheckpointAnchorAdmissionRequest): RopeCheckp }; } +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 { diff --git a/src/domain/graph-rope-runtime.ts b/src/domain/graph-rope-runtime.ts index 43018d9..128d774 100644 --- a/src/domain/graph-rope-runtime.ts +++ b/src/domain/graph-rope-runtime.ts @@ -282,7 +282,7 @@ function anchorCheckpoint( return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_MISSING_HEAD }; } const request = createCheckpointAnchorAdmissionRequest(checkpoint, input.materializationRoots); - if (validateCheckpointAnchorAdmissionRequest(request) !== null) { + if (request === null || validateCheckpointAnchorAdmissionRequest(request) !== null) { return { ok: false, code: GRAPH_ROPE_RUNTIME_OBSTRUCTION_INVALID_FACT }; } if (state.causalAnchorAdmission === null) { From 79f698e8c07b22f7509601f10aa22d00eeff2a85 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 15 Jul 2026 04:19:33 -0700 Subject: [PATCH 07/10] Fix: require explicit Echo adapter success --- spec/graph-rope-runtime.spec.mjs | 41 +++++++++++++++++++ src/domain/graph-rope-runtime-echo-adapter.ts | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index 0a9b59c..7928b73 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -304,6 +304,8 @@ test('graph runtime validates materialization roots before invoking Echo', async })); const invalidRootSets = [ + null, + {}, [null], [{ id: '', role: 'materialization' }], [{ id: 'cas:authority', role: 'authority' }], @@ -455,6 +457,8 @@ test('graph runtime snapshots opaque Echo evidence before admitting its associat }, }, }); + evidence.authority = 'echo'; + evidence.nonCloneable = () => 'adapter-owned'; const graph = runtime.createGraphRopeRuntime({ hash: createHashPort(), causalAnchorAdmission: createTestEchoCausalAnchorAdmissionPort({ @@ -478,11 +482,48 @@ test('graph runtime snapshots opaque Echo evidence before admitting its associat })); 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({ diff --git a/src/domain/graph-rope-runtime-echo-adapter.ts b/src/domain/graph-rope-runtime-echo-adapter.ts index 4e6daff..28a71e5 100644 --- a/src/domain/graph-rope-runtime-echo-adapter.ts +++ b/src/domain/graph-rope-runtime-echo-adapter.ts @@ -13,7 +13,7 @@ export function requestCheckpointAnchorAdmission( ): EchoCausalAnchorAdmissionEvidence | null { try { const result = port.admitCheckpointAnchor(cloneRequest(request)); - if (result == null || !result.ok) { + if (result == null || result.ok !== true) { return null; } return snapshotEvidence(result.evidence); From 06476d27c34c979d1815b7f96e2672fa2208740d Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 15 Jul 2026 04:36:16 -0700 Subject: [PATCH 08/10] Fix: reject malformed materialization root containers --- spec/graph-rope-runtime.spec.mjs | 1 + src/domain/graph-rope-runtime-checkpoint.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index 7928b73..a1fb279 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -306,6 +306,7 @@ test('graph runtime validates materialization roots before invoking Echo', async const invalidRootSets = [ null, {}, + { map() { return null; } }, [null], [{ id: '', role: 'materialization' }], [{ id: 'cas:authority', role: 'authority' }], diff --git a/src/domain/graph-rope-runtime-checkpoint.ts b/src/domain/graph-rope-runtime-checkpoint.ts index ea6aef7..3f23b2a 100644 --- a/src/domain/graph-rope-runtime-checkpoint.ts +++ b/src/domain/graph-rope-runtime-checkpoint.ts @@ -63,13 +63,16 @@ 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: materializationRoots.map((root) => ({ + materializationRoots: Array.from(materializationRoots, (root) => ({ id: root.id, role: root.role, })), From cb947268e1475cfb2dd5f9295c1c1fbd880cf378 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 15 Jul 2026 04:39:18 -0700 Subject: [PATCH 09/10] Fix: validate checkpoint reasons before hashing --- spec/graph-rope-runtime.spec.mjs | 20 ++++++++++--------- .../graph-rope-checkpoint-validation.ts | 7 ++++++- src/domain/graph-rope-runtime.ts | 4 ++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/spec/graph-rope-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index a1fb279..8341cfb 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -648,7 +648,7 @@ test('graph runtime rejects checkpoints for a different worldline', async () => }); }); -test('graph runtime rejects unknown checkpoint reasons', async () => { +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({ @@ -656,14 +656,16 @@ test('graph runtime rejects unknown checkpoint reasons', async () => { initialText: 'alpha', })); - assert.deepEqual(graph.createCheckpoint({ - worldlineId: 'worldline:checkpoint-reason', - headId: created.head.headId, - reason: 'pretend-save', - }), { - ok: false, - code: OBSTRUCTION_INVALID_FACT, - }); + 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, + }); + } }); test('graph runtime preserves untouched leaf identity across narrow replacements', async () => { diff --git a/src/domain/graph-rope-checkpoint-validation.ts b/src/domain/graph-rope-checkpoint-validation.ts index 9b91c34..eccd805 100644 --- a/src/domain/graph-rope-checkpoint-validation.ts +++ b/src/domain/graph-rope-checkpoint-validation.ts @@ -16,6 +16,7 @@ import { type FactValidationErrorCode, type FactValidationResult, type RopeAdmittedFact, + type RopeCheckpointReason, type RopeFactValidationContext, } from './graph-rope-types.js'; @@ -34,7 +35,7 @@ export function validateRopeCheckpointFact( if (idIssue !== null) { return invalidFact(idIssue); } - if (!VALID_CHECKPOINT_REASONS.has(fact.reason)) { + if (!isRopeCheckpointReason(fact.reason)) { return invalidFact(FACT_VALIDATION_ERROR_INVALID_REFERENCE); } const worldline = resolveFactById(context, fact.worldlineId); @@ -55,6 +56,10 @@ export function validateRopeCheckpointFact( : 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, diff --git a/src/domain/graph-rope-runtime.ts b/src/domain/graph-rope-runtime.ts index 128d774..6823146 100644 --- a/src/domain/graph-rope-runtime.ts +++ b/src/domain/graph-rope-runtime.ts @@ -20,6 +20,7 @@ import { 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 { createCheckpointAnchorAdmissionRequest, createCheckpointAnchorAssociation, @@ -254,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 }; From 7fe6237b56a6b3f49bdba951c1940074f571e28c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 15 Jul 2026 04:47:45 -0700 Subject: [PATCH 10/10] Fix: split graph rope runtime specifications --- spec/graph-rope-checkpoint-runtime.spec.mjs | 498 +++++++++++++++++ spec/graph-rope-runtime.spec.mjs | 530 +------------------ spec/support/graph-rope-runtime-test-kit.mjs | 32 ++ 3 files changed, 537 insertions(+), 523 deletions(-) create mode 100644 spec/graph-rope-checkpoint-runtime.spec.mjs create mode 100644 spec/support/graph-rope-runtime-test-kit.mjs diff --git a/spec/graph-rope-checkpoint-runtime.spec.mjs b/spec/graph-rope-checkpoint-runtime.spec.mjs new file mode 100644 index 0000000..c6e9a8d --- /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-runtime.spec.mjs b/spec/graph-rope-runtime.spec.mjs index 8341cfb..79922cc 100644 --- a/spec/graph-rope-runtime.spec.mjs +++ b/spec/graph-rope-runtime.spec.mjs @@ -1,46 +1,19 @@ 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'; +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'; const OBSTRUCTION_INVALID_BYTE_RANGE = 'invalid-byte-range'; const OBSTRUCTION_INVALID_UTF8_BOUNDARY = 'invalid-utf8-boundary'; 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'; - -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(); @@ -179,495 +152,6 @@ test('graph runtime no-op replacement does not mint text authority facts', async assert.equal(replaced.receipt, null); }); -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, - }); - } -}); - 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 0000000..0200dcf --- /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)), + )); +}