diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index c711a77d..26c32507 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 fd002a10..bae6b238 100644 --- a/.agents/skills/proof/glossary.md +++ b/.agents/skills/proof/glossary.md @@ -80,6 +80,15 @@ to a Blob. Both links must stay within the same Effort. `flatbread proof relatio New edge vocabulary needs a dogfooded query the existing vocabulary cannot express. +## Retraction + +`Retract` hides a record that should not have been journaled. The file stays +on disk with `retracted: true` so ids remain resolvable and +`PROOF_DANGLING_RELATION` does not fire. Browse reads omit retracted +records. `proof get` still returns the body and the reason. This is not +supersession (a better same-kind claim) and not invalidation (a Finding that +the target was wrong). Git is the undo story; there is no Restore mutation. + ## Intentional non-models Session, Run, Plan, Artifact, Agent, Investigation, Question, Proposal, diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index 2270bd77..80b93e84 100644 --- a/.agents/skills/proof/reference.md +++ b/.agents/skills/proof/reference.md @@ -11,7 +11,7 @@ Generated as `---<16-char-crockford>` with prefixes `eff`, identity. Let the writer generate ids; capture them from mutation results (`artifacts[0].id` for creates). -## The 15 mutations (`flatbread proof write ''`) +## The 16 mutations (`flatbread proof write ''`) Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields @@ -76,6 +76,30 @@ target was wrong (stronger than superseded). other `proposed` Decision in the Effort to `rejected` with a back-pointer. All mutations run in one journal transaction (save-or-undo). +### Retract a record that should not stay on the live graph + +```json +{ "type": "Retract", "recordId": "", "reason": "..." } +``` + +`Retract` is for session noise and other records that should never have been +written. It is not a hard delete and not a fold into a survivor: + +- The file stays. Frontmatter gains `retracted: true`, `retracted_at`, and + `retracted_reason`. The body is unchanged so `proof get` can still explain + what was removed. +- The writer clears that record's relation fields and strips its id from + every other record in the same Effort in the same journal transaction. +- Browse reads (`list`, `records`, `blocking-decisions`) omit retracted + records. `proof get` still returns them. `relations` follows stored edges + that remain; after a successful Retract, survivors should have none. +- Efforts cannot be retracted. Set status to `abandoned` instead. +- Later creates, `Supersede`, `Invalidate`, and lifecycle mutations reject + retracted ids. Git history is the undo story; there is no Restore mutation. + +Folding several noisy records into one survivor is a body edit on the +survivor (score 4/4 if it adds claims) plus `Retract` on the rest. + ### Mutation result ```json @@ -204,6 +228,7 @@ flatbread proof cache prune - Do not hand-edit record frontmatter or `.journal/`; bodies are freely editable (the reindexer validates and repairs projections). +- Do not `git rm` Proof records to correct the graph. Use `Retract`. - Do not parse digest files or `summary` as data feeds for other programs — the digest is evidence for you to read or search; the envelope is the machine surface. diff --git a/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md b/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md deleted file mode 100644 index 6132f2d0..00000000 --- a/.flatbread-proof/citations/cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 grouped review 22 Aug -role: evidence -created_at: '2026-08-22T16:46:24.804Z' ---- - -https://github.com/FlatbreadLabs/flatbread/pull/254#pullrequestreview-5000573079 diff --git a/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md b/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md deleted file mode 100644 index 26f049ca..00000000 --- a/.flatbread-proof/citations/cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -id: cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 journal-quality review 22 Aug -role: evidence -created_at: '2026-08-22T17:40:41.756Z' ---- - -https://github.com/FlatbreadLabs/flatbread/pull/254#pullrequestreview-5000641420 diff --git a/.flatbread-proof/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-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md b/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md deleted file mode 100644 index df0e9745..00000000 --- a/.flatbread-proof/decisions/dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -id: dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: Address PR 254 review as five disjoint file groups -state: superseded -created_at: '2026-08-22T16:46:40.750Z' -derives_from: - - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc -superseded_by: - - dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v ---- - -Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only and to lock two missing tests, plus optional refuse of hasMore without a cursor. - -Choice: one follow-up branch with five exclusive file owners. - -1. CHANGELOG Unreleased now says page.has_more is pagination-only; use cap_reasons and complete for hard caps. primary_records stays an in-process signal after the CLI slice. -2. Both reference.md copies (source plus skills:sync) now say page only when page.has_more; hard caps that paging cannot clear mean narrow or fail closed. -3. CLI completeness spawn now asserts a non-null next_cursor on page-only list, a null cursor on bytes, and summary names displayed_edges and pagination together on relations. -4. Digest unit now covers displayed_edges plus hasMore/nextCursor, and refuses hasMore without a cursor. -5. renderDigest treats pagination as present only when hasMore is true and nextCursor is a non-empty string. It does not OR the 25-record wall back into has_more. - -Alternatives: amend PR 254 in place; skip optional refuse. We kept the refuse because docs already call a null cursor an error. - -Reversal: revert this follow-up. Digest cache rebuilds on the next read. diff --git a/.flatbread-proof/decisions/dec-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 index be32ccd7..88621181 100644 --- a/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md +++ b/.flatbread-proof/decisions/dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v.md @@ -4,21 +4,14 @@ effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve title: Treat page.has_more as pagination-only state: accepted created_at: '2026-08-22T17:41:04.977Z' -derives_from: - - fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt - - iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b -supersedes: - - dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn --- -Supersedes the prior Decision that named five file owners as the Choice. The file split is how the follow-up was split for review, not the rule that shipped. +Context: A Proof read can be incomplete because another page exists or because the digest hit a hard cap. Treating both cases as page.has_more tells callers to page when no cursor can recover the omitted data. -Context: PR 254 already exposes complete and cap_reasons. The 22 Aug review asked to document that page.has_more is pagination-only, lock two missing tests, and optionally refuse hasMore without a cursor. +Choice: page.has_more means that another cursor-backed page exists. An input with hasMore: true and no non-empty nextCursor is refused. Hard caps appear through complete and cap_reasons; callers narrow the query or fail closed. -Choice: page.has_more means pagination only. Unpaired hasMore without a nextCursor is refused. Hard caps stay on complete and cap_reasons. Callers page only when page.has_more is true. They use cap_reasons and complete for walls. +Alternatives: Mark every incomplete read as page.has_more, or allow has_more without a cursor. Both options blur recoverable pagination with terminal truncation and can make callers retry a page that cannot help. -Note: the follow-up locked that rule in five file groups: CHANGELOG, both reference.md copies, the CLI spawn, the digest unit, and renderDigest. That split is a working note, not the Choice. +Consequences: The JSON envelope, digest header, and summary share one distinction. Callers page only with a cursor and treat hard caps as walls. -Alternatives: keep the file-split Decision as the accepted record; skip the unpaired-cursor refuse. We kept the refuse because docs already call a null cursor an error. - -Reversal: revert the follow-up. Digest cache rebuilds on the next read. +Reversal: Revisit this split only if every incomplete read gains one safe recovery action. diff --git a/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md b/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md deleted file mode 100644 index d598fdb1..00000000 --- a/.flatbread-proof/findings/fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -id: fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 completeness review asked for a paging-only has_more map and two tests -kind: retrospective -created_at: '2026-08-22T17:40:57.335Z' -derives_from: - - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -supersedes: - - fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc -cites: - - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 - - cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 ---- - -Supersedes the prior Finding that used Issue kind gap. The 22 Aug grouping review of PR 254 listed four open 19 Aug notes and one optional harden. Those were docs and test locks, recorded here as a retrospective. - -1. CHANGELOG advertised complete and cap_reasons but not that page.has_more is pagination-only. -2. Both reference.md copies still said narrow or page when a hard cap hit. Paging cannot clear displayed_edges. -3. The CLI page-only spawn checked has_more but not next_cursor, and skipped a null cursor on bytes plus summary co-list on relations. -4. Digest unit cases never set hasMore with a hard cap, so summary pagination plus a hard reason was unproven. -5. Optional: renderDigest could still emit has_more true with a null cursor if DigestInput was mis-paired. - -None of these said the feature was wrong. They asked to say the Load more rule out loud and lock it. The follow-up commit did that. A later review asked to journal the product rule, not the file split, and to stop using Issue kind on this Finding. diff --git a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md b/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md deleted file mode 100644 index 0f565c7d..00000000 --- a/.flatbread-proof/findings/fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -id: fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 still omitted paging-only has_more map and two tests -kind: gap -created_at: '2026-08-22T16:46:30.481Z' -derives_from: - - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -superseded_by: - - fnd-pr-254-completeness-review-asked-for-a-paging-on--r631nr0gnqp9sypt -cites: - - cit-pr-254-grouped-review-22-aug--cpbe5anhpby3h625 ---- - -The 22 Aug grouping review of PR 254 listed four open 19 Aug notes and one optional harden. - -1. CHANGELOG advertised complete and cap_reasons but not that page.has_more is pagination-only. -2. Both reference.md copies still said narrow or page when a hard cap hit. Paging cannot clear displayed_edges. -3. The CLI page-only spawn checked has_more but not next_cursor, and skipped a null cursor on bytes plus summary co-list on relations. -4. Digest unit cases never set hasMore with a hard cap, so summary pagination plus a hard reason was unproven. -5. Optional: renderDigest could still emit has_more true with a null cursor if DigestInput was mis-paired. - -None of these said the feature was wrong. They asked to say the Load more rule out loud and lock it. diff --git a/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md b/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md deleted file mode 100644 index 48d6efd5..00000000 --- a/.flatbread-proof/issues/iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -id: iss-pr-254-journal-used-issue-kind-on-a-finding-and--9p7t7amz79y5rn3b -effort: eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -title: PR 254 journal used Issue kind on a Finding and titled the file split -kind: gap -status: resolved -created_at: '2026-08-22T17:40:48.881Z' -derives_from: - - eff-proof-and-contributor-operating-system--ahhgtafvdhg4dfve -resolved_by: - - dec-treat-page-has-more-as-pagination-only--dv24ta688adf262v -cites: - - cit-pr-254-journal-quality-review-22-aug--bf2s44nw13221za3 ---- - -The 22 Aug PR review of the completeness follow-up found two journal errors, not envelope bugs. - -1. Finding fnd-pr-254-still-omitted-paging-only-has-more-map-an--hk8r9xfee39s64vc used kind gap. That kind belongs on WriteIssue. A Finding should use measurement, retrospective, or a review label. -2. Accepted Decision dec-address-pr-254-review-as-five-disjoint-file-grou--bx44enbv52ztnrnn titled the Choice as five file owners. The product rule is: page.has_more is pagination-only; unpaired hasMore is refused; hard caps stay on complete and cap_reasons. diff --git a/CHANGELOG.md b/CHANGELOG.md index 00054da1..8284f4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,37 @@ ## Unreleased -- The DAG runner is now `@flatbread/oven` (`pnpm exec oven`); the memory package is now `@flatbread/proof` with the `flatbread proof` CLI. +- `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 empty directory, and a Proof write creates only the directory it writes, so @@ -29,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 ab6e03fa..e4763fc9 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -1222,3 +1222,92 @@ export default { source: source(), transformer: transformer(), content: proofCon process.exitCode = previous; } ); + +test.serial( + 'Retract drops a record from browse reads and keeps get', + async (t) => { + const cwd = await createTempProject('flatbread-effort-retract-', t); + for (const directory of [ + 'efforts', + 'issues', + 'findings', + 'decisions', + 'constraints', + 'risks', + 'citations', + 'blobs', + ]) + await mkdir(join(cwd, '.flatbread-proof', directory), { + recursive: true, + }); + await writeFile( + join(cwd, 'flatbread.config.js'), + `import { source } from '@flatbread/source-filesystem'; +import { transformer } from '@flatbread/transformer-markdown'; +import { proofContent } from '@flatbread/proof'; +export default { + source: source(), + transformer: transformer(), + content: proofContent(${JSON.stringify( + relative(cwd, join(cwd, '.flatbread-proof')) + )}), +};` + ); + const effort = await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'E', body: '' }), + { cwd } + ); + const effortId = effort.artifacts[0].id; + const finding = await handleEffortWrite( + JSON.stringify({ + type: 'WriteFinding', + effort: effortId, + title: 'Checklist kind', + body: 'temporary review note', + kind: 'survey', + }), + { cwd } + ); + const findingId = finding.artifacts[0].id; + const decision = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effortId, + title: 'Keep', + body: 'durable product rule', + derives_from: [findingId], + }), + { cwd } + ); + const decisionId = decision.artifacts[0].id; + const retracted = await handleEffortWrite( + JSON.stringify({ + type: 'Retract', + recordId: findingId, + reason: 'session noise; not a turning point', + }), + { cwd } + ); + const listed = await handleEffortRecords(effortId, { + cwd, + kinds: ['finding', 'decision'], + strictMinGeneration: retracted.generation, + }); + const listedDigest = await readFile(listed.artifact_path, 'utf8'); + t.false(listedDigest.includes(findingId)); + t.true(listedDigest.includes(decisionId)); + const got = await handleEffortGet(findingId, { + cwd, + strictMinGeneration: retracted.generation, + }); + const gotDigest = await readFile(got.artifact_path, 'utf8'); + t.true(gotDigest.includes('retracted: true')); + t.true(gotDigest.includes('temporary review note')); + const neighbors = await handleEffortRelations(effortId, decisionId, { + cwd, + relations: ['derives_from'], + strictMinGeneration: retracted.generation, + }); + t.is(neighbors.page.returned, 0); + } +); diff --git a/packages/flatbread/src/proof/read.ts b/packages/flatbread/src/proof/read.ts index 4cbdf0df..fe22b17d 100644 --- a/packages/flatbread/src/proof/read.ts +++ b/packages/flatbread/src/proof/read.ts @@ -71,6 +71,9 @@ const FRONTMATTER_FIELDS = [ 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', @@ -186,6 +189,10 @@ function sortRecords(records: ReadRecord[]): ReadRecord[] { ); } +function isRetracted(record: ReadRecord): boolean { + return record.frontmatter.retracted === true; +} + function owningEffort(record: ReadRecord): string | undefined { if (record.kind === 'effort') return record.id; return typeof record.frontmatter.effort === 'string' @@ -364,6 +371,9 @@ class EngineProjection { 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'invalidates', 'invalidated_by', @@ -581,7 +591,10 @@ export async function effortRecords( ) ) .flat() - .filter((record) => record.frontmatter.effort === effortId) + .filter( + (record) => + record.frontmatter.effort === effortId && !isRetracted(record) + ) ); return render( options, @@ -625,8 +638,10 @@ export async function listEfforts( await projection.query('Effort', { status: { in: normalizedStatuses }, }) - ).filter((record) => - normalizedStatuses.includes(String(record.frontmatter.status)) + ).filter( + (record) => + normalizedStatuses.includes(String(record.frontmatter.status)) && + !isRetracted(record) ) ); return render( @@ -737,7 +752,9 @@ export async function blockingDecisions( kind: { eq: 'blocker' }, status: { eq: 'open' }, }) - ).filter((issue) => issue.frontmatter.effort === effortId); + ).filter( + (issue) => issue.frontmatter.effort === effortId && !isRetracted(issue) + ); const blockerIds = new Set(issues.map((issue) => issue.id)); const decisions = sortRecords( ( @@ -747,6 +764,7 @@ export async function blockingDecisions( ).filter( (decision) => decision.frontmatter.effort === effortId && + !isRetracted(decision) && (decision.relations.derives_from ?? []).some((id) => blockerIds.has(id)) ) ); diff --git a/packages/proof/README.md b/packages/proof/README.md index c58f8921..84e22992 100644 --- a/packages/proof/README.md +++ b/packages/proof/README.md @@ -23,7 +23,9 @@ next run restores the earlier contents of the unfinished change. Version 1 supports these actions: `CreateEffort`, `SetEffortStatus`, `WriteIssue`, `WriteFinding`, `WriteDecision`, `WriteConstraint`, `WriteRisk`, `WriteCitation`, `WriteBlob`, `Supersede`, `Invalidate`, `ResolveIssue`, -`AcceptDecision`, `MitigateRisk`, and `SetRiskState`. +`AcceptDecision`, `MitigateRisk`, `SetRiskState`, and `Retract`. `Retract` +hides a record that should not have stayed on the live graph without deleting +the file. An Issue, Finding, Decision, Constraint, or Risk may name Citation ids in `cites` (Flatbread `refs`). A Citation body alone is valid (e.g. a URL); an diff --git a/packages/proof/skills/proof/SKILL.md b/packages/proof/skills/proof/SKILL.md index c711a77d..26c32507 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 fd002a10..bae6b238 100644 --- a/packages/proof/skills/proof/glossary.md +++ b/packages/proof/skills/proof/glossary.md @@ -80,6 +80,15 @@ to a Blob. Both links must stay within the same Effort. `flatbread proof relatio New edge vocabulary needs a dogfooded query the existing vocabulary cannot express. +## Retraction + +`Retract` hides a record that should not have been journaled. The file stays +on disk with `retracted: true` so ids remain resolvable and +`PROOF_DANGLING_RELATION` does not fire. Browse reads omit retracted +records. `proof get` still returns the body and the reason. This is not +supersession (a better same-kind claim) and not invalidation (a Finding that +the target was wrong). Git is the undo story; there is no Restore mutation. + ## Intentional non-models Session, Run, Plan, Artifact, Agent, Investigation, Question, Proposal, diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index 2270bd77..80b93e84 100644 --- a/packages/proof/skills/proof/reference.md +++ b/packages/proof/skills/proof/reference.md @@ -11,7 +11,7 @@ Generated as `---<16-char-crockford>` with prefixes `eff`, identity. Let the writer generate ids; capture them from mutation results (`artifacts[0].id` for creates). -## The 15 mutations (`flatbread proof write ''`) +## The 16 mutations (`flatbread proof write ''`) Common optional fields on all creates: `id`, `created_at` (ISO with offset), `produced_in`, `created_by` (opaque provenance strings). Forward edge fields @@ -76,6 +76,30 @@ target was wrong (stronger than superseded). other `proposed` Decision in the Effort to `rejected` with a back-pointer. All mutations run in one journal transaction (save-or-undo). +### Retract a record that should not stay on the live graph + +```json +{ "type": "Retract", "recordId": "", "reason": "..." } +``` + +`Retract` is for session noise and other records that should never have been +written. It is not a hard delete and not a fold into a survivor: + +- The file stays. Frontmatter gains `retracted: true`, `retracted_at`, and + `retracted_reason`. The body is unchanged so `proof get` can still explain + what was removed. +- The writer clears that record's relation fields and strips its id from + every other record in the same Effort in the same journal transaction. +- Browse reads (`list`, `records`, `blocking-decisions`) omit retracted + records. `proof get` still returns them. `relations` follows stored edges + that remain; after a successful Retract, survivors should have none. +- Efforts cannot be retracted. Set status to `abandoned` instead. +- Later creates, `Supersede`, `Invalidate`, and lifecycle mutations reject + retracted ids. Git history is the undo story; there is no Restore mutation. + +Folding several noisy records into one survivor is a body edit on the +survivor (score 4/4 if it adds claims) plus `Retract` on the rest. + ### Mutation result ```json @@ -204,6 +228,7 @@ flatbread proof cache prune - Do not hand-edit record frontmatter or `.journal/`; bodies are freely editable (the reindexer validates and repairs projections). +- Do not `git rm` Proof records to correct the graph. Use `Retract`. - Do not parse digest files or `summary` as data feeds for other programs — the digest is evidence for you to read or search; the envelope is the machine surface. diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index abebaa70..8fbe7e6b 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -954,3 +954,336 @@ 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); +}); diff --git a/packages/proof/src/__tests__/schemas.test.ts b/packages/proof/src/__tests__/schemas.test.ts index 95f6e9cd..01975a7f 100644 --- a/packages/proof/src/__tests__/schemas.test.ts +++ b/packages/proof/src/__tests__/schemas.test.ts @@ -86,11 +86,16 @@ const validMutations: Record> = { state: 'realized', evidence: [fnd], }, + Retract: { + type: 'Retract', + recordId: fnd, + reason: 'session noise; not a durable turning point', + }, }; -test('each of the 15 mutation schemas accepts a valid input', (t) => { +test('each of the 16 mutation schemas accepts a valid input', (t) => { const types = Object.keys(validMutations); - t.is(types.length, 15); + t.is(types.length, 16); for (const type of types) { t.notThrows(() => ProofMutationSchema.parse(validMutations[type]), type); } diff --git a/packages/proof/src/__tests__/writer.test.ts b/packages/proof/src/__tests__/writer.test.ts index 216abdf0..05fc0b2c 100644 --- a/packages/proof/src/__tests__/writer.test.ts +++ b/packages/proof/src/__tests__/writer.test.ts @@ -719,3 +719,50 @@ test('snapshot before-image drives journal-compatible end-to-end output', async t.is(result.artifacts[0].frontmatter.status, 'paused'); t.is(result.artifacts[0].body, 'captured body\n'); }); + +test('Retract marks the file and strips inbound ids on survivors', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const finding = soleId( + await writer.mutate({ + type: 'WriteFinding', + effort, + title: 'Noise', + body: 'checklist kind was wrong', + kind: 'survey', + }) + ); + const decision = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Keep', + body: 'durable product rule', + derives_from: [finding], + }) + ); + const result = await writer.mutate({ + type: 'Retract', + recordId: finding, + reason: 'session noise; not a turning point', + }); + t.true(result.touched.some((row) => row.id === finding)); + t.true(result.touched.some((row) => row.id === decision)); + const retracted = await readFrontmatter(root, `findings/${finding}.md`); + t.is(retracted.data.retracted, true); + t.is(retracted.data.retracted_reason, 'session noise; not a turning point'); + t.is(retracted.content.trim(), 'checklist kind was wrong'); + const survivor = await readFrontmatter(root, `decisions/${decision}.md`); + t.is(survivor.data.derives_from, undefined); + t.is(survivor.data.retracted, undefined); + await t.throwsAsync( + writer.mutate({ + type: 'Retract', + recordId: finding, + reason: 'again', + }), + { instanceOf: ProofValidationError, message: /already retracted/ } + ); +}); diff --git a/packages/proof/src/decision-lifecycle.ts b/packages/proof/src/decision-lifecycle.ts index 25406eb8..c1f89e35 100644 --- a/packages/proof/src/decision-lifecycle.ts +++ b/packages/proof/src/decision-lifecycle.ts @@ -28,7 +28,8 @@ export function acceptDecisionLifecycle( for (const sibling of snapshot.siblingDecisions( String(target.frontmatter.effort), { state: 'proposed', excludeId: target.id } - )) + )) { + if (sibling.frontmatter.retracted === true) continue; changes.push({ record: sibling, nextFrontmatter: { @@ -37,6 +38,7 @@ export function acceptDecisionLifecycle( rejected_by: target.id, }, }); + } return changes; } export function supersedeDecisionLifecycle( diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index 9cdded01..655f16db 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -78,6 +78,9 @@ const FRONTMATTER_KEYS = [ 'slug', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', diff --git a/packages/proof/src/frontmatter.ts b/packages/proof/src/frontmatter.ts index d23ebab7..de7f41f7 100644 --- a/packages/proof/src/frontmatter.ts +++ b/packages/proof/src/frontmatter.ts @@ -14,6 +14,9 @@ const order = [ 'created_at', 'produced_in', 'created_by', + 'retracted', + 'retracted_at', + 'retracted_reason', 'derives_from', 'supersedes', 'superseded_by', diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index 7934aad8..e4dbf31f 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -55,6 +55,75 @@ 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 assertCites( get: GetRecord, effortId: string, @@ -66,6 +135,7 @@ function assertCites( throw new ProofValidationError( `cites must target a Citation, got ${target.kind} (${citeId})` ); + assertLive(target); assertTargetEffort('cites', effortId, target); } } @@ -99,8 +169,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 +283,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 +317,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 +359,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 +417,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 +439,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 +455,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 +475,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 +497,53 @@ 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)) + 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