Config Guide — where a setting lives, and why

This guide covers configuration: the two files you can edit, the rules that decide which one a setting belongs in, and the three categories of value that are deliberately not settings at all. It is implemented in packages/solver/solver/config/.

The short version, for when that is all you need:

I want to… do this
see every setting {config} in the shell ({config.timeout_single} for one)
change one edit packages/solver/solver/config/values.conf
change what a deployed service reads edit that service's /etc/euler/*.env, not this repo
find which variable a service reads packages/solver/solver/config/env.conf

There is no manage-config command and no config.json. Both existed; both are gone. Reading the settings needed a command and an admin floor to see three numbers, and the file it wrote was JSON, so it could carry no explanation of what any of them meant.


1. The shape

packages/solver/solver/config/
  __init__.py     the way in: `from solver.config import config`
  settings.py     every setting — its name, its type, its default
  values.conf     the editable half: what a person tunes
  values.py       reading values.conf
  env.conf        the web services' environment: variable name and default per setting
  env.py          reading env.conf
  paths.py        the anchors — package_root, repo_root(), secrets_dir(), share_file()
  identity.py     dynamic: the resolved Subject, and per-user state
  theme.py        dynamic: the rich theme and the prompt_toolkit style

Split by what a value is, not by who reads it. Every part of the solver reaches the same singleton — the shell, the AI commands, the crypto package, the git filter — so there is one answer to "where is the stack?" rather than one per caller. Four separate derivations of the repo root drifted once, and the drift cost a collaborator their decryption (§5.2).


2. Two files, two species

They look similar and are not interchangeable.

values.conf is what a person tunes. Solution timeouts, the file names inside a problem directory, where the stack lives, the FX rate costs converts with. It is tracked, so an edit is a commit and every clone gets it. Values may name each other and the computed anchors:

[paths]
solutions_dir = ${root_dir}/solutions
env_file      = ${secrets_dir}/env

Available anchors: ${root_dir}, ${package_dir}, ${secrets_dir}, ${home_dir}.

env.conf is deployment wiring for the web services. Each runs as its own uid, from /opt/euler, with a scoped EnvironmentFile= under /etc/euler. What lives here is not the value — it is the map from a setting to the variable that carries it, and what it is worth when that variable is unset:

[auth]
state_dir   = EULER_AUTH_STATE_DIR | /var/lib/euler-auth
admin_token = EULER_ADMIN_TOKEN    | !required

!required means the service refuses to start without it, naming the variable. A secret or a public URL with no sane default is better as a startup failure than a quiet wrong answer — a guessed EULER_BASE_URL mails invite links that go nowhere.

[common] holds what several services share, so a default is stated once. euler-web used to be written into five separate files, and a group name that disagrees with the Caddy config is a socket nobody can connect to.

Which file does my setting go in?

Ask who sets it. If the answer is a person with a clone, values.conf. If it is a systemd unit on the host, env.conf. If it is neither — if the value is a consequence of where the code is running, or something two machines must agree on byte for byte — it is not a setting at all; see §5.


3. Precedence, and the two defaults

environment  >  the conf file  >  the default declared in code

Two environment variables outrank values.conf: EULER_REPO_ROOT (which decides root_dir, and through it most of [paths]) and EULER_BASE_URL.

Every setting has its default written twice — once in settings.py as the field's default, once in the conf file. That is deliberate, and it is the part most likely to look like an accident:

  • the code default is a safety net. A missing, truncated or mis-edited conf file must never be able to stop the shell from starting. It is also what a direct construction gets — SiteConfig(...) in a test, site_config() inside UserConfig.
  • the file default is a catalogue. It makes the file a complete list of the knobs, each with a comment saying what it is for, rather than a sparse overlay you have to read the source to interpret.

Because they can drift, tests hold them to each other: test_values.ShippedValuesTests builds the configuration with the file and without it and requires every setting to agree, and test_env does the same for each service class against its section. Both have already caught a real drift.

A value the file names but the code does not declare is ignored, and one that will not coerce to its declared type leaves the default standing. Neither is fatal: the file is data, not code, and it survives a release that retires a setting. Retiring server_port once made every older config file raise KeyError at import, before anything could print an error saying so.

env.conf is stricter — an uncoercible value there is a startup failure naming the variable, because a variable someone deliberately set is a stated intention, not a stale line in a file that shipped years ago.


4. Reading it

{config}                       every setting, one per line
{config.timeout_single}        one of them
{config.scripts.sync}          a script path

In Python, from solver.config import config, then attribute access. Item access (config['timeout_single']) resolves the same way, including for the settings that are computed rather than stored.

Importing solver.config builds nothing. The singleton is resolved on first access, and that is load-bearing rather than tidy — see §5.3.


