Migrate GTB to go/config v0.3.x¶
- Authors
- Matt Cockayne, Claude Opus 4.8 (AI drafting assistant)
- Date
- 20 July 2026
- Status
- IMPLEMENTED — 20 July 2026
- Related
- config module extraction (the v0.2.0 extraction this supersedes in practice),
module extraction playbook (§ normative guidance that names
config.Containableand must be updated), go/config specs:2026-07-19-store-architecture,2026-07-20-filesystem-abstraction
Summary¶
go/config v0.3.0 replaced the Viper-backed container with a Store architecture. GTB is on
v0.2.0 and depends on the module entirely, so this is not an optional upgrade — it is the
migration that unblocks every subsequent config change.
The target is v0.3.1, plus config-afero v0.1.0 for the filesystems GTB hands to
config that are not the real one.
Five things changed, and they are not equally expensive:
| Change | Cost |
|---|---|
Containable → Reader |
~148 sites, mechanical — Reader is a strict superset |
Several constructors → NewStore |
~25 sites, six of them genuine redesigns |
WithFiles takes config.FS, not afero.Fs |
6 sites, needs an adapter for two of them |
| Watching is explicit | 1 site that does not exist yet — see D6 |
| Viper is gone | 11 escape-hatch sites, two with no direct replacement |
Motivation¶
GTB cannot stay on v0.2.0. The module is published, the old API is gone, and every future config improvement lands on v0.3.x. But the migration is worth doing on its own merits:
Writes stop destroying user files. pkg/setup and pkg/cmd/config currently write by
re-encoding the merged view, so a saved file loses every comment its author wrote and gains
every value that arrived from somewhere else. Two helpers exist solely to work around this
(D9). Provenance-routed Apply removes both the problem and the workarounds.
Reads become coherent. Configuration is read live today, so two related values can be read either side of a reload. Measured upstream: 1,759 mismatched pairs in 6.76M reads against 0 for snapshot-pinned reads.
The dependency graph shrinks. Viper leaves. GTB's own footprint guard currently permits the Viper stack; after this it forbids it (D12).
Blast radius¶
Measured on de7969b, not estimated.
| Count | |
|---|---|
| Production files importing config | 29 |
| Test files importing config | 72 |
| Generated mock files | 12 |
| Distinct call sites | ~350 |
| Documentation lines mentioning config/viper/Containable | 595 across ~110 files |
Read/write split across production code:
GetString 72 IsSet 35 GetBool 23 GetInt 7 Sub 5
GetDuration 2 Has 2 Unmarshal 1 GetFloat 1 → 148 reads
Set 22 WriteConfigAs 2 → 24 writes
GetViper 11 → 11 escape hatches
Roughly 80% is mechanical, because config.Reader is a strict superset of Containable
for every read method GTB uses. The remaining 20% is where this spec spends its decisions.
Decisions¶
D1 — Props.Config becomes *config.Store, and reads take a View explicitly¶
This decision sizes every other one, so it comes first.
Containable was live: it wrapped a container that reloaded underneath its readers. Its
natural replacement, config.Reader, is satisfied by *config.View — which is pinned to a
snapshot and goes stale after a reload. A direct type swap would silently freeze
configuration at startup.
Three options were weighed:
(a) Props.Config *config.Store, readers call .View(). Correct, coherent, and costs
~148 edits inserting .View().
(b) A live-forwarding adapter implementing Reader by delegating each method to
store.View(). Zero edits — and it reintroduces exactly the tearing the Store exists to
prevent, because each call resolves against a different snapshot. This is the option that
looks cheapest and is worst: it would give GTB the behaviour upstream measured at 1,759
mismatched pairs and rejected.
© Props.Config config.Reader holding a View. Cheapest of all and simply wrong: the
configuration would stop tracking the file.
Decision: (a). Props.Config is a *config.Store. ConfigProvider.GetConfig() returns
*config.Store. Read sites become props.Config.View().GetString(...).
Where several values must agree with each other, use store.With(func(v *config.View) error)
rather than taking several views — that is what it is for.
D2 — ConfigProvider keeps its shape; a second interface exposes reads¶
ConfigProvider.GetConfig() *config.Store stays the injection point, because writers need
the Store and it is the thing that is live.
Consumers that only read should not take a Store. pkg/props gains:
// ConfigReader provides read access to resolved configuration.
type ConfigReader interface {
Config() *config.View
}
so a component can declare the narrow dependency, and the four *_config_adapter.go files
that only read take config.Reader directly rather than any GTB type. That is the local
one-method-interface pattern the toolkit already uses, applied to the read surface.
D3 — Props.FS stays afero.Fs; config gets an adapted view of it¶
GTB carries 232 non-test afero references and internal/generator/dryrun.go installs an
afero.CopyOnWriteFs as the application filesystem. Converting GTB to config.FS wholesale
is a separate, larger change and is not in scope.
Props gains:
// ConfigFS adapts the application filesystem for the config module.
func (p *Props) ConfigFS() config.FS { return configafero.Wrap(p.FS) }
configafero.Wrap claims RealPather and LinkReader only where the wrapped filesystem
supports them, which matters here: the dry-run CopyOnWriteFs can read links but has no path
the operating system can watch, and claiming otherwise would send a watcher after files that
are not there.
Production paths that pass the real filesystem may use config.OS() directly and skip the
adapter.
D4 — The eleven Viper escape hatches, individually¶
Nine have direct replacements. Two do not.
| Site | Replacement |
|---|---|
pkg/chat/config_adapter.go GetViper().GetStringSlice(k) |
view.GetStringSlice(k) |
pkg/cmd/config/list.go GetViper().AllSettings() |
store.Snapshot().Values() |
pkg/cmd/config/unset.go GetViper() + ReadInConfig() |
store.Apply(ctx, config.Remove(k)) |
pkg/cmd/config/set.go GetViper() |
store.Apply(ctx, config.Set(k, v)) |
pkg/cmd/config/migrate.go GetViper() |
store.Snapshot().Values() + Apply |
pkg/cmd/telemetry/telemetry.go GetViper() |
store.Apply — and see D9 |
pkg/cmd/doctor/report.go GetViper() |
store.Sources() / view.Explain(key) |
pkg/cmd/root/root.go (×2, ConfigFileUsed presence test) |
store.Sources() — "did any file load?" |
The two that need design work:
pkg/cmd/root/root.go — GetViper().MergeConfig(strings.NewReader(cfg.ToJSON())). This
deep-merges embedded defaults underneath user config. There is no equivalent because the
Store does not need one: defaults are an ordinary layer. Resolved — see D13.
pkg/telemetry/datadir_config_adapter.go — ResolveDataDir(GetViper().ConfigFileUsed()).
"The config file used" is not a single value in a layered store; it asks a question the model
does not answer. Options, to be settled before implementation:
- Resolve against the highest-precedence writable file layer — closest to intent, since the data directory sits beside the file a user edits.
- Take it from
store.Sources()filtered toSourceFile, last entry. - Give telemetry its own explicit data-directory setting and stop deriving it.
Option 3 is the honest one — deriving a data directory from a config file path was always incidental — but it is a behaviour change for existing installs. Recorded as open question 1.
D5 — ObserveSection sites need no change, and no ApplyInitial¶
Four sites bind typed sections: pkg/http, pkg/grpc, pkg/gateway,
pkg/telemetry/observability_otel_config.go. None registers an apply callback; all consume
by polling .Value()/.Current().
An earlier assessment held that these needed an ApplyInitial() decision because delivery is
now change-only. That was wrong. v0.2.0's ObserveSection also reached its apply callback
solely from inside the observer it registered — verified against the v0.2.0 source. Delivery
has always been change-only, nothing is accommodating a change, and no ApplyInitial call is
required to preserve behaviour.
What does change in those files: the first parameter becomes a config.Binder (*Store
satisfies it), and the closures passed to WithSectionDefaultFunc take config.Observed
where they took config.Containable.
D6 — Watching must be explicitly wired, and nothing today calls it¶
This is the only change in the migration with no compiler error.
v0.2.0 started an fsnotify watcher inside every constructor, so GTB got hot-reload without a
call site. Nothing in the codebase calls a watch API today, because there was never anything
to call. v0.3.x requires store.Watch(ctx), and if it is not added the code compiles, the
tests pass, and configuration silently stops reloading.
pkg/cmd/root gains the call, with the returned stop tied to the command's context, and
OnReloadError wired to the logger so a rejected reload is reported rather than swallowed.
Acceptance for this decision is a test, not a review: an integration test that changes a file on disk and asserts an observer fires. Without it, this decision is unenforceable.
D7 — The generator's four emission sites change in lockstep with setup.Initialiser¶
The generator emits config-typed signatures into every scaffolded project, so its output is the public contract of every downstream tool.
| Emission site | Emits |
|---|---|
templates/command.go:369 |
IsConfigured(cfg config.Containable) bool |
templates/command.go:373 |
Configure(p *props.Props, cfg config.Containable) error |
templates/command.go:746 |
Validate<Name>Config(cfg config.Containable) error |
templates/command.go:805 and stubs.go:60-75 |
Init<Name>(p *props.Props, cfg config.Containable) error |
All four become config.Reader. stubs.go duplicates the stub independently for the
main.go-preserved path and must change with the template, or a scaffolded project gets one
signature from --force and a different one without it.
pkg/setup/init.go:44,46 declares the Initialiser interface the emitted code implements.
It must change in the same commit, or generated code stops satisfying it.
Amended 2026-07-20, during implementation: Configure (and therefore its emitted
counterpart) takes a new setup.Editor — View() *config.View plus Set(key, value) error
— rather than config.Reader. The table above glossed that Configure writes: every
initialiser calls Set, which Reader cannot express. IsConfigured and
Validate<Name>Config take config.Reader as planned. The editor is backed by the store's
Apply, so wizard writes edit the target document in place and template comments survive.
Good news the survey established: the generated pkg/cmd/root/root.go does not construct
a container — it builds Props and delegates to GTB's NewCmdRoot. Downstream tools inherit
the Store transparently, so these four signatures are the entire generated break surface.
D8 — ValidateStruct should be widened upstream rather than worked around here¶
The emitted Validate<Name>Config breaks in a way a rename does not fix:
It takes a concrete *View, so the generated config.ValidateStruct[FooConfig](cfg)
fails even after Containable → Reader, and forcing the generated signature to
*config.View would make downstream validation unmockable.
It does not need the concrete type. validateView calls exactly four methods — Get, Has,
Keys, Shadowed — and all four are on Reader.
Prototyped upstream and measured, rather than assumed. The change is three signatures:
func ValidateStruct[T any](cfg Reader, opts ...SchemaOption) error // was *View
func validateView(view Reader, schema *Schema) *ValidationResult // was *View
func configuredKeys(view Reader) []string // was *View
ValidateStruct calls validateView(cfg, schema) directly instead of cfg.Validate(schema).
View.Validate is untouched and keeps working.
Results of the prototype:
- config's full suite passes unmodified — no behaviour change.
- A
mocks.MockReadervalidates successfully, which is the point: downstream generated code stays mockable. - Existing callers passing
store.View()compile unchanged, because*ViewsatisfiesReader. The widening is source-compatible.
Decision: take the upstream widening and depend on it. The alternative — emitting
*config.View — exports a concrete type into every scaffolded tool to work around a parameter
that was over-narrow, and makes every downstream Validate<Name>Config untestable without a
real Store.
Release shape: widening a parameter is additive, so it is a feat: and lands as config
v0.4.0 rather than a patch. That is the only cost, and it is sequencing rather than risk —
recorded as open question 2.
D9 — Two workaround helpers are deleted, not ported¶
pkg/cmd/telemetry/telemetry.go ensureConfigFile and pkg/cmd/root/root.go
ensureMinimalConfig both construct a fresh Viper, set one key and write it, explicitly to
avoid dumping embedded defaults into a user's file.
That problem does not exist in v0.3.x: Apply edits the target document in place and writes
only the key it was given. Both helpers are deleted rather than ported, and their call sites
become a single Apply.
D10 — Documentation is swept in three tiers, not uniformly¶
595 lines across ~110 files. Treating them alike would either waste effort rewriting history or leave user-facing instructions wrong.
Tier 1 — correct in place (user-facing). These are copy-paste instructions that no longer
compile: docs/how-to/test-configuration.md, config-hot-reload.md,
validate-component-config.md, testing.md, embed-custom-assets.md,
observe-typed-config.md, add-initialiser.md, plus the transport adapter guides
(security-headers.md, add-grpc-service.md, expose-grpc-as-rest.md).
Two need rewriting rather than editing, because the change invalidates their narrative and not just their identifiers:
config-hot-reload.mdteaches implicit always-on watching as the container's defining behaviour. ExplicitStore.Watch(ctx)inverts the premise.test-configuration.mdbuilds its entire testing story onNewReaderContainerandGetViper(). The replacement story isconfig.Dir(t.TempDir())or the published mocks.
Tier 2 — leave alone (historical). Migration notes, implementation notes and IMPLEMENTED specs are dated records of what was true then. Editing them destroys the record. Two exceptions:
docs/reference/migration/v0.x-config-extracted.mdgets a successor note, not an edit. Its thesis — "It is the toolkit's Viper layer: it carries the Viper stack so nothing else has to" — is now exactly inverted, and that is worth showing rather than hiding.docs/reference/migration/index.mdgains a row.
Tier 3 — forward-looking, must be corrected.
docs/development/specs/2026-07-12-go-module-extraction-playbook.md is the only APPROVED,
normative spec, and it names config.Containable twice as guidance for future extractions. It
will keep propagating a dead type until changed.
Also in scope, and easy to miss: the marketing surface.
docs/about/comparison.md argues GTB's value proposition in terms of wrapping Viper ("GTB
uses both Cobra and Viper internally"), docs/about/coming-from-other-ecosystems.md says the
same, and README.md:57 says "Viper-powered configuration" behind a link that is already
stale. These are positioning claims outside where a docs sweep naturally looks, and they will
be flatly false.
Diagrams requiring redraw: docs/explanation/concepts/architecture.md has
Containable ..> Viper : wraps — an edge to a dependency that no longer exists.
interface-design.md reproduces the whole Containable interface verbatim including
GetViper() and WriteConfigAs(). validate-component-config.md has an ASCII diagram naming
"Viper merge hierarchy" as a box.
D13 — Precedence is already documented, and it translates directly¶
The order is not something to re-derive. It is stated in docs/development/security.md:15
and AGENTS.md:155, and the code at pkg/cmd/root/root.go:189 agrees with it — embedded
configs are the base and the main config is merged over them, with the comment saying so
explicitly.
Highest to lowest, combining both sources with the project-local layer described at
root.go:112:
| Layer | |
|---|---|
| 1 | CLI flags |
| 2 | Environment variables |
| 3 | Project-local .<tool>.yaml (repo-root, walked up from cwd) — suppressed entirely when --config is given, see D14 |
| 4 | Config files: the --config paths if given, otherwise the two defaults — see D14 |
| 5 | Embedded assets (setup.DefaultConfig) |
| 6 | Internal defaults |
That maps onto the Store with no interpretation required, because precedence there is the order sources are added:
store, err := config.NewStore(ctx,
config.WithReaders(config.NamedSource{
Name: "embedded:defaults.yaml", Content: setup.DefaultConfig,
}), // 5 — lowest
config.WithFiles(fsys, cfgPaths...), // 4 — --config, or the defaults
config.WithFiles(fsys, projectLocalPath), // 3 — later wins over the above
config.WithEnv(props.Tool.EnvPrefix), // 2
config.WithFlags(cmd.Flags()), // 1 — highest
)
Amended 2026-07-20: the setup.DefaultConfig NamedSource shown at layer 5 is superseded —
the embedded-defaults layer is the merged assets/config.yaml read through props.Assets,
per the segregated default config spec. The order
itself is unchanged.
This is a strict improvement, not just a translation. Today the order is an emergent
property of where MergeConfig is called and in which direction; a reader has to trace three
functions to work it out, and the doc is a separate assertion that could drift. After the
migration the call site is the documentation.
One behavioural nuance worth stating rather than discovering. cfg.ToJSON() serialises the
resolved main config, so environment-derived values are flattened into the merge at load
time. In the Store, environment stays a live layer. Resolved values are identical — the
environment does not change under a running process — but provenance improves: Explain can
say a value came from env:MYTOOL_SERVER_PORT where today it would name the merged config.
D14 — --config replaces the default paths, is repeatable, and is not the last word¶
--config is load-bearing and undocumented outside development notes, so its exact semantics
are recorded here and must survive the migration unchanged.
It is a StringArray with the default paths as its default value
(pkg/cmd/root/root.go:803-808). pflag's stringArray replaces on first Set and appends
after, which gives override semantics rather than additive ones. Verified against pflag
v1.0.10:
no flag → [~/.config/<tool>/config.yaml /etc/<tool>/config.yaml]
--config a.yaml → [a.yaml] # defaults discarded
--config a.yaml --config b.yaml → [a.yaml b.yaml] # repeatable, later wins
This maps onto WithFiles exactly. config's contract is "the first is the base and the
last wins", which is the same rule config.Load applies today, so the whole list — however it
was arrived at — passes straight through:
A bug to fix, not behaviour to port. projectConfigPaths (root.go:115-129) appends
the discovered project-local file unconditionally, including when the paths came from an
explicit --config. So:
That contradicts the intent the flag was built for. --config is a StringArray whose
default value is the standard paths precisely so that supplying it replaces them outright —
the replace-not-append semantics are the design, not an accident of pflag. A flag designed to
be the authoritative answer should not then be overridden by a file the user did not name and
may not know is there. The failure mode is quiet and bad: a tool invoked with an explicit
config runs against different settings, with nothing on the command line to explain why.
The doc comment on discoverProjectConfig shows the gap in reasoning — it says the layer is
appended last so it overrides "the global ~/.<tool>/config.yaml". It contemplates the
default path. Nobody weighed it against an explicit one.
The fix. Suppress the project-local layer when --config was explicitly set. pflag
records this exactly, and it is the right test — it distinguishes "the user named files" from
"the defaults happen to be in the slice", which inspecting the paths themselves cannot do:
func projectConfigPaths(props *p.Props, cmd *cobra.Command, base []string) []string {
// An explicit --config is authoritative: it already replaced the default
// paths, and a project-local file the user did not name must not override
// files they did.
if cmd.Flags().Changed("config") {
return base
}
...
}
There is exactly one call site (root.go:700), which already holds cmd, so this is a
one-line signature change and a one-line guard.
Scope of the change. This is user-visible: a tool run with both --config and a
project-local file in scope now uses only the named file. That is the behaviour the flag was
intended to have, so it is a fix rather than a feature, but it wants a release note and a
test pinning both directions — with --config, the project layer is absent; without it, the
project layer still applies.
It does not depend on the config v0.3 swap and could have shipped separately, but it lands as part of this migration (resolved 2026-07-20). The precedence documentation has to be rewritten either way — D10's sweep and this fix touch the same table — and splitting them would mean documenting the old rule and then correcting it a release later. One behaviour change to the precedence order, described once.
The release note is therefore the migration's, and must call this out separately from the config swap: the swap is intended to be behaviour-preserving, and this is the one deliberate user-visible change riding with it. Burying it under "upgraded config" is how a real behaviour change gets missed.
Documentation gap. --config appears nowhere in the user-facing precedence material —
not in docs/development/security.md, not in docs/reference/config/index.md, not in
AGENTS.md. It is mentioned only in implementation notes and specs. The precedence table in
D13 is therefore the first complete statement of the order, and the doc sweep (D10) must add
it to the user-facing pages rather than merely correcting what is already there.
D11 — Mocks are regenerated, never hand-edited¶
12 generated mock files under mocks/ cover ConfigProvider, CoreProvider,
LoggingConfigProvider and setup.Initialiser. All change signature. mockery regenerates
them; the diff is reviewed but not authored.
D12 — The dependency footprint guard inverts¶
GTB's guard currently permits the Viper stack, on the reasoning recorded in the extraction
spec that config legitimately carries it. After this migration Viper is absent from the graph
entirely, and the guard should forbid github.com/spf13/viper so it cannot return
unnoticed.
Verify by removing the entry and watching the guard fail, per the project's own testing guidance — a guard nobody has watched fail is a guard nobody knows works.
Testing strategy¶
The compile gate is internal/generator/compile_integration_test.go. It generates a
skeleton, adds commands, injects a replace, and runs go build ./.... It is the only test
that proves generated code compiles against the real module, and it will fail until D7 and D8
are complete. It is the acceptance criterion for the generator work.
~20 generator tests break on construction alone — config.NewFilesContainer does not exist
in v0.3.x. These are mechanical and should be converted via a single shared test helper rather
than 20 independent edits, so the next migration touches one place.
D6 needs a behavioural test. Everything else in this migration fails to compile if done wrong. Watching fails silently, so it needs an integration test that writes a file and asserts an observer fires.
Write-path behaviour changes and needs new assertions. The old code round-tripped the whole merged tree; the new code edits a targeted layer. Expect different bytes on disk, and assert the new guarantee — comments survive, only the named key changes — rather than adjusting old assertions until they pass.
Migration procedure¶
Phased so each phase is independently verifiable, and ordered so the largest mechanical pass happens after the shape is settled.
Phase 0 — upstream. Land Done —
config v0.4.0. Widening a parameter is additive, so it released as a minor rather than the
patch this originally guessed at.ValidateStruct[T](cfg Reader, ...) (D8).
One thing the D8 prototype missed, recorded because it is the kind of thing that recurs when a
concrete parameter becomes an interface: validateView guarded on view == nil, which caught
a nil *View while the parameter was concrete. Boxed in an interface that comparison is
false — the interface carries a type — so the nil pointer reached Get and panicked, on
exactly the input the guard existed for. Fixed with a reflective isNil, and confirmed to
panic without it rather than assumed to be covered.
Phase 1 — pkg/props. Props.Config *config.Store, ConfigProvider, ConfigReader,
ConfigFS(). Nothing else compiles until this is settled, and it dictates the diff everywhere
else (D1, D2, D3). Regenerate mocks.
Phase 2 — construction. pkg/cmd/root bootstrap: one NewStore replacing the
Load/LoadEmbed/NewReaderContainer chain, including the embedded-defaults precedence
re-derivation (D4). Add Watch (D6) with its test.
Phase 3 — the mechanical pass. Containable → Reader, .View() insertion across ~148
read sites and the nine *_config_adapter.go files. Split by package (open question 5), so
each commit is small enough to actually read.
Phase 4 — writes. pkg/setup/{init,ai,github,bitbucket,telemetry} and pkg/cmd/config
move to Apply. Delete the two workaround helpers (D9).
Phase 5 — the remaining escape hatches. The nine direct replacements, then the two designed ones (D4).
Phase 6 — generator. Four emission sites, stubs.go, and setup.Initialiser in lockstep
(D7). Green compile_integration_test.go is the gate.
Phase 7 — tests. The ~20 construction-only generator tests, plus the 72 test files.
Phase 8 — documentation sweep (D10), three tiers.
Phase 9 — guard and cleanup. Invert the footprint guard (D12), confirm Viper is absent
from go.mod and go.sum.
Acceptance criteria¶
go build ./...andgo test -race ./...pass.github.com/spf13/viperappears in neithergo.modnorgo.sum, and the footprint guard forbids it — verified by removing the entry and watching it fail.compile_integration_test.gopasses, proving generated projects compile.- A test asserts that a file changed on disk reaches an observer (D6).
- A test asserts a write preserves comments and changes only the named key.
- No documentation in Tier 1 contains a config API symbol that no longer exists.
mockeryoutput is current and unedited by hand.
Open questions¶
How shouldResolved 2026-07-20 by the parity principle. Viper'sResolveDataDirFromPropsresolve a data directory?ConfigFileUsed()returns the last file it loaded — stated indocs/reference/migration/v0.21-config-files-accessor.md:26, which is whyConfigFiles()was added in the first place. Parity is therefore the last file source:store.Sources()filtered toSourceFile, last entry. An explicit telemetry data-directory setting remains the better long-term answer and is out of scope here.Do we take the upstreamResolved 2026-07-20: yes, released as config v0.4.0. Additive rather than breaking, so it does not disturb the pre-release bedding-in period. See Phase 0 for the nil-guard hazard the prototype missed.ValidateStructwidening?DoesResolved 2026-07-20: no.Props.FSeventually becomeconfig.FS?Props.FSstays a full afero instance —OsFslive,MemMapFsin tests — because that is how GTB operates today and parity is the goal. config gets an adapted view of it throughconfigafero.Wrap, per D3. Nothing else changes.What is the precedence for embedded defaults after the merge is replaced?Resolved 2026-07-20: the documented order, which the code already matches. See D13.Is a single mechanical commit for Phase 3 reviewable at ~148 sites?Resolved 2026-07-20: split by package. A diff that large gets rubber-stamped, and reviewers skim mechanical changes precisely when the one non-mechanical site is hiding among them. The cost — a longer-lived broken build — is confined to a feature branch nobody else builds from. (Question 6 — whether an explicit--configshould suppress the project-local layer — resolved 2026-07-20: yes. It was always intended to be authoritative over both the defaults and the project-local file, which is why it was built as a replacing flag. Recorded in D14.)
Status¶
IMPLEMENTED — 20 July 2026, in the same MR that approved it. All six open questions were
resolved the same day (question 2 as config v0.4.0; question 5 as split-by-package;
question 6 as D14). Every acceptance criterion holds: build/test/race green module-wide,
viper absent from go.mod and go.sum with a guard verified by failure, the generated-project
compile gate green, the D6 watch integration test green, comment-preserving targeted writes
asserted, tier-1 docs swept, and mocks regenerated. One amendment recorded during
implementation: emitted Configure takes setup.Editor (D7 note), since a Reader cannot
write.