From 9d6e84fd08a63526da8e708c05324921213e674a Mon Sep 17 00:00:00 2001 From: tonyketcham Date: Fri, 25 Sep 2026 02:32:00 -0700 Subject: [PATCH] fix(proof): reject only explicit Decision alternatives AcceptDecision now rejects proposed Decisions only when they share a question Issue, whether open or closed, or when the caller lists them in rejects. It refuses another accepted answer to the same question and refuses a reopened alternative while its prior rejector remains accepted. The result names every changed and rejected Decision; dry run previews those changes without advancing the journal generation. ReopenDecision returns a rejected Decision to proposed and retains its rejection history. Supersede the hard mutation-enum Constraint through Proof's journaled writer to account for the seventeenth mutation. Update the source and bundled skills, API reference, README, and Unreleased changelog. Fixes #277 Validation: - Pinned pnpm 10.33.0 install and workspace build passed. - Writer suite: 21 tests passed, including the two review regressions. - Focused lifecycle, planner, schema, and CLI tests passed earlier. - Repository typecheck, skills:check, changed-file Prettier, and diff check passed. - Full verify stops at skills:pack-check on Windows: its Node child cannot launch npm, while direct npm pack --dry-run --json succeeds. - Repository lint flags unchanged root README.md. - Broad Proof tests have existing Windows path-separator failures. --- .agents/skills/effort-modeling/SKILL.md | 5 +- .agents/skills/proof/SKILL.md | 29 +- .agents/skills/proof/evals/evals.json | 4 +- .agents/skills/proof/glossary.md | 3 +- .agents/skills/proof/reference.md | 26 +- ...ys-deliberately-small--0vf4ssfg2jmzxyn4.md | 2 + ...ys-deliberately-small--3hw451bsedfj6khs.md | 19 ++ CHANGELOG.md | 9 + packages/flatbread/src/cli/proof.test.ts | 78 ++++++ packages/flatbread/src/cli/proof.ts | 25 +- packages/flatbread/src/proof/read.ts | 4 + packages/proof/README.md | 14 +- .../proof/skills/effort-modeling/SKILL.md | 5 +- packages/proof/skills/proof/SKILL.md | 29 +- packages/proof/skills/proof/evals/evals.json | 4 +- packages/proof/skills/proof/glossary.md | 3 +- packages/proof/skills/proof/reference.md | 26 +- .../src/__tests__/decision-lifecycle.test.ts | 25 +- packages/proof/src/__tests__/planner.test.ts | 12 +- packages/proof/src/__tests__/schemas.test.ts | 9 +- packages/proof/src/__tests__/writer.test.ts | 249 ++++++++++++++++-- packages/proof/src/decision-lifecycle.ts | 136 ++++++++-- packages/proof/src/digest.ts | 1 + packages/proof/src/planner.ts | 17 ++ packages/proof/src/schemas.ts | 8 + packages/proof/src/types.ts | 3 + packages/proof/src/writer.ts | 48 +++- 27 files changed, 681 insertions(+), 112 deletions(-) create mode 100644 .flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md diff --git a/.agents/skills/effort-modeling/SKILL.md b/.agents/skills/effort-modeling/SKILL.md index 1e04bcf0..bb7aaeb5 100644 --- a/.agents/skills/effort-modeling/SKILL.md +++ b/.agents/skills/effort-modeling/SKILL.md @@ -71,8 +71,9 @@ create a Citation instead of copying the source into the record body: Record a Decision when it is hard to reverse, surprising without context, and the result of a real trade-off. Create it as proposed while the user is still -deciding; call `AcceptDecision` only after they commit. Always pass -`"rejectSiblings": false` unless deliberately closing every competing proposal. +deciding; call `AcceptDecision` only after they commit. It rejects only +proposals tied to the same `question` Issue, even after it closes. Use `rejects` to name other +alternatives and `dryRun: true` to review the planned changes first. Use the long-form body template in [DECISION-BODY.md](./DECISION-BODY.md) when the rationale would otherwise be lost. The body is the durable explanation; diff --git a/.agents/skills/proof/SKILL.md b/.agents/skills/proof/SKILL.md index 1ab5428b..580aa6d6 100644 --- a/.agents/skills/proof/SKILL.md +++ b/.agents/skills/proof/SKILL.md @@ -10,7 +10,7 @@ repository. It has eight record types: **Effort**, **Issue**, **Finding**, **Decision**, **Constraint**, and **Risk** capture the work and reasoning; **Citation** stores a source or reference; and **Blob** stores attached content such as a document, JSON, or image. Every record belongs to one -Effort. Create and update records through 16 typed mutations, and read them +Effort. Create and update records through 17 typed mutations, and read them through 5 bounded queries. Do not hand-edit record frontmatter, although you may edit record bodies freely. @@ -57,7 +57,7 @@ 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`, +claims. Lifecycle transitions (`AcceptDecision`, `ReopenDecision`, `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 @@ -84,17 +84,17 @@ 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: +One command for all 17 mutations — pass the payload as a single JSON argument: ```bash flatbread proof write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' ``` -Response: `{"generation":"","artifacts":[{"id","path","operation"}],"touched":[...]}`. -**Capture `artifacts[0].id`** to wire later edges, and **keep `generation`** -for strict read-your-writes. +`AcceptDecision` response: `{"generation":"","dryRun":false,"artifacts":[{"id","path","operation"}],"touched":[...],"changedDecisionIds":["dec-..."],"rejectedIds":["dec-..."]}`. +For creates, **capture `artifacts[0].id`** to wire later edges. Keep the +returned `generation` for strict read-your-writes. -Full payload shapes for all 16 mutations: read [reference.md](./reference.md). +Full payload shapes for all 17 mutations: read [reference.md](./reference.md). Critical semantics: - Creates always start in the initial lifecycle state: `WriteDecision` → @@ -108,9 +108,13 @@ Critical semantics: 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. +- `AcceptDecision` defaults `rejectSiblings: true`, which rejects only + proposed Decisions derived from the same `question` Issue, even if the + Issue is closed. Use + `rejects: [""]` to name other alternatives. The result lists + `changedDecisionIds` and `rejectedIds`; pass `dryRun: true` to preview + without saving. Use `ReopenDecision` with a reason to restore a rejected + Decision to proposed while keeping its rejection in `reopen_history`. - Edges are forward-only in payloads (`derives_from`, `supersedes`, `invalidates`); back-edges are materialized automatically. - External sources: create a `WriteCitation` record (its body may be a URL, @@ -203,8 +207,9 @@ server-side. 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. These lifecycle +4. **On commitment:** preview `AcceptDecision` with `dryRun: true`, inspect + its `rejectedIds`, then commit the acceptance. Use `ResolveIssue` 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 diff --git a/.agents/skills/proof/evals/evals.json b/.agents/skills/proof/evals/evals.json index f83d2160..bb9a9a85 100644 --- a/.agents/skills/proof/evals/evals.json +++ b/.agents/skills/proof/evals/evals.json @@ -35,10 +35,10 @@ { "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.", + "expected_output": "Create and accept one Proof Decision through the typed writer. Confirm 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", + "AcceptDecision leaves unrelated proposed Decisions untouched", "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 0e816150..15301226 100644 --- a/.agents/skills/proof/glossary.md +++ b/.agents/skills/proof/glossary.md @@ -87,7 +87,8 @@ 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. +the target was wrong). Git is the undo path for Retract; `ReopenDecision` only returns a rejected +Decision to proposed. There is no generic Restore mutation. ## Intentional non-models diff --git a/.agents/skills/proof/reference.md b/.agents/skills/proof/reference.md index 56b79a41..c62ba2a4 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 16 mutations (`flatbread proof write ''`) +## The 17 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 @@ -67,14 +67,23 @@ target was wrong (stronger than superseded). ```json {"type":"ResolveIssue","issueId":"","resolution":"resolved|deferred|wontfix","resolvedBy":[""]} -{"type":"AcceptDecision","decisionId":"","rejectSiblings":false} +{"type":"AcceptDecision","decisionId":"","rejects":[""],"dryRun":true} +{"type":"ReopenDecision","decisionId":"","reason":"Rejected by mistake"} {"type":"MitigateRisk","riskId":"","decisionId":""} {"type":"SetRiskState","riskId":"","state":"realized|accepted","evidence":[""]} ``` -`AcceptDecision` with `rejectSiblings: true` (the default!) also sets every -other `proposed` Decision in the Effort to `rejected` with a back-pointer. -All mutations run in one journal transaction (save-or-undo). +`AcceptDecision` with `rejectSiblings: true` (the default) rejects only proposed +Decisions that derive from the same Issue whose `kind` is `question`, even +once it closes. Use `rejects` to name other alternatives. The result lists `changedDecisionIds` +and `rejectedIds`. Set `dryRun: true` or pass `--dry-run` to `proof write` to see the same planned changes without +saving them or advancing `generation`. A preview fails if an earlier journal +transaction still needs recovery. `ReopenDecision` returns a rejected +Decision to `proposed`, clears its live `rejected_by` link, and keeps the old +link and reason in `reopen_history`. Acceptance fails if a shared question +already has an accepted Decision, or if the proposed Decision was reopened +after rejection by a still-accepted Decision. Commits run in one journal +transaction. ### Retract a record that should not stay on the live graph @@ -103,7 +112,9 @@ written. It is not a hard delete and not a fold into a survivor: 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. + retracted ids. Git is the undo path for Retract; `ReopenDecision` only + returns a rejected Decision to proposed. There is no generic 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. @@ -113,6 +124,9 @@ survivor (score 4/4 if it adds claims) plus `Retract` on the rest. ```json { "generation": "57", + "dryRun": false, + "changedDecisionIds": ["dec-..."], + "rejectedIds": ["dec-..."], "artifacts": [ { "id": "...", "path": "decisions/....md", "operation": "created|updated" } ], 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 index 3e6dbed5..e30f1891 100644 --- a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4.md +++ b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4.md @@ -6,6 +6,8 @@ kind: hard created_at: '2026-08-22T20:28:27.386Z' supersedes: - con-mutation-enum-stays-deliberately-small--02k06bxbjwrjfp9x +superseded_by: + - con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs --- V1 has exactly sixteen named mutations. Every operation has a Zod schema, validates against a committed index generation, and owns a defined semantic transition. diff --git a/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md new file mode 100644 index 00000000..a41396c3 --- /dev/null +++ b/.flatbread-proof/constraints/con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs.md @@ -0,0 +1,19 @@ +--- +id: con-mutation-enum-stays-deliberately-small--3hw451bsedfj6khs +effort: eff-effort-graph-memory-and-agent-wedge--szeqvmgqjqnhd002 +title: Mutation enum stays deliberately small +kind: hard +created_at: '2026-09-25T09:56:29.313Z' +supersedes: + - con-mutation-enum-stays-deliberately-small--0vf4ssfg2jmzxyn4 +--- + +V1 has exactly seventeen named mutations. Every operation has a Zod schema, validates against a committed index generation, and owns a defined semantic transition. The seventeenth mutation exists because an Effort-wide Decision rejection could silently discard unrelated proposals and could not be undone through Proof. + +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`, `ReopenDecision`, `MitigateRisk`, `SetRiskState`); and `Retract` for records that should not stay on the live graph. + +`AcceptDecision` rejects only explicit alternatives: proposed Decisions derived from a shared question Issue, regardless of whether that Issue remains open, or proposed Decisions named in `rejects`. It names every changed and rejected Decision in the result, and a dry run previews the same changes without committing them. It must not accept a second answer to a shared question or a reopened Decision still rejected by an accepted alternative. `ReopenDecision` restores only a rejected Decision to proposed and retains the earlier rejection in history. + +`Retract` remains the named archive operation. It tombstones a file in place, strips live relation references to that id, and drops the record from browse reads. It is not a generic frontmatter patch, hard delete, or fold into a survivor. Git is the undo path for Retract; there is no generic Restore mutation. + +No generic frontmatter patch, hard delete, standalone `RejectDecision`, or body-edit mutation is part of v1. 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/CHANGELOG.md b/CHANGELOG.md index cfba670f..7f648f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Proof write contract (#277): `AcceptDecision` now rejects only explicit + alternatives tied to the same question Issue or named in `rejects`, reports + changed and rejected Decision ids, and supports a journal-safe dry run. The + seventeenth typed mutation, `ReopenDecision`, returns a rejected Decision to + proposed while retaining rejection history. Acceptance refuses a second + winner for the same question or an alternative rejected by a still-accepted + Decision. The changed default rejection scope is a breaking behavioral + change for callers that relied on Effort-wide rejection. + ## 1.2.0 - `flatbread proof install-skill` installs the Proof skill that shipped with diff --git a/packages/flatbread/src/cli/proof.test.ts b/packages/flatbread/src/cli/proof.test.ts index d1c51a27..fa1b2fa6 100644 --- a/packages/flatbread/src/cli/proof.test.ts +++ b/packages/flatbread/src/cli/proof.test.ts @@ -1335,3 +1335,81 @@ export default { t.is(neighbors.page.returned, 0); } ); + +test.serial( + 'proof write --dry-run previews Decision changes without saving', + async (t) => { + const cwd = await createTempProject('flatbread-decision-preview-', 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 effort = await handleEffortWrite( + JSON.stringify({ type: 'CreateEffort', title: 'Preview', body: '' }), + { cwd } + ); + const decision = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effort.artifacts[0].id, + title: 'Choice', + body: '', + }), + { cwd } + ); + const id = decision.artifacts[0].id; + const preview = await runCli( + cwd, + 'proof', + 'write', + JSON.stringify({ type: 'AcceptDecision', decisionId: id }), + '--dry-run' + ); + t.is(preview.code, 0); + const result = JSON.parse(preview.stdout); + t.true(result.dryRun); + t.deepEqual(result.changedDecisionIds, [id]); + t.deepEqual(result.rejectedIds, []); + t.is(result.generation, decision.generation); + const saved = await handleEffortGet(id, { cwd }); + const text = await readFile(saved.artifact_path, 'utf8'); + t.regex(text, /proposed/); + const alternative = await handleEffortWrite( + JSON.stringify({ + type: 'WriteDecision', + effort: effort.artifacts[0].id, + title: 'Alternative', + body: '', + }), + { cwd } + ); + const alternativeId = alternative.artifacts[0].id; + await handleEffortWrite( + JSON.stringify({ + type: 'AcceptDecision', + decisionId: id, + rejects: [alternativeId], + }), + { cwd } + ); + const reopened = await handleEffortWrite( + JSON.stringify({ + type: 'ReopenDecision', + decisionId: alternativeId, + reason: 'Wrong choice', + }), + { cwd } + ); + const reopenedGet = await handleEffortGet(alternativeId, { + cwd, + strictMinGeneration: reopened.generation, + }); + const reopenedText = await readFile(reopenedGet.artifact_path, 'utf8'); + t.regex(reopenedText, /reopen_history:/); + t.regex(reopenedText, /Wrong choice/); + t.true(reopenedText.includes(id)); + } +); diff --git a/packages/flatbread/src/cli/proof.ts b/packages/flatbread/src/cli/proof.ts index 4de8dd36..0a36f595 100644 --- a/packages/flatbread/src/cli/proof.ts +++ b/packages/flatbread/src/cli/proof.ts @@ -37,6 +37,7 @@ export interface EffortCliOptions { cursor?: string; relations?: string[]; verify?: boolean; + dryRun?: boolean; } export function mapEffortCliOptions( @@ -67,6 +68,10 @@ export function mapEffortCliOptions( cursor: typeof options.cursor === 'string' ? options.cursor : undefined, relations: split(options.relations), verify: options.verify === true ? true : undefined, + dryRun: + options['dry-run'] === true || options.dryRun === true + ? true + : undefined, }).filter(([, value]) => value !== undefined) ) as EffortCliOptions; } @@ -117,6 +122,9 @@ export async function handleEffortWrite( generation: string; artifacts: { id: string; path: string; operation: string }[]; touched: { id: string; path: string }[]; + dryRun?: boolean; + changedDecisionIds?: string[]; + rejectedIds?: string[]; }> { const raw: unknown = JSON.parse(json); if ( @@ -128,7 +136,18 @@ export async function handleEffortWrite( throw new ProofValidationError( 'CreateEffort does not accept cites; create the Effort before its Citations.' ); - const input = ProofMutationSchema.parse(raw); + if ( + options.dryRun && + (raw === null || + typeof raw !== 'object' || + (raw as Record).type !== 'AcceptDecision') + ) + throw new ProofValidationError( + '--dry-run is only valid for AcceptDecision' + ); + const input = ProofMutationSchema.parse( + options.dryRun ? { ...(raw as object), dryRun: true } : raw + ); const cwd = options.cwd ?? process.cwd(); const writer = createProofWriter({ rootDir: await rootFor(cwd) }); const result = await writer.mutate(input); @@ -140,6 +159,9 @@ export async function handleEffortWrite( operation, })), touched: result.touched, + dryRun: result.dryRun, + changedDecisionIds: result.changedDecisionIds, + rejectedIds: result.rejectedIds, }; } @@ -425,6 +447,7 @@ export function registerProofCommands(prog: any): void { prog .command('proof write ', 'Write a validated Proof mutation') + .option('--dry-run', 'Preview an AcceptDecision without writing', false) .action(async (json: string, options: Record) => printResult(handleEffortWrite(json, mapEffortCliOptions(options))) ); diff --git a/packages/flatbread/src/proof/read.ts b/packages/flatbread/src/proof/read.ts index fe22b17d..b91f17ff 100644 --- a/packages/flatbread/src/proof/read.ts +++ b/packages/flatbread/src/proof/read.ts @@ -81,6 +81,7 @@ const FRONTMATTER_FIELDS = [ 'invalidated_by', 'resolved_by', 'rejected_by', + 'reopen_history', 'mitigated_by', 'evidence', 'cites', @@ -374,6 +375,9 @@ class EngineProjection { 'retracted', 'retracted_at', 'retracted_reason', + ...((available.has('reopen_history') + ? ['reopen_history { at reason rejected_by }'] + : []) as string[]), 'derives_from', 'invalidates', 'invalidated_by', diff --git a/packages/proof/README.md b/packages/proof/README.md index 8c4d1e6f..91e3d65a 100644 --- a/packages/proof/README.md +++ b/packages/proof/README.md @@ -152,7 +152,7 @@ An agent resuming work follows the same bounded loop each time. Reads cap at 25 primary records, one relation hop, 50 displayed edges, and a 64 KiB digest; check `complete`, `page.has_more`, and `cap_reasons` in the envelope before treating a digest as the whole story. -3. **Write only durable knowledge.** All 16 typed mutations go through one +3. **Write only durable knowledge.** All 17 typed mutations go through one command: `flatbread proof write ''`. Before a create or a body edit adds a claim, the packaged skill applies a four-part gate — future need, durable effect, causal value, and unique signal — and writes only when @@ -164,10 +164,12 @@ An agent resuming work follows the same bounded loop each time. generation; Proof waits up to 3000 ms by default, then fails with `PROOF_GENERATION_WAIT_TIMEOUT`. Do not build a polling loop. 5. **Close the loop.** Use lifecycle mutations when the team commits to a - choice or resolves an Issue. One default deserves care: `AcceptDecision` - sets `rejectSiblings` to `true`, which rejects every other proposed - Decision in the same Effort — pass `"rejectSiblings":false` unless that - is what you mean. When a record should never have entered the graph, use + choice or resolves an Issue. `AcceptDecision` rejects only proposed + Decisions tied to the same `question` Issue, even after it closes. Pass `rejects` to name + other alternatives and `dryRun: true` to preview the changes. Its result + lists all changed and rejected Decision ids. `ReopenDecision` restores a + rejected Decision to proposed with a reason. When a record should never + have entered the graph, use `Retract`: the file and reason stay in history, but browse reads omit the record. Do not delete record files or hand-edit frontmatter. @@ -226,7 +228,7 @@ service; your normal Git workflow decides when records are shared. Every command prints one JSON object to standard output; errors print JSON to standard error and exit with status 1. The -[full API reference](./skills/proof/reference.md) lists all 16 mutations, +[full API reference](./skills/proof/reference.md) lists all 17 mutations, read flags, lifecycle states, relation names, paging rules, and error codes. ## Optional explorer diff --git a/packages/proof/skills/effort-modeling/SKILL.md b/packages/proof/skills/effort-modeling/SKILL.md index 1e04bcf0..bb7aaeb5 100644 --- a/packages/proof/skills/effort-modeling/SKILL.md +++ b/packages/proof/skills/effort-modeling/SKILL.md @@ -71,8 +71,9 @@ create a Citation instead of copying the source into the record body: Record a Decision when it is hard to reverse, surprising without context, and the result of a real trade-off. Create it as proposed while the user is still -deciding; call `AcceptDecision` only after they commit. Always pass -`"rejectSiblings": false` unless deliberately closing every competing proposal. +deciding; call `AcceptDecision` only after they commit. It rejects only +proposals tied to the same `question` Issue, even after it closes. Use `rejects` to name other +alternatives and `dryRun: true` to review the planned changes first. Use the long-form body template in [DECISION-BODY.md](./DECISION-BODY.md) when the rationale would otherwise be lost. The body is the durable explanation; diff --git a/packages/proof/skills/proof/SKILL.md b/packages/proof/skills/proof/SKILL.md index 1ab5428b..580aa6d6 100644 --- a/packages/proof/skills/proof/SKILL.md +++ b/packages/proof/skills/proof/SKILL.md @@ -10,7 +10,7 @@ repository. It has eight record types: **Effort**, **Issue**, **Finding**, **Decision**, **Constraint**, and **Risk** capture the work and reasoning; **Citation** stores a source or reference; and **Blob** stores attached content such as a document, JSON, or image. Every record belongs to one -Effort. Create and update records through 16 typed mutations, and read them +Effort. Create and update records through 17 typed mutations, and read them through 5 bounded queries. Do not hand-edit record frontmatter, although you may edit record bodies freely. @@ -57,7 +57,7 @@ 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`, +claims. Lifecycle transitions (`AcceptDecision`, `ReopenDecision`, `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 @@ -84,17 +84,17 @@ 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: +One command for all 17 mutations — pass the payload as a single JSON argument: ```bash flatbread proof write '{"type":"WriteDecision","effort":"","title":"...","body":"...","derives_from":[""]}' ``` -Response: `{"generation":"","artifacts":[{"id","path","operation"}],"touched":[...]}`. -**Capture `artifacts[0].id`** to wire later edges, and **keep `generation`** -for strict read-your-writes. +`AcceptDecision` response: `{"generation":"","dryRun":false,"artifacts":[{"id","path","operation"}],"touched":[...],"changedDecisionIds":["dec-..."],"rejectedIds":["dec-..."]}`. +For creates, **capture `artifacts[0].id`** to wire later edges. Keep the +returned `generation` for strict read-your-writes. -Full payload shapes for all 16 mutations: read [reference.md](./reference.md). +Full payload shapes for all 17 mutations: read [reference.md](./reference.md). Critical semantics: - Creates always start in the initial lifecycle state: `WriteDecision` → @@ -108,9 +108,13 @@ Critical semantics: 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. +- `AcceptDecision` defaults `rejectSiblings: true`, which rejects only + proposed Decisions derived from the same `question` Issue, even if the + Issue is closed. Use + `rejects: [""]` to name other alternatives. The result lists + `changedDecisionIds` and `rejectedIds`; pass `dryRun: true` to preview + without saving. Use `ReopenDecision` with a reason to restore a rejected + Decision to proposed while keeping its rejection in `reopen_history`. - Edges are forward-only in payloads (`derives_from`, `supersedes`, `invalidates`); back-edges are materialized automatically. - External sources: create a `WriteCitation` record (its body may be a URL, @@ -203,8 +207,9 @@ server-side. 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. These lifecycle +4. **On commitment:** preview `AcceptDecision` with `dryRun: true`, inspect + its `rejectedIds`, then commit the acceptance. Use `ResolveIssue` 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 diff --git a/packages/proof/skills/proof/evals/evals.json b/packages/proof/skills/proof/evals/evals.json index f83d2160..bb9a9a85 100644 --- a/packages/proof/skills/proof/evals/evals.json +++ b/packages/proof/skills/proof/evals/evals.json @@ -35,10 +35,10 @@ { "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.", + "expected_output": "Create and accept one Proof Decision through the typed writer. Confirm 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", + "AcceptDecision leaves unrelated proposed Decisions untouched", "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 0e816150..15301226 100644 --- a/packages/proof/skills/proof/glossary.md +++ b/packages/proof/skills/proof/glossary.md @@ -87,7 +87,8 @@ 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. +the target was wrong). Git is the undo path for Retract; `ReopenDecision` only returns a rejected +Decision to proposed. There is no generic Restore mutation. ## Intentional non-models diff --git a/packages/proof/skills/proof/reference.md b/packages/proof/skills/proof/reference.md index 56b79a41..c62ba2a4 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 16 mutations (`flatbread proof write ''`) +## The 17 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 @@ -67,14 +67,23 @@ target was wrong (stronger than superseded). ```json {"type":"ResolveIssue","issueId":"","resolution":"resolved|deferred|wontfix","resolvedBy":[""]} -{"type":"AcceptDecision","decisionId":"","rejectSiblings":false} +{"type":"AcceptDecision","decisionId":"","rejects":[""],"dryRun":true} +{"type":"ReopenDecision","decisionId":"","reason":"Rejected by mistake"} {"type":"MitigateRisk","riskId":"","decisionId":""} {"type":"SetRiskState","riskId":"","state":"realized|accepted","evidence":[""]} ``` -`AcceptDecision` with `rejectSiblings: true` (the default!) also sets every -other `proposed` Decision in the Effort to `rejected` with a back-pointer. -All mutations run in one journal transaction (save-or-undo). +`AcceptDecision` with `rejectSiblings: true` (the default) rejects only proposed +Decisions that derive from the same Issue whose `kind` is `question`, even +once it closes. Use `rejects` to name other alternatives. The result lists `changedDecisionIds` +and `rejectedIds`. Set `dryRun: true` or pass `--dry-run` to `proof write` to see the same planned changes without +saving them or advancing `generation`. A preview fails if an earlier journal +transaction still needs recovery. `ReopenDecision` returns a rejected +Decision to `proposed`, clears its live `rejected_by` link, and keeps the old +link and reason in `reopen_history`. Acceptance fails if a shared question +already has an accepted Decision, or if the proposed Decision was reopened +after rejection by a still-accepted Decision. Commits run in one journal +transaction. ### Retract a record that should not stay on the live graph @@ -103,7 +112,9 @@ written. It is not a hard delete and not a fold into a survivor: 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. + retracted ids. Git is the undo path for Retract; `ReopenDecision` only + returns a rejected Decision to proposed. There is no generic 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. @@ -113,6 +124,9 @@ survivor (score 4/4 if it adds claims) plus `Retract` on the rest. ```json { "generation": "57", + "dryRun": false, + "changedDecisionIds": ["dec-..."], + "rejectedIds": ["dec-..."], "artifacts": [ { "id": "...", "path": "decisions/....md", "operation": "created|updated" } ], diff --git a/packages/proof/src/__tests__/decision-lifecycle.test.ts b/packages/proof/src/__tests__/decision-lifecycle.test.ts index 4ab39a09..1323819e 100644 --- a/packages/proof/src/__tests__/decision-lifecycle.test.ts +++ b/packages/proof/src/__tests__/decision-lifecycle.test.ts @@ -16,8 +16,23 @@ function snapshot( states = ['proposed', 'proposed', 'rejected'], foreign = false ) { - return createProofSnapshot( - ids.map((id, i) => ({ + return createProofSnapshot([ + { + id: 'iss-question--0123456789abcdef', + kind: 'issue' as const, + path: 'issues/question.md', + frontmatter: { + id: 'iss-question--0123456789abcdef', + effort, + title: 'Which choice?', + created_at: '2025-01-01T00:00:00.000Z', + kind: 'question', + status: 'open', + }, + body: '', + rawBytes: Buffer.from('question'), + }, + ...ids.map((id, i) => ({ id, kind: 'decision' as const, path: `decisions/${id}.md`, @@ -27,11 +42,12 @@ function snapshot( title: id, created_at: '2025-01-01T00:00:00.000Z', state: states[i], + ...(i < 2 ? { derives_from: ['iss-question--0123456789abcdef'] } : {}), }, body: '', rawBytes: Buffer.from(id), - })) - ); + })), + ]); } test('21 acceptance rejects proposed siblings', (t) => { const changes = acceptDecisionLifecycle(snapshot(), { @@ -57,6 +73,7 @@ test('22 supersede flips a Decision target', (t) => { title: ids[0], created_at: '2025-01-01T00:00:00.000Z', state: 'proposed', + derives_from: ['iss-question--0123456789abcdef'], }; const change = supersedeDecisionLifecycle(snapshot(), ids[0]); t.deepEqual(change.nextFrontmatter, { diff --git a/packages/proof/src/__tests__/planner.test.ts b/packages/proof/src/__tests__/planner.test.ts index 57b585f8..b5febb85 100644 --- a/packages/proof/src/__tests__/planner.test.ts +++ b/packages/proof/src/__tests__/planner.test.ts @@ -394,6 +394,7 @@ test('18 AcceptDecision', (t) => { title: 'A', created_at: '2025-01-01T00:00:00.000Z', state: 'proposed', + derives_from: [ids.issue], }); const b = record(ids.decision2, 'decision', { id: ids.decision2, @@ -401,10 +402,19 @@ test('18 AcceptDecision', (t) => { title: 'B', created_at: '2025-01-01T00:00:00.000Z', state: 'proposed', + derives_from: [ids.issue], + }); + const question = record(ids.issue, 'issue', { + id: ids.issue, + effort: E, + title: 'Question', + created_at: '2025-01-01T00:00:00.000Z', + kind: 'question', + status: 'open', }); const w = planMutation( { type: 'AcceptDecision', decisionId: ids.decision }, - snap([a, b]), + snap([a, b, question]), '/root', now ); diff --git a/packages/proof/src/__tests__/schemas.test.ts b/packages/proof/src/__tests__/schemas.test.ts index 01975a7f..51b21d9c 100644 --- a/packages/proof/src/__tests__/schemas.test.ts +++ b/packages/proof/src/__tests__/schemas.test.ts @@ -79,6 +79,11 @@ const validMutations: Record> = { resolvedBy: [dec], }, AcceptDecision: { type: 'AcceptDecision', decisionId: dec }, + ReopenDecision: { + type: 'ReopenDecision', + decisionId: dec, + reason: 'Rejected by mistake', + }, MitigateRisk: { type: 'MitigateRisk', riskId: rsk, decisionId: dec }, SetRiskState: { type: 'SetRiskState', @@ -93,9 +98,9 @@ const validMutations: Record> = { }, }; -test('each of the 16 mutation schemas accepts a valid input', (t) => { +test('each of the 17 mutation schemas accepts a valid input', (t) => { const types = Object.keys(validMutations); - t.is(types.length, 16); + t.is(types.length, 17); 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 80c900d6..2c27ac29 100644 --- a/packages/proof/src/__tests__/writer.test.ts +++ b/packages/proof/src/__tests__/writer.test.ts @@ -369,7 +369,7 @@ test('ResolveIssue rejects non-open issues and cross-effort sources', async (t) ); }); -test('AcceptDecision rejects proposed siblings and leaves others untouched', async (t) => { +test('AcceptDecision changes only stated alternatives and dry-run predicts the same IDs', async (t) => { const { root, writer } = await makeWriter(); const effort = soleId( await writer.mutate({ type: 'CreateEffort', title: 'E1', body: '' }) @@ -377,54 +377,249 @@ test('AcceptDecision rejects proposed siblings and leaves others untouched', asy const otherEffort = soleId( await writer.mutate({ type: 'CreateEffort', title: 'E2', body: '' }) ); + const question = soleId( + await writer.mutate({ + type: 'WriteIssue', + effort, + title: 'Which model?', + body: '', + kind: 'question', + }) + ); const winner = soleId( - await writer.mutate({ type: 'WriteDecision', effort, title: 'W', body: '' }) + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Winner', + body: '', + derives_from: [question], + }) ); - const sibling = soleId( - await writer.mutate({ type: 'WriteDecision', effort, title: 'S', body: '' }) + const alternative = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Alternative', + body: '', + derives_from: [question], + }) + ); + const unrelated = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Other question', + body: '', + }) ); const foreign = soleId( await writer.mutate({ type: 'WriteDecision', effort: otherEffort, - title: 'Other', + title: 'Foreign', body: '', }) ); - const result = await writer.mutate({ + const before = await readFrontmatter(root, `decisions/${alternative}.md`); + const preview = await writer.mutate({ type: 'AcceptDecision', decisionId: winner, + dryRun: true, }); - t.deepEqual(result.touched.map((x) => x.id).sort(), [winner, sibling].sort()); - const winnerDoc = await readFrontmatter(root, `decisions/${winner}.md`); - t.is(winnerDoc.data.state, 'accepted'); - const siblingDoc = await readFrontmatter(root, `decisions/${sibling}.md`); - t.is(siblingDoc.data.state, 'rejected'); - t.is(siblingDoc.data.rejected_by, winner); - const foreignDoc = await readFrontmatter(root, `decisions/${foreign}.md`); - t.is(foreignDoc.data.state, 'proposed'); - t.is(foreignDoc.data.rejected_by, undefined); - // A later accept must not touch the already-rejected sibling. - const later = soleId( - await writer.mutate({ type: 'WriteDecision', effort, title: 'L', body: '' }) - ); - const secondAccept = await writer.mutate({ + t.true(preview.dryRun); + t.deepEqual(preview.changedDecisionIds, [winner, alternative]); + t.deepEqual(preview.rejectedIds, [alternative]); + t.deepEqual( + (await readFrontmatter(root, `decisions/${alternative}.md`)).data, + before.data + ); + t.is( + (await readFrontmatter(root, `decisions/${winner}.md`)).data.state, + 'proposed' + ); + const result = await writer.mutate({ type: 'AcceptDecision', - decisionId: later, + decisionId: winner, }); - t.deepEqual( - secondAccept.touched.map((x) => x.id), - [later] + t.is(Number(result.generation), Number(preview.generation) + 1); + t.deepEqual(result.changedDecisionIds, preview.changedDecisionIds); + t.deepEqual(result.rejectedIds, preview.rejectedIds); + t.is( + (await readFrontmatter(root, `decisions/${alternative}.md`)).data + .rejected_by, + winner + ); + t.is( + (await readFrontmatter(root, `decisions/${unrelated}.md`)).data.state, + 'proposed' + ); + t.is( + (await readFrontmatter(root, `decisions/${foreign}.md`)).data.state, + 'proposed' + ); + await t.throwsAsync( + writer.mutate({ + type: 'AcceptDecision', + decisionId: unrelated, + rejects: [foreign], + }), + { instanceOf: ProofValidationError } + ); + t.is( + (await readFrontmatter(root, `decisions/${unrelated}.md`)).data.state, + 'proposed' + ); + await t.throwsAsync( + writer.mutate({ + type: 'AcceptDecision', + decisionId: unrelated, + rejects: [alternative], + }), + { instanceOf: ProofValidationError } ); - const siblingAfter = await readFrontmatter(root, `decisions/${sibling}.md`); - t.is(siblingAfter.data.rejected_by, winner); - // Accepting a non-proposed decision is rejected. await t.throwsAsync( writer.mutate({ type: 'AcceptDecision', decisionId: winner }), { instanceOf: ProofValidationError } ); }); +test('ReopenDecision restores a rejected Decision and records why', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const winner = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'W', body: '' }) + ); + const other = soleId( + await writer.mutate({ type: 'WriteDecision', effort, title: 'O', body: '' }) + ); + await writer.mutate({ + type: 'AcceptDecision', + decisionId: winner, + rejects: [other], + }); + const result = await writer.mutate({ + type: 'ReopenDecision', + decisionId: other, + reason: 'Rejected by mistake', + }); + t.deepEqual(result.changedDecisionIds, [other]); + const data = (await readFrontmatter(root, `decisions/${other}.md`)).data; + t.is(data.state, 'proposed'); + t.is(data.rejected_by, undefined); + t.is(data.reopen_history[0].reason, 'Rejected by mistake'); + t.is(data.reopen_history[0].rejected_by, winner); + await t.throwsAsync( + writer.mutate({ type: 'AcceptDecision', decisionId: other }), + { + instanceOf: ProofValidationError, + message: /rejected by accepted Decision/, + } + ); + t.is( + (await readFrontmatter(root, `decisions/${other}.md`)).data.state, + 'proposed' + ); + await t.throwsAsync( + writer.mutate({ + type: 'ReopenDecision', + decisionId: other, + reason: 'again', + }), + { instanceOf: ProofValidationError } + ); +}); + +test('AcceptDecision refuses a second accepted answer to a closed question', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const question = soleId( + await writer.mutate({ + type: 'WriteIssue', + effort, + title: 'Which?', + body: '', + kind: 'question', + }) + ); + const first = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'First', + body: '', + derives_from: [question], + }) + ); + const second = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Second', + body: '', + derives_from: [question], + }) + ); + await writer.mutate({ + type: 'AcceptDecision', + decisionId: first, + rejectSiblings: false, + }); + await writer.mutate({ + type: 'ResolveIssue', + issueId: question, + resolution: 'resolved', + resolvedBy: [first], + }); + await t.throwsAsync( + writer.mutate({ type: 'AcceptDecision', decisionId: second }), + { + instanceOf: ProofValidationError, + message: /already has accepted Decision/, + } + ); + t.is( + (await readFrontmatter(root, `decisions/${second}.md`)).data.state, + 'proposed' + ); +}); + +test('Decision preview refuses an unfinished journal without changing records', async (t) => { + const { root, writer } = await makeWriter(); + const effort = soleId( + await writer.mutate({ type: 'CreateEffort', title: 'E', body: '' }) + ); + const decision = soleId( + await writer.mutate({ + type: 'WriteDecision', + effort, + title: 'Choice', + body: '', + }) + ); + const generationPath = join(root, '.journal', 'generation.json'); + const beforeGeneration = await readFile(generationPath, 'utf8'); + await mkdir(join(root, '.journal', 'txns', 'unfinished'), { + recursive: true, + }); + await t.throwsAsync( + writer.mutate({ + type: 'AcceptDecision', + decisionId: decision, + dryRun: true, + }), + { instanceOf: ProofValidationError, message: /journal recovery is pending/ } + ); + t.is(await readFile(generationPath, 'utf8'), beforeGeneration); + t.is( + (await readFrontmatter(root, `decisions/${decision}.md`)).data.state, + 'proposed' + ); +}); + test('MitigateRisk requires an accepted Decision in the same Effort', async (t) => { const { writer } = await makeWriter(); const effort = soleId( diff --git a/packages/proof/src/decision-lifecycle.ts b/packages/proof/src/decision-lifecycle.ts index c1f89e35..04eec3f8 100644 --- a/packages/proof/src/decision-lifecycle.ts +++ b/packages/proof/src/decision-lifecycle.ts @@ -8,6 +8,25 @@ export interface DecisionLifecycleChange { export interface AcceptDecisionLifecycleInput { readonly decisionId: string; readonly rejectSiblings: boolean; + readonly rejects?: readonly string[]; +} +function questionIds( + snapshot: ProofSnapshot, + record: ProofSnapshotArtifact +): Set { + const ids = record.frontmatter.derives_from; + if (!Array.isArray(ids)) return new Set(); + return new Set( + ids.filter((id): id is string => { + if (typeof id !== 'string') return false; + const issue = snapshot.getRecord(id); + return ( + issue?.kind === 'issue' && + issue.frontmatter.kind === 'question' && + issue.frontmatter.retracted !== true + ); + }) + ); } export function acceptDecisionLifecycle( snapshot: ProofSnapshot, @@ -18,29 +37,116 @@ export function acceptDecisionLifecycle( throw new ProofValidationError(`Unknown artifact ${input.decisionId}`); if (target.kind !== 'decision' || target.frontmatter.state !== 'proposed') throw new ProofValidationError('Decision is not proposed'); + const questions = questionIds(snapshot, target); + const accepted = snapshot.siblingDecisions( + String(target.frontmatter.effort), + { + state: 'accepted', + excludeId: target.id, + } + ); + for (const other of accepted) { + if ([...questionIds(snapshot, other)].some((id) => questions.has(id))) + throw new ProofValidationError( + `Question already has accepted Decision ${other.id}` + ); + } + const history = target.frontmatter.reopen_history; + if (Array.isArray(history)) { + for (const entry of history) { + const rejectorId = + entry && typeof entry === 'object' + ? (entry as Record).rejected_by + : undefined; + const rejector = + typeof rejectorId === 'string' + ? snapshot.getRecord(rejectorId) + : undefined; + if ( + rejector?.kind === 'decision' && + rejector.frontmatter.effort === target.frontmatter.effort && + rejector.frontmatter.state === 'accepted' && + rejector.frontmatter.retracted !== true + ) + throw new ProofValidationError( + `Decision was rejected by accepted Decision ${rejector.id}` + ); + } + } const changes: DecisionLifecycleChange[] = [ { record: target, nextFrontmatter: { ...target.frontmatter, state: 'accepted' }, }, ]; - if (input.rejectSiblings) - 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: { - ...sibling.frontmatter, - state: 'rejected', - rejected_by: target.id, - }, - }); - } + const siblings = input.rejectSiblings + ? snapshot + .siblingDecisions(String(target.frontmatter.effort), { + state: 'proposed', + excludeId: target.id, + }) + .filter((sibling) => + [...questionIds(snapshot, sibling)].some((id) => questions.has(id)) + ) + : []; + const rejectIds = new Set([ + ...siblings.map((sibling) => sibling.id), + ...(input.rejects ?? []), + ]); + for (const id of rejectIds) { + const sibling = snapshot.getRecord(id); + if ( + !sibling || + sibling.kind !== 'decision' || + sibling.id === target.id || + sibling.frontmatter.effort !== target.frontmatter.effort || + sibling.frontmatter.state !== 'proposed' || + sibling.frontmatter.retracted === true + ) + throw new ProofValidationError(`Cannot reject Decision ${id}`); + changes.push({ + record: sibling, + nextFrontmatter: { + ...sibling.frontmatter, + state: 'rejected', + rejected_by: target.id, + }, + }); + } return changes; } +export function reopenDecisionLifecycle( + snapshot: ProofSnapshot, + decisionId: string, + reason: string, + now: Date +): DecisionLifecycleChange { + const target = snapshot.getRecord(decisionId); + if ( + !target || + target.kind !== 'decision' || + target.frontmatter.state !== 'rejected' || + target.frontmatter.retracted === true + ) + throw new ProofValidationError('Decision is not a live rejected Decision'); + const history = Array.isArray(target.frontmatter.reopen_history) + ? target.frontmatter.reopen_history + : []; + const nextFrontmatter: Record = { + ...target.frontmatter, + state: 'proposed', + reopen_history: [ + ...history, + { + at: now.toISOString(), + reason, + rejected_by: target.frontmatter.rejected_by ?? null, + }, + ], + }; + delete nextFrontmatter.rejected_by; + return { record: target, nextFrontmatter }; +} export function supersedeDecisionLifecycle( snapshot: ProofSnapshot, decisionId: string diff --git a/packages/proof/src/digest.ts b/packages/proof/src/digest.ts index 655f16db..a9ed70aa 100644 --- a/packages/proof/src/digest.ts +++ b/packages/proof/src/digest.ts @@ -88,6 +88,7 @@ const FRONTMATTER_KEYS = [ 'invalidated_by', 'resolved_by', 'rejected_by', + 'reopen_history', 'mitigated_by', 'evidence', 'role', diff --git a/packages/proof/src/planner.ts b/packages/proof/src/planner.ts index 8c64e318..1c9057af 100644 --- a/packages/proof/src/planner.ts +++ b/packages/proof/src/planner.ts @@ -8,6 +8,7 @@ import { } from './ids.js'; import { acceptDecisionLifecycle, + reopenDecisionLifecycle, supersedeDecisionLifecycle, } from './decision-lifecycle.js'; import type { ProofSnapshot } from './snapshot.js'; @@ -467,6 +468,7 @@ export function planMutation( for (const change of acceptDecisionLifecycle(snapshot, { decisionId: input.decisionId, rejectSiblings: input.rejectSiblings !== false, + rejects: input.rejects, })) add( change.record.id, @@ -476,6 +478,21 @@ export function planMutation( ); return [...writes.values()]; } + if (input.type === 'ReopenDecision') { + const change = reopenDecisionLifecycle( + snapshot, + input.decisionId, + input.reason, + now + ); + add( + change.record.id, + change.record.kind, + { ...change.nextFrontmatter }, + change.record.body + ); + return [...writes.values()]; + } if (input.type === 'MitigateRisk') { const r = get(input.riskId), d = get(input.decisionId); diff --git a/packages/proof/src/schemas.ts b/packages/proof/src/schemas.ts index eb91fd28..f4e57d20 100644 --- a/packages/proof/src/schemas.ts +++ b/packages/proof/src/schemas.ts @@ -96,6 +96,13 @@ export const AcceptDecisionSchema = z.object({ type: z.literal('AcceptDecision'), decisionId: id, rejectSiblings: z.boolean().optional().default(true), + rejects: id.array().optional(), + dryRun: z.boolean().optional(), +}); +export const ReopenDecisionSchema = z.object({ + type: z.literal('ReopenDecision'), + decisionId: id, + reason: z.string().min(1), }); export const MitigateRiskSchema = z.object({ type: z.literal('MitigateRisk'), @@ -127,6 +134,7 @@ export const ProofMutationSchema = z.discriminatedUnion('type', [ InvalidateSchema, ResolveIssueSchema, AcceptDecisionSchema, + ReopenDecisionSchema, MitigateRiskSchema, SetRiskStateSchema, RetractSchema, diff --git a/packages/proof/src/types.ts b/packages/proof/src/types.ts index f7f48587..4ecd2ef2 100644 --- a/packages/proof/src/types.ts +++ b/packages/proof/src/types.ts @@ -24,6 +24,9 @@ export interface MutationResult { generation: GenerationToken; artifacts: WrittenArtifact[]; touched: TouchedArtifact[]; + dryRun?: boolean; + changedDecisionIds?: string[]; + rejectedIds?: string[]; } export interface CommittedGenerationPublication { rootDir: string; diff --git a/packages/proof/src/writer.ts b/packages/proof/src/writer.ts index 51f16d44..6bc15118 100644 --- a/packages/proof/src/writer.ts +++ b/packages/proof/src/writer.ts @@ -1,6 +1,7 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { acquireWriterLock } from './lock.js'; +import { ProofValidationError } from './errors.js'; import { commitJournal, recoverJournal } from './journal.js'; import { planMutation } from './planner.js'; import { filesystemProofSnapshotSource } from './snapshot.js'; @@ -26,7 +27,21 @@ export function createProofWriter(options: ProofWriterOptions): ProofWriter { async function mutate(input: any): Promise { const lock = await acquireWriterLock(options.rootDir, options.lockOptions); try { - await recoverJournal(options.rootDir, (p) => publisher.publish(p)); + const dryRun = input.type === 'AcceptDecision' && input.dryRun === true; + if (dryRun) { + const pending = await readdir( + join(options.rootDir, '.journal', 'txns') + ).catch((error: { code?: string }) => { + if (error.code === 'ENOENT') return []; + throw error; + }); + if (pending.length) + throw new ProofValidationError( + 'Cannot preview while journal recovery is pending; recover first' + ); + } else { + await recoverJournal(options.rootDir, (p) => publisher.publish(p)); + } let current = 0; try { current = @@ -46,14 +61,16 @@ export function createProofWriter(options: ProofWriterOptions): ProofWriter { options.randomBytes ); const generation = Number(current) + 1; - const tx = await commitJournal( - options.rootDir, - lock.token, - writes, - generation, - (p) => publisher.publish(p), - () => lock.verify() - ); + const tx = dryRun + ? String(current) + : await commitJournal( + options.rootDir, + lock.token, + writes, + generation, + (p) => publisher.publish(p), + () => lock.verify() + ); const artifacts: WrittenArtifact[] = writes.map((w) => { const parsed = parseDocument(w.afterBytes, w.kind); return { @@ -69,6 +86,17 @@ export function createProofWriter(options: ProofWriterOptions): ProofWriter { generation: tx, artifacts, touched: writes.map((w) => ({ id: w.id, path: w.relativePath })), + ...(input.type === 'AcceptDecision' + ? { + dryRun, + changedDecisionIds: writes.map((w) => w.id), + rejectedIds: artifacts + .filter((a) => a.frontmatter.state === 'rejected') + .map((a) => a.id), + } + : input.type === 'ReopenDecision' + ? { changedDecisionIds: writes.map((w) => w.id) } + : {}), }; } finally { await lock.release();