Configuration

September 2, 2026 · View on GitHub

Domain: _shared

Overview

Fab uses a set of complementary configuration files — the 5 Cs of Quality: fab/project/config.yaml for project settings, fab/project/constitution.md for principles and constraints, fab/project/context.md for free-form project context, fab/project/code-quality.md for coding standards, and fab/project/code-review.md for review policy. Config says what you use; constitution says how you use it; context describes what you're working with; code-quality defines how code should look when writing; code-review defines what to look for when validating. All are generated by /fab-setup and loaded as context by all skills that generate or validate artifacts.

Requirements

Schema Discovery — fab config explain

fab config explain [<key>] [--json] is the canonical schema-discovery surface. Bare forms print the full commented reference or deterministic field table; keyed forms resolve nested rows to their owning rendered segment and return that segment or its matching JSON row(s). It is a byte-stable pure query with at most one positional key. Unknown keys fail non-zero naming the key. reference remains an invisible Cobra alias so shipped pointer comments and historical migrations keep working; the 2.9.2-to-2.10.0 migration's seeded line remains exactly # Full reference of all available options: fab config reference.

Covers both key sets — binary-consumed AND skill-consumed. The Go Config struct (internal/config) models only binary-consumed keys (test_paths, true_impact_exclude, the top-level providers: map — each ProviderConfig carrying interactive_command, native, headless_command, plus a profiles map of per-role {model, effort} fills, agent.session/agent.workers — the two depth knobs — and agent.profiles — the sparse per-role {provider, model, effort} override, stage_hooks, fab_version, project.linear_workspace); several keys are skill-consumed — read only by markdown skills and therefore invisible to Go reflection (project.name/description, source_paths, checklist.extra_categories). The reference documents both. Two coverage tests guard this split (cmd/fab/config_test.go): recursive yaml-tag reflection over Config (a new binary key forces a reference update) plus the registry-internal init-seed invariant TestConfigInitSeedKeysSubsetOfRegistry (init-seeded keys ⊆ registry keys — the skill-key anchor, since there is no scaffold config.yaml to compare against), alongside a config.LoadPath round-trip and a byte-stability assertion. A retired-keys guard (TestConfigReferenceRetiresLegacyKeys) additionally asserts the reference renders none of review_tools, agent.spawn_command, or branch_prefix (all retired keys) (tykw) (h1eu); the reference documents the providers: block and the whole agent: block.

Layout: baseline keys every project sets appear live with example values; the agent: block ships live with both depth knobs (session: claude / workers: claude) plus a commented profiles: example line; the remaining opt-in override block (stage_hooks) appears commented-out with fab-kit's built-in defaults shown. fab_version is documented as machine-managed. The providers: block presents all four built-in providers (claude/codex/agy/kimi) as capability grammar rendered live at uniform indentation — in a managed fence or init --system scaffold every provider line carries exactly one leading # prefix (an inline # ... note on a command line stays content) — see § providers.

The reference is the full schema; the per-project managed fence is a slimmer subset. agent.profiles and providers are advertise: false, so the agent machinery enters the project fence only as the short two-knob agent: advert — no providers: scaffold — while fab config explain (YAML and --json), fab config init --system, and fab config upgrade --system keep documenting every system-visible row in full. The reference header states this split explicitly. See § Design Decisions → "The Agent Machinery Is Demoted from the Fence, Not from the Reference".

Generated from a per-field metadata table — no second copy to drift (6nke) (ff2v). The reference is generated in Go from the ordered registry in internal/configref. Registry row order is presentation order — both target-aware managed fences, fab config init --system, config show, and --json all walk it — so the rendered walk runs … consolidate → dispatch → agent → providers → autopilot → stage_hooks …, policy (dispatch.mode) above the capability knobs (agent/providers) that consume it. Each row carries its key, default, expected YAML kind, description, scope, advertise/rename/init metadata, and rendered segment. Symbol-backed defaults continue to come from the binary's canonical values. There is no reserved validate verb: mutation's unknown-key refusal and scope enforcement provide the actionable typo guard.

Per-field metadata: scope / advertise / renamed_from / init-seed (ff2v) (j0qm). All four fields have live consumers: Scope drives the cascade (lpb5); Advertise drives the fab config upgrade managed fence; RenamedFrom drives the mechanical rename carry; and InitSeed drives fab config init --project (see § fab config upgrade / § fab config init --project above):

  • Scope (project/system/both) — agent.session, agent.workers, agent.profiles, providers, dispatch.mode, dispatch.column_width, dispatch.reap_done, and autopilot.merge_mode are both; semantic project inputs stay project. Scope enforcement is fail-open for the system layer and single-sourced in internal/configscope.
  • Advertise (bool) — selects the slimmer project fence. The system fence instead selects every scope: system/both row, so provider machinery remains absent from per-project scaffolds but present in machine-level scaffolds.
  • RenamedFrom (string) — previous key path for mechanical rename carry-forward. Two rows carry it: agent.profiles, recording agent.tiers; and providers, recording the nested command-field rename (providers.<name>.session_command, providers.<name>.dispatch_command). The carry itself is a top-level-key operation, so it deliberately skips both — one rename sits inside the agent: block, the other inside providers.<name>: blocks — making the rows metadata (surfaced in --json), while the on-disk rewrites are the 2.16.19-to-2.17.0 and 2.18.1-to-2.19.0 migrations' jobs and read-time aliases cover the windows between them (see § agent and § providers). Other past key relocations (e.g. agent.spawn_commandproviders.claude.interactive_command) are covered by shipped migrations (tykw) and NOT backfilled. Consumed by fab config upgrade's rename carry (j0qm), which no shipped row can currently exercise.
  • InitSeed (bool) (j0qm) — marks the A-class identity fields written LIVE at fab config init --project time (project.name/project.description, source_paths, test_paths). Exposed via configref.InitSeedKeys(); a test-anchored invariant (init-seeded keys ⊆ registry keys).

fab config explain --json (ff2v). Emits the registry as a deterministic, byte-stable JSON array in table order; keyed form preserves the array/object shape and selects the owning segment rows. The YAML and JSON projections remain guarded against key drift. Each row's default is the typed built-in value the cascade reaches when no higher layer overrides that field; null uniformly means that no meaningful built-in exists and is never replaced by a typed empty ([], {}, or ""). Values shown only as rendering examples (such as source_paths and test_paths) therefore remain null, while real scalar defaults (agent.session/agent.workers: claude; dispatch.mode: native; dispatch.column_width: 35; dispatch.reap_done: true; autopilot.merge_mode: cherry-pick-ladder) and structured agent/provider defaults retain their actual types. Sparse provider fills omit roles with no shipped fill, and deprecated flat provider fills remain absent because they are read-time aliases rather than defaults. docs/specs/config.md records the design rationale for this shipped behavior.

Override Cascade & Scope Enforcement

The cascade does not require a project. A config-only command — one that resolves or reads configuration and never touches change state — runs outside any fab/ project and resolves env > system > built-in defaults, dropping only the project tier: fab agent, deprecated fab resolve-agent, fab config show, and fab config explain (which was already project-free, being registry-backed). System-targeted config writers also run repo-free: config init --system, set --system, unset --system, and upgrade --system resolve ~/.fab-kit/config.yaml directly. Bare/project-targeted config writes and upgrade --all still fail closed with ERROR: fab/ directory not found, because they include fab/project/config.yaml. Scope enforcement makes the degradation safe by construction: the system tier only honors system/both fields, so a semantics-class key (source_paths, test_paths) can never arrive from it, and outside a project there is no project tier to protect.

The Scope metadata and ordered dotted-key enumeration drive a four-tier override cascade at the single loader seam, plus the visibility commands below. Effective config resolves per leaf across four tiers, highest precedence first:

  1. environment — recognized FAB_* variables inherited by the current process tree
  2. system~/.fab-kit/config.yaml (co-located with the version cache; XDG rejected), resolved via os.UserHomeDir() through the homeDir package-var seam so tests pin it with t.Setenv("HOME", …)
  3. projectfab/project/config.yaml (the path LoadPath is given)
  4. built-in defaults — applied at the existing point-of-use seams (internal/agent's role/provider merge and nil-safe accessors), and projected as a materialized tier for the read-model surfaces by configref.DefaultsMap/DefaultsMapFor (§ Six-Verb Surface). Their physical source is the module-root embedded defaults.yaml (src/go/fab/defaults.yaml, embedded by the root fab package, parsed by internal/agent) — the single value source for every built-in default, the dispatch.* values included (they reach internal/config's exported symbols by init-time injection — § Design Decisions → "Dispatch Defaults Are Init-Injected from defaults.yaml") — shaped as a config-file fragment so it can serve as tier 0 (see runtime/providers-and-profiles.md § The built-in defaults are an embedded defaults.yaml)

The system tier outranks the project file, and scope enforcement is what makes that safe. Only scope: system/both (preference-class) fields are honored in the system file at all, so a semantics-class key (source_paths, test_paths, stage_hooks, …) can never be affected by the order — the repo stays reproducible for teammates and CI while a personal machine-wide preference beats a repo's committed suggestion. Nothing on disk changes shape, so the order carries no migration.

Environment mapping is generic and forward-only. LoadPath walks configscope.DottedKeys() in registry order and derives each variable as FAB_ + the uppercased dotted key with dots replaced by underscores (agent.workersFAB_AGENT_WORKERS, agent.profilesFAB_AGENT_PROFILES). It never scans or reverse-parses arbitrary FAB_* names, so underscore-versus-dot ambiguity cannot arise and unknown variables are ignored silently. Each honored value is YAML-parsed, allowing scalars and flow collections, nested beneath its dotted path, and merged as the highest-precedence overlay. An empty value is unset at this tier like any other — both a blank variable and one whose YAML parses to null, "", [], or {} — contributing neither an overlay leaf nor a provenance entry. (2d1w)

The cascade lands entirely inside internal/config.LoadPath — the single seam every consumer reaches through (Load is a thin join over it; preflight, impact, status, agent, deprecated resolve-agent, dispatch, operator, batch, spawn, prmeta all go through it). Every call site receives effective config without its own environment-resolution path. LoadPath decodes the two files to generic map[string]any values, scope-prunes the system map, builds the environment map, and computes MergeLayers(projectMap, systemMap, envMap) — lowest tier first — before unmarshalling the merged tree into Config. That tail is exported as config.FromMap, so a caller already holding the layers (the provenance surfaces, via LoadLayers) reaches the typed config without re-running the cascade and re-emitting every fail-open warning. Built-in point-of-use fallbacks complete the fourth tier; the loader's merged tree deliberately carries no defaults tier (§ Design Decisions → "The Defaults Tier Is Materialized for the Read Model, Not for the Loader").

Per-leaf merge (config.MergeLayers over config.IsEmptyValue, Config-agnostic): maps merge per-key recursively (for example, environment agent.profiles.review.provider composes with non-conflicting project/system profile leaves), lists replace (never concatenate), and scalars replace. Each leaf takes the value of the highest tier that defines it non-empty; a map-vs-non-map type mismatch replaces wholesale.

Empty-skip. A leaf whose value at some tier is null, "", [], or {} neither wins nor blocks — it falls through to the tier below, and a mapping whose every leaf is empty defines nothing and falls through wholesale. false and 0 are real values and are never skipped, so a project dispatch: {reap_done: false} resolves false. The rule has exactly one implementation, consumed by the loader and by every provenance surface alike, so visibility can never disagree with resolution.

Fail-open (config must never brick):

  • Absent system file ⇒ byte-identical to the pre-cascade single-file behavior (empty overlay, no error, no warning).
  • Malformed or unreadable system file ⇒ a fab: warning: on stderr and the system layer is SKIPPED — a broken personal file must not break every repo on the machine. Warnings go to the warnw package-var (os.Stderr in production; redirected in tests) and never change the exit code or any stdout contract.
  • A malformed PROJECT file keeps today's error behavior (the parse error is returned).
  • An unparseable or Config-type-incompatible environment value ⇒ a fab: warning: and that variable is SKIPPED, leaving lower layers effective. Compatibility is checked per variable by trial-unmarshalling its nested fragment into a throwaway Config, so one bad variable cannot poison the final merged-tree unmarshal. (2d1w)

Scope enforcement. Before file merging, pruneProjectScoped looks up each system-map top-level key via configscope.ScopeFor: a scope: project field is pruned with a fab: warning: ignoring project-scoped field "<key>" in ~/.fab-kit/config.yaml (project-scoped fields belong in fab/project/config.yaml); both/system fields are honored; an unknown file key remains silent for yaml.v3 to ignore. The environment walk applies the same taxonomy per registry row: only both/system variables are honored, while a set project-scoped variable warns and is ignored. Enforcement is stderr-only and never changes stdout or the loader's return error.

internal/configscope — the leaf source of truth (single-sourced, cycle-free). The dependency-free package owns both the scope taxonomy (Scope, ScopeProject/ScopeSystem/ScopeBoth, Valid, and the top-level keyScopes table read through ScopeFor) and the ordered dotted registry-key enumeration read through DottedKeys. This exists because internal/config cannot import internal/configref — the chain configref → agent → config would close a cycle. internal/config consumes the leaf for system pruning and environment enumeration; internal/configref aliases the scope enum, derives each row's scope from it, and parity-tests its ordered registry keys against DottedKeys, keeping both the taxonomy and env eligibility synchronized without reversing the import edge. (2d1w)

Adding a registry row requires a PAIRED keyScopes entry (4v91). A new configref row whose top-level key is absent from configscope.keyScopes resolves to the empty scope, which validScope rejects — so lintFields makes configref.Fields() return an error and every consumer breaks wholesale (fab config explain, fab config upgrade, fab config init). The constraint a change satisfies is one registry row, not one file touched: consolidate.detectors in configref ships alongside "consolidate": ScopeProject in configscope. See § Design Decisions → "A Registry Row Needs Its Paired Scope-Taxonomy Entry".

