ADR-093 — Bot-var overrides: ${ITERION_X:-default} resolved from the DB before the pod env
- Status: Accepted
- Date: 2026-08-29
- Extends: ADR-090 (runtime operational settings), the
platformcfgfamilies (bot_roles, sandbox) - Family:
bot_vars(pkg/platformcfg)
Context
Bots parameterize their model pins, reasoning effort and tunables as ${ITERION_X:-default} expansions (ITERION_VIBE_MODEL_CLAUDE, ITERION_MODERNIZE_EFFORT, …). Those expansions read the runner pod's env, so re-tuning a bot in production meant a Helm values change and a rollout — while credentials, bot bundles, usage caps, role bindings and the sandbox image had all already moved to the CLI→API→DB surface. The operator asked for the same for bot vars, live within the settings TTL.
Decision
A fourth platformcfg family, bot_vars: one document holding a validated map[ITERION_*]value. It reaches the expansions through ONE choke point — ir.ExpandEnvWithDefault consults an overlay (ir.SetEnvOverlay, installed once at process boot by the server and runner cmds, backed by a TTL resolver) before os.Getenv. Precedence: setting > pod env > the .bot's :- default. An empty overlay value counts as unset (clearing is removing the key, never pinning "").
The record stays typed and validated in platformcfg (the ADR-090 rejection of a generic KV table): only the value SPACE is a map, because the keys are declared by bots, not the engine. Validate refuses names outside ITERION_[A-Z0-9_]+, credential-shaped names (KEY/TOKEN/SECRET/PASSWORD/PRIVATE/CREDENTIAL — a var that suggests a secret must never transit a non-secret surface), the infra namespaces (Mongo/NATS/JWT/secrets/S3/blob/Valkey/OIDC/OAuth/public-URL/bootstrap), and the families that already have their own audited surface (usage cap, sandbox default image). Values are single-line, non-blank, bounded.
Writes are super-admin only (PUT /api/admin/settings/bot-vars, merge per key: string = set, null = clear, absent = untouched), audited with old→new per touched key (values are non-secret by construction), and invalidate the mutating replica's resolver; every other replica converges within platformcfg.DefaultTTL. Runs claimed after that expand the new value — no restart anywhere.
Beyond the ${…} expansions, the direct os.Getenv("ITERION_*") model-pin sites (supervisor, verified-action, classifier, conflict resolver, session board) are routed through the exported ir.LookupEnv so the same setting reaches them; mcp_server command/args/url moved from os.ExpandEnv to ExpandEnvWithDefault, which also gives those fields the documented :- form. Other os.Getenv reads (boot config, infra) stay env-only by design — the blocklist keeps their names unwritable so routing more sites later can never turn a stored var into an infra override; the list is namespace hygiene and depth, not the containment boundary (that is the overlay's ITERION_-gated reach).
Consequences
iterion remote admin vars [set NAME VALUE | rm NAME]is the operator surface; the GET echoes stored+origin+propagation bound.- Values are stored, echoed and audit-logged in clear: the name gate refuses credential-shaped NAMES, nothing can vouch for values — the CLI help says never to store a secret in a bot var.
- A var only takes effect where the engine resolves it through the expansion chain; a name nothing reads is accepted but inert.
- Local CLI runs keep byte-identical env-only behaviour (nil overlay).
- Vars read by shell scripts INSIDE a sandbox container (container env) are out of scope.
- Propagation is per NODE boundary, not per run: expansions happen as each node builds, so a run longer than the TTL can straddle a change (node 1 old value, node 30 new). Same posture as a cap tightened mid-run. A redelivered message re-expands with the CURRENT settings — nothing is pinned on the RunMessage.
- Concurrent PUTs are safe: the write is a CAS on
updated_at; a lost race is a loud 409, never a silently dropped key.
Addendum (2026-09-30) — the refused set is a classification, held by a test
The namespaces the Decision lists were the first cut. The rule they serve is now written above botVarsInfraExact: a stored bot var tunes how a bot's runs behave. It never sets a name that lifts or weakens a guard (security, spend and quota ceilings, bounds on untrusted input, workspace safety), configures the process or the fleet rather than a run, names an endpoint or an identity presented outside, or is written by the engine for a child process — a stored value would forge it: a tool command's ${ITERION_ARTIFACT_FILES_DIR} resolves through the overlay before the child's own env.
TestEveryEngineEnvNameIsClassifiedwalks what the engine ships — the string literals of its Go source (pkg,cmd,internal) and of the vendored claw, the head of aNAME=valueassignment included, and every name written in the manifests and scripts it embeds underpkg(plugin manifests, the pi extension; gitignored build outputs excluded) — and fails unless each one is refused or listed inwritableBotVarswith its reason. A new switch cannot land without somebody deciding which side it belongs on.TestGuardNamesAreNeverBotVarspins the guard names themselves: the classification alone would accept one moved to the writable side.TestEveryShippedBotKnobIsABotVarholds the other side: every$ITERION_*a shipped bot reads, braced or bare, stays writable, unless it is listed as infrastructure or as a value the engine writes for a tool's shell.- A bot var may tune a bot's own budget knob (
…_MAX_DURATION). The valueBudget.ClampToCeilingjudges against the platform ceiling is the value that runs — frozen, so a stored value changed after the clamp does not reach the budget — and a zero or negative duration, unlimited to the runtime, is raised to the ceiling like an absent one. Two questions stay open: the exit grace a run spends past a ceiling it sits under (#2024), and one bot-var snapshot per run instead of reads at several moments (#2025). - Credential-shaped names are refused by whole segments as well as by substring (
PATwould matchPATH), and agree withstore.IsSecretEnvName: a name the engine redacts as secret is never a bot var — anAUTHORsegment, a shipped knob, excepted. - The routed model pins stay writable — they pick a model, never whether a guard runs. The permission classifier's model does not: naming it turns the classifier ON, and a classifier Allow skips the workflow's
tool_policy, so it is refused with the gate's mode (ITERION_PERMISSION). - A stored entry the wider rule now refuses is not deleted: the overlay ignores it with a warning (the pod env and the .bot default apply),
RefusedEntriesnames it, and the record stays editable.
Addendum (2026-10-22) — a sovereign pool's own env block is pinned ahead of the record
The precedence above is the shared fleet's. A sovereign runner pool (runnerPools[], sovereign pools) carries its routing in its own env block — gateway model ids, the openai_compatible hint, the claw backend — and the record, written for the shared fleet's vendor, reached those pods too: the record's vendor id under the pool's gateway hint, refused at build on every route, a pool that ran nothing (SocialGouv/iterion#2697).
ITERION_BOT_VARS_ENV_PINS names, comma-separated, the ITERION_* names the process environment answers ahead of the record. The chart renders it from the pool's env block (every ITERION_ name, in union with a list the block spells itself); platformcfg.EnvOverlayFor — the one overlay both cmds install — wraps the checked record read in PinnedOverlay. For a pinned name the precedence is pod env > the .bot's :- default; the record never, an empty pod value included. Every other name keeps setting > pod env > default, so a platform-wide effort dial still tunes a pool, and so does a model pin on a slot the block forgot: the pool block spells every dial the bots it runs read. The pin list is itself refused as a stored name; a token outside ITERION_[A-Z0-9_]+ pins nothing and is warned about at boot.
