StackPanel

Reconciliation

How stack doctor and stack setup keep a repo converged, and which surface a change belongs in

Stackpanel treats a repository the way a reconciler treats infrastructure: configuration declares the desired state, and one engine converges disk onto it. Four concepts make that work. Three already existed; only one is new.

ConceptAnswersWhere it livesNew?
ModuleWhat does it mean to have X?stackpanel.files.entries, scripts, checks - all under mkIf cfg.enableNo
ReconcileDoes disk match what the modules declare?write-files, stack preflight run, stack setupNo (gained a preview mode)
AddonCould you turn X on?adoption = { ... } in a module, or _addons/<id>/addon.nixYes
DoctorIs something wrong?stackpanel.doctor.<module>.<name>Replaces moduleChecks and healthchecks.modules

Two flat commands drive all four over one engine, split by whether they may write:

  • stack doctor reports. Never prompts, never writes.
  • stack setup shows the same report, then confirms, then applies.

Which surface do I want?

The temptation is to invent a declaration surface that can install things. That surface already exists - it is called a module. Keeping installation there is what keeps everything else small.

You want to...UseNot
Ship a file, script, package, or pinned versiona module: add it to files.entriesan addon with a files payload (removed), a check that installs
Deliver a version bump to every adopteredit the module's entry - reconciliation delivers it on the next shell entrya doctor check, a new addon
Suggest turning a module onan addon: six lines of metadata plus a config mutationa module that enables itself
Observe machine state (tools, caches, browsers, credentials)a runtime doctor check with a fixCommand hinta repo check; a check that runs the fix
Observe the checkout itselfa repo doctor checka fix command - repo state is fixed by reconciliation
Gate CI on a derivation buildinga build doctor check (nix flake check consumes it)a runtime check
Resolve two modules writing one pathNix module priorities: mkForce, mkOverride, mkBefore/mkAfter on opsa priority axis on file entries

The file schema

Every entry in stackpanel.files.entries is described on three independent axes:

format = "text" | "json" | "yaml" | "toml" | "lines" | "derivation" | "symlink";  # how content is produced
writer = "full" | "block" | "paths";    # how much of the file we own
adopt  = "none" | "backup" | "refuse";  # policy on first contact with a pre-existing file

writer = "paths" owns specific paths inside a structured document and leaves everything else to the user. adopt is first contact, not contention: ongoing overwrite is decided by writer, and two modules targeting one path is decided by module priorities. What the reconciler adds is plan-time collision detection: two modules that set the same path with different values are reported instead of silently racing.

The op vocabulary is deliberately not RFC 6902. add on an array appends on every application, and reconciliation runs on every shell entry; appendUnique is the idempotent fix. set and merge are both needed: a deep-merging set could never replace a subtree with a smaller one. Paths accept either segment lists or RFC 6901 JSON Pointer strings.

The former spellings were removed rather than kept as sugar: type = "json-ops" is now format = "json"; writer = "paths", type = "line-set" is format = "lines", managed = "block" is writer = "block", and jsonValue is value.

.stack/config.nix is never a file entry. Everything in files.entries is an output of evaluation; config.nix is the input. The studio writes it imperatively, so a reconciled config.nix would revert every such write.

Lifecycle of an adoption

Using the Playwright reference module as the example:

  • Never adopted. Only the offer exists. setup lists it from metadata alone and asks once. Declining writes one ledger entry and nothing else - no enable = false, because "not now" is not "never".
  • Adopting. The config mutation is written, Nix re-evaluates, and the module's four file entries materialize through ordinary reconciliation.
  • Version bump ships. Applied silently by preflight run on shell entry: package.json ~ devDependencies."@playwright/test" "1.44.0" -> "1.48.0".
  • Declined, then revised. Re-surfaces only when the author bumps revision; a typo fix in the label does not re-nag.
  • Disabled. The module's entries leave the manifest: playwright.config.ts and e2e.yml are deleted, package.json drops back to baseline for the two managed paths while bun's entries survive, and the shared .gitignore block is recomputed without Playwright's lines. .stack/config.nix is untouched.

Speculative evaluation

Not duplicating declarations has a price: to show what adopting Playwright would do, setup evaluates the module system with the accepted mutations overlaid, diffs the resulting files.entries against disk, renders that, and discards the evaluation. It is one evaluation for the whole accepted set, and none when nothing was accepted. It writes nothing and realizes no derivation.

Compatibility

The authoring options stackpanel.moduleChecks and stackpanel.healthchecks.modules were removed in favour of stackpanel.doctor, but the computed outputs stay byte-identical: moduleChecksFlattened and moduleChecksCertification still feed nix flake check; healthchecksComputed and healthchecksList still emit HEALTHCHECK_TYPE_* strings for the agent API and the studio traffic lights. The storage is stackpanel.doctor; the wire types are still called Healthcheck. The authoring surface changed, the wire format did not.

On this page