Six-Verb Surface — show / explain / set / unset / init / upgrade

All six verbs are wired into the config command group in cmd/fab/config.go.

  • fab config show [<key>] [--origin] — a pure query (no file writes, cobra.MaximumNArgs(1) rejects extra positional args). Every form uses the materialized defaults projection produced by readModelDefaults through configref.DefaultsMap/DefaultsMapFor. Bare show merges that projection beneath layers.Effective and prints the fully composed four-tier configuration — environment over system over project over built-in defaults — as YAML. (rvza) --origin changes only the provenance presentation; composed values do not require it. Bare --origin is winner-only: it walks configref.Fields() and annotates each effective leaf with one origin ∈ {$FAB_…, system path, project path, default} (the git config --show-origin label vocabulary); an environment leaf names the exact variable that supplied it (for example, agent.workers = codex # $FAB_AGENT_WORKERS). Per-key drill-down preserves distinct variable origins under map-valued rows. config.LoadLayers runs the same cascade as LoadPath and returns Layers{ProjectPath, SystemPath, Project, System, Env, EnvOrigins, Effective}, and each invocation loads the read model onceconfig.FromMap takes those already-merged layers to the typed config the defaults projection needs — so visibility cannot drift from consumer resolution and a fail-open warning prints exactly once. (2d1w) A known dotted key selects its effective scalar/list (raw, no key = prefix) or map subtree (YAML), composing the defaults tier. Unknown keys fail non-zero naming the key.

    Keyed show <key> --origin lists the key's FULL STACK — one line per tier that defines it, highest precedence first, the winner marked (effective) and the rest (shadowed). It is the surface that answers "why is my override not taking effect", and it needs no new flag: winner-only is the all-keys listing's job. A map-valued key drills down per leaf, each leaf listing its own defining tiers, and a descendant beneath an ancestor some higher tier replaced reports that replacing tier rather than inventing a child. Each line names the tier word alongside its label — agent.workers = kimi3 # system /home/u/.fab-kit/config.yaml (shadowed) — because a stack of two bare file paths is not readable. "This tier defines the leaf" is !config.IsEmptyValue(value), the merge's own test, so no code path distinguishes "present but null" from "absent".

    The composed agent.profiles.<role> drill-down rows are knob-aware. They are derived from the depth knobs, the per-role provider overrides, and the provider fills rather than stored, so configref.DefaultsMapFor recomposes them against the live config (agent.ResolveRole), per leaf. The provider leaf resolves with the user's agent.profiles/agent.tiers entries stripped: a defaults tier must report the built-in that a user override shadows, never echo that override back as its own default. The model/effort leaves resolve with each per-role provider override KEPT and only the per-role model/effort overrides stripped — the built-in fill is a function of the provider the role actually dispatches to, and an overridden provider's own fills appear in no higher tier, so keeping the override composes honest fills without echoing anything (05wy). A knob or override naming a provider fab ships nothing for is reported verbatim, with empty fills falling through under empty-skip (provider-neutral). See § Design Decisions in runtime/providers-and-profiles.md → "DefaultProfile Is Resolution Against a Nil Config".

  • fab config set <key> <value> [--system] / unset <key> [--system] — comment-preserving, scope-aware surgical writes through internal/configupgrade. set is scalar-leaf-only: it accepts one comment-free line typed as YAML string/bool/int/float, refuses structural or collection-valued paths and collection/multiline/comment-bearing values with manual-edit guidance, and materializes the full registry-rendered ancestor chain for a missing deep leaf. An empty value is refused ahead of the writer, with guidance pointing at fab config unset: under empty-skip an empty leaf falls through and can never be effective, so writing one is a footgun rather than a way to clear a key. Emptiness is tested on the parsed value (config.IsEmptyValue), so the quoted-empty spellings ('', "") and an explicit null are refused alongside a blank or whitespace-only argument. A successful set that a higher tier shadows prints a fab: warning: on stderr naming the tier that wins (agent.workers is shadowed by env $FAB_AGENT_WORKERS — the written value is not in effect) and still exits 0, the write itself being valid; it fires for either target (project shadowed by system or environment, --system shadowed by environment) and never for --system over a project value, which the system tier outranks. unset is kind-ungated so malformed values remain repairable, and absent known keys are exit-zero notices that name the tier where the key IS live plus the command that would remove it (live in system /home/u/.fab-kit/config.yaml — use: fab config unset agent.workers --system); an environment tier is named as one unset cannot remove, and a key supplied only by the built-in default keeps the bare notice. Both notices resolve the cascade through the same shared tier descent the keyed --origin listing uses, and both are fail-open — an unresolvable repo or unreadable layer prints nothing rather than failing a write that already succeeded. System writes accept only system/both scope, retain the system managed fence, and create a missing file with the canonical fence-owned header inside that fence. The shared parser remains collection-aware for environment overlays, where lists/maps are valid in flow or block style; whitespace-only, comment-only, multi-document, and bare-date (!!timestamp) env spellings warn-and-skip per-variable, fail-open.

  • fab config init [--system] [--print] [--force] — bare selects project generation; --project remains compatible, and passing both explicit modes errors. --print renders the exact file init would write to stdout with zero writes, composing with both modes and never blocked by an existing target (it is a preview, not a write — combined with --force it stays a pure preview). --force replaces the existing-file refusal with an explicit overwrite; refusal remains the default without it.

No project migration for the system scaffold. The first upgrade --system normalizes the older unfenced machine file in place, but the project migration pipeline is the wrong vehicle: one ~/.fab-kit/config.yaml is shared by every repo, so a per-project migration would target it repeatedly with the wrong cardinality. Adoption belongs to the idempotent machine-targeted upgrade path itself.

fab config upgrade — the Managed Fence & Shared-Writer Invariant

Comments are regenerable generated output inside a managed fence. internal/configupgrade is the only engine that writes existing config files: upgrade owns whole-file reconciliation, while set/unset splice one known path and reuse the same fence renderer and atomic writer. One reconciliation engine serves both config layers — a Target descriptor (configupgrade.ProjectTarget() / configupgrade.SystemTarget(), carrying path, field filter, and fence preamble; the system header lives INSIDE that preamble, so no deployed text above the fence) is threaded through computeUpgrade, and Upgrade and Check both delegate to it; there is no forked system-side logic. Field renames/removals remain mechanical registry data rather than hand-written comment migrations.

A/B/C field-category model (spec docs/specs/config.md § Advertise semantics):

  • A) live (user-overridden) fields — kept verbatim, byte-for-byte, including the user's own comments on them. Presence = intent (decision 2): a live field is an override even when its value equals the default, and is NEVER auto-removed. B-hygiene ("these fields equal current defaults — remove?") is an advisory report line only — never a mutation.
  • C) target-eligible fields not currently overridden — regenerated as a fully-commented scaffold inside the managed fence (below). Project targets use advertise: true; system targets use scope: system/both. The fence omits fields already overridden as live keys above it.
  • Unknown fields (live top-level keys not in the registry) — parked, never silently deleted (below).
  • Renames — a live top-level key matching some registry row's RenamedFrom is carried to the new key mechanically (value verbatim). The carry rewrites a column-0 key: token and preserves the block below it verbatim, so it addresses top-level renames only: both rows carrying RenamedFrom today (agent.profilesagent.tiers, and providers ← the nested command-field spellings) are renames inside a block and are deliberately skipped, leaving no shipped row this path can fire for. A nested rename is carried by a shipped migration plus a read-time alias instead (§ agent, § providers). Hardened to skip the carry when the target key is already live (no clobber) and to guard against nested renames.

The managed fence. The C-field scaffold lives between two byte-exact splice anchors (internal/configupgrade constants, fenceWidth = 76 dash-padding):

  • BEGIN# >>> fab reference (kit %s) >>> + dash pad (the %s is the running binary's kit-version stamp, making staleness visible to the --check drift probe below);
  • END# <<< end fab reference <<< + dash pad.

Upgrade rewrites ONLY the region between (and including) the anchors; everything outside is the user's. The parameterized explanatory preamble names the selected command (fab config upgrade or fab config upgrade --system); for the system target, the precedence header is fence-owned — it heads the fence preamble inside the anchors rather than a preamble above them, so the above-fence region belongs to the user's live keys and comments alone (no code path can be two writers for the same lines; see the ownership Design Decisions below). Every scaffolded block is fully commented including parent keys, and every fence marker lands at column 0. configupgrade.CommentOutSegment skips only prose already commented at column 0 and prefixes everything else, preserving exact reversibility for indented commented content such as the agent block's profiles: example lines. A legacy project file gets a fence appended. A legacy unfenced system scaffold is adopted: a comment paragraph is discarded only when every line in it is accounted for as generated output — matched by whole-paragraph byte-exact identity against the registry's current renderings (the system scaffold header and each field's ShortSegment) plus a sha256 digest catalog of historical released renderings (knownGeneratedSystemParagraphDigests, covering v2.15.1–v2.23.8's above-fence header, append-only; TestGeneratedSystemParagraphCatalogIncludesCurrentRenderer fails a registry/prose change until its new digest is registered). Any unaccounted line — a hand-written note, an edited value — preserves the whole paragraph as user content, even when that leaves a visible duplicate below the new fence: duplication is the accepted cost, deletion is not. Live keys are preserved verbatim and hoisted above the one new system fence, unrecognized comments alongside them. The line-complete rule holds identically for unfenced adoption, later --system upgrades, and the surgical set --system / unset --system path — and it is now gated by an explicit adoptLegacyFile predicate decoupled from the former header-presence early return, so blanking the system header for relocation could not silently disable legacy adoption. Non-parked content below an old fence is hoisted above it and preserved.

Two header copies on a hand-edited file is a known benign edge, not a defect. A user-edited above-fence header passes R10a's accounting as user content, so upgrade preserves it above the fence while the fence carries the canonical text — the documented duplication-over-deletion direction. It is self-limiting: deleting the above-fence copy sticks, since no code path regenerates user territory above the fence.

