03 · Direction — rules, plan, checks¶
← 02 The three-beat loop, and what is disposable · Contents · Next: 04 Build — red to green, inside scope →
Why Direction is first¶
Direction is the steering; Build is the engine. You do not start the engine until the wheel is set. In one beat the AI authors a single node — its RULES, its PLAN, and its CHECKS — and runs the checks red. A person freezes it, once, and that freeze is the only human decision in the whole task.
Everything vague here does not stay vague: it becomes a concrete wrong guess in the code, discovered late. The cheapest moment to remove an ambiguity is now, in a sentence, before anything depends on it. There is a diagnostic value too — if you cannot state the rules, you do not yet understand the feature well enough to build it. The inability to specify is information, not an obstacle to push past.
How the node gets authored¶
add new Task <slug> scaffolds the node file with a ## CARD (its goal:, a one-line why:, the current beat) and empty ## RULES / ## PLAN / ## CHECKS sections. There is no author verb — you fill those sections by editing the node directly. The engine records; it never writes the method for you.
Before drafting, ground the real code. Gather the actual files, symbols, signatures, and conventions the task touches into a lean grounding map, and surface the anchors the contract will cite. Grounding is AI-owned and adds no approval; it aims the whole node at reality instead of assumption.
The node is not dictated by one side. It is co-specified in three moves:
- Diverge — brainstorm by both. Before drafting, the AI surfaces the decision space: the two or three genuine ways to frame the feature, and the open questions it would otherwise resolve by guessing. You react — add, kill, redirect. This lives in the conversation, not in a new document.
- Converge — the AI drafts, and ranks its own uncertainty. The AI authors the whole node in one silent draft, then ranks where its confidence is lowest. It does not hand you a flat list of equal-looking assumptions to nod through; it tells you where it is most likely wrong, and what that would cost.
- Validate — you decide, with the AI's advice. You read the ranked uncertainty first, then confirm, correct, or send it back. Your approval is real because your attention was aimed.
Compose the whole node in one draft, then present it for the one approval, lowest-confidence-first.
RULES — what it must do, and refuse¶
The RULES section is four short lists plus the one thing most likely to be wrong.
- Must — the behaviors the feature is required to perform.
- Reject — the inputs or situations it must refuse, each paired with a named error code.
- After — the state that is true once it succeeds (what changed).
- The assumptions — recorded in their own
## ASSUMPTIONSsection: what the request did not say, the reading you took, and what it costs if that reading is wrong. A node with genuinely nothing uncertain still names its single biggest risk — the AI never claims a blank mind.
Sweep it; don't free-associate. Author gives: first — the S<n> surfaces this node publishes — then tag each assumption line with one dimension and the surfaces it covers: - A<n> [who] covers: S1, S3 · …. The dimensions are who (identity · authority · scope), which (inclusion · visibility), when (boundaries · timing), absent (missing values · defaults), order (sequencing · ties) and experience (audience · difficulty). Take each surface and ask all six. The last one is the only one that is not about correctness: who asks whose data it is and which caller may act, experience asks who receives the output and what would make it hard for them — answer one and the other is still open. Retire one that cannot apply with [<dim>] n/a · <why> — recording a considered no is the work; what is refused is silence.
freeze refuses while the slot is template, gives: is unauthored, or any (dimension, surface) pair is unswept — and it names the pairs. add todo counts them down while you author. Both halves were learned the hard way: the instruction to record assumptions existed before the section did and was skipped because skipping cost nothing — then, once the section existed, three live runs each wrote five to seven real assumptions and all three still shipped the same silent decision, because nothing asked whether the list was complete. Free-association follows the spec's own emphasis, and the dimension nobody wrote a sentence about is exactly the one that ships as a guess.
depth: quick skips the sweep — depth tunes ceremony, never the authority floor.
Declaring a reading checkable — the · probe: opt-in. An assumption line is a priced
guess on the record; most should stay exactly that. But when the shipped behavior can
show whether the reading was right, append · probe: <what shipped behavior must show>
to the line. That marks the A<n> id as a binding referent: some check must cite it in
its covers: and report passing, or the gate holds the PASS — the same binding rules
get. Opt-in on purpose: the engine enforces exactly what you declared checkable, and an
unmarked line stays a declared, priced guess.
Naming the errors matters. "Reject bad amounts" is an instruction to guess; amount <= 0 -> "amount_invalid" is a rule that produces a testable check and a defined contract response.
Give each Must a stable M<n>: ID; a Reject's own error code already is its ID, referenced later as R:<code>. These IDs are what the checks bind to.
▶ Example — RULES¶
Feature: Transfer money between my own accounts
Framings weighed: synchronous single-currency transfer (chosen) · queued transfer · multi-currency with FX
Must:
M1: move an amount from one of my accounts to another of mine
M2: source and destination are different accounts
M3: source has enough balance
Reject:
amount <= 0 -> "amount_invalid"
source == destination -> "same_account"
balance < amount -> "insufficient_funds"
account not mine -> "forbidden"
After:
- source balance -= amount, destination balance += amount
⚠ same currency only (no FX) in v1 — lowest confidence because the ticket never said;
if wrong: the whole amount/rounding model changes and this contract is wrong
The Framings weighed: line shows what was considered and dropped, so the chosen shape is a decision, not a default. The ⚠ line is the one the stakeholder reads first: the assumption most likely to be wrong and most expensive to get wrong.
PLAN — the contract, the strategy, the scope¶
PLAN turns the grounded code into the shape neighbours can depend on. It carries three things:
- The contract shape — the interface this node publishes: the endpoints, functions, or messages, their request and response shapes, the names (drawn from the project glossary so one concept has one name everywhere), and a contracted response for every error code the rules reject. This shape becomes the node's frozen
gives:— the interface other nodes depend on and the build is not allowed to disturb. - The build strategy — how the code will satisfy the rules, and any place where correctness depends on more than shape (a transaction boundary, an ordering guarantee).
- The
scope:tokens — the paths this node may touch. Scope is also the freshness set: the files a verify receipt must observe unchanged. Nothing outside scope is edited during Build.
The frozen contract is the decision point¶
The AI is allowed to write and rewrite code quickly. That is only safe if there is a stable surface the rest of the system depends on and the AI is not allowed to move. The frozen gives: is that surface. Below it, the code is disposable and can be regenerated freely; above it, nothing breaks, because the shape it depends on does not move. Freezing is not bureaucracy — it is the precondition for letting the AI build freely and fast.
▶ Example — PLAN¶
# authored into the node's frontmatter before the freeze
gives: "POST /transfers { fromAccountId, toAccountId, amount }
-> 200 { transferId, fromBalance, toBalance }
-> 400 { error: amount_invalid | same_account | insufficient_funds }
-> 403 { error: forbidden }"
scope:
- src/transfers/**
Strategy: read both accounts in one transaction; check ownership, then amount,
then balance; debit + credit atomically or not at all.
Schema: accounts.balance (read + write, must be transactional).
Every error code traces back to a Reject rule; the must be transactional note flags the one place where correctness depends on more than shape — a hint Verify will follow up. The graph and every compiled brief read gives: from the frontmatter, so it must be hand-authored there before the freeze — nothing records it for you.
The change-request rule¶
Once frozen, a gives: does not change casually. A needed change is a change request: you fold the new shape back into RULES, re-freeze, and come forward again. The AI never alters a frozen contract on its own initiative — if it could drift, nothing built on it would be safe.
Do: freeze the contract shape before any implementation. Don't: let Build quietly change an interface to make the code easier — that breaks everything depending on it.
CHECKS — the red suite¶
CHECKS is where "correct" is pinned down so a machine can judge it. Before writing a single assertion, rewrite each rule as a concrete, pass-or-fail scenario — readable by a person and checkable by a machine at the same time.
Scenarios — Given / When / Then¶
- Given — the starting situation.
- When — the action taken.
- Then — the result that must follow.
- And — what must not change (add this wherever a rule constrains side effects).
Scenario: insufficient funds # covers: R:insufficient_funds
Given A has 20, mine
When I transfer 50 from A to B
Then it is rejected "insufficient_funds"
And no balance changes
The And no balance changes line does real work: it specifies that a rejected transfer leaves the world untouched — a property the AI could easily violate by deducting before checking.
The evidence router — which modes a change earns¶
Not every rule is best proven by an example. The router keys on the change kind and the engine's computed authority floor (process · plan · human), so nothing new is declared. It is preferred, not enforced: the floor still computes from sensitivity, and a divergence is recorded on the PLAN line rather than hidden.
| change kind | default frozen evidence | added at floor ≥ plan | usually not frozen |
|---|---|---|---|
| mechanical · refactor | existing regression + static/architecture check | diff-scope check | new acceptance checks |
| small business rule | 1–3 acceptance examples | a property, if an invariant is nameable | a new unit suite |
| algorithm · pricing · ranking | acceptance examples | properties + mutation on changed code | dozens of hand-picked edges |
API · event boundary (a consumed gives:) |
acceptance + consumer contract | one integration smoke | a broad E2E suite |
| data migration | data invariants as properties | rehearsal + rollback proof | unit-only evidence |
| UI workflow | domain-level acceptance per screen state | 1–2 browser E2E + an accessibility check | every scenario through a browser |
| concurrency | acceptance | invariant under stress / schedule probes | an example-only suite |
| auth · payment · security (floor human) | acceptance + negative examples | properties + adversarial probes + a refute by a fresh session | builder-visible checks alone |
A property, a consumer contract and a mutation score on changed code are all the same shape as the reconciliation checker in the skill's domains.md: a script that emits JUnit, whose threshold is a frozen Must and whose test id a covers: line names. Mutation runs once at Verify on the files the diff touched inside scope:, never inside the build loop.
Every rule bound by a check that would fail without it¶
Bind every Must, every Reject, and every edge case that changes behavior to at least one check — each written to fail on the most plausible wrong implementation. A check exists to discriminate, never to fill a quota: one check may cover several rules when it genuinely discriminates each, and a high-consequence rule may need two checks of different kind — at a plan-or-human floor the freeze prints a notice naming any Must still on one evidence mode (a notice, never a refusal; add todo carries the count). Each check carries a covers: key naming the rule it proves — M<n> for a Must, R:<code> for a Reject, E<n> for an enumerated edge. A Must or Reject encoded in no check means the rules are not understood — stop and say so. Minor variants are build guidance, not gated checks.
The covers: binding is enforced at the gate: it refuses a PASS while any M<n> or R:<code> is covered by no check, or when the named checks did not demonstrably pass on a fresh receipt. Freeze only stamps the node approved; the coverage proof is checked when the verdict is recorded. Coverage is a binding, not a label.
Red for the right reason¶
After authoring the checks, run them: they must fail, and fail because the behavior is absent — not because a name is misspelled or an import is missing. A check that passes before any code exists is testing nothing; it is a false reassurance that will later wave bad code through. Confirming the suite is "red for the right reason" is what makes it genuinely protective.
Tests assert behavior, not internals — the observable result, never how it is implemented — so the code can be regenerated freely beneath them.
▶ Example — CHECKS¶
def test_successful_transfer(): # covers: M1
a = account(balance=100, owner=me); b = account(balance=0, owner=me)
r = transfer(a.id, b.id, 30)
assert r.status == 200
assert a.balance == 70 and b.balance == 30
def test_insufficient_funds(): # covers: R:insufficient_funds
a = account(balance=20, owner=me); b = account(balance=0, owner=me)
r = transfer(a.id, b.id, 50)
assert r.status == 400 and r.error == "insufficient_funds"
assert a.balance == 20 # unchanged — the side-effect assertion
def test_not_my_account(): # covers: R:forbidden
c = account(balance=100, owner=someone_else); b = account(balance=0, owner=me)
r = transfer(c.id, b.id, 10)
assert r.status == 403 and r.error == "forbidden"
Run these now, with no implementation: all fail. That is the correct, honest starting point for Build.
Sweep the edge cases¶
Beyond the Reject rules, sweep the recurring gaps that apply and add an E<n> check for each (or rule it out on purpose): boundary, duplicate/idempotent, ownership, stale/out-of-order, partial failure, concurrency, malformed input, limits/volume. A case earns a check when getting it wrong is a defect a reader would call a bug; otherwise reference it in prose as build guidance.
Acceptance first — the readable example, then the check¶
For code, the default frozen check is an acceptance check: business-readable, exercised through the application's port with deterministic adapters behind it, and bound to a filled E<n> written as Given · When · Then — the example a non-technical owner can read and confirm. A unit check is frozen only when it is the cheapest discriminating evidence for a rule, never because a rule exists; every other unit test belongs to Build and is disposable. The free text on each CHECKS line opens with a mode word from a closed list — acceptance · property · contract · static · unit · e2e · manual — which the engine never parses and the evidence router (§ PLAN) selects by change kind and floor.
Not every task ships code. A documentation task, a release, or an infrastructure change has no unit to exercise; forcing an executable test onto it is ceremony. For these (kind: docs · release · infra), CHECKS is the manual mode — a failing-first acceptance list, short, concrete, verifiable pass/fail evidence, red before the artifact exists and green once it does:
## CHECKS — acceptance (failing-first)
- [ ] `mkdocs build` succeeds (fails now: the page does not exist) # covers: M1
- [ ] the Personas chapter covers author · validate · flow # covers: M2
- [ ] every internal link resolves # covers: M3
Only the form is relaxed. Everything else holds — red before build, every rule bound by a check, evidence not internals, and a person confirms it at the gate. manual is the weakest rung on purpose: the receipt records command-exit, not test-ids, and says so.
Common mistakes¶
- Stating only the happy path. The Reject list is where most real complexity lives; an empty one usually means it has not been thought through.
- Free-text errors. Errors must be named codes, not sentences, so they can become checks and contract responses.
- A flat list of "confirmed" assumptions. Eight equal-looking ticks invite a reflex approval. Rank them; flag the one that is load-bearing. An unranked list hides the risk inside the noise.
- "Existing behavior" claims without a citation. Any assumption or wiring claim that depends on the current state of an existing path must carry a grep/line citation (e.g.
file.rs:203) — and it must name the production caller chain from a real entry point, not just a symbol's location. A function nothing calls is dead, not wired. - Inconsistent names. If the contract says
fromAccountIdand the schema sayssrc_acct, the AI produces subtle mismatches. Use the glossary everywhere. - Freezing too early or too late. Freeze once the rules and contract are stable — not before they are agreed, and not after code has been written against an unfrozen shape.
- Vague check results. "Then it works" is not checkable; the result must be a specific, observable fact ("A has 70").
- Forgetting the unchanged state. For any rejection, assert that nothing changed, or a corrupting partial failure passes silently.
- Tests that assert internals. Coupling a check to private detail defeats disposability — assert observable behavior only.
- A green check before the build. A lying red (a
should_panic/ skipped test that passes while unimplemented) proves nothing. Declare unimplemented paths withtodo!()(or the equivalent) so the check actually fails.
Exit check¶
Direction is done when:
- [ ] Every required behavior is a Must; every rejection is a named error code; the success state-change is stated.
- [ ] The assumptions are ordered lowest-confidence first, with the one
⚠flag carrying why + cost — or, for trivial scope, an honest "none material" that still names the single biggest risk. - [ ] The contract shape is authored into
gives:, versioned in intent, and every rejection has a contracted response. - [ ] Every Must, Reject and behavior-changing edge is named by at least one check that would fail without it — each with a
covers:referent. - [ ] The suite (or the acceptance list) runs in the pipeline and is red for the right reason.
- [ ] Checks assert observable behavior, not internals.
If the check fails¶
If you cannot state a rule clearly, the feature is not ready to build — stop, take the question to whoever owns the requirement, and resolve it. A rule with no check will never be verified: write the missing check or remove the rule. If a check passes before any implementation, it is a fake check — repair it now, because it is your only independent standard for the build.
The one approval — freeze¶
When RULES, PLAN, and CHECKS are authored and the checks are red for the right reason, a person freezes the node. This is the single human decision of the task, and it opens Build:
add freeze <slug> --by "<name>" --authority human
The freeze stamps direction closed — it approves the whole node at once. Reject any part and the whole node returns to draft (backward correction, not failure). Freeze is the approval; the gate in Verify is the proof.
The lowest-confidence flag is node-wide: at this one decision point the AI leads with "of everything I'm asking you to freeze, this one point is most likely wrong" — and it may point at an uncovered edge or the contract shape, not only a Must. The authority floor is set by the task's sensitivity: mechanical work carries a light floor, but anything touching data, architecture, or security is held to a human at the freeze, and security is never derived and never batched (see 09 Governance).
When the feature has a user interface. Extend Direction with a quick design: the user flows (happy path and main alternatives) and every screen state — loading, empty, error, success. Correct logic behind a confusing or incomplete interface is still a poor product, and undesigned states are exactly where an AI will improvise something ugly. Each of those states is a filled edge with an acceptance check bound to it — the default frozen check for code, run through the port, never a browser test per state.