diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index f1842a44..df2e3623 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -17,7 +17,9 @@ Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields on all creates except `CreateEffort`, `WriteCitation`, and `WriteBlob`: `derives_from[]`, `supersedes[]`, `invalidates[]` (arrays of existing ids; -targets are validated). +targets are validated and must stay in the new record's Effort). An Effort +record counts as belonging to itself, so a record may `derive_from` its own +governing Effort. Optional `cites[]` on Issue, Finding, Decision, Constraint, and Risk creates: existing **Citation** ids in the **same Effort**. Create Citations first, then @@ -57,8 +59,9 @@ provided, `blob` must be the id of a Blob in the same Effort as the Citation. {"type":"Invalidate","findingId":"","targetId":""} ``` -`Supersede` is same-primitive only and rejects an already-superseded target. -`Invalidate` asserts the target was wrong (stronger than superseded). +`Supersede` is same-primitive and same-Effort only, and rejects an +already-superseded target. `Invalidate` is same-Effort only and asserts the +target was wrong (stronger than superseded). ### Lifecycle transitions @@ -146,7 +149,8 @@ flatbread proof cache prune never produce a partial success. For `derives_from`, `invalidates`, `invalidated_by`, `resolved_by`, and `evidence`, the CLI returns `PROOF_DANGLING_RELATION`. Flatbread's core reference check rejects missing - targets for the other relations. + targets for the other relations. A stored target from another Effort returns + `PROOF_CROSS_EFFORT_RELATION`; it never becomes a successful empty page. - `--resolve head`: follow `superseded_by` to the current tip; ancestors render as checkpoint lines (max 5, then a count). - `blocking-decisions` membership (frozen): Decision in the effort with @@ -161,8 +165,9 @@ flatbread proof cache prune generation, or fail. `--timeout-ms ` bounds the wait (default 3000). - Errors (stderr JSON, exit 1): `PROOF_GENERATION_WAIT_TIMEOUT`, `PROOF_INVALID_CURSOR` (cursor reused across a different query or - generation), and `PROOF_DANGLING_RELATION` (a stored relation target is - missing). + generation), `PROOF_DANGLING_RELATION` (a stored relation target is missing), + and `PROOF_CROSS_EFFORT_RELATION` (a stored relation target belongs to another + Effort). ## Configuration surface diff --git a/CHANGELOG.md b/CHANGELOG.md index 705d05ae..49908023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ incomplete provenance as complete. The error names the record, the relation, and the missing id. Records written before this release keep any dangling edge until you repair the file. +- **Breaking for writes:** Proof now rejects create-time `derives_from`, + `supersedes`, and `invalidates` targets from another Effort. The later + `Supersede` and `Invalidate` forms already rejected these edges. Rejected + creates write no record or reverse projection and leave the generation + unchanged. + `flatbread proof relations` now reports stored legacy or hand-edited foreign + edges as `PROOF_CROSS_EFFORT_RELATION` instead of dropping them into a + successful empty page. Notes for the Flatbread release train. Some packages also keep their own changelog; this file covers the repository as a whole. diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index e7795792..6e72e99e 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os'; import { proofContent } from '@flatbread/proof'; import { proofContent as publicProofContent } from '../index.js'; import { + ProofCrossEffortRelationError, ProofDanglingRelationError, ProofValidationError, serializeDocument, @@ -828,6 +829,239 @@ export default { } ); +test.serial( + 'cross-Effort relations reject on write and report legacy edges', + async (t) => { + const cwd = await createTempProject('flatbread-effort-cross-edge-', t); + await writeFile( + join(cwd, 'flatbread.config.js'), + `import { source } from '@flatbread/source-filesystem'; +import { transformer } from '@flatbread/transformer-markdown'; +import { proofContent } from '@flatbread/proof'; +export default { + source: source(), + transformer: transformer(), + content: proofContent('.flatbread-proof'), +};` + ); + const effortA = await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'Effort A', body: '' }), + { cwd } + ); + const effortB = await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'Effort B', body: '' }), + { cwd } + ); + const effortAId = effortA.artifacts[0].id; + const effortBId = effortB.artifacts[0].id; + const findingA = await handleEffortWrite( + JSON.stringify({ + type: 'WriteFinding', + effort: effortAId, + title: 'Finding A', + body: '', + kind: 'measurement', + }), + { cwd } + ); + const findingAId = findingA.artifacts[0].id; + const decisionA = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effortAId, + title: 'Decision A', + body: '', + }), + { cwd } + ); + const decisionAId = decisionA.artifacts[0].id; + + const ownEffortDecision = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effortBId, + title: 'Own Effort context', + body: '', + derives_from: [effortBId], + }), + { cwd } + ); + const ownEffortRead = await handleEffortRelations( + effortBId, + ownEffortDecision.artifacts[0].id, + { + cwd, + relations: ['derives_from'], + strictMinGeneration: ownEffortDecision.generation, + } + ); + t.is(ownEffortRead.page.returned, 1); + + const decisionB = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effortBId, + title: 'Decision B', + body: '', + }), + { cwd } + ); + const decisionBId = decisionB.artifacts[0].id; + const rejectedWrites = [ + { + relation: 'derives_from', + input: { + type: 'WriteDecision', + effort: effortBId, + title: 'Cross derive', + body: '', + derives_from: [findingAId], + }, + }, + { + relation: 'supersedes', + input: { + type: 'WriteDecision', + effort: effortBId, + title: 'Cross supersede', + body: '', + supersedes: [decisionAId], + }, + }, + { + relation: 'invalidates', + input: { + type: 'WriteDecision', + effort: effortBId, + title: 'Cross invalidate', + body: '', + invalidates: [decisionAId], + }, + }, + ]; + for (const attempt of rejectedWrites) + await t.throwsAsync( + () => handleEffortWrite(JSON.stringify(attempt.input), { cwd }), + { + instanceOf: ProofValidationError, + message: `${attempt.relation} target ${ + attempt.relation === 'derives_from' ? findingAId : decisionAId + } belongs to a different effort`, + } + ); + t.is( + JSON.parse( + await readFile( + join(cwd, '.flatbread-proof', '.journal', 'generation.json'), + 'utf8' + ) + ).generation, + Number(decisionB.generation) + ); + + await writeFile( + join(cwd, '.flatbread-proof', 'decisions', `${decisionBId}.md`), + serializeDocument('Legacy foreign edges.', { + id: decisionBId, + effort: effortBId, + title: 'Decision B', + created_at: '2025-01-02T00:00:00.000Z', + state: 'proposed', + derives_from: [findingAId], + supersedes: [decisionAId], + invalidates: [decisionAId], + }) + ); + await writeFile( + join(cwd, '.flatbread-proof', 'decisions', `${decisionAId}.md`), + serializeDocument('Legacy reverse edges.', { + id: decisionAId, + effort: effortAId, + title: 'Decision A', + created_at: '2025-01-01T00:00:00.000Z', + state: 'proposed', + superseded_by: [decisionBId], + invalidated_by: [decisionBId], + }) + ); + + const forwardEdges = [ + { + relation: 'derives_from', + to_id: findingAId, + target_effort_id: effortAId, + }, + { + relation: 'supersedes', + to_id: decisionAId, + target_effort_id: effortAId, + }, + { + relation: 'invalidates', + to_id: decisionAId, + target_effort_id: effortAId, + }, + ]; + const forwardError = await t.throwsAsync( + () => + handleEffortRelations(effortBId, decisionBId, { + cwd, + relations: ['derives_from', 'supersedes', 'invalidates'], + strictMinGeneration: decisionB.generation, + }), + { instanceOf: ProofCrossEffortRelationError } + ); + t.deepEqual(forwardError?.shape, { + error: { + code: 'PROOF_CROSS_EFFORT_RELATION', + message: `Record ${decisionBId} in effort ${effortBId} stores relation targets outside that effort: derives_from -> ${findingAId} (effort ${effortAId}), supersedes -> ${decisionAId} (effort ${effortAId}), invalidates -> ${decisionAId} (effort ${effortAId})`, + effort_id: effortBId, + from_id: decisionBId, + edges: forwardEdges, + }, + }); + + const reverseEdges = [ + { + relation: 'superseded_by', + to_id: decisionBId, + target_effort_id: effortBId, + }, + { + relation: 'invalidated_by', + to_id: decisionBId, + target_effort_id: effortBId, + }, + ]; + const reverseError = await t.throwsAsync( + () => + handleEffortRelations(effortAId, decisionAId, { + cwd, + relations: ['superseded_by', 'invalidated_by'], + strictMinGeneration: decisionB.generation, + }), + { instanceOf: ProofCrossEffortRelationError } + ); + t.deepEqual(reverseError?.shape.error.edges, reverseEdges); + + const result = await runCli( + cwd, + 'proof', + 'relations', + effortBId, + decisionBId, + '--relations', + 'derives_from,supersedes,invalidates', + '--strict-min-generation', + decisionB.generation + ); + t.is(result.code, 1); + t.is(result.stdout, ''); + t.deepEqual(JSON.parse(result.stderr), forwardError?.shape); + t.false(result.stderr.includes(' at ')); + } +); + test.serial( 'bootstrap detects a ready custom root and verify returns action-required JSON state', async (t) => { diff --git a/packages/flatbread/src/proof/read.ts b/packages/flatbread/src/proof/read.ts index 9d68edc0..4cbdf0df 100644 --- a/packages/flatbread/src/proof/read.ts +++ b/packages/flatbread/src/proof/read.ts @@ -2,6 +2,7 @@ import { FlatbreadProvider, type LoadedFlatbreadConfig } from '@flatbread/core'; import { canonicalizeReadQuery, ProofConsistencyError, + ProofCrossEffortRelationError, ProofDanglingRelationError, ProofInvalidCursorError, ProofReadValidationError, @@ -185,6 +186,13 @@ function sortRecords(records: ReadRecord[]): ReadRecord[] { ); } +function owningEffort(record: ReadRecord): string | undefined { + if (record.kind === 'effort') return record.id; + return typeof record.frontmatter.effort === 'string' + ? record.frontmatter.effort + : undefined; +} + function encodeCursor(value: Record): string { return Buffer.from(JSON.stringify(value)).toString('base64url'); } @@ -657,14 +665,16 @@ export async function relations( const source = collection ? await projection.one(collection, fromId) : undefined; - const sourceInEffort = - source?.kind === 'effort' && fromId === effortId - ? true - : source?.frontmatter.effort === effortId; + const sourceInEffort = source ? owningEffort(source) === effortId : false; if (!source || !sourceInEffort) throw new Error(`Record ${fromId} does not exist in effort ${effortId}`); const selected = new Map(); const dangling: { relation: ReadRelation; to_id: string }[] = []; + const foreign: { + relation: ReadRelation; + to_id: string; + target_effort_id: string | null; + }[] = []; for (const relation of relationNames) { for (const targetId of source.relations[relation] ?? []) { const targetCollection = collectionForId(targetId); @@ -677,13 +687,21 @@ export async function relations( dangling.push({ relation, to_id: targetId }); continue; } - // Keep the existing effort-scoped read behavior for legacy or - // hand-authored cross-effort links. - if (target.frontmatter.effort === effortId) - selected.set(target.id, target); + const targetEffort = owningEffort(target); + if (targetEffort !== effortId) { + foreign.push({ + relation, + to_id: target.id, + target_effort_id: targetEffort ?? null, + }); + continue; + } + selected.set(target.id, target); } } if (dangling.length) throw new ProofDanglingRelationError(fromId, dangling); + if (foreign.length) + throw new ProofCrossEffortRelationError(effortId, fromId, foreign); const records = sortRecords([...selected.values()]); const edges = records.flatMap((record) => relationNames diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index f1842a44..df2e3623 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -17,7 +17,9 @@ Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields on all creates except `CreateEffort`, `WriteCitation`, and `WriteBlob`: `derives_from[]`, `supersedes[]`, `invalidates[]` (arrays of existing ids; -targets are validated). +targets are validated and must stay in the new record's Effort). An Effort +record counts as belonging to itself, so a record may `derive_from` its own +governing Effort. Optional `cites[]` on Issue, Finding, Decision, Constraint, and Risk creates: existing **Citation** ids in the **same Effort**. Create Citations first, then @@ -57,8 +59,9 @@ provided, `blob` must be the id of a Blob in the same Effort as the Citation. {"type":"Invalidate","findingId":"","targetId":""} ``` -`Supersede` is same-primitive only and rejects an already-superseded target. -`Invalidate` asserts the target was wrong (stronger than superseded). +`Supersede` is same-primitive and same-Effort only, and rejects an +already-superseded target. `Invalidate` is same-Effort only and asserts the +target was wrong (stronger than superseded). ### Lifecycle transitions @@ -146,7 +149,8 @@ flatbread proof cache prune never produce a partial success. For `derives_from`, `invalidates`, `invalidated_by`, `resolved_by`, and `evidence`, the CLI returns `PROOF_DANGLING_RELATION`. Flatbread's core reference check rejects missing - targets for the other relations. + targets for the other relations. A stored target from another Effort returns + `PROOF_CROSS_EFFORT_RELATION`; it never becomes a successful empty page. - `--resolve head`: follow `superseded_by` to the current tip; ancestors render as checkpoint lines (max 5, then a count). - `blocking-decisions` membership (frozen): Decision in the effort with @@ -161,8 +165,9 @@ flatbread proof cache prune generation, or fail. `--timeout-ms ` bounds the wait (default 3000). - Errors (stderr JSON, exit 1): `PROOF_GENERATION_WAIT_TIMEOUT`, `PROOF_INVALID_CURSOR` (cursor reused across a different query or - generation), and `PROOF_DANGLING_RELATION` (a stored relation target is - missing). + generation), `PROOF_DANGLING_RELATION` (a stored relation target is missing), + and `PROOF_CROSS_EFFORT_RELATION` (a stored relation target belongs to another + Effort). ## Configuration surface diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index b78c7201..abebaa70 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -2,9 +2,11 @@ import test from 'ava'; import { parseDocument, serializeDocument } from '../frontmatter.js'; import { createProofSnapshot } from '../snapshot.js'; import { planMutation } from '../planner.js'; +import type { ProofMutation } from '../schemas.js'; import type { PrimitiveKind } from '../types.js'; const E = 'eff-one--0123456789abcdef'; +const E2 = 'eff-two--0123456789abcdef'; const ids = { issue: 'iss-one--0123456789abcdef', finding: 'fnd-one--0123456789abcdef', @@ -819,3 +821,136 @@ test('derives_from accepts an existing record', (t) => { state: 'proposed', }); }); + +test('derives_from accepts the record governing Effort', (t) => { + const w = planMutation( + { + type: 'WriteDecision', + id: ids.decision, + effort: E, + title: 'D', + body: '', + derives_from: [E], + }, + snap(), + '/root', + now + ); + one(t, w, ids.decision, `decisions/${ids.decision}.md`, 'create', { + id: ids.decision, + effort: E, + title: 'D', + derives_from: [E], + created_at: now.toISOString(), + state: 'proposed', + }); +}); + +test('create relations reject targets from another Effort', (t) => { + const otherEffort = record(E2, 'effort', { + id: E2, + title: 'E2', + created_at: '2025-01-01T00:00:00.000Z', + status: 'active', + }); + const foreignFindingId = 'fnd-foreign--0123456789abcdef'; + const foreignFinding = record(foreignFindingId, 'finding', { + id: foreignFindingId, + effort: E2, + title: 'Foreign', + kind: 'measurement', + created_at: '2025-01-01T00:00:00.000Z', + }); + const s = snap([otherEffort, foreignFinding]); + const attempts: { relation: string; input: ProofMutation }[] = [ + { + relation: 'derives_from', + input: { + type: 'WriteDecision', + id: ids.decision, + effort: E, + title: 'Cross derive', + body: '', + derives_from: [foreignFindingId], + }, + }, + { + relation: 'supersedes', + input: { + type: 'WriteFinding', + id: 'fnd-superseder--0123456789abcdef', + effort: E, + title: 'Cross supersede', + body: '', + kind: 'measurement', + supersedes: [foreignFindingId], + }, + }, + { + relation: 'invalidates', + input: { + type: 'WriteDecision', + id: ids.decision, + effort: E, + title: 'Cross invalidate', + body: '', + invalidates: [foreignFindingId], + }, + }, + ]; + + for (const attempt of attempts) + t.throws(() => planMutation(attempt.input, s, '/root', now), { + message: `${attempt.relation} target ${foreignFindingId} belongs to a different effort`, + }); +}); + +test('later relation mutations reject targets from another Effort', (t) => { + const otherEffort = record(E2, 'effort', { + id: E2, + title: 'E2', + created_at: '2025-01-01T00:00:00.000Z', + status: 'active', + }); + const localFinding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Local finding', + kind: 'measurement', + created_at: '2025-01-01T00:00:00.000Z', + }); + const foreignFindingId = 'fnd-foreign--0123456789abcdef'; + const foreignFinding = record(foreignFindingId, 'finding', { + id: foreignFindingId, + effort: E2, + title: 'Foreign finding', + kind: 'measurement', + created_at: '2025-01-01T00:00:00.000Z', + }); + const foreignDecisionId = 'dec-foreign--0123456789abcdef'; + const foreignDecision = record(foreignDecisionId, 'decision', { + id: foreignDecisionId, + effort: E2, + title: 'Foreign decision', + state: 'proposed', + created_at: '2025-01-01T00:00:00.000Z', + }); + const s = snap([otherEffort, localFinding, foreignFinding, foreignDecision]); + + for (const input of [ + { + type: 'Supersede', + supersederId: ids.finding, + targetId: foreignFindingId, + }, + { + type: 'Invalidate', + findingId: ids.finding, + targetId: foreignDecisionId, + }, + { type: 'Supersede', supersederId: E, targetId: E2 }, + ] as ProofMutation[]) + t.throws(() => planMutation(input, s, '/root', now), { + message: 'Invalid edge', + }); +}); diff --git a/packages/proof/src/__tests__/read.test.ts b/packages/proof/src/__tests__/read.test.ts index 5d0192fe..0aec52ff 100644 --- a/packages/proof/src/__tests__/read.test.ts +++ b/packages/proof/src/__tests__/read.test.ts @@ -2,6 +2,7 @@ import test from 'ava'; import { canonicalizeReadQuery, parseGenerationToken, + ProofCrossEffortRelationError, readQueryHash, } from '../index.js'; @@ -47,3 +48,33 @@ test('strict generation tokens are canonical safe non-negative integers', (t) => t.is(parseGenerationToken('0'), 0); t.is(parseGenerationToken('42'), 42); }); + +test('cross-Effort relation errors name both Efforts and every edge', (t) => { + const error = new ProofCrossEffortRelationError( + 'eff-local--0123456789abcdef', + 'dec-source--0123456789abcdef', + [ + { + relation: 'derives_from', + to_id: 'fnd-foreign--0123456789abcdef', + target_effort_id: 'eff-foreign--0123456789abcdef', + }, + ] + ); + t.deepEqual(error.shape, { + error: { + code: 'PROOF_CROSS_EFFORT_RELATION', + message: + 'Record dec-source--0123456789abcdef in effort eff-local--0123456789abcdef stores relation targets outside that effort: derives_from -> fnd-foreign--0123456789abcdef (effort eff-foreign--0123456789abcdef)', + effort_id: 'eff-local--0123456789abcdef', + from_id: 'dec-source--0123456789abcdef', + edges: [ + { + relation: 'derives_from', + to_id: 'fnd-foreign--0123456789abcdef', + target_effort_id: 'eff-foreign--0123456789abcdef', + }, + ], + }, + }); +}); diff --git a/packages/proof/src/__tests__/writer.test.ts b/packages/proof/src/__tests__/writer.test.ts index 00408be2..216abdf0 100644 --- a/packages/proof/src/__tests__/writer.test.ts +++ b/packages/proof/src/__tests__/writer.test.ts @@ -102,6 +102,131 @@ test('WriteDecision with supersedes materializes superseded_by on the target fil t.deepEqual(newer.frontmatter.supersedes, [older]); }); +test('one create preserves both reverse projections to the same target', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'Dual edge', body: '' }) + ); + const target = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'Target', + body: '', + kind: 'measurement', + }) + ); + const result = await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'Replacement and correction', + body: '', + kind: 'measurement', + supersedes: [target], + invalidates: [target], + }); + const source = result.artifacts.find( + (artifact) => artifact.operation === 'created' + )?.id; + t.truthy(source); + + const sourceRecord = await readFrontmatter( + root, + `findings/${source as string}.md` + ); + t.deepEqual(sourceRecord.data.supersedes, [target]); + t.deepEqual(sourceRecord.data.invalidates, [target]); + + const targetRecord = await readFrontmatter(root, `findings/${target}.md`); + t.deepEqual(targetRecord.data.superseded_by, [source]); + t.deepEqual(targetRecord.data.invalidated_by, [source]); +}); + +test('cross-Effort create relations reject without changing files or generation', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'Local', body: '' }) + ); + const otherEffort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'Foreign', body: '' }) + ); + const target = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort: otherEffort, + title: 'Foreign target', + body: '', + kind: 'measurement', + }) + ); + const targetPath = `findings/${target}.md`; + const targetBefore = await readFile(join(root, targetPath)); + const generationPath = join(root, '.journal', 'generation.json'); + const generationBefore = await readFile(generationPath, 'utf8'); + const attempts: { + relation: string; + path: string; + input: ProofMutation; + }[] = [ + { + relation: 'derives_from', + path: 'decisions/dec-cross-derive--0000000000000001.md', + input: { + type: 'WriteDecision', + id: 'dec-cross-derive--0000000000000001', + effort, + title: 'Cross derive', + body: '', + derives_from: [target], + }, + }, + { + relation: 'supersedes', + path: 'findings/fnd-cross-supersede--0000000000000002.md', + input: { + type: 'WriteFinding', + id: 'fnd-cross-supersede--0000000000000002', + effort, + title: 'Cross supersede', + body: '', + kind: 'measurement', + supersedes: [target], + }, + }, + { + relation: 'invalidates', + path: 'decisions/dec-cross-invalidate--0000000000000003.md', + input: { + type: 'WriteDecision', + id: 'dec-cross-invalidate--0000000000000003', + effort, + title: 'Cross invalidate', + body: '', + invalidates: [target], + }, + }, + ]; + + for (const attempt of attempts) { + await t.throwsAsync(writer.mutate(attempt.input), { + instanceOf: ProofValidationError, + message: `${attempt.relation} target ${target} belongs to a different effort`, + }); + t.false( + await readFile(join(root, attempt.path)).then( + () => true, + () => false + ) + ); + } + + t.is(await readFile(generationPath, 'utf8'), generationBefore); + t.deepEqual(await readFile(join(root, targetPath)), targetBefore); + const targetAfter = await readFrontmatter(root, targetPath); + t.is(targetAfter.data.superseded_by, undefined); + t.is(targetAfter.data.invalidated_by, undefined); +}); + test('Supersede sets a Decision target state to superseded, exactly 2 files', async (t) => { const { root, writer } = await makeWriter(); const effort = soleId( diff --git a/packages/proof/src/index.ts b/packages/proof/src/index.ts index eaf39c5e..f62b4220 100644 --- a/packages/proof/src/index.ts +++ b/packages/proof/src/index.ts @@ -16,6 +16,7 @@ export * from './digest.js'; export { READ_RELATIONS, ProofConsistencyError, + ProofCrossEffortRelationError, ProofDanglingRelationError, ProofInvalidCursorError, ProofReadValidationError, @@ -26,6 +27,7 @@ export { } from './read.js'; export type { ConsistencyErrorShape, + CrossEffortRelationEdge, EffortStatus, ReadOptions, ReadQuery, diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index aea65701..7934aad8 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -14,6 +14,9 @@ import type { ProofSnapshot } from './snapshot.js'; import type { PlannedWrite, PrimitiveKind } from './types.js'; import type { ProofMutation } from './schemas.js'; +type SnapshotRecord = NonNullable>; +type GetRecord = (id: string) => SnapshotRecord; + const kinds: Record = { WriteIssue: 'issue', WriteFinding: 'finding', @@ -53,7 +56,7 @@ function assertNoCitationBlobEdges( } function assertCites( - get: (id: string) => NonNullable>, + get: GetRecord, effortId: string, cites: string[] | undefined ): void { @@ -63,13 +66,28 @@ function assertCites( throw new ProofValidationError( `cites must target a Citation, got ${target.kind} (${citeId})` ); - if (target.frontmatter.effort !== effortId) - throw new ProofValidationError( - `cites target ${citeId} belongs to a different effort` - ); + assertTargetEffort('cites', effortId, target); } } +function owningEffort(record: SnapshotRecord): string | undefined { + if (record.kind === 'effort') return record.id; + return typeof record.frontmatter.effort === 'string' + ? record.frontmatter.effort + : undefined; +} + +function assertTargetEffort( + relation: string, + effortId: string, + target: SnapshotRecord +): void { + if (owningEffort(target) !== effortId) + throw new ProofValidationError( + `${relation} target ${target.id} belongs to a different effort` + ); +} + /** * `derives_from` is the one forward edge with no reverse projection, so nothing * else in the create path ever looks its targets up. Resolve each one here: @@ -77,10 +95,12 @@ function assertCites( * record that points at nothing from ever reaching the journal. */ function assertDerivesFrom( - get: (id: string) => NonNullable>, + get: GetRecord, + effortId: string, derivesFrom: string[] | undefined ): void { - for (const targetId of derivesFrom ?? []) get(targetId); + for (const targetId of derivesFrom ?? []) + assertTargetEffort('derives_from', effortId, get(targetId)); } export function planMutation( @@ -197,7 +217,7 @@ export function planMutation( } if (EPISTEMIC_CREATE.has(kind)) { assertCites(get, raw.effort, raw.cites); - assertDerivesFrom(get, raw.derives_from); + assertDerivesFrom(get, raw.effort, raw.derives_from); } const fm: Record = { ...raw, @@ -216,6 +236,10 @@ export function planMutation( delete fm.invalidates; } add(id, kind, fm, raw.body, 'create'); + const reverseUpdates = new Map< + string, + { target: SnapshotRecord; frontmatter: Record } + >(); for (const edge of ['supersedes', 'invalidates'] as const) for (const targetId of (fm[edge] as string[] | undefined) ?? []) { const target = get(targetId); @@ -235,21 +259,24 @@ export function planMutation( throw new ProofValidationError( 'Invalidation target must be a Finding or Decision' ); + assertTargetEffort(edge, raw.effort, target); const reverse = edge === 'supersedes' ? 'superseded_by' : 'invalidated_by'; - add( - target.id, - target.kind, - { - ...target.frontmatter, + const current = + reverseUpdates.get(target.id)?.frontmatter ?? target.frontmatter; + reverseUpdates.set(target.id, { + target, + frontmatter: { + ...current, [reverse]: [ - ...((target.frontmatter[reverse] as string[] | undefined) ?? []), + ...((current[reverse] as string[] | undefined) ?? []), id, ], }, - target.body - ); + }); } + for (const { target, frontmatter } of reverseUpdates.values()) + add(target.id, target.kind, frontmatter, target.body); return [...writes.values()]; } if (input.type === 'Supersede' || input.type === 'Invalidate') { @@ -260,10 +287,14 @@ export function planMutation( const edge = input.type === 'Supersede' ? 'supersedes' : 'invalidates'; const back = input.type === 'Supersede' ? 'superseded_by' : 'invalidated_by'; + const sourceEffort = owningEffort(a); + const targetEffort = owningEffort(b); if ( a.id === b.id || (input.type === 'Supersede' && a.kind !== b.kind) || - (a.frontmatter.effort !== b.frontmatter.effort && a.kind !== 'effort') + sourceEffort === undefined || + targetEffort === undefined || + sourceEffort !== targetEffort ) throw new ProofValidationError('Invalid edge'); if (input.type === 'Invalidate') { diff --git a/packages/proof/src/read.ts b/packages/proof/src/read.ts index c08a4443..286e8863 100644 --- a/packages/proof/src/read.ts +++ b/packages/proof/src/read.ts @@ -122,6 +122,54 @@ export class ProofDanglingRelationError extends Error { } } +export interface CrossEffortRelationEdge { + relation: string; + to_id: string; + target_effort_id: string | null; +} + +/** + * Raised when an effort-scoped read finds a stored edge whose target belongs + * to another effort. Old or hand-edited records fail closed instead of + * turning a foreign edge into a successful empty page. + */ +export class ProofCrossEffortRelationError extends Error { + readonly shape: { + error: { + code: 'PROOF_CROSS_EFFORT_RELATION'; + message: string; + effort_id: string; + from_id: string; + edges: CrossEffortRelationEdge[]; + }; + }; + constructor( + effortId: string, + fromId: string, + edges: CrossEffortRelationEdge[] + ) { + const message = `Record ${fromId} in effort ${effortId} stores relation targets outside that effort: ${edges + .map( + (edge) => + `${edge.relation} -> ${edge.to_id} (effort ${ + edge.target_effort_id ?? 'unknown' + })` + ) + .join(', ')}`; + super(message); + this.name = 'ProofCrossEffortRelationError'; + this.shape = { + error: { + code: 'PROOF_CROSS_EFFORT_RELATION', + message, + effort_id: effortId, + from_id: fromId, + edges, + }, + }; + } +} + export class ProofConsistencyError extends Error { readonly shape: ConsistencyErrorShape; constructor(shape: ConsistencyErrorShape) {