Refusals, audited
Commission’s most-quoted principle is that a refusal is a prompt: exit code 2
is not a crash, it is an instruction, and an agent with no human present should
be able to read one and know exactly what to do next.
That claim is only worth what the worst refusal in the codebase is worth. This
page is the audit: every refusal(), notFound() and thrown CommissionError in
src/ was read at once, and the twenty-one an agent actually hits in the RC1 loop
were judged one by one against a single question:
Given only this message, with no human to ask and no other command run first, can an agent take the next action?
Passing needs three things. The refusal has to name what is missing as
concrete items rather than prose; it has to give the command that fixes it;
and that command has to carry real ids, not a placeholder the caller has to
go and look up. A hint that says “see commission actors” is a redirect, not a fix —
commission already knew the answer and made the caller pay a round trip for it.
The mechanical half of this is enforced by tests/cli-contract.test.ts
(see Mechanical rules below). The judgement half is this
page, and it is deliberately willing to call a refusal weak.
Verdicts
Twenty refusals, in roughly the order a working agent meets them. Every example is real output captured from the CLI, not an illustration.
1. done with unchecked criteria — strong
cannot complete shop-1: 2 unchecked criteria; gate 'shipped' missing evidence [test, commit]
missing: criterion 0: checkout works end to end
missing: criterion 1: payment errors surface
missing: evidence.test
missing: evidence.commit
hint: tick each with 'commission check shop-1 0', 'commission check shop-1 1' — only once it is actually true; attach with 'commission evidence shop-1 test=<ref> commit=<ref>'; or --force to override (logged)Every unmet condition is a separate missing line, and each one maps to a
command with the position already substituted. This is the refusal the whole
principle is named after and it now earns it.
Was weak until this audit. The hint used to read tick with 'commission check shop-1 <n>'. The positions were sitting in the missing list one line above,
and the agent was still asked to work out <n> for itself. Fixed in
src/core/transitions.ts.
test=<ref> stays a placeholder on purpose: the value is the PR number or the
build url, which is the caller’s knowledge and nothing commission can supply.
2. done with outstanding approval — strong
cannot complete shop-1: 1 outstanding approval(s)
missing: approval from a human — autonomy is supervised, so a human must accept the work
hint: a person has to accept this — blaze or dana: COMMISSION_ACTOR=blaze commission approve shop-1 -m "<why it is acceptable>"The missing line explains why judgement is required (the autonomy setting),
not just that it is. The hint names the people who can give it and hands back a
line that runs as-is.
Was weak until this audit. It used to end — see who can: commission actors,
which is the redirect pattern: Commission had the actor list in hand and sent the
agent to fetch it. Fixed in src/core/approvals.ts. When no humans are
declared, it says so and gives the .commission.json edit instead — the honest
answer, since there genuinely is nobody to name.
3. start on a blocked task — strong
cannot start shop-1: blocked by shop-2 (open)
missing: shop-2
hint: finish the blockers first, or 'commission start shop-1 --force' to override deliberatelyNames the blocker, its status, and both branches (do the work, or override knowingly). An agent can act on this without reading anything else.
4. Claiming a task somebody holds — strong
shop-2 is already claimed by agent:b
hint: pick another with 'commission ready', or 'commission release shop-2' if that claim is stale (lease expires 2026-07-28T02:00:00.000Z)The lease expiry is the fact that decides which branch to take, and it is in the message rather than one command away. This is the model the rest of this page is measured against.
5. Claiming a task the policy forbids — strong
agent:a cannot claim shop-3: autonomy is human_only and agent:a is an agent
missing: autonomy: human_only
hint: blaze can: COMMISSION_ACTOR=blaze commission claim shop-3 — or change the policy: commission edit shop-3 --autonomy supervisedWas weak until this audit for the same reason as #2: see who may work it: commission actors. It now resolves the eligible pool and names it, so an agent can
hand off by name instead of reporting “refused”. Fixed in
src/core/execution.ts.
6. A blocking question — strong
shop-2 is waiting on an answer: "Stripe or Adyen?"
missing: answer to question #1
hint: blaze decides this: COMMISSION_ACTOR=blaze commission answer shop-2 -m "<the decision, and why>" — pick something else meanwhile: commission next --claimThis is the refusal an unattended agent hits when it must genuinely stop, so it
is the one that most needs somewhere to go. The second clause matters as much as
the first: without commission next --claim the correct behaviour is “halt”, and a
halted agent looks identical to a broken one.
Was weak until this audit — it said “a human decides this” and stopped.
Fixed in src/core/questions.ts.
7. An agent trying to approve or answer — strong
agent:a cannot approve shop-1: approval is a human judgement
missing: a human actor
hint: a human runs this — blaze or dana: COMMISSION_ACTOR=blaze commission approve shop-1 -m "<why>"The refusal that must never be routed around, so the wording is careful: it
names who, and never suggests a flag that would bypass it, because none exists.
--force overrides commission’s own checks and never a person’s sign-off.
8. An illegal transition — strong
cannot move shop-2 from done to in_progress
hint: done is terminal; 'commission reopen shop-2' firstTwo branches, both correct: from done it names the one legal exit; otherwise
it enumerates the legal targets (allowed from open: in_progress, done, cancelled). Nothing to look up.
9. Transitioning to the status it already has — strong
shop-2 is already done
hint: nothing to do — it is already closed; 'commission reopen shop-2' if that was wrongWas the worst refusal in the codebase. It had no hint at all — a bare
refusal() on one line, which the old mechanical test could not even see (it
only matched multi-line calls). For an unattended agent this is the dangerous
shape: “already done” reads as failure, the agent retries, and the loop cannot
terminate. The fix says the quiet part — nothing to do — which is the only
thing that stops the retry. Fixed in src/core/transitions.ts.
10. --force with no reason — strong
--force requires -m "<why>"
missing: -m "<why the override is justified>"
hint: commission done shop-4 --force -m "<why>" — the override and its reason both go in the event log, and the next session reads themWas weak until this audit. The old hint — “the override is recorded in the
event log; say why it’s justified” — explained the policy and left the agent to
reconstruct the invocation. It now echoes the exact line, with the right verb
for the transition being attempted. Fixed in src/core/transitions.ts.
<why> is the one thing commission cannot fill in, which is the point of the
refusal.
11. Cancelling with no reason — strong
cancellation requires a reason
hint: commission cancel shop-4 -m "why" — or --superseded-by <id> if another task replaces it<id> here is a flag value naming a different task the caller has to choose.
That is genuinely unknowable, and distinct from a placeholder standing in for an
id commission already has. The mechanical test draws exactly that line.
12. A stale write (--if-version) — strong
shop-1 has changed since you read it: you have version 1, it is now 8
missing: version 1
hint: re-read it and decide again: commission show shop-1 --jsonBoth versions in the message, and the hint says decide again rather than “retry” — a retry with the same content is the bug this refusal exists to catch.
13. A criterion index that does not exist — strong
task shop-1 has no criterion #7
hint: it has 1, numbered 0–0 (criteria are 0-indexed) — read them with 'commission show shop-1'Was weak until this audit — it said only “see criteria with ‘commission show
shop-1’”. Criteria are 0-indexed, models overwhelmingly guess 1-indexed, and the
old message gave an agent nothing to correct the guess with; the natural repair
is to run show and re-derive the convention. Stating the range ends it in one
step. Fixed in src/core/tasks.ts.
14. An unknown gate — strong
unknown gate 'nope' for project 'shop'
missing: gate profile 'nope' in project 'shop'
hint: known gates: shipped (test, commit) — use one of those, or define this one: commission gates --set nope=test,commitWas adequate, now strong. It listed gate names but proposed commission gates --set nope=<kind>,<kind> — asking the caller to invent an evidence vocabulary
the project had already established. It now shows each gate’s kinds and seeds
the definition from the ones in use. Fixed in src/core/tasks.ts.
15. Malformed evidence — strong
evidence must be kind=value, got 'pr14'
missing: a '=' in 'pr14'
hint: gate 'shipped' on shop-1 wants [test, commit] — e.g. commission evidence shop-1 test=<ref> commit=<ref>Was bare until this audit — evidence must be kind=value, got 'pr14' and
nothing else. It now reads the task’s own gate profile, so the correction is not
just the right shape but the right kinds. Fixed in
src/cli/commands/work.ts.
15a. Evidence that is a placeholder — strong
evidence that says 'pending' is not evidence
missing: commit: 'pending' is a placeholder, not a commit
hint: attach the real reference — commit=9c4473f — or leave it off until you have it; the gate refusal will keep telling you what is missing. If the work genuinely has no such reference, commission evidence shop-1 commit=<why there is none> --force -m "<why>".Added after this audit, because the audit itself missed it. Every refusal
above checks a shape; none checked a value, so commit=pending satisfied a
gate that wanted a commit and done proceeded. Fifteen tasks in commission’s own
repository were closed that way, including tasks gated on released.
The hint does three things in order of what the caller most likely needs: the real value, permission to attach nothing at all, and the override for the case where there genuinely is no such reference. The middle clause matters most — without it the refusal reads as “you must produce a commit now”, and the honest answer is usually “finish the work first”.
Deliberately not a per-kind format validator. Whether gates should declare
shapes is a real design question; guessing at what a SHA or a URL looks like
would refuse legitimate values, and a refusal that cries wolf gets --forced
until it means nothing. Only bare placeholders are refused — pending review of PR 14 is a real note about state and is accepted.
16. bind with no target — strong
a binding needs the external object shop-1 maps to
missing: one of --jira <KEY>, --pr <owner/repo#number>
hint: e.g. commission bind shop-1 --jira RTB-543, or commission bind shop-1 --pr Row-Labs/gulfline#17 — 'commission branch shop-1' names a branch whose PRs auto-bindWas bare until this audit (pass --jira <KEY> or --pr <owner/repo#N>). The
third clause is the valuable one: it points at the path where binding happens by
itself, which is usually what the caller should have done.
17. A dependency on itself — strong
shop-1 cannot depend on itself
hint: name the task it actually waits on: commission dep shop-1 --on <other-id> — 'commission list' shows the candidatesWas bare until this audit. <other-id> is honest: the whole content of the
mistake is that Commission does not know which task was meant, and the hint names the
command that lists them. Fixed in src/core/tasks.ts.
18. An apply document that references an unknown key — strong
apply: reference 'ghost' is neither a key in this document nor an existing task id
missing: a task with key or id 'ghost'
hint: keys in this document: a — add a task with key 'ghost', use one of those, or use a real id ('commission list --project shop' shows them)Was bare until this audit. Listing the document’s own keys is what makes it
actionable: the error is almost always a typo against a key that is right there.
Fixed in src/core/apply.ts.
19. No project resolved — strong
multiple projects exist: shop, billing
hint: name one, e.g. --project shop — or bind this repo to it once: commission config init shopWas weak until this audit. The message listed the slugs and the hint then
said pass --project <slug> — a placeholder for a value printed on the line
above. Fixed in src/cli/config.ts and src/adapters/github.ts.
20. A workspace with no free ports — acceptable, with a caveat
no free block of 3 ports for shop-1 in [4000, 4009]
missing: 3 blocks of 3 in the range, all held or in use
missing: 4 ports held by other prepared workspaces
hint: release a workspace you are done with (commission workspace list, then commission workspace release <id>), or widen workspace.ports.range in .commission.jsonThe diagnosis is excellent — it distinguishes “the range is too small” from “other workspaces are holding them”, which are different fixes.
The caveat, stated rather than hidden: commission workspace release <id> is a
placeholder, and it is the one case on this page where that is defensible.
Which workspace to release is a judgement about work in flight, not a lookup;
commission cannot choose, and the hint names the command that shows the options
immediately before it. It stays under review — if workspace list ever ranks by
idleness, this hint should name the top candidate outright.
What did not survive
Three patterns were treated as defects across the whole codebase, not just in the twenty-one above:
The redirect. — see who can: commission actors, see who may work it: commission actors. Commission had the answer loaded and charged the caller a command to get it.
Every instance now resolves the list and names it.
The reconstructed command. A hint that explains the policy (“say why it’s justified”, “a human must run this”) and leaves the agent to assemble the invocation. Every instance now prints the line.
The bare refusal. Fourteen construction sites had no hint and no missing
at all. Most were single-line calls, which the old mechanical test structurally
could not see — the check matched only calls that closed on their own line, so
the shortest and most neglected refusals were exactly the ones exempt from it.
Mechanical rules
tests/cli-contract.test.ts walks every error-construction site in src/ with
a bracket-balancing scanner rather than a regex, because refusals nest template
literals, ternaries and ${} interpolations that defeat pattern matching. Five
rules hold:
| rule | what it catches |
|---|---|
every refusal carries a hint or a missing list | a refusal with no remediation at all, single-line or not |
| no hint names a command that is not in the tree | commission unclaim after the command was renamed |
| no hint names a flag that is not in the tree | --reason when the flag is -m |
a refusal that interpolated an id must not then ask for <id> | commission show <id> in a refusal that just printed the id |
| the scan itself found >80 sites, including single-line ones | the check silently going blind, which is how rule 1 failed before |
The fourth rule is deliberately narrow: it fires only on a positional argument
to a commission command, so --superseded-by <id> (#11) and test=<ref> (#1) —
values only the caller can know — are not flagged.
None of this can check whether a hint is good. That is what this page is for, and it should be re-read whenever the command surface moves.
Explainability: the same rule for automatic actions
A refusal explains why Commission would not act. The mirror obligation is explaining why it did — and for anything commission does on your behalf, that explanation has to survive in the event log, because it cannot be recomputed later. The PR is closed by then, the frontier has moved, the config has changed.
tests/explainability.test.ts holds the line. Four automatic actions were not
answerable from the log before this audit:
next --claimwrote{ via: "next" }and nothing else. The whole point of the command is that commission chooses for you, and the choice was unrecorded. It now carries the selection policy, the field values it compared, every candidate it passed over with the reason, and the routing sentence naming who else was eligible.commission startsilently claimed the task. A lease appeared with no event behind it, so “who took this?” had no answer. It now writes aclaimevent saying it was the start that took it, and names the expired holder it took over from.commission synclogged counts —{ changes: 2, divergences: 1 }. Which tasks moved, why, and what the drift warnings said were all absent. The payload is now itemised: each change with the external fact that forced it, each divergence with its sentence and its fix, each planned outbound action with its reason.- auto-close on merge recorded
PR acme/app#1 merged— the fact, but not the setting. A task closing itself is the most surprising thing commission does, and the answer to “why did this close?” is a config line, so the reason now namesintegrations.github.autoCloseOnMergeas well as the PR.
The invariant the test enforces is narrow: a transition marked via must
carry a reason. via is precisely the marker for “machinery did this, not
the caller” — a caller-driven transition needs no reason, because the caller is
the reason and the actor column already records it.
Two things are deliberately not logged, and should stay that way. Delegation
suggestions and routing explanations are derived on every call and never
written: a suggestion cannot go stale if it is never stored, which is the same
reason blocked is derived from dependencies. Drift warnings are likewise
derived — but their input, the context fingerprint the claim was taken under, is
stamped on the claim event, so the warning is reconstructible from the log
even though the warning itself is not in it.
This page is docs/reference/refusals.md in the Commission
repository, rendered in place — the site keeps no copy of it. The repository is private, so there is no edit link to follow.