Skip to content

ADR 0026: One typed (Zod) config schema as the single source of truth

Amendments.

  • Vocabulary: current pointers use standards (formerly ratchets), map where docs names the command, config, or tree, Project Script for the former project Recipe surface, and [jobs] / [jobs.<name>], known/custom job (formerly [capabilities] / [checks.<name>], gate capability / custom check); 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:

  1. 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 missing bool was silently false; nothing validated at load time. A mistake surfaced (if at all) as wrong behaviour far from its cause.
  1. 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), the paths.ts DEFAULT_* constants, doctor's ad-hoc checks (which re-parsed the config ~10 times), plus the prose in discern.toml.tmpl and the docs.
  1. 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 root discern.toml now) and its agents enum listed only ["claude_code","codex"], missing gemini. 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/toml then configSchema.safeParse, collecting every problem; parseConfigOrThrow / loadConfig fail fast with a clear, path-qualified ConfigValidationError. A TOML syntax error stays a ConfigParseError. 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 old get/array/bool/has/subsections/keys/getNumber surface is gone.
  • Per-call-site defaults collapse into the schema. [skills].dir, gate.fail_fast (ON), the features-default-ON rule, a resource's required/gc/retries, the paths.ts DEFAULT_* constants — each is one .default(...), defined once.
  • Strict everywhere. Unknown sections/keys, a dead [worktree.db]/[worktree.dev_server] adapter, a standard with no run, 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.
  • doctor folds 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.json from a configDocSchema that reuses the same Zod building blocks as the live config, and map/10-installer/config-reference.md from 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) is z.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: the discern config get/… Project Script passthrough (a jq-for-the-config over arbitrary keys) and the standard "never-loosen vs main" baseline (which reads an older, possibly un-migrated git show main:discern.toml for one number). RawConfig carries 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/upgrade write the same bytes, and a correctly-migrated schema-8 config validates cleanly. This is a pure read/validation/generation refactor, so SCHEMA_VERSION stays 8. The stricter validation can reject a previously-tolerated (never really valid) hand-edited config; that read-time behaviour change is documented in MIGRATION_PROMPT.md, and discern doctor now 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. doctor reads 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/.discern staleness 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 the no-slow-types lint rule is satisfied; it works under exactOptionalPropertyTypes / 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 upgrade already removes the known legacy tables; discern doctor names anything else. See MIGRATION_PROMPT.md.
  • Slightly more ceremony to add a key. A new config key is one schema field (type + default + describe), then deno task codegen and 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 doctor exists to make the fix obvious.
  • Fully generate discern.toml.tmpl from 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 Config accessor for "flexibility." Rejected: the flexibility was the bug. The one place that genuinely needs generic access (the Project Script passthrough) keeps it explicitly, as RawConfig.

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.

choose openEsc close