Skip to content

0012 — Config lifecycle, first-run bootstrap & global settings

Status: IMPLEMENTED (2026-07-05) — all five slices landed: first-run docs, dynamic reload, the global→shoot cascade, the global settings panel + per-shoot override checkboxes, and the docs. The configuration story for a real installed studio: a documented krites init first-run step (the launcher chains init && studio), a global settings panel in the studio, changes that apply without a server restart, and a global→shoot config cascade — tune once globally, override per shoot, with some keys (AI provider/token, whether ONNX is enabled) global-only. Companion to 0003-studio.md (the settings surface this extends), 0010-packaging-update.md §1/§3 (the launcher that must bootstrap config on first install), and the config keys owned by 0004-face-eye.md (face.*), 0008-object-removal.md (remove.*) and 0009-ai-review.md (review.*).

§7 fully resolved (2026-07-05, Matt). All open questions answered; implementation may begin, slice by slice (§8). Status flips to IN PROGRESS as Slice 1 lands.

Why now. Enabling the LaMa eraser in a live studio (2026-07-05) surfaced three sharp edges that a shipped, non-technical single user (Hailey) will hit on day one:

  1. First run needs krites init. GTB requires it by design (it is the command that writes config); the fix is to document it and to have the launcher chain init && studio (§2) — not to self-heal in code.
  2. Enabling remove.enabled needed a full studio restart to take effect — the studio builds its inpainter/critic once and caches them, so a config edit is invisible until relaunch. Unacceptable for a settings panel.
  3. Cull thresholds are only editable per-shoot, falling back to a hardcoded seed (cull.WeddingDefault()) for new shoots. There is no global layer, so a sweeping taste change means editing every shoot by hand; and app-wide concerns that have no per-shoot meaning — the AI provider + token, whether the ONNX backends are enabled — are modelled at the same (per-shoot) altitude as thresholds. 0001 §6 always intended the judgement to be "config, never Go constants"; what's missing is the global base + shoot override cascade on top of it.

GTB already gives us the machinery for all three (the init command, the config.Observable hot-reload watcher, the config layer). This spec wires them into krites so configuration is a first-class, restart-free, UI-driven surface.


1. Goals

  1. First run is clear and painless. krites init (required by GTB design) is documented as the one-time post-install step; the packaged launcher chains init && studio so the double-click path needs no terminal. init is idempotent and self-update keeps config current.
  2. A global settings panel in the studio, reachable from the shoots/library landing page (not only from inside a shoot), that edits the app-wide config: AI review (review.*), eye detection (face.*), object removal (remove.*), and the global default cull profile.
  3. Changes apply without a restart. Saving from the panel (or editing the YAML by hand) re-reads config and rebuilds the affected live components (inpainter, critic) on the next request — via GTB's config observer.
  4. A cascade, not snapshots. The global config is the base layer; a shoot's config is a sparse override layer on top (GTB's native [global, shoot] file cascade). A global edit moves every shoot that hasn't overridden that key — Hailey tunes once, all shoots follow — while a shoot keeps only its deliberate deviations. Some keys are global-only and have no per-shoot meaning at all (§5.1).

Non-goals

  • No multi-profile catalog UI (naming/switching between several saved profiles) — one global default is enough for a single user; the catalog stays 0001 §6 future work.
  • No change to the non-destructive guarantee — settings never touch originals/; re-resolving verdicts against a new profile is the existing, reversible putProfile path.
  • No cloud/remote config sync.

2. First run — krites init is required (by design)

By design, not a gap. A command that reads config errors with "no configuration files found please run init" when no config exists — this is GTB's intended contract: init is the one command that writes the config, and it is stamped so GTB's root pre-run skips the config-load requirement for it. We do not self-heal a missing config inside other commands (it would fight the framework and duplicate init). Instead: run krites init once after install.

R-CFG-1 — documented first-run step. The install/getting-started docs state plainly that krites init must be run once after installing, before krites studio (or any command). This is the tutorial's first step and the how-to's prerequisite (§6).

R-CFG-2 — the launcher chains it (0010). The macOS launcher runs krites init && krites studioinit first (idempotent; a no-op when config already exists), then the studio. So the packaged, double-click experience needs zero terminal steps while the CLI keeps GTB's explicit contract. (Adds a requirement to 0010 §1/§3; tracked there, specified here.) Our dev/test harness already does exactly this (the dbus wrapper runs init before studio).

R-CFG-3 — init is idempotent; self-update refreshes config. Re-running init never clobbers user settings (a reset is the explicit init --clean), and GTB's self-update re-runs the init flow after an update to bring the config schema forward (update.UpdateConfig). So config stays valid across upgrades without user action. No krites code change is required for §2 — it is docs + the launcher chain.


3. Dynamic reload — no restart

