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:
- 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 chaininit && studio(§2) — not to self-heal in code.- Enabling
remove.enabledneeded 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.- 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
initcommand, theconfig.Observablehot-reload watcher, the config layer). This spec wires them into krites so configuration is a first-class, restart-free, UI-driven surface.
1. Goals¶
- First run is clear and painless.
krites init(required by GTB design) is documented as the one-time post-install step; the packaged launcher chainsinit && studioso the double-click path needs no terminal.initis idempotent and self-update keeps config current. - 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. - 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.
- 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, reversibleputProfilepath. - 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 studio — init 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 fullcull.Profilesnapshot) with sparsecull.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/omitemptybookkeeping, 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_path— whether the ONNX eraser is available at all + its runtime. An install-level capability.face.enabled,face.library_path— whether 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 it — clears 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.Configis 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/cullprofilepackage, NOT inpkg/shoot— GTB'sconfiglayer (viper) does not compile to WASM, andpkg/shoot/pkg/cullare in the WASM-compilable engine set, so they must stay config-free.internal/cullprofileexposesGlobal(cfg)/Effective(cfg, fs, shootDir)(theWeddingDefault → global → shootoverlay) andWriteOverrides/Overrides/ChangedKeysfor the sparse Layer-1 file. The glue callers (pkg/cmd/cull,pkg/cmd/dedup,pkg/studio) call it instead ofWorkspace.LoadProfile/SaveProfile; thecull.Profilestruct and itsValidate/Fingerprintare 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), thenkrites studio. A "Configure krites from the studio" how-to covers the global settings panel — what each section does, that changes apply live — withkrites initas 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
initis required (GTB's config contract, §2), the launcherinit && studiochain, and the config observer wiring (R-CFG-4) — so the launcher work in0010and 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 —
initis required by GTB design. Document the one-timekrites initstep; the launcher chainsinit && studio;initis 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 initas 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 && studiochain against0010(R-PKG-3). - Slice 2 — dynamic reload (R-CFG-4..6). ✅ LANDED (2026-07-05). Config
observer drops the cached inpainter on reload; an
inpUseRWMutex 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/cullprofileresolvesWeddingDefault → global → shootsparse override; every caller (cull, dedup, studio get/put/cull) reads viaResolve(migrates legacyprofile.yaml→cull.profile.*, backed up to.pre-cascade.bak).putProfilewrites the sparse diff-from-global (interim; explicit checkbox keys land in Slice 4). DeadWorkspace.LoadProfile/SaveProfileremoved — 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.