Mechanism 1 of 4 · Spec → done

An unanswered question is not a resolved one.

The absence this makes loud: a question nobody answered.

A spec that was never sharpened doesn't produce a warning — it produces work that looks exactly like work against a good spec, right up until you deliver the wrong thing. So Graphban won't let a spec reach "approved" while a dimension of it is still blank.

The grill

Four dimensions, graded — and the grade is not yours to set.

Grilling is a conversation that sharpens a PRD before anyone builds it: unstated assumptions, scope boundaries, failure modes. The server owns it as state, not the client as a chat log — so the record of what was asked and what was actually answered survives the session.

Every grill is graded against the same four dimensions. Each comes back resolved, deferred, or unanswered. Grading is a separate call from the conversation, run at temperature zero, and when the model is unavailable it falls back to a deterministic stub rather than silently passing.

DimensionThe question it refuses to leave blank
scope_edgesWhat is explicitly out of scope for the first version?
failure_modesWhat happens on the failure path — bad input, missing data, timeout?
contractsWhat is the exact shape of the inputs and outputs at the boundary?
open_decisionsWhich decisions are still open, and which need a prototype to settle?
Four grill dimensions each graded resolved, deferred or unanswered. Three pass the gate; the unanswered one is stopped, and the approval call is refused. resolved deferred unanswered scope_edges resolved failure_modes deferred contracts resolved open_decisions the gate update_prd(status:"approved") ApprovalNotEarned still unanswered: open_decisions answer it — or defer it explicitly — and the status flips by itself.
Approval is a property of the grid, and the exception is the grid speaking. Note that deferred passes. Deferring is a decision on the record; the failure this catches is an implicit non-answer being counted as an answer.

Earned, not set

update_prd(status="approved") is a request, not a switch.

update_prd(prd_id, body?, status?) refuses status="approved" — ApprovalNotEarned: "approved is reached by finishing the grill, not set directly."

The refusal lives in the service layer, not in a route handler, so the REST API, the web UI and the MCP surface are all covered by one check. That placement is the whole point: this call is otherwise exactly how an agent would freeze an intent baseline that nobody had read.

Two more guards sit behind it. A verdict must cite a grill turn that actually resolves the dimension it claims — a citation that doesn't dereference is rejected as malformed. And if the grading standard itself is later found broken, it retroactively invalidates the approvals it granted, because an approval is only as good as the process that granted it.

Zero answers never grades as approved

A grill with no recorded answers is incomplete whatever any model says about it. Without that floor, an empty conversation grades straight through — which is the failure mode this entire mechanism exists to prevent, arriving through the mechanism itself.

The baseline

Approval freezes a version. That version is what the tickets mean.

The moment a PRD earns approval, its body is frozen as a baseline version carrying the per-dimension grill outcomes — so a deferral is visible on the baseline itself, not just in a transcript. Re-approving an unchanged spec is idempotent: it never mints a second "original intent".

decompose_prd then creates one work item per uncovered ## section. The section is the atom of intent, and that choice buys rename detection for free — retitle a section and the work linked under it keeps its identity, so a rename can never manufacture a false absence.

The intent hold

You cannot miss the hold, because it is not a notification.

An agent claims an item, then the spec moves underneath it. Nothing about the item changes. The classic fix is to send a notice — which works exactly as well as everyone remembering to read their notices.

An item claimed against baseline v1 keeps working while the governing baseline moves to v2; the mismatch is derived and returned on every read. + new section v1 v2 supersedes_id GRPH-380 baseline_at_claim = v1 intent_hold: claimed v1 < governing v2 names which sections moved, and whether yours did derived at read time from two stored values — so there is no state to forget to update.
The hold is derived at read time from two stored values, so there is nothing to forget to update and nothing to miss. It rides on every item read — claim, heartbeat, update, search. There is no way to work on an item without reading one.

Rebaselining

A rebaseline is a new link in a chain, not an edit.

When intent genuinely moves, you ask for a new baseline in your own words. The request does not approve anything — it re-opens the grill, and the existing baseline keeps governing until a new one is earned on its own answers.

Verdicts

A verdict cites, and the citation is dereferenced.

Judging whether a section was actually delivered is a job for something holding the repo — so Graphban doesn't guess. It takes a verdict from an agent that does, and then checks the agent's homework by resolving what it cited.

A verdict with three citations; two resolve to real targets, the third points into nothing and is stopped, invalidating the verdict. submit_verdict code: services/fleet.py intent: § Roles evidence: GRPH-380 code_nodes — resolves baseline § Roles — resolves nothing here citing nothing, or citing something that does not resolve, is rejected as malformed.
A verdict cannot cite something that isn't there — so "I checked" becomes a claim the system can dereference. An absence finding cites the baseline section it says was never delivered, which is why absence is citable rather than silent.

Signing off work you claimed yourself is flagged, not refused — and the separation check has three states, not two: independent, not-independent, and unverifiable. That third state exists because "we have no record of who did the work" must never render as a clean pass.

Completeness

completeness will never tell you it is complete.

Classifying the work that exists can only ever find drift and stowaway scope. It can never surface work that was never done — and "is this PRD finished" is entirely a question about what is missing.

A bar split into delivered, undelivered and absent segments, with a struck-out "100% complete" label above it. 100% complete delivered undelivered absent linked work is done specced, tracked, not built not in the spec at all a single green number is precisely what this must never render.
undelivered is a queue. absent is a hole in the spec. A tool that renders them the same colour is the tool that taught you silence means success.

Four things it deliberately refuses to do:

Closing

You close a PRD by deciding, not by finishing.

Closing is terminal and irreversible — no edit, no reopen, no rebaseline afterwards. And it is gated on disposition, never on delivery.

close_prd(prd_id, dispositions[]) refuses unless every section with nothing delivered appears exactly once — as promoted to an item or successor PRD, or deferred with a reason.

You are allowed to close a PRD having built four of nine sections. You are not allowed to close one without saying what happened to the other five. The close report then measures against the original intent, not the latest rebaseline — so a spec that quietly shrank to match what got built still reads as a spec that shrank.

← previousMemory & the learning loop All four mechanisms ↑ next →The fleet

What's new

Every entry is work that merged. The full history is on GitHub.