diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index 931662a5..1ab5428b 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 @@ -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. @@ -51,7 +51,40 @@ The write journal is `/.journal/`; read digests cache under ## Writing (journaling) -One command for all 15 mutations — pass the payload as a single JSON argument: +### 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`), `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 +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 16 mutations — pass the payload as a single JSON argument: ```bash flatbread proof write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' @@ -61,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` → @@ -69,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. @@ -156,14 +195,17 @@ 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. 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. + 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..f83d2160 --- /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. 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 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" + ] + }, + { + "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/.agents/skills/proof/glossary.md b/.agents/skills/proof/glossary.md index 79926b2c..0e816150 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 df2e3623..56b79a41 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,38 @@ 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: + +- 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. +- 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. +- 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 @@ -102,6 +134,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-...\")"] } @@ -118,11 +152,29 @@ 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: + +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 @@ -184,8 +236,10 @@ 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 `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. - 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/.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. 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..88621181 --- /dev/null +++ b/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md @@ -0,0 +1,17 @@ +--- +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' +--- + +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. + +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. + +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. + +Consequences: The JSON envelope, digest header, and summary share one distinction. Callers page only with a cursor and treat hard caps as walls. + +Reversal: Revisit this split only if every incomplete read gains one safe recovery action. diff --git a/CHANGELOG.md b/CHANGELOG.md index 49908023..8284f4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## 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 + 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 @@ -23,17 +53,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/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index eb6b5656..d1c51a27 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -333,6 +333,133 @@ export default { } ); +test.serial( + 'spawned CLI exposes complete, paged, byte-, and edge-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 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, + '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); + t.truthy(paged.page.next_cursor); + + 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); + t.is(capped.page.next_cursor, null); + + 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); + t.true(edgeCapped.summary.includes('displayed_edges')); + t.true(edgeCapped.summary.includes('pagination')); + } +); + test.serial( 'effort list defaults to active and supports explicit statuses and cursors', async (t) => { @@ -1119,3 +1246,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 931662a5..1ab5428b 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 @@ -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. @@ -51,7 +51,40 @@ The write journal is `/.journal/`; read digests cache under ## Writing (journaling) -One command for all 15 mutations — pass the payload as a single JSON argument: +### 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`), `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 +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 16 mutations — pass the payload as a single JSON argument: ```bash flatbread proof write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' @@ -61,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` → @@ -69,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. @@ -156,14 +195,17 @@ 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. 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. + 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..f83d2160 --- /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. 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 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" + ] + }, + { + "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/packages/proof/skills/proof/glossary.md b/packages/proof/skills/proof/glossary.md index 79926b2c..0e816150 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 df2e3623..56b79a41 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,38 @@ 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: + +- 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. +- 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. +- 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 @@ -102,6 +134,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-...\")"] } @@ -118,11 +152,29 @@ 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: + +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 @@ -184,8 +236,10 @@ 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 `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. - 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 c84ae46e..65939b92 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,167 @@ 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: [], + }); + const digest = await readFile(result.artifact_path, 'utf8'); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['primary_records']); + 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) => { + 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`, + })), + }); + const digest = await readFile(result.artifact_path, 'utf8'); + t.false(result.complete); + t.deepEqual(result.cap_reasons, ['displayed_edges']); + 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('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({ + 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) => { const cacheRoot = await mkdtemp(join(tmpdir(), 'eg-digest-blob-')); const secret = 'SECRET_BLOB_PAYLOAD_SHOULD_NOT_APPEAR'; diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index abebaa70..52fa1d58 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -954,3 +954,517 @@ 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`) } + ); +}); + +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); +}); + +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/__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 5495ed29..655f16db 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[]; } @@ -74,6 +78,9 @@ const FRONTMATTER_KEYS = [ 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', @@ -93,7 +100,24 @@ 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, + hasMore: boolean +): string { const query = JSON.stringify(input.query); return [ '---', @@ -105,17 +129,17 @@ function yamlHeader(input: DigestInput, complete: boolean, reasons: string[]) { `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: ${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 +224,7 @@ function renderRecord( function summary( records: readonly ReadRecord[], complete: boolean, - reasons: string[], + reasons: readonly string[], hasMore: boolean ): string { const states = new Map(); @@ -242,6 +266,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}` @@ -255,10 +284,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(hasMore, reasons); const checkpoints = input.checkpointLines?.length ? ['## Lineage checkpoints', ...input.checkpointLines, ''] : []; @@ -270,7 +299,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, hasMore), ...(input.anomaly ? [`> anomaly: ${input.anomaly}`, ''] : []), '# Proof read', '## Index', @@ -291,6 +320,7 @@ export async function renderDigest(input: DigestInput): Promise { ].join('\n'); if (Buffer.byteLength(markdown) > CAP_BYTES) { reasons.push('bytes'); + 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 @@ -303,7 +333,7 @@ export async function renderDigest(input: DigestInput): Promise { ? 'body exceeded digest byte cap' : input.anomaly; const header = [ - yamlHeader(input, false, reasons), + yamlHeader(input, completeness, hasMore), ...(anomaly ? [`> anomaly: ${anomaly}`, ''] : []), '# Proof read', '## Index', @@ -363,18 +393,20 @@ export async function renderDigest(input: DigestInput): Promise { const envelope: ReadEnvelope = { summary: summary( visible, - reasons.length === 0 && !input.hasMore, - reasons, - Boolean(input.hasMore) + completeness.complete, + completeness.capReasons, + 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), - 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}")`) 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..4b006cc9 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -55,6 +55,96 @@ 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 }; +} + +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 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, @@ -66,6 +156,7 @@ function assertCites( throw new ProofValidationError( `cites must target a Citation, got ${target.kind} (${citeId})` ); + assertLive(target); assertTargetEffort('cites', effortId, target); } } @@ -99,8 +190,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 +304,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 +338,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 +380,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 +438,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 +460,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 +476,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 +496,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 +518,56 @@ 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 dependents: string[] = []; + for (const record of snapshot.recordsByEffort(effortId)) { + if (record.id === target.id || isRetracted(record)) continue; + if ( + isSoleCloser(record.frontmatter, target.id) || + isLastLiveFindingEvidence(record, target.id, snapshot) + ) + 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( + 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