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}")`)