Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/effort-modeling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 17 additions & 12 deletions .agents/skills/proof/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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":"<eff-id>","title":"...","body":"...","derives_from":["<id>"]}'
```

Response: `{"generation":"<token>","artifacts":[{"id","path","operation"}],"touched":[...]}`.
**Capture `artifacts[0].id`** to wire later edges, and **keep `generation`**
for strict read-your-writes.
`AcceptDecision` response: `{"generation":"<token>","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` →
Expand All @@ -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: ["<dec-id>"]` 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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/proof/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion .agents/skills/proof/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 20 additions & 6 deletions .agents/skills/proof/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Generated as `<prefix>-<slug>--<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 '<json>'`)
## The 17 mutations (`flatbread proof write '<json>'`)

Common optional fields on all creates: `id`, `created_at` (ISO with offset),
`produced_in`, `created_by` (opaque provenance strings). Forward edge fields
Expand Down Expand Up @@ -67,14 +67,23 @@ target was wrong (stronger than superseded).

```json
{"type":"ResolveIssue","issueId":"<iss-id>","resolution":"resolved|deferred|wontfix","resolvedBy":["<dec-or-fnd-id>"]}
{"type":"AcceptDecision","decisionId":"<dec-id>","rejectSiblings":false}
{"type":"AcceptDecision","decisionId":"<dec-id>","rejects":["<other-dec-id>"],"dryRun":true}
{"type":"ReopenDecision","decisionId":"<rejected-dec-id>","reason":"Rejected by mistake"}
{"type":"MitigateRisk","riskId":"<rsk-id>","decisionId":"<accepted-dec-id>"}
{"type":"SetRiskState","riskId":"<rsk-id>","state":"realized|accepted","evidence":["<fnd-id>"]}
```

`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

Expand Down Expand Up @@ -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.
Expand All @@ -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" }
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions packages/flatbread/src/cli/proof.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
);
25 changes: 24 additions & 1 deletion packages/flatbread/src/cli/proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface EffortCliOptions {
cursor?: string;
relations?: string[];
verify?: boolean;
dryRun?: boolean;
}

export function mapEffortCliOptions(
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 (
Expand All @@ -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<string, unknown>).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);
Expand All @@ -140,6 +159,9 @@ export async function handleEffortWrite(
operation,
})),
touched: result.touched,
dryRun: result.dryRun,
changedDecisionIds: result.changedDecisionIds,
rejectedIds: result.rejectedIds,
};
}

Expand Down Expand Up @@ -425,6 +447,7 @@ export function registerProofCommands(prog: any): void {

prog
.command('proof write <json>', 'Write a validated Proof mutation')
.option('--dry-run', 'Preview an AcceptDecision without writing', false)
.action(async (json: string, options: Record<string, unknown>) =>
printResult(handleEffortWrite(json, mapEffortCliOptions(options)))
);
Expand Down
4 changes: 4 additions & 0 deletions packages/flatbread/src/proof/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const FRONTMATTER_FIELDS = [
'invalidated_by',
'resolved_by',
'rejected_by',
'reopen_history',
'mitigated_by',
'evidence',
'cites',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading