From d12228011877fbd802129b59fa70a1fefa04fa7c Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Sat, 15 Aug 2026 23:45:02 +0000 Subject: [PATCH 1/9] fix(proof): expose read completeness Add `complete` and `cap_reasons` to every Proof read envelope. Derive the JSON fields, digest header, and summary from the same state so pagination and hard caps cannot disagree. Keep pagination out of `cap_reasons`; `page.has_more` and its cursor describe it. Report primary record, displayed edge, and byte caps as stable, duplicate-free values. Tests: - `pnpm verify` Fixes #252 Change-Id: I4cbd9019dd5ef042482e5e5824211dc95d1e1592 --- .agents/skills/proof/reference.md | 8 +++ CHANGELOG.md | 3 + packages/flatbread/src/cli/proof.test.ts | 66 +++++++++++++++++++++ packages/proof/skills/proof/reference.md | 8 +++ packages/proof/src/__tests__/digest.test.ts | 58 ++++++++++++++++++ packages/proof/src/digest.ts | 46 ++++++++++---- 6 files changed, 176 insertions(+), 13 deletions(-) diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index f1842a44..5e4cfbea 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -99,6 +99,8 @@ the generated schema) and return a `ReadEnvelope`: "artifact_sha256": "...", "served_generation": "55", "consistency": { "mode": "eventual|strict", "min_generation": null }, + "complete": true, + "cap_reasons": [], "page": { "returned": 2, "has_more": false, "next_cursor": null }, "hints": ["getRecord(\"dec-...\")"] } @@ -121,6 +123,12 @@ than expecting more. If a `get` body alone exceeds the 64 KiB digest byte cap, the digest fails closed with a byte-cap banner (it does **not** fake a full body via the 600/12 excerpt). +Every read envelope carries `complete` and `cap_reasons`. A page with more +records has `complete: false`, an empty `cap_reasons`, and +`page.has_more: true`. Hard caps use the stable reasons `primary_records`, +`displayed_edges`, and `bytes`. Programs must read these fields from the JSON +envelope; do not parse the digest or `summary` as a data feed. + ### Commands ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 705d05ae..6f8acc8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased - The DAG runner is now `@flatbread/oven` (`pnpm exec oven`); the memory package is now `@flatbread/proof` with the `flatbread proof` CLI. +- Proof read envelopes now expose `complete` and `cap_reasons`. Callers can + tell paging from the `primary_records`, `displayed_edges`, and `bytes` caps + without parsing the digest Markdown or `summary` text. - `@flatbread/source-filesystem` reads a content directory that does not exist as an empty collection instead of throwing `ENOENT`. Git cannot store an empty directory, and a Proof write creates only the directory it writes, so diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index e7795792..1405718d 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -332,6 +332,72 @@ export default { } ); +test.serial( + 'spawned CLI exposes complete, paged, and byte-capped reads', + async (t) => { + const cwd = await createTempProject('flatbread-effort-completeness-', 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 firstEffort = await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'First', body: '' }), + { cwd } + ); + await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'Second', body: '' }), + { cwd } + ); + const blob = await handleEffortWrite( + JSON.stringify({ + type: 'WriteBlob', + effort: firstEffort.artifacts[0].id, + title: 'Large payload', + body: 'x'.repeat(70 * 1024), + kind: 'markdown', + }), + { cwd } + ); + + const completeResult = await runCli( + cwd, + 'proof', + 'get', + firstEffort.artifacts[0].id + ); + t.is(completeResult.code, 0); + const complete = JSON.parse(completeResult.stdout); + t.true(complete.complete); + t.deepEqual(complete.cap_reasons, []); + + const pagedResult = await runCli(cwd, 'proof', 'list', '--limit', '1'); + t.is(pagedResult.code, 0); + const paged = JSON.parse(pagedResult.stdout); + t.false(paged.complete); + t.deepEqual(paged.cap_reasons, []); + t.true(paged.page.has_more); + + const cappedResult = await runCli( + cwd, + 'proof', + 'get', + blob.artifacts[0].id + ); + t.is(cappedResult.code, 0); + const capped = JSON.parse(cappedResult.stdout); + t.false(capped.complete); + t.deepEqual(capped.cap_reasons, ['bytes']); + t.false(capped.page.has_more); + } +); + test.serial( 'effort list defaults to active and supports explicit statuses and cursors', async (t) => { diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index f1842a44..5e4cfbea 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -99,6 +99,8 @@ the generated schema) and return a `ReadEnvelope`: "artifact_sha256": "...", "served_generation": "55", "consistency": { "mode": "eventual|strict", "min_generation": null }, + "complete": true, + "cap_reasons": [], "page": { "returned": 2, "has_more": false, "next_cursor": null }, "hints": ["getRecord(\"dec-...\")"] } @@ -121,6 +123,12 @@ than expecting more. If a `get` body alone exceeds the 64 KiB digest byte cap, the digest fails closed with a byte-cap banner (it does **not** fake a full body via the 600/12 excerpt). +Every read envelope carries `complete` and `cap_reasons`. A page with more +records has `complete: false`, an empty `cap_reasons`, and +`page.has_more: true`. Hard caps use the stable reasons `primary_records`, +`displayed_edges`, and `bytes`. Programs must read these fields from the JSON +envelope; do not parse the digest or `summary` as a data feed. + ### Commands ```bash diff --git a/packages/proof/src/__tests__/digest.test.ts b/packages/proof/src/__tests__/digest.test.ts index c84ae46e..ec31cfbe 100644 --- a/packages/proof/src/__tests__/digest.test.ts +++ b/packages/proof/src/__tests__/digest.test.ts @@ -40,6 +40,8 @@ test('renderDigest is deterministic and reuses the atomic cache artifact', async const bytes = await readFile(first.artifact_path); const second = await renderDigest(input); t.deepEqual(first, second); + t.true(first.complete); + t.deepEqual(first.cap_reasons, []); t.is(await stat(first.artifact_path).then((x) => x.isFile()), true); const digest = bytes.toString(); t.true(digest.includes(longBody)); @@ -99,6 +101,8 @@ test('renderDigest fullBody byte-cap miss does not fake-full with excerpt', asyn edges: [], }); const digest = await readFile(result.artifact_path, 'utf8'); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['bytes']); t.true(digest.includes('complete: false')); t.true(digest.includes('cap_reasons')); t.true(digest.includes('body exceeded digest byte cap')); @@ -162,6 +166,8 @@ test('pagination is incomplete without adding a cap reason', async (t) => { nextCursor: 'next', }); const digest = await readFile(result.artifact_path, 'utf8'); + t.false(result.complete); + t.deepEqual(result.cap_reasons, []); t.true(digest.includes('complete: false')); t.true(digest.includes('"total_known":2')); t.false(digest.includes('cap_reasons')); @@ -170,6 +176,58 @@ test('pagination is incomplete without adding a cap reason', async (t) => { t.is(result.page.next_cursor, 'next'); }); +test('primary-record caps are machine readable', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-record-cap-')); + const result = await renderDigest({ + query: { type: 'listRecords', effort: 'eff-one--0123456789abcdef' }, + queryHash: 'record-cap', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + records: Array.from({ length: 26 }, (_, index) => ({ + id: `fnd-record-${index}--0123456789abcdef`, + kind: 'finding' as const, + path: `findings/record-${index}.md`, + frontmatter: { title: `Record ${index}` }, + body_excerpt: '', + relations: {}, + })), + edges: [], + }); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['primary_records']); + t.true(result.page.has_more); +}); + +test('displayed-edge caps are machine readable', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-edge-cap-')); + const result = await renderDigest({ + query: { type: 'relations', effort_id: 'eff-one--0123456789abcdef' }, + queryHash: 'edge-cap', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + records: [ + { + id: 'dec-one--0123456789abcdef', + kind: 'decision' as const, + path: 'decisions/one.md', + frontmatter: { title: 'One' }, + body_excerpt: '', + relations: {}, + }, + ], + edges: Array.from({ length: 51 }, (_, index) => ({ + from_id: 'dec-one--0123456789abcdef', + relation: 'derives_from' as const, + to_id: `fnd-edge-${index}--0123456789abcdef`, + })), + }); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['displayed_edges']); + t.is(new Set(result.cap_reasons).size, result.cap_reasons.length); +}); + test('renderDigest omits Blob bodies from bounded digests', async (t) => { const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-blob-')); const secret = 'SECRET_BLOB_PAYLOAD_SHOULD_NOT_APPEAR'; diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index 5495ed29..de371c15 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -30,12 +30,16 @@ export interface ReadEdge { to_id: string; } +export type ReadCapReason = 'primary_records' | 'displayed_edges' | 'bytes'; + export interface ReadEnvelope { summary: string; artifact_path: string; artifact_sha256: string; served_generation: string; consistency: { mode: 'eventual' | 'strict'; min_generation: string | null }; + complete: boolean; + cap_reasons: ReadCapReason[]; page: { returned: number; has_more: boolean; next_cursor: string | null }; hints: string[]; } @@ -93,7 +97,20 @@ function scalar(value: unknown): string { return JSON.stringify(value); } -function yamlHeader(input: DigestInput, complete: boolean, reasons: string[]) { +interface DigestCompleteness { + complete: boolean; + capReasons: ReadCapReason[]; +} + +function digestCompleteness( + hasMore: boolean, + reasons: readonly ReadCapReason[] +): DigestCompleteness { + const capReasons = [...new Set(reasons)].sort(); + return { complete: !hasMore && capReasons.length === 0, capReasons }; +} + +function yamlHeader(input: DigestInput, state: DigestCompleteness) { const query = JSON.stringify(input.query); return [ '---', @@ -107,15 +124,15 @@ function yamlHeader(input: DigestInput, complete: boolean, reasons: string[]) { total_known: input.totalKnown ?? input.records.length, has_more: Boolean(input.hasMore), })}`, - `complete: ${complete}`, + `complete: ${state.complete}`, `caps: ${JSON.stringify({ primary_records: CAP_RECORDS, relation_hops: 1, displayed_edges: CAP_EDGES, bytes: CAP_BYTES, })}`, - ...(reasons.length - ? [`cap_reasons: ${JSON.stringify(reasons.sort())}`] + ...(state.capReasons.length + ? [`cap_reasons: ${JSON.stringify(state.capReasons)}`] : []), '---', ].join('\n'); @@ -200,7 +217,7 @@ function renderRecord( function summary( records: readonly ReadRecord[], complete: boolean, - reasons: string[], + reasons: readonly string[], hasMore: boolean ): string { const states = new Map(); @@ -255,10 +272,10 @@ export async function renderDigest(input: DigestInput): Promise { ) ) .slice(0, CAP_EDGES); - const reasons = [ - ...(records.length > CAP_RECORDS ? ['primary_records'] : []), - ...(input.edges.length > CAP_EDGES ? ['displayed_edges'] : []), - ]; + const reasons: ReadCapReason[] = []; + if (records.length > CAP_RECORDS) reasons.push('primary_records'); + if (input.edges.length > CAP_EDGES) reasons.push('displayed_edges'); + let completeness = digestCompleteness(Boolean(input.hasMore), reasons); const checkpoints = input.checkpointLines?.length ? ['## Lineage checkpoints', ...input.checkpointLines, ''] : []; @@ -270,7 +287,7 @@ export async function renderDigest(input: DigestInput): Promise { const renderRelated = (record: ReadRecord) => renderRecord(record, { bodyMode: 'excerpt' }); let markdown = [ - yamlHeader(input, reasons.length === 0 && !input.hasMore, reasons), + yamlHeader(input, completeness), ...(input.anomaly ? [`> anomaly: ${input.anomaly}`, ''] : []), '# Proof read', '## Index', @@ -291,6 +308,7 @@ export async function renderDigest(input: DigestInput): Promise { ].join('\n'); if (Buffer.byteLength(markdown) > CAP_BYTES) { reasons.push('bytes'); + completeness = digestCompleteness(Boolean(input.hasMore), reasons); // Full-body digests must not silently fall back to the 600/12 excerpt. // Prefer a visible byte-cap miss banner over a fake "full" body. const overflowBodyMode: RecordBodyMode = input.fullBody @@ -303,7 +321,7 @@ export async function renderDigest(input: DigestInput): Promise { ? 'body exceeded digest byte cap' : input.anomaly; const header = [ - yamlHeader(input, false, reasons), + yamlHeader(input, completeness), ...(anomaly ? [`> anomaly: ${anomaly}`, ''] : []), '# Proof read', '## Index', @@ -363,14 +381,16 @@ export async function renderDigest(input: DigestInput): Promise { const envelope: ReadEnvelope = { summary: summary( visible, - reasons.length === 0 && !input.hasMore, - reasons, + completeness.complete, + completeness.capReasons, Boolean(input.hasMore) ), artifact_path: path, artifact_sha256: createHash('sha256').update(bytes).digest('hex'), served_generation: input.generation, consistency: input.consistency, + complete: completeness.complete, + cap_reasons: completeness.capReasons, page: { returned: visible.length, has_more: Boolean(input.hasMore || records.length > CAP_RECORDS), From dd877732aee5cd4750d73f07f582daca8ac101cc Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Wed, 19 Aug 2026 07:12:57 +0000 Subject: [PATCH 2/9] fix(proof): address completeness review Apply the reviewed pagination contract, stacked cap tests, CLI edge coverage, and synced read guidance as a fast-forward follow-up to PR #254.\n\nFixes #252 Change-Id: I74d0516d4279254bced9cc4e81970ed1cc498de8 --- .agents/skills/proof/reference.md | 24 ++++++--- packages/flatbread/src/cli/proof.test.ts | 59 ++++++++++++++++++++- packages/proof/skills/proof/reference.md | 24 ++++++--- packages/proof/src/__tests__/digest.test.ts | 46 +++++++++++++++- packages/proof/src/digest.ts | 2 +- 5 files changed, 137 insertions(+), 18 deletions(-) diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index 0c054ef3..027f5808 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -126,11 +126,20 @@ than expecting more. If a `get` body alone exceeds the 64 KiB digest byte cap, the digest fails closed with a byte-cap banner (it does **not** fake a full body via the 600/12 excerpt). -Every read envelope carries `complete` and `cap_reasons`. A page with more -records has `complete: false`, an empty `cap_reasons`, and -`page.has_more: true`. Hard caps use the stable reasons `primary_records`, -`displayed_edges`, and `bytes`. Programs must read these fields from the JSON -envelope; do not parse the digest or `summary` as a data feed. +Every read envelope carries `complete` and `cap_reasons`. Read it in this +order: + +1. If `complete` is true, the artifact is complete. +2. If `page.has_more` is true, fetch `page.next_cursor`. A null cursor is an + error; do not retry the same page. +3. If `cap_reasons` is not empty, narrow the query or fail closed. It can hold + several sorted, duplicate-free values from `primary_records`, + `displayed_edges`, and `bytes`. +4. A hard cap alone leaves `page.has_more: false` and `next_cursor: null`. + Paging and hard caps can occur together, but caps never create a cursor. + +Programs must read these fields from the JSON envelope; do not parse the +digest or `summary` as a data feed. ### Commands @@ -192,8 +201,9 @@ flatbread proof cache prune - Do not hand-edit record frontmatter or `.journal/`; bodies are freely editable (the reindexer validates and repairs projections). -- Do not parse digest files as data feeds for other programs — they are - evidence for you to Read/grep; the envelope is the machine surface. +- Do not parse digest files or `summary` as data feeds for other programs — + the digest is evidence for you to read or search; the envelope is the + machine surface. - Do not build polling loops around generations; strict reads wait server-side. - Do not model sessions/plans/agents as records — put provenance in diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index 9997090e..7a870027 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -334,7 +334,7 @@ export default { ); test.serial( - 'spawned CLI exposes complete, paged, and byte-capped reads', + 'spawned CLI exposes complete, paged, byte-, and edge-capped reads', async (t) => { const cwd = await createTempProject('flatbread-effort-completeness-', t); await writeFile( @@ -366,6 +366,47 @@ export default { }), { cwd } ); + const effortId = firstEffort.artifacts[0].id; + const citationIds = Array.from( + { length: 51 }, + (_, index) => + `cit-edge-${String(index).padStart(2, '0')}--0123456789abcdef` + ); + const findingId = 'fnd-edge-source--0123456789abcdef'; + await mkdir(join(cwd, '.flatbread-proof', 'citations'), { + recursive: true, + }); + await mkdir(join(cwd, '.flatbread-proof', 'findings'), { + recursive: true, + }); + await Promise.all( + citationIds.map((id, index) => + writeFile( + join(cwd, '.flatbread-proof', 'citations', `${id}.md`), + serializeDocument(`https://example.com/${index}`, { + id, + effort: effortId, + title: `Edge ${index}`, + created_at: `2025-01-01T00:00:${String(index).padStart( + 2, + '0' + )}.000Z`, + role: 'evidence', + }) + ) + ) + ); + await writeFile( + join(cwd, '.flatbread-proof', 'findings', `${findingId}.md`), + serializeDocument('', { + id: findingId, + effort: effortId, + title: 'Edge source', + created_at: '2025-01-01T00:01:00.000Z', + kind: 'measurement', + cites: citationIds, + }) + ); const completeResult = await runCli( cwd, @@ -396,6 +437,22 @@ export default { t.false(capped.complete); t.deepEqual(capped.cap_reasons, ['bytes']); t.false(capped.page.has_more); + + const edgeResult = await runCli( + cwd, + 'proof', + 'relations', + effortId, + findingId, + '--relations', + 'cites' + ); + t.is(edgeResult.code, 0); + const edgeCapped = JSON.parse(edgeResult.stdout); + t.false(edgeCapped.complete); + t.deepEqual(edgeCapped.cap_reasons, ['displayed_edges']); + t.true(edgeCapped.page.has_more); + t.truthy(edgeCapped.page.next_cursor); } ); diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index 0c054ef3..027f5808 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -126,11 +126,20 @@ than expecting more. If a `get` body alone exceeds the 64 KiB digest byte cap, the digest fails closed with a byte-cap banner (it does **not** fake a full body via the 600/12 excerpt). -Every read envelope carries `complete` and `cap_reasons`. A page with more -records has `complete: false`, an empty `cap_reasons`, and -`page.has_more: true`. Hard caps use the stable reasons `primary_records`, -`displayed_edges`, and `bytes`. Programs must read these fields from the JSON -envelope; do not parse the digest or `summary` as a data feed. +Every read envelope carries `complete` and `cap_reasons`. Read it in this +order: + +1. If `complete` is true, the artifact is complete. +2. If `page.has_more` is true, fetch `page.next_cursor`. A null cursor is an + error; do not retry the same page. +3. If `cap_reasons` is not empty, narrow the query or fail closed. It can hold + several sorted, duplicate-free values from `primary_records`, + `displayed_edges`, and `bytes`. +4. A hard cap alone leaves `page.has_more: false` and `next_cursor: null`. + Paging and hard caps can occur together, but caps never create a cursor. + +Programs must read these fields from the JSON envelope; do not parse the +digest or `summary` as a data feed. ### Commands @@ -192,8 +201,9 @@ flatbread proof cache prune - Do not hand-edit record frontmatter or `.journal/`; bodies are freely editable (the reindexer validates and repairs projections). -- Do not parse digest files as data feeds for other programs — they are - evidence for you to Read/grep; the envelope is the machine surface. +- Do not parse digest files or `summary` as data feeds for other programs — + the digest is evidence for you to read or search; the envelope is the + machine surface. - Do not build polling loops around generations; strict reads wait server-side. - Do not model sessions/plans/agents as records — put provenance in diff --git a/packages/proof/src/__tests__/digest.test.ts b/packages/proof/src/__tests__/digest.test.ts index ec31cfbe..44f6a279 100644 --- a/packages/proof/src/__tests__/digest.test.ts +++ b/packages/proof/src/__tests__/digest.test.ts @@ -194,9 +194,19 @@ test('primary-record caps are machine readable', async (t) => { })), edges: [], }); + const digest = await readFile(result.artifact_path, 'utf8'); t.false(result.complete); t.deepEqual(result.cap_reasons, ['primary_records']); - t.true(result.page.has_more); + t.false(result.page.has_more); + t.is(result.page.next_cursor, null); + t.true( + digest.includes( + 'primary: {"returned":25,"total_known":26,"has_more":false}' + ) + ); + t.true(digest.includes('complete: false')); + t.true(digest.includes('cap_reasons: ["primary_records"]')); + t.true(result.summary.includes('incomplete: primary_records')); }); test('displayed-edge caps are machine readable', async (t) => { @@ -223,9 +233,41 @@ test('displayed-edge caps are machine readable', async (t) => { to_id: `fnd-edge-${index}--0123456789abcdef`, })), }); + const digest = await readFile(result.artifact_path, 'utf8'); t.false(result.complete); t.deepEqual(result.cap_reasons, ['displayed_edges']); - t.is(new Set(result.cap_reasons).size, result.cap_reasons.length); + t.false(result.page.has_more); + t.is(result.page.next_cursor, null); + t.true(digest.includes('complete: false')); + t.true(digest.includes('cap_reasons: ["displayed_edges"]')); + t.true(result.summary.includes('incomplete: displayed_edges')); +}); + +test('stacked byte and record caps survive the byte rebuild', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-stacked-caps-')); + const result = await renderDigest({ + query: { type: 'listRecords', effort: 'eff-one--0123456789abcdef' }, + queryHash: 'stacked-caps', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + records: Array.from({ length: 26 }, (_, index) => ({ + id: `fnd-large-${index}--0123456789abcdef`, + kind: 'finding' as const, + path: `findings/large-${index}.md`, + frontmatter: { title: `Large ${index} ${'x'.repeat(3000)}` }, + body_excerpt: '', + relations: {}, + })), + edges: [], + }); + const digest = await readFile(result.artifact_path, 'utf8'); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['bytes', 'primary_records']); + t.false(result.page.has_more); + t.is(result.page.next_cursor, null); + t.true(digest.includes('cap_reasons: ["bytes","primary_records"]')); + t.true(result.summary.includes('incomplete: bytes, primary_records')); }); test('renderDigest omits Blob bodies from bounded digests', async (t) => { diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index de371c15..0d8dcdbf 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -393,7 +393,7 @@ export async function renderDigest(input: DigestInput): Promise { cap_reasons: completeness.capReasons, page: { returned: visible.length, - has_more: Boolean(input.hasMore || records.length > CAP_RECORDS), + has_more: Boolean(input.hasMore), next_cursor: input.hasMore ? input.nextCursor ?? null : null, }, hints: ( From e7f6dc86fbd4508e0d49775c17d840587e74be69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 16:47:08 +0000 Subject: [PATCH 3/9] fix(proof): lock paging-only has_more after review Addresses the 19 Aug / 22 Aug grouped notes on #254. - CHANGELOG: page.has_more is pagination-only; use cap_reasons for walls - Both reference.md copies: page only when has_more; hard caps need a narrower query - CLI spawn: assert next_cursor on page-only, null on bytes, summary co-list - Digest unit: paging plus displayed_edges; refuse hasMore without a cursor - renderDigest: has_more requires a non-empty nextCursor Change-Id: I4023a95677e42aee745e3a2e6748852cb8ea1af3 Co-authored-by: Tony --- .agents/skills/proof/reference.md | 13 ++-- ...grouped-review-22-aug--cpbe5anhpby3h625.md | 9 +++ ...ve-disjoint-file-grou--bx44enbv52ztnrnn.md | 23 +++++++ ...-only-has-more-map-an--hk8r9xfee39s64vc.md | 21 ++++++ CHANGELOG.md | 9 ++- packages/flatbread/src/cli/proof.test.ts | 4 ++ packages/proof/skills/proof/reference.md | 13 ++-- packages/proof/src/__tests__/digest.test.ts | 67 +++++++++++++++++++ packages/proof/src/digest.ts | 27 +++++--- 9 files changed, 164 insertions(+), 22 deletions(-) create mode 100644 .flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md create mode 100644 .flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md create mode 100644 .flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index 027f5808..2270bd77 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -120,11 +120,14 @@ lists), one-hop related records, and an edge table. Body policy: from these digests — use `proof get` for the payload. Citation bodies (usually short) still excerpt normally. -Caps: 25 primary records, one hop, 50 edges, 64 KiB; hitting a cap sets -`complete: false` with named `cap_reasons` — narrow the query or page rather -than expecting more. If a `get` body alone exceeds the 64 KiB digest byte -cap, the digest fails closed with a byte-cap banner (it does **not** fake a -full body via the 600/12 excerpt). +Caps: 25 primary records, one hop, 50 edges, 64 KiB. Hitting a cap sets +`complete: false` with named `cap_reasons`. Page only when `page.has_more` is +true. Non-empty hard `cap_reasons` that paging cannot clear mean narrow the +query or fail closed. `primary_records` is a defensive in-process signal after +the CLI pre-slices to at most 25 primary records; `proof list` and +`proof records` do not emit it. If a `get` body alone exceeds the 64 KiB +digest byte cap, the digest fails closed with a byte-cap banner (it does **not** +fake a full body via the 600/12 excerpt). Every read envelope carries `complete` and `cap_reasons`. Read it in this order: diff --git a/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md b/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md new file mode 100644 index 00000000..6132f2d0 --- /dev/null +++ b/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md @@ -0,0 +1,9 @@ +--- +id: cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: PR 254 grouped review 22 Aug +role: evidence +created_at: '2026-08-22T16:46:24.804Z' +--- + +https://github.com/FlatbreadLabs/flatbread/pull/254#pullrequestreview-5000573079 diff --git a/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md b/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md new file mode 100644 index 00000000..880b28a7 --- /dev/null +++ b/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md @@ -0,0 +1,23 @@ +--- +id: dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: Address PR 254 review as five disjoint file groups +state: accepted +created_at: '2026-08-22T16:46:40.750Z' +derives_from: + - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc +--- + +Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only and to lock two missing tests, plus optional refuse of hasMore without a cursor. + +Choice: one follow-up branch with five exclusive file owners. + +1. CHANGELOG Unreleased now says page.has_more is pagination-only; use cap_reasons and complete for hard caps. primary_records stays an in-process signal after the CLI slice. +2. Both reference.md copies (source plus skills:sync) now say page only when page.has_more; hard caps that paging cannot clear mean narrow or fail closed. +3. CLI completeness spawn now asserts a non-null next_cursor on page-only list, a null cursor on bytes, and summary names displayed_edges and pagination together on relations. +4. Digest unit now covers displayed_edges plus hasMore/nextCursor, and refuses hasMore without a cursor. +5. renderDigest treats pagination as present only when hasMore is true and nextCursor is a non-empty string. It does not OR the 25-record wall back into has_more. + +Alternatives: amend PR 254 in place; skip optional refuse. We kept the refuse because docs already call a null cursor an error. + +Reversal: revert this follow-up. Digest cache rebuilds on the next read. diff --git a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md b/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md new file mode 100644 index 00000000..e525a010 --- /dev/null +++ b/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md @@ -0,0 +1,21 @@ +--- +id: fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: PR 254 still omitted paging-only has_more map and two tests +kind: gap +created_at: '2026-08-22T16:46:30.481Z' +derives_from: + - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +cites: + - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 +--- + +The 22 Aug grouping review of PR 254 listed four open 19 Aug notes and one optional harden. + +1. CHANGELOG advertised complete and cap_reasons but not that page.has_more is pagination-only. +2. Both reference.md copies still said narrow or page when a hard cap hit. Paging cannot clear displayed_edges. +3. The CLI page-only spawn checked has_more but not next_cursor, and skipped a null cursor on bytes plus summary co-list on relations. +4. Digest unit cases never set hasMore with a hard cap, so summary pagination plus a hard reason was unproven. +5. Optional: renderDigest could still emit has_more true with a null cursor if DigestInput was mis-paired. + +None of these said the feature was wrong. They asked to say the Load more rule out loud and lock it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 77560fa3..00054da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,12 @@ ## Unreleased - The DAG runner is now `@flatbread/oven` (`pnpm exec oven`); the memory package is now `@flatbread/proof` with the `flatbread proof` CLI. -- Proof read envelopes now expose `complete` and `cap_reasons`. Callers can - tell paging from the `primary_records`, `displayed_edges`, and `bytes` caps - without parsing the digest Markdown or `summary` text. +- Proof read envelopes now expose `complete` and `cap_reasons`. `page.has_more` + is pagination-only and no longer signals the 25-record wall; use + `cap_reasons` / `complete` for hard caps on `displayed_edges` and `bytes`. + The `primary_records` limit remains an in-process defensive signal because + the CLI read bridge slices to 25 records before rendering. Callers can tell + paging from hard caps without parsing the digest Markdown or `summary` text. - `@flatbread/source-filesystem` reads a content directory that does not exist as an empty collection instead of throwing `ENOENT`. Git cannot store an empty directory, and a Proof write creates only the directory it writes, so diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index 7a870027..ab6e03fa 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -425,6 +425,7 @@ export default { t.false(paged.complete); t.deepEqual(paged.cap_reasons, []); t.true(paged.page.has_more); + t.truthy(paged.page.next_cursor); const cappedResult = await runCli( cwd, @@ -437,6 +438,7 @@ export default { t.false(capped.complete); t.deepEqual(capped.cap_reasons, ['bytes']); t.false(capped.page.has_more); + t.is(capped.page.next_cursor, null); const edgeResult = await runCli( cwd, @@ -453,6 +455,8 @@ export default { t.deepEqual(edgeCapped.cap_reasons, ['displayed_edges']); t.true(edgeCapped.page.has_more); t.truthy(edgeCapped.page.next_cursor); + t.true(edgeCapped.summary.includes('displayed_edges')); + t.true(edgeCapped.summary.includes('pagination')); } ); diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index 027f5808..2270bd77 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -120,11 +120,14 @@ lists), one-hop related records, and an edge table. Body policy: from these digests — use `proof get` for the payload. Citation bodies (usually short) still excerpt normally. -Caps: 25 primary records, one hop, 50 edges, 64 KiB; hitting a cap sets -`complete: false` with named `cap_reasons` — narrow the query or page rather -than expecting more. If a `get` body alone exceeds the 64 KiB digest byte -cap, the digest fails closed with a byte-cap banner (it does **not** fake a -full body via the 600/12 excerpt). +Caps: 25 primary records, one hop, 50 edges, 64 KiB. Hitting a cap sets +`complete: false` with named `cap_reasons`. Page only when `page.has_more` is +true. Non-empty hard `cap_reasons` that paging cannot clear mean narrow the +query or fail closed. `primary_records` is a defensive in-process signal after +the CLI pre-slices to at most 25 primary records; `proof list` and +`proof records` do not emit it. If a `get` body alone exceeds the 64 KiB +digest byte cap, the digest fails closed with a byte-cap banner (it does **not** +fake a full body via the 600/12 excerpt). Every read envelope carries `complete` and `cap_reasons`. Read it in this order: diff --git a/packages/proof/src/__tests__/digest.test.ts b/packages/proof/src/__tests__/digest.test.ts index 44f6a279..65939b92 100644 --- a/packages/proof/src/__tests__/digest.test.ts +++ b/packages/proof/src/__tests__/digest.test.ts @@ -243,6 +243,73 @@ test('displayed-edge caps are machine readable', async (t) => { t.true(result.summary.includes('incomplete: displayed_edges')); }); +test('renderDigest refuses hasMore without a next cursor', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-refuse-page-')); + const result = await renderDigest({ + query: { type: 'listEfforts', page: { limit: 1 } }, + queryHash: 'refuse-page', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + edges: [], + records: [ + { + id: 'eff-one--0123456789abcdef', + kind: 'effort' as const, + path: 'efforts/one.md', + frontmatter: { title: 'One' }, + body_excerpt: '', + relations: {}, + }, + ], + hasMore: true, + }); + const digest = await readFile(result.artifact_path, 'utf8'); + t.true(result.complete); + t.deepEqual(result.cap_reasons, []); + t.false(result.page.has_more); + t.is(result.page.next_cursor, null); + t.true( + digest.includes('primary: {"returned":1,"total_known":1,"has_more":false}') + ); + t.true(result.summary.includes('complete')); + t.false(result.summary.includes('pagination')); +}); + +test('renderDigest reports displayed-edge caps with pagination', async (t) => { + const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-edge-page-cap-')); + const cursor = 'next-edge-page'; + const result = await renderDigest({ + query: { type: 'relations', effort_id: 'eff-one--0123456789abcdef' }, + queryHash: 'edge-page-cap', + generation: '4', + consistency: { mode: 'eventual' as const, min_generation: null }, + cacheRoot, + records: [ + { + id: 'dec-one--0123456789abcdef', + kind: 'decision' as const, + path: 'decisions/one.md', + frontmatter: { title: 'One' }, + body_excerpt: '', + relations: {}, + }, + ], + edges: Array.from({ length: 51 }, (_, index) => ({ + from_id: 'dec-one--0123456789abcdef', + relation: 'derives_from' as const, + to_id: `fnd-edge-page-${index}--0123456789abcdef`, + })), + hasMore: true, + nextCursor: cursor, + }); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['displayed_edges']); + t.true(result.page.has_more); + t.is(result.page.next_cursor, cursor); + t.true(result.summary.includes('incomplete: displayed_edges, pagination')); +}); + test('stacked byte and record caps survive the byte rebuild', async (t) => { const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-stacked-caps-')); const result = await renderDigest({ diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index 0d8dcdbf..9cdded01 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -110,7 +110,11 @@ function digestCompleteness( return { complete: !hasMore && capReasons.length === 0, capReasons }; } -function yamlHeader(input: DigestInput, state: DigestCompleteness) { +function yamlHeader( + input: DigestInput, + state: DigestCompleteness, + hasMore: boolean +): string { const query = JSON.stringify(input.query); return [ '---', @@ -122,7 +126,7 @@ function yamlHeader(input: DigestInput, state: DigestCompleteness) { `primary: ${JSON.stringify({ returned: Math.min(input.records.length, CAP_RECORDS), total_known: input.totalKnown ?? input.records.length, - has_more: Boolean(input.hasMore), + has_more: hasMore, })}`, `complete: ${state.complete}`, `caps: ${JSON.stringify({ @@ -259,6 +263,11 @@ async function durableWrite(path: string, bytes: Buffer): Promise { export async function renderDigest(input: DigestInput): Promise { const primaryBodyMode: RecordBodyMode = input.fullBody ? 'full' : 'excerpt'; + const nextCursor = + typeof input.nextCursor === 'string' && input.nextCursor.length > 0 + ? input.nextCursor + : null; + const hasMore = Boolean(input.hasMore) && nextCursor !== null; const records = [...input.records].sort((a, b) => `${String(a.frontmatter.created_at ?? '')}\0${a.id}`.localeCompare( `${String(b.frontmatter.created_at ?? '')}\0${b.id}` @@ -275,7 +284,7 @@ export async function renderDigest(input: DigestInput): Promise { const reasons: ReadCapReason[] = []; if (records.length > CAP_RECORDS) reasons.push('primary_records'); if (input.edges.length > CAP_EDGES) reasons.push('displayed_edges'); - let completeness = digestCompleteness(Boolean(input.hasMore), reasons); + let completeness = digestCompleteness(hasMore, reasons); const checkpoints = input.checkpointLines?.length ? ['## Lineage checkpoints', ...input.checkpointLines, ''] : []; @@ -287,7 +296,7 @@ export async function renderDigest(input: DigestInput): Promise { const renderRelated = (record: ReadRecord) => renderRecord(record, { bodyMode: 'excerpt' }); let markdown = [ - yamlHeader(input, completeness), + yamlHeader(input, completeness, hasMore), ...(input.anomaly ? [`> anomaly: ${input.anomaly}`, ''] : []), '# Proof read', '## Index', @@ -308,7 +317,7 @@ export async function renderDigest(input: DigestInput): Promise { ].join('\n'); if (Buffer.byteLength(markdown) > CAP_BYTES) { reasons.push('bytes'); - completeness = digestCompleteness(Boolean(input.hasMore), reasons); + completeness = digestCompleteness(hasMore, reasons); // Full-body digests must not silently fall back to the 600/12 excerpt. // Prefer a visible byte-cap miss banner over a fake "full" body. const overflowBodyMode: RecordBodyMode = input.fullBody @@ -321,7 +330,7 @@ export async function renderDigest(input: DigestInput): Promise { ? 'body exceeded digest byte cap' : input.anomaly; const header = [ - yamlHeader(input, completeness), + yamlHeader(input, completeness, hasMore), ...(anomaly ? [`> anomaly: ${anomaly}`, ''] : []), '# Proof read', '## Index', @@ -383,7 +392,7 @@ export async function renderDigest(input: DigestInput): Promise { visible, completeness.complete, completeness.capReasons, - Boolean(input.hasMore) + hasMore ), artifact_path: path, artifact_sha256: createHash('sha256').update(bytes).digest('hex'), @@ -393,8 +402,8 @@ export async function renderDigest(input: DigestInput): Promise { cap_reasons: completeness.capReasons, page: { returned: visible.length, - has_more: Boolean(input.hasMore), - next_cursor: input.hasMore ? input.nextCursor ?? null : null, + has_more: hasMore, + next_cursor: hasMore ? nextCursor : null, }, hints: ( input.hints ?? ids.slice(0, 10).map((id) => `getRecord("${id}")`) From 9ee1e4079d6b8d59c8a8f52ef2f6b8301717ab10 Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Sat, 22 Aug 2026 10:42:13 -0700 Subject: [PATCH 4/9] docs(proof): journal paging-only has_more as the product rule The 22 Aug review of #254 asked to record that page.has_more is pagination-only, not a five-file split, and to stop using Issue kind on the Finding. Co-authored-by: Cursor Change-Id: I18e831209621f3373523f5c0b2aa3acceafed031 --- ...quality-review-22-aug--bf2s44nw13221za3.md | 9 +++++++ ...ve-disjoint-file-grou--bx44enbv52ztnrnn.md | 4 +++- ...re-as-pagination-only--dv24ta688adf262v.md | 24 +++++++++++++++++++ ...asked-for-a-paging-on--r631nr0gnqp9sypt.md | 24 +++++++++++++++++++ ...-only-has-more-map-an--hk8r9xfee39s64vc.md | 2 ++ ...kind-on-a-finding-and--9p7t7amz79y5rn3b.md | 19 +++++++++++++++ 6 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 .flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md create mode 100644 .flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md create mode 100644 .flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md create mode 100644 .flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md diff --git a/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md b/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md new file mode 100644 index 00000000..26f049ca --- /dev/null +++ b/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md @@ -0,0 +1,9 @@ +--- +id: cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: PR 254 journal-quality review 22 Aug +role: evidence +created_at: '2026-08-22T17:40:41.756Z' +--- + +https://github.com/FlatbreadLabs/flatbread/pull/254#pullrequestreview-5000641420 diff --git a/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md b/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md index 880b28a7..df0e9745 100644 --- a/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md +++ b/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md @@ -2,10 +2,12 @@ id: dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve title: Address PR 254 review as five disjoint file groups -state: accepted +state: superseded created_at: '2026-08-22T16:46:40.750Z' derives_from: - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc +superseded_by: + - dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v --- Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only and to lock two missing tests, plus optional refuse of hasMore without a cursor. diff --git a/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md b/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md new file mode 100644 index 00000000..be32ccd7 --- /dev/null +++ b/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md @@ -0,0 +1,24 @@ +--- +id: dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: Treat page.has_more as pagination-only +state: accepted +created_at: '2026-08-22T17:41:04.977Z' +derives_from: + - fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt + - iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b +supersedes: + - dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn +--- + +Supersedes the prior Decision that named five file owners as the Choice. The file split is how the follow-up was split for review, not the rule that shipped. + +Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only, lock two missing tests, and optionally refuse hasMore without a cursor. + +Choice: page.has_more means pagination only. Unpaired hasMore without a nextCursor is refused. Hard caps stay on complete and cap_reasons. Callers page only when page.has_more is true. They use cap_reasons and complete for walls. + +Note: the follow-up locked that rule in five file groups: CHANGELOG, both reference.md copies, the CLI spawn, the digest unit, and renderDigest. That split is a working note, not the Choice. + +Alternatives: keep the file-split Decision as the accepted record; skip the unpaired-cursor refuse. We kept the refuse because docs already call a null cursor an error. + +Reversal: revert the follow-up. Digest cache rebuilds on the next read. diff --git a/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md b/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md new file mode 100644 index 00000000..d598fdb1 --- /dev/null +++ b/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md @@ -0,0 +1,24 @@ +--- +id: fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: PR 254 completeness review asked for a paging-only has_more map and two tests +kind: retrospective +created_at: '2026-08-22T17:40:57.335Z' +derives_from: + - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +supersedes: + - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc +cites: + - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 + - cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 +--- + +Supersedes the prior Finding that used Issue kind gap. The 22 Aug grouping review of PR 254 listed four open 19 Aug notes and one optional harden. Those were docs and test locks, recorded here as a retrospective. + +1. CHANGELOG advertised complete and cap_reasons but not that page.has_more is pagination-only. +2. Both reference.md copies still said narrow or page when a hard cap hit. Paging cannot clear displayed_edges. +3. The CLI page-only spawn checked has_more but not next_cursor, and skipped a null cursor on bytes plus summary co-list on relations. +4. Digest unit cases never set hasMore with a hard cap, so summary pagination plus a hard reason was unproven. +5. Optional: renderDigest could still emit has_more true with a null cursor if DigestInput was mis-paired. + +None of these said the feature was wrong. They asked to say the Load more rule out loud and lock it. The follow-up commit did that. A later review asked to journal the product rule, not the file split, and to stop using Issue kind on this Finding. diff --git a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md b/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md index e525a010..0f565c7d 100644 --- a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md +++ b/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md @@ -6,6 +6,8 @@ kind: gap created_at: '2026-08-22T16:46:30.481Z' derives_from: - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +superseded_by: + - fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt cites: - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 --- diff --git a/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md b/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md new file mode 100644 index 00000000..48d6efd5 --- /dev/null +++ b/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md @@ -0,0 +1,19 @@ +--- +id: iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b +effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +title: PR 254 journal used Issue kind on a Finding and titled the file split +kind: gap +status: resolved +created_at: '2026-08-22T17:40:48.881Z' +derives_from: + - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve +resolved_by: + - dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v +cites: + - cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 +--- + +The 22 Aug PR review of the completeness follow-up found two journal errors, not envelope bugs. + +1. Finding fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc used kind gap. That kind belongs on WriteIssue. A Finding should use measurement, retrospective, or a review label. +2. Accepted Decision dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn titled the Choice as five file owners. The product rule is: page.has_more is pagination-only; unpaired hasMore is refused; hard caps stay on complete and cap_reasons. From fe367ceb362ad702d83ca9ed9ecef85636b38903 Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Sat, 22 Aug 2026 11:54:01 -0700 Subject: [PATCH 5/9] docs(proof): gate journaling on a 4/4 retention score Keep Proof writes for durable turning points only. Score the information before any mutation or body edit. Existing records do not bypass the gate. Bundle the four eval cases with the skill. Drop PR-lifecycle Proof records from #254 and keep the accepted pagination Decision as the durable rationale. Co-authored-by: Cursor Change-Id: I9c2eab5cc1df96e6ca2287d21c9f69872d99a072 Co-authored-by: Cursor --- .agents/skills/proof/SKILL.md | 44 ++++++++++++++--- .agents/skills/proof/evals/evals.json | 48 +++++++++++++++++++ ...grouped-review-22-aug--cpbe5anhpby3h625.md | 9 ---- ...quality-review-22-aug--bf2s44nw13221za3.md | 9 ---- ...ve-disjoint-file-grou--bx44enbv52ztnrnn.md | 25 ---------- ...re-as-pagination-only--dv24ta688adf262v.md | 17 ++----- ...asked-for-a-paging-on--r631nr0gnqp9sypt.md | 24 ---------- ...-only-has-more-map-an--hk8r9xfee39s64vc.md | 23 --------- ...kind-on-a-finding-and--9p7t7amz79y5rn3b.md | 19 -------- CHANGELOG.md | 31 +++++++----- packages/proof/skills/proof/SKILL.md | 44 ++++++++++++++--- packages/proof/skills/proof/evals/evals.json | 48 +++++++++++++++++++ 12 files changed, 196 insertions(+), 145 deletions(-) create mode 100644 .agents/skills/proof/evals/evals.json delete mode 100644 .flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md delete mode 100644 .flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md delete mode 100644 .flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md delete mode 100644 .flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md delete mode 100644 .flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md delete mode 100644 .flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md create mode 100644 packages/proof/skills/proof/evals/evals.json diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index c711a77d..b4a782ce 100644 --- a/.agents/skills/proof/SKILL.md +++ b/.agents/skills/proof/SKILL.md @@ -1,6 +1,6 @@ --- name: proof -description: Journal reasoning (decisions, findings, issues, constraints, risks, citations, blobs) into a Flatbread Proof and recall it with bounded reads. Use when starting or resuming a thread of work, recording a decision or finding, resolving an issue, checking what is blocking or still open on an effort, or when the user mentions effort graph, journaling, blocking decisions, agent memory, citation, blob, cites, longform, WriteCitation, or WriteBlob. +description: Read and update Flatbread Proof, the repository's durable project memory, through bounded queries and typed mutations. Use for recall when resuming a known effort, checking blockers, or when the user mentions Proof or journaling. Use the write path only for a decision-relevant turning point that outlives the current PR or session and adds unique causal rationale. Never journal routine progress, handoffs, review notes, temporary gaps, implementation steps, or journal corrections. --- # Proof — agent journaling and recall @@ -51,6 +51,36 @@ The write journal is `/.journal/`; read digests cache under ## Writing (journaling) +### Mandatory write gate + +Proof is a map of durable reasons, not a work log. How to use Proof lives in +this skill; do not journal the process itself as a Decision. + +Score only new retained information: create mutations and body text that add +claims. Lifecycle transitions (`AcceptDecision`, `ResolveIssue`, +`SetEffortStatus`, `MitigateRisk`, `SetRiskState`) and `proof cache prune` do +not add retained claims and do not need a 4/4 score. + +Before a create or a body edit that adds claims, score the information being +added — not the record that would receive it. Answer each test in private +reasoning: + +1. **Future need:** Would losing it make a future agent materially + misunderstand why the project is shaped this way? +2. **Durable effect:** Will it outlive the current PR or session and change a + product principle, public contract, architecture, constraint, risk, or docs + direction? +3. **Causal value:** Does it explain why that change happened or what evidence + could reverse it? +4. **Unique signal:** Does it add a reason or link that code, docs, Git, the PR + or tracker issue, and retained records do not already make clear? + +Do not create a record or add body claims unless the information scores +**4/4**. An existing or open record does not bypass this gate; appending +low-value text still consumes bounded reads. Keep failed candidates in the +PR, tracker issue, commit, or run artifact. Citations and Blobs persist only +when they support a 4/4 record. + One command for all 15 mutations — pass the payload as a single JSON argument: ```bash @@ -156,14 +186,16 @@ server-side. for the full body. Reserve opening `.flatbread-proof/**/*.md` for rare cases (e.g. digest byte-cap miss on an oversized record), not normal zoom-in. -3. **During work:** when outside material supports a record, save large - content with `WriteBlob` if needed, then create a `WriteCitation`, then - create the Issue, Finding, Decision, Constraint, or Risk with +3. **During work:** apply the write gate above before any create or body + edit that adds claims. When outside material supports a 4/4 record, save + large content with `WriteBlob` if needed, then create a `WriteCitation`, + then create the Issue, Finding, Decision, Constraint, or Risk with `cites: [""]`. You cannot add a citation later, so create the Citation first. Open Issues for real gaps or blockers, and use `derives_from` on Decisions to link the Findings, Constraints, and Issues they respond to. 4. **On commitment:** `AcceptDecision` (mind `rejectSiblings`), `ResolveIssue` - with `resolvedBy` citing the closing Decision/Findings. + with `resolvedBy` citing the closing Decision/Findings. These lifecycle + transitions do not need a 4/4 score. 5. Maintenance: `flatbread proof cache prune` deletes digests older than - 24h / over the 100 MiB ceiling. + 24h / over the 100 MiB ceiling. Prune does not need a 4/4 score. diff --git a/.agents/skills/proof/evals/evals.json b/.agents/skills/proof/evals/evals.json new file mode 100644 index 00000000..2aa08d4b --- /dev/null +++ b/.agents/skills/proof/evals/evals.json @@ -0,0 +1,48 @@ +{ + "skill_name": "proof", + "evals": [ + { + "id": 1, + "prompt": "An implementation branch passes lint and 41 of 42 tests. The last failure is a flaky timer assertion owned by this branch. The next session should rerun it and adjust the timeout if it repeats. Preserve this handoff where it belongs.", + "expected_output": "Keep the handoff in a branch, run, PR, or tracker artifact. Do not create or update a Proof record.", + "assertions": [ + "No .flatbread-proof record is created or updated", + "The response identifies the handoff as temporary implementation state", + "The response names a native work artifact instead of Proof" + ] + }, + { + "id": 2, + "prompt": "Put this in project agent memory: for the current PR, the follow-up is split among five temporary file owners covering the changelog, mirrored references, CLI tests, digest tests, and render logic. The map is useful until the PR merges but changes no product rule.", + "expected_output": "Keep the ownership map in the PR or run artifact despite the request to put it in agent memory. Do not create or update a Proof record.", + "assertions": [ + "No .flatbread-proof record is created or updated", + "The direct request to use project memory does not bypass the retention gate", + "The response places the ownership map in the PR or run artifact" + ] + }, + { + "id": 3, + "prompt": "A prior agent created a Finding only to note that a PR checklist used the wrong record kind. Product behavior did not change, and the correction matters only until review ends. Decide what durable project-memory action is warranted.", + "expected_output": "Create no new Proof record. The writer has no delete mutation. Leave the temporary Finding for maintainers, or if a PR must drop the file, strip inbound and outbound ids on retained records in the same change so reads do not fail closed on dangling edges. Keep any separate durable product decision.", + "assertions": [ + "No new Proof record is created", + "The response treats the journal correction as temporary bookkeeping", + "The response does not teach a bare record delete", + "Any cleanup preserves separate durable rationale and clears edges on retained records" + ] + }, + { + "id": 4, + "prompt": "Maintainers made a project-wide, hard-to-reverse choice: Proof will not add numeric confidence fields to any record type. Uncertainty stays in cited evidence and record prose because scores from different models are not comparable. This will govern schema work, writer behavior, and docs. Preserve the conclusion through the repository's normal process.", + "expected_output": "Create and accept one Proof Decision through the typed writer. Pass rejectSiblings false so unrelated proposed Decisions on the same Effort stay proposed. Preserve the rationale, alternatives, consequences, and reversal criteria.", + "assertions": [ + "One durable Proof Decision is created", + "AcceptDecision passes rejectSiblings false", + "The rationale explains why model confidence scores are not comparable", + "The Decision covers schema, writer, and documentation consequences", + "No unrelated Proof record is created or rejected" + ] + } + ] +} diff --git a/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md b/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md deleted file mode 100644 index 6132f2d0..00000000 --- a/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 grouped review 22 Aug -role: evidence -created_at: '2026-08-22T16:46:24.804Z' ---- - -https://github.com/FlatbreadLabs/flatbread/pull/254#pullrequestreview-5000573079 diff --git a/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md b/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md deleted file mode 100644 index 26f049ca..00000000 --- a/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 journal-quality review 22 Aug -role: evidence -created_at: '2026-08-22T17:40:41.756Z' ---- - -https://github.com/FlatbreadLabs/flatbread/pull/254#pullrequestreview-5000641420 diff --git a/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md b/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md deleted file mode 100644 index df0e9745..00000000 --- a/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: Address PR 254 review as five disjoint file groups -state: superseded -created_at: '2026-08-22T16:46:40.750Z' -derives_from: - - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc -superseded_by: - - dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v ---- - -Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only and to lock two missing tests, plus optional refuse of hasMore without a cursor. - -Choice: one follow-up branch with five exclusive file owners. - -1. CHANGELOG Unreleased now says page.has_more is pagination-only; use cap_reasons and complete for hard caps. primary_records stays an in-process signal after the CLI slice. -2. Both reference.md copies (source plus skills:sync) now say page only when page.has_more; hard caps that paging cannot clear mean narrow or fail closed. -3. CLI completeness spawn now asserts a non-null next_cursor on page-only list, a null cursor on bytes, and summary names displayed_edges and pagination together on relations. -4. Digest unit now covers displayed_edges plus hasMore/nextCursor, and refuses hasMore without a cursor. -5. renderDigest treats pagination as present only when hasMore is true and nextCursor is a non-empty string. It does not OR the 25-record wall back into has_more. - -Alternatives: amend PR 254 in place; skip optional refuse. We kept the refuse because docs already call a null cursor an error. - -Reversal: revert this follow-up. Digest cache rebuilds on the next read. diff --git a/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md b/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md index be32ccd7..88621181 100644 --- a/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md +++ b/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md @@ -4,21 +4,14 @@ effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve title: Treat page.has_more as pagination-only state: accepted created_at: '2026-08-22T17:41:04.977Z' -derives_from: - - fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt - - iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b -supersedes: - - dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn --- -Supersedes the prior Decision that named five file owners as the Choice. The file split is how the follow-up was split for review, not the rule that shipped. +Context: A Proof read can be incomplete because another page exists or because the digest hit a hard cap. Treating both cases as page.has_more tells callers to page when no cursor can recover the omitted data. -Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only, lock two missing tests, and optionally refuse hasMore without a cursor. +Choice: page.has_more means that another cursor-backed page exists. An input with hasMore: true and no non-empty nextCursor is refused. Hard caps appear through complete and cap_reasons; callers narrow the query or fail closed. -Choice: page.has_more means pagination only. Unpaired hasMore without a nextCursor is refused. Hard caps stay on complete and cap_reasons. Callers page only when page.has_more is true. They use cap_reasons and complete for walls. +Alternatives: Mark every incomplete read as page.has_more, or allow has_more without a cursor. Both options blur recoverable pagination with terminal truncation and can make callers retry a page that cannot help. -Note: the follow-up locked that rule in five file groups: CHANGELOG, both reference.md copies, the CLI spawn, the digest unit, and renderDigest. That split is a working note, not the Choice. +Consequences: The JSON envelope, digest header, and summary share one distinction. Callers page only with a cursor and treat hard caps as walls. -Alternatives: keep the file-split Decision as the accepted record; skip the unpaired-cursor refuse. We kept the refuse because docs already call a null cursor an error. - -Reversal: revert the follow-up. Digest cache rebuilds on the next read. +Reversal: Revisit this split only if every incomplete read gains one safe recovery action. diff --git a/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md b/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md deleted file mode 100644 index d598fdb1..00000000 --- a/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 completeness review asked for a paging-only has_more map and two tests -kind: retrospective -created_at: '2026-08-22T17:40:57.335Z' -derives_from: - - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -supersedes: - - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc -cites: - - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 - - cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 ---- - -Supersedes the prior Finding that used Issue kind gap. The 22 Aug grouping review of PR 254 listed four open 19 Aug notes and one optional harden. Those were docs and test locks, recorded here as a retrospective. - -1. CHANGELOG advertised complete and cap_reasons but not that page.has_more is pagination-only. -2. Both reference.md copies still said narrow or page when a hard cap hit. Paging cannot clear displayed_edges. -3. The CLI page-only spawn checked has_more but not next_cursor, and skipped a null cursor on bytes plus summary co-list on relations. -4. Digest unit cases never set hasMore with a hard cap, so summary pagination plus a hard reason was unproven. -5. Optional: renderDigest could still emit has_more true with a null cursor if DigestInput was mis-paired. - -None of these said the feature was wrong. They asked to say the Load more rule out loud and lock it. The follow-up commit did that. A later review asked to journal the product rule, not the file split, and to stop using Issue kind on this Finding. diff --git a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md b/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md deleted file mode 100644 index 0f565c7d..00000000 --- a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -id: fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 still omitted paging-only has_more map and two tests -kind: gap -created_at: '2026-08-22T16:46:30.481Z' -derives_from: - - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -superseded_by: - - fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt -cites: - - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 ---- - -The 22 Aug grouping review of PR 254 listed four open 19 Aug notes and one optional harden. - -1. CHANGELOG advertised complete and cap_reasons but not that page.has_more is pagination-only. -2. Both reference.md copies still said narrow or page when a hard cap hit. Paging cannot clear displayed_edges. -3. The CLI page-only spawn checked has_more but not next_cursor, and skipped a null cursor on bytes plus summary co-list on relations. -4. Digest unit cases never set hasMore with a hard cap, so summary pagination plus a hard reason was unproven. -5. Optional: renderDigest could still emit has_more true with a null cursor if DigestInput was mis-paired. - -None of these said the feature was wrong. They asked to say the Load more rule out loud and lock it. diff --git a/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md b/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md deleted file mode 100644 index 48d6efd5..00000000 --- a/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -id: iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 journal used Issue kind on a Finding and titled the file split -kind: gap -status: resolved -created_at: '2026-08-22T17:40:48.881Z' -derives_from: - - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -resolved_by: - - dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v -cites: - - cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 ---- - -The 22 Aug PR review of the completeness follow-up found two journal errors, not envelope bugs. - -1. Finding fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc used kind gap. That kind belongs on WriteIssue. A Finding should use measurement, retrospective, or a review label. -2. Accepted Decision dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn titled the Choice as five file owners. The product rule is: page.has_more is pagination-only; unpaired hasMore is refused; hard caps stay on complete and cap_reasons. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00054da1..b770bb0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,31 @@ ## Unreleased -- The DAG runner is now `@flatbread/oven` (`pnpm exec oven`); the memory package is now `@flatbread/proof` with the `flatbread proof` CLI. +- The Proof skill now applies a 4/4 write gate. Agents score the information + before a create or a body edit that adds claims; existing records do not + bypass the gate. Four bundled eval cases ship with the skill for a manual + eval run. - Proof read envelopes now expose `complete` and `cap_reasons`. `page.has_more` is pagination-only and no longer signals the 25-record wall; use `cap_reasons` / `complete` for hard caps on `displayed_edges` and `bytes`. The `primary_records` limit remains an in-process defensive signal because the CLI read bridge slices to 25 records before rendering. Callers can tell paging from hard caps without parsing the digest Markdown or `summary` text. +- **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. + +## 1.0.1 + +- The DAG runner is now `@flatbread/oven` (`pnpm exec oven`); the memory package is now `@flatbread/proof` with the `flatbread proof` CLI. - `@flatbread/source-filesystem` reads a content directory that does not exist as an empty collection instead of throwing `ENOENT`. Git cannot store an empty directory, and a Proof write creates only the directory it writes, so @@ -29,17 +47,6 @@ 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. ## 1.0.0 diff --git a/packages/proof/skills/proof/SKILL.md b/packages/proof/skills/proof/SKILL.md index c711a77d..b4a782ce 100644 --- a/packages/proof/skills/proof/SKILL.md +++ b/packages/proof/skills/proof/SKILL.md @@ -1,6 +1,6 @@ --- name: proof -description: Journal reasoning (decisions, findings, issues, constraints, risks, citations, blobs) into a Flatbread Proof and recall it with bounded reads. Use when starting or resuming a thread of work, recording a decision or finding, resolving an issue, checking what is blocking or still open on an effort, or when the user mentions effort graph, journaling, blocking decisions, agent memory, citation, blob, cites, longform, WriteCitation, or WriteBlob. +description: Read and update Flatbread Proof, the repository's durable project memory, through bounded queries and typed mutations. Use for recall when resuming a known effort, checking blockers, or when the user mentions Proof or journaling. Use the write path only for a decision-relevant turning point that outlives the current PR or session and adds unique causal rationale. Never journal routine progress, handoffs, review notes, temporary gaps, implementation steps, or journal corrections. --- # Proof — agent journaling and recall @@ -51,6 +51,36 @@ The write journal is `/.journal/`; read digests cache under ## Writing (journaling) +### Mandatory write gate + +Proof is a map of durable reasons, not a work log. How to use Proof lives in +this skill; do not journal the process itself as a Decision. + +Score only new retained information: create mutations and body text that add +claims. Lifecycle transitions (`AcceptDecision`, `ResolveIssue`, +`SetEffortStatus`, `MitigateRisk`, `SetRiskState`) and `proof cache prune` do +not add retained claims and do not need a 4/4 score. + +Before a create or a body edit that adds claims, score the information being +added — not the record that would receive it. Answer each test in private +reasoning: + +1. **Future need:** Would losing it make a future agent materially + misunderstand why the project is shaped this way? +2. **Durable effect:** Will it outlive the current PR or session and change a + product principle, public contract, architecture, constraint, risk, or docs + direction? +3. **Causal value:** Does it explain why that change happened or what evidence + could reverse it? +4. **Unique signal:** Does it add a reason or link that code, docs, Git, the PR + or tracker issue, and retained records do not already make clear? + +Do not create a record or add body claims unless the information scores +**4/4**. An existing or open record does not bypass this gate; appending +low-value text still consumes bounded reads. Keep failed candidates in the +PR, tracker issue, commit, or run artifact. Citations and Blobs persist only +when they support a 4/4 record. + One command for all 15 mutations — pass the payload as a single JSON argument: ```bash @@ -156,14 +186,16 @@ server-side. for the full body. Reserve opening `.flatbread-proof/**/*.md` for rare cases (e.g. digest byte-cap miss on an oversized record), not normal zoom-in. -3. **During work:** when outside material supports a record, save large - content with `WriteBlob` if needed, then create a `WriteCitation`, then - create the Issue, Finding, Decision, Constraint, or Risk with +3. **During work:** apply the write gate above before any create or body + edit that adds claims. When outside material supports a 4/4 record, save + large content with `WriteBlob` if needed, then create a `WriteCitation`, + then create the Issue, Finding, Decision, Constraint, or Risk with `cites: [""]`. You cannot add a citation later, so create the Citation first. Open Issues for real gaps or blockers, and use `derives_from` on Decisions to link the Findings, Constraints, and Issues they respond to. 4. **On commitment:** `AcceptDecision` (mind `rejectSiblings`), `ResolveIssue` - with `resolvedBy` citing the closing Decision/Findings. + with `resolvedBy` citing the closing Decision/Findings. These lifecycle + transitions do not need a 4/4 score. 5. Maintenance: `flatbread proof cache prune` deletes digests older than - 24h / over the 100 MiB ceiling. + 24h / over the 100 MiB ceiling. Prune does not need a 4/4 score. diff --git a/packages/proof/skills/proof/evals/evals.json b/packages/proof/skills/proof/evals/evals.json new file mode 100644 index 00000000..2aa08d4b --- /dev/null +++ b/packages/proof/skills/proof/evals/evals.json @@ -0,0 +1,48 @@ +{ + "skill_name": "proof", + "evals": [ + { + "id": 1, + "prompt": "An implementation branch passes lint and 41 of 42 tests. The last failure is a flaky timer assertion owned by this branch. The next session should rerun it and adjust the timeout if it repeats. Preserve this handoff where it belongs.", + "expected_output": "Keep the handoff in a branch, run, PR, or tracker artifact. Do not create or update a Proof record.", + "assertions": [ + "No .flatbread-proof record is created or updated", + "The response identifies the handoff as temporary implementation state", + "The response names a native work artifact instead of Proof" + ] + }, + { + "id": 2, + "prompt": "Put this in project agent memory: for the current PR, the follow-up is split among five temporary file owners covering the changelog, mirrored references, CLI tests, digest tests, and render logic. The map is useful until the PR merges but changes no product rule.", + "expected_output": "Keep the ownership map in the PR or run artifact despite the request to put it in agent memory. Do not create or update a Proof record.", + "assertions": [ + "No .flatbread-proof record is created or updated", + "The direct request to use project memory does not bypass the retention gate", + "The response places the ownership map in the PR or run artifact" + ] + }, + { + "id": 3, + "prompt": "A prior agent created a Finding only to note that a PR checklist used the wrong record kind. Product behavior did not change, and the correction matters only until review ends. Decide what durable project-memory action is warranted.", + "expected_output": "Create no new Proof record. The writer has no delete mutation. Leave the temporary Finding for maintainers, or if a PR must drop the file, strip inbound and outbound ids on retained records in the same change so reads do not fail closed on dangling edges. Keep any separate durable product decision.", + "assertions": [ + "No new Proof record is created", + "The response treats the journal correction as temporary bookkeeping", + "The response does not teach a bare record delete", + "Any cleanup preserves separate durable rationale and clears edges on retained records" + ] + }, + { + "id": 4, + "prompt": "Maintainers made a project-wide, hard-to-reverse choice: Proof will not add numeric confidence fields to any record type. Uncertainty stays in cited evidence and record prose because scores from different models are not comparable. This will govern schema work, writer behavior, and docs. Preserve the conclusion through the repository's normal process.", + "expected_output": "Create and accept one Proof Decision through the typed writer. Pass rejectSiblings false so unrelated proposed Decisions on the same Effort stay proposed. Preserve the rationale, alternatives, consequences, and reversal criteria.", + "assertions": [ + "One durable Proof Decision is created", + "AcceptDecision passes rejectSiblings false", + "The rationale explains why model confidence scores are not comparable", + "The Decision covers schema, writer, and documentation consequences", + "No unrelated Proof record is created or rejected" + ] + } + ] +} From b473525cf5568470ebc3992e45406a03f07d0038 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 20:25:48 +0000 Subject: [PATCH 6/9] feat(proof): add Retract for records that should not stay live Git-deleting Proof files leaves dangling edges and breaks the writer contract. Retract tombstones a record in place, strips its id from the same Effort, and drops it from browse reads. proof get still returns the file and reason. Eval 3 now teaches Retract instead of a file-drop path. The 4/4 write gate carves Retract out with other lifecycle mutations. Co-authored-by: Cursor Change-Id: Idc3e85815ca30398b1843d5a27636e1554fb35d2 --- .agents/skills/proof/SKILL.md | 22 +++- .agents/skills/proof/evals/evals.json | 8 +- .agents/skills/proof/glossary.md | 9 ++ .agents/skills/proof/reference.md | 27 ++++- CHANGELOG.md | 6 + packages/flatbread/src/cli/proof.test.ts | 89 +++++++++++++++ packages/flatbread/src/proof/read.ts | 26 ++++- packages/proof/README.md | 4 +- packages/proof/skills/proof/SKILL.md | 22 +++- packages/proof/skills/proof/evals/evals.json | 8 +- packages/proof/skills/proof/glossary.md | 9 ++ packages/proof/skills/proof/reference.md | 27 ++++- packages/proof/src/__tests__/planner.test.ts | 111 +++++++++++++++++++ packages/proof/src/__tests__/schemas.test.ts | 9 +- packages/proof/src/__tests__/writer.test.ts | 47 ++++++++ packages/proof/src/decision-lifecycle.ts | 4 +- packages/proof/src/digest.ts | 3 + packages/proof/src/frontmatter.ts | 3 + packages/proof/src/planner.ts | 108 +++++++++++++++++- packages/proof/src/schemas.ts | 6 + 20 files changed, 513 insertions(+), 35 deletions(-) diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index b4a782ce..26c32507 100644 --- a/.agents/skills/proof/SKILL.md +++ b/.agents/skills/proof/SKILL.md @@ -10,7 +10,7 @@ repository. It has eight record types: **Effort**, **Issue**, **Finding**, **Decision**, **Constraint**, and **Risk** capture the work and reasoning; **Citation** stores a source or reference; and **Blob** stores attached content such as a document, JSON, or image. Every record belongs to one -Effort. Create and update records through 15 typed mutations, and read them +Effort. Create and update records through 16 typed mutations, and read them through 5 bounded queries. Do not hand-edit record frontmatter, although you may edit record bodies freely. @@ -58,8 +58,11 @@ this skill; do not journal the process itself as a Decision. Score only new retained information: create mutations and body text that add claims. Lifecycle transitions (`AcceptDecision`, `ResolveIssue`, -`SetEffortStatus`, `MitigateRisk`, `SetRiskState`) and `proof cache prune` do -not add retained claims and do not need a 4/4 score. +`SetEffortStatus`, `MitigateRisk`, `SetRiskState`), `Retract`, and +`proof cache prune` do not add retained claims and do not need a 4/4 score. +`Supersede` and `Invalidate` write retained edges; score the reason for the +edge the same as a create. Body edits that only drop claims stay out of the +gate. Before a create or a body edit that adds claims, score the information being added — not the record that would receive it. Answer each test in private @@ -81,7 +84,7 @@ low-value text still consumes bounded reads. Keep failed candidates in the PR, tracker issue, commit, or run artifact. Citations and Blobs persist only when they support a 4/4 record. -One command for all 15 mutations — pass the payload as a single JSON argument: +One command for all 16 mutations — pass the payload as a single JSON argument: ```bash flatbread proof write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' @@ -91,7 +94,7 @@ Response: `{"generation":"","artifacts":[{"id","path","operation"}],"touc **Capture `artifacts[0].id`** to wire later edges, and **keep `generation`** for strict read-your-writes. -Full payload shapes for all 15 mutations: read [reference.md](./reference.md). +Full payload shapes for all 16 mutations: read [reference.md](./reference.md). Critical semantics: - Creates always start in the initial lifecycle state: `WriteDecision` → @@ -99,6 +102,12 @@ Critical semantics: state; use lifecycle mutations (`AcceptDecision`, `ResolveIssue`, `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 + be retracted; abandon them instead. - `AcceptDecision` defaults `rejectSiblings: true`, which rejects ALL other proposed Decisions in the same Effort. Pass `"rejectSiblings": false` unless you deliberately want the competing proposals closed. @@ -196,6 +205,7 @@ server-side. they respond to. 4. **On commitment:** `AcceptDecision` (mind `rejectSiblings`), `ResolveIssue` with `resolvedBy` citing the closing Decision/Findings. These lifecycle - transitions do not need a 4/4 score. + transitions do not need a 4/4 score. Retract session noise with `Retract` + rather than deleting files. 5. Maintenance: `flatbread proof cache prune` deletes digests older than 24h / over the 100 MiB ceiling. Prune does not need a 4/4 score. diff --git a/.agents/skills/proof/evals/evals.json b/.agents/skills/proof/evals/evals.json index 2aa08d4b..f83d2160 100644 --- a/.agents/skills/proof/evals/evals.json +++ b/.agents/skills/proof/evals/evals.json @@ -24,12 +24,12 @@ { "id": 3, "prompt": "A prior agent created a Finding only to note that a PR checklist used the wrong record kind. Product behavior did not change, and the correction matters only until review ends. Decide what durable project-memory action is warranted.", - "expected_output": "Create no new Proof record. The writer has no delete mutation. Leave the temporary Finding for maintainers, or if a PR must drop the file, strip inbound and outbound ids on retained records in the same change so reads do not fail closed on dangling edges. Keep any separate durable product decision.", + "expected_output": "Create no new Proof record. Retract the temporary Finding with type Retract and a reason. Do not git rm the file or hand-edit frontmatter. Keep any separate durable product decision.", "assertions": [ "No new Proof record is created", - "The response treats the journal correction as temporary bookkeeping", - "The response does not teach a bare record delete", - "Any cleanup preserves separate durable rationale and clears edges on retained records" + "The response retracts the temporary Finding through the typed writer", + "The response does not teach a git delete or frontmatter strip", + "Any cleanup preserves separate durable rationale" ] }, { diff --git a/.agents/skills/proof/glossary.md b/.agents/skills/proof/glossary.md index fd002a10..bae6b238 100644 --- a/.agents/skills/proof/glossary.md +++ b/.agents/skills/proof/glossary.md @@ -80,6 +80,15 @@ to a Blob. Both links must stay within the same Effort. `flatbread proof relatio New edge vocabulary needs a dogfooded query the existing vocabulary cannot express. +## Retraction + +`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 +supersession (a better same-kind claim) and not invalidation (a Finding that +the target was wrong). Git is the undo story; there is no Restore mutation. + ## Intentional non-models Session, Run, Plan, Artifact, Agent, Investigation, Question, Proposal, diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index 2270bd77..80b93e84 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -11,7 +11,7 @@ Generated as `---<16-char-crockford>` with prefixes `eff`, identity. Let the writer generate ids; capture them from mutation results (`artifacts[0].id` for creates). -## The 15 mutations (`flatbread proof write ''`) +## The 16 mutations (`flatbread proof write ''`) Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields @@ -76,6 +76,30 @@ target was wrong (stronger than superseded). other `proposed` Decision in the Effort to `rejected` with a back-pointer. All mutations run in one journal transaction (save-or-undo). +### Retract a record that should not stay on the live graph + +```json +{ "type": "Retract", "recordId": "", "reason": "..." } +``` + +`Retract` is for session noise and other records that should never have been +written. It is not a hard delete and not a fold into a survivor: + +- The file stays. Frontmatter gains `retracted: true`, `retracted_at`, and + `retracted_reason`. The body is unchanged so `proof get` can still explain + what was removed. +- The writer clears that record's relation fields and strips its id from + every other record in the same Effort in the same journal transaction. +- Browse reads (`list`, `records`, `blocking-decisions`) omit retracted + records. `proof get` still returns them. `relations` follows stored edges + that remain; after a successful Retract, survivors should have none. +- Efforts cannot be retracted. Set status to `abandoned` instead. +- Later creates, `Supersede`, `Invalidate`, and lifecycle mutations reject + retracted ids. Git history is the undo story; there is no Restore mutation. + +Folding several noisy records into one survivor is a body edit on the +survivor (score 4/4 if it adds claims) plus `Retract` on the rest. + ### Mutation result ```json @@ -204,6 +228,7 @@ flatbread proof cache prune - Do not hand-edit record frontmatter or `.journal/`; bodies are freely editable (the reindexer validates and repairs projections). +- Do not `git rm` Proof records to correct the graph. Use `Retract`. - Do not parse digest files or `summary` as data feeds for other programs — the digest is evidence for you to read or search; the envelope is the machine surface. diff --git a/CHANGELOG.md b/CHANGELOG.md index b770bb0f..8284f4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- `flatbread proof write` now accepts `Retract`. Session noise and other + records that should not stay on the live graph are tombstoned in place: + the file remains, browse reads omit it, and the writer strips the id from + other records in the same Effort so relation reads do not fail closed. + `proof get` still returns the retracted record. Efforts cannot be + retracted; abandon them instead. Do not `git rm` Proof records. - The Proof skill now applies a 4/4 write gate. Agents score the information before a create or a body edit that adds claims; existing records do not bypass the gate. Four bundled eval cases ship with the skill for a manual diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index ab6e03fa..e4763fc9 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -1222,3 +1222,92 @@ export default { source: source(), transformer: transformer(), content: proofCon process.exitCode = previous; } ); + +test.serial( + 'Retract drops a record from browse reads and keeps get', + async (t) => { + const cwd = await createTempProject('flatbread-effort-retract-', t); + for (const directory of [ + 'efforts', + 'issues', + 'findings', + 'decisions', + 'constraints', + 'risks', + 'citations', + 'blobs', + ]) + await mkdir(join(cwd, '.flatbread-proof', directory), { + recursive: true, + }); + 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(${JSON.stringify( + relative(cwd, join(cwd, '.flatbread-proof')) + )}), +};` + ); + const effort = await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'E', body: '' }), + { cwd } + ); + const effortId = effort.artifacts[0].id; + const finding = await handleEffortWrite( + JSON.stringify({ + type: 'WriteFinding', + effort: effortId, + title: 'Checklist kind', + body: 'temporary review note', + kind: 'survey', + }), + { cwd } + ); + const findingId = finding.artifacts[0].id; + const decision = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effortId, + title: 'Keep', + body: 'durable product rule', + derives_from: [findingId], + }), + { cwd } + ); + const decisionId = decision.artifacts[0].id; + const retracted = await handleEffortWrite( + JSON.stringify({ + type: 'Retract', + recordId: findingId, + reason: 'session noise; not a turning point', + }), + { cwd } + ); + const listed = await handleEffortRecords(effortId, { + cwd, + kinds: ['finding', 'decision'], + strictMinGeneration: retracted.generation, + }); + const listedDigest = await readFile(listed.artifact_path, 'utf8'); + t.false(listedDigest.includes(findingId)); + t.true(listedDigest.includes(decisionId)); + const got = await handleEffortGet(findingId, { + cwd, + strictMinGeneration: retracted.generation, + }); + const gotDigest = await readFile(got.artifact_path, 'utf8'); + t.true(gotDigest.includes('retracted: true')); + t.true(gotDigest.includes('temporary review note')); + const neighbors = await handleEffortRelations(effortId, decisionId, { + cwd, + relations: ['derives_from'], + strictMinGeneration: retracted.generation, + }); + t.is(neighbors.page.returned, 0); + } +); diff --git a/packages/flatbread/src/proof/read.ts b/packages/flatbread/src/proof/read.ts index 4cbdf0df..fe22b17d 100644 --- a/packages/flatbread/src/proof/read.ts +++ b/packages/flatbread/src/proof/read.ts @@ -71,6 +71,9 @@ const FRONTMATTER_FIELDS = [ 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', @@ -186,6 +189,10 @@ function sortRecords(records: ReadRecord[]): ReadRecord[] { ); } +function isRetracted(record: ReadRecord): boolean { + return record.frontmatter.retracted === true; +} + function owningEffort(record: ReadRecord): string | undefined { if (record.kind === 'effort') return record.id; return typeof record.frontmatter.effort === 'string' @@ -364,6 +371,9 @@ class EngineProjection { 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'invalidates', 'invalidated_by', @@ -581,7 +591,10 @@ export async function effortRecords( ) ) .flat() - .filter((record) => record.frontmatter.effort === effortId) + .filter( + (record) => + record.frontmatter.effort === effortId && !isRetracted(record) + ) ); return render( options, @@ -625,8 +638,10 @@ export async function listEfforts( await projection.query('Effort', { status: { in: normalizedStatuses }, }) - ).filter((record) => - normalizedStatuses.includes(String(record.frontmatter.status)) + ).filter( + (record) => + normalizedStatuses.includes(String(record.frontmatter.status)) && + !isRetracted(record) ) ); return render( @@ -737,7 +752,9 @@ export async function blockingDecisions( kind: { eq: 'blocker' }, status: { eq: 'open' }, }) - ).filter((issue) => issue.frontmatter.effort === effortId); + ).filter( + (issue) => issue.frontmatter.effort === effortId && !isRetracted(issue) + ); const blockerIds = new Set(issues.map((issue) => issue.id)); const decisions = sortRecords( ( @@ -747,6 +764,7 @@ export async function blockingDecisions( ).filter( (decision) => decision.frontmatter.effort === effortId && + !isRetracted(decision) && (decision.relations.derives_from ?? []).some((id) => blockerIds.has(id)) ) ); diff --git a/packages/proof/README.md b/packages/proof/README.md index c58f8921..84e22992 100644 --- a/packages/proof/README.md +++ b/packages/proof/README.md @@ -23,7 +23,9 @@ next run restores the earlier contents of the unfinished change. Version 1 supports these actions: `CreateEffort`, `SetEffortStatus`, `WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, `WriteCitation`, `WriteBlob`, `Supersede`, `Invalidate`, `ResolveIssue`, -`AcceptDecision`, `MitigateRisk`, and `SetRiskState`. +`AcceptDecision`, `MitigateRisk`, `SetRiskState`, and `Retract`. `Retract` +hides a record that should not have stayed on the live graph without deleting +the file. An Issue, Finding, Decision, Constraint, or Risk may name Citation ids in `cites` (Flatbread `refs`). A Citation body alone is valid (e.g. a URL); an diff --git a/packages/proof/skills/proof/SKILL.md b/packages/proof/skills/proof/SKILL.md index b4a782ce..26c32507 100644 --- a/packages/proof/skills/proof/SKILL.md +++ b/packages/proof/skills/proof/SKILL.md @@ -10,7 +10,7 @@ repository. It has eight record types: **Effort**, **Issue**, **Finding**, **Decision**, **Constraint**, and **Risk** capture the work and reasoning; **Citation** stores a source or reference; and **Blob** stores attached content such as a document, JSON, or image. Every record belongs to one -Effort. Create and update records through 15 typed mutations, and read them +Effort. Create and update records through 16 typed mutations, and read them through 5 bounded queries. Do not hand-edit record frontmatter, although you may edit record bodies freely. @@ -58,8 +58,11 @@ this skill; do not journal the process itself as a Decision. Score only new retained information: create mutations and body text that add claims. Lifecycle transitions (`AcceptDecision`, `ResolveIssue`, -`SetEffortStatus`, `MitigateRisk`, `SetRiskState`) and `proof cache prune` do -not add retained claims and do not need a 4/4 score. +`SetEffortStatus`, `MitigateRisk`, `SetRiskState`), `Retract`, and +`proof cache prune` do not add retained claims and do not need a 4/4 score. +`Supersede` and `Invalidate` write retained edges; score the reason for the +edge the same as a create. Body edits that only drop claims stay out of the +gate. Before a create or a body edit that adds claims, score the information being added — not the record that would receive it. Answer each test in private @@ -81,7 +84,7 @@ low-value text still consumes bounded reads. Keep failed candidates in the PR, tracker issue, commit, or run artifact. Citations and Blobs persist only when they support a 4/4 record. -One command for all 15 mutations — pass the payload as a single JSON argument: +One command for all 16 mutations — pass the payload as a single JSON argument: ```bash flatbread proof write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' @@ -91,7 +94,7 @@ Response: `{"generation":"","artifacts":[{"id","path","operation"}],"touc **Capture `artifacts[0].id`** to wire later edges, and **keep `generation`** for strict read-your-writes. -Full payload shapes for all 15 mutations: read [reference.md](./reference.md). +Full payload shapes for all 16 mutations: read [reference.md](./reference.md). Critical semantics: - Creates always start in the initial lifecycle state: `WriteDecision` → @@ -99,6 +102,12 @@ Critical semantics: state; use lifecycle mutations (`AcceptDecision`, `ResolveIssue`, `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 + be retracted; abandon them instead. - `AcceptDecision` defaults `rejectSiblings: true`, which rejects ALL other proposed Decisions in the same Effort. Pass `"rejectSiblings": false` unless you deliberately want the competing proposals closed. @@ -196,6 +205,7 @@ server-side. they respond to. 4. **On commitment:** `AcceptDecision` (mind `rejectSiblings`), `ResolveIssue` with `resolvedBy` citing the closing Decision/Findings. These lifecycle - transitions do not need a 4/4 score. + transitions do not need a 4/4 score. Retract session noise with `Retract` + rather than deleting files. 5. Maintenance: `flatbread proof cache prune` deletes digests older than 24h / over the 100 MiB ceiling. Prune does not need a 4/4 score. diff --git a/packages/proof/skills/proof/evals/evals.json b/packages/proof/skills/proof/evals/evals.json index 2aa08d4b..f83d2160 100644 --- a/packages/proof/skills/proof/evals/evals.json +++ b/packages/proof/skills/proof/evals/evals.json @@ -24,12 +24,12 @@ { "id": 3, "prompt": "A prior agent created a Finding only to note that a PR checklist used the wrong record kind. Product behavior did not change, and the correction matters only until review ends. Decide what durable project-memory action is warranted.", - "expected_output": "Create no new Proof record. The writer has no delete mutation. Leave the temporary Finding for maintainers, or if a PR must drop the file, strip inbound and outbound ids on retained records in the same change so reads do not fail closed on dangling edges. Keep any separate durable product decision.", + "expected_output": "Create no new Proof record. Retract the temporary Finding with type Retract and a reason. Do not git rm the file or hand-edit frontmatter. Keep any separate durable product decision.", "assertions": [ "No new Proof record is created", - "The response treats the journal correction as temporary bookkeeping", - "The response does not teach a bare record delete", - "Any cleanup preserves separate durable rationale and clears edges on retained records" + "The response retracts the temporary Finding through the typed writer", + "The response does not teach a git delete or frontmatter strip", + "Any cleanup preserves separate durable rationale" ] }, { diff --git a/packages/proof/skills/proof/glossary.md b/packages/proof/skills/proof/glossary.md index fd002a10..bae6b238 100644 --- a/packages/proof/skills/proof/glossary.md +++ b/packages/proof/skills/proof/glossary.md @@ -80,6 +80,15 @@ to a Blob. Both links must stay within the same Effort. `flatbread proof relatio New edge vocabulary needs a dogfooded query the existing vocabulary cannot express. +## Retraction + +`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 +supersession (a better same-kind claim) and not invalidation (a Finding that +the target was wrong). Git is the undo story; there is no Restore mutation. + ## Intentional non-models Session, Run, Plan, Artifact, Agent, Investigation, Question, Proposal, diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index 2270bd77..80b93e84 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -11,7 +11,7 @@ Generated as `---<16-char-crockford>` with prefixes `eff`, identity. Let the writer generate ids; capture them from mutation results (`artifacts[0].id` for creates). -## The 15 mutations (`flatbread proof write ''`) +## The 16 mutations (`flatbread proof write ''`) Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields @@ -76,6 +76,30 @@ target was wrong (stronger than superseded). other `proposed` Decision in the Effort to `rejected` with a back-pointer. All mutations run in one journal transaction (save-or-undo). +### Retract a record that should not stay on the live graph + +```json +{ "type": "Retract", "recordId": "", "reason": "..." } +``` + +`Retract` is for session noise and other records that should never have been +written. It is not a hard delete and not a fold into a survivor: + +- The file stays. Frontmatter gains `retracted: true`, `retracted_at`, and + `retracted_reason`. The body is unchanged so `proof get` can still explain + what was removed. +- The writer clears that record's relation fields and strips its id from + every other record in the same Effort in the same journal transaction. +- Browse reads (`list`, `records`, `blocking-decisions`) omit retracted + records. `proof get` still returns them. `relations` follows stored edges + that remain; after a successful Retract, survivors should have none. +- Efforts cannot be retracted. Set status to `abandoned` instead. +- Later creates, `Supersede`, `Invalidate`, and lifecycle mutations reject + retracted ids. Git history is the undo story; there is no Restore mutation. + +Folding several noisy records into one survivor is a body edit on the +survivor (score 4/4 if it adds claims) plus `Retract` on the rest. + ### Mutation result ```json @@ -204,6 +228,7 @@ flatbread proof cache prune - Do not hand-edit record frontmatter or `.journal/`; bodies are freely editable (the reindexer validates and repairs projections). +- Do not `git rm` Proof records to correct the graph. Use `Retract`. - Do not parse digest files or `summary` as data feeds for other programs — the digest is evidence for you to read or search; the envelope is the machine surface. diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index abebaa70..78c887cd 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -954,3 +954,114 @@ test('later relation mutations reject targets from another Effort', (t) => { message: 'Invalid edge', }); }); + +test('Retract tombstones the target and strips inbound edges', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Noise', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + derives_from: [ids.issue], + }); + const issue = record(ids.issue, 'issue', { + id: ids.issue, + effort: E, + title: 'Q', + kind: 'question', + status: 'open', + created_at: '2025-01-01T00:00:00.000Z', + }); + const decision = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Keep', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + derives_from: [ids.finding], + supersedes: [ids.finding], + }); + const s = snap([issue, finding, decision]); + const writes = planMutation( + { + type: 'Retract', + recordId: ids.finding, + reason: 'PR checklist noise', + }, + s, + '/root', + now + ); + t.is(writes.length, 2); + const byId = Object.fromEntries( + writes.map((write) => [ + write.id, + parseDocument(write.afterBytes, write.kind).frontmatter, + ]) + ); + t.is(byId[ids.finding].retracted, true); + t.is(byId[ids.finding].retracted_reason, 'PR checklist noise'); + t.is(byId[ids.finding].retracted_at, now.toISOString()); + t.is(byId[ids.finding].derives_from, undefined); + t.deepEqual(byId[ids.decision].derives_from, undefined); + t.deepEqual(byId[ids.decision].supersedes, undefined); + t.is(byId[ids.decision].retracted, undefined); + t.is( + writes.find((write) => write.id === ids.issue), + undefined + ); +}); + +test('Retract refuses Efforts, repeats, and live links to retracted records', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Noise', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + retracted: true, + retracted_reason: 'already gone', + }); + const s = snap([finding]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: E, reason: 'no' }, + s, + '/root', + now + ), + { message: /does not apply to Efforts/ } + ); + t.throws( + () => + planMutation( + { + type: 'Retract', + recordId: ids.finding, + reason: 'again', + }, + s, + '/root', + now + ), + { message: new RegExp(`Artifact ${ids.finding} is already retracted`) } + ); + t.throws( + () => + planMutation( + { + type: 'WriteDecision', + id: ids.decision, + effort: E, + title: 'D', + body: '', + derives_from: [ids.finding], + }, + s, + '/root', + now + ), + { message: new RegExp(`Artifact ${ids.finding} is retracted`) } + ); +}); diff --git a/packages/proof/src/__tests__/schemas.test.ts b/packages/proof/src/__tests__/schemas.test.ts index 95f6e9cd..01975a7f 100644 --- a/packages/proof/src/__tests__/schemas.test.ts +++ b/packages/proof/src/__tests__/schemas.test.ts @@ -86,11 +86,16 @@ const validMutations: Record> = { state: 'realized', evidence: [fnd], }, + Retract: { + type: 'Retract', + recordId: fnd, + reason: 'session noise; not a durable turning point', + }, }; -test('each of the 15 mutation schemas accepts a valid input', (t) => { +test('each of the 16 mutation schemas accepts a valid input', (t) => { const types = Object.keys(validMutations); - t.is(types.length, 15); + t.is(types.length, 16); for (const type of types) { t.notThrows(() => ProofMutationSchema.parse(validMutations[type]), type); } diff --git a/packages/proof/src/__tests__/writer.test.ts b/packages/proof/src/__tests__/writer.test.ts index 216abdf0..05fc0b2c 100644 --- a/packages/proof/src/__tests__/writer.test.ts +++ b/packages/proof/src/__tests__/writer.test.ts @@ -719,3 +719,50 @@ test('snapshot before-image drives journal-compatible end-to-end output', async t.is(result.artifacts[0].frontmatter.status, 'paused'); t.is(result.artifacts[0].body, 'captured body\n'); }); + +test('Retract marks the file and strips inbound ids on survivors', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const finding = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'Noise', + body: 'checklist kind was wrong', + kind: 'survey', + }) + ); + const decision = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Keep', + body: 'durable product rule', + derives_from: [finding], + }) + ); + const result = await writer.mutate({ + type: 'Retract', + recordId: finding, + reason: 'session noise; not a turning point', + }); + t.true(result.touched.some((row) => row.id === finding)); + t.true(result.touched.some((row) => row.id === decision)); + const retracted = await readFrontmatter(root, `findings/${finding}.md`); + t.is(retracted.data.retracted, true); + t.is(retracted.data.retracted_reason, 'session noise; not a turning point'); + t.is(retracted.content.trim(), 'checklist kind was wrong'); + const survivor = await readFrontmatter(root, `decisions/${decision}.md`); + t.is(survivor.data.derives_from, undefined); + t.is(survivor.data.retracted, undefined); + await t.throwsAsync( + writer.mutate({ + type: 'Retract', + recordId: finding, + reason: 'again', + }), + { instanceOf: ProofValidationError, message: /already retracted/ } + ); +}); diff --git a/packages/proof/src/decision-lifecycle.ts b/packages/proof/src/decision-lifecycle.ts index 25406eb8..c1f89e35 100644 --- a/packages/proof/src/decision-lifecycle.ts +++ b/packages/proof/src/decision-lifecycle.ts @@ -28,7 +28,8 @@ export function acceptDecisionLifecycle( for (const sibling of snapshot.siblingDecisions( String(target.frontmatter.effort), { state: 'proposed', excludeId: target.id } - )) + )) { + if (sibling.frontmatter.retracted === true) continue; changes.push({ record: sibling, nextFrontmatter: { @@ -37,6 +38,7 @@ export function acceptDecisionLifecycle( rejected_by: target.id, }, }); + } return changes; } export function supersedeDecisionLifecycle( diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index 9cdded01..655f16db 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -78,6 +78,9 @@ const FRONTMATTER_KEYS = [ 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', diff --git a/packages/proof/src/frontmatter.ts b/packages/proof/src/frontmatter.ts index d23ebab7..de7f41f7 100644 --- a/packages/proof/src/frontmatter.ts +++ b/packages/proof/src/frontmatter.ts @@ -14,6 +14,9 @@ const order = [ 'created_at', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index 7934aad8..4acb787e 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -55,6 +55,52 @@ function assertNoCitationBlobEdges( ); } +function isRetracted(record: SnapshotRecord): boolean { + return record.frontmatter.retracted === true; +} + +function assertLive(record: SnapshotRecord): void { + if (isRetracted(record)) + throw new ProofValidationError(`Artifact ${record.id} is retracted`); +} + +const RELATION_VALUE_KEYS = [ + 'derives_from', + 'supersedes', + 'superseded_by', + 'invalidates', + 'invalidated_by', + 'resolved_by', + 'rejected_by', + 'mitigated_by', + 'evidence', + 'cites', + 'blob', +] as const; + +function stripRelationId( + frontmatter: Record, + id: string +): { next: Record; changed: boolean } { + const next = { ...frontmatter }; + let changed = false; + for (const key of RELATION_VALUE_KEYS) { + const value = next[key]; + if (value === id) { + delete next[key]; + changed = true; + continue; + } + if (Array.isArray(value) && value.includes(id)) { + const filtered = value.filter((item) => item !== id); + if (filtered.length) next[key] = filtered; + else delete next[key]; + changed = true; + } + } + return { next, changed }; +} + function assertCites( get: GetRecord, effortId: string, @@ -66,6 +112,7 @@ function assertCites( throw new ProofValidationError( `cites must target a Citation, got ${target.kind} (${citeId})` ); + assertLive(target); assertTargetEffort('cites', effortId, target); } } @@ -99,8 +146,11 @@ function assertDerivesFrom( effortId: string, derivesFrom: string[] | undefined ): void { - for (const targetId of derivesFrom ?? []) - assertTargetEffort('derives_from', effortId, get(targetId)); + for (const targetId of derivesFrom ?? []) { + const target = get(targetId); + assertLive(target); + assertTargetEffort('derives_from', effortId, target); + } } export function planMutation( @@ -210,6 +260,7 @@ export function planMutation( throw new ProofValidationError( `Citation.blob must target a Blob, got ${blob.kind}` ); + assertLive(blob); if (blob.frontmatter.effort !== raw.effort) throw new ProofValidationError( `Citation.blob ${raw.blob} belongs to a different effort` @@ -243,6 +294,7 @@ export function planMutation( for (const edge of ['supersedes', 'invalidates'] as const) for (const targetId of (fm[edge] as string[] | undefined) ?? []) { const target = get(targetId); + assertLive(target); if (edge === 'supersedes' && target.kind !== kind) throw new ProofValidationError( 'Supersedes must target the same kind' @@ -284,6 +336,8 @@ export function planMutation( input.type === 'Supersede' ? input.supersederId : input.findingId ); const b = get(input.targetId); + assertLive(a); + assertLive(b); const edge = input.type === 'Supersede' ? 'supersedes' : 'invalidates'; const back = input.type === 'Supersede' ? 'superseded_by' : 'invalidated_by'; @@ -340,11 +394,15 @@ export function planMutation( } if (input.type === 'ResolveIssue') { const r = get(input.issueId); + assertLive(r); if (r.kind !== 'issue' || r.frontmatter.status !== 'open') throw new ProofValidationError('Issue is not open'); - for (const id of input.resolvedBy) - if (get(id).frontmatter.effort !== r.frontmatter.effort) + for (const id of input.resolvedBy) { + const source = get(id); + assertLive(source); + if (source.frontmatter.effort !== r.frontmatter.effort) throw new ProofValidationError('Different effort'); + } add( r.id, r.kind, @@ -358,6 +416,7 @@ export function planMutation( return [...writes.values()]; } if (input.type === 'AcceptDecision') { + assertLive(get(input.decisionId)); for (const change of acceptDecisionLifecycle(snapshot, { decisionId: input.decisionId, rejectSiblings: input.rejectSiblings !== false, @@ -373,6 +432,8 @@ export function planMutation( if (input.type === 'MitigateRisk') { const r = get(input.riskId), d = get(input.decisionId); + assertLive(r); + assertLive(d); if ( r.kind !== 'risk' || r.frontmatter.state !== 'open' || @@ -391,12 +452,15 @@ export function planMutation( } if (input.type === 'SetRiskState') { const r = get(input.riskId); + assertLive(r); if (r.kind !== 'risk' || r.frontmatter.state !== 'open') throw new ProofValidationError('Risk is not open'); const evidence = input.evidence.map(get); - for (const x of evidence) + for (const x of evidence) { + assertLive(x); if (x.frontmatter.effort !== r.frontmatter.effort) throw new ProofValidationError('Different effort'); + } if ( input.state === 'realized' && !evidence.some((x) => x.kind === 'finding') @@ -410,5 +474,39 @@ export function planMutation( ); return [...writes.values()]; } + if (input.type === 'Retract') { + const target = get(input.recordId); + if (target.kind === 'effort') + throw new ProofValidationError( + 'Retract does not apply to Efforts; set status to abandoned' + ); + if (isRetracted(target)) + throw new ProofValidationError( + `Artifact ${target.id} is already retracted` + ); + const effortId = owningEffort(target); + if (!effortId) + throw new ProofValidationError('Retract target has no effort'); + const tombstone = { ...target.frontmatter }; + for (const key of RELATION_VALUE_KEYS) delete tombstone[key]; + add( + target.id, + target.kind, + { + ...tombstone, + retracted: true, + retracted_at: now.toISOString(), + retracted_reason: input.reason, + }, + target.body + ); + for (const record of snapshot.recordsByEffort(effortId)) { + if (record.id === target.id) continue; + const result = stripRelationId({ ...record.frontmatter }, target.id); + if (!result.changed) continue; + add(record.id, record.kind, result.next, record.body); + } + return [...writes.values()]; + } throw new ProofValidationError('Unsupported mutation'); } diff --git a/packages/proof/src/schemas.ts b/packages/proof/src/schemas.ts index 048a19e7..eb91fd28 100644 --- a/packages/proof/src/schemas.ts +++ b/packages/proof/src/schemas.ts @@ -108,6 +108,11 @@ export const SetRiskStateSchema = z.object({ state: z.enum(['realized', 'accepted']), evidence: id.array().min(1), }); +export const RetractSchema = z.object({ + type: z.literal('Retract'), + recordId: id, + reason: z.string().min(1), +}); export const ProofMutationSchema = z.discriminatedUnion('type', [ CreateEffortSchema, SetEffortStatusSchema, @@ -124,6 +129,7 @@ export const ProofMutationSchema = z.discriminatedUnion('type', [ AcceptDecisionSchema, MitigateRiskSchema, SetRiskStateSchema, + RetractSchema, ]); export type ProofMutation = z.input; export const EffortFrontmatterSchema = z From ff1d38c1b23a3264f9325374c3dae16cc2166cfd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 20:28:54 +0000 Subject: [PATCH 7/9] docs(proof): accept Retract as the named archive mutation Supersede the fifteen-mutation Constraint and record why Retract tombstones files instead of git-deleting them. Co-authored-by: Cursor Change-Id: Idea69e9bdc016d6f0f18c934f676b5dbee60fabe --- ...ys-deliberately-small--02k06bxbjwrjfp9x.md | 2 + ...ys-deliberately-small--0vf4ssfg2jmzxyn4.md | 17 +++++++++ ...-deleting-proof-files--k6jk0d2bdp1m9jw9.md | 37 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 .flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4.md create mode 100644 .flatbread-proof/decisions/dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9.md diff --git a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x.md b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x.md index 361e38a2..11efb508 100644 --- a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x.md +++ b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x.md @@ -9,6 +9,8 @@ derives_from: - fnd-skill-and-hard-constraint-still-teach-13-mutatio--gvg2btns0q7rp0eq supersedes: - con-mutation-enum-stays-deliberately-small--45v1ae3neq26g1rz +superseded_by: + - con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4 --- V1 has exactly fifteen named mutations. Every operation has a Zod schema, diff --git a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4.md b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4.md new file mode 100644 index 00000000..3e6dbed5 --- /dev/null +++ b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4.md @@ -0,0 +1,17 @@ +--- +id: con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4 +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Mutation enum stays deliberately small +kind: hard +created_at: '2026-08-22T20:28:27.386Z' +supersedes: + - con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x +--- + +V1 has exactly sixteen named mutations. Every operation has a Zod schema, validates against a committed index generation, and owns a defined semantic transition. + +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`, `MitigateRisk`, `SetRiskState`); and `Retract` for records that should not stay on the live graph. + +`Retract` is the named archive operation. It tombstones a file in place, strips that id from other records in the same Effort, and drops the record from browse reads. It is not a generic frontmatter patch, not a hard delete, and not a fold into a survivor. + +No generic frontmatter patch, hard delete, standalone `RejectDecision`, or body-edit mutation is part of v1. Git is the undo story; Decision sibling rejection is part of accepting an alternative; and 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/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 new file mode 100644 index 00000000..1fa914ee --- /dev/null +++ b/.flatbread-proof/decisions/dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9.md @@ -0,0 +1,37 @@ +--- +id: dec-retract-noise-instead-of-deleting-proof-files--k6jk0d2bdp1m9jw9 +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Retract noise instead of deleting Proof files +state: accepted +created_at: '2026-08-22T20:28:28.832Z' +derives_from: + - con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x + - con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4 +--- + +## Context + +PR 260 cleaned session noise from an Effort by deleting record files and stripping ids on the kept Decision. Review refused that path: Proof has no delete mutation, the skill forbids hand-edits of frontmatter, and leftover stored ids fail closed with PROOF_DANGLING_RELATION. + +Supersede keeps both records. Invalidate adds a Finding that says a target was wrong. Leaving junk in place fills the 25-record / 50-edge browse caps. Git rm is the wrong tool. + +## 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. + +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. + +## Alternatives considered + +- **Git rm plus a frontmatter-edit exception:** rejected because it makes agents responsible for reverse projections and dangling ids. The writer already owns multi-file transactions. +- **Leave noise forever:** rejected because bounded reads are the recall surface; session debris crowds out turning points. +- **Supersede or Invalidate the junk:** rejected because both keep the bad record visible and, for Invalidate, add another record to say so. +- **Hard delete that unlinks the file:** rejected because the journal has no content unlink, missing ids fail closed, and other branches that still store the id would dangle. + +## Consequences + +Eval 3 can teach Retract. Cleanup PRs no longer need to strip frontmatter by hand. The mutation enum grows to sixteen with dogfood from the #260 review trail. + +## Reversal criteria + +Revisit if tombstones still crowd raw-file grep, if agents Retract durable rationale, or if a Restore mutation becomes necessary because git revert is too costly in concurrent workflows. From 9878dc18e7240832a769c897ed6c7e19f24e6751 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 22:43:44 +0000 Subject: [PATCH 8/9] fix(proof): refuse Retract of a sole closer for a live same-Effort record stripRelationId removes a retracted record's id from resolved_by, mitigated_by, evidence, rejected_by, and superseded_by on every same-Effort record, but never restores the survivor's status or state. There is no reopen mutation, so the survivor stayed terminal with the pointer gone. Retract now refuses when the target is the last value in any of those closer-pointer fields on a live same-Effort record, and names the dependents in the error. Retracted survivors are skipped, and a target that shares its closer role with others still retracts (the survivor keeps the remaining closers). Adds planner tests for the resolved Issue, mitigated/realized Risk, rejected sibling Decision, and superseded record shapes, plus the multiple-closer and retracted-survivor controls. Change-Id: I31d67dde9cce8354582c6733da4fceaa86444801 Co-authored-by: Erika Ruth Witt --- packages/proof/src/__tests__/planner.test.ts | 222 +++++++++++++++++++ packages/proof/src/planner.ts | 37 ++++ 2 files changed, 259 insertions(+) diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index 78c887cd..8fbe7e6b 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -1065,3 +1065,225 @@ test('Retract refuses Efforts, repeats, and live links to retracted records', (t { message: new RegExp(`Artifact ${ids.finding} is retracted`) } ); }); + +test('Retract refuses the sole closer for a live Issue (resolved_by)', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Resolves Q', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const issue = record(ids.issue, 'issue', { + id: ids.issue, + effort: E, + title: 'Q', + kind: 'question', + status: 'resolved', + created_at: '2025-01-01T00:00:00.000Z', + resolved_by: [ids.finding], + }); + const s = snap([issue, finding]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for ${ids.issue}`) } + ); +}); + +test('Retract refuses the sole closer for a live Risk (mitigated_by / evidence)', (t) => { + const decision = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Mitigator', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + }); + const riskMitigated = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R1', + state: 'mitigated', + created_at: '2025-01-01T00:00:00.000Z', + mitigated_by: [ids.decision], + }); + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Realizes R2', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const riskRealized = record('rsk-two--0123456789abcdef', 'risk', { + id: 'rsk-two--0123456789abcdef', + effort: E, + title: 'R2', + state: 'realized', + created_at: '2025-01-01T00:00:00.000Z', + evidence: [ids.finding], + }); + const s = snap([decision, riskMitigated, finding, riskRealized]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.decision, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for ${ids.risk}`) } + ); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for rsk-two--0123456789abcdef`) } + ); +}); + +test('Retract refuses the sole closer for a rejected sibling Decision (rejected_by)', (t) => { + const accepted = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Accepted', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + }); + const rejected = record(ids.decision2, 'decision', { + id: ids.decision2, + effort: E, + title: 'Rejected sibling', + state: 'rejected', + created_at: '2025-01-01T00:00:00.000Z', + rejected_by: [ids.decision], + }); + const s = snap([accepted, rejected]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.decision, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for ${ids.decision2}`) } + ); +}); + +test('Retract refuses the sole closer for a superseded record (superseded_by)', (t) => { + const supersedee = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Old', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + superseded_by: [ids.decision], + }); + const superseder = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'New', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + supersedes: [ids.finding], + }); + const s = snap([supersedee, superseder]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.decision, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for ${ids.finding}`) } + ); +}); + +test('Retract allows retracting one of several closers and strips the pointer', (t) => { + const findingA = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Resolver A', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const findingB = record('fnd-two--0123456789abcdef', 'finding', { + id: 'fnd-two--0123456789abcdef', + effort: E, + title: 'Resolver B', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const issue = record(ids.issue, 'issue', { + id: ids.issue, + effort: E, + title: 'Q', + kind: 'question', + status: 'resolved', + created_at: '2025-01-01T00:00:00.000Z', + resolved_by: [ids.finding, 'fnd-two--0123456789abcdef'], + }); + const s = snap([issue, findingA, findingB]); + const writes = planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ); + const byId = Object.fromEntries( + writes.map((write) => [ + write.id, + parseDocument(write.afterBytes, write.kind).frontmatter, + ]) + ); + t.is(byId[ids.finding].retracted, true); + t.deepEqual(byId[ids.issue].resolved_by, ['fnd-two--0123456789abcdef']); + t.is(byId[ids.issue].status, 'resolved'); +}); + +test('Retract ignores retracted survivors when checking sole closers', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Resolves Q', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const issue = record(ids.issue, 'issue', { + id: ids.issue, + effort: E, + title: 'Q', + kind: 'question', + status: 'resolved', + created_at: '2025-01-01T00:00:00.000Z', + resolved_by: [ids.finding], + retracted: true, + retracted_reason: 'gone', + }); + const s = snap([issue, finding]); + const writes = planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ); + const byId = Object.fromEntries( + writes.map((write) => [ + write.id, + parseDocument(write.afterBytes, write.kind).frontmatter, + ]) + ); + t.is(byId[ids.finding].retracted, true); + t.is(byId[ids.issue].resolved_by, undefined); + t.is(byId[ids.issue].retracted, true); +}); diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index 4acb787e..e4dbf31f 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -101,6 +101,29 @@ function stripRelationId( return { next, changed }; } +const CLOSER_POINTER_KEYS = [ + 'resolved_by', + 'mitigated_by', + 'evidence', + 'rejected_by', + 'superseded_by', +] as const; + +function isSoleCloser( + frontmatter: Record, + id: string +): boolean { + for (const key of CLOSER_POINTER_KEYS) { + const value = frontmatter[key]; + if (Array.isArray(value) && value.includes(id)) { + if (value.filter((item) => item !== id).length === 0) return true; + } else if (value === id) { + return true; + } + } + return false; +} + function assertCites( get: GetRecord, effortId: string, @@ -487,6 +510,20 @@ export function planMutation( const effortId = owningEffort(target); if (!effortId) throw new ProofValidationError('Retract target has no effort'); + const dependents: string[] = []; + for (const record of snapshot.recordsByEffort(effortId)) { + if (record.id === target.id || isRetracted(record)) continue; + if (isSoleCloser(record.frontmatter, target.id)) + dependents.push(record.id); + } + if (dependents.length) + throw new ProofValidationError( + `Cannot retract ${ + target.id + }; it is the sole closer for ${dependents.join( + ', ' + )}. Supersede or retract those records first.` + ); const tombstone = { ...target.frontmatter }; for (const key of RELATION_VALUE_KEYS) delete tombstone[key]; add( From 3b4a684dd9dfa2864a085dac10618a55c10c17a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 23:10:22 +0000 Subject: [PATCH 9/9] fix(proof): refuse Retract of last Finding on a realized Risk isSoleCloser counted remaining evidence ids, so mixed evidence of a Finding and a Decision let Retract strip the Finding and leave state: realized with no Finding. Refuse Retract when the target is the last live Finding-kind id on a realized Risk. Document the gate in both reference.md copies. Addresses review on #254. Change-Id: I81f3c56b41f483a46d71330b71ea656aaf6453ae Co-authored-by: Erika Ruth Witt --- .agents/skills/proof/reference.md | 12 +- packages/proof/skills/proof/reference.md | 12 +- packages/proof/src/__tests__/planner.test.ts | 181 +++++++++++++++++++ packages/proof/src/planner.ts | 26 ++- 4 files changed, 226 insertions(+), 5 deletions(-) diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index 80b93e84..56b79a41 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -85,11 +85,19 @@ All mutations run in one journal transaction (save-or-undo). `Retract` is for session noise and other records that should never have been written. It is not a hard delete and not a fold into a survivor: +- Retract throws when the target is the last remaining value of + `resolved_by`, `mitigated_by`, `evidence`, `rejected_by`, or + `superseded_by` on a live same-Effort record. It also throws when the + target is the last live Finding-kind id on a realized Risk's `evidence`, + even if other non-Finding ids remain. Supersede or retract those + dependent records first. Do not `git rm`, and do not hand-edit + frontmatter to strip the pointer. - The file stays. Frontmatter gains `retracted: true`, `retracted_at`, and `retracted_reason`. The body is unchanged so `proof get` can still explain what was removed. -- The writer clears that record's relation fields and strips its id from - every other record in the same Effort in the same journal transaction. +- 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 + journal transaction. - Browse reads (`list`, `records`, `blocking-decisions`) omit retracted records. `proof get` still returns them. `relations` follows stored edges that remain; after a successful Retract, survivors should have none. diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index 80b93e84..56b79a41 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -85,11 +85,19 @@ All mutations run in one journal transaction (save-or-undo). `Retract` is for session noise and other records that should never have been written. It is not a hard delete and not a fold into a survivor: +- Retract throws when the target is the last remaining value of + `resolved_by`, `mitigated_by`, `evidence`, `rejected_by`, or + `superseded_by` on a live same-Effort record. It also throws when the + target is the last live Finding-kind id on a realized Risk's `evidence`, + even if other non-Finding ids remain. Supersede or retract those + dependent records first. Do not `git rm`, and do not hand-edit + frontmatter to strip the pointer. - The file stays. Frontmatter gains `retracted: true`, `retracted_at`, and `retracted_reason`. The body is unchanged so `proof get` can still explain what was removed. -- The writer clears that record's relation fields and strips its id from - every other record in the same Effort in the same journal transaction. +- 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 + journal transaction. - Browse reads (`list`, `records`, `blocking-decisions`) omit retracted records. `proof get` still returns them. `relations` follows stored edges that remain; after a successful Retract, survivors should have none. diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index 8fbe7e6b..52fa1d58 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -1287,3 +1287,184 @@ test('Retract ignores retracted survivors when checking sole closers', (t) => { t.is(byId[ids.issue].resolved_by, undefined); t.is(byId[ids.issue].retracted, true); }); + +test('Retract refuses the last Finding on a realized Risk with mixed evidence', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Realizes R', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const decision = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Also cited', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + }); + const risk = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R', + state: 'realized', + likelihood: 'low', + severity: 'high', + created_at: '2025-01-01T00:00:00.000Z', + evidence: [ids.finding, ids.decision], + }); + const s = snap([finding, decision, risk]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for ${ids.risk}`) } + ); +}); + +test('Retract allows retracting a Decision from mixed evidence on a realized Risk', (t) => { + const finding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Realizes R', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const decision = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Also cited', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + }); + const risk = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R', + state: 'realized', + likelihood: 'low', + severity: 'high', + created_at: '2025-01-01T00:00:00.000Z', + evidence: [ids.finding, ids.decision], + }); + const s = snap([finding, decision, risk]); + const writes = planMutation( + { type: 'Retract', recordId: ids.decision, reason: 'noise' }, + s, + '/root', + now + ); + const byId = Object.fromEntries( + writes.map((write) => [ + write.id, + parseDocument(write.afterBytes, write.kind).frontmatter, + ]) + ); + t.is(byId[ids.decision].retracted, true); + t.is(byId[ids.risk].state, 'realized'); + t.deepEqual(byId[ids.risk].evidence, [ids.finding]); +}); + +test('Retract allows retracting one of several Findings from mixed evidence on a realized Risk', (t) => { + const findingA = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Realizes R A', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const findingB = record('fnd-two--0123456789abcdef', 'finding', { + id: 'fnd-two--0123456789abcdef', + effort: E, + title: 'Realizes R B', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const decision = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Also cited', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + }); + const risk = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R', + state: 'realized', + likelihood: 'low', + severity: 'high', + created_at: '2025-01-01T00:00:00.000Z', + evidence: [ids.finding, 'fnd-two--0123456789abcdef', ids.decision], + }); + const s = snap([findingA, findingB, decision, risk]); + const writes = planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ); + const byId = Object.fromEntries( + writes.map((write) => [ + write.id, + parseDocument(write.afterBytes, write.kind).frontmatter, + ]) + ); + t.is(byId[ids.finding].retracted, true); + t.is(byId[ids.risk].state, 'realized'); + t.deepEqual(byId[ids.risk].evidence, [ + ids.decision, + 'fnd-two--0123456789abcdef', + ]); +}); + +test('Retract refuses the last live Finding when remaining Finding evidence is retracted', (t) => { + const liveFinding = record(ids.finding, 'finding', { + id: ids.finding, + effort: E, + title: 'Still live', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + }); + const retractedFinding = record('fnd-two--0123456789abcdef', 'finding', { + id: 'fnd-two--0123456789abcdef', + effort: E, + title: 'Already gone', + kind: 'survey', + created_at: '2025-01-01T00:00:00.000Z', + retracted: true, + retracted_reason: 'gone', + }); + const decision = record(ids.decision, 'decision', { + id: ids.decision, + effort: E, + title: 'Also cited', + state: 'accepted', + created_at: '2025-01-01T00:00:00.000Z', + }); + const risk = record(ids.risk, 'risk', { + id: ids.risk, + effort: E, + title: 'R', + state: 'realized', + likelihood: 'low', + severity: 'high', + created_at: '2025-01-01T00:00:00.000Z', + evidence: ['fnd-two--0123456789abcdef', ids.finding, ids.decision], + }); + const s = snap([liveFinding, retractedFinding, decision, risk]); + t.throws( + () => + planMutation( + { type: 'Retract', recordId: ids.finding, reason: 'noise' }, + s, + '/root', + now + ), + { message: new RegExp(`sole closer for ${ids.risk}`) } + ); +}); diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index e4dbf31f..4b006cc9 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -124,6 +124,27 @@ function isSoleCloser( return false; } +function isLiveFinding(snapshot: ProofSnapshot, id: unknown): boolean { + if (typeof id !== 'string') return false; + const record = snapshot.getRecord(id); + return ( + record !== undefined && record.kind === 'finding' && !isRetracted(record) + ); +} + +function isLastLiveFindingEvidence( + record: SnapshotRecord, + targetId: string, + snapshot: ProofSnapshot +): boolean { + if (record.kind !== 'risk' || record.frontmatter.state !== 'realized') + return false; + const evidence = record.frontmatter.evidence; + if (!Array.isArray(evidence) || !evidence.includes(targetId)) return false; + if (!isLiveFinding(snapshot, targetId)) return false; + return !evidence.some((id) => id !== targetId && isLiveFinding(snapshot, id)); +} + function assertCites( get: GetRecord, effortId: string, @@ -513,7 +534,10 @@ export function planMutation( const dependents: string[] = []; for (const record of snapshot.recordsByEffort(effortId)) { if (record.id === target.id || isRetracted(record)) continue; - if (isSoleCloser(record.frontmatter, target.id)) + if ( + isSoleCloser(record.frontmatter, target.id) || + isLastLiveFindingEvidence(record, target.id, snapshot) + ) dependents.push(record.id); } if (dependents.length)