5. What is not a setting

Three categories, and the question that sorts them: may I change this, and what happens if I do?

5.1 Protocol — packages/solver/solver/crypto/wire.py

The magic bytes, the nonce length, the filter driver's name, the .gitattributes line, the vault's KDF iteration count, the verify plaintext. Each is part of a format something else already depends on: a blob in git, a file on another machine, a key the browser derives. Changing one does not adjust behaviour — it makes existing data unreadable. None of it has an override, and it lives in the crypto package as code.

This used to sit in the same table as the key locations, which are ordinary settings and now live in [crypto] of values.conf. One table holding both could not answer the question above, which is exactly why they were separated.

5.2 Discovered — packages/solver/solver/config/paths.py

root_dir and share_file are not matters of opinion. The first is wherever the checkout actually is; the second depends on whether this machine is a deployed host (/etc/euler) or a plain clone (the secrets dir). Writing either into a conf file would be writing down an answer that is only true on one machine.

repo_root() resolves in a fixed order — EULER_REPO_ROOT, then git rev-parse from the cwd, then from this file's directory, then two levels up but only if it looks like the checkout — and raises rather than guessing. That last guard is a scar: with the git filter running under -P, __file__ moved into site-packages, a laxer fallback adopted site-packages as the repo, and the filter reported a private key missing from a path no key had ever been at. A wrong root is worse than no root, because it turns "I cannot tell" into a confident lie further downstream.

5.3 Computed — the service classes

shell_argv is this interpreter. static_dir is a path inside whatever tree was discovered. The per-user socket's name embeds the instance's slug, which is only known once the environment has been read. Each is computed in the class's from_env and documented there; test_env requires every other field to be in the table, so a new one cannot be quietly forgotten.


6. Two invariants, and why they exist

Both are pinned by tests/test_config.py. Both look like style until you know what they are load-bearing for.

Importing configuration does nothing. No Config, no chdir, no PATH rewrite, no identity resolution, no rich. It used to do all of it, at import, on behalf of every caller — including a git filter that wanted one path constant and got a relocated process and a resolved security subject with it.

The sharp edge is the web tier: euler-auth, euler-msg and euler-ws run from /opt/euler with no working tree and no EULER_REPO_ROOT, so repo_root() raises for them by design. They read env.conf, so a Config built underneath them would kill euler-auth at import — and Caddy authenticates every request through euler-auth, so the site goes with it. That has happened once, for a neighbouring reason (web-server-guide §11).

The shell's process move is therefore explicit: enter_repo(config.root_dir), called by solver.main.main at startup. Identity is a cached_property, forced by main at the one moment the web shell's single-use ticket handoff can happen.

No module in the package may be called config. The static settings live in settings.py for this reason alone. A submodule of that name would be bound over the package's own config attribute by the import machinery the first time anything imported it by path, and every from solver.config import config afterwards would quietly receive a module — an AttributeError a long way from its cause, at every one of ~50 call sites. A test asserts the name stays unclaimed.


7. The one value a command writes

ecb_usd_rate — euros per US dollar, for what costs reports. Currencies drift daily, so update-usd-rate refreshes it from the European Central Bank's reference feed and writes it back through set_value, which rewrites that one line and leaves the sections, the comments and every other setting exactly as they were. A whole-file rewrite would round-trip the values and discard everything around them, which is most of what makes the file worth editing by hand.

It is the only setting a command may write, because it is the only one that is maintained rather than chosen. Everything else in values.conf is somebody's decision.


8. Tests

file what it pins
tests/test_config.py the two invariants of §6, the anchors, and that item and attribute access never disagree
tests/test_values.py the shipped file and the declared defaults state the same thing; a retired key, a bad value and a malformed file are all survivable; set_value preserves the comments
tests/test_env.py reading the table constructs no Config; the table and the five classes agree in both directions; !required exits naming its variable

9. Files

file role
packages/solver/solver/config/settings.py every setting: name, type, default — and the Config class
packages/solver/solver/config/values.conf the editable half: what a person tunes
packages/solver/solver/config/values.py reading it, and the surgical single-line write
packages/solver/solver/config/env.conf the web services' environment: variable name and default per setting
packages/solver/solver/config/env.py reading it — importable by a service with no clone
packages/solver/solver/config/paths.py the anchors, and the shell's explicit enter_repo()
packages/solver/solver/config/identity.py the resolved Subject and per-user state, reached only lazily
packages/solver/solver/config/theme.py the one palette, in rich and prompt_toolkit dialects
packages/solver/solver/crypto/wire.py the formats that are not configuration (§5.1)
packages/site/euler_site/*/config.py one class per service: the shape, and what a table cannot say