--- name: graph-engineering description: How to run Graph Engineering at Flux Point — deciding when a loop must become a graph, specifying multi-agent campaigns as a declarative IR in WORK.md with typed contracts and verification tiers, compiling it deterministically to a Claude Code Workflow script, and repairing runs with targeted resume. Use whenever the user mentions graphs, graph engineering, multi-agent work, orchestration, councils, judge panels, fan-out, swarms, wiring or organizing subagents, WORK.md, workflow scripts, or asks to parallelize verified work across agents, even if they never say "graph engineering". --- # Graph Engineering Loops made one agent's behavior programmable; graphs make the organization of agents programmable. The graph is the artifact: a declarative IR (`WORK.md`), a compiler that turns it into a Workflow script with no model in the loop, and the same deterministic harness deciding green. A node is not a prompt — it is a loop-engineered unit with a scoped context packet in and a typed contract out. The rule that survives every upgrade: the agent never decides "done"; the harness does. ## Loop or graph Before a new implementation goal, use `/fluxpoint:grill-me` (Codex: `$fluxpoint-grill-me`) to research and fill the decision tree, challenge its assumptions and lock the requirement packet. Routine decisions have explicit model defaults the user can override. Mutating graph compilation requires `.fluxpoint-spec.json` and its current lock; every requirement names checks, and the compiler carries the packet into node context. Schema validation is structural; the declared tests, model checks or proofs and runtime exercises establish the behavior. Read-only exploration can precede the locked packet. Stay in loop mode when the work is one zone, one contract, serial evidence. Escalate to a graph when two or more hold: independent subtasks that fail independently; a work-list to fan out over; claims that need adversarial verification from independent context; cross-zone ownership (validators vs off-chain vs infra); a budget worth isolating per node. A work graph of more than ten nodes is scope creep — split the campaign; the compiler warns past ten. Never build a graph as ceremony around a single slice. Two stay-in-loop cases that look graph-shaped and are not. Work whose *decomposition* is still unknown is loop work: a discovery `repeat` covers unknown size (known angles, unknown count), never unknown structure — you cannot fan out over subtasks you cannot yet name, so explore in loop mode first and graph what the exploration finds. And work a human steers step-by-step is loop work: the graph's human machinery (`actor: human`, `confirm`) models permission boundaries inside an autonomous run, not a person redirecting each move — a graph whose every edge waits on a person is a loop wearing ceremony. ## The six primitives and their bindings | Primitive | Meaning | Binding | |---|---|---| | Node | one responsibility, its own context | an IR `nodes[]` entry → one `agent()` call; stable roles via `roles` → `agentType` | | Edge | explicit routing | IR `after` / `foreach`; the compiler derives `pipeline()`/`parallel()` — never a model-improvised hand-off | | Contract | what a node must produce | `contract: "NameV1"` → `contracts/NameV1.schema.json`, validated structured output | | Context packet | what a node may believe | the IR `prompt`, with `{{A.x}}` inputs, `{{item.*}}` fan-out, `{{prev}}` predecessor contract or `{{prev.}}` one projected field — never the transcript | | Reducer | deterministic code between agents | a `reduce` node: `{from, over, dedupeBy, sortBy, order, topK}` → emitted JS, zero spawns. Models for ambiguity, code for plumbing | | Verification | who decides an edge is green | the `verify` tier, plus `independent: true` nodes that re-derive gates | **Compress before you reason.** A synthesis node handed every raw fan-out item pays a reasoning model to do a Set's job. Put a `reduce` node between fan-out and judgment (dedupe → rank → cut, in that order, each cut named in the log — no silent caps), or project just the field a consumer needs with `{{prev.}}` instead of pasting the whole contract. Both are validated at compile time: a projected field must exist in the predecessor's contract, and projecting off a node that yields an item array (a verified sweep, a fan-out, another reduce) is rejected — consume `{{prev}}` whole or reduce it first. Durable coordination rides on the executor: every run has a `runId`, `journal.jsonl` records each node's actual return, and `record-run.py` writes the provenance artifact and the Evidence row. The runtime replays an unchanged call prefix; opt-in node replay below adds independent, ancestor-bound reuse after that prefix ends. ## Independent lanes, replay and rejudging Declare `lane: "engine"` or `lane: "publisher"` on independent chains to start their bodies concurrently. Nodes inside one lane retain declaration order. A lane-free node joins the active lanes; irreversible nodes must be lane-free, and their independent `prove:` guard must be an actual `after` ancestor. An earlier guard in a sibling lane cannot authorize an effect. Use `after: ["gate-engine", "gate-publisher"]` for a consumer that needs both results, and consume each explicitly with `{{prev.gate-engine}}` or a contract field such as `{{prev.gate-publisher.exit}}`. Every listed edge is validated. Keep a repair loop's fixer, checker and integration nodes in the same lane. A halted lane stops new spawns in every lane; already running calls finish and are recorded before the single final outcome is returned. Every mutating lane node declares `pushes: "repository#branch"`. Lanes may not push the same declared repository/branch. Use one consistent repository identity for a shared remote; aliases are not separate write destinations. Lanes require the tree guard. It reads before each gate, at forks and joins, and at campaign end. Guards after the first fork include the launcher's fresh `_replayLaunchNonce`, so the Workflow call-index cache cannot replay an old reading. In-flight calls reserve their token floors atomically. Each executed builder with `pushes` is bracketed by fixed-command `git ls-remote --heads origin` snapshots. Any undeclared head created, changed or deleted during that interval halts the campaign. An unchanged declared head leaves a visible `PUSH-NO-CHANGE` finding. A live builder requires fresh endpoints; missing or uncorroborated receipts cannot record a successful run. Verified prefix calls retain their original endpoint slots, and a builder that falls out of the prefix refreshes its before-read. These are conservative interval observations: an unrelated concurrent push also halts. They do not prove who pushed, observe tags, or detect a head changed and restored between the two snapshots. Replay is opt-in: `replay: "inputs"` on a scalar builder, and `replay: "tree"` on a scalar independent gate. The gate declares `repository` and `branch`; it receives a fresh fixed-command sentinel's exact tip, must check out that commit and return it as `sha`. A missing or different `sha` halts. A builder replays only when its branch's live reading equals the last corroborated source reading on that branch. Its verifying gate retains its own exact judged tip, so that gate reruns when a later builder moved the branch. No worker-reported head admits a replay. Every `after` ancestor must itself have replayed in this launch. A live ancestor forces its descendants live, including joined consumers. Loops, fan-outs, councils, red-team and irreversible effects use their existing execution paths rather than node memo replay. `fresh: true` forces a live call for exchange, chain, CI and clock readings. The launcher alone loads `_memo` and fingerprinted `_prefix` entries from completed journal calls, transcripts and the recorded compiler receipts. Never hand-write either map. SHA-256 keys bind the complete resolved prompt, options, immutable launch stamp, gate reading and ordered ancestor keys; record-run independently corroborates replay provenance before appending Evidence. `REPLAYED-MEMO runId/agentId` names the original execution. Receipt-enabled graphs, including ordinary serial irreversible graphs, use captured-prefix replay on structured resume. The compiler embeds a versioned pool of exact verified results; equal encoded results share one entry. Native object key order and string types are retained because they affect later rendered prompts. The complete replay state is capped at 128 KiB, and the launcher still enforces the total compiled-script limit. A source with incomplete receipts, a foreign-content result or a capture limit ends the available prefix. Required fresh, rejudge and fork readings execute in the live suffix. The assembled Workflow object starts a fresh native run. `_resumeFromRunId` and its immutable artifact hash identify the source internally. Matching prefix calls return their captured result locally and carry source-backed logical call receipts; they create no current native worker. Recording recursively checks those receipts against the original native evidence. The native Workflow UI and token meter describe current live work. For an explicit gate-only rerun, supply public args `rejudge: {"gate-engine": {"nonce": "review-2", "reason": "why the prior judgment needs review", "note": "optional gate-specific context"}}`. Only that gate and its preceding sentinel receive the nonce and note. Reasons remain record metadata, and the old verdict remains recorded. Whole-argument prompt tokens exclude rejudge metadata and internal launcher state. Unknown or unreached gates refuse the launch plan. Before resuming, run `compile-graph.py --plan WORK.md --resume RUN --args args.json --gates-root .`. It renders the exact generated program locally with verified prior results, using Node.js and no agents or network reads. Each node is `prefix`, `memo if the live read matches`, or `live`, with its cause and recorded cost (unknown values are explicit). A conditional memo prediction is checked against a real fresh reading during the launch. For graphs with lane, push, replay or fresh declarations, the plan also renders a serial counterfactual with those opt-ins removed from the same current graph, once with recorded prior inputs and once with requested inputs. Recorded node outputs stay fixed in both renders. Each row shows its serial prefix/live status; the comparison warns when the opt-in render executes more reached nodes or more known recorded-node minutes. Costs are estimates from the source run, with unknown costs explicit. They do not predict new model outputs, changed graph history, actual remote tips or future elapsed time; an unreached node contributes no executed cost. Graphs without these declarations or rejudge args keep their existing serial script and runtime keys. ## The IR is the source of truth One ```json graph-ir fenced block in WORK.md. `graph-run` compiles it mechanically, so the spec cannot drift from the executor. **Never hand-edit a compiled `.graph.js`** — edit the IR and recompile. The compiler rejects, at compile time: - a node with no contract, or an unknown contract name - `verifyOver` that is not a field of the node's contract - an even panel (majority undefined) or a panel with no `verifyOver` - `verify: harness` — removed; it compiled to nothing while the spec claimed the node was checked. Gate on the harness the honest way: `mutates: true` on the producer plus an `independent` node that runs it - an unknown field at any level (a misspelled `verifyOver` used to disable verification silently), or `after` whose prompt never uses `{{prev}}` — declaration order already sequences nodes, so a consumed-nothing edge is a phantom - `mutates: true` with no `independent: true` node that `verifies` it - a node verifying itself, or a verifier not marked independent - `after`/`foreach`/`role` pointing at things that do not exist - `{{prev}}` with no `after` edge, or `{{seen}}` with no `repeat` block (hidden coupling) - `{{prev.}}` naming a field the predecessor's contract lacks, reaching deeper than one hop, or projecting off a node that yields an item array — it used to compile clean and die at launch on a ReferenceError, which is the exact failure the scope check exists for - `onRed` outside `halt | drop+log` — a misspelling used to fall back to a default silently, weakening the declared failure policy in the permissive direction — and `onRed` on a parked node, where nothing can die. On fan-out and discovery nodes the field is now real, too: `halt` ends the campaign on a dead worker, and a round that lost ANY worker never counts toward the dry rule — a finder that keeps crashing must not end a sweep looking converged. A spawn the node ceiling declined is a different sentence from a death: it files SKIPPED, and under `halt` the run ends `BUDGET-EXHAUSTED`, never `NODE-DEAD` - a `reduce` node with no operation, an `over` that is not a field of its source's contract, `over` on a source that already yields items, a dedupe/sort key outside the item schema — or over items that declare no fields at all, where every key would read `String(undefined)` and dedupe would collapse distinct items to one — `order` without `sortBy`, `topK` without a ranking, or combined with any agent-node field - a `repeat` block missing its dry rule, round ceiling, or dedup key, or whose dry rule can never fire; a dedup key that is not a field of the contract's items - planned agent calls exceeding `budget.maxNodes`, or no ceiling at all — and rounds are priced in, so a four-round discovery loop is costed at four rounds, not one - `irreversible: true` without `confirm` in `requiredArgs`, without an earlier `independent` node carrying a `haltWhen`, or combined with `foreach`/`repeat` Those are structural. `/fluxpoint:graph-audit` judges what is left: scoping, tier-vs-stakes, prompt quality. It runs as a shipped workflow (`workflows/graph-audit.js`): six lens-scoped auditors, a reduce that merges their duplicates, and verifiers told to refute each finding, three per finding at HIGH and above. One auditor per round read as convergence when it was one reader running out of path, so SOUND now means every lens returned and nothing survived refutation. Rounds persist under `.claude/fluxpoint/audits/`, and what earlier rounds fixed or refuted is passed back as a settled list so a round does not relitigate it. `--quick` keeps the single auditor for checks between repairs; it never makes a graph READY. Before the lenses, every `isolation: true`, `isolation: 'worktree'` or `mutates: true` node gets an isolated probe that tries each command its prompt prescribes, and SOUND needs every probe complete. The probe's own grammar covers Git reads and a few read-only shell words. A node that runs a script declares how each such command is probed, in a `probe` object beside its prompt: `read-only` runs the exact command, `shape` runs a reviewed `--help` form placed before any `--` or child program, and a `template` names the concrete commands behind an illustrative `git -C ...`. `compile-graph.py --check` runs the same grammar, so an undeclared script command or a refused declaration shows before a round is bought; it reads an `{{A.x}}` it cannot resolve as the word `A.x`, so the audit, with the real arguments, has the last word. A shape-only receipt proves the isolation hook admits the command's executable, cwd and argv prefix, and the graph's attested gates prove what the command does. The grammar refuses known hazards (`-h`, inline-code interpreter options, launchers such as `docker` or `npx`, writers declared read-only), but a declaration stays the author's assertion, and the probe runs it before any lens reads the IR, so review the IR diff first. `commands/graph-audit.md` holds the rules and an example declaration. ## Work nobody on the graph can do The engine used to have two answers for a node it could not complete: halt the whole campaign, or drop the item and continue with a `null`. Neither is *"this one is blocked, work the other branches"* — and real deliveries guarantee that third case. 2-of-3 hardware signing. A withdrawal only an external counterparty can perform. A 72-hour governance timelock. An operator wallet with nothing spendable until someone tops it up. **Park last, not first.** Most work that feels human-only is not: a CLI, an API, a headless browser, a read-only query, or a generated file the person only has to sign. Genuine blockers are narrow — key material an agent must not hold, legal authority, physical possession, another party's own action. So `release.whyNotAgent` is required and has to name what was ruled out; `graph-auditor` treats a reason that does not survive contact with the repo's own tooling as HIGH. Every unnecessary park is a person waiting on work that could have been finished. **A block never arrives empty.** When a node does park, the graph spawns one advisor first, contracted to `DecisionV1` — so a bare "ask the operator" cannot satisfy it. The advisor is told to attack `whyNotAgent` before accepting it, and if the step turns out to be automatable its recommendation *is* that concrete path and the tooling it needs. Otherwise it returns the best available course of action with the alternatives it rejected and the strongest objection to each, including to the one it recommends. That lands in the provenance, the inbox row, and `/fluxpoint:status`, so what reaches a person is a recommendation with reasoning attached, not a hand-off. The advisor costs one agent call per parked node and is priced into `budget.maxNodes`; if the floor declines it, the block says so explicitly rather than quietly arriving bare. Mark those `actor: human` or `actor: third-party` with a `release` block: ```json { "id": "sign", "actor": "human", "contract": "HarnessCheckV1", "prompt": "The campaign has built an unsigned transaction body and needs 2-of-3 hardware signatures before it can submit. Attack the claim that this needs a person before accepting it, then recommend how the operator should proceed.", "release": { "instructions": "Sign with 2 of the 3 hardware keys and paste the cardano-cli output.", "whyNotAgent": "the keys live on hardware devices held by three people; an agent may never hold them. The unsigned body IS built headlessly by the previous node.", "proofContract": "HarnessCheckV1" }, "wake": { "check": "cardano-cli query tip --mainnet", "everyMinutes": 30 } } ``` The compiler emits **no spawn** for that node. It reads a release file; if there is none it reports `BLOCKED` with those instructions, sets the run `INCOMPLETE`, and keeps going. `instructions` is the entire message the blocked person gets, so write it for someone with no context — the compiler rejects an empty one, and rejects a `proofContract` that differs from the node's contract, because the node yields exactly what was pasted. Four consequences worth knowing: - **Blocked is inherited.** A node whose `after` is blocked is blocked too, not handed the `null` that reads like a failure. A dependent chain parks as a unit; unrelated branches finish. - **A parked run can never read COMPLETE.** It is `INCOMPLETE`, and the Evidence row names the blocked nodes. - **Releases are proof, not assent.** `/fluxpoint:release` validates what the operator pastes against `proofContract` and refuses an adjective. The campaign resumes on the strength of that file; one that resumes on recollection will eventually resume on a mistake. Never write a release on someone's behalf — a fabricated release is strictly worse than a stalled campaign, because the stall is visible. - **Nothing is waiting silently.** Every block, expired wake, and refused confirmation lands in `.claude/fluxpoint/inbox.jsonl`; `/fluxpoint:status` leads with it and SessionStart injects the count. `wake` parks a predicate rather than a person: `scripts/wake-check.sh`, driven by a Routine or by `loop.sh`, runs the due checks and reports which campaigns can resume. It deliberately does not resume them itself — re-invoking a graph spends budget and may sit upstream of an irreversible node, so a human or an explicitly configured Routine makes that call. In loop mode the same idea is a Plan marker: `- [~] — blockedOn: `, which the loop skips. Without it, step 1's "pick the first unchecked item" re-picks a human-blocked slice every iteration and a 72-hour wait spends the entire iteration budget in minutes. **This is not a scheduler, on purpose.** There is no ready-set, no topological sort, no `needs`/`priority`. Declaration order plus park-and-skip-dependents covers a largely serial critical path with independent slices hanging off it, which is what campaigns actually look like. A DAG scheduler is the right answer to a problem no graph here has had yet; build it when one does, not before. ## What a worktree is, and what it is not `mutates: true` compiles to `isolation: 'worktree'`. Three things about that are worth knowing before you depend on it, because each has cost a real campaign a wrong answer rather than an error: - **Every isolated node establishes the exact campaign base.** The runtime cuts the worktree. Its `worktree.baseRef` setting accepts `"head"` to start at the launching HEAD, or `"fresh"` (the default) for its fresh branch base. Set `{"worktree":{"baseRef":"head"}}` in `.claude/settings.local.json` when launching from campaign commits. A node given isolation has been observed reading a tree cut from the default branch rather than the campaign's, which makes a file an earlier node committed simply ABSENT — and nothing inside says so: `git status` is clean and a missing file looks like a missing file, so the node returns a confident, well-evidenced, wrong report. The graph therefore refuses to start without `args._base` (graph-run loads `git rev-parse HEAD` at launch), and the compiler injects a preamble ahead of every worktree-isolated node's own prompt: read HEAD and root, refuse the shared checkout, dirt and detached HEAD, and fast-forward a clean behind worktree with plain `git merge --ff-only `. The final HEAD must equal the base exactly; ahead, divergent and missing-object states stop without reset or detach. Schemas require the full `baseSeen` observed before the requested work, retained even when that work creates later commits; the script files a missing or different hash as `WRONG-BASE` and drops those findings, including restored results. Preflight's project/local `head`, `fresh/absent` or `unknown` setting report is advisory because managed/user settings and CLI overrides can differ. A downstream node that must read what an upstream node committed should still fetch or check out that branch by name rather than assuming it is there. - **A fan-out that measures declares `isolation: 'measure'`.** `parallel()` and `pipeline()` read as isolated units and, for pure reasoning, effectively are. The moment a node's output is a *measurement* of the tree — a build size, a byte delta, a benchmark — a shared working tree makes that number a function of what every sibling is doing, and the failure is silent: every node exits 0 with a confident figure. `measure` is not the runtime's worktree: the node's injected preamble has it cut a frozen `git archive HEAD` snapshot into a temp dir and work only there — cheaper than a worktree, immune to the sibling problem, and based on the campaign's own HEAD by construction. The compiler warns on a fan-out whose prompt looks like it builds or measures with no isolation declared. - **The shared tree is guarded, not trusted.** "Throw the branch away" in a prompt is prose to a model, not a cleanup contract; nothing evaluates it, and a gate that already passed was judging a tree that no longer exists. Every compiled graph brackets the run with a tree sentinel — a tiny schema-forced agent recording `git rev-parse HEAD` and `git status --porcelain` at campaign start, before every verdict-minting node (`independent` verifiers and `prove:` gates), and at campaign end. Mutators run in worktrees and measurers in snapshots, so the shared tree must be IDENTICAL at every checkpoint: any drift halts the campaign as `TREE-MOVED` with the dirt named, instead of advancing verdicts about a tree that is gone. The plugin's own writes are left out of the comparison: the state file (`WORK.md`, `LOOP.md`, the graph file itself), where the Stop hook appends its Evidence row whenever the orchestrator ends a turn, and everything under `.claude/`. The sentinel is an `agent()` call, so a resume replays its first reading rather than taking one; the launcher's fresh reading in `args._base` (HEAD and porcelain, taken at every launch by `/fluxpoint:graph-run`) is compared against it, and a resume on a tree that moved since the run it replays halts `TREE-MOVED` at launch instead of handing back cached verdicts about the old tree. The record rides out in the summary as `tree`. `treeGuard: false` in the IR turns it off, on the record. **Prove the harness in a worktree before you trust a `mutates` node.** Green in the primary checkout is not green under the isolation the campaign imposes: `git worktree add` checks out tracked files only, so a gitignored build artifact or an installed `node_modules` is absent, and a test that pins an absolute path is false there by construction. Run `git worktree add --detach` and `bash scripts/harness.sh --full` inside it once. Otherwise the campaign halts at its verification node blaming the implementer, which is both wrong and pointed at an innocent node — and the repair it invites is weakening the assertion that was right. ## Effects that cannot be undone `mutates: true` buys `isolation: 'worktree'`. That is real containment for a filesystem write and none whatsoever for a chain write, a published release, or a destructive migration — the same marker on both reads as protection it does not provide. Mark those `irreversible: true`. Three things follow, and the second is the one that matters: 1. The node refuses to fire unless a human named it in `confirm`. Naming the campaign is not naming the effect, so a blanket "yes" carries nothing along with it, and refusal is its own outcome (`CONFIRM-REQUIRED`), never a warning in a log. 2. **Resume stops double-firing.** Repair-one-node-and-resume is the recovery path this skill prescribes, and it is also the operation that mints twice: every node after the repair re-runs. Before each irreversible spawn the compiled graph checks a ledger keyed by campaign, node, and prompt hash; a hit restores the recorded result and logs `REPLAYED-FROM-LEDGER` instead of performing the effect. A replayed node is filed `REPLAYED`, never `OK` — a ceremony that did not happen must not read like one that did. A verified ledger hit occupies its original logical prefix slot without submitting the effect prompt to an agent. A source-known effect with a missing or changed ledger receipt refuses as `LEDGER-CONFLICT`. A prior key for the same campaign/node also refuses, including when a compiler upgrade changes a preamble. Carried `confirm` cannot rearm that identity. Give a genuinely new effect a new node or campaign identity and confirm it explicitly, while retaining the previous ledger history. **The ledger is local, not committed, and that bounds what it can promise.** It lives at `.claude/fluxpoint/irreversible.jsonl`, inside the directory `init` and `migrate` add to `.gitignore`, so a fresh clone starts with none: `--load` returns `{}` and exits 0, which the guard reads as "first run" and the effect fires again. Within one checkout the once-only property holds; across checkouts it does not, and no amount of care in the graph changes that. If an effect must be once-only for a ceremony that more than one machine can reach, the receipt has to outlive the working copy — a committed receipt log, or a lock the effect's own service holds. Do not read this ledger as that guarantee. 3. The gate must be *ordered before* the effect. The compiler requires an earlier `independent` node with a `haltWhen`, because a verifier that runs afterwards cannot un-mint an NFT. `haltWhen` compares one top-level field to a literal; it cannot reach into an array. A contract whose deciding fact lives only inside a list needs a top-level field bound to that list, or the gate reads the wrong thing. `RedTeamV1` carries one: `worstSeverity` and `worstSeverityRank` are bound to `findings[]` and to the verdict, and SHIP over a HIGH or CRITICAL finding is refused by the contract, re-derived by the compiled graph in code (a halt, before the next node), and re-derived again by `record-run.py` (`BLOCKED-REDTEAM`). So `verdict == 'BLOCK'` already stops every HIGH and CRITICAL; halt on `worstSeverityRank >= 2` to stop on a MEDIUM as well. The sandbox running the compiled graph has no filesystem, so the ledger row is written from the run summary afterwards — a crash between the effect landing and the run ending leaves no record, and the confirm gate is what stands in that window. Editing a ceremony's prompt changes its key and triggers the history conflict guard for that campaign/node. A different effect needs a distinct identity and an explicit confirmation. A human must name the node for each authorized effect. ## Choosing a verification tier By stakes, never by habit. `schema-only` for cheap mechanical output whose consumer re-reads the source anyway. `skeptic:1` for low-severity claims. `panel:3` for findings that will cost someone real time. `panel:5` only for CRITICAL. Panels are odd so majority is defined; refuters are prompted to *refute*, default to refuted when uncertain, re-read the underlying code themselves, and run at low effort — cheap skeptics beat expensive believers. The expensive lesson: verification fan-out dominates cost. A three-finder review with `panel:3` on every finding is ~30 agent calls. Tier down and the same campaign costs a fraction with the same guarantees where they matter. ## Never trust a self-report A node that writes to the tree may not certify its own work. Mark it `mutates: true` (which also worktree-isolates it) and give the gate to a node with `independent: true`, `verifies: ""`, and a `haltWhen` on the real exit code. That node checks out the branch and re-runs the harness itself. This is a compiler-enforced invariant because it shipped as a bug once: the graph trusts exit codes it re-derived, not adjectives it was told. Independence of *context* is what the compiler can enforce; fidelity of *execution* it cannot, because the verifier still types the exit code into its contract by hand. So declare the commands that decide things in `.fluxpoint-gates.json`, and a PostToolUse hook records the runtime's own exit for every one of those runs. `record-run.py` then cross-checks each claimed gate exit against that log and reports `ATTESTED`, `UNATTESTED`, or `MISMATCH`. Three things to know: only the exact declared invocation is attested (a pipe or a trailing `|| true` reports a different exit and is credited to nothing); an absent attestation is `UNATTESTED`, never a failure — an executor that does not route through the Bash tool must not read as guilt; and PostToolUse has been measured not to fire when a Bash call fails, so the log binds passes and may hold no reds at all. The third bounds the second: a node claiming exit 0 with no row should have left one, and `record-run.py` lists exactly those separately as the unverified ones. **`verify: "prove:"` turns that observation into enforcement.** The node returns `ExecutionV1` — `{gate, exit, attestId}` — and cites the attestation its run produced. The gate name is resolved against the manifest *at compile time*, so a tier naming nothing refuses to compile; that is the `verify: harness` lesson, which once priced a tier into the budget and emitted no check at all. At record time a cited attestation that does not exist, attests a different gate, or recorded a different exit files the whole run `TAMPERED-EXECUTION` — a campaign does not get to report clean when its own verification says its exit codes are not what happened. A `prove:` node citing nothing is `INCOMPLETE` instead: the declared verification did not run, which is not the same accusation. A citation is also bound to the run that cites it. The compiled graph refuses to start without the launch stamp `/fluxpoint:graph-run` passes in `args._launch` (`attest.py --stamp`: a `since` and a run nonce), tells each `prove:` node to run its gate as `FPL_ATTEST_NONCE= `, and carries the stamp into the summary. A cited row minted before `since`, by a run with another nonce, on a different commit than the one the tree guard read, or already backing another node is `STALE`, which files the run `INCOMPLETE`. Without that, any earlier row of the same gate and exit — another campaign's, weeks old, or an overlapping run's on the same commit — passed as this node's execution. A resume reuses the stamp of the run it resumes, since its replayed citations carry the original nonce. Two witnesses cover what one tool call cannot. A gate longer than the 600-second cap runs through `attest.py --run ` (the manifest's command, resolved from the gate name) started in the background, and `attest.py --await ` collects it in bounded foreground slices; the runner mints the row itself when the command ends, including a red exit the hook never sees. CI's own statuses on a commit are `prove:ci`: a top-level `ci` section in the manifest names the forge, contexts and pinned `witness.statusCreators` / `witness.checkRunApps` identities. The harness refuses a creator matching its own gh login. Every citation re-reads the forge at record time; the local log is only a pointer. A latest pinned success can green; a historical failure from any writer is red. See `references/forge-evidence.md`. For CI observation, `attest.py --ci --pr [--wait]` (or `--ref `) has the forge name the commit and mints a row from its statuses (every page of them, held to the total the forge reports), and pending, unreported or partially listed contexts mint nothing. A `prove:ci` claim returns that `sha` and must match the row; a row for a sha the node chose (`--sha`) cannot back it, since CI on any older green commit would pass. Pin the merge after the gate to the same commit with `gh pr merge --match-head-commit `. Independent multi-step nodes of any object contract can declare `cites: [gate,...]`. The compiler adds structured `citations` to their result schema, starts the local lane and asks for CI reuse in bounded waits. A forge green can cancel local work; a completed red from either source dominates. Reuse coverage and a full `ci.packetSha` base are pinned only in the manifest, never in node arguments. Local hook/wrapper rows carry `dirty`; a dirty or legacy unbound row is refused. Each row names its witness — `hook`, `wrapper` or `forge`. A gate run from a linked worktree of the campaign branch is attested into the project's log and bound to the project's HEAD, whichever witness saw it: the launch stamp carries the project `root` and the prove preamble passes it as `--root` (under Claude Code, `CLAUDE_PROJECT_DIR` names it too). The gate runs at the project's place in the checkout the caller is in, never in a subdirectory its shell last entered, and the tree it ran on is recorded beside it as `treeSha`. A gate declared as `cd && ...` matches only a run in that directory. `attest.py --last --nonce ` prints the attestId a node cites. A manifest the witness refuses (a gate named `ci`, an unknown forge) fails every `prove:` node at compile time, and a run under one is filed `INCOMPLETE`. Nodes that merely happen to match a declared gate stay observed rather than enforced. Opting in is what earns the stricter reading, and a check that starts by failing runs is a check people switch off. And where a repo declares gates, an `irreversible` node's mandatory earlier guard **must** use `prove:`. The ordering invariant — gate before effect — was always sound in structure and hollow in fidelity while the guard typed its own exit code. An effect nobody can undo may not rest on a number the node that ran it wrote by hand. `prove:ci` and a `--run`/`--await` gate qualify, so a merge guarded by CI's statuses or a long suite no longer has to park on a person for want of a gate that fits one call. ## Canonical shapes - **Fan-out/verify** (`templates/WORK.md`): dimensions → finders → per-finding refuter panel. The default review campaign. - **Council → build → gate** (`templates/WORK.feature.md`): independent keyed designs from stated angles → a `decides` node with `verify: "judges:3"` compares them side by side (`{{prev}}`, a justified barrier) → a mutator implements the elected candidate's original `{{winner}}` → an independent node re-runs the harness → the proof-auditor judges whether verification got weaker (`ProofV1`, halting on WEAKENED; NOT-APPLICABLE when the diff carries no proof surface) → red-team closes. - **Verified slice** (`templates/WORK.verified.md`, opt-in): program synthesis and proof synthesis as separate mutators. The `builder` writes the logic and may not touch a statement; the `prover` (`agents/prover.md`, contracted to `SliceV1` because its output is edits) writes the invariants, lemmas, decreases clauses and property tests, and may add obligations but never remove one. Two mutators need two independent gates, since `verifies` names one node; the prover's gate is a `prove:harness` node so the attested exit, never a typed one, stands behind the proofs. Prototype it on a repo with a real proof surface before promoting the split into the feature template. - **Advisor–orchestrator**: a planner node emits the work-list as a typed contract; worker nodes consume it. The planner never grades its own plan. - **Zone defense** (org graph): stable `agents/*.md` roles own domains — `red-team-reviewer` owns the adversarial pass, `proof-auditor` owns the verification review, `prover` owns proof synthesis — referenced by `agentType`, not re-prompted inline. An agent file may declare the contract it answers in (`contract: RedTeamV1` or `contract: ProofV1` in its frontmatter, or `contract: prose` for one that reports rather than returns a schema, as `graph-auditor` does: it reviews the IR itself, and a node running it inside the graph it audits would be circular). Binding a node to a resolved agent whose declared contract is not the node's is rejected at compile time: the name being real is not evidence the answer fits, and that mismatch otherwise surfaces as a dead gate late in the run. An `agentType` the compiler cannot resolve is only ever a **warning** — plugins and built-in types are outside its view, so refusing would reject valid IR. - **Loop-until-dry** (`templates/WORK.discovery.md`): a `repeat` block on a finder turns fixed fan-out into unknown-size discovery. Use it when "how many are there" is the question rather than an input — audits, sweeps, reviews whose coverage the dry rule decides rather than a fixed pass count. The prompt asks for findings; the budget floors and the dry rule enforce thoroughness, so the prompt never has to exhort it. - **Repair loop** (`templates/WORK.repair.md`): after a BLOCK, put `repeat: {"until": "verdict == 'SHIP'", "maxRounds": 3, "integrate": ["merge", "review"]}` on an independent checker that `verifies` and consumes a `mutates` fixer. Both use the same static `foreach` list with unique lowercase keys. The checker must use `isolation: 'measure'`: it applies the current patch onto a fresh `git archive HEAD` snapshot and independently attacks it. Passed slices leave the loop; failed checks reach the next fixer as `{{lastCheck}}`. The cap files remaining slices INCOMPLETE. FixCheckV1 names each failed mechanism in `defectClass`; `classBy: ["defectClass"]` and `reframeAfter: 2` force the next round to state the authoritative invariant and adopt/refute a class rewire with a measurement, or remove the mechanism. The fixer returns structured reframe evidence and the independent checker reruns its measurement; a third narrow patch is REFRAME-REQUIRED before integration. This is a feedback loop over independent findings, rather than a retry on the fixer's own exit. `{{roundDir}}` gives each item and round an absolute, versioned output directory below `.claude/fluxpoint/rounds//`; use it for scratch and complete patches and never overwrite older outputs. The optional integration pair runs once per round over all passing patches. Its merge lists top-level definitions across patches and its independent measure review checks the merged tree, including composition; a failed merged review reopens passing slices with that feedback. The compiler prices both workers and integration at every allowed round and emits tree checks and the normal runtime ceiling. Only one repair loop belongs in a graph, so every round directory has one owner. `graph-run` stamps `_launch` even without a prove tier and records the run normally. - **Pipeline of loops**: each mutating node works one loop slice — TDD, `--changed` green per edit, `--full` before returning. The graph sequences slices; it never replaces the gate. - **Escalation ladder** (approximated): most items are cheap to judge and a few deserve expensive reasoning. Build it as two stages sharing an edge — a `skeptic:1` first pass, then a downstream node (or a second campaign seeded by `memory`) that re-judges only the survivors at `panel:3`/high `effort` — with a `reduce` node between them cutting to the items worth escalating. Runtime uncertainty-routing (an item's own confidence deciding its tier mid-run) is deliberately not an IR construct yet; see ROADMAP before building it ad hoc. ## Inputs, failure, budget Inputs are normalized by the generated code (object, JSON string, or bare string) and logged; required args throw. Declare optional inputs in `argDefaults` so a default is a stated decision, not a silent substitution — a wrong-target run looks exactly like a successful one, which makes it the most expensive failure a graph has. Failure is local: `onRed: drop+log` drops one item and says so; `halt` stops the campaign. Dead nodes land in provenance with a reason. Budget is enforced twice, and neither check is advisory. At compile time `budget.maxNodes` is a hard ceiling on planned agent calls, priced at the worst case including discovery rounds. At run time two floors apply: `verifyFloorTokens` stops verification fan-out (claims left unchecked are logged UNVERIFIED) and `nodeFloorTokens` stops *work* fan-out before a node or another discovery round starts — recorded as `SKIPPED` in provenance and carried into the Evidence row as incomplete coverage. A campaign that ran out of budget says so; it never reads as a clean sweep. **Calls are the wrong unit for the bill.** Spend is dominated by whether each call's prefix was a cache read or a cold prefill, and by how much the model deliberated — so twenty medium-effort nodes sharing a warm prefix can cost less than six at `max` running cold, and raising a node's effort was free as far as `maxNodes` could see. The compiler therefore estimates every graph in cold-input-token equivalents: a shared prefix per call (priced at a tenth when warm), work tokens scaled by effort (`low` 0.5×, `medium` 1×, `high` 1.8×, `xhigh` 2.5×, `max` 3.5×), the whole call weighted by the model's price, refuters and tree sentinels included. The text the author writes is priced too, one token per UTF-8 byte like the packet: each node's prompt with its `{{A.}}` defaults and `{{item}}` values expanded as the script renders them (text as itself, a list or an object as JSON), and a park's release instructions in its advisor call. A fan-out's workers read their shared prompt prefix from cache after the first; a different node's prompt is never discounted, because the cache has not seen it. `{{prev}}`, `{{decisions.*}}`, `{{seen}}` and a launch argument with no `argDefault` are sized only at run time; `--check` and the compiled header name each one and the nodes that use it as `unpriced`, so the number never reads as complete when it is not. So a prompt that grows between audit rounds moves the estimate, and the ceiling set from it can refuse the growth. The constants are stated assumptions in `compile-graph.py`, and `metrics.py` folds the estimate against the runtime's own `spent` per run so they get corrected by evidence rather than argued. `budget.maxEstimatedTokens` is the ceiling on that estimate and is refused at compile time like `maxNodes`; the estimate itself prints on every `--check`, in the compiled script's header, and in the run summary as `estimate` beside `spent`, with a per-node `profile` (effort, model, calls, tokens) so an effort setting has a number to answer for. **The prompt cache is a budget decision, declared as one.** A forked call shares its parent's cache only on a byte-identical prefix at the same model and the same effort, and the default time-to-live is five minutes from the request start — which a parent blocking on a `panel:5`, a `foreach`, or a discovery round outlives as a matter of course, and a park outlives by days. So the IR declares what it needs: `budget.cacheTtl` (`"5m"`, the default, or `"1h"`). Under `1h` the estimate prices consecutive calls at the same `(model, effort)` as warm; under `5m` only siblings that dispatch together are, and every sequential hop is a cold prefill. The runtime sets the TTL per session, never per call, so the declaration is a requirement on the session that runs the graph — `/fluxpoint:graph-run` names it in preflight — and the compiled script logs it at launch. A node that waits on a park (through `after` or a reduce's `from`) runs after the release, in a later run: a cold start whatever the TTL, and priced as one. A node that does not wait on it runs on in the same run and keeps what that run warmed. Every shipped template declares `1h`. **An effort transition is a cold prefill.** The compiler warns on every consecutive pair of nodes whose `(model, effort)` changes to a key the run has not warmed yet — the hops the estimate charges cold — and on an inline `effort` equal to what the role or defaults already give (it changes nothing and reads as a decision). A return to a key an earlier call already warmed is a cache read and is not reported. A park ends the relation for what waits on it: the first call after the release is priced cold whatever its key, so a change placed there costs nothing extra and is not reported; a node that does not wait on the park still runs right after the one before it and is judged against it. Where a change is justified, put it where the cache is cold anyway — on a node that waits on a park — and put same-effort work together; `graph-audit` judges whether the bump buys anything, because the compiler cannot know a task's shape. Whether a role's effort is right at all is a separate question. The feature template's role settings are measured (DESIGN-NOTES, "Measured on 2026-10-29"): builder and red-team run at `medium`, architect at `medium`. ## Discovery loops A fixed fan-out finds what one pass happens to find. When the size of the work is unknown, add `repeat` to the finder: ```json "repeat": { "untilDryRounds": 2, "maxRounds": 6, "dedupeBy": ["file", "line", "claim"], "classBy": ["defectClass"], "reframeAfter": 2 } ``` Rounds re-run until `untilDryRounds` consecutive rounds surface nothing new, bounded by `maxRounds`. Use `{{seen}}` in the prompt so each round is told what earlier rounds found and spends itself on new ground. `classBy` names fields of the `verifyOver` items and must agree with `memory.classBy` when both are declared. A class producing a new surviving variant in `reframeAfter` consecutive rounds (default 2) stops the sweep with `RECURRING-CLASS` provenance and `{class, rounds, keys}` records. Under `onRed: halt` that is the campaign outcome; otherwise the sweep stops and the campaign remains INCOMPLETE. Refuted and unresolved claims never count as survivors. Reframe the authoritative invariant instead of starting a third narrow patch. The shipped discovery template enables this rule. **`maxRounds` is a backstop, not a thoroughness dial.** The dry rule is what should end a healthy sweep; the ceiling exists for the loop that never converges. Set it with headroom — `untilDryRounds + 3` or more — or the ceiling ends every run and every run reports INCOMPLETE, which trains readers to ignore the word. The compiler warns when the headroom is under two rounds. A generous ceiling is cheap, because it prices risk rather than spend: `budget.maxNodes` must cover the worst case (rounds are priced in), but the loop exits the moment it goes dry, so rounds that never run cost nothing. Raising `maxRounds` from 4 to 6 raises the declared ceiling by 50% and typical spend by roughly zero. Let the token floors, not the round count, be what actually caps cost. ## Sweeps that compound A `repeat` block makes one sweep exhaustive; it does nothing for the next one. Without memory, run two re-finds everything run one found, and pays a fresh panel to reach verdicts that already exist — which is where the "verification fan-out dominates cost" lesson actually bites. `memory` closes that loop: ```json "memory": { "seed": "defect-sweep", "emit": "defect-sweep", "classBy": ["defectClass"] } ``` `emit` files every judged item as a `LessonV1` row in `.claude/fluxpoint/memory.jsonl` — survivors *and* the panel's kills with the objection that killed them, which is the half `verifyItems` used to discard. `seed` hands the next run that frontier. Rows are written by `record-run.py` from the run's own summary, never by an agent, and a row whose `provenance.runId` names no recorded run is refused — the opening a fabricated summary would use to plant durable knowledge. The compiler rejects: a `memory` block declaring neither side; `seed` without `repeat` (the seed feeds a sweep's seen-list); `emit` without a verification tier (filing unjudged output would promote a well-formed guess to institutional knowledge) or without `verifyOver`, or on a contract whose items carry no `claim`; `memory.key` alongside `repeat.dedupeBy`, since two spellings of one identity is how they drift apart; and `memory.classBy` that is not a non-empty list of real item fields, or that sits on a node emitting nothing. **A lesson learned twice is not a lesson. It is a missing gate.** Every filed row carries `arrivals`, the distinct runs that have filed that identity, and it is carried onto the superseding row rather than reset by it: latest-state-wins is right for the claim and was silently wrong for the count, so a thing learned a third time left a store that looked exactly as it had the first time. `memory.classBy` declares a second, coarser identity — the defect's SHAPE — because `file`, `line` and `claim` between them describe only where a defect is and how it was worded, so one shape found in three places files three lessons and no join over those fields recovers it. The class is declared by the finder and never inferred; a class guessed from claim similarity collapses unrelated lessons, and a gate that fires on everything is one people learn to skim. At two arrivals `recurrence-guard.py` stops accepting a restated claim as an answer and demands a command in `.fluxpoint-recurrence.json` whose real exit code is the lesson's verdict, run by `harness.sh --full` and reported at session start until it passes. **A seed is advisory and never suppressive.** It reaches the finder's prompt; it never enters the dedup set. Seeding the dedup set would silently drop a re-found item — and a finding that comes back is evidence the lesson went stale, exactly the regression a sweep is run to catch. So a re-found item is judged again on its merits, and the cost saving comes from a finder that knows where the frontier was, not from a loop that refuses to look. Two rules the compiler enforces because getting them wrong is subtle: - **Dedup against everything seen, not everything confirmed.** Items enter the seen-set before verification. Dedup against survivors instead and every judge-rejected finding reappears next round — the loop never converges and the panel re-judges the same rejects forever. - **Hitting `maxRounds` is not exhaustion.** Ending on the ceiling while still finding new items is logged `discovery INCOMPLETE, not exhausted`. A sweep that stopped early and a sweep that finished are different claims and never get blurred into one. Seeds are loaded ranked, not raw: `scripts/recall.py --format seedmap` serves the same `{tag: {keys, killed}}` shape as `memory.py --load`, but ordered by a hybrid of BM25, the memory graph (provenance, kill events, touched files, campaign membership walked with personalized PageRank), and — when an embedder key is present — semantic similarity to the campaign line. Relevance decides what reaches the prompt *first* under a budget; it never decides what gets judged. Lessons whose tag never matches the sweep's still surface at SessionStart, ranked against the work file, so knowledge filed under one campaign reaches the next one without anyone guessing the tag. The killed half has its own channel. Declaring ```json "memory": { "seed": "defect-sweep", "emit": "defect-sweep", "priors": true } ``` hands every refuter the node spawns the seed tag's killed claims with the objections that killed them — capped at 5 items of 400 chars, framed explicitly as priors the panel may overturn. The finder's prompt never carries them: priors are addressed to judges, whose job is to not re-derive an argument the store already holds, not to the search, whose job is to look everywhere. The compiler rejects `priors` without a `seed` (nothing to load) or without a verification tier (nobody to tell). ## Running, repairing, evidence `/fluxpoint:graph-run` compiles, runs, and records. Watch with `/workflows`; never poll with sleep. Repair is targeted: fix the one red node in the IR, then use `resume.py` to carry the recorded inputs, apply only named changes, recompile and print the per-node rerun plan. The runtime can replay an unchanged prefix; opted-in independent nodes can also replay after fresh tree and branch checks, while a live ancestor forces its descendants live. Start fresh when the judged tree moved or the old run lacks the required source provenance. Before diagnosing an empty result, read `journal.jsonl`. There is deliberately no per-node retry knob: recovery is memoized resume plus the once-only ledger, because an in-run retry loop is a second failure policy hiding inside the first. Two caches share a vocabulary and are different mechanisms. **Memoized resume** is the executor replaying completed `agent()` calls whose `(prompt, opts)` did not change — a journal lookup, valid for as long as the run's journal exists. The **prompt cache** is the model API reusing the prefill of a byte-identical prompt prefix on the same model at the same effort — a KV cache with a time-to-live measured in minutes. This skill says "memoized resume" for the first and "prompt cache" or "warm prefix" for the second; the older wording that used one phrase for both is retired. ## Observe the graph, not the chat Tune campaigns against numbers, not transcripts. Every run already records them: the summary carries `spawned` (agent calls that really went out) and `declined` (calls the node ceiling refused) against `planned` (the compile-time worst case) and `spent` (the runtime's own token meter); discovery rounds carry structured found/fresh/kept tallies with per-worker unique-new counts keyed by worker identity (fan-out efficiency: a worker at zero is width without coverage, and a dead worker reads null, never a shifted neighbor's count); reduces carry before/after (compression). `scripts/metrics.py` folds `runs/*.json`, `memory.jsonl`, and `inbox.jsonl` into per-campaign rates — node death and skip rates, sweep endings split four ways (dry rule, ceiling, budget-truncated, halted — only the dry rule is convergence), panel kill rate, inbox pressure — and `/fluxpoint:status` reports the trend block. A kill rate near 0% means the panels may be decoration; near 100% means the finders are badly scoped. One number is knowingly absent: per-node wall-clock (the executor forbids `Date` in workflow scripts to keep resume deterministic), so the serialization cost of an undeclared edge is caught at compile time by a warning, not measured at run time — the compiler flags adjacent top-level nodes that declare no dependency. Graph green is not done. The campaign still exits through the loop-engineering ship pipeline — `harness.sh --full`, red-team `VERDICT: SHIP`, the Merge policy in WORK.md — and the Stop-hook DoD gate keeps final authority. ## Where the Workflow tool is unavailable Same IR, same compiled script: run its nodes as parallel subagent calls with the same contracts, sequencing stages yourself, then record with `--executor degraded-subagents`. Do not silently downgrade — the Evidence row says which executor ran the graph. Halt diagnostics use the same validated token renderer as prompts. `haltReason` supports `{{A.field}}`, `{{campaign}}`, honored decisions and `{{prev}}` / `{{prev.field}}`; at halt time `prev` is the result that tripped the condition (the first tripping result for a fan-out). Item/round tokens are rejected there because the halt runs after the worker scope ends. `--check` compiles in memory and reports UTF-8 script bytes and headroom against the Workflow launch limit (524,288 bytes, configurable with `--max-script-bytes`); below 16 KiB headroom warns. Repeat the check with `--check-script ` immediately before launch. A council's `judges:N` tier chooses among candidates; `panel:N` refutes claims. Use a positive odd seat count and `contract: "DecisionV1"` plus `decides`. Its predecessor must be an unverified `foreach` with unique lowercase candidate keys; `none` is reserved. The compiler attaches the source list's key to every returned object before filtering dead seats. It gives each judge a JudgmentV1 schema narrowed to the surviving keys plus `none`. Each ballot ranks every key exactly once and includes a rationale of at least 40 characters and a `strongest_objection` object keyed by every choice, each objection at least 20 characters. A prose winner, unknown key, malformed ranking or missing objection casts no vote. A winner needs more than half of all N seats, including dead seats in the denominator. A split or a majority for `none` halts as NO-MAJORITY and records the tally; an empty candidate set halts as NO-CANDIDATES. There is no first-candidate fallback. The compiler builds the DecisionV1 from the tally and judges' actual objections, with `chosen` equal to the winning key. The council decision is reversible (`frozen_by: "none"`) and compares no prior ruling (`overturned_prior: false`); a separate irreversible gate retains its normal authority requirements. `{{prev}}` carries that decision to its direct consumer, while `{{winner}}` carries the original elected candidate object, including plan, files and risks. The `decides`/`honors` record remains available to later nodes. Legacy schema-only decisions keep their existing free-text representation and import behavior.