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
17 changes: 11 additions & 6 deletions .agents/skills/proof/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,8 +59,9 @@ provided, `blob` must be the id of a Blob in the same Effort as the Citation.
{"type":"Invalidate","findingId":"<fnd-id>","targetId":"<finding-or-decision-id>"}
```

`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

Expand Down Expand Up @@ -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
Expand All @@ -161,8 +165,9 @@ flatbread proof cache prune
generation, or fail. `--timeout-ms <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

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
234 changes: 234 additions & 0 deletions packages/flatbread/src/cli/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ProofCrossEffortRelationError>(
() =>
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<ProofCrossEffortRelationError>(
() =>
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) => {
Expand Down
34 changes: 26 additions & 8 deletions packages/flatbread/src/proof/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FlatbreadProvider, type LoadedFlatbreadConfig } from '@flatbread/core';
import {
canonicalizeReadQuery,
ProofConsistencyError,
ProofCrossEffortRelationError,
ProofDanglingRelationError,
ProofInvalidCursorError,
ProofReadValidationError,
Expand Down Expand Up @@ -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, unknown>): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
Expand Down Expand Up @@ -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<string, ReadRecord>();
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);
Expand All @@ -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
Expand Down
Loading
Loading