0014 — studio per-project config & themes (R-CFG-1, project layer)¶
Status: IMPLEMENTED (tasks #63 + #64 — §8 decisions resolved 2026-06-26. Closes
the deferred per-project slice of R-CFG-1 noted in 0011 §2.2.
Phase A — the per-project container map + accessors + load/select-on-switch +
write-to-project (local + in-memory), thread-safe (fixes the latent a.themes race).
Phase B — local fsnotify hot-reload (R-CFG-3): a local project's .keryx.yaml
is watched and an external edit rebuilds its cached config + themes; in-memory falls
back to load-on-switch (afero can't be watched).)
Date: 2026-06-26
Depends on: the project switcher + registry (0011 §2.2, handlers_projects.go,
registry.go), the studio config/themes reads (handlers_config.go,
handlers_themes.go, reloadThemes), GTB pkg/config (Containable, file
containers, observers), the in-memory worktree (0012, WorkFS()).
1. Goal & scope¶
Today config + themes are user-global: props.Config is built once at process
start from ~/.keryx/config.yaml, frozen into api.cfg, and shared across every
project; switching projects rebinds only the reel root. This closes the R-CFG-1
project layer — a project's repo-root .keryx.yaml — so switching projects in
the studio adopts that project's config + themes (provider selections, theme
catalog + defaults, workspace.root, git toggles, take count, cost estimates).
This is the ownership model made real (spec 0001 §3.2: keryx is stateless; config lives in the owning project's repo). It must not regress the global path, must not expose or write secrets (R-CFG-2), and must stay correct under concurrent requests.
In scope: loading + selecting per-project config/themes on switch; writing studio settings to the active project's file; hot-reload where the filesystem allows. Out of scope: CLI config resolution (the CLI already layers project→global via GTB when run inside a project dir — see §7); schema/validation changes.
2. The model — a registry of per-project containers (not a mutated global)¶
props.Config is never swapped or written through. It stays the global
container (global concerns only). The studio instead holds a map of per-project
Containables keyed by project, and switching is a map lookup:
type api struct {
// ... existing ...
global config.Containable // = props.Config; the global layer, immutable
cfgs map[string]*projectConfig // projectKey → its loaded config + themes
active string // active project key ("" → global only)
// cfg/themes accessors now read the active entry under a.mu (see §4)
}
type projectConfig struct {
cfg config.Containable // project file merged over global+embedded (§3)
themes *theme.Catalog // derived from cfg
watch func() // stop the fsnotify watcher (local only); nil otherwise
}
Rationale (your steer): a separate object per project gives flexibility without touching the core/global config, and a keyed map is trivial to navigate on switch. It also removes the hardest part of the alternative (rebuilding/mutating the frozen global container in place) and naturally isolates one project's config from another.
Project key. Reuse the registry's identity so the map aligns with the switcher:
the local absolute path for local/clone projects, memKey(remote) for in-memory
(the synthetic inmemory:<remote> key already in registry.go). One key per
registry entry; built lazily on first switch (or registration), cached in the map.
3. Construction & precedence (R-CFG-1)¶
R-CFG-1 precedence: flags → env → project .keryx.yaml → global ~/.keryx/config.yaml
→ embedded defaults. Each projectConfig.cfg is a self-contained container that
encodes project→global→embedded, built once per project.
Selective global inheritance (not wholesale). The global file holds more than
project-relevant defaults — global-only operational settings, infra wiring, and
secret-fallback keys. Merging all of global under the project file would leak that
implementation into project scope and pull secrets into the per-project container.
So the project container inherits only a dedicated allowlist of global subtrees
(the content/generation defaults a project legitimately overrides); everything else
in global stays global-only and is read from props.Config directly by the
non-project code paths that own it. The project's own .keryx.yaml is merged
whole (the project owns its file, §7) — selectivity applies only to what we pull
down from global.
3.1 Inherited global subtrees (the merge allowlist)¶
Resolved set (§8-D2) — the clearly project-scoped, non-secret-bearing subtrees:
| Subtree | Why project-relevant |
|---|---|
themes |
the theme catalog + themes.defaults.* (a project's house style) |
providers |
image/voice/music/render/chat selections + models + pricing (non-secret) |
workspace |
workspace.root (where this project's reels live) |
git |
commit_on_save, auto_push toggles |
studio |
take_count and other studio prefs |
voices |
named speaker registry (multi-author narration) |
Deliberately NOT inherited from global (flagged, §8-D2): platforms.* and
auth.*. Both mix a project-relevant signal (which platforms a project posts to)
with secret-fallback keys (platforms.<p>.refresh_token, auth.* tokens) and
account/infra wiring (auth.writeback.backend, auth.alerts.backend). Pulling the
whole subtree would drag secret fallbacks from global into every project container.
Resolved (§8-D2): excluded from global-inheritance — posting/auth config is
account-level and carries secret fallbacks; a project that posts sets its own under
its .keryx.yaml. This keeps secret-fallback keys from ever leaking from global
into a per-project container.
The inherited set should stay aligned with the settings write allowlist (
handlers_config.goconfigKeys): a key the studio lets you edit per project should also be one it inherits per project. Keep the two lists in step.
Construction, per backend:
- Local / clone project: start from global's inherited subtrees (
global.Get(k)for each allowlistedk), merge the project<projectDir>/.keryx.yaml(read over the OS fs) on top. Embedded defaults sit underneath the inherited subtrees. Flags/env still win via viper inside the container. - In-memory project: the same, but the project
.keryx.yamlis read from the afero worktree (mem.WorkFS()). Viper reads over afero work (SetFs); only watching doesn't (§5). - No project
.keryx.yaml: no entry is built — the active config falls back toglobal(today's behaviour). Per-project is purely additive; existing projects keep working unchanged.
4. Selection, accessors & thread-safety¶
a.cfg/a.themes are read locklessly today (safe only because immutable) — and
there is already a latent race: a settings-save rewrites a.themes
(reloadThemes) while concurrent requests read it. Making config switch-mutable
forces the issue, and fixes it: route all config/theme reads through accessors
under a.mu.
func (a *api) config() config.Containable { // active project cfg, else global
a.mu.RLock(); defer a.mu.RUnlock()
if p := a.cfgs[a.active]; p != nil { return p.cfg }
return a.global
}
func (a *api) themesCatalog() *theme.Catalog { // active project themes, else global
a.mu.RLock(); defer a.mu.RUnlock()
if p := a.cfgs[a.active]; p != nil { return p.themes }
return a.globalThemes
}
All current a.cfg.… / a.themes.… sites move to a.config() / a.themesCatalog()
(cost.go, commit.go, handlers_media.go takeCount, handlers_themes.go, paletteFor,
projectReelRoot). setActive (already the one locked writer) gains the active-key
swap; switching never blocks a request longer than a map lookup.
5. Hot-reload (R-CFG-3) — watch local, load-on-switch in-mem¶
Per the decision: local/clone projects get live hot-reload; in-memory reload on switch only.
- Local: the project container is built from files over the OS fs, so GTB's
fsnotifywatcher runs. Register an observer that rebuildsprojectConfig.themes(and re-reads derived values) when the project's.keryx.yamlchanges on disk — the same propagation the CLI gets.projectConfig.watchholds the stop func; it's called when the entry is evicted (forget). - In-memory: a RAM afero worktree emits no
fsnotifyevents, so there's no live watch. Its config (re)loads on switch and on settings-save (§6). Acceptable — in-memory is the rare path, and the studio is the only writer to that worktree.
6. Writing settings → the active project's .keryx.yaml¶
A studio settings save writes the active project's .keryx.yaml (created if
absent under the project dir / worktree), not the global file — settings travel with
the project and are committable alongside its reels (ownership). putConfig keeps
its allowlist (R-CFG-2: never writes a secret) and writes through the active
projectConfig.cfg's backing file. After a write it reloads that project's themes
(as today). For an in-memory project the write lands in the worktree and is picked
up by commit-on-save. (Editing the global file stays a CLI/manual concern — the
studio always has an active project, starting from the cwd.)
7. Secrets (R-CFG-2) — load whole, view allowlist-only¶
Per the decision: load the project file as-is (so themes/providers/defaults
apply); the studio's settings view (getConfig) and write path stay
allowlist-filtered, so a secret in a project file is never displayed or written
back. Secrets-in-file remain the project's responsibility (the keychain-first design
means they're usually absent). This matches the CLI's own load of the same file, so
studio and CLI see identical config — no second load behaviour to keep in sync.
8. Decisions to confirm before building¶
- Map model — per-project
Containableregistry keyed by registry identity,props.Configuntouched as the global fallback (your steer). Confirm the key scheme (local abs path /inmemory:<remote>), and lazy build-on-switch + cache. - Precedence construction + selective global inheritance — one merged container
per project (every read site stays a plain
GetX), inheriting only the allowlisted global subtrees (§3.1:themes,providers,workspace,git,studio,voices), not wholesale global. Confirm the inherited set, and the call onplatforms.*/auth.*(recommend exclude from global-inheritance — they carry secret fallbacks + account/infra wiring; a project sets its own if needed). Keep the inherited set aligned with the settings write allowlist. - Write target — settings writes go to the active project's
.keryx.yaml(rec). Confirm there's no studio path to edit the global file (CLI/manual only). keryx initalignment — init seeds a globalconfig.yaml; a project wants a repo-root.keryx.yaml. Do we (a) just load.keryx.yamlif present and leave seeding to a follow-up, or (b) also teachinit/a studio action to seed a project.keryx.yaml? Recommend (a) for this MR; seeding is a separate CLI change.- Phasing — Phase A: the map + accessors + load/select on switch + write-to- project (the core, local + in-mem). Phase B: local hot-reload watcher (R-CFG-3). Land A first? Recommend yes.
- Thread-safety refactor scope — moving every cfg/themes read behind accessors
touches several handlers; it's mechanical but broad, and fixes the existing
a.themesrace. Confirm doing it as part of this work (rec) vs a separate fix.
9. Testing¶
- Unit: a project with a
.keryx.yamloverriding a theme default / provider /workspace.root→ after switch,a.config()/a.themesCatalog()reflect it; switching back to a project without one falls back to global. Two projects with different.keryx.yaml→ no cross-contamination (the map isolates them). - Secrets:
getConfignever returns a non-allowlisted key even when the project file contains one;putConfigstill refuses a secret key. - Concurrency:
-raceover concurrent switch + config/theme reads (pins the accessor refactor; catches the pre-existing themes race). - godog: create a project with a
.keryx.yamlsetting a theme default, switch to it, GET /themes reflects the project default; switch away, it reverts. (No paid paths.) - In-memory: build the container over a
WorkFS()afero worktree (fake), assert it reads the worktree's.keryx.yaml.
10. Definition of done¶
Failing test → code → green just ci; the accessor refactor with -race; a godog
scenario for the switch-adopts-project-config workflow; docs updated
(docs/components/studio, the 0011 §2.2 deferral note resolved); /simplify +
/code-review. Spec status → IMPLEMENTED on merge.