Skip to content

ADR 0017: Declare capabilities, derive the gate — retire slots + phases

Amendments.

  • Vocabulary: current pointers use standards (formerly ratchets); the retired product-category wording reads as discern, 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_JOBS now lives in src/shared/capabilities.ts; the shell capabilities.sh mirror is gone.
  • ADR 0168 — one [jobs] table: the two-table [capabilities]/[checks] split dissolved into one [jobs] table — gate capability / custom check read as known/custom job, KNOWN_CAPABILITIES as KNOWN_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:

  1. 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-things in the check phase 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: an agent doctor that tells a newcomer whether discern is actually wired, and a future discern audit that could advise other repositories toward best-practice tooling — both of which need to reason over a closed, known vocabulary.
  1. 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. But fix, build, check, test are nearly an alias of what the command is for: a formatter is a fix, a linter/type-checker is a check, the suite is a test. 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.

  1. 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.

  1. The engine derives the stage; "phase" retires as a user word. Each known capability maps to an internal scheduling stageformat→fix, build→build, lint→check, typecheck→check, test→test. The four stages survive inside the engine (the parallel shape fix → build → check ∥ test is unchanged), but the author never writes one. The mapping lives in exactly two mirrored places: KNOWN_CAPABILITIES in src/lib/config.ts and cap_stage() in templates/.discern/engine/lib/capabilities.sh.
  1. 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 doctor reports 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.
  1. 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 explicit stage, a run, and an optional free-text provides label:
   [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 doctor can 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 an discern audit would 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 e2e or security) 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 --json report 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 empty jobs array rather than a list of noop rows; "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 inline run). 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 provides tag on every slot). Rejected: it adds a concept without removing one, working against the goal of fewer nouns, and free-text tags fragment (static-analysis vs static_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 a stage is 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.
choose openEsc close