0043 — A keychain backend for phpboyscout/go/config¶
Status: DRAFT — NOT SCHEDULED. Shaping questions Q1/Q2 deferred with Matt
2026-07-26 until this is picked up. Not a prerequisite for anything: 0042 must
work without it (the machine where the secret problem bites has no working
keychain). This is the tidier long-term resolution model, and a candidate
sibling module to config-afero.
Date: 2026-07-26
Relates: 0042 (where platform secrets live — the immediate fix) · 0001
§4.2 (token storage) · gitlab.com/phpboyscout/go/config (the Backend seam)
· gitlab.com/phpboyscout/go/config-afero (the adapter-module precedent).
1. Problem¶
keryx resolves every platform secret through a hand-rolled ladder in
pkg/oauth/store.go:
func (s Store) Resolve(ctx, r config.Reader) string {
// 1. os.Getenv(s.EnvVar)
// 2. keychain.Backend{}.Retrieve(s.KeychainService, s.KeychainAccount)
// 3. r.GetString(s.ConfigKey)
}
with a mirrored Save choosing keychain-else-config. Five platforms each declare
an oauth.Store triple (EnvVar, KeychainService/Account, ConfigKey) and
the ladder is re-walked per lookup.
This works, but it sits outside the config system rather than inside it, and that has costs:
- Two precedence models.
go/configalready merges layers in a defined order with provenance (Origin/Explain). The oauth ladder is a second, private ordering that nothing can introspect —keryx configcannot say where a token came from, because config never saw it. - The blind spot 0042 exists to fix. Because resolution takes whichever
config.Readerit is handed, handing it a project store silently loses the global layer. A backend inside the Store could not have that bug: layers are the Store's own business. - Writes are bespoke.
Savere-implements "keychain if available, else this file", where the Store already knows how to route a write to a writable layer. - Per-secret ceremony. Every new platform re-declares the triple.
2. Direction¶
go/config exposes a first-class Backend seam — WithBackend(b Backend),
where a backend has an ID(), a Load() returning Layers in precedence order,
and (for writes) the WritableBackend half that the Store finds again by
matching a layer's Source.Name against the backend's ID().
That is precisely the shape a keychain wants. So: a config-keychain sibling
module contributing an OS-keychain layer, exactly as config-afero contributes
a filesystem:
store, err := config.NewStore(ctx,
config.WithFiles(config.OS, "/etc/keryx.yaml", "~/.keryx/config.yaml"),
config.WithBackend(configkeychain.New("keryx", configkeychain.Keys(
"platforms.instagram.access_token",
"platforms.tiktok.refresh_token",
))),
config.WithEnv("KERYX"),
)
Resolution then becomes ordinary config precedence — env over keychain over
files — with provenance for free, and Set on a keychain-backed key routing to
the keychain because that is where its writable layer lives.
3. Design sketch¶
3.1 What the backend contributes¶
ID()→ a stable name (e.g.keychain:keryx) used for write routing.Load()→ one layer,Source{Kind: SourceKeychain (new) or SourceFile-like, Name: ID(), Writable: true}, whoseValuesare the configured keys that the keychain currently holds. Absent keys are simply absent — a keychain miss is not an error, it is a layer that does not supply that key.WritableBackend→Set(path, value)stores under the service/account derived from the path; delete removes the entry.
3.2 Capability honesty (the config-afero lesson)¶
config-afero's doc comment is explicit that the valuable part of an adapter is
claiming only the capabilities the underlying thing actually has — it returns
one of four types so config never takes a path the filesystem cannot honour.
The same discipline applies here, and matters more:
- A keychain may be absent or hostile. On a headless box the secret service
can be missing, or — as on this dev server — present enough to connect and then
block indefinitely (see the
dev-server-no-keychainnote;Available()hangs ongodbus ReadMessage). The backend must therefore treat availability as a probe with a timeout, and degrade to contributing no layer rather than hanging the Store'sLoad. A config system that can hang on construction is worse than no adapter. - Writability is conditional. If the keychain is unavailable, the backend must not claim a writable layer, or writes will route into a black hole.
3.3 Secrets and the rest of config¶
Two properties worth designing for deliberately:
- Values must never be echoed. Anything that prints config (
keryx config,Explain, diagnostics) must be able to show provenance — "from the keychain" — without the value. This probably wants aSensitivemarker on the layer or key, which is useful beyond keychains (it would let a file-sourced token be redacted in the same way). - A keychain layer is not serialisable. Any "write the effective config out" path must not materialise keychain values into a file — the exact accident 0042 is cleaning up.
4. Open questions¶
Deferred with Matt, 2026-07-26 — this spec is not scheduled, so the shaping questions wait until it is. Recorded now so the thinking is not lost:
- Q1 — Where does this module live? → DEFERRED. Sibling module
gitlab.com/phpboyscout/go/config-keychain(matchingconfig-afero), or insidego/configproper. Leaning sibling, on theconfig-aferoprecedent. - Q2 — Which keychain implementation? → DEFERRED. Wrap GTB's
keychain.Backend(used today bypkg/oauth), or depend directly sogo/configneed not depend on GTB. Theconfig-aferodoc comment argues for the latter — it avoids obliging consumers to import a dependency chosen for them — but that is a call for whoever picks this up.
Still open:
- Q3 — Does Sensitive belong in go/config itself? Redaction is not
keychain-specific. If it lands in the core, the adapter just sets it.
- Q4 — Scope of adoption in keryx. Once the backend exists, does
pkg/oauth's ladder get deleted in favour of plain config lookups (the
point of the exercise), or kept as a compatibility path? Deleting it is the
prize; it is also the riskiest part, and wants its own slice.
- Q5 — Priority. This is quality-of-implementation, not capability: nothing a
user can do changes. It competes with the deferred backlog. Worth doing before
or after the remaining 0041 fast-follows?
5. Relationship to 0042 — the same socket¶
0042 contributes the user-scoped ~/.keryx/accounts.yaml as a secret layer
through this same Backend seam, explicitly as a stopgap. That a dedicated
credentials file is what gets superseded — rather than secret keys scattered
through general config — is part of why 0042 shapes it that way (0042 D6). So this spec is additive, not a
rewrite: the keychain backend is registered at higher precedence and the file
layer becomes the fallback beneath it.
Nothing at a call site changes, there is never a second resolution model to reconcile, and on a machine with no keychain the 0042 layer keeps working exactly as before — which is the whole point of shaping it this way now.
6. Non-goals¶
- Unblocking 0042. Stated again because it is the likeliest misreading: the keychain is unavailable on the machine that has the problem, so this adapter would fall through to exactly the same config layer. 0042 stands alone.
- Changing where keryx keeps secrets. User scope, decided in 0042 D1, is unaffected — this changes how they are reached, not whose they are.
7. Test plan (sketch)¶
- Unit — the backend contributes a layer only for keys the keychain holds; an unavailable keychain contributes no layer and claims no writability; a probe that would block is abandoned within the timeout.
- Precedence — env beats keychain beats file, via the Store's ordinary
merging, with
Explainnaming the keychain layer. - Redaction — no path that renders config emits a keychain value.
- Integration (env-gated, GTB pattern) — against a real secret service on a desktop; skipped where unavailable, which is most CI.