Fence adverts are short, scope-annotated pointers. Each field advert rendered into either fence (and into init --system's scaffold) comes from the registry row's ShortSegment: a short description line carrying the row's [project|system|both] scope tag from the internal/configscope taxonomy, a # Full prose: fab config explain <key> pointer, and — for scope: both (preference-class) fields — a # Settable machine-wide: fab config set --system <key> <value> pointer instead of an uncomment-in-repo invitation. The long-form essays stay on the row's Segment and remain fab config explain's output at unchanged depth; generated files point at them rather than duplicating them. Byte-stability and the golden/idempotence tests pin the short shape.

Parked removals. A live top-level key not in the registry is parked in a # removed in <ver> (parked by fab config upgrade — delete when done): comment block below the fence, with the user's value serialized in the comment. A registry-known key that is out of scope for the target (e.g. a scope: project key found in the machine-level file) parks the same way under a distinct # out of scope for this config layer — registry-known, unused at this layer (…) header — it was not removed; the layer ignores it. Parkings are user territory: appended exactly once, never regenerated away on a subsequent run (a parkedHeaderRe recognizes an already-parked block of either header form). Because a removed field has no live registry version to name, the header uses the stable phrasing an earlier release (parkedVersionPlaceholder) rather than a made-up version.

Parse-validate-refuse + byte-stability. Upgrade parses the existing YAML and refuses to write if it does not parse (never corrupts a broken file further). The output is byte-stable and idempotent — running twice yields a byte-identical file — enforced by golden tests (golden_test.go, full-document literal got != want over a small synthetic field set, per the memoryindex/golden_test.go precedent) and an idempotence test (freeze_test.go). yamlScalar escapes backslashes/control chars so serialized parked values round-trip.

Modes and --check. Bare/--project reconciles the project file and requires a repo; --system reconciles ~/.fab-kit/config.yaml repo-free; --all runs both with labelled result lines and therefore requires a repo. The mode flags are mutually exclusive. --check composes with each mode and shares the target-parameterized Upgrade render + validate computation while writing nothing. It exits non-zero on selected-layer drift; --all --check exits zero only when both layers are clean. Files remain byte-identical before and after a check.

Single-writer invariant. Every mutation of an existing config routes through internal/configupgrade; no command-local masher or whole-document marshal exists. fab_version remains outside the file in fab/.fab-version.

fab config init --project — Registry-Driven Init Generation

Bare fab config init selects project mode; --project is the compatible explicit spelling and remains mutually exclusive with --system. Project generation still writes the A-class identity fields live above the shared managed fence, refuses overwrite, and pins no agent profile.

The registry carries an InitSeed bool per-field flag (configref.Field) marking the A-class identity rows written live at init, exposed via configref.InitSeedKeys(). The generator takes seeds as flags (fab-kit's detection is the input, see kit-architecture.md / setup.md); InitSeedKeys() is a test-anchored invariant (TestConfigInitSeedKeysSubsetOfRegistry — init-seeded keys ⊆ registry keys).

config.yaml Schema

fab/project/config.yaml SHALL contain the following sections:

fab_version — lives in fab/.fab-version, not config.yaml

The version pin lives in fab/.fab-version, NOT in config.yaml (j0qm). It is a one-line plain-text file (bare semver + \n), committed, sibling to fab/.kit-migration-version — written by fab-kit's stampFabVersion(repoRoot, version) (shaped like stampMigrationVersion), called from Init and Upgrade. Kept separate from .kit-migration-version: the deployed-kit version and the migration baseline diverge exactly when migrations are pending, which is when both are needed distinctly. Keeping the pin outside config.yaml leaves internal/configupgrade as the sole writing engine — no separate version-stamp masher remains in the file (see § fab config upgrade above and kit-architecture.md § Version Tracking).

"committed" is actively defended (8ken). Two mechanisms make "committed" real: a !fab/.fab-version negation in .gitignore (shipped in the scaffold fragment, self-healed on every fab sync; verified + committed for already-shipped repos by the 2.15.1-to-2.15.2 migration — see kit-architecture.md and migrations.md), and a fail-open stamp-time warning (warnIfFabVersionIgnored, wired into stampFabVersion's Init+Upgrade callers) that prints a fab: warning: to stderr whenever the just-stamped file is still ignored. Why both exist: see § Design Decisions → "fab/.fab-version Ignore-Class Defense".

fab/.fab-version is the sole version source for both reader stacks — config.yaml's fab_version: key is never consulted:

  • fab-go internal/config resolves Config.FabVersion from fab/.fab-version only (readDotFabVersion(fabRoot) overlaid in Load; the sole consumer is preflight's staleness check). Config.FabVersion is tagged yaml:"-", so a stale fab_version: in config.yaml is an inert unknown key that nothing unmarshals.
  • fab-kit readFabVersion(repoRoot) reads fab/.fab-version only (dotFabVersionRelPath = "fab/.fab-version") — feeding ConfigResult.FabVersion (router pinned-version resolution, sync version guard, upgrade-repo, migrations-status). An absent/empty file is the error case.

The 2.14.0-to-2.15.0 migration moves the value and deletes the key for a pre-2.15 repo (see migrations.md). A repo never migrated past 2.15.0 (pin still only in config.yaml) hard-fails fab-kit router resolution with no fab version found in fab/.fab-version. Run 'fab init' (new repo) or 'fab upgrade-repo' (existing repo) to set one; recovery is fab upgrade-repo. Format: bare semver string (e.g., "2.15.0"). Not user-editable — machine-managed. The registry carries no fab_version row (configref.Fields()) and internal/configscope's keyScopes carries no "fab_version" entry, so fab config explain does not document it (the key is out of the file).

project

  • name — Project name (string)
  • description — Project description (string)
  • linear_workspace — Linear workspace slug (string, optional). When present, /git-pr constructs issue hyperlinks using https://linear.app/{linear_workspace}/issue/{ISSUE_ID}. When absent, issue IDs are rendered as bare text. Set once per project. Also the config gate for /fab-issue (see issue-linking) — when unset/null, the skill skips Linear linking entirely.

checklist

  • extra_categories — Project-specific quality categories added to the default checklist categories (functional_completeness, behavioral_correctness, scenario_coverage, edge_cases, code_quality, security)

consolidate

  • detectors — duplicate-detection commands /code-dedupe runs to seed its sweep (4v91). A list of shell command templates; {paths} (the resolved scope) and {out} (a scratch dir) are substituted at run time as shell-quoted values, so paths carrying spaces or shell metacharacters stay intact arguments rather than splitting or injecting into the command. Absent → the skill defaults to jscpd alone. Skill-consumed (invisible to Go reflection over Config — the registry row is what documents it), Scope: project, Advertise: true, Default: nil. A detector whose binary is missing is skipped silently; a non-zero exit is treated as a finding, not an error. See pipeline/code-dedupe.md for the sweep contract.

There is deliberately no consolidate.memory_file key — /code-dedupe's utilities memory home is hardcoded to docs/memory/_shared/utilities.md.

providers

Top-level map holding each agent's capability grammar and per-role fills, keyed by opaque provider names. ProviderConfig carries interactive_command, headless_command, native, and profiles. agent.ResolveProvider merges user fields over the embedded built-ins per field; explicit native: false disables a built-in native capability, so YAML-presence tracking distinguishes it from an omitted value.

  • interactive_command declares how to open an interactive session and is the pane-dispatch prerequisite.
  • native declares that the provider can run through the native Agent tool. Fab never infers this from the provider name.
  • headless_command declares how to run one headless stage task. The prompt is supplied on stdin.
  • profiles supplies per-role {model, effort} fills with precedence invocation flag > agent.profiles.<role> field > providers.<p>.profiles.<role> > providers.<p>.profiles.default > empty.
  • Deprecated spellings (read-time alias). The pre-2.19 session_command/dispatch_command keys are still decoded and read as per-field fallbacks: a non-empty new spelling wins, independently per field, so a half-migrated config resolves everything. The alias is silent (no deprecation warning). ProviderConfig keeps both yaml tags, so decode retains whichever spelling a layer carries; the per-field preference itself is applied by agent.ResolveProvider after the cascade merges, so every cascade layer — env FAB_PROVIDERS values included — resolves both spellings. It is a file-read affordance only: the fab config set/unset/explain/show dotted-key matcher accepts the new spellings exclusively. The 2.18.1-to-2.19.0 migration rewrites both scopes on disk.

These fields describe how a provider can run; none selects whether a mode runs. dispatch.mode owns policy and tests the capability fields independently while descending pane → native → headless. A missing capability skips that rung during automatic selection; a forced mode that lacks its required capability is a hard error. The command fields are never merged or substituted for one another.

Fab ships claude, codex, agy, and kimi as built-ins. claude has all three capabilities (native: true plus both commands); codex, agy, and kimi have interactive and headless commands and no native capability. agy's interactive grammar is exactly agy --dangerously-skip-permissions --model {model}; kimi's is kimi --auto -m {model}, and kimi deliberately ships no fills. A fresh agy workspace may stop at an exact-path trust wall, which the ordinary readiness gate handles; operators may seed the exact path in trustedWorkspaces in ~/.gemini/antigravity-cli/settings.json, while fab does not write the provider trust store. Claude's headless grammar is exactly claude -p --permission-mode bypassPermissions --model {model} --effort {effort}. User entries extend or override the built-ins without changing the mode policy merely by being present.

Provider names and model/effort values are opaque, unvalidated pass-through strings. {model}/{effort} substitution continues through spawn.WithProfile; a role's provider is always resolved before its fills, preventing cross-provider leakage. The pre-2.17.0 flat provider model/effort spellings remain read-time aliases for profiles.default, exactly as the pre-2.19 command-field spellings do for the renamed keys (the bullet above).

{model}/{effort} placeholder substitution (template mode — reuses spawn.WithProfile) (6tmi). Both command fields MAY contain the literal placeholders {model} and/or {effort}, which relocate provider grammar into the config (consistent with the resolver's verbatim/no-validation philosophy). spawn.WithProfile(cmd, model, effort) — the one place fab composes a command line — operates in one of two modes selected by placeholder presence:

  • Template mode (the command contains {model} or {effort}): substitute every occurrence of each placeholder with the resolved value. Template mode is all-or-nothing — the presence of any placeholder disables the append below entirely, so a value whose placeholder is absent from the template is simply not injected (this prevents e.g. a Claude --effort flag being appended to a codex command that only templated {model}). Example: 'codex exec --dangerously-bypass-approvals-and-sandbox -m {model} -c model_reasoning_effort={effort}'.

  • Append mode (no placeholder): the Claude-shaped behavior, byte-for-byte — append --model <id> then --effort <level> to the end (each omitted when its value is empty). This mode stays load-bearing for a user's plain-form provider command carried forward by the 2.13.0 migration (a pre-2.19 config holds it under the old session_command spelling, which the read-time alias resolves until the 2.18.1-to-2.19.0 migration rewrites the disk). The built-in claude interactive_command / DefaultInteractiveCommand fallback is itself a {model}/{effort} template (260703-gvxd) — it resolves via template mode, with the placeholders placed last.

    Empty-value token-drop rule (template mode). An empty model/effort is the documented "inherit/omit" signal (see context-loading.md § Per-Stage Model Resolution). On substitution of an empty value the command is tokenized on whitespace and the token containing the placeholder is dropped, along with the immediately preceding token when it begins with -. This deterministically handles the four common flag shapes without a templating engine: -m {model} → both tokens dropped; --model {model} → both dropped; --model={model} → the single token dropped (no preceding --flag); -c model_reasoning_effort={effort} → the ...={effort} token and the preceding -c dropped. When every substituted value is non-empty, resolution is a plain strings.ReplaceAll on the raw string, so the author's whitespace is preserved exactly — tokenization applies only on the empty-value drop path — whitespace preservation (6tmi). The token-drop grammar is quote-blind and limited to those four value-carrying flag shapes; a placeholder inside quotes or preceded by a valueless flag (e.g. --verbose {model}) is outside the supported grammar.

Four built-in providers rendered live and uniformly. fab config explain (internal/configref) presents the providers: block as claude/codex/agy/kimi, each command string interpolated from its canonical internal/agent symbol — which reads the embedded defaults.yaml — so the reference holds no literal copy. All four blocks render live at the same indentation: in a managed fence or init --system scaffold (configupgrade.CommentOutSegment) every provider line carries exactly one #, and stripping that one layer from a whole block restores the live text byte-exactly. The prose above the blocks carries the pinning warning: a hoisted block's fills become a live override that pins the shown values against kit-release refreshes, so the preferred override is a single field (providers.<name>.profiles.<role>.model). The registry row's Description and Segment document the profiles fills, including the precedence position, the profiles.default cross-role fallback, and the scope: both machine-wide-fill affordance. The block is advertise: false, so it is documented here in the reference and not scaffolded into each project's managed fence.

  • Claude's native, interactive_command, and headless_command restate its three built-in capabilities. The command's presence does not select headless mode; dispatch.mode does.
  • The codex, agy and kimi blocks render live like claude's, each showing the command fields its built-in actually defines plus its real shipped profiles: fills — interpolated from agent.ResolveProvider(nil, name).Profiles in agent.RoleNames() order (no map range-iteration, so the rendering is byte-stable), with the renderer's omitempty shaping so agy's model-only rows and codex's effort-only rows each render as written, and a note to override one to pin a newer model. kimi has no fills, so profilesLines returns "" and its block renders no profiles: key at all rather than a stray empty one. A block hoisted out of a fence registers the same values as a project override — which is exactly the pinning the prose warning exists to discourage.

Every provider's map is printed, claude's included. The rendered reference is the user-facing half of the block, so a reader never has to reach for --json to see what the default provider resolves. The same values are also projected by fab config explain providers --json (providerDefaults() walks them all) and resolved per stage or role by fab agent <stage|role> -o yaml. The fill lines interpolate from the resolved provider table rather than literals, so a defaults.yaml bump reaches the rendered reference — and the test that pins it — without either side being hand-edited.

Two facts on the agy and kimi strings:

  • Neither carries an {effort} placeholder. agy's model IDs embed the reasoning level as a suffix (gemini-3.1-pro-high) and kimi ships no fills at all, so a resolved effort has nowhere to go in either grammar and is simply not injected.
  • Both headless commands nest a shell: sh -c '<cli> … -p "$(cat)"'. fab dispatch pipes the stage prompt to the command's stdin, but both CLIs take the prompt as the argument to -p and never read stdin. POSIX expands $(cat) before the redirect applies, so the un-nested form would read the outer stdin; nesting makes the inner shell's stdin the redirected prompt file. (codex needs no nesting — it reads its prompt from stdin via the exec subcommand.)

Presentation guarantees. All four provider blocks render live and parse as valid YAML restating the built-ins exactly (GetProvider returns each with its built-in commands). All strings derive from the embedded defaults and render as single-quoted scalars via the exported configref.YAMLSingleQuoted — the one owner of the quote-doubling rule the nested-shell agy/kimi headless commands depend on — so whole-block uncommenting from a fence remains valid YAML, and byte-stability/coverage guards pin the four-provider capability and fill surface.

agent

Holds the agent-selection surface: two advertised depth knobs plus a sparse per-role override beneath them. The invocation grammar lives in the top-level providers: table (see § providers). Modeled on Config.Agent AgentConfig (yaml:"agent"); yaml.v3 ignores unknown keys, so widening the struct is free for existing configs.

  • session / workers — the two advertised knobs, each naming a provider, selecting it by agent depth. session governs the Tier 1 roles (the agents a user talks to — default, operator); workers governs the Tier 2 roles (the agents pipeline stages dispatch to — doing, review, hydrate, fast). Both default to claude, both are scope: both (settable once machine-wide), and both are advertise: true — they are the whole advertised agent surface. Read via nil-safe GetAgentSession()/GetAgentWorkers(), where "" means "not configured" (the built-in fallback lives in internal/agent, not here).

    agent:
      session: claude    # Tier 1 — what you talk to: fab agent, fab operator, fab batch
      workers: agy       # Tier 2 — what pipeline stages dispatch to
    

    A knob supplies only the provider rung; model and effort come from that provider's own per-role fills, which is what makes naming a provider a complete configuration. The role→depth partition is fab-owned and NOT user-overridable — as is the stage→role mapping. See runtime/providers-and-profiles.md § Two depth knobs.

  • profiles — the sparse per-role escape hatch beneath the knobs, and advertise: false (machinery: documented in fab config explain, not scaffolded into each project's fence). Keys are the six role names — default, operator, doing, review, hydrate, fast; each value is a {provider, model, effort} object with every field optional. A set provider beats the role's depth knob; a set model/effort beats the resolved provider's fill. Modeled on AgentConfig.Profiles map[string]RoleProfile, read via the nil-safe GetAgentProfile(role) (RoleProfile, bool) — the bool distinguishes "no override" from "override present with empty fields", which the resolver needs.

    agent:
      profiles:
        review: { provider: codex }                   # run just the critic elsewhere
        doing:  { model: <model-id>, effort: high }   # shape only
    

    There is no cross-role inheritance on the agent side. agent.profiles.default is the default role's own override, never a fallback source for the other five. The system's one cross-role fallback is providers.<p>.profiles.defaultproviders), which is what makes cross-provider leakage structurally impossible and leaves no cutoff rule to document.

    For the current built-in values, run fab config explain — it renders live from the embedded defaults, so it cannot go stale. This doc deliberately does not restate the per-role model IDs; the one human-readable mirror is the drift-guarded table in docs/specs/stage-models.md § Default role profiles.

    Intake rides the default role — it is pre-boundary and never dispatches, so it runs wherever the interactive session runs, which is also why default sits at the session depth.

  • tiers — the deprecated pre-2.17.0 spelling of profiles, read per role as a fallback (GetAgentProfile prefers profiles wherever it carries the role, so a half-migrated config resolves every role). The agent.profiles registry row records it via renamed_from, and the 2.16.19-to-2.17.0 migration performs the on-disk rewrite in both scopes. One semantic the rewrite cannot preserve: the old map re-based every unset field from its default tier, and that inheritance has no successor — the migration warns on the shape rather than guessing the intent. The alias resolves after the scope cascade, so during the pre-migration window a system-layer agent.profiles.<role> beats a project-layer agent.tiers.<role> (pinned by TestResolveCrossScopeLegacyAliasPrecedence); see runtime/providers-and-profiles.md § Deprecated spellings stay readable.

What the block overrides — only what a role means (provider + model + effort), never which stages belong to a role. There is no stage_roles map and no per-stage escape hatch. The six roles, their fixed referents, and fab-kit's built-in profiles are owned by the Go internal/agent package (mirrored in docs/specs/stage-models.md, drift-guarded):

RoleFixed referents (NOT overridable)Depth
defaultintake (advisory only — foreground); spawned worker sessions (fab batch), fab agent with no role; the /fab-proceed create-intake dispatchTier 1
operatorthe operator coordinator session (fab operator)Tier 1
doingapply, review-pr — execution that must not errTier 2
reviewreview — the critic (its own role so its model/effort dial independently of the author's)Tier 2
hydratehydrate — memory writing (its own role so it runs on a different model/effort than apply)Tier 2
fastship — near-mechanical work — plus the /fab-proceed prefix steps (/fab-switch, /git-branch)Tier 2

The roles above are stable; the profiles are not restated here (fab config explain, or stage-models.md § Default role profiles).

A role is stage-named only where it maps 1:1 to a single referent (review, hydrate); default, doing, and fast keep role names because each is multi-referent (fast governs the ship stage AND the /fab-proceed prefix-step dispatches).

review vs review-pr are deliberately different roles despite the shared word: review is the critic (discovers what's wrong from a diff → review); review-pr is responsive (fixes already-articulated feedback → doing). Do not group them. hydrate (memory writing) is its own role — the odd one out from the author-role stages.

Documented style: reach for a knob first. agent.workers: codex expresses the common intent in one line; agent.profiles.<role> is for dialing a single role. Model IDs are written versioned (claude-sonnet-5, claude-opus-5): bare family IDs (claude-sonnet) are invalid at the API and miss ModelAlias's trailing-hyphen prefix match, so they fail both dispatch seams.

Resolution is provider-neutral with NO validationfab agent <stage|role> -o yaml (_cli-fab.md is the canonical CLI reference) maps stage → role → {provider, model, effort} and projects the strings verbatim. fab does not validate the model or effort against any provider's accepted set (Constitution Principle I); a misconfigured pair (e.g. Sonnet + xhigh) is NOT corrected by fab and surfaces as a dispatch-time error in the harness. This is what lets a project switch the underlying agent by naming another provider on a knob and supplying that provider's model IDs and effort vocabulary (gpt-5 / reasoning_effort:high, etc.) with nothing in fab rejecting it. Haiku is excluded from the defaults (no effort param — passing effort 400s — and ship needs faithful PR-description comprehension), but a user MAY still override a role to Haiku via pass-through. The dispatch wiring (the orchestrators / /fab-continue calling fab agent <stage> -o yaml before each stage's sub-agent) is documented in context-loading.md § Per-Stage Model Resolution. See pipeline/change-lifecycle.md for where this sits in the lifecycle, and runtime/providers-and-profiles.md for the full model.

Non-pipeline consumers. The stage pipeline is not the only consumer. fab operator (the standalone coordination command — NOT a pipeline stage) launches its coordinating agent on the operator role, and fab agent [role] resolves any role for interactive launch — both Tier 1, so agent.session is the knob that governs them, and it binds at launch rather than per dispatch. See runtime/operator.md and runtime/providers-and-profiles.md.

dispatch

Machine-level dispatch preferences. Modeled on Config.Dispatch DispatchConfig (yaml:"dispatch"), read via the nil-safe GetDispatchMode() / GetDispatchColumnWidth() / GetDispatchReapDone(). Scope: both for all three keys — a single ~/.fab-kit/config.yaml setting covers every repo on the machine, while a project value overrides it. The three built-in default values live only in the dispatch: block of the module-root embedded defaults.yaml; the exported config.DefaultDispatch* symbols are package-level vars carrying no literals, assigned from the parsed file at package init — see § Design Decisions → "Dispatch Defaults Are Init-Injected from defaults.yaml".

All three keys live under one registry-rendered YAML block: dispatch.mode's Segment documents and scaffolds all of them, and dispatch.column_width and dispatch.reap_done each carry an empty Segment of their own — the same project.name/project.description/project.linear_workspace shape. Whole-file project reconciliation detects an existing override at top-level-block granularity. System reconciliation and surgical set/unset instead derive live dotted leaves and reduce the shared fence segment, so a live dispatch.mode suppresses only that leaf while non-live siblings such as dispatch.column_width remain advertised. See § Design Decisions → "Several Registry Rows Under One YAML Block Share a Single Segment".

  • mode (enum pane | native | headless, default native) — the preferred ceiling of the descent ladder. Resolution starts at that rung and moves only downward through pane → native → headless, choosing the first rung whose prerequisites hold. Pane requires reachable tmux plus interactive_command; native requires native: true; headless requires headless_command. It never ascends above the configured preference. Invalid values warn and resolve to native; no watchable compatibility alias is read.
  • column_width (int percent, default 35 — the canonical symbol config.DefaultDispatchColumnWidth, which the registry row interpolates rather than copying) — the width of the pane-worker column a pane-mode stage worker opens into. The first worker carves the column out of the dispatching agent's pane (tmux split-window -h -l <n>%), so the agent the user is watching keeps the remaining 100 − n%; later workers stack inside that column with unsized -v splits, and the Left/Right separator is never touched again. An absent key, or any value outside 1..99, resolves to the default: an absent YAML int is indistinguishable from 0, so 0 cannot mean "unsized", and 0/100 are degenerate widths that would leave one side nothing. A tmux too old for -l <n>% (pre-3.1) degrades to an unsized split with a warning rather than failing the dispatch — placement is cosmetic. The placement rule the width feeds lives in runtime/dispatch.md § Split placement.
  • reap_done (bool, default true — the canonical symbol config.DefaultDispatchReapDone, which the registry row interpolates rather than copying) — done-worker pane reaping. When true, fab dispatch reap kills a pane-mode stage worker's tmux pane once its {stage}-result.yaml is present, reclaiming the column space a finished worker would otherwise hold for the rest of the run (a pane worker never exits on completion — it sits at its prompt). Set false to keep a done worker's pane and its scrollback. Reap is not kill: it never touches a running/orphaned/failed dispatch, is a reported no-op for a headless record, and removes no .fab-dispatch/ state. Modeled as ReapDone *bool, unlike its two siblings: the default is true, so nil (unset) reads as true and an explicit reap_done: false stays distinguishable from absent — see § Design Decisions → "A Default-True Bool Needs a Pointer". The verb it gates lives in runtime/dispatch.md § fab dispatch reap.

autopilot

Machine-level autopilot preference — modeled on Config.Autopilot AutopilotConfig (yaml:"autopilot"), read via the nil-safe GetAutopilotMergeMode(). Scope: both — a single ~/.fab-kit/config.yaml setting covers every repo on the machine, while a project value overrides it — and Advertise: true with its own Segment/ShortSegment rendering a commented top-level # autopilot: block of its own (no other row owns that parent, unlike dispatch.column_width riding dispatch.mode's). The built-in default lives only in the autopilot: block of the module-root embedded defaults.yaml (merge_mode: cherry-pick-ladder), init-injected into the literal-free config.DefaultAutopilotMergeMode var — the DefaultDispatch* pattern (§ Design Decisions → "Dispatch Defaults Are Init-Injected from defaults.yaml"). The valid-modes enum is exported once as config.ValidAutopilotMergeModes; cmd/fab and the registry row both reference that symbol — no second literal list exists (§ Design Decisions → "The Autopilot Merge-Mode Default Rides defaults.yaml; the Enum Stays Go-Side").

  • merge_mode (enum cherry-pick-ladder | merge-auto | stacked-prs, default cherry-pick-ladder) — the standing merge-topology preference for fab operator autopilot start. The accessor does absent→default only and returns an invalid value raw — a deliberate divergence from GetDispatchMode's fail-open warn (§ Design Decisions → "The Autopilot Merge-Mode Accessor Returns Raw; start Owns Validation"). start resolves explicit --mode flag > config > built-in default (flag-absent detected via flag-changed state, so an explicit --mode cherry-pick-ladder still reports source flag), prints mode: <name> (<source>) with source ∈ flag / config / default, and an invalid config value exits non-zero naming the key and the valid set, writing no state — merging is destructive-tier, so there is no silent fallback to a different topology than the one configured. The config load works from a neutral (fab-less) cwd: with no fab/ project up the tree, the system tier (~/.fab-kit/config.yaml) and env (FAB_AUTOPILOT_MERGE_MODE, via the generic mapping) still compose, and a config load error fails soft to the built-in default. A pre-existing autopilot state block lacking mode reads as the built-in default — never config-resolved (the mode was fixed at queue start and persisted). The operator-side rule that consumes this (silent resolution, enumerated misfits) lives in runtime/operator.md § Autopilot.

true_impact_exclude

Optional top-level field. A YAML sequence of pathspec exclusion patterns — typically directory prefixes ending in / (e.g., fab/, docs/, vendor/), but any pattern accepted by git diff :(exclude)<pattern> syntax is valid. The registry row advertises [fab/, docs/] in the managed fence, so a project that opts in emits the impact block out of the box.

Semantics:

  • Present and non-empty — the Impact table of the PR ## Meta block (rendered by fab pr-meta) (rj31) names the excluded scope in its <sub> provenance caption (<sub>excludes fab/, docs/ · generated by fab-kit vX.Y.Z</sub> — no separate **Impact**: lead-in line) (pnao), the caption reflecting the config values verbatim (each per-element backtick-wrapped, never hardcoded). Computed via git diff --shortstat "$BASE...HEAD" against the merge-base with and without the :(exclude)<pattern> pathspec args (canonical math in internal/impact).
  • Absent, null, or [] — the impact block is omitted entirely; the rest of the PR body matches the no-block output byte-for-byte.
  • No fab context (fab/project/config.yaml does not exist) — the block is omitted silently, preserving /git-pr's fab-optional behavior.
  • True-impact pass returns zero (every modified file falls inside an excluded path) — the entire block is omitted to avoid a misleading +0 / −0 line.

Consumed by the impact engine (internal/impact) and its consumers: the fab pr-meta subcommand (which renders the PR ## Meta block Impact line for /git-pr, which does not assemble it inline) (rj31), the fab impact CLI, and the apply/hydrate/ship true_impact write path. The exclude values are wrapped per-element in backticks in the rendered Impact line (never hardcoded).

test_paths

Optional top-level field (7t5a). A YAML sequence of glob/pathspec patterns identifying test files, mirroring the source_paths / true_impact_exclude style (top-level list of strings). Read into config.Config as TestPaths []string (yaml:"test_paths").

Purpose — attribution, NOT exclusion. test_paths does NOT strip lines from the impact universe — it attributes the already-scaffolding-excluded lines to tests vs. implementation, so reviewers see the split (e.g. "140 impl / 400 test") instead of one conflated number. This is orthogonal to true_impact_exclude, which removes scaffolding/doc noise from the universe entirely. Tests are first-class deliverables (constitution principle VII, Test Integrity), not scaffolding noise — adding test patterns to true_impact_exclude was explicitly rejected because it would conflate the two axes and destroy the test-coverage signal.

No kit default (Portability, constitution principle V). fab-kit is language-agnostic; there is no universal test-file pattern (*_test.go, test_*.py, *.spec.ts all differ by language), so the kit MUST NOT ship a hardcoded default test_paths list. Instead, the value is detected per project at setup: fab-kit auto-detects the project's ecosystem from on-disk marker files (go.mod→Go, pyproject.toml→Python, etc.) non-interactively and passes the derived patterns as repeatable --test-path flags to fab config init --project, which writes them live above the managed fence (see setup.md § marker table and § fab config init --project above). Unrecognized or inline-test stacks (e.g. Rust) are left empty — the fence advertises the key instead and the breakdown collapses to a single total. Existing projects are backfilled by the 2.7.1-to-2.8.0 migration (same detection; skips projects that already set test_paths). This repo dogfoods it with test_paths: ["**/*_test.go"].

Glob/pathspec format. Include patterns are applied as :(glob)<pattern> magic pathspecs in the test-only git diff --shortstat pass, so wildcards behave like .gitignore-style globs — notably ** matches across directory boundaries, so **/*_test.go matches both root-level foo_test.go and nested pkg/foo_test.go. (Without :(glob), plain git pathspec rules treat * as not crossing /, silently missing root-level test files and under-counting tests.) The pass combines these includes with the same :(exclude) args derived from true_impact_exclude, so tests are counted within the scaffolding-excluded universe; when true_impact_exclude is empty, the pass runs with the includes alone (tests attributed within the raw universe).

Graceful collapse. When test_paths is absent, null, or [], the impact engine skips the test pass entirely (Result.Tests stays nil), no tests sub-block is written to .status.yaml, and impl/tests rendering collapses to today's single-number display — matching the existing lazy-omit posture of the excluding sub-block.

Consumed by the impact engine (internal/impact/, via ComputeForRepo) and rendered by the /git-pr PR body (the fab pr-meta Meta-block Impact table — nested └ impl/└ tests rows under the bold true row) (pnao) and fab change list --show-stats (compact {impl}i+{tests}t={total} column). See the true_impact block in schemas.md for the tests sub-block schema and the render-time impl = max(0, total − tests) residual.

stage_hooks

Optional map of per-stage pre/post shell commands honored by fab status start/finish — live Go behavior (config.go, status.go) (c5tr); _cli-fab.md § stage_hooks is the canonical reference. Not seeded by the scaffold — add the key by hand. Shape: stage_hooks.{stage}.pre/post, each value a command line executed as sh -c from the repo root with stdout/stderr passthrough; absent/empty hooks (or a missing config file) are silent no-ops. A failing pre hook blocks fab status start (the transition is not applied); a post hook runs after finish's transition is saved (stage already done, next stage already auto-activated). Caveats: finish's auto-activation does NOT fire the next stage's pre hook, and a failing post hook cannot be re-fired by re-running finish (done→done is rejected — run the hook command by hand, or reset the stage first). Migration 2.1.6-to-2.2.0.md explicitly preserves the key. See pipeline/change-lifecycle.md for the lifecycle-side summary.

context.md

fab/project/context.md is a free-form markdown file describing the project's tech stack, conventions, architecture, and domain context. Skills load this to understand the project landscape. For monorepos, use labeled sections per package. Optional — skills proceed without error if missing. Generated by /fab-setup during bootstrap from a scaffold template.

code-quality.md

fab/project/code-quality.md is a markdown file defining coding standards consumed during apply and review. Contains three sections:

  • ## Principles — Positive coding standards the agent follows as soft constraints during apply
  • ## Anti-Patterns — Patterns to avoid, checked during review with file:line references on violation
  • ## Test Strategy — Controls test timing. Valid values: test-alongside (default), test-after, tdd

Optional — skills proceed without error if missing. Generated by /fab-setup during bootstrap from a scaffold template.

code-review.md

fab/project/code-review.md is a markdown file defining review policy consumed by the validation sub-agent during review. Contains seven sections:

  • ## Severity Definitions — Three-tier priority scheme: must-fix, should-fix, nice-to-have. Overrides the hardcoded defaults in the skill prompt when present
  • ## Review Scope — What the sub-agent inspects (changed files only, exclusions for generated code/vendors)
  • ## False Positive Policy — How to suppress findings (inline <!-- review-ignore: {reason} --> comments)
  • ## Rework Budget — rework-cycle budget for the /fab-ff//fab-fff auto-rework loop. The Max cycles: {N} line is consumed (c5tr): _pipeline.md reads it at bracket entry as the {max_cycles} cycle cap, defaulting to 3 when the file, section, or line is absent. Only the cap is configurable; the escalation threshold (2 consecutive fix-code attempts) stays fixed
  • ## Project-Specific Review Rules — Project-specific validation rules
  • ## Parsimony Pass (optional) — Single field Enabled: true|false (default true when section absent or field unset). When true, the single review agent's parsimony validation step runs (per execution-skills § Review Behavior). When false, the parsimony step is silently skipped (treated identically to a skip-list match — other validation checks still run). This is the only parsimony-related project-level knob — the 100-line advisory threshold and the [docs, chore, ci] skip list are intentionally hard-coded in the kit (per intake rationale: "until real-world usage shows the defaults are wrong, a single set of numbers across projects keeps the surface area small")
  • ## Review Tools (optional) (tykw) — Toggles for the automated reviewers, listed as - <tool>: false bullets. Absent section / absent tool = enabled (list a tool false only to DISABLE it). One toggle: copilot controls the /git-pr-review Phase 2 Copilot request (the --tool copilot flag force-overrides it) — the only reviewer toggle the kit reads. The review stage invokes no external reviewer CLI, so it has no toggle here (see execution-skills § Reviewer Diversity Lives Outside the Review Block). The scaffold code-review.md ships this section as a commented block; fab/project/code-review.md writers seed it only when a tool is disabled

Note the author-vs-critic split: code-quality.md guides the writing agent during apply; code-review.md guides the reviewing sub-agent during review. Different cognitive modes, different concerns.

Optional — skills proceed without error if missing. Generated by /fab-setup during bootstrap from a scaffold template.

Hardcoded Kit Knobs (Not Configurable)

A few quality/shape thresholds are deliberately hardcoded in the kit, not surfaced in config.yaml — keeping a single set of numbers across projects keeps the config surface small until real-world usage shows the defaults are wrong:

  • Parsimony advisory threshold (100 lines) + the [docs, chore, ci] skip list — the only project-level parsimony knob is code-review.md's ## Parsimony Pass Enabled: true|false toggle (see above).
  • Memory tree shape bounds (tciy) — the ideal-shape guidance is hardcoded SHOULD guidance, NOT a config.yaml field: ~12 topic files per folder (soft upper bound; fab memory-index warns above it), ~5 lower floor, depth ≤ 3 (docs/memory/{domain}/{sub-domain}/{topic}.md), and a sub-domain earns its own index only when a cohesive cluster of ≥ 8 files exists. Reserved domains _shared/ (cross-cutting) and _unsorted/ (staging) are exempt from the width warning. The thresholds live in internal/memoryindex (WidthWarnThreshold = 12, MaxDepth = 3) and are documented as guidance in the hydrate skills, docs-reorg-memory's Shape Report, and docs/specs/templates.md. A config.yaml surface was explicitly deferred (YAGNI; it can be added with the follow-up rebalance change if needed).

constitution.md Structure

The constitution is the architectural DNA of a Fab project. It defines immutable principles that govern how specifications become code.

Structure

# {Project Name} Constitution

## Core Principles

### I. {Principle Name}
{Description using MUST/SHALL/SHOULD keywords. Include rationale.}

## Additional Constraints
<!-- Project-specific: security, performance, testing, etc. -->

## Governance
**Version**: {MAJOR.MINOR.PATCH} | **Ratified**: {DATE} | **Last Amended**: {DATE}

Purpose

  1. Enforce discipline — prevent over-engineering and architectural drift
  2. Ensure consistency — all code follows the same patterns
  3. Guide AI agents — principles constrain agent behavior during planning and implementation

How Skills Use It

  • /fab-setup generates it from project context (README, existing memory, conversation)
  • /fab-continue and /fab-ff load it when generating the intake and the unified plan.md (## Requirements + ## Tasks + ## Acceptance) at apply entry
  • /fab-continue (review) checks implementation against constitutional principles (not just the requirements)
  • Constitution violations found during review are flagged as high-severity issues

Versioning

Semantic versioning — MAJOR for principle removals, MINOR for additions, PATCH for clarifications. Changes to the constitution SHOULD be intentional and documented, not done as a side effect of a change.

Relationship Between Configuration Files

  • config.yaml holds project settings (identity, source/test paths, true-impact excludes, plan-acceptance categories, the providers: command table, the agent: depth knobs and per-role overrides, optional stage_hooks — the real consumed surface)
  • constitution.md holds principles and constraints (MUST/SHOULD/MUST NOT rules)
  • context.md holds free-form project context (tech stack, conventions, architecture)
  • code-quality.md holds coding standards (principles, anti-patterns, test strategy)
  • code-review.md holds review policy (severity definitions, scope, rework budget)
  • All five are loaded by the "Always Load" context layer (see context-loading); context.md, code-quality.md, and code-review.md are optional

Lifecycle Management

Updating Config

Run /fab-setup config to see all editable sections:

  1. project — name and description
  2. source_paths — implementation code directories
  3. checklist — extra plan-acceptance categories
  4. context.md — free-form project context
  5. code-quality.md — coding standards for apply/review
  6. code-review.md — review policy for validation sub-agent
  7. Done

Skip the menu with /fab-setup config <section> (e.g., /fab-setup config context); stage_directives is rejected as an unknown section.

Updates use targeted string replacement on the specific section being edited. Comments and formatting in other sections are preserved. This is important because config.yaml relies on inline comments for self-documentation.

ScenarioCommand
See every available config option (the full schema)fab config explain (canonical schema-discovery surface — see above)
Inspect one effective value and its sourcefab config show <key> --origin
Set one project or machine overridefab config set <key> <value> [--system]
Restore inheritance for one overridefab config unset <key> [--system]
Add a new tech to the stackEdit fab/project/context.md directly or /fab-setup config context.md
Add a new source directory/fab-setup config source_paths
Add a custom checklist category/fab-setup config checklist
Verify config after manual edit/fab-setup config (it validates after every edit — see § Validation)

Amending Constitution

Run /fab-setup constitution when constitution.md exists to enter amendment mode:

  1. Current constitution is displayed
  2. Amendment menu offers: add principle, modify principle, remove principle, add/modify constraint, update governance
  3. Multiple amendments can be made per session
  4. Version is bumped automatically based on change severity

When constitution.md doesn't exist, /fab-setup constitution generates one from project context: config.yaml, README, codebase patterns, and conversation.

Semantic Versioning

Constitution versions follow MAJOR.MINOR.PATCH:

Change typeBumpExample
Remove or fundamentally change a principleMAJOR1.2.0 → 2.0.0
Add a new principle or constraintMINOR1.2.0 → 1.3.0
Clarify wording without changing meaningPATCH1.2.0 → 1.2.1

When multiple amendments are made in one session, the highest-severity bump takes precedence (MAJOR > MINOR > PATCH).

Structural Rules

The constitution maintains a consistent structure:

  • Level-1 heading: # {Project Name} Constitution
  • ## Core Principles with Roman numeral headings (### I., ### II., etc.)
  • ## Additional Constraints
  • ## Governance with version, ratified date, and last amended date

When principles are removed, remaining principles are re-numbered sequentially.

Amendment summaries are included in the command output. The constitution file itself does not contain a changelog — git history serves as the authoritative record, and the version number provides semantic signal.

Validation

Validation is built into the editing subcommands, not a standalone pass. /fab-setup config and /fab-setup constitution each validate after every edit, offering to revert invalid changes.

/fab-setup validate is a redirect only: it prints "Validation is built into /fab-setup config and /fab-setup constitution — each validates after every edit." and STOPs, running no checks of its own. It is not listed among the valid subcommands (config, constitution, migrations) in the unknown-argument message.

See setup for the complete command suite.

Design Decisions

Config for Facts, Constitution for Principles

Decision: Separate factual configuration (config.yaml) from principles and constraints (constitution.md). Why: Config changes frequently (new dependencies, naming tweaks). Constitution changes rarely and deliberately. Separation makes the governance boundary clear. Rejected: Single config file mixing facts and principles — blurs what's configurable vs. what's immutable. Source: doc/fab-spec/ARCHITECTURE.md

Constitution Loaded Implicitly, Not Gated

Decision: Skills load constitution.md as context and are expected to respect principles implicitly. Constitutional violations are caught during /fab-continue (review), not gated at plan time. Why: Lightweight enforcement. A formal gate at every stage adds friction without proportional benefit — most violations are caught naturally by the agent. Rejected: Explicit "Constitution Check" gate in the plan template (SpecKit's approach) — too heavyweight for Fab's lightweight workflow. Source: doc/fab-spec/TEMPLATES.md

Stage Graph in Schema, Not Config

Decision: The stage pipeline is defined by the workflow schema authority — the Go state machine (src/go/fab/internal/status + statusfile) (c5tr) — not in config.yaml. There is no declarative workflow.yaml (see pipeline/schemas.md) and no stages: section in config — no skill ever consumed one; all skills derive stage ordering from the state machine's CLI surface or their own hardcoded logic. Why: A stages: config section is dead config — inspectability in config was never used in practice, and omitting it keeps the config surface small without losing functionality. Introduced by: 260218-bb93-restructure-config-yaml; Updated by: 260612-c5tr-scaffold-config-truth-srad-coherence

Author-vs-Critic Split (code-quality.md vs code-review.md)

Decision: Separate coding standards for the writing agent (code-quality.md) from review policy for the validating sub-agent (code-review.md). Why: The apply agent and review sub-agent operate in different cognitive modes. The author needs principles and anti-patterns to guide implementation. The critic needs severity definitions, scope boundaries, and rework budgets to guide validation. Combining both in one file conflates two distinct concerns. Rejected: Single code-quality.md with a review section — blurs the author/critic boundary and makes it unclear which sections are consumed by which agent. Introduced by: 260218-xkkc-add-code-review-5cs-quality

Companion Markdown Files Over YAML Sections

Decision: Extract context: and code_quality: from config.yaml into standalone markdown files (fab/project/context.md, fab/project/code-quality.md). Why: Free-form content (tech stack descriptions, coding principles) is awkward in YAML — multiline strings, quoting, indentation issues. Markdown is the natural format and allows richer structure (headings, lists, examples). Also reduces config.yaml to pure settings. Rejected: Keeping everything in config.yaml — bloats a settings file with prose content. Introduced by: 260218-bb93-restructure-config-yaml

Single config.yaml Parser in the fab Module (internal/config)

Decision: The fab module reads fab/project/config.yaml exclusively through internal/config — every consumed key (stage_hooks, true_impact_exclude, test_paths, the providers: map, the agent: block, project.linear_workspace) is modeled on the single Config struct and read via a nil-safe accessor (GetStageHook, GetProvider, GetAgentTier, GetLinearWorkspace); the spawn command resolves via GetProvider(name) + agent.ResolveProvider (tykw). Config.FabVersion is on the same struct but tagged yaml:"-" — it is NOT parsed from config.yaml; Load populates it from the fab/.fab-version overlay (readDotFabVersion) and GetFabVersion reads it (sole consumer: preflight's staleness check). Load(fabRoot) is a thin join over LoadPath(path); explicit-path callers (fab agent --repo <path>) use LoadPath directly. LoadNoProject() and LoadLayersNoProject() are the project-free counterparts — the same cascade with the project tier dropped (env > system > built-in defaults), for a CONFIG-ONLY command run where no fab/ exists. All four entry points share one merge tail (fromProjectMap / layersFrom), so they cannot drift on which tiers compose or in what order. LoadNoProject cannot fail — its only error path is a project-file parse and there is no project file — and leaves FabVersion empty, since .fab-version is a per-project pin. LoadPath returns effective config across the four-tier cascade — it merges environment over ~/.fab-kit/config.yaml over the project file before unmarshal, with built-in defaults applied at point of use (see § Override Cascade & Scope Enforcement); FromMap is that unmarshal tail on its own, and the LoadLayers sibling exposes the raw maps and environment provenance for fab config show --origin. A missing file yields an empty config without error; default semantics live with each consumer (spawn's default command, empty Linear workspace, preflight's silent staleness skip), not in the loader. Why: A single parse point removes the silent-drift surface of five independent parse sites with one-off structs (the shared Config, internal/spawn's local parse, batch_switch.go's own branch-name reader, internal/prmeta's readLinearWorkspace — which parsed the same file twice within one derivation — and internal/preflight's anonymous staleness struct). yaml.v3 ignores unknown keys, so widening the shared struct is free for existing configs. The consolidation payoff compounds: because all ~12 consumers funnel through this one seam, the system layer (lpb5) reaches every one of them with zero per-caller change. Known coupled-failure caveat (deliberate): a single final Unmarshal couples file-layer failure modes — a YAML type error on any modeled project/system key fails the whole parse and sends every accessor to its documented fallback (the merged tree is still one Unmarshal). Environment fragments are trial-unmarshalled per variable before entering that tree, so a type-incompatible variable warns and is omitted instead of triggering the coupled fallback. Each remaining consumer defines a fallback, but a malformed file-layer value can still degrade consumers together rather than per key. Rejected: Keeping per-caller structs (the status-quo drift source the consolidation removes); sharing the parser with the fab-kit module (its readFabVersion stays — Go internal-package visibility forbids cross-module imports). Introduced by: 260612-ye8r-cli-single-sourcing-doc-conformance; Updated by: 260702-tykw-agent-providers-role-tiers, 260708-lpb5-config-cascade-visibility, 260719-kq7v-remove-fab-version-fallback

The Agent Surface Overrides Budget, Not Taxonomy (Fixed Mapping vs Overridable Profile)

Decision: The agent: block is the only per-stage-model override surface, and it overrides what a role means (provider + model + effort), never which stages belong to a role. The six roles: default={intake (advisory), fab batch, fab agent, /fab-proceed create-intake dispatch}, operator={fab operator}, doing={apply, review-pr}, review={review}, hydrate={hydrate}, fast={ship, /fab-proceed prefix steps /fab-switch + /git-branch}. A role is stage-named only where it maps 1:1 to a single referent (review, hydrate); default/doing/fast keep role names because each is multi-referent. The stage→role mapping, the role→depth partition, and the built-in profile per role are fab-owned, fixed, and live in the Go internal/agent package — there is no stage_roles config and no per-stage escape hatch. Why: The taxonomy is fab's considered judgment from a dimensional analysis of each stage's cognitive mode; users legitimately disagree about budget (what a role costs), not taxonomy (which stages cluster). Hydrate is its own role because memory writing is knowledge work with a different profile than apply's diff work, so it can run on a different model/effort. Abstract roles also survive model churn — when a new top model lands, fab bumps the one shipped defaults file and every non-overriding project upgrades for free, where pinned model IDs in config would rot. The split keeps the override surface small (two knobs, with six roles × three dials available beneath them) instead of six stages × three dials. Rejected: stage_roles reassignment / per-stage {provider, model, effort} pins (invites undoing reasoning the user hasn't done; explodes the knob count). Pinned model IDs in config (opts every project out of the upgrade curve). Naming the fast role ship (misnames a multi-referent role once it also governs the /fab-proceed prefix steps, and would force an unnecessary carry-forward migration).Related: review (the critic → review) and review-pr (responsive → doing) are deliberately in different roles despite the shared word — discovering bugs from a diff vs. fixing already-articulated feedback are different cognitive modes. apply stays on doing, not a cheaper role: apply produces the diff review critiques, so a cheaper apply drives more (capped-at-3) rework rounds. There is no thinking role — review is its own role (author/critic separation) and intake never dispatches (tykw) (see the Providers-Extracted decision below). Consequence worth knowing: an agent.profiles.doing override governs apply and review-pr only — it does NOT govern hydrate. A project that dials doing but sets no hydrate: entry resolves hydrate at the kit default (run fab config explain for the shipped profile); to run hydrate on the same profile it adds a matching hydrate: entry. Introduced by: 260613-l3ja-per-stage-model-tiers; Updated by: 260702-tykw-agent-providers-role-tiers, 260719-g55d-stage-model-tier-defaults-v2, 260806-j9nh-agent-profiles-session-workers

The Agent Machinery Is Demoted from the Fence, Not from the Reference

Decision: agent.profiles and providers carry advertise: false, so fab config upgrade scaffolds neither into a project's managed fence, while both keep their registry rows, their --json defaults, and a full rendered segment in fab config explain. The fence's whole agent surface is one ~20-line agent: block: the two knobs live, the role→depth partition compacted to one line per depth, and a commented profiles: example. That block is emitted by a single segment (owned by the agent.session row), and its partition lines derive from agent.IsSessionRole. Why: The fence is per-project noise — the provider/agent commentary it used to scaffold dominated every repo's config.yaml while describing machinery almost nobody overrides. The reference is the canonical schema surface and must stay complete: its header promises every key is documented, and fab config init --system renders from the same segments. Demotion targets the noise without hollowing out either. One segment per YAML block is forced by the parser — two segments emitting a live agent: parent would collide into a duplicate key (the project.name / dispatch.mode block-ownership precedent) — and deriving the partition from the exported predicate keeps the fence from re-encoding a mapping internal/agent owns. Rejected: Dropping the segments entirely (regresses fab config explain and fab config init --system, which render from them); keeping advertise: true (leaves the fence bloated, which is the size requirement this decision answers); a second agent: segment for the profiles example (duplicate YAML key); hardcoding the session/workers role lists in the renderer (a second copy of a fab-owned partition). Introduced by: 260806-j9nh-agent-profiles-session-workers

No Validation — Provider-Neutral Verbatim Pass-Through

Decision: The shared resolution engine projected by fab agent <stage|role> -o yaml performs NO validation — it maps stage→role→{provider, model, effort} and carries the strings verbatim, whatever they are. fab has no provider-specific knowledge in the resolution path; the resolved profile is consumed unchanged by the harness adapter. Why: Provider neutrality (Constitution Principle I). Validating against Claude's effort enum (low/medium/high/xhigh/max, Opus-only xhigh, Haiku-rejects-all) would hard-code Claude into the resolver and bolt the door on other agents. Keeping it open is what lets a project switch the underlying agent by naming another provider on a depth knob and supplying that provider's vocabulary. The safety net moves from fab to the runtime/harness: a misconfigured pair (e.g. Claude Sonnet + xhigh, which Sonnet 400s) surfaces as a dispatch-time error, not a fab correction. fab is architecture-neutral + documented, NOT shipped/tested against a non-Claude harness — no provider-detection, no non-Claude integration test; the acceptance proof is "a non-Claude project can point the knobs elsewhere and nothing in fab rejects it." Each built-in provider's shipped fills use that provider's own vocabulary (Claude model IDs and effort levels on claude; codex's catalog slugs and agy's effort-suffixed IDs on theirs; kimi's user-config aliases, which is why it ships none), and all of them are pass-through and fully replaceable. Rejected: Effort-enum enforcement / a degrade-gracefully drop of an incompatible effort. Shipped/tested multi-provider support (a far larger change). Introduced by: 260613-l3ja-per-stage-model-tiers

spawn_command Placeholders — Template Mode with Empty-Value Token-Drop

Decision: spawn.WithProfile — the one place fab composes a spawn command line — is provider-forgiving via literal {model}/{effort} placeholders. When either is present the command is a template (substitute every occurrence, all-or-nothing so the append is disabled entirely); when neither is present it keeps the Claude-shaped append byte-for-byte (load-bearing for plain-form user configs carried forward by the 2.12.1→2.13.0 migration). An empty substituted value triggers a token-drop rule: tokenize on whitespace, drop the placeholder's token plus a preceding --flag token — deterministically covering the four flag shapes -m {model} / --model {model} / --model={model} / -c key={effort}. The all-non-empty path uses plain strings.ReplaceAll on the raw string, so author whitespace is preserved and tokenization runs only on the empty-value drop path. Placeholders live on the provider commands (providers.<name>.interactive_command/headless_command) (tykw), and the built-in claude interactive_command / spawn.DefaultSpawnCommand fallback is itself a {model}/{effort} template with the placeholders placed last — resolving byte-identical to the plain-form append output (gvxd). There is no raw-print leak-guard path (no StripPlaceholders): every consumer spawns WITH a resolved profile — fab agent [--print], and fab batch new/switch compose the default-role provider interactive_command + profile (tykw) (see runtime/operator.md → "Operator Launch…" and distribution/kit-architecture.md). Why: fab's whole resolution path (fab agent -o yaml, the agent: block) is deliberately provider-neutral / verbatim, but the pre-6tmi WithProfile blindly appended Claude CLI grammar ( --model … --effort …), so pointing spawn_command at a non-Claude CLI (codex wants -m <model> and -c model_reasoning_effort=<level>, has no --effort) produced a broken fab operator launch. Placeholders relocate provider grammar into the user's config — the one composition site now honors the same no-validation philosophy as the resolver. The token-drop rule is the simplest deterministic empty-value handling that needs no templating engine; whitespace preservation on the non-empty path is strictly safer than an unconditional single-space rejoin. Why (built-in default templated) (gvxd): (1) explicitness — a reader of the scaffold sees where the role profile lands instead of it being appended invisibly by WithProfile; (2) internal consistency — every command string in the scaffold is templated (the live claude interactive_command matches its own commented headless_command and the non-claude starter blocks); (3) reference sync by constructionfab config explain renders the interactive_command from agent.DefaultInteractiveCommand (configref.go), so the reference cannot contradict the binary. Template-mode substitution of the placed-last placeholders yields output byte-identical to the append path (verified empirically against fab agent default/operator --print), and append mode stays load-bearing for plain-form user configs (some pin --model/--effort and rely on append-last/last-wins) — both forms resolve identically, so no migration ships. Rejected: Per-half independence (would append Claude flags to a non-Claude command that only templated one half). Raw empty substitution (leaves a dangling -m / model_reasoning_effort=). A full templating language (heavy, YAGNI). Printing templated output verbatim + documenting the brace-leak hazard (a foot-gun — mooted now that every consumer spawns with a resolved profile). Threading template awareness into operator.go (needless coupling — WithProfile stays the single seam, its signature unchanged). No provider validation (placeholders only relocate where the strings land). Cross-harness stage dispatch lives on the provider — providers.<name>.headless_command, with fab agent -o yaml reusing this decision's spawn.WithProfile substitution for dispatch.command (see the Providers-Extracted decision below). Introduced by: 260702-6tmi-spawn-command-placeholders; Updated by: 260702-tykw-agent-providers-role-tiers, 260703-gvxd-templated-claude-session-command

Providers Extracted; Roles; review_toolscode-review.md

Decision: Agent config v3 separates opaque provider capability grammar from fab-owned role/budget policy. Providers carry independent session, native, and headless capabilities plus per-role fills; roles resolve {provider, model, effort}; review policy lives in code-review.md; fab agent launches sessions and exposes the structured profile plus dispatch: key-presence seam; deprecated fab resolve-agent remains a compatibility projection. Why: Commands and native support belong to the provider, while adapter preference belongs to dispatch.mode. Keeping those axes separate makes role composition safe and prevents a new command from silently changing dispatch policy. The two command fields remain unmerged because interactive and headless CLI grammar differ. Rejected: Merging interactive_command+headless_command into one command (the grammars differ — one template can't express both). Cross-fallback from headless_command to interactive_command (the silent-behavior-flip above — the single most important thing this design does NOT do). A top-level stage_dispatch map (duplicates the stage→role resolution fab already owns). Folding agent.spawn_command in as a default-role command (implies the fallback semantics the 3a–3d series deliberately rejected). Keeping thinking (nothing left for it to govern once review split out). Inferring a provider from a model string (needs a provider registry the no-validation/provider-neutrality contract refuses). A fab spawn-command deprecation alias (its only CLI consumer, the operator skill, ships and is updated in the same kit). Introduced by: 260702-tykw-agent-providers-role-tiers

Non-Claude CLIs Are Go Built-in Providers With Fills — the "Template Text Only" Decision Is Fully Reversed

Decision: The non-claude agent CLIs fab-kit supports are Go built-in providers carrying invocation grammar and (where their model addressing allows) per-role fills, inside the single providers registry row (whose per-provider fill surface is profiles, with the pre-2.17.0 flat model/effort still read as its alias). This fully reverses ho9y's "No new built-in providers are added in Go — they are template text only", and the reversal is recorded here rather than applied silently. Why: Built-in providers are capability data, not policy. Shipping claude's three capabilities and the non-claude command grammars changes no selection by itself because dispatch.mode owns preference; a row becomes behaviorally relevant only when role resolution names that provider. Embedded fills remain release-refreshed, unvalidated defaults that users can override per role. Rejected: Keeping the ho9y state (leaves cross-provider work config-gated at the moment it should be frictionless); stopping at grammar-only (preserves the silent role-flattening for the flagship one-line knob); reversing it silently; per-provider or per-field registry rows for the fill (breaks the map-valued single-override-unit model and the key-parity guard); emitting an empty-string model in the providers structured default (asserts a built-in fill that does not exist); a migration for the fills (the embedded data is additive). Introduced by: 260805-j3cm-builtin-provider-templates-and-fill; Updated by: 260806-ywkx-ship-codex-gemini-fills, 260808-rpsr-remove-gemini-add-agy-kimi — see runtime/providers-and-profiles.md § Design Decisions for the refresh-cadence policy and the per-provider fill shapes

fab/.fab-version Ignore-Class Defense — Gitignore Negation + Stamp-Time Warning

Decision: Two mechanisms keep fab/.fab-version genuinely committed: a !fab/.fab-version negation line in .gitignore (shipped in the scaffold fragment, self-healed on every fab sync, verified + committed for already-shipped repos by the 2.15.1-to-2.15.2 migration) and the fail-open stamp-time warning warnIfFabVersionIgnored (wired into stampFabVersion's Init+Upgrade callers), which prints a fab: warning: to stderr whenever the just-stamped file is still ignored. Why: The scaffold's .fab-* gitignore line (for the root runtime files .fab-status.yaml/.fab-backend/.fab-runtime.yaml) is slash-less, hence unanchored — a gitignore pattern with no slash matches at any directory depth, so .fab-* also matches fab/.fab-version. An ignored file exists on disk but can never be committed, and ignored ≠ untracked (git status shows nothing), so the failure surfaces only on a fresh worktree/clone/CI: no version source, and fab sync/wt init fail-loud (no fab version found in fab/.fab-version or config.yaml). The stamp-time warning keeps the written-but-ignored class from ever going quiet again. Rejected: Relying on the "committed" design intent alone — it shipped silently defeated, and only a fresh checkout revealed it. Introduced by: 260708-8ken-fab-version-gitignore-fix

A Registry Row Needs Its Paired Scope-Taxonomy Entry

Decision: Adding a configref []Field row for a new top-level YAML key requires a paired keyScopes entry in internal/configscope in the same change — the pairing is a hard invariant, not a nicety. consolidate.detectors (Scope: ScopeProject, Advertise: true, Default: nil) ships with "consolidate": ScopeProject. Why: configref.lintFields cross-checks every row's declared Scope against configscope.ScopeFor(topLevel(key)), and an unregistered top-level key resolves to the empty scope, which validScope rejects. So a configref-only row makes configref.Fields() return an error at construction and breaks every consumer wholesale — fab config explain (both renderings), fab config upgrade's fence, and fab config init. The taxonomy is deliberately single-sourced in the cycle-free leaf package, so the leaf is where a new key is admitted. A per-key registry constraint stated as "one registry key" is about one row, not one file touched. Rejected: Adding the row to configref alone — it fails loud at construction rather than degrading, which is the correct failure mode but a needless rework cycle. Deriving keyScopes from the registry — that closes the configref → agent → config import cycle the leaf package exists to avoid. Fixture note: advertised rows do not churn configupgrade's full-document goldens — golden_test.go renders a small synthetic goldenFields() set precisely so pinned bytes stay put when a real registry row lands. Fence coverage for a new key is therefore a new test over the shipped registry, never a golden regeneration. Introduced by: 260728-4v91-add-fab-dedupe-skill

Dotted Keys Stay in the Cycle-Free Leaf

Decision: The generic environment walk consumes the ordered dotted-key enumeration from internal/configscope; a configref parity test guards equality with the richer registry. Why: internal/config cannot import internal/configref without closing configref → agent → config, while configscope already owns the cycle-free scope taxonomy. Rejected: Copying the keys into internal/config, reverse-scanning FAB_*, or importing configref despite the cycle. Introduced by: 260808-2d1w-env-override-layer-launch-flags

Dispatch Mode Is a Preference Ceiling Over Pure Capabilities

Decision: dispatch.mode (pane | native | headless, default native, scope both) selects the highest adapter fab may try. The shared resolver descends pane → native → headless without ascending and chooses the first possible rung. Provider interactive_command, native, and headless_command fields independently declare pane, native, and headless capability. Why: Separating preference from capability lets the same provider expose several valid execution forms without command presence silently changing policy. A single pure selection function keeps fab agent -o yaml and fab dispatch start|restart on the same matrix while allowing start/restart to add the real tmux reachability probe. Rejected: Command-presence policy; $TMUX presence as a global selector; a boolean pane opt-in; ascending above the configured preference; and separate resolver/runtime ladders. Introduced by: 260808-yilt-dispatch-mode-descent-ladder

Dispatch Scalars Carry Their Typed Defaults

Decision: The three dispatch registry rows carry their real built-in defaults: mode: native, column_width: 35, and reap_done: true, sourced from canonical config symbols. Why: The JSON reference should report the values the runtime actually reaches when keys are absent. reap_done remains pointer-backed because its default differs from the Go bool zero value; mode and width use validated accessors. Rejected: nil defaults that hide runtime behavior, duplicate literals, and accepting invalid mode or width values verbatim. Introduced by: 260806-fe6f69a1 (off-pipeline, PR #529); 260807-g4a5-pane-worker-column-invariant; 260807-zfl7-dispatch-reap-done-panes

Dispatch Defaults Are Init-Injected from defaults.yaml

Decision: The three dispatch.* built-in defaults (mode: native, column_width: 35, reap_done: true) have exactly one value source — the dispatch: block of the module-root embedded defaults.yaml. The exported config.DefaultDispatch* symbols are package-level vars carrying no literals, assigned from the parsed file by an internal/agent init(). Accessor signatures, names, and fail-open semantics (nil/absent/invalid/out-of-range resolve to the defaults, same invalid-mode warning text) are unchanged, so configref, cmd/fab, and existing tests compile against the same symbols. Drift guards name themselves on failure: pin tests on the dispatch: block and the injection wiring, plus a blank-import test in internal/config that links agent into the config test binary — closing the zero-value hazard of a test binary that never links agent. Why: internal/agent imports internal/config, so config cannot read the values back from agent — push-from-agent at init is the only direction the import graph allows, and Go's init-order guarantee means every real binary (the fab module always links agent) sees the injected values before runtime use. Keeping the exported names avoids repo-wide call-site churn. Rejected: Moving the accessors off config.Config into agent (churns call sites and loses the nil-safe method idiom); a second embedded file in internal/config (violates the one-value-source requirement outright); keeping fallback literals in config "just in case" (recreates the duplication this arrangement exists to kill). Introduced by: 260809-wll4-config-source-consolidation

A Default-True Bool Needs a Pointer

Decision: DispatchConfig.ReapDone is a *bool (nil = unset = the built-in true), unlike its two plain-valued siblings, with the built-in itself living in the single exported symbol config.DefaultDispatchReapDone that both the nil-safe GetDispatchReapDone() accessor and the registry row read. Why: The convention that a scalar rides its Go zero value works only while the built-in default is that zero value. reap_done's default is true, so a plain bool would make an absent key and an explicit reap_done: false indistinguishable — silently disabling reaping for every project that never sets the key, the exact opposite of the shipped posture. The pointer is the minimum machinery that keeps "unset" and "explicitly false" apart, and confining it to this one field keeps the nil-deref surface to a single accessor rather than spreading it across the struct. Empty-skip does not retire it: false is a real value and survives the merge either way, but the loader's merged tree carries no built-in-defaults tier (that projection belongs to the read model — below), so an unset key still reaches Unmarshal as absent and the pointer is what keeps absent distinguishable from an explicit false. Rejected: A plain bool with an inverted key name (keep_done_panes) — it would fit the zero value, but it advertises the non-default posture as the key's subject and diverges from its two dispatch siblings. A plain bool plus a separate reap_done_set marker field (two fields modeling one value, free to disagree). Flipping the default to false so the zero value fits (makes the common case the broken one — see runtime/dispatch.md § Design Decisions → space-reclaimed default). Introduced by: 260807-zfl7-dispatch-reap-done-panes

Several Registry Rows Under One YAML Block Share a Single Segment

Decision: Where two or more override units live under the same top-level key, the rendered Segment belongs to the first row and documents them all; the rest carry an empty Segment. project.name owns the project: block for project.description/project.linear_workspace; dispatch.mode owns the dispatch: block for dispatch.column_width and dispatch.reap_done. Whole-file project reconciliation suppresses that shared segment at top-level-block granularity, while system reconciliation and surgical mutation render a reduced copy that omits only the live registry leaves. Why: Single segment ownership prevents duplicate YAML parents in the reference and fence. The leaf-aware path preserves per-leaf intent because it parses the live dotted paths and removes those rows from the shared commented segment; unset re-advertises the removed leaf without re-advertising a still-live sibling. Project upgrade keeps its established top-level suppression for byte compatibility, while system upgrade stays byte-identical to system set/unset output so a subsequent --check --system is clean. Rejected: A # dispatch: parent in each row's segment (duplicate-key collision on uncomment). Splitting the block into distinct top-level keys to give each row its own segment (churns the schema to work around a rendering detail). Treating top-level upgrade suppression as the mutation rule too (hides non-live siblings after one leaf is set). Introduced by: 260807-g4a5-pane-worker-column-invariant

Fence Comment Markers Land at Column 0 (Two-Level Comment Scheme)

Decision: configupgrade.CommentOutSegment — the single comment-out helper shared by both managed-fence targets and fab config init --system — tests the raw line for a # at column 0 and skips only those; every other non-blank line gets the # prefix. A rendered segment therefore carries fence-level prose and content-level commented YAML such as the agent block's # profiles: example lines; content-level lines are prefixed like live lines. Why: The column-0 rule keeps the fence visually uniform and exactly reversible: stripping the leading # restores each segment byte-for-byte, including the agent examples that intentionally remain commented at their original indent. Rejected: Testing the trimmed line — it skips content-level lines, leaving their # at column 2/4 while every live line's marker sits at column 0: a ragged fence, and a block whose strip yields YAML with the deliberately-commented lines uncommented, silently activating the agent block's example profiles: override. Blanket double-commenting of prose (prefixing fence-level lines too, giving ## ) — it makes the strip reversible in the naive sense but disfigures every prose line in the fence and breaks the anchor lines the fence parser matches; the asymmetry between the two levels is real, so the helper honors it rather than flattening it. A per-segment opt-out flag (pushes a global formatting invariant into each segment author's hands). Introduced by: 260806-fe6f69a1 (off-pipeline, PR #529)

--check Is the Same Compute Path, Minus the Write

Decision: fab config upgrade --check is implemented as configupgrade.Check(target, kitVersion), which shares Upgrade's target-parameterized render + validate computation and returns the would-change verdict and report lines without writing; the cobra verb maps would-change (a missing file counts — a real run would create it) to a non-zero exit and clean to exit 0. --all invokes the same check once per layer and ORs the drift verdicts. Why: Upgrade already isolates computation from the atomic write, so reusing it guarantees --check can never disagree with a real run about what would change. Rejected: A separate diff/preview implementation — a second opinion on drift is a second source of truth. Introduced by: 260809-wll4-config-source-consolidation

Managed Fence Over Header-Anchored Region (System Layer)

Decision: The system layer's regenerable region is delimited with the same BEGIN/END anchors the project layer uses, rather than treating the contiguous comment block after the header as implicitly owned. Why: The fence is an explicit, machine-recognizable boundary, so fab can distinguish its own generated prose from a user's hand-written note. It also reuses renderFence, beginLineRe, and the parking machinery wholesale instead of forking a second delimitation scheme. Rejected: A header-anchored comment region (cannot tell user prose from generated prose — silently eats hand-written notes); a whole-file rebuild (discards all user comments unconditionally). Introduced by: 260830-m4ai-config-upgrade-system-scaffold

Live Keys Hoist Above the Fence on Adoption

Decision: Adoption reorders existing live keys above the fence, matching the fab/project/config.yaml convention, accepting a one-time visible reshuffle of the user's file. Why: One mental model across both config layers. The project engine already hoists non-parked content above the fence, so this is the existing behavior rather than new logic. Rejected: Preserving the below-comments position — the two layers would then read differently forever, and set --system would keep its divergent append behavior. Introduced by: 260830-m4ai-config-upgrade-system-scaffold

--all Rather Than Repurposing Bare --check

Decision: The both-layer check is spelled fab config upgrade --check --all; bare --check stays project-only. Why: Bare --check is an established CI probe; changing its scope would silently alter the verdict of every existing invocation — a drifted system config would begin failing project CI that never asked about it. --all is additive and non-breaking. Rejected: Making bare --check cover both layers (breaking, and couples repo CI to machine state). Introduced by: 260830-m4ai-config-upgrade-system-scaffold

Generated Paragraphs Recognized by Whole-Paragraph Byte-Exact Identity

Decision: Adoption recognizes generated paragraphs by whole-paragraph byte-exact identity — the registry's current renderings plus a sha256 digest catalog of released historical ones (knownGeneratedSystemParagraphDigests, append-only) — not by structural shape. Why: The line-complete rule's hardest case is a value-only edit of a generated advert; shape matching is value-blind and would delete exactly that paragraph. Byte-exact identity makes any edit or appended line miss the match, so the paragraph is preserved. The catalog is self-enforcing — a registry/prose change fails TestGeneratedSystemParagraphCatalogIncludesCurrentRenderer until its new digest is registered. Rejected: Exact equality against only the current registry's renderings (misses historical scaffolds, which then survive and are duplicated by the regenerated fence); structural/shape matching (deletes user content riding on a generated-looking paragraph). Introduced by: 260830-m4ai-config-upgrade-system-scaffold

System Header Is Fence-Owned, Not Preamble-Owned

Decision: The system precedence header (SystemScaffoldHeader) heads the system target's FencePreamble inside the managed fence; nothing fab-generated renders above the fence. The region above the fence is exclusively user territory — live keys and their own comments. Why: Verified on v2.23.8 that an EDITED above-fence header yielded two copies — R10a preserved the user's paragraph while the binary installed a fresh pristine one. Two writers for the same lines is the defect; relocating eliminates it structurally rather than merely reduces its likelihood. The header stays (its precedence rule is the file's least obvious fact); the "generated/refreshed by …" claim lives once, in the fence prose directly below. Rejected: Deleting the header outright (loses the precedence rule); keeping it above the fence and documenting "don't delete it" (the two-copy defect stays); a sentinel non-empty header purely to keep the discard alive (hides the bug's cause better while preserving it). Introduced by: 260831-n2eo-system-header-inside-fence

Discard Predicate Decoupled from Header Presence

Decision: normalizeTargetPreamble's legacy-paragraph discard is gated by the explicit Target.adoptLegacyFile predicate, not by the presence of Target.Header. Why: The old if target.Header == "" { return preamble } early-return gated both header installation and the discard, so blanking the header to relocate it would silently disable legacy adoption — the duplicated adverts m4ai spent two review cycles eliminating. The decoupling is enforced undeviatingly: m4ai's TestSystemUpgrade_AdoptsReleasedHistoricalScaffoldsAfterRegistryDrift and the two LineCompleteAccounting* suites must pass with no test-body edits. Rejected: Sequencing the relocation before the decoupling, which would temporarily leave legacy adoption broken (the coupling was made explicit to prevent exactly that intermediate state). Introduced by: 260831-n2eo-system-header-inside-fence

Header Relocated at the Shared assemble Seam

Decision: The header relocation lives at the seam where RenderSystemScaffold and the upgrade render path converge — the assemble renderer composing the SystemTarget descriptor's FencePreamble — not in either caller. Why: Both doors compose the same SystemTarget, so init --system and upgrade --system render the same shape by construction; divergence is structurally impossible rather than caught by a matching test. Rejected: Patching the init renderer and the upgrade render path separately (two copies to keep in sync — the drift class --check exists to catch). Introduced by: 260831-n2eo-system-header-inside-fence

The System Tier Outranks the Project File

Decision: The cascade order is environment > system (~/.fab-kit/config.yaml) > project (fab/project/config.yaml) > built-in defaults, and scope enforcement is retained unchanged (system-layer pruning of project-scoped keys, set --system gated on scope ∈ {system, both}, project-scoped environment variables warn-and-ignored). Why: The only fields the system tier can carry are preference-class ones — "which worker provider do I like on this machine" — and for those, a repo's committed suggestion outranking the user's own machine-wide choice is backwards: under the reverse order a personal ~/.fab-kit/config.yaml preference is silently inert in any repo that happens to pin the key. Keeping scope enforcement is what makes the flip safe rather than a hermeticity break: a semantics-class key (source_paths, test_paths, stage_hooks, …) cannot legally appear in the system file at all, so the order is observable only where "my machine beats the repo's suggestion" is the intended answer, and the repo stays reproducible for teammates and CI. Nothing on disk changes shape, so the flip carries no migration — the constitution's migration rule governs user-data restructuring. Rejected: Dropping scope enforcement entirely so the system file could carry anything (the inversion would then reach semantics-class keys and break repo reproducibility); the half-drop where writes are allowed but reads pruned (ignored-write confusion); keeping project-over-system and telling users to stop committing preference keys (the keys are legitimately committed as suggestions — the precedence is the thing to fix, not the practice). Introduced by: 260808-fp02-config-read-model-redesign

Empty Leaves Fall Through Instead of Shadowing

Decision: Emptiness (null, "", [], {} — never false or 0) replaces explicit-presence semantics on the read side: an empty leaf at any tier, environment included, neither wins nor blocks, and no code path distinguishes "present but null" from "absent". Why: An explicit key: null shadowing a lower tier is a footgun, not a feature — nobody writes it meaning "make this unset for everyone below me" — and modelling it costs a presence bit threaded through the loader, the provenance projection, and every field whose default is not its zero value. One uniform rule deletes the distinction outright, and applying it to the environment tier too is what keeps it uniform: an env layer where FAB_X=null stayed a real shadowing override would be the one surviving presence semantic. It also generalizes the environment tier's own "an empty variable behaves as unset" rule rather than contradicting it. The one thing the rule must not eat is a real false/0, or a project dispatch: {reap_done: false} would silently resolve true. Rejected: Keeping presence tracking for the environment tier alone (the exact semantic being deleted, one tier up); treating an empty collection as a real override while nulls fall through (two emptiness rules, and [] is the spelling a user reaches for when they mean "none", not "shadow the tier below"); a per-key opt-in to null-shadowing (config surface for a footgun). Introduced by: 260808-fp02-config-read-model-redesign

The Defaults Tier Is Materialized for the Read Model, Not for the Loader

Decision: configref.DefaultsMap() projects every registry row's canonical default into one YAML-shaped map — the real bottom tier that fab config show, --origin, the set shadow check, and the unset live-tier notice merge beneath the files and the environment (DefaultsMapFor(cfg) is the same projection with the derived agent.profiles rows resolved against the live config). internal/config.LoadPath does not merge it; the point-of-use fallback seams remain the loader's fourth tier. Why: Two constraints, the second decisive. (1) internal/config cannot import internal/configrefconfigref → agent → config would close an import cycle, which is why internal/configscope exists as a leaf. (2) The agent.profiles default is derived (resolved from the depth knobs and the provider fills), not stored: merging it into the loader's tree would land a full {provider, model, effort} in Config.Agent.Profiles, which the resolver reads as a user override outranking the provider's own fills — inverting the documented fill precedence and breaking --provider swaps. A registry-defaults tier is sound for display and provenance; it is not sound for the resolver. The read model carries the tier, so the retained point-of-use seams serve as redundant safety rather than as a second mechanism; their wholesale collapse is a deferred follow-up. Rejected: Relocating defaults.yaml into a new leaf package so the loader could merge tier 0 itself (buys the same effective values the retained seams already produce, at the cost of a structural refactor — and does not fix the agent.profiles poisoning); injecting a defaults provider into internal/config through a package var (spooky action, and the package's own tests would then see a different tier stack than the binary); merging only the dispatch constants as a partial tier 0 (an arbitrary half-layer, after which "the defaults tier" would mean different things in the loader and in show). Introduced by: 260808-fp02-config-read-model-redesign

One Merge Rule, Shared by Resolution and Provenance

Decision: config.MergeLayers over config.IsEmptyValue is the single implementation of the read model. The loader merges through it; the provenance surfaces answer "does this tier define this leaf?" with the same !IsEmptyValue test and descend a dotted path through one shared helper, so show --origin, the set shadow warning, and the unset live-tier notice all read the same stack the loader resolved. Why: Three separate mechanisms — presence tracking, a winner-only origin projection, and point-of-use fallback special cases — can each answer "which tier supplies this leaf?" differently, and the divergence is invisible until a user reads a provenance line that contradicts what their tools do: a defining tier named for a map whose every leaf is empty, or a drill-down row naming a provider no consumer would dispatch to. With one rule, provenance cannot disagree with resolution: a discrepancy would need two implementations, and there is one. It also makes the emptiness predicate exported for a reason — the tier-defines test is not a display detail, it is the merge rule read from the other side. Rejected: Keeping a separate presence-aware projection for the origin listing (the divergence source being removed); recomputing the cascade per surface (a second load re-reads the system file and re-emits every fail-open fab: warning:, so notices would duplicate warnings the query already printed); a provenance cache keyed by leaf (state to invalidate, for a rule cheap enough to re-derive). Introduced by: 260808-fp02-config-read-model-redesign

Provider Command Fields Name Their Interaction Mode, Not the Agent's Depth

Decision: The two provider capability commands are interactive_command and headless_command, and the write surface is exclusive to those spellingsfab config set/unset/explain/show refuses a providers.<name>.session_command / .dispatch_command dotted key as an unknown key with the standard fab config explain pointer. The deprecated spellings stay readable as silent per-field aliases (§ providers; the alias policy itself is runtime/providers-and-profiles.md § Design Decisions → "Read-Time Aliases Back a Rename, Not Just the Migration"), and the 2.18.1-to-2.19.0 migration rewrites both scopes on disk. Why: The two fields split by interaction mode — "open a session a human can watch and steer" vs "run headless, prompt on stdin, exit when done" — and not by agent depth, so depth-flavored names advertise an invariant that does not hold: a Tier-2 pane stage worker runs the interactive command. dispatch_command additionally collided with two unrelated meanings of the same word — the fab dispatch verb family and the dispatch.* config block — and the collision is sharpest at dispatch.mode: native, which resolves a stage to a rung that runs neither command. Making the write surface exclusive is what lets the deprecation converge: a config already on disk keeps resolving until the migration sweeps it, while no new file can acquire an old-spelling key for the migration to chase. Rejected: Depth-aligned names (tier1_command/tier2_command and kin — they re-encode the invariant the pane rung breaks); a hard break with no read-time alias (every unmigrated config breaks the moment the binary upgrades, and configupgrade's renamed_from carry is a top-level-key operation that cannot rewrite a key nested under providers.<name>:); accepting the old spellings on the write surface too (keeps minting keys the migration then has to sweep, so the deprecation never closes); a deprecation warning on alias reads (the agent.tiers precedent reads its alias silently — the migration, not nagging, is the closure mechanism); renaming the dispatch.mode value headless or the fab dispatch verbs to resolve the collision from the other side (they name real, distinct things). Introduced by: 260809-n1he-rename-provider-command-fields

Uniform Single-Hash Provider Blocks; the Pinning Warning Is Prose

Decision: All four built-in provider blocks render live at the same indentation in the reference, so a managed fence or init --system scaffold carries exactly one leading # prefix per provider line (no doubled # # marker; an inline # ... note on a command line stays content), and stripping that one layer from a whole block restores valid YAML restating all four built-ins. The pinning hazard of hoisting a whole block is carried as a prominent prose warning above the blocks (a hoisted block's fills become a live override that shadows kit-release fill refreshes; prefer a per-field override — providers.<name>.profiles.<role>.model), not encoded in comment depth. Why: Comment-depth semantics are illegible — the doubled # # marker read as noise even to the project's own author, so the protective intent (keeping non-claude blocks commented after a one-layer hoist) protected nothing while degrading trust in the rest of the reference. Uniform single-hash is decodable at a glance, and the warning prose states the tradeoff explicitly where the reader needs it. Rejected: Keeping two-tier comment depth with an explanatory legend line — it preserves the visual irregularity and still requires the reader to decode the scheme. The accepted tradeoff: a whole-block hoist now yields four live provider overrides, mitigated by the warning. Introduced by: 260810-ug8b-config-reference-legibility

Dispatch Rows Render Above the Agent Rows

Decision: The three dispatch.* registry rows (dispatch.mode / dispatch.column_width / dispatch.reap_done) sit immediately before agent.session in the internal/configref field table; the providers row stays adjacent after the agent rows. Because registry order is presentation order, the fence, init --system, config show, and --json all render … consolidate → dispatch → agent → providers → autopilot → stage_hooks … (the autopilot.merge_mode row sits between providers and stage_hooks). Why: Policy-before-capability reads better — dispatch.mode is the top-level mode decision (pane → native → headless), and the agent knobs and providers table are what it consumes. No key semantics, defaults, or segment contents change; the reorder carries no migration because the fence self-heals on the next fab config upgrade. Rejected: Leaving dispatch below agent/providers (the consumer listed ahead of the policy it serves); moving the providers row too (nothing asked for it — minimal move wins). Introduced by: 260810-ug8b-config-reference-legibility

One Branch Namespace — No Config Key Varies a Branch Name

Decision: A change's git branch name IS its change folder name, on every path that creates or attaches one (/git-branch, /fab-new, fab batch switch), and no config key varies it. branch_prefix is therefore retired from the whole config surface — the Config struct field, its accessor, the configref registry row, and both configscope tables — so a branch_prefix: on disk is an inert unknown key. TestConfigReferenceRetiresLegacyKeys guards the reference against re-rendering it, and the 2.19.4-to-2.20.0 migration deletes it from user configs (see migrations.md). Why: fab batch switch was the key's only consumer, and it attaches worktrees to existing changes whose branches were created unprefixed. A set prefix made it probe for a name that never existed and create an orphan branch carrying none of the change's commits, while the change's real branch sat untouched. The default "" hid the fault (the concatenation was a no-op) while the managed fence advertised the key to every project. Worktree reuse, PR flows, and the operator's branch-alignment checks all key on branch == folder name, so a variable prefix is a namespace fork rather than a preference. Rejected: Threading the prefix through /git-branch, /fab-new, docs/specs/naming.md, and the operator's branch-alignment checks so every path honors it — a large surface for a key with no known users, and the fork re-opens the moment one path is missed. Bypassing the prefix inside fab batch switch while leaving the key registered — that keeps the zombie-config state (a settable key that configures nothing) the retirement exists to end. Introduced by: 260811-h1eu-remove-batch-switch-branch-prefix

The Autopilot Merge-Mode Default Rides defaults.yaml; the Enum Stays Go-Side

Decision: autopilot.merge_mode's default lives in the embedded defaults.yaml (autopilot: block) and is init-injected into the literal-free config.DefaultAutopilotMergeMode; the valid-modes list is the exported Go symbol config.ValidAutopilotMergeModes, referenced by both cmd/fab and the configref registry row. Why: defaults.yaml is the single value source for every built-in default, and everything in it is user-overridable via the same config key — exactly this key's shape; the enum is fab-owned policy (which values are accepted), matching GetDispatchMode's Go-side switch. Rejected: A plain Go literal default in internal/config (creates the second default-source convention the single-source consolidation exists to kill); putting the enum in defaults.yaml (an accepted-values list is policy, not tunable data). Introduced by: 260821-r5t5-autopilot-merge-mode-config-default

The Autopilot Merge-Mode Accessor Returns Raw; start Owns Validation

Decision: GetAutopilotMergeMode() does absent→default only and returns invalid values raw; fab operator autopilot start validates and errors naming the config key and the valid set, writing no state. Why: The error-not-fallback posture is command policy — merging is destructive-tier, so silently falling back to a different merge topology than the one the user configured is the wrong failure mode — and only the command knows the value's source for an actionable message; it also keeps the accessor consistent with document-don't-validate. Rejected: A (value, error) accessor shape (forces every future reader to handle an error only start cares about); the GetDispatchMode fail-open-with-warning posture (a wrong merge topology applied silently is worse than no queue start). Introduced by: 260821-r5t5-autopilot-merge-mode-config-default