diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index 580aa6d6..cfe62e0f 100644 --- a/.agents/skills/proof/SKILL.md +++ b/.agents/skills/proof/SKILL.md @@ -103,10 +103,11 @@ Critical semantics: `MitigateRisk`, `SetRiskState`) to transition. `WriteCitation` and `WriteBlob` have no lifecycle state. - `Retract` removes a record from browse reads without deleting the file. - Pass a reason. The writer strips that id from other records in the same - Effort so reads do not fail closed. Use it for session noise that should - never have been journaled. Do not `git rm` records or hand-edit - frontmatter. `proof get` still returns a retracted record. Efforts cannot + Pass a reason. The writer strips that id from records in every Effort, + including foreign `derives_from` links, so reads do not fail closed. Use it + for session noise that should never have been journaled. Do not `git rm` + records or hand-edit frontmatter. `proof get` still returns a retracted + record. Efforts cannot be retracted; abandon them instead. - `AcceptDecision` defaults `rejectSiblings: true`, which rejects only proposed Decisions derived from the same `question` Issue, even if the @@ -115,8 +116,14 @@ Critical semantics: `changedDecisionIds` and `rejectedIds`; pass `dryRun: true` to preview without saving. Use `ReopenDecision` with a reason to restore a rejected Decision to proposed while keeping its rejection in `reopen_history`. -- Edges are forward-only in payloads (`derives_from`, `supersedes`, - `invalidates`); back-edges are materialized automatically. +- A Decision may use `derives_from` to link to a live record in another + Effort. The link records a cause without changing that record. `proof relations` + shows each foreign target as one checkpoint with its Effort id, kind, and + current state. Use `proof get` on a target id to read its full record. + `supersedes`, `invalidates`, and lifecycle links stay within one Effort. +- Edges are forward-only in payloads. `supersedes` and `invalidates` write + `superseded_by` and `invalidated_by` reverse projections; `derives_from` + writes no reverse edge. - External sources: create a `WriteCitation` record (its body may be a URL, with optional `blob` and `role` fields), then add `cites: [""]` when creating an Issue, Finding, Decision, diff --git a/.agents/skills/proof/glossary.md b/.agents/skills/proof/glossary.md index 15301226..6d805676 100644 --- a/.agents/skills/proof/glossary.md +++ b/.agents/skills/proof/glossary.md @@ -67,10 +67,11 @@ default; use `proof get ` to read the content. ## Edges -`derives_from` is causal upstream evidence or context. `supersedes` replaces a -record of the same primitive, while `invalidates` says a record was wrong. -Those forward edges are authoritative; `superseded_by` and `invalidated_by` are -writer-materialized reverse projections. +`derives_from` is causal upstream evidence or context and may point to a live +record in another Effort. It writes no reverse projection. `supersedes` +replaces a record of the same primitive, while `invalidates` says a record +was wrong. Those state-changing edges stay within one Effort; their +`superseded_by` and `invalidated_by` reverse projections are writer-materialized. `cites` links an Issue, Finding, Decision, Constraint, or Risk to a Citation. It accepts Citation ids only, never Blob ids. A Citation may optionally point @@ -84,8 +85,10 @@ express. `Retract` hides a record that should not have been journaled. The file stays on disk with `retracted: true` so ids remain resolvable and -`PROOF_DANGLING_RELATION` does not fire. Browse reads omit retracted -records. `proof get` still returns the body and the reason. This is not +`PROOF_DANGLING_RELATION` does not fire. The writer strips inbound relation +ids across every Effort in one transaction, including foreign `derives_from` +references. Browse reads omit retracted records. `proof get` still returns +the body and the reason. This is not supersession (a better same-kind claim) and not invalidation (a Finding that the target was wrong). Git is the undo path for Retract; `ReopenDecision` only returns a rejected Decision to proposed. There is no generic Restore mutation. diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index c62ba2a4..fdeeb436 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -16,10 +16,11 @@ identity. Let the writer generate ids; capture them from mutation results 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 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. +`derives_from[]`, `supersedes[]`, `invalidates[]` (arrays of existing ids). +`derives_from` can name a live record in any Effort. It records a cause but +changes no target. `supersedes` and `invalidates` change targets and must stay +in the new record's Effort. An Effort record counts as belonging to itself, so +a record may `derives_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 @@ -105,7 +106,7 @@ written. It is not a hard delete and not a fold into a survivor: `retracted_reason`. The body is unchanged so `proof get` can still explain what was removed. - On a successful Retract, the writer clears that record's relation fields - and strips its id from every other record in the same Effort in the same + and strips its id from every other record across all Efforts in the same journal transaction. - Browse reads (`list`, `records`, `blocking-decisions`) omit retracted records. `proof get` still returns them. `relations` follows stored edges @@ -216,7 +217,14 @@ flatbread proof cache prune `invalidated_by`, `resolved_by`, and `evidence`, the CLI returns `PROOF_DANGLING_RELATION`. Flatbread's core reference check rejects missing targets for the other relations. A stored target from another Effort returns - `PROOF_CROSS_EFFORT_RELATION`; it never becomes a successful empty page. + `PROOF_CROSS_EFFORT_RELATION` for state-changing or `cites` edges. A foreign + `derives_from` target appears as one checkpoint line with its Effort id, + kind, and current state. Its body is not expanded. Page through more targets + with `--cursor`; `proof get ` opens one explicitly. +- `MitigateRisk` still links a Risk to an accepted Decision in the same Effort. + For a feature Decision in another Effort, write and accept a Decision in the + Risk's Effort that `derives_from` the feature Decision, then use that local + Decision to mitigate the Risk. - `--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 @@ -232,8 +240,8 @@ flatbread proof cache prune - Errors (stderr JSON, exit 1): `PROOF_GENERATION_WAIT_TIMEOUT`, `PROOF_INVALID_CURSOR` (cursor reused across a different query or generation), `PROOF_DANGLING_RELATION` (a stored relation target is missing), - and `PROOF_CROSS_EFFORT_RELATION` (a stored relation target belongs to another - Effort). + and `PROOF_CROSS_EFFORT_RELATION` (a stored state-changing or `cites` + target belongs to another Effort). ## Configuration surface diff --git a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--2pggn03xasqjs9bp.md b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--2pggn03xasqjs9bp.md new file mode 100644 index 00000000..98febf7e --- /dev/null +++ b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--2pggn03xasqjs9bp.md @@ -0,0 +1,19 @@ +--- +id: con-mutation-enum-stays-deliberately-small--2pggn03xasqjs9bp +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Mutation enum stays deliberately small +kind: hard +created_at: '2026-09-25T10:36:35.462Z' +supersedes: + - con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs +--- + +V1 has exactly seventeen named mutations. Every operation has a Zod schema, validates against a committed index generation, and owns a defined semantic transition. The seventeenth mutation exists because an Effort-wide Decision rejection could silently discard unrelated proposals and could not be undone through Proof. + +The surface consists of Effort lifecycle (`CreateEffort`, `SetEffortStatus`); one creation mutation for each primitive (`WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, `WriteCitation`, `WriteBlob`); edge retro-linking (`Supersede`, `Invalidate`); lifecycle transitions (`ResolveIssue`, `AcceptDecision`, `ReopenDecision`, `MitigateRisk`, `SetRiskState`); and `Retract` for records that should not stay on the live graph. + +`AcceptDecision` rejects only explicit alternatives: proposed Decisions derived from a shared question Issue, regardless of whether that Issue remains open, or proposed Decisions named in `rejects`. It names every changed and rejected Decision in the result, and a dry run previews the same changes without committing them. It must not accept a second answer to a shared question or a reopened Decision still rejected by an accepted alternative. `ReopenDecision` restores only a rejected Decision to proposed and retains the earlier rejection in history. + +`Retract` remains the named archive operation. It tombstones a file in place, strips inbound relation references to that id from records across every Effort in the same journal transaction, and drops the record from browse reads. The global strip includes foreign `derives_from` references now that causal links can cross Efforts. It is not a generic frontmatter patch, hard delete, or fold into a survivor. Git is the undo path for Retract; there is no generic Restore mutation. + +No generic frontmatter patch, hard delete, standalone `RejectDecision`, or body-edit mutation is part of v1. Bodies remain ordinary editable markdown while the platform owns frontmatter semantics. Additive mutations require dogfood evidence; removing or reshaping one is a breaking migration. diff --git a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md index a41396c3..eb016e50 100644 --- a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md +++ b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md @@ -6,6 +6,8 @@ kind: hard created_at: '2026-09-25T09:56:29.313Z' supersedes: - con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4 +superseded_by: + - con-mutation-enum-stays-deliberately-small--2pggn03xasqjs9bp --- V1 has exactly seventeen named mutations. Every operation has a Zod schema, validates against a committed index generation, and owns a defined semantic transition. The seventeenth mutation exists because an Effort-wide Decision rejection could silently discard unrelated proposals and could not be undone through Proof. diff --git a/.flatbread-proof/decisions/dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9.md b/.flatbread-proof/decisions/dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9.md index 1fa914ee..c6091f48 100644 --- a/.flatbread-proof/decisions/dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9.md +++ b/.flatbread-proof/decisions/dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9.md @@ -17,7 +17,7 @@ Supersede keeps both records. Invalidate adds a Finding that says a target was w ## Decision -Add Retract as a sixteenth named mutation. Tombstone the file in place with retracted, retracted_at, and retracted_reason. Strip that id from other records in the same Effort in the same journal transaction. Browse reads omit retracted records. proof get still returns the file. Later writes refuse retracted ids. Efforts cannot be retracted; abandon them. +Add Retract as a sixteenth named mutation. Tombstone the file in place with retracted, retracted_at, and retracted_reason. Strip that id from records across every Effort in the same journal transaction, including foreign `derives_from` references. Browse reads omit retracted records. proof get still returns the file. Later writes refuse retracted ids. Efforts cannot be retracted; abandon them. This is not a hard delete and not a Collapse that folds bodies into a survivor. Folding N noisy records into one survivor is a body edit on the survivor plus Retract on the rest. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f648f18..9fff204a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Proof accepts `derives_from` references to live records in other Efforts + (#278). `proof relations`, `proof get`, and bounded digests show each + foreign cause as one checkpoint with its id, kind, owning Effort, and current + state; they do not expand its body. Cross-Effort `supersedes`, `invalidates`, + `cites`, and lifecycle links still fail with + `PROOF_CROSS_EFFORT_RELATION`. `Retract` now strips inbound references + across every Effort in one journal transaction. This narrows the 1.1.0 + foreign-edge rejection and extends its same-Effort Retract cleanup. + - Proof write contract (#277): `AcceptDecision` now rejects only explicit alternatives tied to the same question Issue or named in `rejects`, reports changed and rejected Decision ids, and supports a journal-safe dry run. The diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index fa1b2fa6..7a0dbb27 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -924,6 +924,12 @@ export default { derives_from: [missingId], }) ); + const getResult = await handleEffortGet(decisionId, { cwd }); + t.true( + (await readFile(getResult.artifact_path, 'utf8')).includes( + 'Written before its evidence existed.' + ) + ); const error = await t.throwsAsync( () => handleEffortRelations(effortId, decisionId, { @@ -957,7 +963,97 @@ export default { ); test.serial( - 'cross-Effort relations reject on write and report legacy edges', + 'foreign causal references page without expanding target bodies', + async (t) => { + const cwd = await createTempProject('flatbread-foreign-causal-pages-', 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 domain = ( + await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'Domain', body: '' }), + { cwd } + ) + ).artifacts[0].id; + const feature = ( + await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'Feature', body: '' }), + { cwd } + ) + ).artifacts[0].id; + const causes: string[] = []; + for (let i = 0; i < 26; i++) { + const result = await handleEffortWrite( + JSON.stringify({ + type: 'WriteFinding', + effort: domain, + title: `Cause ${i}`, + body: `foreign body ${i}`, + kind: 'measurement', + }), + { cwd } + ); + causes.push(result.artifacts[0].id); + } + const decision = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: feature, + title: 'Feature choice', + body: '', + derives_from: causes, + }), + { cwd } + ); + const fromId = decision.artifacts[0].id; + const first = await handleEffortRelations(feature, fromId, { + cwd, + relations: ['derives_from'], + limit: 25, + strictMinGeneration: decision.generation, + }); + t.is(first.page.returned, 25); + t.true(first.page.has_more); + const firstDigest = await readFile(first.artifact_path, 'utf8'); + t.is((firstDigest.match(/^- foreign derives_from/gm) ?? []).length, 25); + t.false(firstDigest.includes('foreign body')); + const got = await handleEffortGet(fromId, { + cwd, + strictMinGeneration: decision.generation, + }); + const getDigest = await readFile(got.artifact_path, 'utf8'); + t.is((getDigest.match(/^foreign derives_from/gm) ?? []).length, 25); + t.true( + getDigest.includes( + '1 more foreign references; use proof relations to page through them' + ) + ); + t.false(getDigest.includes('foreign body')); + const second = await handleEffortRelations(feature, fromId, { + cwd, + relations: ['derives_from'], + limit: 25, + cursor: first.page.next_cursor ?? undefined, + strictMinGeneration: decision.generation, + }); + t.is(second.page.returned, 1); + t.false(second.page.has_more); + t.is( + ( + (await readFile(second.artifact_path, 'utf8')).match( + /^- foreign derives_from/gm + ) ?? [] + ).length, + 1 + ); + } +); +test.serial( + 'cross-Effort causal links read as checkpoints while state edges fail closed', async (t) => { const cwd = await createTempProject('flatbread-effort-cross-edge-', t); await writeFile( @@ -1002,6 +1098,14 @@ export default { { cwd } ); const decisionAId = decisionA.artifacts[0].id; + await handleEffortWrite( + JSON.stringify({ + type: 'AcceptDecision', + decisionId: decisionAId, + rejectSiblings: false, + }), + { cwd } + ); const ownEffortDecision = await handleEffortWrite( JSON.stringify({ @@ -1034,17 +1138,37 @@ export default { { cwd } ); const decisionBId = decisionB.artifacts[0].id; + const crossDerive = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effortBId, + title: 'Cross derive', + body: '', + derives_from: [decisionAId], + }), + { cwd } + ); + t.is(crossDerive.touched.length, 1); + const crossDeriveId = crossDerive.artifacts[0].id; + const causalRead = await handleEffortRelations(effortBId, crossDeriveId, { + cwd, + relations: ['derives_from'], + strictMinGeneration: crossDerive.generation, + }); + t.is(causalRead.page.returned, 1); + t.true(causalRead.complete); + const checkpoint = `foreign derives_from -> ${decisionAId} (decision; effort ${effortAId}; state accepted)`; + const causalDigest = await readFile(causalRead.artifact_path, 'utf8'); + t.true(causalDigest.includes(checkpoint)); + t.false(causalDigest.includes('### ' + decisionAId)); + const recordRead = await handleEffortGet(crossDeriveId, { + cwd, + strictMinGeneration: crossDerive.generation, + }); + t.true( + (await readFile(recordRead.artifact_path, 'utf8')).includes(checkpoint) + ); const rejectedWrites = [ - { - relation: 'derives_from', - input: { - type: 'WriteDecision', - effort: effortBId, - title: 'Cross derive', - body: '', - derives_from: [findingAId], - }, - }, { relation: 'supersedes', input: { @@ -1083,7 +1207,7 @@ export default { 'utf8' ) ).generation, - Number(decisionB.generation) + Number(crossDerive.generation) ); await writeFile( @@ -1113,11 +1237,6 @@ export default { ); const forwardEdges = [ - { - relation: 'derives_from', - to_id: findingAId, - target_effort_id: effortAId, - }, { relation: 'supersedes', to_id: decisionAId, @@ -1133,7 +1252,7 @@ export default { () => handleEffortRelations(effortBId, decisionBId, { cwd, - relations: ['derives_from', 'supersedes', 'invalidates'], + relations: ['supersedes', 'invalidates'], strictMinGeneration: decisionB.generation, }), { instanceOf: ProofCrossEffortRelationError } @@ -1141,7 +1260,7 @@ export default { 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})`, + message: `Record ${decisionBId} in effort ${effortBId} stores relation targets outside that effort: supersedes -> ${decisionAId} (effort ${effortAId}), invalidates -> ${decisionAId} (effort ${effortAId})`, effort_id: effortBId, from_id: decisionBId, edges: forwardEdges, @@ -1178,7 +1297,7 @@ export default { effortBId, decisionBId, '--relations', - 'derives_from,supersedes,invalidates', + 'supersedes,invalidates', '--strict-min-generation', decisionB.generation ); diff --git a/packages/flatbread/src/proof/read.ts b/packages/flatbread/src/proof/read.ts index b91f17ff..b5331f3a 100644 --- a/packages/flatbread/src/proof/read.ts +++ b/packages/flatbread/src/proof/read.ts @@ -535,6 +535,32 @@ export async function getRecord( record = next; } } + const displayed = requested && anomaly ? requested : record; + const foreignReferences: string[] = []; + if (displayed) { + for (const targetId of displayed.relations.derives_from ?? []) { + const targetCollection = collectionForId(targetId); + const target = targetCollection + ? await projection.one(targetCollection, targetId) + : undefined; + // A direct get still opens the requested record when a stored cause is + // dangling; relations reports the broken edge explicitly. + if (!target) continue; + const effort = owningEffort(target); + if (effort && effort !== owningEffort(displayed)) { + const state = target.frontmatter.retracted + ? 'retracted' + : target.frontmatter.state ?? target.frontmatter.status ?? 'none'; + foreignReferences.push( + `foreign derives_from -> ${target.id} (${ + target.kind + }; effort ${effort}; state ${String(state)})` + ); + } + } + } + const foreignLimit = 25; + const overflow = foreignReferences.length - foreignLimit; return render( options, { @@ -542,10 +568,22 @@ export async function getRecord( id, ...(options.resolve ? { resolve: options.resolve } : {}), }, - requested && anomaly ? [requested] : record ? [record] : [], + displayed ? [displayed] : [], [], undefined, - { checkpointLines: checkpoints.slice(-5), anomaly, fullBody: true } + { + checkpointLines: [ + ...checkpoints.slice(-5), + ...foreignReferences.slice(0, foreignLimit), + ...(overflow > 0 + ? [ + `${overflow} more foreign references; use proof relations to page through them`, + ] + : []), + ], + anomaly, + fullBody: true, + } ); } @@ -708,11 +746,18 @@ export async function relations( } const targetEffort = owningEffort(target); if (targetEffort !== effortId) { - foreign.push({ - relation, - to_id: target.id, - target_effort_id: targetEffort ?? null, - }); + if (relation === 'derives_from' && targetEffort) { + selected.set(`${relation}\0${target.id}`, { + ...target, + foreign_reference: { relation, effort_id: targetEffort }, + }); + } else { + foreign.push({ + relation, + to_id: target.id, + target_effort_id: targetEffort ?? null, + }); + } continue; } selected.set(target.id, target); @@ -723,11 +768,13 @@ export async function relations( throw new ProofCrossEffortRelationError(effortId, fromId, foreign); const records = sortRecords([...selected.values()]); const edges = records.flatMap((record) => - relationNames - .filter((relation) => - (source.relations[relation] ?? []).includes(record.id) - ) - .map((relation) => ({ from_id: fromId, relation, to_id: record.id })) + record.foreign_reference + ? [] + : relationNames + .filter((relation) => + (source.relations[relation] ?? []).includes(record.id) + ) + .map((relation) => ({ from_id: fromId, relation, to_id: record.id })) ); return render( options, diff --git a/packages/proof/README.md b/packages/proof/README.md index 91e3d65a..6ee56861 100644 --- a/packages/proof/README.md +++ b/packages/proof/README.md @@ -37,11 +37,12 @@ which keeps reads small and gives each piece of evidence a clear home. | **Blob** | Attached content such as a document, JSON payload, or image | Typed relations preserve the reasoning between records. A Decision can -`derive_from` the Findings, Constraints, and Issues it responds to. A Finding +`derives_from` the Findings, Constraints, and Issues it responds to. A Finding can `invalidate` an older Finding or Decision. Records cite evidence through `cites`, which names Citation records; a Citation can attach one Blob. Proof -validates every link and keeps it within one Effort. The -[Proof glossary](./skills/proof/glossary.md) gives the exact meanings. +validates every link. Causal `derives_from` references may reach live records +in other Efforts; state-changing links and `cites` stay within one Effort. +The [Proof glossary](./skills/proof/glossary.md) gives the exact meanings. ## First success @@ -189,6 +190,11 @@ Tracked records live in eight directories under the graph root: └── blobs/ ``` +A feature Effort can keep its Decisions together while each Decision +`derives_from` live records in other Efforts. `proof relations` shows each +foreign cause as a one-line checkpoint with its Effort id and current state. +Use `proof get ` when you need the target's body. Links that change a +target's state, including `supersedes` and `invalidates`, stay in one Effort. A single mutation may touch several of these files, because Proof materializes reverse links and lifecycle changes together. The writer validates IDs, record kinds, and Effort boundaries first, then applies the diff --git a/packages/proof/skills/proof/SKILL.md b/packages/proof/skills/proof/SKILL.md index 580aa6d6..cfe62e0f 100644 --- a/packages/proof/skills/proof/SKILL.md +++ b/packages/proof/skills/proof/SKILL.md @@ -103,10 +103,11 @@ Critical semantics: `MitigateRisk`, `SetRiskState`) to transition. `WriteCitation` and `WriteBlob` have no lifecycle state. - `Retract` removes a record from browse reads without deleting the file. - Pass a reason. The writer strips that id from other records in the same - Effort so reads do not fail closed. Use it for session noise that should - never have been journaled. Do not `git rm` records or hand-edit - frontmatter. `proof get` still returns a retracted record. Efforts cannot + Pass a reason. The writer strips that id from records in every Effort, + including foreign `derives_from` links, so reads do not fail closed. Use it + for session noise that should never have been journaled. Do not `git rm` + records or hand-edit frontmatter. `proof get` still returns a retracted + record. Efforts cannot be retracted; abandon them instead. - `AcceptDecision` defaults `rejectSiblings: true`, which rejects only proposed Decisions derived from the same `question` Issue, even if the @@ -115,8 +116,14 @@ Critical semantics: `changedDecisionIds` and `rejectedIds`; pass `dryRun: true` to preview without saving. Use `ReopenDecision` with a reason to restore a rejected Decision to proposed while keeping its rejection in `reopen_history`. -- Edges are forward-only in payloads (`derives_from`, `supersedes`, - `invalidates`); back-edges are materialized automatically. +- A Decision may use `derives_from` to link to a live record in another + Effort. The link records a cause without changing that record. `proof relations` + shows each foreign target as one checkpoint with its Effort id, kind, and + current state. Use `proof get` on a target id to read its full record. + `supersedes`, `invalidates`, and lifecycle links stay within one Effort. +- Edges are forward-only in payloads. `supersedes` and `invalidates` write + `superseded_by` and `invalidated_by` reverse projections; `derives_from` + writes no reverse edge. - External sources: create a `WriteCitation` record (its body may be a URL, with optional `blob` and `role` fields), then add `cites: [""]` when creating an Issue, Finding, Decision, diff --git a/packages/proof/skills/proof/glossary.md b/packages/proof/skills/proof/glossary.md index 15301226..6d805676 100644 --- a/packages/proof/skills/proof/glossary.md +++ b/packages/proof/skills/proof/glossary.md @@ -67,10 +67,11 @@ default; use `proof get ` to read the content. ## Edges -`derives_from` is causal upstream evidence or context. `supersedes` replaces a -record of the same primitive, while `invalidates` says a record was wrong. -Those forward edges are authoritative; `superseded_by` and `invalidated_by` are -writer-materialized reverse projections. +`derives_from` is causal upstream evidence or context and may point to a live +record in another Effort. It writes no reverse projection. `supersedes` +replaces a record of the same primitive, while `invalidates` says a record +was wrong. Those state-changing edges stay within one Effort; their +`superseded_by` and `invalidated_by` reverse projections are writer-materialized. `cites` links an Issue, Finding, Decision, Constraint, or Risk to a Citation. It accepts Citation ids only, never Blob ids. A Citation may optionally point @@ -84,8 +85,10 @@ express. `Retract` hides a record that should not have been journaled. The file stays on disk with `retracted: true` so ids remain resolvable and -`PROOF_DANGLING_RELATION` does not fire. Browse reads omit retracted -records. `proof get` still returns the body and the reason. This is not +`PROOF_DANGLING_RELATION` does not fire. The writer strips inbound relation +ids across every Effort in one transaction, including foreign `derives_from` +references. Browse reads omit retracted records. `proof get` still returns +the body and the reason. This is not supersession (a better same-kind claim) and not invalidation (a Finding that the target was wrong). Git is the undo path for Retract; `ReopenDecision` only returns a rejected Decision to proposed. There is no generic Restore mutation. diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index c62ba2a4..fdeeb436 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -16,10 +16,11 @@ identity. Let the writer generate ids; capture them from mutation results 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 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. +`derives_from[]`, `supersedes[]`, `invalidates[]` (arrays of existing ids). +`derives_from` can name a live record in any Effort. It records a cause but +changes no target. `supersedes` and `invalidates` change targets and must stay +in the new record's Effort. An Effort record counts as belonging to itself, so +a record may `derives_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 @@ -105,7 +106,7 @@ written. It is not a hard delete and not a fold into a survivor: `retracted_reason`. The body is unchanged so `proof get` can still explain what was removed. - On a successful Retract, the writer clears that record's relation fields - and strips its id from every other record in the same Effort in the same + and strips its id from every other record across all Efforts in the same journal transaction. - Browse reads (`list`, `records`, `blocking-decisions`) omit retracted records. `proof get` still returns them. `relations` follows stored edges @@ -216,7 +217,14 @@ flatbread proof cache prune `invalidated_by`, `resolved_by`, and `evidence`, the CLI returns `PROOF_DANGLING_RELATION`. Flatbread's core reference check rejects missing targets for the other relations. A stored target from another Effort returns - `PROOF_CROSS_EFFORT_RELATION`; it never becomes a successful empty page. + `PROOF_CROSS_EFFORT_RELATION` for state-changing or `cites` edges. A foreign + `derives_from` target appears as one checkpoint line with its Effort id, + kind, and current state. Its body is not expanded. Page through more targets + with `--cursor`; `proof get ` opens one explicitly. +- `MitigateRisk` still links a Risk to an accepted Decision in the same Effort. + For a feature Decision in another Effort, write and accept a Decision in the + Risk's Effort that `derives_from` the feature Decision, then use that local + Decision to mitigate the Risk. - `--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 @@ -232,8 +240,8 @@ flatbread proof cache prune - Errors (stderr JSON, exit 1): `PROOF_GENERATION_WAIT_TIMEOUT`, `PROOF_INVALID_CURSOR` (cursor reused across a different query or generation), `PROOF_DANGLING_RELATION` (a stored relation target is missing), - and `PROOF_CROSS_EFFORT_RELATION` (a stored relation target belongs to another - Effort). + and `PROOF_CROSS_EFFORT_RELATION` (a stored state-changing or `cites` + 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 b5febb85..f124460b 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -57,7 +57,7 @@ function one( ) { t.is(writes.length, 1); t.is(writes[0].id, id); - t.is(writes[0].relativePath, path); + t.is(writes[0].relativePath.replace(/\\/g, '/'), path); t.is(writes[0].operation, op); t.deepEqual( parseDocument(writes[0].afterBytes, writes[0].kind).frontmatter, @@ -891,7 +891,44 @@ test('derives_from accepts the record governing Effort', (t) => { }); }); -test('create relations reject targets from another Effort', (t) => { +test('derives_from accepts a live record in another Effort without a reverse write', (t) => { + const foreignFindingId = 'fnd-foreign--0123456789abcdef'; + const s = snap([ + record(E2, 'effort', { + id: E2, + title: 'E2', + created_at: '2025-01-01T00:00:00.000Z', + status: 'active', + }), + record(foreignFindingId, 'finding', { + id: foreignFindingId, + effort: E2, + title: 'Foreign', + kind: 'measurement', + created_at: '2025-01-01T00:00:00.000Z', + }), + ]); + const writes = planMutation( + { + type: 'WriteDecision', + id: ids.decision, + effort: E, + title: 'Feature choice', + body: '', + derives_from: [foreignFindingId], + }, + s, + '/root', + now + ); + t.is(writes.length, 1); + t.is(writes[0].id, ids.decision); + t.deepEqual( + parseDocument(writes[0].afterBytes, 'decision').frontmatter.derives_from, + [foreignFindingId] + ); +}); +test('state-changing create relations reject targets from another Effort', (t) => { const otherEffort = record(E2, 'effort', { id: E2, title: 'E2', @@ -908,17 +945,6 @@ test('create relations reject targets from another Effort', (t) => { }); 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: { diff --git a/packages/proof/src/__tests__/writer.test.ts b/packages/proof/src/__tests__/writer.test.ts index 2c27ac29..aa351fda 100644 --- a/packages/proof/src/__tests__/writer.test.ts +++ b/packages/proof/src/__tests__/writer.test.ts @@ -143,7 +143,77 @@ test('one create preserves both reverse projections to the same target', async ( t.deepEqual(targetRecord.data.invalidated_by, [source]); }); -test('cross-Effort create relations reject without changing files or generation', async (t) => { +test('a feature Decision can derive from two Efforts and Retract strips foreign links', async (t) => { + const { root, writer } = await makeWriter(); + const feature = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'Feature', body: '' }) + ); + const geometry = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'Geometry', body: '' }) + ); + const exportEffort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'Export', body: '' }) + ); + const geometryDecision = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort: geometry, + title: 'Geometry rule', + body: '', + }) + ); + const exportDecision = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort: exportEffort, + title: 'Export rule', + body: '', + }) + ); + await writer.mutate({ + type: 'AcceptDecision', + decisionId: geometryDecision, + rejectSiblings: false, + }); + await writer.mutate({ + type: 'AcceptDecision', + decisionId: exportDecision, + rejectSiblings: false, + }); + const before = await readFile( + join(root, 'decisions', `${geometryDecision}.md`) + ); + const written = await writer.mutate({ + type: 'WriteDecision', + effort: feature, + title: 'Feature choice', + body: '', + derives_from: [geometryDecision, exportDecision], + }); + t.is(written.touched.length, 1); + t.deepEqual( + await readFile(join(root, 'decisions', `${geometryDecision}.md`)), + before + ); + const featureDecision = written.artifacts[0].id; + t.deepEqual( + (await readFrontmatter(root, `decisions/${featureDecision}.md`)).data + .derives_from, + [geometryDecision, exportDecision].sort() + ); + const retraction = await writer.mutate({ + type: 'Retract', + recordId: geometryDecision, + reason: 'rule was wrong', + }); + t.true(retraction.touched.some((item) => item.id === featureDecision)); + t.deepEqual( + (await readFrontmatter(root, `decisions/${featureDecision}.md`)).data + .derives_from, + [exportDecision] + ); +}); +test('cross-Effort state-changing 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: '' }) @@ -169,18 +239,6 @@ test('cross-Effort create relations reject without changing files or generation' 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', diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index a9ed70aa..7553134b 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -22,6 +22,7 @@ export interface ReadRecord { frontmatter: Record; body_excerpt: string; relations: Partial>; + foreign_reference?: { relation: ReadRelation; effort_id: string }; } export interface ReadEdge { @@ -193,6 +194,14 @@ function renderRecord( record: ReadRecord, options: { bodyMode?: RecordBodyMode } = {} ): string { + if (record.foreign_reference) { + const state = record.frontmatter.retracted + ? 'retracted' + : record.frontmatter.state ?? record.frontmatter.status ?? 'none'; + return `- foreign ${record.foreign_reference.relation} -> ${record.id} (${ + record.kind + }; effort ${record.foreign_reference.effort_id}; state ${String(state)})`; + } const bodyMode = options.bodyMode ?? 'excerpt'; const frontmatter = Object.fromEntries( FRONTMATTER_KEYS.filter((key) => record.frontmatter[key] !== undefined).map( @@ -304,7 +313,9 @@ export async function renderDigest(input: DigestInput): Promise { ...(input.anomaly ? [`> anomaly: ${input.anomaly}`, ''] : []), '# Proof read', '## Index', - ...visible.map((record) => `- [\`${record.id}\`](#${record.id})`), + ...visible + .filter((record) => !record.foreign_reference) + .map((record) => `- [\`${record.id}\`](#${record.id})`), '## Records', ...visible.map((record) => renderPrimary(record)), ...(input.relatedRecords?.length @@ -338,7 +349,10 @@ export async function renderDigest(input: DigestInput): Promise { ...(anomaly ? [`> anomaly: ${anomaly}`, ''] : []), '# Proof read', '## Index', - ...visible.map((record) => `- [\`${record.id}\`](#${record.id})`), + ...visible + .filter((record) => !record.foreign_reference) + .map((record) => `- [\`${record.id}\`](#${record.id})`), + ...checkpoints, '## Records', ]; const sections: string[] = []; diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index 1c9057af..163caf1b 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -188,13 +188,11 @@ function assertTargetEffort( */ function assertDerivesFrom( get: GetRecord, - effortId: string, derivesFrom: string[] | undefined ): void { for (const targetId of derivesFrom ?? []) { const target = get(targetId); assertLive(target); - assertTargetEffort('derives_from', effortId, target); } } @@ -313,7 +311,7 @@ export function planMutation( } if (EPISTEMIC_CREATE.has(kind)) { assertCites(get, raw.effort, raw.cites); - assertDerivesFrom(get, raw.effort, raw.derives_from); + assertDerivesFrom(get, raw.derives_from); } const fm: Record = { ...raw, @@ -551,8 +549,11 @@ export function planMutation( const effortId = owningEffort(target); if (!effortId) throw new ProofValidationError('Retract target has no effort'); + const allRecords = (Object.keys(KIND_DIRECTORY) as PrimitiveKind[]).flatMap( + (kind) => snapshot.recordsByKind(kind) + ); const dependents: string[] = []; - for (const record of snapshot.recordsByEffort(effortId)) { + for (const record of allRecords) { if (record.id === target.id || isRetracted(record)) continue; if ( isSoleCloser(record.frontmatter, target.id) || @@ -581,7 +582,7 @@ export function planMutation( }, target.body ); - for (const record of snapshot.recordsByEffort(effortId)) { + for (const record of allRecords) { if (record.id === target.id) continue; const result = stripRelationId({ ...record.frontmatter }, target.id); if (!result.changed) continue;