Skip to content

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.go configKeys): 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 allowlisted k), 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.yaml is 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 to global (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 fsnotify watcher runs. Register an observer that rebuilds projectConfig.themes (and re-reads derived values) when the project's .keryx.yaml changes on disk — the same propagation the CLI gets. projectConfig.watch holds the stop func; it's called when the entry is evicted (forget).
  • In-memory: a RAM afero worktree emits no fsnotify events, 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

  1. Map model — per-project Containable registry keyed by registry identity, props.Config untouched as the global fallback (your steer). Confirm the key scheme (local abs path / inmemory:<remote>), and lazy build-on-switch + cache.
  2. 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 on platforms.* / 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.
  3. 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).
  4. keryx init alignment — init seeds a global config.yaml; a project wants a repo-root .keryx.yaml. Do we (a) just load .keryx.yaml if present and leave seeding to a follow-up, or (b) also teach init/a studio action to seed a project .keryx.yaml? Recommend (a) for this MR; seeding is a separate CLI change.
  5. 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.
  6. Thread-safety refactor scope — moving every cfg/themes read behind accessors touches several handlers; it's mechanical but broad, and fixes the existing a.themes race. Confirm doing it as part of this work (rec) vs a separate fix.

9. Testing

  • Unit: a project with a .keryx.yaml overriding 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: getConfig never returns a non-allowlisted key even when the project file contains one; putConfig still refuses a secret key.
  • Concurrency: -race over concurrent switch + config/theme reads (pins the accessor refactor; catches the pre-existing themes race).
  • godog: create a project with a .keryx.yaml setting 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.