Skip to content

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 Store owns all config I/O; everything else is a View over an immutable Snapshot. No viper, no GetViper().
  • 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. Remove genuinely removes. Plan is 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 own Store.
  • Inheritance becomes a provenanced layer, not a flattening copy. Today projectconfig.go deepCopys 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 project Store's base layer (a curated NamedSource, or a filtering Backend). Same selectivity, but Origin/Explain stay 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 — a go/config Store, 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 (mutateProjectConfigApply), internal/themecmd + internal/ avatarcmd (viper WriteConfigApply), 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 targeted Set("avatars.matt.likeness", v) / Remove (diff before→after), never re-set the whole themes:/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.FS is a six-method interface, not afero. All six map 1:1 onto afero.Fs — a ~30-line adapter. Implement the optional RealPather so 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.yaml hot-reload) — an observer refreshes the studio when the CLI or git edits the file, fail-closed on a broken edit.
  • ErrConflict on 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 WithEnv is used (a security control); the project Store can omit env entirely.
  • Validation on write — optionally wire a Schema so 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. gate Set/Remove and 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 deepCopy global-inheritance block in projectconfig.go — replaced by a base layer.
  • The viper delete-doesn't-delete workaround — Remove handles it structurally.
  • Catalog.WriteTo(*viper.Viper) — replaced by emitting Changes.

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.Containableconfig.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.