0035 — Adopt go/config for .keryx.yaml (structure-preserving writes)¶
Status: IMPLEMENTED 2026-07-22. keryx is on go/config + go-tool-base v0.32.0 (MR !145,
merged): reads on config.Reader/View, writes on Store.Apply(Set/Remove). CLI
theme/avatar + OAuth token writes are comment-preserving; the studio managers now route
through the project Store's Apply too (this change) — the project store opens over the
.keryx.yaml even when absent so Apply creates it, targeted per-avatar/per-theme writes
land only the touched subtree, and a hand-authored .keryx.yaml (comments, content:
block, ordering) survives a studio save. Remaining nicety (not blocking): per-theme
targeted writes instead of the whole-themes-subtree Set (loses within-themes
comments only — usually inherited, not in the project file).
(Spun out of 0034 Q3 2026-07-18 as "build a keryx-local comment-preserving writer";
re-scoped 2026-07-18 to "consume an upstream feature"; re-scoped 2026-07-21 against the
shipped redesign — a from-scratch layered Store, timed to the framework's switchover.)
Date: 2026-07-21
Relates: 0014 (studio per-project config — the two-instantiation model + the global
inheritance allowlist this builds on), 0034 (studio themes/avatars managers — the
config-editing surfaces), 0001 §7 (config layer), 0010 (auth/refresh — token
writes, out of scope here).
Upstream: gitlab.com/phpboyscout/go/config v0.5.0 — the extracted, redesigned config
module. Comment-safe writes shipped as a first-class feature (issue #1, resolved): a
Store owns all I/O, Apply(Set/Remove) edits the target document in place. keryx
consumes this; it does not implement it.
1. Problem (unchanged)¶
Every .keryx.yaml write goes through viper's whole-file rewrite
(viper.WriteConfig/WriteConfigAs) — the studio's mutateProjectConfig (0034) and the
CLI theme/avatar commands (via props.Config). That strips comments, reorders
keys, flattens the layer hierarchy (WriteConfigAs encodes viper.AllSettings(),
the fully merged view — verified in viper v1.21.0 viper.go:1735), and — because viper's
Set leaves stale flattened keys — cannot cleanly express a delete (0034 hit this on
theme removal). .keryx.yaml is a hand-owned, checked-in, commented file; both surfaces
that edit it are hostile to it.
2. What the upstream redesign gives us¶
go/config v0.5.0 is not viper-with-a-writer. It is a different model, and the parts that
matter here:
- A
Storeowns all config I/O; everything else is aViewover an immutableSnapshot. No viper, noGetViper(). - Layers are never folded away. Files, embedded defaults, env, flags, and custom
backends are distinct layers with provenance recorded during the merge (
Origin,Shadowed,Explain). - Writes are first-class.
store.Apply(ctx, config.Set(path, v), config.Remove(path))edits the target document in place (comments, key order, anchors, block scalars preserved) and routes each change to the correct layer.Removegenuinely removes.Planis a dry run over the same routing pass. - The snapshot comes back from the write — no filesystem round-trip, no watcher lag.
- Validation on write rejects only the violations a change introduces.
- Reads are snapshot-pinned;
With(func(v))pins one snapshot across related reads.
3. Decision — adopt go/config, timed to GTB v0.32.0¶
3.1 Keep the two-instantiation separation (0014); do not collapse it¶
The layered Store does not require merging the whole global into the project scope,
and we must not. 0014-D2 stands: the per-project config inherits only an allowlist of
global subtrees (themes, providers, … — the project-relevant, non-secret-bearing
ones); everything else in global stays global-only. Wholesale merge would leak global infra
and pull secrets into the per-project view — the exact hazard 0014 already rejected.
Under go/config the separation is expressed better than today:
- Two instantiations, as now.
props.Config(global) stays one thing; each project is its ownStore. - Inheritance becomes a provenanced layer, not a flattening copy. Today
projectconfig.godeepCopys selected global subtrees into the project viper, which erases their origin — an inherited theme is indistinguishable from a project-owned one (the ambiguity 0034 had to work around). Instead, feed the allowlisted subtrees as the projectStore's base layer (a curatedNamedSource, or a filteringBackend). Same selectivity, butOrigin/Explainstay truthful: the studio can render "inherited from global · read-only" vs "project-owned · editable", and an edit to an inherited key auto-routes to.keryx.yaml— forking it to project-owned for free, which is exactly 0034's intended semantics.
No leak, no collapse to one instantiation. The project Store only ever holds the
branches we chose to include.
3.2 Why gate on GTB v0.32.0 rather than adopt the studio path alone now¶
.keryx.yaml has two writers: the studio (keryx's own per-project container) and the
CLI theme/avatar commands (via props.Config, which layers project→global through GTB
and writes the same file — 0014 §7). Migrating only the studio would leave the CLI still
stomping the file with viper's whole-file rewrite — a half-fix, and a period running two
config stacks against one file. To fix .keryx.yaml holistically, the CLI/framework path
must move too, and that path is GTB.
GTB v0.32.0 makes props.Config a go/config Store (viper deprecated framework-wide;
confirmed with Matt 2026-07-21). That resolves the one open question — the write API
(Apply/Set/Remove) is exposed to consumers, because props.Config is a Store —
so when it lands, both writers converge on one model in one clean cut: the CLI writes
via props.Config.Apply(...), the studio via its per-project Store.Apply(...), both
comment-safe. Waiting also avoids building against a v0.5.0 module while the framework is
mid-overhaul, and there is no urgency (0034 shipped on the status quo).
Note: the inheritance mechanism itself is not cleaner post-0.32 — extracting the
allowlisted subtrees and feeding them as a layer is the same technique whether the source
is a GTB Containable (now) or a Store (post-0.32). Reading them from a Store's View
is marginally tidier plumbing, nothing more. Waiting is about unifying the two writers,
not about the inheritance.
4. Target architecture (post-0.32)¶
props.Config— ago/configStore, the global/framework config, for the CLI's invocation and as the source the studio extracts the allowlist from. Still never the studio's write target for project config.- Studio per-project registry — unchanged in shape (a map of project configs keyed by
project, for switch-without-reload; 0014 §2), but each entry becomes a
*config.Store:
base := allowlistedGlobalSubtrees(props.Config.View()) // themes, providers, … as YAML
store, err := config.NewStore(ctx,
config.WithReaders(config.NamedSource{Name: "inherited-global", Content: base}),
config.WithFiles(aferoAsFS(projectFS), ".keryx.yaml"),
) // WithEnv optional — R-CFG-1 precedence: flags → env → project → global → embedded
Reads resolve project-over-global natively (no deepCopy). Writes route to .keryx.yaml
(highest writable), never global. mutateProjectConfig/mutateThemeCatalog are deleted.
- CLI theme/avatar — write via props.Config.Apply(config.Set(...)); the viper
WriteConfig calls and Catalog.WriteTo(*viper.Viper) go away.
5. Scope¶
In (moves at 0.32): the .keryx.yaml read+write path — projectconfig.go (kill the
deepCopy inheritance, build a Store), the 0034 studio themes/avatars CRUD +
handlers_config (mutateProjectConfig → Apply), internal/themecmd + internal/
avatarcmd (viper WriteConfig → Apply), and the viper-typed project-config helpers
(theme.Load/Catalog.WriteTo/avatar.Resolve off *viper.Viper onto the View/Change
API). ~23 .GetViper() sites within this subset are the real refactor, not the Apply
calls.
Out: config reads elsewhere (framework-config consumers — provider factories,
spend, certsource — keep reading props.Config; they migrate with GTB's own reader
swap, mechanically). OAuth token writes (pkg/oauth, the platform auth.go/refresh.go)
— they persist user credentials through GTB's keychain/config Store, not .keryx.yaml,
and ride GTB's switchover. The write implementation (upstream, shipped in v0.5.0). A
general YAML editor. Migrating files already flattened by past writes (they heal on first
structured write).
6. Sharp edges to design around¶
Set(map)replaces the whole subtree and drops comments/anchors within it. For editing an existing theme/avatar, issue targetedSet("avatars.matt.likeness", v)/Remove(diff before→after), never re-set the wholethemes:/avatars:node — the seeded house themes carry anchors, aliases and comments (write-fidelity §"why replacing a map is different"). This is the single most important usage rule for keryx.config.FSis a six-method interface, not afero. All six map 1:1 ontoafero.Fs— a ~30-line adapter. Implement the optionalRealPatherso OS-backed projects get native fsnotify watch and in-memory worktrees (0013 D7) fall back to polling. Non-negotiable: the studio runs over in-memory worktrees.- Watch is explicit (
store.Watch(ctx)) — an opportunity, not just a cost: this is the clean way to deliver 0014 Phase B (local.keryx.yamlhot-reload) — an observer refreshes the studio when the CLI or git edits the file, fail-closed on a broken edit. ErrConflicton a concurrent external edit (fingerprint verify) — the studio should catch it,Reload, re-plan, and tell the user rather than clobber.- Env prefix is required where
WithEnvis used (a security control); the projectStorecan omit env entirely. - Validation on write — optionally wire a
Schemaso palette-role edits are validated at write time (rejecting only new violations), replacing keryx's ad-hoc checks. - R-CFG-2 (0014 §7) still applies as keryx policy on top of the
Store: load the project file whole, but the studio's view and write path stay allowlist-filtered so a secret in a project file is never shown or written back — i.e. gateSet/Removeand the Advanced dump to allowlisted key prefixes.
7. What gets deleted / simplified¶
mutateProjectConfig/mutateThemeCatalog(0034) — the read-map→mutate→rewrite-whole- file workaround; gone entirely.- The
deepCopyglobal-inheritance block inprojectconfig.go— replaced by a base layer. - The viper delete-doesn't-delete workaround —
Removehandles it structurally. Catalog.WriteTo(*viper.Viper)— replaced by emittingChanges.
8. Sequencing — three tracks (surface mapped against ../go-tool-base main, 2026-07-21)¶
The v0.32 bump is larger than the config story but decomposes cleanly. Prep against a
replace gitlab.com/phpboyscout/go-tool-base => ../go-tool-base confirmed go mod tidy
fails first on missing packages, not compiler errors — GTB main extracted several
pkg/* into standalone go/* modules. So:
Track 1 — extracted-module import repoints (mechanical). Repoint the import paths and
add the go/* deps GTB main already uses. keryx's affected imports:
| GTB import (keryx files) | New home |
|---|---|
go-tool-base/pkg/config (81) |
gitlab.com/phpboyscout/go/config (+ go/config-afero) |
go-tool-base/pkg/browser (4) |
gitlab.com/phpboyscout/go/browser |
go-tool-base/pkg/credentials/keychain (2) |
gitlab.com/phpboyscout/go/credentials |
go-tool-base/pkg/errorhandling (1) |
gitlab.com/phpboyscout/go/errorhandling |
go-tool-base/pkg/authn (1) |
gitlab.com/phpboyscout/go/authn |
Doing Track 1 is what lets go mod tidy succeed and a real build run — which is the only
way to surface Track 3.
Track 2 — the config semantic migration (this spec's substance).
- Reads: config.Containable → config.Reader (or *config.View). Near-mechanical:
the old read methods are a strict subset of Reader. ~210 lines across the 81 files.
props.Config reads become p.Config.View() / props.GetConfigView().
- GetViper() rework — 27 sites, no equivalent. Concentrated: nearly all feed a raw
*viper.Viper into two keryx helpers — theme.Load(v *viper.Viper) (the meatiest) and
the per-platform tokenStore.Resolve(ctx, v). Retype those to config.Reader/*View and
rewrite their tree traversal (GetStringMap/Sub/UnmarshalKey); the call sites then
fall out. (Token-store reads are framework-config, not .keryx.yaml — but they share the
GetViper removal, so they move in the same pass.)
- Writes → Store.Apply. Rebuild projectconfig.go on config.NewStore (base layer =
allowlisted global subtrees via WithReaders, project file via
WithFiles(configafero.Wrap(fs), ".keryx.yaml")); swap mutateProjectConfig + the CLI
write sites to Apply(Set/Remove); delete Catalog.WriteTo(*viper.Viper) and the
workarounds.
Track 3 — residual GTB API drift (unknown until Track 1 lets a build run). pkg/setup
(57) and pkg/logger (28) resolve in v0.32-pre but 0.28→0.31 may have shifted their APIs;
that only shows as compiler errors once the missing packages are repointed. Budget for it.
Timing caution: ../go-tool-base main is still moving pre-0.32, so a full migration
built against it now risks drift before the tag. Prefer: execute the migration when 0.32
tags (a stable target), following this plan. A reverted spike of Track 2's write path
(rebuild projectconfig.go + one studio theme edit end-to-end under the replace, then
git checkout) is worthwhile prep to validate the core mechanic without committing to the
81-file change against a moving base.
Definition of done: a round-trip test — a commented, non-alphabetical .keryx.yaml
survives a studio and a CLI theme edit with only the touched key changed; a Remove
leaves no residue; provenance reports an inherited theme as global-origin until edited,
then project-origin. Gate with just ci + the studio suites.
Not urgent: pull forward only if the comment/format churn in blog/.keryx.yaml becomes a
daily annoyance before 0.32 — and even then, prefer a studio-only stopgap over splitting the
two writers across two models.