Change scope: the configuration chain¶
- Authors
- Matt Cockayne, Claude Opus 4.8 (AI drafting assistant)
- Date
- 20 July 2026
- Status
- IMPLEMENTED — 20 July 2026
- Related
- config v0.3.x migration, segregated default config
Purpose¶
An exact inventory of what changes, established before anything moves. Two previous attempts at this design failed by inferring the mechanism from one document instead of reading the code; this exists so that cannot happen a third time.
Everything below was established by reading the source on this branch, not from the specs.
Baseline¶
The config path today is 295 lines across 10 functions in pkg/cmd/root/root.go, plus
pkg/setup/init.go at 274 lines.
| Function | Lines | Fate |
|---|---|---|
newRootPreRunE |
70 | change |
buildConfigStore |
55 | change |
resolveBootstrapConfig |
37 | change |
embeddedSources |
28 | change |
autoInitialiseConfig |
28 | fix |
discoverProjectConfig |
21 | unchanged |
projectConfigPaths |
19 | unchanged |
anyConfigFilePresent |
17 | unchanged |
setupRootFlags |
12 | unchanged |
configOpts |
8 | delete |
118 non-test call sites read or write props.Config, across 19 packages. pkg/cmd/config
(26), internal/generator (19) and pkg/setup (14) are the concentrations.
Defects to fix first¶
Three exist on this branch now, all introduced or exposed by Phase 2. They are listed separately because they are corrections, not design.
| # | Defect | Location |
|---|---|---|
| 1 | autoInitialiseConfig calls loadAndMergeConfig, which was deleted and does not exist |
root.go:378 |
| 2 | configOpts names config.ContainerOption, WithLogger, WithEnvPrefix — all deleted in v0.3. Dead and non-compiling |
root.go:102-109 |
| 3 | Orphan doc comment for mergeEmbeddedConfigs, whose function was removed |
root.go:386-387 |
Defect 1 is the serious one: the auto-initialise reload path has no implementation, so a tool
with AutoInitialise set would not build.
Deletions¶
D-1 — setup.DefaultConfig and pkg/setup/assets/config.yaml¶
Four read sites, each with a replacement that already exists:
| Site | Today | After |
|---|---|---|
pkg/setup/init.go:192 |
cfg.ReadConfig(DefaultConfig) |
fs.ReadFile(props.Assets, "assets/config.yaml") |
pkg/setup/github/github.go:682 |
v.ReadConfig(DefaultConfig) fallback |
the store's view |
pkg/setup/bitbucket/bitbucket.go:458 |
same fallback | the store's view |
pkg/cmd/root/root.go:222,227 |
substitute layer when no file | the defaults layer, always present |
The github and bitbucket sites construct a throwaway Viper to read defaults the running store
already holds. Deleting them removes two of the migration spec's eleven Viper escape hatches
as a side effect. Note pkg/setup/ai does not have this fallback — an existing
inconsistency that resolves itself.
~24 lines of YAML, one //go:embed, one exported var, four call sites.
D-2 — The post-hoc flag binding path¶
This is the largest single simplification and it is available only because of v0.4.0.
Today flags are registered on the flag set at build time (root.go:719), then bound onto
config after the store is constructed (root.go:808). That path is already broken:
bindChangedFlags requires BindPFlag, which *config.Store does not have.
v0.4.0 has config.WithFlags(flags, opts...) (store.go:130), which makes flags an ordinary
layer. The store is built inside PersistentPreRunE, where the dispatched command — and
therefore its full flag set — is already known, so a single construction-time WithFlags
covers all three groups the current code binds separately.
Deletes:
| Symbol | Location | Lines |
|---|---|---|
bindCommandFlags |
root.go:853-888 |
36 |
bindChangedFlags |
options.go:120-138 |
19 |
builtinBoundFlags |
root.go:892-895 |
4 |
bindable interface |
options.go:142-144 |
3 |
Also removes the flags.CI \|\| props.Config.GetBool("ci") idiom duplicated at root.go:444,
:501 and :1012 — it exists precisely because the flag was bound into config yet callers
did not trust it. With flags as a layer, GetBool("ci") is authoritative.
WithBoundFlags, WithConventionBoundFlags and ConventionKey stay: they are the public API
tool authors use to declare the flag-to-key mapping, and that mapping is what WithFlags
needs.
~62 lines, plus three duplicated conditionals.
D-3 — The viper-bypass writers¶
| Symbol | Location | Lines | Why |
|---|---|---|---|
ensureMinimalConfig |
root.go:1049-1073 |
25 | builds a fresh viper.New() and writes behind the store |
| the same, duplicated | pkg/cmd/telemetry/telemetry.go:245 |
~20 | near-identical minimal-config writer |
Both exist because the store had no way to persist a single key. Store.Apply(ctx,
config.Set(...)) does, and preserves comments while doing it. promptTelemetryConsent
(root.go:1035-1038) loses its Set + GetViper().WriteConfig() pair for the same reason.
~45 lines and one duplicated concept.
D-4 — Dead code found while mapping¶
| Symbol | Location | Why dead |
|---|---|---|
configOpts |
root.go:102-109 |
defect 2 above |
InitOptions.SkipLogin/SkipKey/SkipAI |
setup/init.go:50-64 |
superseded by the registry's FeatureFlag closures; nothing reads them |
dirPermUserOnly |
setup/init.go:24 |
unreferenced |
| orphan comment | setup/init.go:29 |
duplicate of :38 |
| orphan comment | setup/init.go:274 |
describes configureSSHKeyConfig, which lives in github/ssh.go |
| orphan comment | root.go:386-387 |
defect 3 above |
Note autoInitialiseConfig:369-372 passes SkipLogin/SkipKey/SkipAI: true — dead fields, so
that call is relying on flags that do nothing. Whether the intent (a non-interactive init with
no credential wizards) is achieved by Interactive: &false alone needs confirming before the
fields go.
Changes¶
C-1 — buildConfigStore gains a defaults layer and loses a branch¶
The four-way switch collapses. setup.DefaultConfig was a substitute for a missing file;
assets/config.yaml is a layer, so the "no file" case stops being special:
1. assets/config.yaml merged across bundles (new, always)
2. ConfigPaths embedded assets existing
3. CfgPaths files existing
4. env existing
5. flags (new — was post-hoc binding)
The !AllowEmpty → ErrNoConfigFile arm stays: auto-initialise depends on it.
One inconsistency to resolve while here — the current DefaultConfig branch declares no
file layer, so that store has nothing writable, while the default: branch declares the
files specifically so Apply and config path work. The collapsed form should always declare
the files.
C-2 — embeddedSources loses its error return¶
It never returns a non-nil error, so the check at buildConfigStore:200-202 is unreachable.
C-3 — autoInitialiseConfig calls buildConfigStore¶
Fixes defect 1.
C-4 — Init seeds from the merged assets/init/config.yaml; mergeExtraConfig is deleted¶
(Revised 2026-07-20 — the first version had init reading assets/config.yaml, i.e. writing
defaults into the user's file. Wrong direction: a default written into a user's file is pinned
there, overriding any future change to the shipped default. The written file is a template.)
mergeExtraConfig opens the bare assets/init/config.yaml (setup/init.go:168), but ai and
github mount at a prefix and mountedFS only serves the prefixed path — so their init
content lands in no generated config today. A live bug, and mergeExtraConfig has no test
in either direction.
With bundles registered per segregated-defaults D8, the bare path merges the framework
template with every enabled feature's template. initializeConfig seeds from one
fs.ReadFile(props.Assets, "assets/init/config.yaml"), replacing both the
setup.DefaultConfig read and mergeExtraConfig (~26 lines, deleted). The Mount calls in
NewGitHubInitialiser / NewAIInitialiser are deleted as superseded. See
segregated-defaults D9, including the regression test.
C-5 — The 118 read sites¶
Mechanical: props.Config.GetX(...) → props.Config.View().GetX(...). Already scoped as
Phase 3 of the migration spec, split per package.
Worth recording: zero call sites use AddObserver, Watch, With or View today. The
reactive surface is entirely unadopted. Adopting it is explicitly not in this scope — it
would be a second behaviour change riding on a migration that is meant to be
behaviour-preserving.
C-6 — The generator seeds assets/config.yaml¶
generateAssetFiles (internal/generator/files.go:30-65) writes
<cmdDir>/assets/init/config.yaml containing "<name>:\n". It gains a sibling
assets/config.yaml.
The registration is already correct — templates/command.go:269-273 emits
props.Assets.Register("<name>", &assets) as the constructor's first statement. No template
change needed for wiring, only for the seeded file.
Additions¶
| Item | Where |
|---|---|
Framework bundle: assets/config.yaml (log.*) + assets/init/config.yaml (template) |
pkg/cmd/root/assets/, registered unconditionally at root construction as framework |
assets/config.yaml for github.* |
pkg/setup/github/assets/ |
setup.RegisterAssets(feature, name, bundle) + GetAssets() registry slot |
pkg/setup/registry.go — segregated-defaults D8 |
| Feature-gated bundle application at root construction | pkg/cmd/root, beside registerFeatureCommands |
Generator seed for assets/config.yaml |
internal/generator/files.go |
update.* ships no defaults at all: ResolveUpdatePolicy and ResolveCheckInterval
treat "" as "unset, defer to the tool baseline" (verified), so the god file's empty-string
stanza was a no-op as a default. Its commented placeholders are template commentary and move
to the framework bundle's assets/init/config.yaml. log.* has no owning package and lands
in the framework bundle.
Net effect¶
| Lines | |
|---|---|
| Deleted outright | ~180 (incl. mergeExtraConfig ~26 and the two Mount calls — C-4 revision) |
| Dead code removed | ~20 |
| Added | ~45 (incl. RegisterAssets/GetAssets and the gated application loop) |
| Net | ≈ −155 |
Plus: one god file gone, one duplicated YAML block gone, one duplicated minimal-config writer
gone, three duplicated ci conditionals gone, two Viper escape hatches gone, and one live bug
fixed (ai/github init content never reaching a written config).
Order of work¶
Each step leaves the tree in a defined state.
- Defects — fix 1–3. Restores intent to Phase 2; no behaviour change.
- Phase 3 — the 118 read sites, split per package. Restores the build. Largest diff, lowest risk.
- Phase ⅘ — writes move to
Apply; the remaining escape hatches; D-3 deletions. - Flags as a layer — D-2. Behaviour-preserving in intent but touches precedence, so it wants its own tests and its own commit.
- Defaults layer — C-1, C-4, D-1, the per-package
assets/config.yamlfiles. The one deliberate user-visible behaviour change. - Generator — C-6, then the emission tests.
- Docs — the precedence table,
validate-component-config.md,assets.md.
Steps 4 and 5 are the two that change behaviour. Neither should share a commit with anything else.
Risks¶
Test couplings to literal paths. generator_test.go:80-83 hardcodes
assets/init/config.yaml and the "new-cmd:" content; skeleton_test.go:50,247 hardcode
pkg/cmd/root/assets/init/config.yaml; detectAssets matches the literal string
//go:embed assets/* and would not match all:assets.
props/test.New() builds empty Assets. Tests asserting framework defaults will start
seeing zero values unless the fixture registers the framework bundle.
SealRegistry is never called in production — only from two tests. If registration order
starts carrying precedence, nothing currently prevents a late registration.
Defaults now always apply (segregated-defaults spec D4). A key a user deliberately omitted to get the zero value will resolve to the default. Release-note material.
Open questions¶
Where doResolved 2026-07-20: through the setup registry they already announce themselves in.pkg/setup/githubandpkg/setup/airegister their bundles?setup.RegisterAssets(feature, name, bundle)is called from the same packageinit()assetup.Register; root construction applies the bundles of enabled features toprops.AssetsbeforePersistentPreRunEruns. See segregated-defaults D8.Do the deadResolved 2026-07-20: no — delete them, and it is not a regression. The user-facing skip behaviour never flowed throughSkipLogin/SkipKey/SkipAIfields carry intent thatInteractive: &falsedoes not?InitOptions: the--skip-login/--skip-key/--skip-aiflags bind the package-level vars inpkg/setup/githubandpkg/setup/ai(via the registryFeatureFlagclosures), which gate theInitialiserProvider(nil when skipped) and populateGitHubInitialiser.SkipLogin/SkipKey— a different struct, which is read (github.go:197-217). Nothing has ever read theInitOptionsfields in this repo's history (git log -S 'opts.SkipLogin'is empty back to the initial commit).autoInitialiseConfigis doubly covered without them: it passes noInitialisersat all, so the wizard loop iterates an empty slice, andInteractive: &falseguards the same path.- Should
ConfigPathsandCfgPathsbe renamed? They are disjoint namespaces against different filesystems in different precedence tiers, distinguished only by a missing three letters. Free to fix while the struct is being touched; out of scope if not. Flags-as-a-layer surface (D-2)Resolved 2026-07-20: accept — honest provenance, no new exposure.config.WithFlags(cmd.Flags())contributes every changed flag by the dash-to-dot convention (net new:--configand--outputbecome visible config keys, and inherited persistent flags participate). The exposure question was checked against both read surfaces:config list/getroute every value throughMasker.MaskIfSensitive(masked by default,--unmaskis explicit opt-in), and the doctor report'sreport_redact.gois a single redaction choke point — credential-shaped keys are dropped to a sentinel and every other leaf is scrubbed throughredact.String. A token supplied via a flag therefore sits behind exactly the masking the same token would get from a config file; the surface is unchanged.
Status¶
IMPLEMENTED — 20 July 2026. Open questions 1, 2 and 4 were resolved before implementation;
question 3 (the ConfigPaths/CfgPaths rename) was left as-is — out of scope, still open
as a future cleanup.