ADR 0017: Declare capabilities, derive the gate — retire slots + phases
Amendments.
- Vocabulary: current pointers use
standards(formerlyratchets); the retired product-category wording reads asdiscern, the gate, or the bar; the decision and reasoning are unchanged.- ADR 0018 — sibling record: lands together with its sibling (the four-layer vocabulary consolidation) — one 3→4 schema migration, best read as a pair.
- ADR 0019 — one home:
KNOWN_JOBSnow lives insrc/shared/capabilities.ts; the shellcapabilities.shmirror is gone.- ADR 0168 — one
[jobs]table: the two-table[capabilities]/[checks]split dissolved into one[jobs]table — gatecapability/ customcheckread as known/customjob,KNOWN_CAPABILITIESasKNOWN_JOBS; what this record defended — the closed known-job vocabulary, the derived stages, knowable absence, and custom-declares-stage — survives.
Status: accepted
Context
§The gate config asked every author to learn two coupled nouns before writing a single command. A slot was a named hole you dropped a command into; a phase (fix/build/check/test) was the scheduling bucket that decided when and how it ran. So the smallest possible gate read:
[slots.format]
phase = "fix"
run = "deno fmt"
Three lines and two concepts to say "this is how the project formats." The problem was never that the model didn't work — it did — but that it spoke in mechanism. "Slot" and "phase" describe the machine's plumbing, not the project. Two frictions followed:
- No notion of a complete install. Slot names were free-form, so the engine could not answer "does this project have a test capability?" or "is the build intentionally absent?" A slot named
verify-thingsin thecheckphase carries no semantics — neither the engine nor an agent knows what guarantee it provides. The set of slots was open-ended, and you cannot report what is missing from an open-ended set. This blocks the things readiness enables: anagent doctorthat tells a newcomer whether discern is actually wired, and a futurediscern auditthat could advise other repositories toward best-practice tooling — both of which need to reason over a closed, known vocabulary.
- The phase vocabulary leaked onto the user surface. Because a slot had to carry a
phase, every author met the four scheduling buckets on day one — in the config comments, the README phase table, the gotchas doc. Butfix,build,check,testare nearly an alias of what the command is for: a formatter is afix, a linter/type-checker is acheck, the suite is atest. The author was made to declare the scheduling when the scheduling is almost always implied by the tool.
This is pre-launch (only two internal installs exist), so the model can still change cleanly — the cheapest this will ever be, the window ADR 0009 and ADR 0016 leaned on.
Decision
§Reframe the gate config around capabilities: a small, closed vocabulary of things a project can do. The engine derives the scheduling.
- A known capability vocabulary, declared flat. The core five are
format,build,lint,typecheck,test. Each is one line — a capability name mapped to a command (or an array of commands run in order):
[capabilities]
format = "deno fmt"
lint = "deno lint"
typecheck = "deno check src/main.ts"
test = "deno task test"
The set is closed: an unknown key under [capabilities] is a hard error that points the author at [checks]. A closed set is the whole point — you can only report presence/absence against a fixed vocabulary.
- The engine derives the stage; "phase" retires as a user word. Each known capability maps to an internal scheduling stage —
format→fix,build→build,lint→check,typecheck→check,test→test. The four stages survive inside the engine (the parallel shapefix → build → check ∥ testis unchanged), but the author never writes one. The mapping lives in exactly two mirrored places:KNOWN_CAPABILITIESinsrc/lib/config.tsandcap_stage()intemplates/.discern/engine/lib/capabilities.sh.
- An omitted capability is knowably absent. There is no
:no-op default anymore. A capability you don't have is simply not in the file — and that absence is the readiness signal. A fresh install with no capabilities is still a green gate (nothing to run passes);agent doctorreports the ✓/✗ checklist and a "ready for agentic development" verdict. The old:existed only because a slot table had to physically exist to be discovered; capabilities are discovered by their known names whether present or not, so the structural reason for the no-op is gone. The engine treats an absent known capability as a skip, never an error.
- Custom work has an explicit escape hatch. Anything outside the five known capabilities — a project-specific gate step the engine has no opinion about — is a
[checks.<name>]with an explicitstage, arun, and an optional free-textprovideslabel:
[checks.licenses]
stage = "check"
run = "./scripts/check-licenses.sh"
provides = "license-audit"
stage is required here (the engine can't derive it from an unknown name); provides is a human/audit label, not a constraint.
The explicit nos: no user-facing phase key; no open-ended capability names (custom work is a [check], not an invented capability); no : no-op (absence is meaningful); no "measurement slot" (a phase-less slot read by a standard — that moves to the standard's inline run, see ADR 0018).
Consequences
§- Definition-of-done becomes a checklist, and readiness is first-class. The config reads as a declaration of what the project can do, and
agent doctorcan report which of the five are wired and whether the install clears a minimal bar (test plus at least one static check). This is the foundation andiscern auditwould stand on. - The config reads like a sentence.
format = "deno fmt"replaces a three-line, two-concept block. The most common gate — format/lint/typecheck/test — is four lines under one header, no scheduling boilerplate. - The five-name ceiling is deliberate, and a real constraint. Adding a sixth known capability (say
e2eorsecurity) is not a config tweak — it needs a new stage mapping in two languages and a deliberate widening of the vocabulary, i.e. its own ADR. That friction is the feature: the vocabulary stays small and meaningful. A project that wants a sixth thing today uses a[check]. - A migration is owed. Existing installs carry
[slots]; a schema 3→4 migration transforms them (the mechanics and the rest of the vocabulary consolidation are ADR 0018). The "upgrade ≡ fresh init" convergence test and an idempotent re-run are the guards. - The
--jsonreport changes shape. Per-result reporting is now keyed by job (capability or check), not slot —{name, kind, stage, status, duration_s}, amending ADR 0004. A no-op gate reports an emptyjobsarray rather than a list ofnooprows; "what ran" replaces "what could run." - Amends earlier records. This supersedes the slot/phase surface of ADR 0006 (the
[gate]stream/fail_fast ergonomics survive untouched); amends ADR 0004 (per-job, not per-slot); amends ADR 0003 (the measurement slot it relied on becomes the standard's inlinerun). Design principle #1 ("push every stack fact behind a named slot") is reworded to "a named capability" and links here.
Alternatives considered
§- Keep slots + phases, bolt a readiness report on top. Rejected: readiness over an open set is meaningless. You cannot say "build is absent" when "build" was never a known concept, only a string a user might or might not have typed. The closed vocabulary is precisely what makes the report possible — the report is not a feature you add, it is a property of the model.
- Open-ended capability names (a
providestag on every slot). Rejected: it adds a concept without removing one, working against the goal of fewer nouns, and free-text tags fragment (static-analysisvsstatic_analysis), giving labels but never a checklist. - Fold checks into capabilities with a free
stage. I.e. let[capabilities]hold both known names and arbitrary ones, distinguished by whether astageis written. Rejected: it re-merges the two concepts the split exists to separate — "a known thing whose scheduling I know" vs "a custom thing whose scheduling I must state." Two tables ([capabilities]closed/flat,[checks]open/explicit) keep that line crisp and make the closed set enumerable.