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, andautopilot.merge_modeareboth; semantic project inputs stayproject. Scope enforcement is fail-open for the system layer and single-sourced ininternal/configscope.Advertise(bool) — selects the slimmer project fence. The system fence instead selects everyscope: system/bothrow, 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, recordingagent.tiers; andproviders, 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 theagent:block, the other insideproviders.<name>:blocks — making the rows metadata (surfaced in--json), while the on-disk rewrites are the2.16.19-to-2.17.0and2.18.1-to-2.19.0migrations' jobs and read-time aliases cover the windows between them (see §agentand §providers). Other past key relocations (e.g.agent.spawn_command→providers.claude.interactive_command) are covered by shipped migrations (tykw) and NOT backfilled. Consumed byfab config upgrade's rename carry (j0qm), which no shipped row can currently exercise.InitSeed(bool) (j0qm) — marks the A-class identity fields written LIVE atfab config init --projecttime (project.name/project.description,source_paths,test_paths). Exposed viaconfigref.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:
- environment — recognized
FAB_*variables inherited by the current process tree - system —
~/.fab-kit/config.yaml(co-located with the version cache; XDG rejected), resolved viaos.UserHomeDir()through thehomeDirpackage-var seam so tests pin it witht.Setenv("HOME", …) - project —
fab/project/config.yaml(the pathLoadPathis given) - 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 byconfigref.DefaultsMap/DefaultsMapFor(§ Six-Verb Surface). Their physical source is the module-root embeddeddefaults.yaml(src/go/fab/defaults.yaml, embedded by the root fab package, parsed byinternal/agent) — the single value source for every built-in default, thedispatch.*values included (they reachinternal/config's exported symbols by init-time injection — § Design Decisions → "Dispatch Defaults Are Init-Injected fromdefaults.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 embeddeddefaults.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.workers → FAB_AGENT_WORKERS, agent.profiles → FAB_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 thewarnwpackage-var (os.Stderrin 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 ⇒ afab: warning:and that variable is SKIPPED, leaving lower layers effective. Compatibility is checked per variable by trial-unmarshalling its nested fragment into a throwawayConfig, 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 byreadModelDefaultsthroughconfigref.DefaultsMap/DefaultsMapFor. Bareshowmerges that projection beneathlayers.Effectiveand prints the fully composed four-tier configuration — environment over system over project over built-in defaults — as YAML. (rvza)--originchanges only the provenance presentation; composed values do not require it. Bare--originis winner-only: it walksconfigref.Fields()and annotates each effective leaf with one origin ∈ {$FAB_…, system path, project path,default} (thegit config --show-originlabel 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.LoadLayersruns the same cascade asLoadPathand returnsLayers{ProjectPath, SystemPath, Project, System, Env, EnvOrigins, Effective}, and each invocation loads the read model once —config.FromMaptakes 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, nokey =prefix) or map subtree (YAML), composing the defaults tier. Unknown keys fail non-zero naming the key.Keyed
show <key> --originlists 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, soconfigref.DefaultsMapForrecomposes them against the live config (agent.ResolveRole), per leaf. Theproviderleaf resolves with the user'sagent.profiles/agent.tiersentries stripped: a defaults tier must report the built-in that a user override shadows, never echo that override back as its own default. Themodel/effortleaves resolve with each per-roleprovideroverride 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 → "DefaultProfileIs Resolution Against a Nil Config". -
fab config set <key> <value> [--system]/unset <key> [--system]— comment-preserving, scope-aware surgical writes throughinternal/configupgrade.setis 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 atfab 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 explicitnullare refused alongside a blank or whitespace-only argument. A successfulsetthat a higher tier shadows prints afab: 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,--systemshadowed by environment) and never for--systemover a project value, which the system tier outranks.unsetis 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 oneunsetcannot 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--originlisting 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;--projectremains compatible, and passing both explicit modes errors.--printrenders 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--forceit stays a pure preview).--forcereplaces 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 usescope: 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
RenamedFromis carried to the new key mechanically (value verbatim). The carry rewrites a column-0key:token and preserves the block below it verbatim, so it addresses top-level renames only: both rows carryingRenamedFromtoday (agent.profiles←agent.tiers, andproviders← 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%sis the running binary's kit-version stamp, making staleness visible to the--checkdrift 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/configresolvesConfig.FabVersionfromfab/.fab-versiononly (readDotFabVersion(fabRoot)overlaid inLoad; the sole consumer is preflight's staleness check).Config.FabVersionis taggedyaml:"-", so a stalefab_version:in config.yaml is an inert unknown key that nothing unmarshals. - fab-kit
readFabVersion(repoRoot)readsfab/.fab-versiononly (dotFabVersionRelPath = "fab/.fab-version") — feedingConfigResult.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-prconstructs issue hyperlinks usinghttps://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-deduperuns 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 overConfig— 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_commanddeclares how to open an interactive session and is the pane-dispatch prerequisite.nativedeclares that the provider can run through the native Agent tool. Fab never infers this from the provider name.headless_commanddeclares how to run one headless stage task. The prompt is supplied on stdin.profilessupplies 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_commandkeys 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).ProviderConfigkeeps both yaml tags, so decode retains whichever spelling a layer carries; the per-field preference itself is applied byagent.ResolveProviderafter the cascade merges, so every cascade layer — envFAB_PROVIDERSvalues included — resolves both spellings. It is a file-read affordance only: thefab config set/unset/explain/showdotted-key matcher accepts the new spellings exclusively. The2.18.1-to-2.19.0migration 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--effortflag 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 oldsession_commandspelling, which the read-time alias resolves until the2.18.1-to-2.19.0migration rewrites the disk). The built-inclaudeinteractive_command/DefaultInteractiveCommandfallback 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-cdropped. When every substituted value is non-empty, resolution is a plainstrings.ReplaceAllon 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, andheadless_commandrestate its three built-in capabilities. The command's presence does not select headless mode;dispatch.modedoes. - 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 fromagent.ResolveProvider(nil, name).Profilesinagent.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, soprofilesLinesreturns "" and its block renders noprofiles: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 dispatchpipes the stage prompt to the command's stdin, but both CLIs take the prompt as the argument to-pand 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 theexecsubcommand.)
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.sessiongoverns the Tier 1 roles (the agents a user talks to —default,operator);workersgoverns the Tier 2 roles (the agents pipeline stages dispatch to —doing,review,hydrate,fast). Both default toclaude, both arescope: both(settable once machine-wide), and both areadvertise: true— they are the whole advertised agent surface. Read via nil-safeGetAgentSession()/GetAgentWorkers(), where""means "not configured" (the built-in fallback lives ininternal/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 toA 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, andadvertise: false(machinery: documented infab 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 setproviderbeats the role's depth knob; a setmodel/effortbeats the resolved provider's fill. Modeled onAgentConfig.Profiles map[string]RoleProfile, read via the nil-safeGetAgentProfile(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 onlyThere is no cross-role inheritance on the agent side.
agent.profiles.defaultis thedefaultrole's own override, never a fallback source for the other five. The system's one cross-role fallback isproviders.<p>.profiles.default(§providers), 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 indocs/specs/stage-models.md§ Default role profiles.Intake rides the
defaultrole — it is pre-boundary and never dispatches, so it runs wherever the interactive session runs, which is also whydefaultsits at the session depth. -
tiers— the deprecated pre-2.17.0 spelling ofprofiles, read per role as a fallback (GetAgentProfileprefersprofileswherever it carries the role, so a half-migrated config resolves every role). Theagent.profilesregistry row records it viarenamed_from, and the2.16.19-to-2.17.0migration performs the on-disk rewrite in both scopes. One semantic the rewrite cannot preserve: the old map re-based every unset field from itsdefaulttier, 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-layeragent.profiles.<role>beats a project-layeragent.tiers.<role>(pinned byTestResolveCrossScopeLegacyAliasPrecedence); 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):
| Role | Fixed referents (NOT overridable) | Depth |
|---|---|---|
default | intake (advisory only — foreground); spawned worker sessions (fab batch), fab agent with no role; the /fab-proceed create-intake dispatch | Tier 1 |
operator | the operator coordinator session (fab operator) | Tier 1 |
doing | apply, review-pr — execution that must not err | Tier 2 |
review | review — the critic (its own role so its model/effort dial independently of the author's) | Tier 2 |
hydrate | hydrate — memory writing (its own role so it runs on a different model/effort than apply) | Tier 2 |
fast | ship — 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 validation — fab 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(enumpane | native | headless, defaultnative) — 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 plusinteractive_command; native requiresnative: true; headless requiresheadless_command. It never ascends above the configured preference. Invalid values warn and resolve tonative; nowatchablecompatibility alias is read.column_width(int percent, default35— the canonical symbolconfig.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 remaining100 − n%; later workers stack inside that column with unsized-vsplits, and the Left/Right separator is never touched again. An absent key, or any value outside1..99, resolves to the default: an absent YAML int is indistinguishable from0, so0cannot mean "unsized", and0/100are 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, defaulttrue— the canonical symbolconfig.DefaultDispatchReapDone, which the registry row interpolates rather than copying) — done-worker pane reaping. Whentrue,fab dispatch reapkills a pane-mode stage worker's tmux pane once its{stage}-result.yamlis 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). Setfalseto keep a done worker's pane and its scrollback. Reap is not kill: it never touches arunning/orphaned/faileddispatch, is a reported no-op for a headless record, and removes no.fab-dispatch/state. Modeled asReapDone *bool, unlike its two siblings: the default istrue, sonil(unset) reads astrueand an explicitreap_done: falsestays 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(enumcherry-pick-ladder | merge-auto | stacked-prs, defaultcherry-pick-ladder) — the standing merge-topology preference forfab operator autopilot start. The accessor does absent→default only and returns an invalid value raw — a deliberate divergence fromGetDispatchMode's fail-open warn (§ Design Decisions → "The Autopilot Merge-Mode Accessor Returns Raw;startOwns Validation").startresolves explicit--modeflag > config > built-in default (flag-absent detected via flag-changed state, so an explicit--mode cherry-pick-ladderstill reports sourceflag), printsmode: <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 nofab/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 lackingmodereads 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
## Metablock (rendered byfab pr-meta) (rj31) names the excluded scope in its<sub>provenance caption (<sub>excludesfab/,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 viagit diff --shortstat "$BASE...HEAD"against the merge-base with and without the:(exclude)<pattern>pathspec args (canonical math ininternal/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.yamldoes 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 / −0line.
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-fffauto-rework loop. TheMax cycles: {N}line is consumed (c5tr):_pipeline.mdreads 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 fieldEnabled: true|false(defaulttruewhen section absent or field unset). Whentrue, the single review agent's parsimony validation step runs (per execution-skills § Review Behavior). Whenfalse, 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>: falsebullets. Absent section / absent tool = enabled (list a toolfalseonly to DISABLE it). One toggle:copilotcontrols the/git-pr-reviewPhase 2 Copilot request (the--tool copilotflag 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 scaffoldcode-review.mdships this section as a commented block;fab/project/code-review.mdwriters 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 iscode-review.md's## Parsimony PassEnabled: true|falsetoggle (see above). - Memory tree shape bounds (tciy) — the ideal-shape guidance is hardcoded SHOULD guidance, NOT a
config.yamlfield: ~12 topic files per folder (soft upper bound;fab memory-indexwarns 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 ininternal/memoryindex(WidthWarnThreshold = 12,MaxDepth = 3) and are documented as guidance in the hydrate skills,docs-reorg-memory's Shape Report, anddocs/specs/templates.md. Aconfig.yamlsurface 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
- Enforce discipline — prevent over-engineering and architectural drift
- Ensure consistency — all code follows the same patterns
- Guide AI agents — principles constrain agent behavior during planning and implementation
How Skills Use It
/fab-setupgenerates it from project context (README, existing memory, conversation)/fab-continueand/fab-ffload it when generating the intake and the unifiedplan.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.yamlholds project settings (identity, source/test paths, true-impact excludes, plan-acceptance categories, theproviders:command table, theagent:depth knobs and per-role overrides, optionalstage_hooks— the real consumed surface)constitution.mdholds principles and constraints (MUST/SHOULD/MUST NOT rules)context.mdholds free-form project context (tech stack, conventions, architecture)code-quality.mdholds coding standards (principles, anti-patterns, test strategy)code-review.mdholds 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, andcode-review.mdare optional
Lifecycle Management
Updating Config
Run /fab-setup config to see all editable sections:
project— name and descriptionsource_paths— implementation code directorieschecklist— extra plan-acceptance categoriescontext.md— free-form project contextcode-quality.md— coding standards for apply/reviewcode-review.md— review policy for validation sub-agent- 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.
| Scenario | Command |
|---|---|
| See every available config option (the full schema) | fab config explain (canonical schema-discovery surface — see above) |
| Inspect one effective value and its source | fab config show <key> --origin |
| Set one project or machine override | fab config set <key> <value> [--system] |
| Restore inheritance for one override | fab config unset <key> [--system] |
| Add a new tech to the stack | Edit 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:
- Current constitution is displayed
- Amendment menu offers: add principle, modify principle, remove principle, add/modify constraint, update governance
- Multiple amendments can be made per session
- 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 type | Bump | Example |
|---|---|---|
| Remove or fundamentally change a principle | MAJOR | 1.2.0 → 2.0.0 |
| Add a new principle or constraint | MINOR | 1.2.0 → 1.3.0 |
| Clarify wording without changing meaning | PATCH | 1.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 Principleswith Roman numeral headings (### I.,### II., etc.)## Additional Constraints## Governancewith 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 construction — fab 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_tools → code-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/configref — configref → 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 spellings — fab 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