Problem. Server.inpainter() / the critic factory build once and cache (inpBuilt), reading config at first use. A later remove.enabled or review.provider change is invisible until the process restarts.

R-CFG-4 — observe config, invalidate caches. The studio registers a config observer (GTB Container.AddObserverFunc(func(config.Containable) error)) that, on any reload, invalidates the cached inpainter (closing the held inpaint backend); the next request rebuilds it from the fresh config. GTB's watcher is an fsnotify observer over the config files, debounced (DefaultReloadDebounce 250ms) and validated before notifying, so a bad edit is rejected without disturbing the running server. (Implementation note: the critic is already built per request — it reads live config on each review call — so it needs no invalidation. Only the inpainter is cached, because building it loads ONNX Runtime + the model.)

R-CFG-5 — panel saves are ordinary config writes. The global settings panel persists through the GTB config layer (config.Set + write), which the same watcher observes — so hand-editing the YAML and saving from the UI converge on one reload path, no special-casing.

R-CFG-6 — safe swap. Cache invalidation must not tear a backend out from under an in-flight request. The held inpainter is reference-guarded: an in-flight Fill completes on the old backend; the next request builds the new one. (The existing inpMu mutex + a generation counter, not a bare close.)

R-CFG-6a — the cascade is watched end to end. Because a shoot's effective config is the [global, shoot] cascade (§5), the watcher behind R-CFG-4 must cover both layers for an open shoot: editing the global default or a shoot override reaches the running studio. GTB's container watches every file in its configFiles set, so a per-shoot container built over [global, shoot] hot-reloads on a change to either. (Global-only wiring — inpainter/critic — keys off the process-wide global container; §5.2 covers how the per-shoot containers are managed.)

Scope guard. Only provider wiring rebuilds on reload. Thresholds (remove.margin, review.model, the cull profile) are read per-request already and need no cache. Nothing that would interrupt an active cull reloads mid-run.


4. Global settings panel

