Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .agents/skills/proof/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: ["<cit-id>"]` when creating an Issue, Finding, Decision,
Expand Down
15 changes: 9 additions & 6 deletions .agents/skills/proof/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ default; use `proof get <blob-id>` 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
Expand All @@ -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.
Expand Down
24 changes: 16 additions & 8 deletions .agents/skills/proof/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <targetId>` 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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 139 additions & 20 deletions packages/flatbread/src/cli/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProofDanglingRelationError>(
() =>
handleEffortRelations(effortId, decisionId, {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -1083,7 +1207,7 @@ export default {
'utf8'
)
).generation,
Number(decisionB.generation)
Number(crossDerive.generation)
);

await writeFile(
Expand Down Expand Up @@ -1113,11 +1237,6 @@ export default {
);

const forwardEdges = [
{
relation: 'derives_from',
to_id: findingAId,
target_effort_id: effortAId,
},
{
relation: 'supersedes',
to_id: decisionAId,
Expand All @@ -1133,15 +1252,15 @@ export default {
() =>
handleEffortRelations(effortBId, decisionBId, {
cwd,
relations: ['derives_from', 'supersedes', 'invalidates'],
relations: ['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})`,
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,
Expand Down Expand Up @@ -1178,7 +1297,7 @@ export default {
effortBId,
decisionBId,
'--relations',
'derives_from,supersedes,invalidates',
'supersedes,invalidates',
'--strict-min-generation',
decisionB.generation
);
Expand Down
Loading
Loading