ADR 0026: One typed (Zod) config schema as the single source of truth
Amendments.
- Vocabulary: current pointers use
standards(formerlyratchets),mapwheredocsnames the command, config, or tree, Project Script for the former project Recipe surface, and[jobs]/[jobs.<name>], known/customjob(formerly[capabilities]/[checks.<name>], gatecapability/ customcheck); the decisions below are unchanged.- ADR 0363 — the template boundary: the template is now generated from this schema plus a config prose registry; the "template-generation boundary" section below records the earlier position.
Status: accepted
Context
§A project's entire discern footprint is one root discern.toml. The engine (src/engine/**), installer commands (src/commands/**), and shared core (src/shared/**) all read it. discern already lived by three principles that this file quietly violated:
- a closed, enumerable vocabulary beats an open stringly bag (ADR 0017, applied to the known-name subset of
[jobs]); - one source of truth, nothing to drift;
- the strict TypeScript engine exists precisely so a tool that preaches typechecking doesn't ship an un-typechecked core.
Those principles stopped at the config boundary:
- A stringly-typed reader. The live config was read through
config.get("worktree.resources.db.create"),config.bool(...),config.array(...)— 73 call sites across ~14 files. A typo'd key silently returned a default; a missingboolwas silentlyfalse; nothing validated at load time. A mistake surfaced (if at all) as wrong behaviour far from its cause.
- The config's shape, defaults, and prose were defined nowhere canonical. They were smeared across at least six hand-synced artifacts: the runtime reader, the wizard
DEFAULTS, the init/preset document type, a hand-written editor JSON Schema, the closed vocabularies (capabilities/features), thepaths.tsDEFAULT_*constants,doctor's ad-hoc checks (which re-parsed the config ~10 times), plus the prose indiscern.toml.tmpland the docs.
- The copies had already drifted — proof: the editor JSON Schema still described the config file as
.discern/config.toml(abolished in ADR 0020 — it is a rootdiscern.tomlnow) and itsagentsenum listed only["claude_code","codex"], missinggemini. Nobody noticed because nothing forced them to agree.
Decision
§One Zod schema (src/shared/config_schema.ts) is the single source of truth for the live discern.toml, and everything else derives from it. The schema defines every section, key, type, default, and human description (.describe(...)), and exports the inferred type DiscernConfig.
- Load = parse + validate + default.
parseConfig(text) → { config, issues }runs@std/tomlthenconfigSchema.safeParse, collecting every problem;parseConfigOrThrow/loadConfigfail fast with a clear, path-qualifiedConfigValidationError. A TOML syntax error stays aConfigParseError. Both surface through the one top-level CLI handler, human and--json. The engine reads a fully-typed, fully-defaulted object — typed field access (cfg.gate.fail_fast), autocompleted, no string keys. The oldget/array/bool/has/subsections/keys/getNumbersurface is gone.
- Per-call-site defaults collapse into the schema.
[skills].dir,gate.fail_fast(ON), the features-default-ON rule, a resource'srequired/gc/retries, thepaths.tsDEFAULT_*constants — each is one.default(...), defined once.
- Strict everywhere. Unknown sections/keys, a dead
[worktree.db]/[worktree.dev_server]adapter, a standard with norun, a non-boolean feature, a bad stage/direction — all fail at load with a path-qualified message. The closed known-job vocabulary is now enforced by the type system, not a bespoke check.
doctorfolds its structural validation into the shared validator. One "config schema" check reports the issue list; the config is parsed once, not ~10 times. Only the semantic/liveness checks stay bespoke (is a command on PATH, does a Project Script source the retired shell library, does the gotchas doc exist, does another tool also automate worktrees).
- The editor JSON Schema and the docs reference are GENERATED (
deno task codegen,src/shared/config_codegen.ts):schema/discern-config.schema.jsonfrom aconfigDocSchemathat reuses the same Zod building blocks as the live config, andmap/10-installer/config-reference.mdfrom the live schema's.describe(...)annotations. The two staleness bugs are gone as a consequence of generation, not patched by hand. The init/preset config document (DiscernConfigDoc) isz.input<configDocSchema>— a mechanically-derived subset, so it cannot diverge from the live shape.
- The narrow exception:
RawConfig. Two jobs need un-validated, generic dotted-key access and must NOT trip the schema: thediscern config get/…Project Script passthrough (ajq-for-the-config over arbitrary keys) and the standard "never-loosen vs main" baseline (which reads an older, possibly un-migratedgit show main:discern.tomlfor one number).RawConfigcarries no schema knowledge, so there is nothing in it to drift.
- The template stays hand-authored; tests bind it to the schema. See the boundary below.
- No on-disk shape change, so no migration. Nothing this change writes to disk moved: the template is byte-identical,
setup/upgradewrite the same bytes, and a correctly-migrated schema-8 config validates cleanly. This is a pure read/validation/generation refactor, soSCHEMA_VERSIONstays 8. The stricter validation can reject a previously-tolerated (never really valid) hand-edited config; that read-time behaviour change is documented inMIGRATION_PROMPT.md, anddiscern doctornow names each offending key.
The template-generation boundary (and why)
§The brief asked for the discern.toml.tmpl prose to be generated from the schema's .describe(...) too. We deliberately did not fully generate the template, and instead bound it to the schema with drift-guard tests. The reason is ADR 0005: the legible, comment-annotated config is a feature. The template carries curated, domain-spanning examples (# format = "prettier --write ." # or "ruff format ." or "gofmt -w ."), a known-job→stage table, a per-token reference block, and a deliberate mix of active and commented lines. Mechanically rendering that from one-line .describe() strings would either flatten the legibility or force paragraph-long descriptions and presentation metadata into a runtime schema — degrading the template to satisfy generation, the exact trade the principle forbids.
So the split is:
- Fully generated (cannot drift, regenerate with
deno task codegen): the editor JSON Schema and the docs config-reference. Pure derived artifacts with no curation to lose. - Hand-authored, drift-guarded by tests:
discern.toml.tmpl. Two gate-stage tests bind it to the schema without flattening it — (a) the rendered template must validate under the schema (it can never drift into producing an invalid config), and (b) every schema section must appear in the template (a new section can't be silently undocumented). The per-key reference lives in the generated docs page; the template stays the legible, curated scaffold.
A schema change that isn't regenerated fails the gate: sync tests assert each committed generated artifact equals its generator output, in the test stage that CI runs (deno task dev done). That guard is what stops the drift from coming back.
Consequences
§- Typos and malformed config fail loudly, at load, with the offending path — not silently as a wrong default consumed somewhere downstream.
doctorreads the same validator, so its report and the engine's enforcement agree by construction. - The drift class is closed. The reader, the defaults, the editor schema, the docs, and the closed vocabularies are now one definition with generated/tested mirrors; the gemini/
.discernstaleness bugs cannot recur. - Zod is a new dependency (
jsr:@zod/zod, Zod 4), added the JSR-native way. Its inferred types stay internal (never on the package's exported API), so theno-slow-typeslint rule is satisfied; it works underexactOptionalPropertyTypes/noUncheckedIndexedAccess. - Stricter validation is a (documented) behaviour change. A hand-edited config with an unknown key or a leftover dead adapter that the old reader silently tolerated now fails at load — including a config produced by
discern config set <unknown.key>.discern upgradealready removes the known legacy tables;discern doctornames anything else. SeeMIGRATION_PROMPT.md. - Slightly more ceremony to add a key. A new config key is one schema field (type + default + describe), then
deno task codegenand a one-line note in the template — versus touching six files. The net is far less work and no drift.
Alternatives considered
§- A hand-written TypeScript interface + a manual validator. Rejected: that is what we had in spirit (six copies); it does not generate the JSON Schema or the docs, and offers no single place for defaults + prose.
- Validate leniently (warn, don't fail). Rejected: a tool that preaches a green gate should not quietly run on a config it can't fully understand. Failing at load, with the path, is the honest behaviour, and
doctorexists to make the fix obvious. - Fully generate
discern.toml.tmplfrom the schema. Rejected for now — see the boundary above; it fought ADR 0005's legible-config feature. The drift-guard tests get the "cannot diverge" guarantee without the cost. - Keep the stringly
Configaccessor for "flexibility." Rejected: the flexibility was the bug. The one place that genuinely needs generic access (the Project Script passthrough) keeps it explicitly, asRawConfig.
This applies ADR 0017 (closed vocabulary) and ADR 0019 (one source, nothing to drift) at the config boundary, and respects ADR 0005 (the comment-preserving, legible config) by leaving the template hand-authored.