R-CFG-7 — reachable from the landing page. The shoots/library page gains a Settings entry (gear) that opens a global settings panel without a shoot context. Today Settings.svelte requires a shoot prop (it loads the shoot's cull profile); the global panel drops that dependency for everything except the per-shoot cull sliders (§5).

R-CFG-8 — what it edits. The panel edits the global layer: the global-only keys (§5.1) — AI review (review.* — provider, model, keychain key), eye detection (face.*), object removal (remove.* — enabled, backend lama|migan, library path) — plus the global default cull profile (cull.profile.*, §5). Secrets stay keychain-only and write-through the existing credential path (0009 §3.1) — never rendered back. Per-shoot overrides of the overridable keys are not set here; they live in the per-shoot modal (§5.2).

R-CFG-9 — reflects live availability. After a save, the panel re-queries the probe/availability endpoints (/providers, /review/availability) so the user sees a capability flip on (e.g. object removal becomes available) without reloading the page — the visible confirmation that no restart was needed.

The existing per-shoot Settings modal (opened from inside a shoot) stays; §5 defines how the two relate.

4.1 Config exposure boundary

The config container holds branches the frontend must never see or write — github.* (tokens/URLs), server.*, update.*, log.*, GTB internals. The settings API must not become a generic config read/write.

R-CFG-14 — allowlist, not passthrough. The settings endpoints expose an explicit allowlist of krites feature keys only — review.* (secrets redacted), face.*, remove.*, cull.profile.*. Anything outside the allowlist is neither returned by a GET nor writable by a PUT (a write to an unlisted key is rejected, not silently applied). The allowlist is defined in one place and is the single source of truth for both the API and the panel's rendered controls, so a new feature key is exposed deliberately, never by accident. Secret values (review token) are write-only: a GET reports presence (hasKey), never the value (extends the existing 0009 §3.1 keychain rule to the whole surface).

Today. Workspace.LoadProfile() returns the shoot's .krites/profile.yaml, or the hardcoded cull.WeddingDefault() when absent. There is no global, user-tunable base and no override relationship — a sweeping taste change means editing every shoot by hand, and app-wide concerns sit at the wrong altitude.

The model. Configuration is a two-layer cascade, built on GTB's native ordered-file merge (WithConfigFiles([global, shoot]) → read global, then MergeInConfig(shoot); last file wins per key; hot-reload re-merges both):

  • Layer 0 — global (~/.krites/config.yaml): the base. Every key has a value here (seeded at first-run bootstrap). This is what the global settings panel edits.
  • Layer 1 — shoot (<shoot>/.krites/config.yaml): a sparse override. Contains only the keys this shoot deliberately deviates on. Absent keys fall through to global.

A shoot's effective config is the merge. Change a global key → every shoot that hasn't overridden it moves, live, on hot-reload. Hailey tunes once. A shoot's override is a floor she sets deliberately and only for that shoot.

This replaces the standalone .krites/profile.yaml (a full cull.Profile snapshot) with sparse cull.profile.* keys in the shoot's config, so "override only what differs" and "inherit the rest" come for free from viper's merge — no per-field pointer/omitempty bookkeeping, no snapshot to drift. Migration: §5.3.

5.1 Key taxonomy — global-only vs overridable

Not every key should be overridable. Two classes:

  • Global-only (no per-shoot meaning; set only in Layer 0, never written to a shoot config):
  • review.*AI provider + token + model. A machine/account concern; it makes no sense for one shoot to use a different LLM account.
  • remove.enabled, remove.backend, remove.library_pathwhether the ONNX eraser is available at all + its runtime. An install-level capability.
  • face.enabled, face.library_pathwhether the eye detector runs + its runtime. Same: an install-level capability.
  • Overridable (cascade global → shoot; the judgement, per 0001 §6):
  • cull.profile.* — sharpness floors, exposure clip limits, dedup distance, eye-open soft/hard bands, min-face-box, and the eye calibration (EAR open/closed). These are per-shoot taste and can legitimately differ between, say, a dim reception and a bright ceremony.

R-CFG-10 — global base lives in config. The global config is the base layer for both key classes. (Refinement, slice 3a: the cull profile is seeded logically, not physically — cullprofile.Global falls back to the WeddingDefault() seed for any unset cull.profile.* key, so nothing needs writing at init [which is GTB-generated], and the global stays sparse: the panel writes a cull.profile.* key only when the user changes it. Net behaviour is identical to "seeded from WeddingDefault".) Global-only keys (review.*, face.*, remove.*) already default off/empty and are written on first use.

R-CFG-11 — shoots inherit live via the cascade; no snapshot. A shoot with no override file uses the global values directly (the [global, shoot] cascade with an empty/absent Layer 1). Nothing is copied at import. Editing a global key therefore reaches every non-overriding shoot on the next resolve/reload. (This supersedes snapshot-on-create.)

R-CFG-12 — per-shoot override writes only the delta. The per-shoot Settings modal writes a changed threshold as a single cull.profile.<key> into the shoot's config (creating it if absent), then re-resolves that shoot's verdicts (the existing putProfile path, retargeted from profile.yaml to the shoot config layer). Only overridable keys (§5.1) are writable here; global-only keys are not offered in the per-shoot modal.

R-CFG-13 — per-shoot override UX: inherited-by-default, opt-in per control. In the per-shoot modal every overridable control renders the inherited global value, disabled, with an override checkbox beside it:

  • Unchecked (default) — the control shows the live global value, disabled; no key exists in Layer 1; the field tracks the global base.
  • Checking it — activates the control (seeded from the global value); editing and saving writes that single cull.profile.<key> into the shoot config.
  • Unchecking itclears the override (removes the key from Layer 1) and the field reverts to the global value.

So an override is always a deliberate, visible act, and "reset to global" is just unchecking. A field's checked state is derived from whether Layer 1 contains that key (not stored separately), keeping the UI and the file in lockstep. A "reset all to global" clears every Layer-1 override at once.

5.2 Resolving cascades in a multi-shoot studio

One studio process serves many shoots, so there is no single ambient [global, shoot] container. Resolution:

  • The process-wide props.Config is the global container (Layer 0 only). Global-only wiring — the inpainter and critic (§3) — reads from it and reacts to its observer.
  • Per-shoot effective config is a container cascading [global, <shoot>], built on demand and cached per open shoot (keyed by shoot id), used for the overridable reads (the effective cull profile). Each such container registers its own observer (R-CFG-6a) so an edit to either layer re-resolves that shoot. Closing/evicting a shoot disposes its container (and its watcher).

Implementation seam (as built, slice 3a/3b). Cascade resolution lives in a new internal/cullprofile package, NOT in pkg/shoot — GTB's config layer (viper) does not compile to WASM, and pkg/shoot/pkg/cull are in the WASM-compilable engine set, so they must stay config-free. internal/cullprofile exposes Global(cfg) / Effective(cfg, fs, shootDir) (the WeddingDefault → global → shoot overlay) and WriteOverrides / Overrides / ChangedKeys for the sparse Layer-1 file. The glue callers (pkg/cmd/cull, pkg/cmd/dedup, pkg/studio) call it instead of Workspace.LoadProfile/SaveProfile; the cull.Profile struct and its Validate/Fingerprint are unchanged — only the load/save source moves. Decision-capture provenance (0005) records the effective, merged profile's fingerprint, exactly as today.

5.3 Migration

.krites/profile.yaml is the current per-shoot store. On first load of a shoot that has one, its values are diffed against the global base and only the differing keys are written as cull.profile.* overrides into the shoot config; the old profile.yaml is then retired (kept as a one-time backup, not read again). A shoot whose profile.yaml equalled the seed becomes a clean no-override shoot that now tracks the global base. (Fresh shoots never had one, so this only touches shoots created before this spec lands.)


6. Docs (Definition of Done)

  • Tutorial / how-to (Diátaxis): the getting-started tutorial opens with krites init (the one-time post-install step), then krites studio. A "Configure krites from the studio" how-to covers the global settings panel — what each section does, that changes apply live — with krites init as its prerequisite.
  • Reference: the config keys this panel exposes, cross-linked to 0004/0008 /0009, the global-only vs overridable taxonomy (§5.1), and the global→shoot cascade (how an override is set, shown, and reset).
  • Development guide: why init is required (GTB's config contract, §2), the launcher init && studio chain, and the config observer wiring (R-CFG-4) — so the launcher work in 0010 and future config consumers follow the same pattern.
  • Update 0003 §… (settings surface), 0010 §1/§3 (first-run), and this spec's status as slices land.

7. Open questions (resolve before build)

  • Q1 — inheritance model.RESOLVED (2026-07-05, Matt): live cascade, not snapshot. Global config is the base; a shoot config is a sparse override overlaid via GTB's file cascade; a global edit moves every non-overriding shoot. Some keys are global-only (§5.1). §5 is written to this.
  • Q1a — override granularity.RESOLVED (2026-07-05, Matt): full config cascade. The whole overridable set rides the [global, shoot] cascade. Adds a corollary Matt raised: the API must isolate the config branches it exposes — only krites' feature keys reach the frontend, never the full config (§4.1, R-CFG-14).
  • Q2 — bootstrap trigger.RESOLVED (2026-07-05, Matt): no self-heal — init is required by GTB design. Document the one-time krites init step; the launcher chains init && studio; init is idempotent and self-update refreshes config. No krites code change for §2 (R-CFG-1..3).
  • Q3 — reload blast radius.RESOLVED (2026-07-05, Matt): as stated. A global reload rebuilds only inpainter + critic; a shoot-layer reload only re-resolves that shoot's effective profile; an in-flight cull is never disturbed.
  • Q4 — panel entry & per-shoot override UX.RESOLVED (2026-07-05, Matt): distinct global panel on the landing page; the per-shoot modal stays separate. Per-shoot override UX is specified: each overridable control renders the inherited global value disabled, with an override checkbox beside it; checking it activates the control and writes the override; unchecking clears the override and reverts to the global value (R-CFG-13).
  • Q5 — per-shoot container lifecycle (§5.2).RESOLVED (2026-07-05, Matt): lazy + cached per open shoot, disposed on close — matches the inpainter's lazy-cache pattern and bounds live fsnotify watchers.

8. Slices (proposed)

  • Slice 1 — first-run docs (R-CFG-1..3).LANDED (2026-07-05). No code. Documented krites init as the one-time post-install step (getting-started "First run" section + cull tutorial + run-the-studio how-to), noted idempotency
  • self-update config refresh, and recorded the launcher init && studio chain against 0010 (R-PKG-3).
  • Slice 2 — dynamic reload (R-CFG-4..6).LANDED (2026-07-05). Config observer drops the cached inpainter on reload; an inpUse RWMutex drains in-flight fills before the swap (safe close). Critic was already per-request, so no invalidation needed. Tested: rebuild-after-reload, no-op-when-unbuilt, and concurrent removes racing reloads under -race. (R-CFG-6a — watching the shoot overlay too — lands with the cascade in Slice 3.)
  • Slice 3 — the cascade (R-CFG-10..13, §5.2, §5.3).LANDED (2026-07-05). internal/cullprofile resolves WeddingDefault → global → shoot sparse override; every caller (cull, dedup, studio get/put/cull) reads via Resolve (migrates legacy profile.yamlcull.profile.*, backed up to .pre-cascade.bak). putProfile writes the sparse diff-from-global (interim; explicit checkbox keys land in Slice 4). Dead Workspace.LoadProfile/SaveProfile removed — engine stays config-free, wasm-check green. Tested: resolver overlay/override/clear, migration (convert/idempotent/no-op), and the studio sparse-override round-trip; gated e2e green.
  • Slice 4 — global settings panel (R-CFG-7..9, 14). Landing-page settings surface over the global-only keys + global default profile, behind the allowlist/exposure boundary (§4.1); the per-shoot modal gains the disabled-inherited-value + override-checkbox UX and reset (R-CFG-13); the live capability-flip test.
  • Slice 5 — docs (§6).LANDED (2026-07-05). New how-to Configure krites from the studio (global panel + per-shoot override checkboxes, applied live); config reference gains the global→shoot cascade, the cull.profile.* keys, and the global-only vs overridable taxonomy; the cull- profile how-to reframed around the cascade. Docs build clean.