# CLAUDE.md

## Project Overview

Maxim is a bio-inspired cognitive architecture for AI agents. It combines a 5-agent pipeline (Perception, Memory, Exec, Goal, Statistician) with biological memory systems (Hippocampus, ATL, Angular Gyrus, SCN, NAc) and a reactive Default Network. Works headless, in simulation, or connected to a robot.

## When making changes — required checks

Run these before considering any non-trivial task done:

```bash
# Lint + format
ruff check src/ tests/
ruff format src/ tests/

# Tests (fast suite)
python -m pytest tests/ -x -q -m "not slow" --ignore=tests/integration/test_memory_hub.py

# If touching memory/, decisions/, integration/memory_hub.py:
python -m pytest tests/integration/test_memory_hub.py -q
```

Additional guardrails:
- **Test interactive changes with logging.** When touching interactive mode (display, prompts, stdin reader, orchestrator sim loop), capture a session with `MAXIM_LOG_FILE=/tmp/maxim.jsonl maxim --sim "test basic recall" --interactive --sim-max-turns 3` and read the JSONL to verify percepts, tool calls, and followups flow correctly. Check for `ACTION_FOLLOWUP` entries to confirm user responses reach the LLM. Use `MAXIM_BACKEND_TRACE=1` for per-call token/latency data.
- **No band-aid fixes.** If you spot a bug while working on a task, determine whether the fix addresses the root cause or merely hides the symptom. If it's the latter — a special case, a swallowed exception, a flag that toggles around broken behavior, a fix that would need to be repeated elsewhere — stop, describe the root cause and the scope of the proper fix, and ask the user how to proceed. Never silently choose the smaller fix because it's easier.
- Prefer editing existing modules over creating new ones — this codebase favors many small files already
- Don't rename bio-system classes (Hippocampus, ATL, NAc, SCN, EC, AngularGyrus) — names are load-bearing for the mental model
- If you touch provenance, run a sim with `MAXIM_PROVENANCE_VERBOSITY=2` and eyeball the trace
- **Run `mypy` on public API files** after changing api.py, session.py, create.py, load.py, or __init__.py: `mypy src/maxim/__init__.py src/maxim/api.py src/maxim/session.py src/maxim/create.py src/maxim/load.py --ignore-missing-imports`
- **Run `ruff format`** after any changes: `ruff format src/ tests/`
- **Parallel sessions use worktrees.** When ≥2 Claude sessions run concurrently on independent work, each uses its own git worktree (`git worktree add ../Maxim-wt-<branch> -b <full-branch>`) and works entirely in absolute paths within that worktree. Single-session work stays in the main checkout. Tests in worktrees need `PYTHONPATH=src`. Note `~/.maxim/` is shared across worktrees — don't run sims from concurrent doc/code sessions or they'll collide on persisted state.

## Lessons learned (bugs that bit us)

**[engineering] Don't run the benchmark harness on the same machine as the leader** (Exp 37 cradle smoke session, 2026-06-04): A four-hour debugging cascade traced back to running `scripts/benchmark_cross_session.py` on the leader's machine (a Mac Mini). The cascade: (1) sub-sims auto-detect role via `runtime/role.py`; rank 4 (`~/.cloudflared/config.yml` exists → leader) or rank 6 (`--language-model` flag → solo) both make the sub-sim try to spawn its OWN `llama_cpp.server`; (2) sub-sim's llama-cpp binds port 8100, collides with the leader's existing llama-cpp on the same port, one of them dies; (3) maxim's proxy on port 8099 loses its upstream and starts returning 502s; (4) the operator pkills the broken maxim to clean up, which also kills cloudflared (auto-spawned as maxim's child process), so the public tunnel goes down; (5) external requests get 530s from Cloudflare's edge because the tunnel origin is unreachable; (6) operator restarts maxim, tunnel comes back, next sub-sim restarts the same cascade. Visible symptom was "persistent 530s + sub-sim spawned a stray llama-cpp" — actual cause was the architectural mismatch. **Rule:** the harness was designed for a remote-LLM topology (originally Sonnet via API). Sub-sims send HTTPS to wherever their lane config points. Co-locating the LLM and the harness on one box requires the harness's child processes to bypass that machine's role-detection, which has no clean entry point. **Fix:** run the harness from a different machine. The laptop's `peer.yml` correctly identifies it as peer; sub-sims send HTTPS through the public tunnel to the Mac Mini leader; no port collision possible. **Diagnostic signature:** `cat <sandbox>/util/lane_decisions.jsonl` shows `tier_decisions.large.source: "tier_table"` instead of `"env"` AND `ps aux | grep llama_cpp` shows more than one llama-cpp process on the leader machine. **What does NOT work:** setting `MAXIM_ROLE=peer` + `MAXIM_LANE_LARGE_REMOTE_URL=http://127.0.0.1:8100/v1` in the harness shell — it papers over the immediate symptom but leaves the leader vulnerable to the next operator slip (any env-var leak, any new terminal, anything that re-introduces the role-detection-as-leader pathway). The architectural separation is the only stable answer. **Update (2026-06-05): both candidate hardenings landed, making the leader-local fire safe.** (1) **Singleton spawn guard** — `runtime/llm_server.py::check_existing_llm_server` probes the singleton port (via the canonical `_MaximPeerBackend.for_url(...).health_check()` entry point) BEFORE any spawn in `lane_backends._maybe_auto_spawn_server`: HTTP 200 + matching served model → reuse the existing server (skip spawn, no collision); HTTP 401 (auth-gated but alive) → reuse; connection-refused/timeout → spawn as before; HTTP 200 + DIFFERENT served model → raise `RuntimeError` (fail loud, don't silently serve the wrong model). This replaces the old kill-the-unowned-ghost behavior — a sub-sim on the leader now reuses the leader's live server instead of killing+respawning it. (2) **Harness preflight** — `scripts/benchmark_cross_session.py::assert_subsim_routed_not_local` reads the first sub-sim's `<data_home>/util/lane_decisions.jsonl` and raises `PreflightError` (exit 4) if `tier_decisions.large.source == "tier_table"` (the cascade signature). The reuse path is logged as `source="reused_server"` and env-routing as `source="env"` (both accepted) via `lane_backends._derive_decision_sources` → `decision_log.build_record(decision_sources=...)`; a fresh local spawn is `source="tier_table"` (rejected). With both in place the harness can fire from the leader using local Qwen14B with no tunnel involvement. Regression guard: [tests/unit/test_llm_server.py::TestCheckExistingLlmServer](tests/unit/test_llm_server.py) (spawn/reuse-200/reuse-401/fail-loud-wrong-model) + [tests/behavioral/test_exp37_harness_smoke.py::TestHarnessPreflight](tests/behavioral/test_exp37_harness_smoke.py) (rejects tier_table, accepts env/reused_server).

**[behavioral] Running-mean centroid drift in cosine-similarity pattern completion** (EC centroid drift fix, five-phase plan `docs/plans/ec_centroid_drift_fix.md`, 2026-05-23 / 2026-05-24): the pre-fix `EntorhinalCortex.pattern_complete_or_separate` updated each matched node's centroid as a running mean of all admitted embeddings. With threshold 0.40 on the `"text"` modality, successive marginally-above-threshold matches (cosines 0.42-0.48) pulled the centroid toward a generic "second-person body sensation" prototype that then pattern-completed everything subsequent. The diagnostic ([24_roy_paraphrase_diagnostic.md](docs/experiments/24_roy_paraphrase_diagnostic.md)) showed 19 of 20 unique paraphrase strings collapsing into ONE EC node under sequential streaming, while the SAME pairs in isolation (fresh EC per pair) clustered perfectly (10/10). The asymmetry between isolated and sequential measurement is the diagnostic signature of progressive centroid drift. **Detection rule:** when validating a new EC modality or substrate-encoding path, ALWAYS measure both isolated (fresh EC per item) and sequential (one EC, all items) — if they disagree sharply, drift is the culprit. **Fix rule:** for any modality where downstream concepts must persist as distinct clusters across a session, choose either (a) frozen-prototype semantics — add the modality to `ECConfig.frozen_centroid_modalities` (this is the existing fix for `"interoception"`), or (b) raise `pattern_complete_threshold` enough to reject the marginal admissions that cause drift. The Phase 1/2 matrix sweep showed (b) is the right lever for `"text"` (threshold 0.40 → 0.44 — 0.05 grid sample missed the sweet spot, 0.01 fine sweep named it). (a) hurt P1 paraphrase recall too much for text. **Sampling-granularity rule for parameter sweeps on precision-recall trade-offs:** default to 0.05 granularity (matches the original P1 P2 tuning precedent); drop to 0.01 only when the 0.05 result sits at a regression boundary — N passes, N+1 fails, both within noise of a downstream gate. Don't drop to 0.01 by default. **NAc threshold-override coupling (Phase 3.5):** `NAc.get_threshold_overrides(agent_id, *, base_threshold=None)` accepts the live EC threshold from the caller. Production callers (`LinguisticEncoder._get_reward_overrides` + `encode_decomposed`) MUST pass `self.ec.config.pattern_complete_threshold` so the override tracks whatever threshold the matching EC instance is using. The `None` fallback is hardcoded to 0.44 for legacy callers that lack an EC reference (three unit-test sites); the fallback's value is pinned to the EC default by `test_nac_threshold_override_base_tracks_ec_default`. At non-default EC thresholds (e.g., P2's 0.70), the pre-Phase-3.5 hardcoded base accidentally widened recognition by `(base - 0.44) + bias` cosine units instead of the bio-correct `bias`-magnitude widening — invisible at the production default, load-bearing at any non-default threshold. **Behavioral validation on Roy-2c (Phase 4):** drift was REAL (six spurious priming UUIDs per concept collapsed to one — cluster_reward_bias_l2 dropped 79%) but drift is NOT the dominant Roy persona-inertness mechanism (behavioral signal unchanged — the H1 cross-source alignment gap between priming-substrate text and CLI-fixture text is structurally upstream of drift). Fix ships as substrate hygiene + V1 cross-session prerequisite; JEPA / cross-modal binding stays a 1.0-or-1.1 gate. See [27_ec_drift_phase_4_behavioral.md](docs/experiments/27_ec_drift_phase_4_behavioral.md). Regression guards: [tests/unit/test_ec_centroid_drift_fix.py](tests/unit/test_ec_centroid_drift_fix.py) (4 tests pinning default + parameterization + fallback + clamp floor), [tests/unit/test_roy_5_cosine_localization.py::test_h1c_lower_bound_tracks_ec_default](tests/unit/test_roy_5_cosine_localization.py) (Roy-5 H1C boundary tracks EC default). Roy experiment: [docs/experiments/27_ec_drift_phase_4_behavioral.md](docs/experiments/27_ec_drift_phase_4_behavioral.md) + [docs/experiments/22_roy_5a.md](docs/experiments/22_roy_5a.md) (H1C boundary).

**[engineering] Key-embedded values produce structurally-degenerate statistics** (Wire 1 pre-merge fold, 2026-05-17): if your statistic accumulator's KEY embeds the very dimension you want the statistic to vary over, the per-key statistic is structurally 0 (or degenerate) on every binary-input case. Wire 1 originally placed reward-outcome variance on `CausalLink`, but `CausalLink._generate_link_id` keys on `outcome_signature` (which embeds valence as part of the key). Per-link reward variance was therefore always 0 for binary success/failure observations — every `CausalLink` instance held one outcome class and the Welford accumulator never saw alternation. The fix moves variance one level up to `NAc._event_outcome_welford` keyed by `(agent_id, event_signature)` — the level where the cross-outcome distribution actually lives. **Rule:** before adding a statistic field to an entity, list the entity's key fields and confirm the statistic varies over them. If the value you're trying to measure is part of the key, the statistic accumulator belongs on the parent aggregation, not the keyed entity. Generalises beyond variance: bandit per-arm reward estimates, goal-conditioned success rates, anywhere "we measure how X varies given context Y" but Y is part of the index. The same trap as the `_context_similarity` denominator lesson — both are about silent degenerate-statistic shapes that the test suite reads as zero or one and concludes "feature works fine, value is just small". Reference: `docs/plans/release_0_9_1.md` Stage 4 and the CausalLink class docstring. Regression guard: [src/maxim/decisions/nac.py](src/maxim/decisions/nac.py) — variance accumulator lives on `NAc._event_outcome_welford` (parent aggregation), not on keyed `CausalLink`; class docstring documents the move.

**[engineering] Push silent-no-op invariants into types, not helpers:** The PRIMARY trigger is the *failure mode*, not the count. When a bug class has a SILENT failure mode (no exception, no log, just wrong behavior or missing learning) AND has more than one possible call site, push the invariant DOWN into the type/constructor signature so forgetting becomes a `TypeError`, not a silent no-op. Three repeated instances of the same shape is *strong corroborating evidence* that helper-discipline is too weak — but it's the silent failure mode that makes the rule load-bearing, not the headcount. Loud-failure recurring bugs (raise → log → user sees it) can stay on helper-discipline without harm; silent-failure bugs that are forgotten even ONCE in a security-critical or correctness-critical path should jump straight to structural enforcement. The `build_executor(pain_bus=...)` keyword-only requirement is the canonical example: three identical "forgot to wire `ToolPainBridge`" bugs in `sem_execution_hook.md` Stages 1+2+2c silently skipped NAc tool-outcome learning on three different agent entry points. The bug never raised; the next entry point would have reproduced it. `executor_bootstrap_unification.md` made forgetting impossible at the constructor level — the fourth, fifth, and sixth instances surfaced during the migration audit (api.py headless mode, simulation/tools.py sub-AUT, etc.) became explicit `pain_bus=None` decisions instead of unnoticed silent gaps. **Rule:** count silent failures, not loud ones. One silent-failure miss in a critical path → consider structural enforcement. Three silent-failure misses in any path → no longer a question, push it down. Regression guard: [src/maxim/runtime/bootstrap.py::build_executor](src/maxim/runtime/bootstrap.py) — required keyword-only `pain_bus=` parameter is the canonical example; signature enforces the rule structurally so forgetting becomes a `TypeError`, not a silent no-op.

**[engineering] Mutable globals + module extraction:** When extracting module-level mutable globals (like `_active_spawner`) into a new file, do NOT re-import them by name (`from new_module import _active_spawner`). Python binds by value at import time — assignments in the importing module diverge from the source. Use module reference instead: `import new_module as _mod; _mod._active_spawner = value`. Functions are safe to re-import (they close over their own module's namespace). Regression guard: pattern-lesson, no test enforces; rely on reviewer attention during module extraction PRs.

**[engineering] Per-agent stash dicts (not module-level globals) for multi-agent state** (v1 P4, 2026-04-28): The pre-fix `runtime/bio_integration.py` carried module-level globals `_episode_tick`, `_latest_pain_intensity`, and `_latest_substrate_nodes` with a docstring comment that read "acceptable because multi-agent substrate encoding is not yet a production path. If it becomes one, scope per agent_id." Three failure modes hidden behind that caveat: (1) a *single* tick counter ordered events across all agents instead of per-agent; (2) substrate nodes encoded for agent A on the producer side (`MemoryHub.on_percept_received`) could land on agent B's next `observe_episode` event because the consumer (`agent_loop.observe_episode`) read the same stash slot; (3) max-merging pain intensities silently dropped agent A's pain when agent B happened to be louder in the same tick. **Rule:** any per-agent runtime stash MUST be a `dict[agent_id, value]` from day one, with `agent_id: str` as a required keyword-only parameter on every entry point that reads or writes it. The value type is whatever the slot held before. Empty-string `agent_id` is rejected by a `_check_agent_id` validator at every entry — without it, `agent_id=""` silently merges every "agent" through one shared key and re-introduces the original collision class. **Producer-consumer key alignment is load-bearing:** when a producer (`MemoryHub.on_percept_received`) and a consumer (`agent_loop.observe_episode`) sit on opposite ends of the stash, they MUST agree on the key. P4 picks a single canonical key — `memory_hub.agent_id` (set at `build_memory_hub`/`build_bio_stack`/`AgentFactory.create_agent`) — and the consumer derives `_loop_agent_id = memory_hub.agent_id or _safe_agent_name(agent)` so both ends use the same string. The fallback to `_safe_agent_name(agent)` is for raw-loop callers without a `MemoryHub`; it does NOT route different keys for the same agent in production. **Pain-intensity RMW needs an explicit lock**, not the GIL: `current = get(); if intensity > current: set` is two bytecodes in CPython 3.11+ (specialised arithmetic broke the "single bytecode" assumption that the older inline-cache codepath relied on). Write a `threading.Lock` and acquire it for the read-modify-write, even if there are no production producers today (future-proofing — the bus path bypasses this stash, but the API is exposed). The corresponding `record_outcome(agent_id=...)` invariant in `runtime/tool_dispatch.py` mirrors the same shape: required keyword-only, validated non-empty, tagged onto every `nac.observe(context={"agent_id": agent_id, ...})` so per-agent attribution is filterable from the persisted `event_context`. Test discipline: when two agents share ONE NAc instance (vs two separate NAc instances per the production AgentFactory pattern), the per-agent isolation must STILL be provable via `event_context["agent_id"]` — otherwise the headline "non-overlapping signatures" claim is structurally guaranteed by separate NAc objects, not by the agent_id tag itself. See `tests/integration/test_multi_agent_attribution.py::TestSharedNacIsolation`. Reference: `docs/plans/v1_refinement.md` Section 4 P4. Regression guard: [tests/integration/test_multi_agent_attribution.py](tests/integration/test_multi_agent_attribution.py) (TestSharedNacIsolation) + [src/maxim/runtime/bio_integration.py](src/maxim/runtime/bio_integration.py) (`_check_agent_id` validator rejects empty `agent_id` at every entry point).

**[engineering] Auth in health probes:** Any HTTP health check that probes an endpoint behind API key auth MUST include the auth header. The leader's `_probe_upstream_ready()` was silently getting 401s from an auth-gated llama-cpp-server, causing `llm_ready` to be permanently false. Always send auth in probes, and treat 401 as "server is up" (auth-gated but alive). Regression guard: [src/maxim/models/language/maxim_peer_backend.py](src/maxim/models/language/maxim_peer_backend.py) — `_MaximPeerBackend.health_check` is the canonical probe entry point and threads `api_key` through `_build_headers` for every request.

**[engineering] NAc class name:** The class is `NAc` (in `decisions/nac.py`), NOT `NucleusAccumbens`. Old code may reference the wrong name — always grep for `NucleusAccumbens` after touching NAc-related code. Regression guard: ad-hoc `grep -rn "NucleusAccumbens" src/maxim/` must return zero matches; class definition lives in [src/maxim/decisions/nac.py](src/maxim/decisions/nac.py).

**[engineering] Lane tier names:** The canonical tier names are `"large"`, `"medium"`, `"small"`. The old names `"infer"`, `"review"`, `"record"` have been fully removed. Do not re-introduce them. Regression guard: ad-hoc `grep -rn "infer\|review\|record" src/maxim/runtime/lane_models.py` must not match the legacy tier-name string literals; canonical names live in [src/maxim/runtime/lane_models.py](src/maxim/runtime/lane_models.py).

**[engineering] Lane = capability tier; placement (origin) is a SEPARATE ordered axis** (lane_capability_placement_split.md, 2026-06-09/10). The lane system has two orthogonal axes: **capability/size** (`large`/`medium`/`small`, frozen `_VALID_TIER_NAMES`, assigned by `FunctionRouter` — what the *work* needs) and **placement/origin** (`Origin{LOCAL,CLOUD,PEER}` — *where* it runs). Placement is a first-class TYPE + compile step that **rides on `LLMRouter`'s existing `provider_priority`/`_try_provider` failover — NOT a new resolver** (a second resolver would duplicate `_try_provider` and re-introduce the multi-call hazard the `_MaximPeerBackend` one-HTTP-call invariant exists to kill). The runtime carrier is `LaneConfig.placement: tuple[ProviderPlacement, ...]` ([worker_pool.py](src/maxim/runtime/worker_pool.py)); the declarative twin is `config.json::lanes.<tier>.placement` (`LaneTierPlacement`, [config_loader.py](src/maxim/runtime/config_loader.py)). **Empty `placement == ()` means "derive a 1-element placement from the legacy `model_profile`/`remote_url`/… fields"** — `derive_placement` reproduces the old `_classify` URL-sniff EXACTLY, so existing configs are byte-identical (the `_legacy_classify_oracle` matrix pins this). When `placement` is non-empty it is authoritative; CLI flags edit it on top (CLI > config). Dispatch reads `origin`, not the URL: `_classify = placement_kind(derive_placement(...))`; `_build_backend` dispatches on the primary origin (LOCAL **and CLOUD-profile-without-url** → the profile-driven `_build_local_backend` which `load_llm_config` resolves to the right backend incl. cloud profiles; CLOUD-with-url / PEER → `_build_remote_backend`). Multi-element placements (`local primary → cloud fallback`) compile via **tail-injection** (`_inject_placement_tail` / `_placement_entry_to_provider`) — the primary builds normally, `placement[1:]` append as fallback providers in `provider_priority`; this subsumes `--cloud-fallback`. **Double-injection guard:** the env-driven `_maybe_inject_cloud_fallback` runs ONLY for derived lanes (`cfg.placement == ()`); explicit placements use tail-injection — exactly one path. **Cloud cap:** `MAXIM_MAX_CLOUD_LANES` keys off `placement_kind` = `placement[0].origin` (primary only); a `[LOCAL, CLOUD-fallback]` lane does NOT consume a slot (matches legacy `--cloud-fallback`, gated by `cloud_enabled` + `_validate_cloud_config` instead). Coherence (PEER needs url, LOCAL needs model, CLOUD needs url-or-model) is enforced by `worker_pool.validate_placement_coherence` at the config/CLI producer boundary, NOT in `ProviderPlacement.__post_init__` (the runtime value type stays permissive; derived placements are coherent by construction). `--cloud-lane`/`--cloud-fallback` (+ `MAXIM_CLOUD_LANE_*`/`MAXIM_CLOUD_FALLBACK_MODEL`) are re-expressed as placement edits, kept as deprecated-but-working aliases through 1.x (drop 1.2). Hardware tuning (`n_gpu_layers`/`device`/`n_ctx`/`kv_quant_mode`) stays lane-level on `LaneConfig`, NOT on `ProviderPlacement` (heterogeneous-hardware LOCAL placements are a 1.1+ extension). Regression guard: [tests/unit/test_lane_placement.py](tests/unit/test_lane_placement.py) (`_legacy_classify_oracle` equivalence matrix + explicit-placement dispatch) + [tests/unit/test_lane_placement_config.py](tests/unit/test_lane_placement_config.py) (schema + coherence-at-load) + [tests/unit/test_lane_placement_runtime.py](tests/unit/test_lane_placement_runtime.py) (producer + tail-injection + CLI re-expression).

**[engineering] Startup ordering in cli.py:** The LeaderProxy MUST start BEFORE `_normalize_args()` because arg normalization can trigger heavy CUDA imports (5-15s on GPU systems). Peers polling for the proxy during restart will time out if the proxy starts after these imports. Regression guard: [src/maxim/cli.py::main](src/maxim/cli.py) — LeaderProxy startup must precede `_normalize_args()` call; ordering enforced by code structure at the top of `main()`.

**[engineering] Dead code accumulates silently:** Before publishing or after major refactors, grep for orphan modules: `.py` files whose basename doesn't appear in any `import` statement. We found 15 dead modules (~8,500 LOC) shipping in the wheel. Regression guard: process invariant — periodic grep before publish; no automated test enforces.

**[engineering] Opt-in env vars in hot startup paths need autouse scrubs:** When you wire a new `if os.environ.get("MAXIM_FOO"): do_side_effect()` branch into anything reachable from `build_primary_router` (auto-spawn, tier detection, ensure_available, ...), pair it in the same commit with a `@pytest.fixture(autouse=True)` env-scrub fixture in [tests/conftest.py](tests/conftest.py). Without it, ANY test that sets the env var (e.g., a `normalize_args` unit test asserting `--auto-download` populates the var) leaks into every later test that constructs the runtime — and the leaked side effect runs for real. P5 cost a 9-minute pytest hang on a real 1 GB GGUF download to `~/.maxim/` before this was caught. The two existing scrubs (`_isolate_maxim_llm_profile_env`, `_isolate_maxim_auto_download_env`) are the template. Regression guard: [tests/conftest.py](tests/conftest.py) — autouse env-scrub fixtures pattern; new env-var branches must add a matching scrub in the same commit.

**[engineering] HTTP call sites must use `maxim/utils/http.py`:** Plan 1 R1 consolidated ~11 scattered `urllib.request` call sites into one registry-backed module. The invariant is CI-enforced: `grep -r "urllib.request.urlopen" src/maxim/ | grep -v "utils/http.py"` must return zero matches. The 2026-04-12 Cloudflare Bot Fight Mode incident (commit `8b52cbd`) was a missing `User-Agent` header in one of those call sites — the `_external` endpoint in `utils/http.py` sets it once at registration, so that class of bug is structurally impossible now. When adding a new outbound HTTP call: pick `http.get`/`http.post` (registered endpoint), `http.fetch_url` (arbitrary URL), or `http.download_to_file` (streaming file). The `raw_proxy_forward` escape hatch is reserved for `leader_proxy._proxy_request` ONLY — do not use it elsewhere. Regression guard: CI grep `grep -r "urllib.request.urlopen" src/maxim/ | grep -v "utils/http.py"` must return zero matches; enforced in [.github/workflows/test.yml](.github/workflows/test.yml).

**[engineering] Role detection is the first runtime action:** Plan 2 R2a made role explicit. `cli.py::main()` calls `runtime/role.py::detect_and_apply_role(raw_argv)` immediately after `configure_logging`, BEFORE subcommand dispatch. It exports `MAXIM_ROLE` + emits `role_detected` as the first structured log event. Downstream code (`runtime/llm_server.py::_model_state_file`) reads `MAXIM_ROLE` from env — never re-detects, never infers from `peer.yml` existence. If you're adding a new feature whose behavior depends on role, read `os.environ["MAXIM_ROLE"]`; never call `detect_role()` a second time. Decision order: env var → mesh.yml → peer.yml → `--llm` flag + no peer config → default leader. Persisted state is split per role (`active_llm_model.{role}.txt`). The call site is co-located with `configure_logging` at the top of `main()` — if you move it downstream you'll re-encounter the subcommand-dispatch logging gap described below. Regression guard: [src/maxim/runtime/role.py::detect_and_apply_role](src/maxim/runtime/role.py) + [src/maxim/cli.py::main](src/maxim/cli.py) — call site structurally precedes subcommand dispatch.

**[engineering] `config.json::llm.profile` vs `active_llm_model.{role}.txt` are intentionally separate state files** (2026-06-09 footgun surfaced via the Exp 37 Mistral24B kickoff). `config.json` is the **declarative** operator-intent layer (per [config_unification.md C2](docs/plans/config_unification.md)); `active_llm_model.{role}.txt` is the **runtime** what's-actually-loaded state. `maxim --llm <model>` is a one-shot **runtime swap** — it spawns/replaces the llama-cpp-server AND updates `active_llm_model.{role}.txt`, but does NOT update `config.json::llm.profile`. Per the C2 invariant, automatic runtime writes to declarative config files are FORBIDDEN; the CI grep allow-list in `.github/workflows/test.yml` blocks `set_field` calls from `lane_backends.py` for exactly this reason. The drift is by design — but it surfaces unhelpfully at sub-sim startup, where `_apply_lane_config_to_env` propagates `config.json::llm.profile` into `MAXIM_LLM_PROFILE`, then the singleton-check in `runtime/llm_server.py::check_existing_llm_server` sees configured=<stale> vs served=<new> and raises `RuntimeError`. **Symptom:** operator runs `maxim --llm mistral-small-24b` to swap the leader, then fires a sub-sim (cradle harness, simulation, etc.) and gets "configured 'qwen2.5-32b-instruct' but server is serving Mistral-...". **Resolution path the error message now spells out:** (a) adopt the served model via `maxim config set llm.profile <profile>`, (b) restart the runtime to match config.json, or (c) kill the existing server entirely. The architectural answer to "why not auto-update config.json from `maxim --llm`" is the same as the mesh.yml lesson: declarative config files must NEVER be mutated by runtime operations; operators write them explicitly. Regression guard: [src/maxim/runtime/llm_server.py::check_existing_llm_server](src/maxim/runtime/llm_server.py) — the error message includes the concrete `maxim config set llm.profile` command + the resolution branches.

**[engineering] `BackendError.fix_hint` is never user-controllable:** Plan 2 R2b added the typed `BackendError` hierarchy in `models/language/types.py` mirroring `utils/http.py::HTTPError`. Every subclass has a class-level `fix_hint`. Subclasses may interpolate validated identifiers (model names, URLs) into hint strings, but the format strings themselves are always static. Prevents log injection via user-controlled exception content. Access patterns are exactly three: `.status`, `.response`, `.fix_hint`. Do NOT add `raw_body` or any parallel attribute — Plan 3's router bridge counts on the shape matching `HTTPError`. The `INFERENCE_BROKEN_BACKOFF_S = 15.0` constant in the same module is the single source of truth linking router backoff to probe cache TTL; import it, don't duplicate. Regression guard: [src/maxim/models/language/types.py](src/maxim/models/language/types.py) — `BackendError` hierarchy with class-level `fix_hint` strings (static format strings, not user-controllable).

**[engineering] Subcommand dispatch in `cli.py::main` bypasses logging setup by default:** `cli.py::main` short-circuits to `run_doctor_subcommand` / `run_peer_connect_subcommand` / `run_tunnel_subcommand` before reaching the sim loop that previously was the only caller of `configure_logging`. Any feature that depends on early logging setup (MAXIM_LOG_FILE JSONL handler, future structured event emission, Plan 2 R2a's `detect_role` log event) needs `configure_logging` called at the TOP of `main()` before subcommand dispatch, not at the sim-loop entry. This was a real bug during Plan 1 R1 — MAXIM_LOG_FILE silently did nothing for `maxim doctor` until commit `c8a07e9` added the early call. The sim loop's later `configure_logging(force=True)` call dedupes JSONL handlers by absolute path, so the early call + late call is safe. **Plan 2 R2 re-encountered this class of bug in a different form:** `_has_local_llm_flag` scanned raw `argv` including subcommand names, so `maxim tunnel --llm X` mis-detected role as `solo`. Any code that runs early in `main()` and consumes `argv` must explicitly handle subcommand entry paths — either skip the scan when `argv[0]` is in `{doctor, peer, tunnel, ...}` or only apply the logic to the sim/agent entry path. Regression guard: [src/maxim/cli.py::main](src/maxim/cli.py) — `configure_logging` is called at the top of `main()` before subcommand dispatch; co-located code structure enforces ordering.

**[engineering] Plan review round runs BEFORE PR merge, not after** (refined 2026-04-12 after R1 vs R2 comparison, validated again in R3): every completed sub-plan on a `feat/<plan>` branch triggers a pre-merge review round. Spawn two parallel review Claudes (Executor lens + Architecture lens — templates in [docs/plans/llm_path_refinement.md](docs/plans/llm_path_refinement.md) context or the R1/R2/R3 session histories). Fold findings into the same branch via a follow-up commit BEFORE opening/merging the PR. Do NOT merge first and ship a `fix/<plan>-loose-ends` PR after — that pattern works but splits the bisect surface and leaves known-buggy code on main. R1 used the old timing (required PR #91 follow-up); R2 and R3 used the refined pre-merge timing. R3 found 18 issues including 2 critical behavior bugs (`_MaximPeerBackend.for_url` env-var race + `_emit_dispatch_exhausted` bypassing the canonical `_normalize_request_context` shim). Both reviewers caught the env-var race INDEPENDENTLY — the cross-confirmation is a strong signal to trust the finding even when it feels minor. All 12 blocking findings folded into one fix commit before PR #94 opened. Tests caught zero of those bugs — they're correctness issues in input spaces tests don't cover. Review rounds are non-optional, not ceremony. See [feedback_review_before_ship.md](../../.claude/projects/-Users-dennyschaedig-Scripts-Maxim/memory/feedback_review_before_ship.md) for the full evidence + prompt templates. Regression guard: process invariant — review-round discipline enforced by author + reviewer attention; no automated test enforces.

**[engineering] `_MaximPeerBackend.complete_with_usage()` makes EXACTLY one HTTP call — the load-bearing invariant for Plan 3:** the whole point of Plan 3 was killing the ~52s fail-slow caused by `_OpenAIBackend`'s internal gateway-retry loop amplified by the per-lane `_inference_lock`. `_MaximPeerBackend` in `models/language/maxim_peer_backend.py` replaces that path for self-hosted peer traffic. It raises typed `BackendError` subclasses on failure and lets the router's provider-fallback loop handle failover. **Adding a `try: ... except: <call again>` block anywhere in this file re-introduces the incident.** CI grep enforces the rule: `grep -nE "retry|backoff|gateway" src/maxim/models/language/maxim_peer_backend.py | grep -vE "retry_after_s|retry_timeout_s"` must return zero matches. The two allowed parameter-name matches are `BackendOverloaded.retry_after_s` (Plan 2 R2b contract) and `health_check.retry_timeout_s` (inherited from the pre-R2.6 probe signature, used for the liveness two-attempt budget — that's a retry *budget*, not a retry loop). If you need per-provider cooldown, use `LLMRouter._note_provider_overload` / `_set_long_backoff` / `_set_short_backoff` — they apply at the router layer, not inside the backend. The router's `_try_provider` catches the typed exceptions in specific-before-general order (`BackendOverloaded` → `BackendAuthFailed` → `BackendModelMissing` → `BackendInferenceBroken` → `BackendTimeout` → `BackendDown` → `BackendError` → `Exception` safety net). Long-cooldown branches (auth 300s, model_missing 60s, inference_broken 15s) do NOT call `_note_provider_failure` — that would overwrite the hard value with the exponential ramp. Do NOT "helpfully" add a `_note_provider_failure` call for symmetry; it's load-bearing that those branches skip it. Regression guard: CI grep `grep -nE "retry|backoff|gateway" src/maxim/models/language/maxim_peer_backend.py | grep -vE "retry_after_s|retry_timeout_s"` must return zero matches; enforced in [.github/workflows/test.yml](.github/workflows/test.yml).

**[engineering] Streaming contract difference between `_MaximPeerBackend` and `_OpenAIBackend` is intentional:** `_OpenAIBackend._stream_response` silently collects partial output when a chunk iteration raises mid-stream (cloud providers' first-token-latency UX expects "got some tokens" > "nothing"). `_MaximPeerBackend._stream_response` raises `BackendDown` on any mid-stream failure (malformed JSON chunk, `HTTPConnectionError` during `iter_lines`, or empty content) so the router can fail over to a different provider. These are different contracts for different backends, not a bug in either. Do NOT "fix" the peer backend to match the cloud one — that re-introduces the class of silent-partial-output bugs Plan 3 was designed to eliminate. Regression guards: `test_streaming_mid_stream_malformed_chunk_raises_backend_down` + `test_streaming_connection_error_mid_stream_raises_backend_down` + `test_streaming_empty_content_raises_backend_down`.

**[engineering] Probe entry point is `_MaximPeerBackend.health_check` — Plan 3 R2.6:** any liveness or readiness probe against a peer URL MUST use `_MaximPeerBackend.for_url(url, api_key=k, model=m).health_check(enable_stage2=...)`, not the deprecated `runtime.llm_server.probe_llm_server` / `llm_server_responding_at` / `_probe_once`. The three historical functions still exist as DEPRECATED thin compat shims kept to avoid mass-migrating ~35 test mock sites, but CI grep in `.github/workflows/test.yml` allow-lists the 4 existing call sites (in `llm_server.py`, `lane_backends.py`, `doctor/checks.py`, `maxim_peer_backend.py`) — any new match is a CI failure with a migration hint. If you're writing new code that probes a peer URL, go through the backend's method directly. **`_MaximPeerBackend.for_url` is concurrency-safe via instance-level `_api_key_override` — it does NOT mutate `os.environ`.** The R2.5 original shipment wrote the probe key to `os.environ["MAXIM_PEER_PROBE_KEY"]` which races under concurrent probes; the pre-merge review round caught this as a critical finding and the fix stores the key on the returned instance. If you add a new backend factory, use the same instance-attribute pattern — do NOT mutate process-global state from a factory call. Regression guard: CI grep allow-list in [.github/workflows/test.yml](.github/workflows/test.yml) restricts callers of `probe_llm_server` / `llm_server_responding_at` / `_probe_once` to four existing sites.

**[engineering] Per-tier `lanes.<tier>.timeout_s` flows through `LaneConfig.remote_timeout_s` → provider config without backend wiring changes** (llm_timeout_scalability.md Stage 2, 2026-06-04). The schema field lives on `LaneTierConfig` (CC3 path-a, declared field with `__post_init__` validation: strictly positive, numeric). The env-var family `MAXIM_LANE_<TIER>_TIMEOUT_S` is registered in `_FIELD_TO_ENV` with a float coercer (`_coerce_for_field` in `runtime/config_loader.py`) that enforces the same > 0 invariant at the loader layer. `_apply_lane_config_to_env` populates the env var from `config.json` when source is non-default; `apply_lane_env_overrides` (in `runtime/lane_models.py`) reads the env var into `LaneConfig.remote_timeout_s` with a defensive silent-ignore on malformed or non-positive values (the loader-side raises `ConfigurationError`; this path is the bypass-the-loader belt-and-suspenders). The lane backend manager at `runtime/lane_backends.py` threads `cfg.remote_timeout_s` into `providers[provider_key]["timeout_s"]` only when the value is non-None — both backends (`_MaximPeerBackend._get_timeout_policy()` and `_OpenAIBackend._get_timeout()`) already read `_provider_cfg().get("timeout_s", <default>)` per the Plan 3 R2.5 contract; populating the key is sufficient. **Do NOT change either backend's default value to honor a missing config**: 300s for self-hosted, 60s for cloud is the right pair, and config absence means "use the backend default" — not "use 0". Regression guard: [tests/unit/test_config_loader.py::TestLaneTierTimeoutField](tests/unit/test_config_loader.py) (13 tests pinning field validation, JSON parser, env coercion, resolve_setting precedence) + [tests/unit/test_leader_proxy.py::TestLaneTimeoutFieldFlow](tests/unit/test_leader_proxy.py) (4 tests pinning env passthrough into LaneConfig.remote_timeout_s).

**[engineering] Proxy context-overflow admission gate rejects oversize inference requests BEFORE forwarding** (llm_timeout_scalability.md follow-up, 2026-06-04). Pre-gate, prompts exceeding `MAXIM_LLM_N_CTX` caused llama-cpp-server to silently abort generation, producing a stream that limped along with TTFT keepalives until upstream gave up — terrible debugging UX, wasted compute, no actionable error for the agent. The fix lives entirely in the leader proxy: `_proxy_request` reads the request body, gates on `self.path ∈ _PROXY_INFERENCE_PATHS` (`{/v1/chat/completions, /v1/completions}`) AND `_is_admission_enabled()` AND `_get_proxy_context_window() is not None`, then calls `_check_context_admission(body, n_ctx, overhead)`. Rejection returns HTTP 413 with a typed JSON body matching the OpenAI error envelope shape (`error.code`, `error.type`, `error.message`) plus Maxim-specific fields (`estimated_prompt_tokens`, `max_tokens`, `context_window`, `safety_overhead_tokens`) for operator debugging. Token estimation is **character-based** (`len(text) / _PROXY_CHAR_TO_TOKEN_RATIO = 3.5`) — no new dependencies, no extra HTTP round-trip to a tokenizer. The ~10-30% underestimate vs actual tokenizers is covered by `MAXIM_PROXY_CONTEXT_OVERHEAD_TOKENS` (default 256, clamped 0-4096). **Graceful default-on**: gate is ON when `MAXIM_LLM_N_CTX` is resolvable (which it is on any properly-configured leader); OFF with a one-time startup WARNING otherwise. Explicit opt-out via `MAXIM_PROXY_CONTEXT_ADMISSION=0`. Cache for `_get_proxy_context_window()` is a `(resolved: bool, value: int | None)` tuple with `threading.Lock` + a `_reset_proxy_context_window_cache_for_test` helper invoked by the autouse env-scrub fixture in `tests/conftest.py`. **Malformed body bypasses the gate**: `_check_context_admission` returns None on JSON parse failure rather than rejecting — upstream returns a cleaner 400, and we don't false-reject on edge cases the estimator can't handle (vision multipart with non-text parts, custom prompt schemas). **Two-layer defense with `PromptBuilder` + `PromptBudgeter`** ([src/maxim/agents/prompt_builder.py](src/maxim/agents/prompt_builder.py), [src/maxim/agents/prompt_budgeter.py](src/maxim/agents/prompt_budgeter.py)): the agent-side budgeter is the PRIMARY mechanism — it composes prompts within `n_ctx - response_reserve - template_overhead` using priority-tiered section assembly (MANDATORY → CRITICAL → ... → NICE_TO_HAVE; truncatable sections shorten before being dropped entirely). It uses the real `LlamaCppTokenCounter` (precise) when the LLM is loaded, falling back to `CharEstimateCounter` (`len // 3`, deliberately more conservative than the gate's `len / 3.5`) during warmup. The proxy gate is the SECONDARY safety net — it sees the already-budgeted prompt and validates it fits. Both layers read `n_ctx` from the same source (`config.json::llm.n_ctx` / `MAXIM_LLM_N_CTX`), so they cannot disagree on the ceiling. The gate's coarser char-based estimate + 256-token overhead margin is deliberately calibrated so PromptBuilder's accurate-tokenizer output cannot false-trigger it — when the gate DOES fire, it's signal that (a) a non-PromptBuilder client (curl / external script / stress test) sent the request, (b) PromptBuilder was still on its char-estimate fallback during warmup, or (c) `MAXIM_LLM_N_CTX` is misconfigured (operator sets one value, llama-cpp launches at a smaller `-c`). **Both backends always populate `max_tokens` in the outgoing request** — it's a required keyword parameter on `_MaximPeerBackend.complete_with_usage()` and `_OpenAIBackend._complete()`. This means the gate's `_PROXY_DEFAULT_MAX_TOKENS = 2048` fallback is structurally unreachable for Maxim-internal traffic; it only applies to external clients that omit the field (curl probes, ad-hoc API scripts). Regression guard: [tests/unit/test_leader_proxy.py::TestContextOverheadResolver / TestAdmissionEnableGate / TestInputTokenEstimator / TestAdmissionCheck / TestContextWindowResolver / TestBackendsAlwaysSendMaxTokens](tests/unit/test_leader_proxy.py) — 30+ tests pinning env parsing, gate logic, estimator across body shapes, OpenAI-compatible error envelope, cache thread-safety, and the always-send-max_tokens backend invariant.

**[engineering] TTFT keepalive emitter writes to `self.wfile` under a lock shared with the main chunk writer** (llm_timeout_scalability.md Stage 3, 2026-06-03). When the leader proxy forwards a streaming LLM response (Content-Type `text/event-stream`) through cloudflared, the tunnel's ~100s idle timeout closes the connection if no bytes flow during the upstream's time-to-first-token window. For 30B+ models on edge hardware (Mac Mini, Jetson, etc.) TTFT can routinely exceed 100s and the proxy doesn't notice until it tries to write the first real chunk and gets EPIPE — symptom is `chunks=1 elapsed_ms=149+` in the proxy log. `runtime/leader_proxy.py::_run_keepalive_emitter` runs in a daemon thread alongside the main `_proxy_request` chunk loop, writing `_KEEPALIVE_CHUNK_FRAME` (an HTTP/1.1 chunked frame carrying `: keepalive\n\n` — valid SSE comment per spec, clients ignore) every `MAXIM_PROXY_KEEPALIVE_INTERVAL_S` (default 30, clamped 5-90). Both the emitter and the main chunk writer target `self.wfile` (a single socket); **concurrent writes without synchronisation interleave bytes mid-frame and corrupt the chunked encoding**, so the emitter MUST acquire `write_lock` (a `threading.Lock` declared at the same scope as `stream` so the `finally` block can stop the emitter on every exit path) around each write. The emitter exits on (a) `stop_event.set()` from the main thread on first real chunk, (b) any write exception (treated as client disconnect — sets the stop event for symmetric cleanup), (c) the `finally` block setting the event during error paths. Only fires when `_is_event_stream_response(resp_headers)` returns True — buffered (`application/json`) responses have no in-band slot for SSE comment frames and would be corrupted by injection. The `stop_event.set()` happens BEFORE acquiring `write_lock` in the chunk loop so the emitter sees the signal during its next `wait()` wakeup; reversing the order would let the emitter park at `with write_lock` and emit one extra keepalive AFTER the first real chunk. Regression guard: [tests/unit/test_leader_proxy.py](tests/unit/test_leader_proxy.py) — `TestKeepaliveChunkFrameFormat` pins the wire format, `TestKeepaliveEmitter` pins emit cadence + stop semantics + write-error handling + lock release.

**[engineering] httpx stream contexts must outlive their consumers:** Calling `ctx = client.stream(...)` then `raw = ctx.__enter__()` opens a live HTTP stream, but Python GC will call `ctx.__exit__()` (which closes the stream) as soon as `ctx` goes out of scope. `raw_proxy_forward_streaming()` in `utils/http.py` originally returned `StreamingResponse(_raw=raw)` without storing `ctx`. The function returning caused `ctx` to fall out of scope; GC closed the stream before `_proxy_request` could call `iter_bytes()` — resulting in `httpx.StreamClosed`, 0 chunks forwarded through Cloudflare, and `JSONDecodeError` on the Mac peer. Every inference call silently returned an empty body. Fix: `StreamingResponse._stream_ctx: Any | None = None` holds the context alive; `close()` calls `_stream_ctx.__exit__(None, None, None)` in cleanup. **Rule: any code that enters an httpx stream context manager manually via `.__enter__()` MUST store a reference to the context manager that lives at least as long as the consumer reading the stream.** The `_stream_ctx` field in `StreamingResponse` is load-bearing — do not set it to `None` or remove it. Regression guard: [src/maxim/utils/http.py](src/maxim/utils/http.py) — `StreamingResponse._stream_ctx` field declaration + `close()` enters the context manager's `__exit__`; structural enforcement via the dataclass shape.

**[behavioral] (borderline: code-correctness frame would say engineering; SEM pain cascade end-to-end pins the fix) `NAc._context_similarity` denominator is `len(ctx1)`, not the key union** (Substrate P2 Stage 2, 2026-04-13): The function is directional. Every in-file caller passes the pending-event / stored-link side as `ctx1` and the outcome / current-query side as `ctx2`. Semantics: "how much of the pending event's context is matched by the outcome context?" Extra keys on the outcome side do NOT dilute the score — they represent cause-description the pending event was not conditioned on. The pre-Stage-2 denominator was `len(ctx1 | ctx2)` (union), which silently broke every `record_outcome_full` caller that passed a rich outcome context (notably `ToolPainBridge._on_embodiment_pain` with 7 keys). Pending events would match at ~0.29 instead of 1.0, fall below the 0.5 `context_similarity_threshold`, and never link. A Stage 2 draft tried to work around this by passing a slim 2-key context from `create_pain_nac_subscriber` only — the architecture review caught it as a band-aid (per the no-band-aid rule above) because the same bug still existed in the bridge. Fix is in `decisions/nac.py::_context_similarity`. Regression guards: `tests/unit/test_nac.py::TestContextSimilarity` (7 tests) + `tests/unit/test_pain_bus.py::TestCreatePainNacSubscriber::test_pain_attributes_to_pending_action_via_context_similarity` + `tests/substrate/test_sem_pain_cascade.py` end-to-end. If you add a new caller that needs symmetric similarity (treats ctx1 and ctx2 as interchangeable), build a separate function — do NOT touch `_context_similarity`'s denominator. Roy experiment: [docs/experiments/p2_sem_pain_cascade.md](docs/experiments/p2_sem_pain_cascade.md) (end-to-end cascade validation on rusty_sword fixture).

**[engineering] Probe outcome → classification lives in ONE place and callers do NOT override the returned message** (Plan 4 C1, 2026-04-14, refined by round 2 review): `maxim.peer.probe_classify.classify_probe_outcome(outcome, detail, latency_ms, url)` is the single source of truth for mapping a `ProbeResult.outcome` literal to a `ProbeClassification(status, message, fix)` frozen dataclass. Every caller that probes a peer URL via `_MaximPeerBackend.for_url(...).health_check()` MUST route its classification through this helper: `peer/mesh_cli.py::_probe_node`, `doctor/checks.py::_probe_mesh_node_to_check`, and any future C2 `--node install` / C3 admin-API dashboard code. Round 1 review caught the same logic already drifting between the two initial call sites (fix-hint wording diverged on the first ship). Round 2 review caught the follow-up footgun: the doctor site was *overriding* the classifier's returned message post-hoc to inject role+url, which silently re-creates the drift the shared helper was supposed to kill. **The rule:** if your caller wants a richer message, pass a richer `detail` parameter through the classifier and let it compose the output — DO NOT mutate the returned `ProbeClassification` fields. `doctor/checks.py::_probe_mesh_node_to_check` passes `detail=f"{node.role}, {node.url}"` for `ok` outcomes so the "reachable" string naturally includes that context. **Specific-before-general ordering is load-bearing (Plan 2 R2c):** `auth_rejected` and `inference_broken` must be classified BEFORE the generic network-down bucket. **`retry_id` and status are orthogonal:** producers set `retry_id` to a stable identity regardless of status; the retry loop in `doctor/cli.py` filters on status to decide whether to re-run. Coupling status and retry_id at the producer was a round 1 fold bug that round 2 review caught. The shared helper lives in its own module (`peer/probe_classify.py`) so callers can use it without pulling the mesh parser layer. Regression guard: [src/maxim/peer/probe_classify.py::classify_probe_outcome](src/maxim/peer/probe_classify.py) — single source of truth; callers route through this function (verified by code search of `ProbeClassification` usage sites).

**[engineering] `mesh.yml` parser dialect is FROZEN** (Plan 4 C1, 2026-04-14): `peer/mesh_config.py::parse_mesh_config` is a deliberately trivial hand-rolled YAML-ish parser — flat top-level `key: value` scalars plus a single nested `nodes:` list of `- name: foo` blocks with indented continuation lines. **DO NOT bolt features onto it.** It rejects tabs, bare `- ` entries, duplicate node names, and strips `#` inline comments ONLY when preceded by whitespace (so `cluster_key: sk-abc#literal` is preserved, not silently truncated — round 2 review E1). If you need quoted strings (URL fragments beyond what the whitespace-# rule handles), YAML anchors, multi-line values, or tab indentation: **do not extend this parser.** The two escape hatches are (a) switch `mesh.yml` to TOML and use stdlib `tomllib`, or (b) promote PyYAML from optional extra to core dep. Either change is a C2/C3 architectural decision, not a drive-by patch. Round 1 review flagged five silent-mis-parse classes the original implementation tolerated; round 2 review flagged a sixth (E1 silent `#` truncation). A seventh finding is highly likely if the dialect grows. Regression guard: [src/maxim/peer/mesh_config.py::parse_mesh_config](src/maxim/peer/mesh_config.py) + corresponding unit tests in [tests/unit/test_mesh_config.py](tests/unit/test_mesh_config.py).

**[engineering] `mesh.yml` is declarative; `~/.maxim/util/` is mutable state** (Plan 4 C2, 2026-04-14, refined by the C2 pre-merge fold round): the two-layer split is load-bearing across the entire Plan 4 Stage C surface. **Every mutable peer/mesh state — drained nodes, rate limit overrides, per-agent quotas, request traces — lives in `~/.maxim/util/{name}.{role}.txt` (or `{name}.{role}.json`)** with (a) `filelock.FileLock` serialization around the full read-modify-write cycle for cross-platform concurrency safety, and (b) `maxim.utils.atomic_io.atomic_write_secret(path, content)` for any file that contains secrets (API keys, cluster keys, bearer tokens). Non-secret operator-visible state — like drain state — uses plain `atomic_write_text`; the function name is the "this contains secrets" signal. **Never pass `preserve_mode=True` to `atomic_write_text` directly** — use `atomic_write_secret` so the intent is visible at the call site (C2 pre-merge review A3 fold: makes the safe path verbose, not the unsafe path). `mesh.yml` stays strictly read-only from every runtime Maxim code path — operators edit it by hand and restart the daemon to reload topology. **Any verb that mutates `mesh.yml` lives in `src/maxim/peer/mesh_setup.py` and routes through `peer/mesh_config.py::write_mesh_config`** — that single file is the sanctioned writer surface, enforced via the CI grep allow-list in `.github/workflows/test.yml` (only `mesh_setup.py` + its test file may call `write_mesh_config`). Adding a new verb requires updating the allow-list AND **first asking whether the state belongs in `~/.maxim/util/` instead**. The default answer for "automatic" or "runtime" or "admin-API-driven" mutations is always `~/.maxim/util/`; the only writes that earn a place in `mesh_setup.py` are operator-explicit one-shot setup verbs the operator invokes consciously from the CLI. As of Plan 4 C3.2 this is `init-mesh` (synthesize from `peer.yml`), `add-node` (append/replace), and `remove-node` (drop + auto-clear drain). The file rename (`init_mesh.py` → `mesh_setup.py`) happened in C3.2 when the file grew from one verb to three. **Do NOT enumerate verbs in this lesson** — the question to ask is "does this belong in `mesh_setup.py`?", not "is this in the list of three?" The friction modes for the strict allow-list are tracked in the `feedback_strict_grep_caller_allowlist.md` memory file; if you see repeated additions in successive PRs, inline reimplementations of `write_mesh_config`, or this lesson drifting from the allow-list, the rule needs revisiting. **Do NOT add automatic runtime / admin-API write paths to `mesh.yml`** — C3's admin API, cluster key rotation, per-agent rate limiting, and request-trace ring buffer all write to `~/.maxim/util/` (the mutable state layer), never to `mesh.yml`. **C3's admin API writes to the mutable state layer, never to `mesh.yml`.** This Kubernetes-style spec-vs-status split means the two layers serve strictly disjoint purposes and need NO reconciliation contract; `mesh.yml::drain` is NOT a field, drain lives exclusively in `~/.maxim/util/drained_nodes.{role}.txt`. The C2 design pass that landed this rule folded four cross-confirmed findings (role detection timing, read/write race, orphan validation, permission preservation) into a single architectural invariant. When you add a new mutable mesh surface (C3 cluster key rotation, per-agent rate limits, request trace ring buffer): (1) put it in `~/.maxim/util/`, (2) role-scope the filename via `MAXIM_ROLE`, (3) wrap the RMW in a `filelock.FileLock` from the third-party `filelock` package, (4) use `atomic_write_secret` for credential-bearing files and plain `atomic_write_text` for everything else, (5) validate against `mesh.yml`'s node set at write time and surface orphans as warnings (not fails) at read time. **Invariants enforced at the state layer, not the CLI layer** — the self-drain guard and orphan validation in `drain_state.drain_node` are mandatory so future C3 admin-API writers can't bypass them by calling `drain_node` directly (C2 pre-merge review A2 fold). **`_role()` raises `DrainError` on unexpected `MAXIM_ROLE` values** — silent fallback to `"leader"` was a band-aid per the no-band-aid rule; genuinely-absent env var still defaults to `"leader"` for test isolation (C2 pre-merge review A1 fold). **Note on locking:** Maxim currently has two advisory-file-lock abstractions (`maxim.utils.process_lock` for the model-download path, `filelock.FileLock` for drain state). Unification is tracked in `docs/plans/cross_platform_file_lock.md` as a shell plan — revisit after C3 lands to consolidate. **If you find yourself wanting a `mesh.yml::<mutable-field>` for the convenience of "operator committed their intent," stop — you're about to introduce a two-source-of-truth reconciliation problem. Put it in `~/.maxim/util/` and call `maxim peer list-<thing>` to surface it.** CC2 in the C1 pre-merge review + A1R2 + A3R2 in the C2 pre-design review + A1/A2/A3/CCR1/J1 in the C2 pre-merge fold round all independently cross-confirmed this rule. Regression guard: CI grep allow-list in [.github/workflows/test.yml](.github/workflows/test.yml) restricts callers of `write_mesh_config` to `mesh_setup.py` + its test file; new callers fail CI.

**[behavioral] (borderline: code-correctness frame would say engineering; SEM execution hook validates downstream learning restoration) Context-similarity attribution is the wrong mechanism when a direct lookup key exists** (SEM execution hook Stage 1, 2026-04-14): the pre-fix `ToolPainBridge._on_embodiment_pain` tried to attribute tool-invoked embodiment pain via `nac.record_outcome_full`'s context-similarity path. The pending tool event's context at `record_tool_start` was `{"params": {...}}` (1 key). The outcome context from `body.py::_publish_pain` was `{source, entity, entity_type, failure_mode, composes, intensity, sensor_readings}` (7 keys, zero overlap). Per the P2 Stage 2 directional-denominator rule (`len(ctx1)` on the pending side), similarity = `0 / 1` = 0.0, below the 0.5 threshold. Every tool-invoked SEM affordance silently failed to produce NAc learning. The fix is NOT a context-similarity tweak — it's architectural: when a direct lookup key exists (`(tool_name, invocation_id)` from the executor's pending map), use it. `ToolPainBridge.record_tool_embodiment_failure` pops the pending entry by `(tool_name, invocation_id)` and calls `nac.record_outcome` (not `record_outcome_full`) with NEGATIVE valence. The executor routes to this method when `result.side_effects["embodiment_failures"]` is non-empty. `_on_embodiment_pain` guards on `bool(self._pending_tools)` and short-circuits while any tool is in flight to prevent double-recording. **Rule:** context similarity is the *fallback* for out-of-band attribution (autonomous SEM ticks with no pending tool); it is NEVER the right mechanism when you have a direct lookup key. If a new code path wants to use `record_outcome_full` with context similarity, ask first whether a direct key is available and prefer `record_outcome`. Roy experiment: [docs/experiments/p2_sem_pain_cascade.md](docs/experiments/p2_sem_pain_cascade.md) (validates tool-invoked SEM affordance learning end-to-end via direct-attribution path).

**[engineering] PainBus is the permanent rich-context carrier; ReactionBus is the typed isolation surface** (Substrate P2 Stage 2, 2026-04-13): The two buses coexist by design and serve different audiences. `Reaction` / `ReactionContext` enforces the typed isolation rules in `reactions/types.py` docstring (no cross-agent intent, no private state, no scenario oracles, no learned-policy hints) — its `bindings: dict[str, TraceSnapshot]` is deliberately strict. `PainSignal.context: dict[str, Any]` is the rich free-form carrier for bio-pipeline-internal **cause-description** metadata (`source`, `entity`, `entity_type`, `failure_mode`, `composes`, `sensor_readings`) that feeds NAc causal learning but is NOT a learning hint in the isolation-rule sense. Bio-internal publishers (`body.py`, `sandbox.py`, tool-failure bridges) call `PainBus.publish(PainSignal(...))` — this dispatches to direct PainSignal subscribers with full context AND forwards the converted Reaction to `reaction_bus` for typed subscribers (lossy, by design). Pre-Stage-2 this module was labeled "backward-compatible, Phase 2b deprecation planned"; the rewrite makes PainBus load-bearing. Do NOT route rich cause-description through `ReactionContext.bindings` — that violates the isolation docstring. Do NOT re-add a `ContextVar`-based signal stash as a back channel (the original Stage 2 draft used one; pre-merge review flagged it as a re-entrancy hazard under `@resilient` retry and async contexts). PainBus has its own `(entity, failure_mode)` refractory gate distinct from reaction_bus's coarser `(kind, source)` gate — this is intentional: `pain_signal_to_reaction` synthesizes `source` from `pain_type` alone, so without the finer PainBus gate, two distinct entities firing embodiment pain in the same tick would silently collapse into one dispatch. Any new call site that subscribes via `PainBus.subscribe` receives the full `signal.context`; code that wants the strict typed view subscribes to `self.reaction_bus.subscribe("pain", ...)` directly. Reference: `proprioception/pain_bus.py` rewritten module docstring. Regression guard: [src/maxim/proprioception/pain_bus.py](src/maxim/proprioception/pain_bus.py) (module docstring + dual-dispatch implementation) + [tests/unit/test_pain_bus.py](tests/unit/test_pain_bus.py).

**[engineering] `utils/optional_deps.py` is the canonical optional-dependency surface** (2026-06-05, PR #341): historically each consumer of an optional package rolled its own `try: import X except ImportError: …` block with wildly inconsistent failure behaviour — some raised, some swallowed a WARNING, some failed silently. The canonical failure was `_AnthropicBackend._ensure_client` logging one WARNING and returning `None`, causing the router to treat the missing backbone as a transient hiccup; the whole sim completed at `cost=$0` with every action a `_llm_unavailable` fallback and no user-visible error. `maxim.utils.optional_deps` makes failure behaviour uniform and intentional through three entry points, each matching a specific intent: (1) **`require_optional_dependency(import_name, *, extra=None, feature=None)`** — use when the caller EXPLICITLY requested a feature (a configured cloud LLM profile, `--vision`, a selected TTS voice, a robot controller). Raises `OptionalDependencyError` (subclass of `ImportError`) with an actionable `pip install pymaxim[<extra>]` hint. The dedicated subclass lets fail-soft layers (the LLM router) distinguish "requested feature not installed → abort loudly" from "transient runtime failure → try next provider". (2) **`optional_dependency_available(import_name)`** — use for capability PROBES where absence is normal (GPU detection, "use X if present"). Returns a `bool`, never logs. (3) **`warn_optional_fallback(import_name, *, fallback, extra=None, feature=None, log=None)`** — use when there is a real working fallback (`sentence-transformers` → bag-of-words, `spacy` → identity decomposition, `ddgs` → regex scraping). Emits exactly ONE deduped WARNING per `(import_name, feature)` pair per process via `_warned_fallbacks`; the caller keeps the fallback path. **Do NOT add a new `try: import X except ImportError: pass` (silent), `except ImportError: logger.warning(...)` (non-deduped), or `except ImportError: return None` (swallowed) anywhere in `src/maxim/`.** Route through the appropriate entry point. The `EXTRA_FOR_IMPORT` dict in `optional_deps.py` maps import names to pyproject extras — add new entries there when adding new optional features rather than hardcoding hints at call sites. `OptionalDependencyError` access patterns are exactly three: `.import_name`, `.extra`, `.fix_hint`. Do NOT add `.raw_cause` or parallel attributes — the router branches on the dedicated type, not on message content. Regression guard: [tests/unit/test_optional_deps.py](tests/unit/test_optional_deps.py) — covers `require_optional_dependency` raise/return, `optional_dependency_available` bool, `warn_optional_fallback` dedup, `OptionalDependencyError` subclass shape, and LLM-router reraise behaviour.

## Working principles for new mechanisms

These five principles govern HOW new architectural commitments enter the codebase. They are upstream of the "Lessons learned" and "Architectural invariants" sections — apply them when *adding* to those sections, not just when reading them.

- **Two-tier invariant tracking.** Tag each new CLAUDE.md invariant in brackets: `[engineering]` (code breaks loudly without it — e.g., `build_pain_bus` required args, `_MaximPeerBackend` one-HTTP-call rule, `_format_version` contract) or `[behavioral]` (empirically validated via Roy or equivalent as carrying measurable behavioral weight — e.g., EC default threshold tracking NAc fallback per Roy-5 H1C). **New mechanisms enter as `[engineering]` only.** They graduate to `[behavioral]` when an experiment earns them. Bio-inspired naming (Hippocampus, NAc, EC, SCN) is load-bearing for the mental model but does NOT count as behavioral validation — calling a module Hippocampus doesn't validate it does what a hippocampus does. Retroactive tagging happens naturally as invariants are touched; no sweeping audit pass needed. **Graduation tracking lives in [docs/plans/behavioral_graduation_candidates.md](docs/plans/behavioral_graduation_candidates.md)** — pairs alongside benchmarking as one of two 1.0 gates AND persists post-1.0 as the behavioral analog to this Architectural invariants section: ongoing regression discipline so major changes (encoder swaps, bio-system refactors, substrate-pipeline edits) don't silently break previously-Earned behavioral claims. Tier 1 entries there are the thesis-load-bearing bio-claims that must reach `Earned` before 1.0 ships; Tier 3 entries are the middle subset that graduate-or-downgrade per checkpoint. Earned entries carry **Re-run on:** trigger fields + **Regression guard:** experiment paths so re-validation is grep-able. `Stale` and `Broken` entries block the next release.

- **Dormancy over deletion.** When a mechanism fails to earn behavioral weight — a Roy experiment cancels its planned direction (Roy-4 → cross-modal binding), an audit finds it isn't carrying its claimed load — mark it `Dormant since <date>: <reason>` in its module docstring rather than deleting. Code stays wired, callers intact, no cascade-deletion risk. But: no new features build on top, no new invariants accrue around it, tests beyond regression are not extended. Resurrection requires a new experiment that earns the mechanism's behavioral weight, not just "we have time now." This codebase is intimately wired by design — whim-deletion historically caused secondary problems (wires unintentionally deleted, functionality removed because a sibling system was adjusted). Dormancy is the middle path between deletion-cascade and monotonic accumulation.

- **Front-gate scope pressure at design time.** Before drafting any implementation plan for a new mechanism (bus, bridge, bio-system, annotation Wire, gating layer, builder function), force the question: *"Does this need to be its own mechanism, or can it ride on existing infrastructure?"* If yes-it-needs-to-be-its-own, name the specific reason in the plan doc's motivation section ("existing infrastructure X cannot do this because Y"). If can-ride-on-existing, choose that path even when less architecturally elegant. This is the planning-stage parallel to the existing "audit before building" rule — the front-gate, before the audit even starts. The bar to ship a mechanism is currently "two-lens review + Roy validates the narrow phenomenon," which doesn't ask whether the mechanism should exist as a separate thing.

- **Cycle convergence vs divergence.** Cyclic Roy findings are signal, not noise — the harness is designed to surface them. But distinguish two kinds: **convergence** (Roy finds the *same kind* of issue repeatedly, each iteration narrows it — mechanism is settling, continue the cycle) vs **divergence** (Roy finds *new failure modes* each iteration — mechanism is getting more complex faster than stabilizing). **Divergence is the cue to step back to a bird's-eye view and bisect for outside causes**, not patch at the mechanism layer. Canonical example: the Roy-3c bisect — working hypothesis was that Wire merges caused the priming regression; bisect found two outside causes simultaneously (non-code encoder drift + Wire-A's intentional decay), not the Wires themselves. **Two divergence iterations in a row** is the explicit trigger: stop iterating on the mechanism and run a bird's-eye audit asking "what else changed?", "what's the actual independent variable?", "is the mechanism the cause or the messenger?", "have any non-code dependencies moved (encoder model, library version, env var defaults)?" **Post-hoc-finding handling (refinement from [docs/experiments/31_roy_divergence_audit.md](docs/experiments/31_roy_divergence_audit.md)):** findings that surface from "post-result investigation" rather than from the iteration's planned measurements do NOT directly count as in-cycle divergence signals — they spawn new pre-registered iterations (the canonical response is Roy-3a-retry's registry-gap + imagination-blindness findings spawning [sense_tool_registry.md](docs/plans/sense_tool_registry.md) + [imagination_substrate_signals.md](docs/plans/imagination_substrate_signals.md)). The trigger then watches whether those follow-up iterations behave convergently or diverge further. This preserves pre-registration discipline (a deliberately narrow pre-registration shouldn't *silence* divergence signals — but neither should every debugging observation count). The sharper trigger formulation when post-hoc findings ARE present: **two iterations in a row whose primary criterion fails AND whose post-hoc findings each spawn new follow-up plans** captures "mechanism getting more complex faster than stabilizing" more directly than the bare "new failure modes per iteration" wording. Both forms of the trigger remain valid; the post-hoc form fires when the bare form would be ambiguous.

- **Regression-guard / experiment citation per invariant.** Every `[engineering]` invariant in the "Lessons learned" + "Architectural invariants" sections declares a `Regression guard: <path>` line at the end of its body. Every `[behavioral]` invariant declares a `Roy experiment: <path>` line at the end of its body. Valid `Regression guard:` references include: a test path (`tests/unit/test_*.py::Test*`), a CI grep pattern (`grep -nE "..." in <workflow>`), or a co-located source file that structurally enforces the rule (a typed constructor signature, a frozen dataclass, an `@abstractmethod`). Valid `Roy experiment:` references point at a [docs/experiments/](docs/experiments/) doc, a Roy harness arm, or an equivalent measurement artifact. **A missing line is a visible coverage gap by design** — surfacing the absence is the discipline's value, not a defect. Over time, missing-guard invariants either earn a guard (someone writes the test, defines the grep, or adds a CI lint), get rewritten to align with what IS testable, or get retired for not being load-bearing enough. The [graduation-candidates doc](docs/plans/behavioral_graduation_candidates.md) uses the same format on its Earned entries — a single grep audits both surfaces. CI lint enforcement of this convention is tracked separately (Phase C of the regression-guard work); the convention itself stands without lint enforcement.

## Running simulations — keep them small

Simulations call a live LLM for every turn and can burn cost + time quickly. When running sims from this CLI (for diagnostics, verification, or debugging):

- **IMPORTANT: Use `--interactive false` when running sims from Claude Code or scripts.** Interactive mode is ON by default in CLI with a TTY (0.3.2). The raw terminal reader conflicts with non-human stdin. Always pass `--interactive false` for automated/scripted sim runs.
- **Set a narrow goal.** `--goal "test X specifically"` beats `--goal "test safety"` — specific goals converge faster.
- **Cap duration.** Hit Ctrl+C after 30–90 seconds when you've seen what you need. Sims report partial results on cancel.
- **Prefer --sandbox tmpdir for debugging** unless you're specifically testing Docker — tmpdir has no pull/startup cost.
- **Use --debug sparingly.** The verbose-trace output is great for diagnosing stalls but floods the terminal for routine runs.
- **Don't invoke sims from test suites** unless the test is specifically for sim machinery. The sim runner spins up real LLM calls and can 2-3x test-suite runtime.
- **Re-use sessions with --resume-sim SESSION_ID** to avoid re-running setup + warm-up costs when iterating on a specific run.
- **Local models > Claude for loop-testing.** Use `--language-model mistral-7b` for sanity checks; save Claude for verifying final behavior.
- **Watch for Cost:** in the final report. $0.05–$0.15 per short run is normal; $0.50+ for a single debug session suggests the sim is too broad or too long.

## Architectural invariants (do not break without discussion)

- **[engineering] Memory tier progression is one-way**: FORMING → SHORT_TERM → LONG_TERM. Don't skip or reverse. WORKING is not a tier — it's an Exec-owned `WorkingMemorySet` (active reference layer). See `agents/working_memory.py`. Regression guard: [src/maxim/agents/bus.py](src/maxim/agents/bus.py) — `TierTransitionError` raised on skip/reverse attempts; tier-progression code structurally enforces direction.
- **[engineering] Hippocampus, NAc, and ATL maintain SEPARATE EpisodicMemory instances** — this is intentional coexistence, not tech debt. Don't merge. Regression guard: [src/maxim/integration/memory_hub.py::build_memory_hub](src/maxim/integration/memory_hub.py) — each bio-system takes its own EpisodicMemory instance via constructor params.
- **[engineering] Tool results flow through the agent bus**; don't call agents directly from tools. Regression guard: convention — enforced by reviewer attention; [src/maxim/runtime/executor.py](src/maxim/runtime/executor.py) is the canonical dispatch site.
- **[engineering] Persistence uses `maxim.utils.atomic_io.atomic_write_json`** (fsync + tmp cleanup). Don't hand-roll `open().write()` + `os.replace()`. Regression guard: [src/maxim/utils/atomic_io.py](src/maxim/utils/atomic_io.py) is the canonical writer; ad-hoc `grep -rn "os.replace" src/maxim/ | grep -v atomic_io.py` surfaces violations.
- **[engineering] Frozen dataclasses shipping in 1.0 are forward-compat-audited** (CC3, 2026-04-28). Every `@dataclass(frozen=True)` shipping in 1.0 must declare in its class docstring which of two paths it takes — picking is mandatory before merge for any new frozen dataclass:
  - **(a) Escape-hatch dataclass** — all fields have sensible defaults *and* an `extra: dict[str, Any] = field(default_factory=dict, hash=False, compare=False)` field. The `hash=False, compare=False` is load-bearing: a `dict` field on a `frozen=True` dataclass otherwise breaks `__hash__` and inflates `__eq__` with mutable observability data. `extra` values **must be JSON-serializable** (str/int/float/bool/None + nested list/dict only) — `numpy.ndarray`/`datetime`/objects raise at persistence time. Producers prefer declared fields; `extra` is for genuinely additive metadata only. **The dataclass `__post_init__` must reject extra keys that collide with declared field names** (per the post-implementation Architecture #4 fold for `LaneTierConfig` — the constructor's collision check pushes the invariant into the type so a malformed dataclass cannot reach the writer). Current: `Episode`, `SensorReading`, `LaneTierConfig`, `ProviderPlacement`, `LaneTierPlacement`.
  - **(b) Shape-frozen dataclass** — the docstring carries an explicit `SHAPE-FROZEN at 1.0 (CC3)` marker explaining why an `extra` hatch was rejected (typed contract that an `extra` dict would dilute, isolation-hygiene rule that an `extra` dict would re-open, parser-frozen wire format, etc.). Adding a *required* field post-1.0 is a major-version-bump change. Adding an optional field at the end with a default is non-breaking but still requires the review gate the docstring describes. Current: `Reaction`, `ReactionContext`, `TraceSnapshot`, `PerceptContext`, `CouplingSpec`, `ModulationSpec`, `HomeostaticDriveSpec`, `EntropicDriveSpec`, `TemporalSignature`, `ValenceSignal`, `MeshNode`, `MeshConfig`, `ModelPricing`, `VRAMProjection`, `MaximConfig`, `LLMConfigSection`, `LanesConfigSection`, `CloudConfigSection`, `ProxyConfigSection`, `AutoSpawnConfigSection`, `DataConfigSection`, `SimConfigSection`. (`TraceSnapshot` is shape-frozen because it's reachable from `ReactionContext.bindings` — an `extra` dict on it would re-open the same isolation back-channel `ReactionContext` itself closes.)
  - **Typed exception hierarchies** (`models/language/types.py::BackendError`, `utils/http.py::HTTPError`) follow the same spirit but different mechanics: they are not dataclasses, they have explicit keyword-only `__init__` signatures per subclass. Adding `**kwargs`/`extra` re-opens the silent-typo class of bugs that `BackendError` Fix #8 closed (e.g., `retry_after=2.5` instead of `retry_after_s=2.5`). New keyword-only parameters with defaults may be added per subclass; new subclasses require the matching `_classify_status` / `_try_provider` branch in specific-before-general order.
  - **Out of scope for the audit:** runtime-ephemeral configuration dataclasses (`HippocampusConfig`, `RetrievalConfig`, `NACConfig`, ...) that never cross a wire or a session boundary. Adding fields with defaults at the end is the standard non-breaking path. Persisted *and* wire-crossing types are the audit surface.
  - **Regression guard:** CC3 audit list + the `SHAPE-FROZEN at 1.0 (CC3)` docstring marker on each frozen-without-extra dataclass; new frozen dataclasses must pick path (a) or (b) before merge.
- **[engineering] `_format_version` is the persistence-format contract for every JSON file Maxim writes** (v1.0 freeze, CC1, 2026-04-28). Every persisted JSON file carries `"_format_version": "1.0"` at root. Writers wrap their payload via `from maxim.utils.format_version import with_format_version; atomic_write_json(path, with_format_version(payload))`. Loaders call `check_format_version(data, "<file_type>", log=logger)` after `json.load(f)`; missing field returns the `"0.x"` sentinel and emits ONE warning per `<file_type>` per process — older files load successfully with a surfaced version drift, never silently fail. Adding the field post-1.0 is itself a breaking change (new code writes the field, old code with strict validators rejects unknown keys), which is why this is freeze-critical. Two conventions coexist by design: the envelope-layer `schema_version` (int, snapshot.py) is the migration trigger for the six envelope-conformant bio-systems; `_format_version` (string) is the broader contract every file carries. Bio-system files written via direct `save(path)` carry BOTH at root — the envelope `schema_version` lives at the JSON root alongside `_format_version`. List-rooted files (File*Store, last_run, web_cache, probe_cache) are wrapped in a thin `{kind, items}` (or `{entries: ...}`, `{tools: ...}`, `{sessions: ...}`) dict so the version field has a slot — loaders accept both v1.0 dict and pre-1.0 list. Do NOT bump the legacy payload-layer `"version": "1.0"` strings (ATL, NAc, SCN, etc.) — those are tombstoned per snapshot.py; bump `_format_version` here when the file shape changes, or bump the envelope `schema_version` and register a migration. Regression guard: [tests/integration/test_persistence_compat.py](tests/integration/test_persistence_compat.py).
- **[engineering] LLM access goes through `models/language/router.py`**; backends (anthropic/llama/openai/transformers/**maxim_peer**) should not be imported directly from outside `models/language/`. Self-hosted peer routes go through `_MaximPeerBackend`; cloud routes stay on `_OpenAIBackend`. Selection is driven by `runtime/lane_backends.BACKEND_CLASSES` + `resolve_backend_class` — adding a new backend type is exactly one line in the dispatch table + one branch in `_classify_backend`, no router edit. The `"maxim_peer"` / `"maxim-peer"` spelling is normalised by `resolve_backend_class`. Regression guard: [src/maxim/runtime/lane_backends.py::BACKEND_CLASSES](src/maxim/runtime/lane_backends.py) (single dispatch table); ad-hoc grep of `from maxim.models.language.<backend>` outside `models/language/` flags violations.
- **[engineering] The system prompt MUST be byte-stable across turns within a session** (prompt-caching prerequisite, prompt_caching_for_cloud_backends.md, 2026-06-08). `PromptBuilder.build_prompt` emits `TOOL_PROMPT|<stable_prefix>\x1e<dynamic_remainder>` (delimiter `PROMPT_SEGMENT_DELIMITER`); `LLMRouter._generate_tool_response` routes the stable prefix into the `system` message (appended after `SYSTEM_TOOL_RESPONSE`, so the whole system block ends at the cloud backends' `cache_control` breakpoint) and the dynamic remainder into the `user` message. A section is in the stable prefix iff it calls `budgeter.add(..., cacheable=True)` — reserved for genuinely session/phase-stable content (identity, instructions, tool guidance, foundational, entity_context, tools[phase-scoped]). **Any byte change in the stable prefix between turns invalidates the cache and re-spends ITPM on the full prefix**, so per-turn-dynamic content (drive states/`body_state`, datetime, memories, bio annotations like Wire-A/W1, observations, conversation) MUST NOT be tagged cacheable — it belongs in the dynamic remainder. `PromptBudgeter.build_segmented()` budgets the stable sections first, independent of dynamic content (capped at `_STABLE_BUDGET_FRACTION`=0.7 of budget), so truncation of dynamic sections cannot perturb the prefix. `prompt_cache: true` on a profile (Anthropic: real `cache_control` marker; OpenAI: gates the `openai_cache` measurement event, caching is automatic server-side) flows profile → `LLMConfig.prompt_cache` → synthesized provider entry / backend `_prompt_cache_enabled()` fallback. When adding a new prompt section, default to dynamic (no `cacheable=`); only tag `cacheable=True` after confirming it cannot vary within a session. Measured win on cradle/Sonnet: ~38% ITPM reduction (the prefix is the small stable fraction, NOT a 6× win — see plan Results). Regression guard: [tests/integration/test_prompt_caching.py::test_system_prompt_byte_stable_across_turns](tests/integration/test_prompt_caching.py) + [tests/integration/test_prompt_builder_audit.py](tests/integration/test_prompt_builder_audit.py) (segment-level byte-stability) + [tests/unit/test_prompt_budgeter.py::TestBuildSegmented](tests/unit/test_prompt_budgeter.py).
- **[engineering] `_MaximPeerBackend.complete_with_usage()` makes EXACTLY one HTTP call.** See the Plan 3 lesson above. The CI grep `grep -nE "retry|backoff|gateway" src/maxim/models/language/maxim_peer_backend.py | grep -vE "retry_after_s|retry_timeout_s"` enforces this. Failover is the router's job, not the backend's. Regression guard: CI grep above, enforced in [.github/workflows/test.yml](.github/workflows/test.yml).
- **[engineering] `_MaximPeerBackend.for_url(url, api_key=k, model=m).health_check()` is the canonical probe entry point** (Plan 3 R2.6). `probe_llm_server` / `llm_server_responding_at` / `_probe_once` still exist as DEPRECATED thin compat shims; the CI grep allow-list in `test.yml` blocks new callers. Do NOT add new usages of the shims. Regression guard: CI grep allow-list in [.github/workflows/test.yml](.github/workflows/test.yml) (4 grandfathered call sites; new matches fail CI).
- **[engineering] `_MaximPeerBackend.for_url` is concurrency-safe via instance-level `_api_key_override`** — it does NOT mutate `os.environ`. If you add a new backend factory that needs to accept an override, store it on the instance, never on process-global state. Regression guard: [src/maxim/models/language/maxim_peer_backend.py::_MaximPeerBackend.for_url](src/maxim/models/language/maxim_peer_backend.py) — `_api_key_override` is an instance attribute, not a module-level variable; pre-merge review caught the env-var race and pinned the fix.
- **[engineering] Peer transports are typed per purpose, not generic** (C10 prep, 2026-06-04, transport ships in 1.1). [`_MaximPeerBackend`](src/maxim/models/language/maxim_peer_backend.py) is the LLM-inference transport — the "exactly one HTTP call, no retry, typed `BackendError`" rule is its load-bearing invariant earned via the 2026-04-12 fail-slow incident. Future peer transports (perception in 1.1 per [docs/plans/mesh_perception_transport.md](docs/plans/mesh_perception_transport.md), substrate bundle in 1.1+ per [docs/plans/maxim_hivemind.md](docs/plans/maxim_hivemind.md), any further sibling) live in their own file **next to their domain** — LLM at `src/maxim/models/language/maxim_peer_backend.py`, substrate-bundle transport will land next to [`src/maxim/hivemind/bundle.py`](src/maxim/hivemind/bundle.py) (e.g. `hivemind/peer_transport.py`), perception transport will land next to the percept surface (e.g. `mesh/perception_transport.py` or `agents/remote_percept_source.py`'s sibling). The path-by-domain placement keeps each transport co-located with the surface it serves; do NOT force a `models/<modality>/` convention that fights existing module layout. What every transport DOES share is the playbook: single-purpose backend, typed exception hierarchy with `.fix_hint`, no internal retry, `for_url(api_key=k)` factory pattern with instance-level `_api_key_override` (no `os.environ` mutation), `health_check()` as the canonical probe entry point. Do NOT extend `_MaximPeerBackend` to carry non-LLM payloads — the failover semantics differ (router-handles-retry is LLM-specific; a dropped perception frame is not a router-failover event, it's a missed observation that the agent loop tolerates by reading the next one) and the load-bearing one-call invariant becomes ambiguous when stretched across purposes. The Hivemind substrate-bundle exchange is the second-consumer test: it lands as a sibling file, not as an extension of either existing transport, so the typed-transport-per-purpose pattern is proven by parallel use. Regression guard: the existing CI grep on `maxim_peer_backend.py` (enforcing the one-HTTP-call rule via `grep -nE "retry|backoff|gateway"`) is the template the next transport copies; any new transport class adds its own CI grep allow-list entry on the same pattern so cross-transport leakage stays out.
- **[engineering] `Percept` wire format is distinct from session-persistence format** (C10 prep, 2026-06-04, transport ships in 1.1). [`Percept.to_wire_dict / from_wire_dict`](src/maxim/agents/bus.py) is the cross-process transport contract a network-backed [`PerceptSource`](src/maxim/simulation/sources.py) implementation ships through the perception transport; [`Percept.to_dict / from_dict`](src/maxim/agents/bus.py) is the session-persistence contract on the leader. Both paths are versioned independently under the [`_format_version`](src/maxim/utils/format_version.py) contract — bump the version on the wire-dict path when its shape changes, independently of the session-dict path. **`embedding` and `substrate_node_id` are NEVER on the wire.** The leader owns the substrate (EC, ATL, LinguisticEncoder); the peer ships raw observations. Bio-fidelity argument: the peer is "a sensor," not "a partial cognition." Leader-derived signals (`salience`, `novelty`) and leader-internal runtime state (`maxim_runtime`) are also wire-excluded — the peer cannot compute them without the leader's substrate. Network-backed `PerceptSource` implementations are **non-blocking-by-design**: `next_percept()` MUST NOT make a synchronous network call — it reads from a local in-process inbox populated by a separate transport-layer thread or async handler that consumes `PERCEPT_PUSH` envelopes from the perception transport. A blocking implementation would re-introduce the per-tick HTTP-cost class of bug the Plan 3 R2.5 single-call invariant on `_MaximPeerBackend` was earned to close. Any future revision proposing a blocking `PerceptSource` variant adds a parallel Protocol; the existing one does not change (adding a required member breaks every third-party adapter). Regression guard: [tests/unit/test_percept_wire_format.py](tests/unit/test_percept_wire_format.py) pins the wire-dict whitelist + the substrate-fields-never-on-wire invariant + the explicit wire/session divergence + [tests/unit/test_percept_source_protocol.py](tests/unit/test_percept_source_protocol.py) pins `isinstance(stub_RemotePerceptSource, PerceptSource)` so a Protocol-shape change that breaks the 1.1 adapter fails here.
- **[engineering] The WorkerPool is owned by LLMWorker**, which shuts it down on `stop()`. Don't create a parallel pool. Regression guard: [src/maxim/models/language/llm_worker.py](src/maxim/models/language/llm_worker.py) — `WorkerPool` instance is owned + lifecycle-managed by `LLMWorker`; review attention catches parallel-pool constructions.
- **[engineering] `@resilient` decorator (runtime/resilient.py) wraps any callback that can fail** — use it instead of bare `except Exception: pass`. Regression guard: [src/maxim/runtime/resilient.py](src/maxim/runtime/resilient.py); ad-hoc `grep -rn "except Exception:\s*$\|except Exception:\s*pass" src/maxim/` surfaces violations during review.
- **[engineering] `RequestContext` + `contextvars.ContextVar` is the multi-agent contract.** Set at the request boundary (agent loop, sim orchestrator), read automatically by `maxim/utils/http.py::_build_headers` to populate `X-Maxim-*` outbound headers on internal endpoints. Callers don't thread the context through function signatures — set the contextvar, read at the leaves. The `HTTPEndpoint.internal: bool` flag gates which endpoints propagate X-Maxim-* — third-party URLs (HuggingFace, DuckDuckGo, tool fetches) use `internal=False` so request IDs don't leak. When adding a new cluster-internal endpoint, set `internal=True`; when adding an external one, set `internal=False`. Regression guard: [src/maxim/utils/http.py::_build_headers](src/maxim/utils/http.py) — propagation gated by `HTTPEndpoint.internal` flag at endpoint registration.
- **[engineering] HTTP errors are typed, not string-matched.** `maxim/utils/http.py` defines `HTTPError` + subclasses (`HTTPTimeout`, `HTTPConnectionError`, `HTTPAuthError`, `HTTPServerError`, `HTTPClientError`, `HTTPRateLimited`) with `.status` + `.fix_hint`. Callers branch on these instead of parsing exception messages. **Plan 2 R2b SHIPPED** the parallel `BackendError` hierarchy in `models/language/types.py` (`.status`, `.response`, `.fix_hint` — same three access patterns, no `raw_body` or parallel attributes). **Plan 3 R2.5 SHIPPED** the router bridge in `LLMRouter._try_provider` that catches each subclass in specific-before-general order. Backends convert HTTP errors to Backend errors via one-line `except HTTPRateLimited as e: raise BackendOverloaded(...) from e` pairs; the router branches on the typed Backend exceptions. Do NOT introduce a parallel exception type — extend the existing hierarchy in `types.py` + add the corresponding router branch in specific-before-general order. Order violation is the same class of bug the R2c stage-2 probe review round caught (auth mis-classified as inference_broken). `INFERENCE_BROKEN_BACKOFF_S = 15.0` is the single source of truth linking router backoff to probe cache TTL — import, don't duplicate. Regression guard: [src/maxim/utils/http.py](src/maxim/utils/http.py) + [src/maxim/models/language/types.py](src/maxim/models/language/types.py) (typed hierarchies) + `LLMRouter._try_provider` specific-before-general catch order in [src/maxim/models/language/router.py](src/maxim/models/language/router.py).
- **[engineering] `raw_proxy_forward` and `raw_proxy_forward_streaming` are both reserved for `leader_proxy._proxy_request` ONLY.** Do not call either from other modules. `raw_proxy_forward_streaming` returns a `StreamingResponse` whose `_stream_ctx` field keeps the underlying httpx stream context alive until `close()` is called — see the "httpx stream contexts must outlive their consumers" lesson above. Regression guard: ad-hoc `grep -rn "raw_proxy_forward" src/maxim/ | grep -v "leader_proxy.py\|utils/http.py"` must return zero matches.
- **[engineering] Tool-invoked embodiment pain attributes directly, not via context similarity.** When a tool in flight produces SEM failures (`ModulatorAffordanceTool.execute` populates `ToolOutput.side_effects["embodiment_failures"]`), `runtime/executor.py` routes the post-execute call to `ToolPainBridge.record_tool_embodiment_failure(tool_name, invocation_id, failures)` which calls `nac.record_outcome` with a direct event_id — NO context similarity. `ToolPainBridge._on_embodiment_pain` guards on `bool(self._pending_tools)` and early-returns while any tool is in flight to avoid double-recording and to avoid the broken context-similarity path that the pre-fix code silently failed through (pending event `{"params":...}` vs rich outcome `{source, entity, failure_mode, ...}` had zero key overlap → similarity 0.0 → no link). The guard assumes serialized single-executor semantics (one `Executor` + one `ToolPainBridge` per agent instance); if a future refactor shares a bridge across concurrent in-flight tools, narrow the guard by matching on `signal.context["entity"]` against `_pending_contexts`. See [src/maxim/bridges/tool_pain_bridge.py::record_tool_embodiment_failure](src/maxim/bridges/tool_pain_bridge.py) and the ToolOutput.side_effects docstring in [src/maxim/tools/base.py](src/maxim/tools/base.py). Regression guard: [tests/substrate/test_sem_pain_cascade.py](tests/substrate/test_sem_pain_cascade.py) (end-to-end direct-attribution path validated).
- **[engineering] `ToolOutput.side_effects` is the typed channel for bio-pipeline signals.** A `dict[str, Any] | None` field on `ToolOutput`. The append-only registry of well-known keys lives at [docs/user/tool_side_effects.md](docs/user/tool_side_effects.md) — that page is the authoritative source third-party tool authors read. Current keys: `embodiment_failures` (list of SEM failure event dicts), `entity_acquired` / `entity_released` (entity name str — Mechanism B contact sensation), `affordance_blocked` (precondition-fail metadata, informational — no internal consumer, used by replay tooling and external observers). Add new keys via PR that updates the registry table AND wires the consumer (or marks the key `informational` per the doc's rule #4). Do NOT hijack `metadata` (caller-facing extras) or `output` (main result) — those serve different audiences and collapsing them silently couples the tools layer to bio concepts. Regression guard: [src/maxim/tools/base.py::ToolOutput](src/maxim/tools/base.py) (dataclass + docstring) + [docs/user/tool_side_effects.md](docs/user/tool_side_effects.md) (key registry).
- **[engineering] `Tool.cancel()` is a non-abstract no-op on the Tool ABC** (CC11, v1.0 freeze, 2026-04-29). Default implementation returns `None`; existing third-party `Tool` subclasses keep working without modification. Heavy built-in tools (`HttpFetchTool`, `InternetSearchTool`) override it to set a `threading.Event` (`self._cancelled`) which `execute()` checks at safe points — BEFORE side-effect accounting (rate-limit slots, request-time stamps) and BEFORE the network call. `cancel()` is called from a different thread than `execute()`; implementations must be thread-safe (the `threading.Event` pattern is the canonical shape) and must never raise. `execute()` clears the event at entry so the registered singleton tool stays usable after a prior cancellation; the cancel signal applies only to in-flight or imminent work, not to subsequent calls. **No 1.0 dispatch path calls `Tool.cancel()`** — it ships as forward-compat infrastructure for 1.1+ MCP-subprocess and async-cancel work. The `Tool.timeout` field is also not currently enforced by `runtime/executor.py`; both are independent reservations of contract surface for the same future cancellation pathway. Adding `@abstractmethod` post-1.0 is a breaking change for every third-party Tool subclass — do not do it. Regression guard: [tests/unit/test_tool_cancel.py::test_cancel_has_no_caller_in_executor_dispatch](tests/unit/test_tool_cancel.py) — if a future refactor wires `cancel()` into the executor, update that test and document the new caller here.

- **[engineering] `PerceptSource` and `ActionSink` are minimal protocols with no orchestrator, narrative-phase, conversational-turn, or tool-registry coupling** (CC8, v1.0 freeze, 2026-04-29). Required: `PerceptSource` exposes `name`, `next_percept()`, `is_exhausted()`, `capabilities`; `ActionSink` exposes `record(action)` + `actions`. Optional duck-typed extensions (`has_pending`, `advance_step`) are probed via `hasattr`/`getattr` with sensible defaults. New optional extensions follow the same duck-typed-with-default pattern. The `is_sim_mode` flag in [runtime/sim_adapter.py](src/maxim/runtime/sim_adapter.py) is more accurately read as "an external adapter is driving percepts" — kept as `is_sim_mode` for back-compat. Behaviors gated by the flag: skip Default Network startup, sim-specific `sim_log` calls, skip LLM fallback proposals, AND end the session via `MemoryHub.on_session_end_lightweight()` (skipping the blocking sleep/replay consolidation). The lightweight session-end is correct for short-lived sim runs but **may surprise long-running external adapters** (e.g. a Minecraft session running for hours expects full consolidation). The 1.0 trade-off is preserved to avoid changing existing sim behavior; revisit when the first non-sim adapter ships and decide whether to split the flag (`is_external_adapter` vs `is_sim_mode`) or wire `end_bio_session(is_sim_mode=False, ...)` per adapter. The `isinstance(percept_source, SimulationBridge)` checks in `resolve_confirmation` / `resolve_plan_approval` / `resolve_timeout_retry` are intentional orchestrator-coupled escape hatches — non-sim adapters fall through to the default `"yes"` / `None` and own confirmation resolution themselves. The previously-dead `getattr(percept_source, "_bridge", None)` fallback was removed in the CC8 fold round (no producer ever wired it). Regression guard: [src/maxim/agents/percept_context.py](src/maxim/agents/percept_context.py) (`PerceptSource` Protocol shape) + [src/maxim/runtime/sim_adapter.py](src/maxim/runtime/sim_adapter.py) (`is_sim_mode` flag site).

- **[engineering] CWD-relative path resolution in public API verbs is documented per-verb** (CC10, v1.0 freeze, 2026-04-29). Three verbs accept bare or relative paths that resolve against the current working directory: `maxim.benchmark(suite=...)`, `maxim.imagine(scenario=...)`, `maxim.campaign(path=...)`. **Pass absolute paths from async / pip-install / arbitrary-CWD callers** (e.g. `asyncio.to_thread(maxim.imagine, ...)` from a FastAPI handler). Other public verbs are CWD-independent (they go through `os.path.expanduser` / explicit `home_dir=` resolution). The CWD-relative behavior is preserved as a developer-checkout convenience — fixing it post-1.0 (e.g. resolving via package data or a `MAXIM_SCENARIOS_DIR` env var) is a non-breaking add. The `benchmark` failure mode includes the active CWD in the `ConfigurationError` message so the failure is obvious. Regression guard: [src/maxim/api.py](src/maxim/api.py) — three verbs documented per-verb in module docstrings; `benchmark` ConfigurationError surfaces CWD context.

- **[engineering] `runtime/llm_call_registry.py` is the canonical in-flight LLM call surface; `runtime/stall_threshold.py::compute_stall_threshold` is the canonical stall-threshold derivation** (stall_detector_timeout_awareness.md, 2026-06-04). New stall detectors MUST consult `compute_stall_threshold(*, lane_tier, lane_timeout_s, ...)` rather than defining their own hardcoded thresholds. The orchestrator stall detector + heartbeat monitor (Stage 2, pending; allowlisted in CI grep until then) + `maxim doctor`'s Derived Config rows all query `any_call_in_flight(tier=...)` + `oldest_byte_silence_s(tier=...)` from the registry to suppress nudges during legitimate inference. The instrumentation site is **`models/language/router.py::LLMRouter._complete_text_locked`** (one `register_call_start` at dispatch entry, one `register_call_end` in `try/finally` covering the provider-fallback loop) — wrapping at the per-backend `complete_with_usage` layer is **incorrect** because it leaves a gap between provider-A end and provider-B start where the orchestrator's stall detector would fire spurious nudges mid-failover. Byte-arrival updates flow from per-backend `_stream_response` chunk loops via `register_byte_received()` reading the active call_id from a ContextVar (no signature threading needed); this includes PR #320 TTFT keepalive frames (`: keepalive\n\n`) which the SSE parser sees as bytes-on-wire. `register_call_start` / `register_call_end` use **stack-token LIFO semantics** so nested dispatches don't strand the outer call's ContextVar. The `_InFlightCall` dataclass is SHAPE-FROZEN at 1.0 (CC3) with an `extra: dict` escape hatch rejected because the registry's value type has no caller-extensible metadata; `compute_stall_threshold` accepts `model`/`prompt_tokens`/`**future` kwargs as forward-compat for `llm_timeout_scalability.md` Stage 4 adaptive prediction (1.1). **Known limitation:** the orchestrator's `_stall_detector` hardcodes `lane_tier="large"` and reads `MAXIM_LANE_LARGE_TIMEOUT_S` directly (not via `LaneConfig.remote_timeout_s`) — this drifts silently if an operator configures `lanes.large.timeout_s` via `config.json` only without env-var bridge; tracked as a follow-up for the FunctionRouter-consultation migration. Regression guard: [tests/unit/test_llm_call_registry.py](tests/unit/test_llm_call_registry.py) (15 tests incl. LIFO + nested-dispatch) + [tests/unit/test_stall_threshold.py](tests/unit/test_stall_threshold.py) (20 tests) + [tests/unit/test_stall_detector_with_registry.py](tests/unit/test_stall_detector_with_registry.py) (6 integration tests) + CI grep in [.github/workflows/test.yml](.github/workflows/test.yml) blocking hardcoded `STALL_S=30` assignment literals outside the canonical module (heartbeat.py allowlisted pending Stage 2; pattern is assignment-only — arithmetic expressions and non-`STALL`-prefixed identifiers are intentionally not enforced).

- **[engineering] `Tool.input_schema` is dual-format; `Tool.to_json_schema()` is the canonical export** (CC9, 2026-04-28). Tool authors may write either the legacy custom format (`{NAME: type}` / `{NAME: (type, default)}` / `{NAME: "description"}`) or JSONSchema 2020-12 (`{"type": "object", "properties": {...}, "required": [...]}`). `Tool.__init__` does NOT normalize — `input_schema` is left exactly as authored, so the `@maxim.tool` decorator's JSONSchema output and the legacy class-based custom-format tools coexist. **Format-sensitive consumers reading `tool.input_schema` MUST route through `tool.to_json_schema()`** rather than iterating `input_schema.items()` directly. Motivating bug class: pre-CC9, `runtime/agent_loop.py:2702-2722` iterated `input_schema.items()` and called `v.__name__` on each value — fine for custom-format, silent `AttributeError` swallowed by `except (KeyError, Exception): pass` for `@maxim.tool` tools whose values are JSONSchema property dicts. Result: every `@maxim.tool` tool silently rendered with empty params in the dynamic-tool prompt branch, and no test caught it because the failure path was a swallowed exception. The same silent-no-op pattern this codebase pushes structurally elsewhere — fixed by routing through `to_json_schema()` and pinned by `tests/unit/test_tool_dual_schema.py::TestAgentLoopParamRendering`. The custom format is strictly less expressive than JSONSchema (no `enum`, `pattern`, `format`, `oneOf/anyOf/allOf`, `additionalProperties`, nested object schemas) — authors needing those features must author JSONSchema directly. The description-as-value pattern (`{NAME: "description string"}`) is **legacy convention**: dispatches as required + emits `required: [NAME]` in the JSONSchema export, even when the description text says "Optional" — strict MCP / Anthropic clients will reject calls that omit the parameter, so new tool code should migrate to `{NAME: (str, None)}` + JSONSchema descriptions. **This contract is frozen at 1.0** — adding a third format or collapsing one is a breaking change. See `docs/plans/mcp_compatibility.md` for the broader 1.1+ MCP work this unlocks. Regression guard: [tests/unit/test_tool_dual_schema.py::TestAgentLoopParamRendering](tests/unit/test_tool_dual_schema.py) — pins both formats render correctly via `Tool.to_json_schema()`.
- **[engineering] `runtime/bootstrap.py::build_executor` is the canonical bridge wiring site.** The `pain_bus` parameter is REQUIRED (keyword-only, no default). Every caller makes an explicit `pain_bus=<bus>` (opt in to bio-learning) or `pain_bus=None` (explicit opt-out for sandbox executors, headless agents, tests) decision. Bridges cannot be retrofitted onto an Executor — wrapping (`FearGatedExecutor`, `PainInterceptorExecutor`, `AnticipatoryPainExecutor`) MUST happen AFTER `build_executor` returns. The previous helper `runtime/embodiment_bootstrap.py::bootstrap_embodiment_and_pain_bridge` is DELETED — do not re-introduce it. The fail-fast invariants are checked at the top of `build_executor` BEFORE any object construction: `pain_bus` XOR `pain_detector`, `entity_ref` requires `pain_bus` + `component_registry`, any subscription path requires `nac`. Six logical call sites in `src/maxim/` (cli.py, simulation/orchestrator.py × 2, embodied_runtime/agentic_runtime.py, api.py, simulation/tools.py) — seven physical calls because two sites have an `if/else` fallback. Each makes an explicit `pain_bus=` decision. The AUT call site in `simulation/orchestrator.py` conditionally passes `entity_ref` + `component_registry` + `pain_bus=aut_pain_bus` when `--embodiment` is used with `--sim` (E0, 0.6). Bridge construction is gated on `nac is not None`, NOT on `pain_bus`/`pain_detector` — the bridge's primary value is direct attribution via `record_tool_embodiment_failure`; subscription is the secondary out-of-band path. See `docs/plans/executor_bootstrap_unification.md`. Regression guard: [src/maxim/runtime/bootstrap.py::build_executor](src/maxim/runtime/bootstrap.py) — required keyword-only `pain_bus` parameter; signature enforces explicit decision at every call site.
- **[engineering] `proprioception/pain_bus.py::build_pain_bus` is the canonical PainBus construction site** (Wave 1 of biosystem_unification, 2026-04-14). Required keyword-only `hippocampus` and `nac`; forgetting either is a `TypeError`, not a silent no-op. `None` is the explicit opt-out for sandboxes / tests / api.py headless. Auto-subscribes `create_pain_memory_subscriber` and `create_pain_nac_subscriber` for the non-None subjects; optional `additional_subscribers` tuple appends after the standard learners. The motivating bug class: three CLI entry points (non-sim, `--sim agent`, `--sim interactive`) constructed `PainBus()` and subscribed only `create_pain_memory_subscriber`, silently skipping `create_pain_nac_subscriber` even though `_cli_nac` was in scope — out-of-band SEM pain reached hippocampus but never NAc on three of four agent paths. Tool-invoked pain still reached NAc via the direct-attribution path through `ToolPainBridge`, so the bug was invisible to the substrate P2 cascade test. The structural fix mirrors `build_executor` — push the wiring invariant DOWN into the constructor signature so the next sibling entry point cannot reproduce the bug. **Per L4** (gate on the learning subject, not the signal source), `build_pain_bus` takes only `hippocampus`/`nac`/extras — no ReactionBus, no enabled flag, no kind filter. **Per L5** (declared fields, not stashes), `PainBus.__init__` keeps its existing declared fields untouched. **Raw `PainBus()` construction now raises `TypeError`** (C6 hard-error flip, PR #301, 2026-05-29). Tests pass `_allow_raw=True` explicitly (~30 sites across 7 files); the keyword is preserved as the structural opt-out. The door is now BOTH the type AND the production builder: forgetting `build_pain_bus` fails at construction time, not at first-tick silent-no-op. **DefaultNetwork** ([default_network/network.py:360](src/maxim/default_network/network.py)) currently constructs `PainBus()` directly with split subscriber ownership (NAc via `PainCircuitBridge`, hippocampus via the external consumer in `embodied_runtime/agentic_runtime.py:719`); this is **deliberately deferred to Wave 2** (`memory_hub_unification.md`) because the proper fix couples DefaultNetwork to MemoryHub. **api.py headless** ([api.py:436](src/maxim/api.py)) keeps its explicit `pain_bus=None` opt-out — the structural side is resolved (the door exists) but the user-facing API decision (default-on vs default-off bio-learning for headless `pymaxim` agents) belongs to `agent_factory_canonicalization.md` Stage F5. **Latent bridge × subscriber attribution-asymmetry trap** (surfaced by the pre-merge architecture review): post-Wave-1, CLI paths now wire BOTH `create_pain_nac_subscriber` AND `ToolPainBridge` to the same bus. The bridge has a `_pending_tools` guard at [tool_pain_bridge.py::_on_embodiment_pain](src/maxim/bridges/tool_pain_bridge.py); the subscriber does NOT. Today, the subscriber's `record_outcome_full` silently fails to link pending tool events because `_context_similarity({"params":...}, {7-key-body-context}) = 0/1 = 0.0 < 0.5 threshold`. Net: no double-counting **today**. **The correctness is load-bearing on this similarity mismatch, not on any guard.** If anyone enriches `record_tool_start`'s pending-event context (e.g., adds `entity` for the broad-guard narrowing contemplated at `tool_pain_bridge.py:367-371`), double-counting starts silently. Tripwire: `tests/unit/test_pain_bus.py::TestBuildPainBus::test_subscriber_does_not_link_pending_tool_event`. If that test fails, **DO NOT relax the assertion** — open [docs/plans/pain_bus_bridge_subscriber_unification.md](docs/plans/pain_bus_bridge_subscriber_unification.md) and ship the deeper Option-B fix (`build_pain_bus(*, tool_pain_bridge=...)` parameter + bridge-aware subscriber, OR invert the wiring so the bridge mediates NAc attribution and the subscriber is the no-bridge fallback). Adding the `_pending_tools` guard to the subscriber directly is a coupling band-aid that violates layer boundaries (subscriber lives in `proprioception/pain_bus.py`, bridge state lives in `bridges/tool_pain_bridge.py`). See [docs/plans/pain_bus_unification.md](docs/plans/pain_bus_unification.md) "Latent risk surfaced during pre-merge review" section. Regression guard: [src/maxim/proprioception/pain_bus.py::build_pain_bus](src/maxim/proprioception/pain_bus.py) — required keyword-only `hippocampus`/`nac` parameters + [tests/unit/test_pain_bus.py::TestBuildPainBus::test_subscriber_does_not_link_pending_tool_event](tests/unit/test_pain_bus.py) tripwire.

- **[engineering] `runtime/bootstrap.py::build_default_network` is the canonical DefaultNetwork construction site** (Wave 2 of biosystem_unification, 2026-04-16). Layer 4 → Layer 5 upgrade: `nac` is now REQUIRED keyword-only; forgetting it is a `TypeError`. Pass `None` to explicitly opt out. `maxim=None` is the headless/sim opt-out — DN still provides pain detection + novelty tracking without motor control (previous version returned `None` early, which was wrong for sim). `pain_bus=` parameter closes Gap B from `pain_bus_unification.md`: when provided, DN uses the injected bus (which already has hippocampus + NAc subscribers from `build_pain_bus`) instead of constructing its own internally. This inverts DN from bus **constructor** to bus **consumer**, closing the split-subscriber-ownership problem where hippocampus was wired externally at `agentic_runtime.py:719`. Exception handling narrowed: `ImportError` → `None` + warning (optional dep); config/type errors propagate. `config=` parameter accepts pre-built `DefaultNetworkConfig` (sim mode) with precedence over `config_path=`. Two production callers: `agentic_runtime.py` (Reachy robot path — injects PainBus) and `simulation/orchestrator.py` (sim path — injects aut_pain_bus, `maxim=None`). CLI non-sim and api.py headless are explicit opt-outs (no DN — no robot → no vision → no reactive behaviors; documented per Gaps D+E). See [docs/plans/default_network_unification.md](docs/plans/default_network_unification.md). Regression guard: [src/maxim/runtime/bootstrap.py::build_default_network](src/maxim/runtime/bootstrap.py) — required keyword-only `nac=` parameter.
- **[engineering] `reactions/bus.py::build_reaction_bus` is the canonical ReactionBus construction site** (Wave 1 of biosystem_unification, 2026-04-16). Accepts optional `per_kind_subscribers`, `all_subscribers`, `history_size`, `refractory_overrides`. Unlike `build_pain_bus`, does NOT have required learning-subject parameters — ReactionBus is a generic typed pub/sub whose subscribers vary by caller. The builder exists for **downstream Wave 3 sequencing**: `bio_stack_unification.md` prescribes `reaction_bus = build_reaction_bus(...)` constructed BEFORE `pain_bus = build_pain_bus(..., reaction_bus=reaction_bus)` because PainBus depends on ReactionBus at construction time. Today the builder has ZERO production callers — `PainBus.__init__` constructs `ReactionBus(_allow_raw=True)` internally. Wave 3's `build_bio_stack` will be the first production caller when it constructs a standalone ReactionBus and passes it to `build_pain_bus(..., reaction_bus=rb)`. The thin wrapper is intentional — the interface is the deliverable. **Raw `ReactionBus()` construction now raises `TypeError`** (C6 hard-error flip, PR #301, 2026-05-29); tests pass `_allow_raw=True` (~14 sites across 3 files). `PainBus.__init__`'s internal `ReactionBus(_allow_raw=True)` call covers the inner bus when the outer PainBus opts out. **`cerebellum_modulator_factory` now accepts `reaction_bus=`** — pre-audit the factory silently dropped the parameter, so every SEM modulator failure reaction was silently discarded (`_emit_failure_reaction` returned at `if self._reaction_bus is None`). The factory itself has zero production callers today (the entire Cerebellum backend path is infrastructure ready for SEM wiring but not yet invoked from any runtime startup path); the fix is preemptive so the next caller wires it correctly. See [docs/plans/reaction_bus_unification.md](docs/plans/reaction_bus_unification.md). Regression guard: [src/maxim/reactions/bus.py::build_reaction_bus](src/maxim/reactions/bus.py) (canonical builder; first production caller is `build_bio_stack`).
- **[engineering] `integration/memory_hub.py::build_memory_hub` is the canonical MemoryHub construction site** (Wave 2 of biosystem_unification, 2026-04-16). Takes the four core bio-systems (`hippocampus`, `scn`, `nac`, `ec`) as required keyword-only args plus optional bio-systems (`atl`, `angular_gyrus`, `worker_pool`, `cerebellum`, `embodiment`) and bridge deps (`spatial`, `attention`, `salience`, `fear_agent`, `novelty_tracker`). **Always calls `.connect()` internally**, so the three always-created bridges (PlanHistoryBridge, EscalationLearningBridge, FearCircuitBridge) are alive on every hub returned by the builder. The motivating bug class: two production sites (cli.py non-sim agent, AgentFactory NPC agents) constructed `MemoryHub()` and **never called `.connect()`** — all three bridges were permanently `None`, silently disabling plan-template learning, escalation-threshold learning, and risk-adjustment learning. A third site (orchestrator orch hub) was dead code — `MemoryHub(hippocampus=..., nac=...)` missing required `scn`/`ec` dataclass fields, TypeError swallowed by `except Exception`. **Raw `MemoryHub()` construction now raises `TypeError`** (C6 hard-error flip, PR #301, 2026-05-29); tests pass `_allow_raw=True` (~16 sites across 8 files). The keyword-only `_allow_raw` dataclass field is preserved as the structural opt-out (kept out of `repr` / `compare` per CC3 + the `test_allow_raw_kw_only` invariant). Site #4 (Reachy embodied runtime) uses the builder at construction then calls `.connect()` again later to wire spatial/salience from DefaultNetwork — this is safe because bridges are stateless at construction (the second call overwrites). See [docs/plans/memory_hub_unification.md](docs/plans/memory_hub_unification.md). Regression guard: [src/maxim/integration/memory_hub.py::build_memory_hub](src/maxim/integration/memory_hub.py) (required keyword-only core bio-systems + auto `.connect()`) + [tests/integration/test_memory_hub.py](tests/integration/test_memory_hub.py).

- **[engineering] `runtime/bio_stack.py::build_bio_stack` is the canonical bio-pipeline construction site** (Wave 3 of biosystem_unification, 2026-04-17). Composes the four individual Wave 1+2 builders (`build_reaction_bus`, `build_pain_bus`, `build_memory_hub`, `build_default_network`) in the correct dependency order. Returns a frozen `BioStack` dataclass containing all wired bio-systems. `persistence_dir: Path | str | None` is the primary configuration — sub-paths (`hippocampus.json`, `atl.json`, `angular_gyrus.json`) are derived internally. `pain_bus=` parameter accepts a pre-built PainBus (sim AUT pattern where the sandbox needs the bus before the rest of the stack); standard learners are subscribed to the pre-existing bus. `with_default_network=True` constructs a DefaultNetwork (Reachy + sim AUT only). **`agent_id=` is REQUIRED keyword-only** (2026-05-27 fold of exp 32 Bug A): the prior `default_agent` default produced a silent agent_id divergence between `MemoryHub.agent_id` and every other agent_id surface, breaking Wire-A's cross-session annotation read. Same silent-no-op pattern as `build_executor(pain_bus=...)` and `build_pain_bus(hippocampus=..., nac=...)` — pushed into the type so the next miss is a `TypeError`, not a silent agent_id divergence. **Two production callers** (the AgentFactory consolidation finished after this invariant's initial write): `AgentFactory.create_full_agent` — which itself fans out to CLI non-sim, simulation/orchestrator.py AUT + orch NPC, and headless `pymaxim` — and `embodied_runtime/agentic_runtime.py` Reachy. CLI sim modes stay as-is (just `build_pain_bus`). `build_memory_hub` carries the same required-`agent_id=` contract. See [docs/plans/bio_stack_unification.md](docs/plans/bio_stack_unification.md). Regression guard: [src/maxim/runtime/bio_stack.py::build_bio_stack](src/maxim/runtime/bio_stack.py) (composes Wave 1+2 builders in correct dep order; required `agent_id=` parameter) + [tests/integration/test_multi_agent_attribution.py::TestCreateFullAgentBioStackAgentIdPropagation](tests/integration/test_multi_agent_attribution.py) (pins `config.agent_id` propagation through AgentFactory into the bio-stack's MemoryHub).

- **[engineering] `Hippocampus.recall()` always calls `memory.touch()` on each result** and adds RECALL entries to WorkingMemorySet when `working_memory=` is provided (reconsolidation pull into active context). Content is NOT mutated on access. Regression guard: [src/maxim/memory/hippocampus.py::Hippocampus.recall](src/maxim/memory/hippocampus.py) + recall-side tests in [tests/integration/test_memory_hub.py](tests/integration/test_memory_hub.py).
- **[engineering] SHORT_TERM → LONG_TERM promotion is pressure-based** (Stage 7): each context-diverse recall accrues `promotion_pressure` via `_compute_access_score`; wall-clock elapsed time decays pressure (`_PRESSURE_DECAY_RATE`); threshold crossing (`_PROMOTION_PRESSURE_THRESHOLD = 3.0`) triggers promotion. Context diversity uses query-string hashing — identical queries don't accumulate. FORMING → SHORT_TERM remains outcome-triggered. The old `should_promote()` methods on `MemoryItem`/`WorkingMemoryEntry` in `agents/bus.py` have been removed. Regression guard: [src/maxim/memory/hippocampus.py](src/maxim/memory/hippocampus.py) (`_compute_access_score`, `_PROMOTION_PRESSURE_THRESHOLD`) + tier-progression tests in [tests/integration/test_memory_hub.py](tests/integration/test_memory_hub.py).
- **[engineering] `MemoryRecord` now carries `promotion_pressure`, `last_scored_at`, `access_contexts`** — all three fields deserialize with backward-compatible defaults (0.0, 0.0, empty deque) from old persisted data. Regression guard: [src/maxim/memory/store.py::MemoryRecord](src/maxim/memory/store.py) + [tests/integration/test_persistence_compat.py](tests/integration/test_persistence_compat.py).
- **[engineering] `Episode.valence` defaults to 0.0 on old data.** Backward compatible. Old episode dicts without the valence field deserialize cleanly. Regression guard: [src/maxim/memory/episode.py::Episode](src/maxim/memory/episode.py) (dataclass default) + [tests/integration/test_persistence_compat.py](tests/integration/test_persistence_compat.py).
- **[engineering] `spreading_activation(propagate_valence=False)` returns `dict[str, float]` unchanged.** The `propagate_valence=True` path returns `dict[str, tuple[float, float]]`. Existing callers are unaffected. Regression guard: [src/maxim/memory/hippocampus.py::spreading_activation](src/maxim/memory/hippocampus.py) — function signature documents both return shapes.
- **[engineering] NAc `_reward_bias` clamps to [0, max_reward_bias].** Negative rewards (pain) produce 0.0 bias. Bias only widens EC recognition, never narrows. Pain avoidance is handled by valence annotation on edges, not by reward bias. Regression guard: [src/maxim/decisions/nac.py](src/maxim/decisions/nac.py) — clamp implemented inside `_reward_bias` update path.
- **[engineering] `BioStack.save_cerebellum()` must be called at session end.** Without it, learned forward models are lost. Regression guard: [src/maxim/runtime/bio_stack.py::BioStack.save_cerebellum](src/maxim/runtime/bio_stack.py) + session-end handlers in [src/maxim/integration/memory_hub.py](src/maxim/integration/memory_hub.py).
- **[engineering] NAc per-tick decay is wired into agent_loop.py section 8.5.** Both `decay_eligibility()` and `decay_reward_biases()` run every tick, conditional on `_loop_nac is not None`. Prior to 2026-04-24, these methods were defined but never called from production code — eligibility traces and reward biases persisted indefinitely. The fix is load-bearing for affordance concept transfer (SCN temporal coupling assumes traces decay). Regression guard: [src/maxim/runtime/agent_loop.py](src/maxim/runtime/agent_loop.py) section 8.5 — decay calls co-located with loop tick.
- **[behavioral] (borderline: mechanism-description frame would say engineering; affordance concept transfer ships with measured cross-entity transfer via this PoC) SCN temporal coupling for eligibility traces (first SCN-substrate PoC).** `NAc._temporal_anchors` stores `(original_activation, TemporalSignature)` per `(agent_id, node_id)`. When fast-decay eligibility traces expire, `distribute_reward` falls back to temporal similarity — nodes activated in the same temporal phase as the reward still receive credit at `NACConfig.temporal_credit_weight` (default 0.3x, env-var `MAXIM_NAC_TEMPORAL_CREDIT_WEIGHT`). Session-scoped — NOT persisted. Cross-session transfer uses `reward_bias` (persisted). `_temporal_anchors` are pruned in `decay_eligibility` when both the fast trace expired AND the anchor is older than `temporal_window_seconds`. Roy experiment: [docs/experiments/temporal_credit_validation.md](docs/experiments/temporal_credit_validation.md) (named-experiment citation pending stricter Roy validation per the borderline note).
- **[engineering] SCN oscillator is enabled by default in `build_bio_stack`** (B2, 2026-04-26). `scn.enable_oscillator()` runs after SCN construction. The oscillator learns per-event-type circadian phase patterns via `OscillatorNetwork.observe_event()` (called from `TemporalCreditDistributor.record_event()`). Anticipatory pre-activation: `TemporalCreditDistributor.anticipatory_pre_activate(agent_id)` primes NAc eligibility traces for oscillator-predicted imminent events (call once per tick before `distribute()`). `distribute()` has two credit paths: (1) fast-decay eligibility (includes pre-activated traces), (2) phase-similarity fallback. Anticipatory strength = `OscillatorConfig.anticipatory_weight` (default 0.2x) × imminence. Cold-start guard: < 3 observations per event type returns 0.0 imminence. `_event_phases` on OscillatorNetwork is the ring-buffer store (capped at `max_event_phases=50`); only written under the distributor's RLock. Persisted via `oscillator.to_dict()` → `scn.dump()` → SCN JSON. Regression guard: [src/maxim/runtime/bio_stack.py::build_bio_stack](src/maxim/runtime/bio_stack.py) (oscillator enable at construction) + [src/maxim/decisions/temporal_credit.py](src/maxim/decisions/temporal_credit.py) (TemporalCreditDistributor composition).
- **[engineering] Affordance names are encoded through a SEPARATE `LinguisticEncoder` from the percept encoder.** Both share the same EC/ATL/NAc backing, but the affordance encoder uses `AffordanceDecompositionStrategy` (splits on underscores: `fire_breath` → `["fire breath", "fire", "breath"]`) while the percept encoder uses `SpaCyNounChunkStrategy`. The module-level singleton `AFFORDANCE_STRATEGY` in `similarity/decomposer.py` is used by bio_enrichment, discovery tools, and trigger for annotation lookups. `ImaginationTrigger._aff_encoder` and the standalone `encode_entity_affordances()` are constructed via the shared `_make_aff_encoder()` factory. Regression guard: [src/maxim/similarity/decomposer.py](src/maxim/similarity/decomposer.py) (`AFFORDANCE_STRATEGY` singleton + `_make_aff_encoder` factory).
- **[engineering] Drive protocol: `HomeostaticDriveSpec` and `EntropicDriveSpec` are compartmentalized types** (cradle Stage 1a, 2026-04-26). Homeostatic drives (temperature, pressure, stamina) drift toward `set_point`, pain proportional to deviation beyond `comfort_band`. Entropic drives (hunger, thirst, fatigue) drift away from equilibrium, pain at `deprivation_threshold`. Both live in `embodiment/sem.py`. `CouplingSpec` and `ModulationSpec` are 1.0 interface reservations (parsed from YAML, not evaluated). `pain_model: str` field future-proofs the homeostatic pain formula. Do NOT add fields to these frozen dataclasses post-1.0 without a breaking-change plan. Regression guard: [src/maxim/embodiment/sem.py](src/maxim/embodiment/sem.py) — frozen dataclasses with `SHAPE-FROZEN at 1.0` docstring marker per CC3.
- **[engineering] Entity acquisition via `entity_acquired`/`entity_released` side_effects** (cradle Stage 2, Mechanism B from proprioceptive_discovery.md). When the agent calls `pick_up` on an acquirable entity (`metadata["acquirable"] == True`), `ModulatorAffordanceTool` emits `entity_acquired` in `ToolOutput.side_effects`. `Executor._handle_entity_acquisition` reparents the entity to the agent's body, registers its tools, and flips ownership in `EntityMap`. `drop` reverses via `entity_released`. Acquired entity sensors contribute to the damage model while equipped. Regression guard: [src/maxim/embodiment/entity_map.py](src/maxim/embodiment/entity_map.py) (`transfer_to_self`/`transfer_to_scene`) + [src/maxim/runtime/executor.py::_handle_entity_acquisition](src/maxim/runtime/executor.py).
- **[engineering] `self_effect` on `AffordanceSchema` writes to agent body sensors** (cradle Stage 3). Scene entity affordances declare `self_effect: {sensor: delta}` in YAML. Fires only on voluntary agent actions (not reflex/orchestrator). Supports qualified modulator sub-sensor names (`arms.thermal`). Clamped to sensor range. Logged via `sim_sensor()`. Regression guard: [src/maxim/embodiment/tool_bridge.py](src/maxim/embodiment/tool_bridge.py) (`self_effect` write-back at affordance dispatch).
- **[engineering] Three interaction levels for entities:** Observe (look/examine — no contact), Touch (self_effect — brief one-time sensor delta), Pick up (entity acquisition — sustained contact, sensors join body). The three-layer sensation model: Contact (Layer 1, entity acquisition), Proximity (Layer 2, orchestrator sensor writes), Narrative (Layer 3, keyword reflex fallback). All converge on: sensor change → `evaluate_failures()` → PainBus → NAc. Regression guard: [src/maxim/embodiment/body.py::evaluate_failures](src/maxim/embodiment/body.py) — single convergence point for all three layers.
- **[engineering] `NarrativePhase.act` and `.world_entities` structure long-horizon sims** (cradle Stage 5). `act: str | None` groups phases into narrative acts. `world_entities: tuple[str, ...]` lists component refs activated on phase entry. The generative runner activates entities per-phase with imagination fallback for missing refs. Entities persist across phases (accumulate, not reset). Regression guard: [src/maxim/simulation/arcs.py::NarrativePhase](src/maxim/simulation/arcs.py) (dataclass shape) + [src/maxim/simulation/generative_runner.py::_activate_phase_entities](src/maxim/simulation/generative_runner.py).
- **[engineering] `EnergyReactionBridge` and `MovementEnergyTracker` are DELETED** (cradle Stage 1c). Zero callers — hard-removed as dead code. Interoceptive drive signals are handled by the drive protocol. `LLMEnergyTracker` and `EnergyRegistry` remain live. Regression guard: ad-hoc `grep -rn "EnergyReactionBridge\|MovementEnergyTracker" src/maxim/` must return zero matches.
- **[engineering] Embodiment tick cycle lives in `percepts.py`, not `body.py`.** `EmbodimentPerceptSource.next_percept()` runs: evaluate_failures → tick_vital_drift(dt) → format_body_state_for_prompt. Wall-clock dt (`now - last_poll`, not planned interval) ensures drives advance during LLM latency. Regression guard: [src/maxim/embodiment/percepts.py::EmbodimentPerceptSource.next_percept](src/maxim/embodiment/percepts.py) — tick cycle co-located in this method, not split across `body.py`.
- **[engineering] LLM-derived entity specs route through `normalize_llm_entity_spec` before `_parse_entity`** (v1 C4-followup-1, 2026-05-29). C4 (`v1_refinement.md` §C4) requires every modulator to either own ≥1 sensor or declare `abstract: true`. **Post-PR-A.3 (C4 hard-error flip, 2026-05-29) this normalizer is LOAD-BEARING**: without it, every LLM-imagined or foundry-generated entity with a bare capability-only modulator crashes `_parse_entity` with `ConfigurationError`. Bundled YAMLs are audited; user-authored YAMLs (DM campaigns via `campaign_runner`, curated arc data via `generative_runner`) get the 1.0 `ConfigurationError` as the **user-facing migration signal** asking the author to add the marker explicitly. LLM specs (`imagination/trigger.py` 3 sites + `simulation/foundry.py` 2 sites) cannot be expected to remember the marker — the normalizer is the safety net that fills `abstract: True` on capability-only modulators. Per-source contract is deliberate: do NOT auto-normalize bundled / user YAMLs (that would suppress the user-migration signal). The LLM prompt in [`simulation/entity_designer.py::_SEM_SCHEMA_PROMPT`](src/maxim/simulation/entity_designer.py) is the right-shaped ask; the normalizer is the post-process backstop for LLM forgetfulness. Regression guard: CI grep in [.github/workflows/test.yml](.github/workflows/test.yml) ("v1 C4-followup-1 LLM-spec normalizer rule") allow-lists the four non-LLM input source files (`embodiment/spec.py` internal, `embodiment/component_registry.py` bundled, `simulation/campaign_runner.py` user YAML, `simulation/generative_runner.py` curated arc) and rejects any new `_parse_entity(` caller outside them that omits the normalizer — new LLM-derived entry points must update both the allow-list and this invariant in the same commit; new non-LLM bundled/user-YAML sources also update the allow-list to extend the migration-signal coverage.

- **[engineering] `src/maxim/hivemind/` is the substrate-sharing layer** (v1 §B5 Hivemind shareability, 2026-05-30 / 2026-05-31, PRs #305/#308/#309/#310). The four-PR surface is the load-bearing contract that 1.1 Oasis ingestion and 1.2 P2P Hivemind protocol depend on; later work builds ON these surfaces without retrofitting them.
  - **`hivemind/merge.py` provides `nac_merge` / `ec_merge`** as pure-function utilities consuming `NAc.dump()`-shape state dicts and the `substrate_nodes` slice of `EC.save()`. Inputs are never mutated; outputs are fresh dicts that load cleanly via `NAc.load_state` / `EC.load`. **Both functions REQUIRE keyword-only `left_source=` / `right_source=`** and route them through `_validate_source` which rejects (a) non-strings + empty strings and (b) any contributor ID in the reserved `_*` namespace. The validator is the shared entry point — `hivemind/bundle.py::compose_bundle` reuses it for `contributor_id` validation rather than re-implementing the check. Adding a third entry point that takes a `source`/contributor ID MUST also route through `_validate_source`.
  - **Reserved-namespace registry**: `_consensus` (`merge.CONSENSUS_SOURCE`) marks a link/node aggregated across ≥2 contributors; `_identity` (`identity.IDENTITY_DOMAIN_MARKER`) marks an EC node the operator wants quarantined from every bundle regardless of any other domain filter. New `_*`-prefixed sentinels must be documented in the module that introduces them AND rejected by `_validate_source` (which catches the entire prefix uniformly). Do NOT use `_*` for non-sentinel internal state — the prefix is a public-facing reservation.
  - **`ec_merge` respects `frozen_centroid_modalities`** (PR D bio-fidelity fold). When the matched node's modality is in `frozen_centroid_modalities` (default: `frozenset({"interoception"})` matching `ECConfig.frozen_centroid_modalities`), the merge accumulates `count` + `contributors` but does NOT update the centroid embedding. This preserves the bio-fidelity invariant from the EC centroid-drift lesson: a running-mean centroid update across contributors would re-introduce the drift the frozen-modality contract was designed to prevent within a single substrate. Callers MUST NOT bypass the parameter — adding a new caller that wants a different frozen set passes it explicitly; do NOT mutate the default.
  - **`hivemind/identity.py` is a substrate-only heuristic**, NOT the full Mother-plan ATL/SEM identity-map walk (deferred to 1.1+ via the documented `identity_map: set[str]` parameter slot). Bundle-default threshold is `2` (vs the heuristic's default of `1`) because PR C's architecture review flagged game-substrate over-flagging at threshold=1; this asymmetry is intentional. The English-skew + bio-system-context-unused limitations are documented in the module docstring.
  - **`hivemind/bundle.py` ships substrate snapshot bundles as a ZIP** of `manifest.json` + `nac.json` + `ec.json`. Hippocampus episodes are NEVER included by construction — the load-bearing privacy invariant from [maxim_hivemind.md](docs/plans/maxim_hivemind.md). ATL, reflexes, and cerebellum payloads are reserved for 1.1 (the migration-registry seam is in place: bumping `BUNDLE_SCHEMA_VERSION` to 2 + registering a v1 migration via `register_bundle_migration` is the only change needed). Every ZIP entry is routed through `_safe_join` which rejects empty names, absolute paths, and any resolved target outside `output_dir` (ZIP-slip CVE class). Manifest `signature` + `signature_algorithm` slots are reserved-null at 1.0; populating them is 1.1+ verification work.
  - **`hivemind/cli.py::run_substrate_subcommand` is dispatched from `maxim/cli.py`** for the `substrate` positional subcommand. The 1.0 `import` verb extracts only — it does NOT auto-merge into a live system; that's reserved for the 1.1 Oasis ingestion pipeline so PR D stays side-effect-free at the bio-stack layer.
  - **1.2 poison-resistance hooks** ride on `trusted_sources: frozenset[str] | None` + `validate_link` / `validate_node: Callable | None` keyword-only parameters on both merge functions. They default `None` (no filter) at 1.0; 1.2 wires actual callers. Adding new merge entry points MUST reserve the same parameter shape so the 1.2 P2P protocol doesn't need to retrofit.
  - Regression guard: [tests/unit/test_hivemind_merge.py](tests/unit/test_hivemind_merge.py) (Welford parallel-merge correctness, valence-distinct-stays-separate, sorted-key determinism, frozen-modality preservation, reserved-namespace rejection) + [tests/unit/test_hivemind_identity.py](tests/unit/test_hivemind_identity.py) (short-proper-noun coverage, threshold semantics) + [tests/unit/test_hivemind_bundle.py](tests/unit/test_hivemind_bundle.py) (ZIP-slip rejection, migration-seam, float-precision survival, identity filter, end-to-end round-trip through real NAc + EC instances).

- **[engineering] `~/.config/maxim/config.json` is the operator-config layer; `runtime/config_writer.py` is the canonical writer site** (config_unification.md C1+C2, 2026-06-02, PR pending). Maxim's CLI-canonical instance config absorbs ~22 daily-use `MAXIM_*` env vars onto one source of truth. Path mirrors `peer.yml` / `mesh.yml` / `profiles.yml`. Precedence chain (CLI > env > config.json > builtin defaults) is exposed via `maxim.runtime.config_loader.resolve_setting(field_path, cli_value=None, *, config=cfg)`. Override logging is load-bearing: shadow (env mismatches config) logs WARNING; convergence (env and config agree) logs INFO (the Mac Mini "two sources of truth set, operator doesn't know which wins" failure class). Empty-string env vars are treated as UNSET via `_env_is_set` per the pre-implementation C-1 fold (the leaked-`export FOO=` Mac Mini Trigger #4). The deprecation INFO for absorbed env vars fires exactly once per env var per startup via a module-level `_warned_envs` set. **`runtime/config_writer.py` is the only sanctioned writer for config.json** — `write_config` / `mutate_config` / `set_field` use `atomic_write_json` + `with_format_version` (the canonical CC1 helper per the post-implementation Architecture #1 fold) under a `filelock.FileLock` that acquires BEFORE the read (the pre-implementation I-5 concurrent-set fold). The mesh.yml two-layer-split invariant applies: declarative config files are operator-explicit one-shot setup verbs only — no automatic runtime / admin-API writes. CI grep allow-list enforces single-writer (see Regression guard below). API key references (`lanes.<tier>.remote_api_key_ref`) accept ONLY file paths (`/...` or `~/...`) or keyring URIs (`keyring:<service>:<account>`). Inline plaintext keys are rejected at load time per the cross-confirmed pre-implementation I-3/IM3 fold; the legacy `MAXIM_LANE_<TIER>_REMOTE_API_KEY` env-var path still passes inline keys through (legacy semantics). Regression guard: CI grep in [.github/workflows/test.yml](.github/workflows/test.yml) ("config_unification.md C2 + C6 invariants (IM2 fold)") allow-lists callers of `write_config` / `mutate_config` / `set_field` — new callers fail CI; tests in [tests/unit/test_config_writer.py](tests/unit/test_config_writer.py) + [tests/unit/test_config_cli.py](tests/unit/test_config_cli.py) + [tests/unit/test_config_loader.py](tests/unit/test_config_loader.py) pin every fold from the pre-implementation review.

- **[engineering] `runtime/role.py::detect_role` is the single source of truth for role detection** (config_unification.md C3, 2026-06-02, PR pending). The pre-C3 two-detector architecture (this module + `runtime/leader_mode.py`) was split because they grew from different motivating cases. Both ran at startup and could silently disagree. The 2026-06-01 Mac Mini regression hit divergence on day one. **Post-C3 the seven-rank decision order lives in `role.py::detect_role`**: (1) `MAXIM_ROLE` env var; (2) `config.json::role` (NEW per C3); (3) `mesh.yml` exists → peer; (4) `~/.cloudflared/config.{yml,yaml}` exists → leader (MOVED UP from `leader_mode.py` + extension widened to fix Mac Mini Trigger #2); (5) `peer.yml` exists → peer (DEMOTED below cloudflared — fixes Trigger #3, the stale-peer.yml-on-now-leader case); (6) `--llm <local>` CLI flag + no peer/mesh/cloudflared → solo; (7) default → leader. `runtime/leader_mode.py::detect_role` is a thin wrapper that translates `Role` → `RoleDecision(role, bind_host, reason)`, returns the legacy `client` term for what `role.py` calls `peer` (back-compat through 1.x, drops in 1.2). The `role_divergence` event continues firing at DEBUG with `data={"deprecated": true, "reason": "single detector (C3)"}` for one minor (N-2 fold), drops entirely in 1.2 so external dashboards don't silently lose a column. `_config_json_role()` surfaces `ConfigurationError` as a WARNING (post-implementation Executor I3 fold) so a typo in config.json doesn't silently corrupt rank ordering. Regression guard: [tests/unit/test_role_unification.py](tests/unit/test_role_unification.py) (the eight-cell matrix pinning every Mac Mini failure mode + the ConfigurationError surface) + [tests/unit/test_role_detection.py](tests/unit/test_role_detection.py) (the legacy seven-rank tests updated for C3 ordering) + [tests/unit/test_leader_mode.py](tests/unit/test_leader_mode.py) (wrapper translation contract).

- **[engineering] `_maybe_migrate_from_peer_yml` auto-migration writes peer.yml → config.json on first startup** (config_unification.md C4 IM5 fold, 2026-06-02, PR pending). Triggers iff config.json is absent + peer.yml is present + cloudflared config is absent. The cloudflared absent condition preserves the legitimate leader-with-stale-peer.yml case (Mac Mini Trigger #3) — a leader machine must never have its role auto-flipped to peer just because an old peer.yml lingers. Writes `config.json::role=peer` + `lanes.large.{remote_url, remote_model, remote_api_key_ref}` populated from peer.yml fields. peer.yml is NEVER deleted by the shim. The API key is written to `~/.config/maxim/api_key` via `atomic_write_secret` inside a tightened `umask(0o077)` window (post-implementation Executor C3 fold closes the umask race) + explicit `chmod(0o600)`; config.json stores the file-path reference. Module-level `_migration_attempted` flag prevents repeat work but is set AFTER successful write (post-implementation Executor I2 fold) so a transient OSError (permission denied, disk full) can recover on the next `load_config()` call. **Self-hosted classification preservation:** when `lanes.<tier>.remote_url` resolves via env or config (not cloud config), `runtime/lane_backends.classify_self_hosted_lane` sets `MAXIM_MAX_CLOUD_LANES=1` so the cloud-lane gate doesn't refuse self-hosted peer URLs. Cloud-provider opt-in stays explicit via `config.json::cloud.enabled = true`. **`_apply_lane_config_to_env` is idempotent** (post-implementation Executor C1 fold) via `_lane_env_applied` flag — without the guard, a second call sees env vars set by the first call and re-attributes the source from "config" to "env", silently breaking C5's doctor source attribution. Tests reset via `_reset_lane_env_applied` in the autouse `_isolate_config_json_env` fixture. **peer.yml fallback compat-read** in `_apply_lane_config_to_env` handles the cloudflared-present case (migration skipped); replicates the env-population pattern in-function rather than routing through the legacy `apply_peer_config_to_env` shim (post-implementation Executor C2 fold). Regression guard: [tests/unit/test_lane_routing_via_config.py](tests/unit/test_lane_routing_via_config.py) (migration shim + idempotency + retry-on-transient-failure + self-hosted classification + peer.yml fallback).

## `maxim doctor` — environment diagnostics

Runs platform-aware checks + prints fix hints with the user's actual IPs filled in.
Lives in [src/maxim/doctor/](src/maxim/doctor/) — three modules:

- `platform_detect.py` — OS + runtime (native/WSL1/WSL2/docker) + Linux distro
- `checks.py` — individual check functions, each returns a `CheckResult` (status: ok/warn/fail/info)
- `cli.py` — `maxim doctor` and `maxim peer test` subcommands

**Check surface (v2):** GPU/CUDA, tier detection, llama-cpp-server, auto-spawn reachability, inference coherence, leader role, LAN access, cloudflared, tunnel config/sync, API key (presence + age + permissions + auth smoke), disk space, RAM headroom, lane metrics. Peer mode: URL reachability, key check, auth, model availability, latency.

**CLI flags:** `--retry` (interactive fix loop), `--json` (machine-readable output), `--as peer <url>` / `--as leader` / `--as solo` (role override).

Companion: `maxim tunnel` subcommand in [src/maxim/tunnel/](src/maxim/tunnel/) (cloudflared wrapping + API key management).

### Maintaining this over time

**Adding a new check:**
1. Write a pure function in `doctor/checks.py` that takes `PlatformInfo` (if platform-aware) and returns a `CheckResult`.
2. Add it to the correct section in `run_all_checks()`. For peer-only checks, add to the `detected_role == "peer"` branch; for leader/solo checks, add to the `else` branch.
3. If the fix differs per platform, branch on `info.runtime` / `info.os` / `info.distro` inside the check and produce platform-specific `fix` strings with user-visible commands (users copy-paste, so make them runnable as-is).
4. Use actual detected values (IPs, paths) in fix strings — call `detect_wsl_ip()` / `detect_lan_ip()` rather than `<your-ip>` placeholders when possible.
5. Add a unit test in `tests/unit/test_doctor.py`; mock out network/process calls so tests run offline.

**When a check references another module's function** (e.g., `find_cloudflared`, `_llm_server_responding_at`), import inside the function body (not module-level) to keep `maxim doctor` fast when unused features aren't installed. Tests must patch the **original** module path (`maxim.tunnel.cloudflared.find_cloudflared`), not `maxim.doctor.checks.find_cloudflared`.

**Retry loop** (`maxim doctor --retry`): the loop is data-driven — any `CheckResult` with a `retry_id` and non-ok status is automatically included. Add the retry_id to the `retryable_fns` dict in `cli._retry_loop` with a callable that re-runs the check.

**Role detection** (`_detect_doctor_role()`): auto-detects from `MAXIM_LANE_LARGE_REMOTE_URL`. A non-localhost URL triggers peer mode. Override with `--as peer/leader/solo`.

**Adding a new platform:** extend `PlatformInfo`'s `OSName` / `Runtime` / `Distro` Literal types + the detection branches in `platform_detect.py`, and add fix-hint branches in every platform-aware check.

**`maxim peer test`** should stay self-contained — no imports from the agent runtime. It's run from peer machines that may not have the full dependency set installed.

**Don't:**
- Don't auto-execute fixes without the user asking (`--fix` flag is explicit opt-in).
- Don't make checks slow (> 1s). Network probes use short timeouts (1.5–2s). Long-running benchmarks belong in a future `maxim benchmark` subcommand.
- Don't silently drop failures — any failing check needs a user-actionable `fix` string.

Remaining doctor enhancements are tracked inline as TODO comments in [src/maxim/doctor/](src/maxim/doctor/); no standalone plan doc.

## Key Commands

```bash
# Quick start — interactive menu (no args needed)
maxim                                        # Rich menu: campaigns, chat, doctor, help

# Agent runtime
maxim --llm mistral-7b                       # local LLM
maxim --llm claude-sonnet                    # Claude (needs ANTHROPIC_API_KEY)

# Model management
maxim --list-models                          # show models + download status
maxim --delete-model llama-2-13b-chat        # free disk space

# Simulation (interactive mode ON by default for CLI with TTY)
maxim --sim "test memory recall"             # generative campaign (interactive)
maxim --sim interactive                      # interactive chat (full generative sim stack)
maxim --sim scenarios/campaigns/heist_v1.yaml  # DM campaign (human picks choices + free-text roleplay)
maxim --sim "test safety" --persona adversarial --research  # with research report
maxim --sim benchmark --models mistral-7b,qwen2.5-14b      # benchmark
maxim --sim scenarios/substrate/P0_paraphrase_collapse.yaml --seed 42  # fixture-driven (S1+S4)
# In-sim commands: /cancel /pause /resume /status /report /display clean|bio|debug
# /new <goal> /persona <name> — arrow keys scroll the log
# DM campaigns: type choice number/name, or free-text to roleplay before choosing

# Embodiment in sim (0.6+) — AUT gets SEM affordance tools + pain cascade
maxim --sim "test sword combat" --embodiment weapons/rusty_sword  # generative + entity
maxim --sim interactive --embodiment bodies/reachy_mini            # interactive + entity

# Cradle sensorimotor development (0.8+) — developmental stages via narrative acts
maxim --sim cradle --embodiment bodies/infant_humanoid             # 4-act developmental sim
maxim --sim cradle --embodiment bodies/infant_humanoid --sim-max-turns 40  # long-horizon run

# Non-interactive (for Claude Code, CI, scripting, or debugging)
maxim --sim "test memory recall" --interactive false  # raw output, no Rich panel

# Asset Foundry (0.6+) — LLM-driven SEM component generation
maxim --foundry "cyberpunk weapons" --foundry-genre cyberpunk  # generate + test + score
maxim --foundry "fantasy creatures" --foundry-count 20         # larger batch
maxim --foundry "test" --foundry-dry-run                       # generate + validate only

# Auto-Curation (0.7+) — pre-sim coverage gap filling
maxim --sim "test combat" --embodiment weapons/rusty_sword --auto-curate  # fill gaps
maxim --sim "explore" --embodiment X --auto-curate --curate-threshold 8   # higher bar
maxim --sim "test" --embodiment X --no-curate                             # explicit opt-out

# Diagnostics + networking
maxim doctor                                 # environment check
maxim doctor --retry                         # interactive fix loop
maxim tunnel setup                           # Cloudflare tunnel
maxim peer update && maxim peer restart      # remote update (auto-detects pip/git mode)
maxim peer update --version 0.3.1            # pin specific PyPI version
maxim peer update --dev                      # force git mode (origin/main)
maxim peer update --dev feat/foo             # force git mode (specific branch)
maxim peer install semantic                  # install optional extra on leader
maxim peer deps                              # show leader's installed packages

# Tests
python -m pytest tests/ -x -q -m "not slow" --ignore=tests/integration/test_memory_hub.py
```

Full CLI reference: [docs/user/cli-reference.md](docs/user/cli-reference.md)

## Remote Update Workflow

```bash
# Pip-installed leaders (auto-detected):
maxim peer update && maxim peer restart

# Git-checkout leaders (dev workflow):
git push origin main && maxim peer update --dev && maxim peer restart
```

Use `--dry-run` first if unsure. The update command auto-detects whether the leader is pip-installed or a git checkout. Use `--dev` to force git mode, `--version X.Y.Z` to pin a specific PyPI version. Use `--force` (dev mode only) if the leader has untracked files blocking the pull. Troubleshooting: [docs/troubleshooting/remote_update.md](docs/troubleshooting/remote_update.md).

**Important for Claude agents:** `maxim peer update --dry-run`, `maxim peer version`, `maxim peer logs`, `maxim peer llm --status`, and `maxim peer deps` are safe and read-only. `maxim peer update`, `maxim peer restart`, `maxim peer llm <model>`, and `maxim peer install <extras>` modify leader state — only run when explicitly asked by the user.

## Versioning

Version is defined in two places that **must stay in sync**: `pyproject.toml` and `src/maxim/__init__.py`.

**When to bump:** Any change that affects runtime behavior, CLI interface, or peer/leader protocol. Docs-only or test-only changes do not require a bump.

**Check versions:** `python -c "from maxim import get_version_info; print(get_version_info())"` or `maxim peer version` to compare local vs leader. Version mismatch means the leader needs `maxim peer update && maxim peer restart`.

## Architecture Essentials

Project structure is documented in [docs/reference.md](docs/reference.md).

- **Agent loop** lives in `runtime/agent_loop.py` with `LoopController` in `runtime/loop_controller.py`
- **Multi-agent runtime**: `AgentFactory` in `runtime/agent_factory.py` creates independent agent instances (NPC agents with isolated Hippocampus, NAc, ATL). `AgentPool` in `runtime/agent_pool.py` orchestrates concurrent multi-agent execution with `LocalMessageBus`.
- **LLM routing** lives in `models/language/router.py` (config in `models/language/config.py`). 8 cloud providers (Anthropic, OpenAI, Google Gemini, Groq, Together, Fireworks, Mistral, DeepSeek) across 15 cloud profiles, plus 15 local profiles (llama-cpp and PyTorch/Transformers backends). Self-hosted peer tunnels go through `models/language/maxim_peer_backend.py::_MaximPeerBackend` (Plan 3 R2.5) — purpose-built single-HTTP-call backend with typed failure mapping; cloud providers stay on `_OpenAIBackend`. Backend class dispatch is driven by `runtime/lane_backends.BACKEND_CLASSES` + `resolve_backend_class`.
- **Simulation** orchestrator in `simulation/orchestrator.py`, bridge in `simulation/bridge.py`. Campaign runners in `simulation/campaign_runner.py` (generative + DM + fixture). Fixture-driven testing in `simulation/fixture_orchestrator.py` (S1). Types in `simulation/sim_types.py`.
- **Interactive runtime** in `interactive/` — universal prompt protocol (`PromptRequest`/`PromptHandler`), rich terminal display with split panels, DM display extensions.
- **Mode system**: ProcessingState (awake/sleep) x OperationalMode (planning/supervised/autonomous). Sleep is a tool the agent calls; it wakes automatically on user input.
- **Memory tiers**: FORMING -> SHORT_TERM -> LONG_TERM (enforced by `TierTransitionError` in `agents/bus.py` — see F0.7). Active-reference context lives in `WorkingMemorySet` (Exec-owned, `agents/working_memory.py`), not a memory tier.
- **Memory store protocols**: `EpisodicStore`, `CausalStore`, `SemanticStore` in `memory/store.py` — split persistence protocols with `File*Store` defaults and database implementations for Mother Maxim.
- **Percept/Reaction dual surface** (reaction_abstraction_plan, Phases 1–4 shipped):
  - **Percept** = sensory/environmental input. Typed `PerceptContext` (channel, sender, agent_id, scn_tag) in `agents/percept_context.py`. Named factories in `agents/percept_factory.py`: `make_text_percept`, `make_scene_percept`, `make_intero_percept`. `SensoryTag` populated at all producers via `agents/modality.py`.
  - **Reaction** = evaluative signal driving learning. Types in `reactions/types.py` (`Reaction`, `ReactionContext`, `TraceSnapshot`, `ReactionKind`). `ReactionBus` in `reactions/bus.py` (generalized from PainBus, per-kind dispatch). `PerceptProducer`/`ReactionProducer` protocols in `reactions/protocols.py`.
  - **SEM integration**: sensors → PerceptProducer (via `EmbodimentPerceptSource`), modulators → ReactionProducer (via `CerebellumModulator` mediation). SEM specs don't import Reaction types.
  - **Runtime unification**: both MaximAgent and AgentPool produce typed Percepts. AgentPool.run_turn wraps string input via `make_text_percept`.
  - **Isolation hygiene**: PerceptContext and ReactionContext must NOT carry cross-agent intent, private state, scenario oracles, or learned-policy hints. Rules documented in module docstrings of `percept_context.py` and `reactions/types.py`.
- **Lane tier system**: Functions route to capability tiers (large/medium/small) via `FunctionRouter` in `runtime/function_router.py`. `detect_tiers()` in `lane_models.py` auto-detects from hardware.
- **Data paths**: Bundled seed data in `src/maxim/_data/` (components, encounters, prompts, templates). User data at `~/.maxim/` (memory, sessions, benchmarks, config). Resolution via `utils/paths.py`.
- **SEM Component Registry**: `embodiment/component_registry.py` discovers SEM entity templates from campaign-local, `~/.maxim/components/`, and `_data/components/`. 65 seed components across 7 categories (bodies, creatures, environments, items, npcs, vehicles, weapons). Genre-gated: fantasy, cyberpunk, scifi, horror, historical, modern, devops. **Asset Foundry** (`simulation/foundry.py`) generates new components via LLM + template fallback, validates, tests (8 SEM protocol tests + 3-encounter gauntlet), and scores on 4 bio-system engagement dimensions. **ComponentIndex** (`embodiment/component_index.py`) provides two-layer semantic discovery: alias hash table (from `component.synonyms` YAML field, O(1)) + embedding cosine similarity (sentence-transformers, threshold 0.65). Reuses `similarity.encoder._get_encoder` singleton — no duplicate model. Thread-safe via RLock. Persistence via `.npy` + `.json` sidecar (no pickle). Used by imagination trigger (I1) and auto-curation dedup (E3).
- **Imagination system** (`imagination/`): Real-time entity design from novel percept mentions. Pipeline: entity noun-phrase extraction → ImaginationCache check → ComponentIndex two-layer lookup → DN arousal gate → energy budget check → EntityDesigner LLM call → quick validation → `register_ephemeral()` + `ComponentIndex.add()` → scene-scoped tool registration. Session-scoped ephemeral overlay on ComponentRegistry (`_ephemeral_index`, separate from persistent `_index`). Episodes and CausalLinks from imagined entities carry `imagined=True` provenance; on session end, `NAc.decay_imagined_links(0.5)` reduces confidence by 50%. Wired into agent loop post-`state.update()` via `imagination_trigger` parameter on `run_agentic_loop`. Per-phrase design guard prevents concurrent LLM calls for the same phrase (AUT + orchestrator race). Thread-safe throughout via RLock.
- **Thread model**: Main loop at 2-30Hz + WorkerPool (tier-based lanes: large/medium/small, owned by LLMWorker) + Hippocampus capture thread (owned + shut down by MemoryHub.on_session_end)

## Quick reference — where to look

| Area | Key files |
|---|---|
| Agent loop | `runtime/agent_loop.py`, `runtime/loop_controller.py` |
| Tools | `tools/registry.py` (scene-scoped activation, active tool cap, per-tool `deactivate_tool`), `tools/base.py` (Tool ABC), `tools/discovery.py` (SEM discovery: `SenseToolsTool`, `UniversalSenseTool`, goal top-k, LRU eviction), `runtime/executor.py` (dispatch + active-tool gate, aliases), `embodiment/tool_bridge.py` (entity tool generation, affordance `requires` gating), `embodiment/entity_map.py` (name→Entity resolution), `simulation/tools.py::DamageComponentTool` (body-part targeted damage, cascading integrity) |
| LLM routing | `models/language/router.py` (provider fallback, typed exception branches, `dispatch_exhausted` aggregated WARN), `models/language/maxim_peer_backend.py` (self-hosted peer backend — one HTTP call, typed failure, streaming with strict mid-stream fail, `health_check` + `for_url` factory), `runtime/lane_backends.py::BACKEND_CLASSES` (dispatch table), `models/language/config.py` (profiles), `models/language/json_parser.py` (JSON repair) |
| Memory | `memory/hippocampus.py`, `memory/concept_extractor.py`, `memory/store.py` (protocols), `memory/percept_trace_buffer.py` (τ-decay ring buffer) |
| Causal learning | `decisions/nac.py` (reward bias, eligibility traces, distribute_reward, **goal_reward_bias**), `decisions/causal_link.py` (CausalLink, percept_refs) |
| Temporal credit | `decisions/temporal_credit.py` (**TemporalCreditDistributor** — NAc+SCN composition, fast-decay + phase-similarity credit, **anticipatory_pre_activate** (B2 oscillator feedback)), `time/temporal_event.py` (TemporalEvent envelope), `decisions/valence_signal.py` (ValenceSignal transport for WMS salience modulation) |
| Substrate encoding | `similarity/encoder.py` (LinguisticEncoder), `similarity/ec.py` (pattern_complete_or_separate, centroid update) |
| Prompt composition | `agents/prompt_builder.py` (PromptBuilder + budgeter — single composition point), `prompts/acting_coach.py` (B3: Acting Coach — bio-modulated affordance exploration meta-prompt). Substrate-plan B1's PromptAssembler scaffold was deleted; never wired into production. |
| Percept schema | `agents/percept_context.py` (PerceptContext), `agents/percept_factory.py` (factories), `agents/modality.py` (SensoryTag, SubstrateModality) |
| Reactions | `reactions/types.py` (Reaction, ReactionContext, TraceSnapshot), `reactions/bus.py` (ReactionBus), `reactions/protocols.py` (PerceptProducer, ReactionProducer) |
| Cross-layer wiring | `integration/memory_hub.py` (single coordinator) |
| Persistence | `utils/atomic_io.py`, `utils/paths.py` (data path resolution) |
| Simulation | `simulation/orchestrator.py`, `simulation/bridge.py`, `simulation/fixture_orchestrator.py`, `simulation/personas.py` |
| Substrate test infra | `models/language/backend_protocol.py` (S2), `utils/seeding.py` (S4), `tests/substrate/` (S2+S3+P1 metrics) |
| Generative campaigns | `simulation/arcs.py` (NarrativePhase with `act` + `world_entities`, BUILTIN_ARCS, select_arc_for_goal), `simulation/narrator.py` (two-call decide+generate, phase instruction passthrough), `simulation/generative_runner.py` (per-phase entity activation, imagination fallback) |
| DM campaigns | `simulation/dm_schema.py`, `simulation/dm_runtime.py` |
| Asset Foundry | `simulation/foundry.py` (FoundryRunner, generate, validate, gauntlet, score) |
| Benchmarks | `simulation/benchmark.py`, `simulation/validation.py` |
| Research | `simulation/research_agents.py`, `simulation/research_orchestrator.py` |
| Valence | `memory/episode.py` (Episode.valence, apply_hebbian_on_close, salience_spike_rule), `agents/bus.py` (propagate_valence), `memory/hippocampus.py` (capture_reaction, include_valence) |
| Embodiment | `embodiment/sem.py` (Entity, Sensor, Modulator, AffordanceSchema with `requires` + `self_effect`, **HomeostaticDriveSpec**, **EntropicDriveSpec**, CouplingSpec, ModulationSpec), `embodiment/body.py` (evaluate_failures, derived health, component integrity, **drive pain evaluation**, **_publish_drive_pain**), `embodiment/spec.py` (SpecModulator, _parse_drive_spec), `embodiment/percepts.py` (EmbodimentPerceptSource — tick cycle, wall-clock dt), `embodiment/entity_map.py` (name→Entity resolution, **transfer_to_self/transfer_to_scene**), `embodiment/tool_bridge.py` (entity tool generation, affordance `requires` gating, **self_effect write-back**, **entity_acquired/entity_released side_effects**), `embodiment/resolution.py` (LOD depth), `embodiment/cerebellum.py` (forward models), `embodiment/motor.py` |
| Drives | `embodiment/sem.py` (HomeostaticDriveSpec, EntropicDriveSpec, CouplingSpec, ModulationSpec), `embodiment/body.py::tick_vital_drift` (homeostatic drift toward set_point, entropic drift in direction), `embodiment/body.py::evaluate_failures` (drive pain evaluation), `prompts/acting_coach.py::_compose_drive_modulation` (Layer 4: drive guidance in prompt), `runtime/gating.py::GatingContext.drive_states` (salience modulation interface) |
| Cradle | `simulation/arcs.py::BUILTIN_ARCS["cradle"]` (4-act developmental arc), `_data/components/bodies/infant_humanoid.yaml` (infant body with drives + somatosensory), `_data/components/items/cradle_*.yaml` (6 scene entities), `_data/reflexes/infant.yaml` (thermal contact reflexes), `simulation/generative_runner.py::_activate_phase_entities` (per-phase entity activation with imagination fallback) |
| Imagination | `imagination/trigger.py` (entity extraction + ComponentIndex lookup + design dispatch), `imagination/designer.py` (ImaginationDesigner wrapping EntityDesigner), `imagination/cache.py` (session-scoped ImaginationCache) |
| Mesh | `mesh/identity.py`, `mesh/knowledge.py`, `mesh/task_delegation.py`, `mesh/clock.py` |
| Lane tiers | `runtime/function_router.py`, `runtime/lane_models.py`, `runtime/lane_backends.py` |
| Multi-agent | `runtime/agent_factory.py`, `runtime/agent_pool.py` |
| Interactive UI | `interactive/prompts.py`, `interactive/display.py` |
| Seed data | `_data/components/`, `_data/encounters/` |
| Adding env vars | Add to the env table below + touch whatever reads it |

## Environment Variables

```bash
ANTHROPIC_API_KEY          # Required for Claude backend
OPENAI_API_KEY             # Required for OpenAI backend
GOOGLE_API_KEY             # Required for Gemini backend
GROQ_API_KEY               # Required for Groq backend
TOGETHER_API_KEY           # Required for Together backend
FIREWORKS_API_KEY          # Required for Fireworks backend
MISTRAL_API_KEY            # Required for Mistral API backend
DEEPSEEK_API_KEY           # Required for DeepSeek backend
MAXIM_ROLE=leader          # Plan 2 R2a. Explicit role: leader|peer|solo. Exported by cli.py::main at startup; downstream reads it from env (runtime/llm_server.py::_model_state_file picks active_llm_model.{role}.txt).
MAXIM_LLM_ENABLED=1        # Enable LLM inference
MAXIM_LLM_PROFILE=claude-sonnet  # Default model profile
MAXIM_LLM_N_CTX=4096       # Override auto-computed llama.cpp n_ctx (P4c). Same as --llm-n-ctx.
MAXIM_AUTO_DOWNLOAD_MODELS=1     # Skip the auto-download prompt (P5). Same as --auto-download.
MAXIM_DATA_BUDGET_GB=50          # Optional soft cap on ~/.maxim disk usage; refuses downloads over budget.
MAXIM_SKIP_REMOTE_PROBE=1        # Bypass the remote-URL probe (P6) — CI escape hatch.
MAXIM_REMOTE_PROBE_FIRST_TIMEOUT_S=1.5   # First probe attempt timeout (clamped 0.2-5.0; cold httpx≈710ms)
MAXIM_REMOTE_PROBE_RETRY_TIMEOUT_S=8.0   # Retry probe timeout (clamped 0.5-10.0; dormant Cloudflare tunnel re-establishment >2.5s)

# Plan 3.5 R2 — agent-level LLM call timeout (strict safety net above HTTP layer)
MAXIM_LLM_CALL_TIMEOUT_S=300              # LLMWorker agent-level timeout (clamped 10-1800; default 300s, was 60s pre-plan). Strictly larger than _INFERENCE_PROXY_TIMEOUT_S so HTTP layer fires first with typed BackendTimeout → clean lock release. If this ever fires, it's a LOUD bug signal (HTTP layer is wedged).
MAXIM_REMOTE_PROBE_CACHE_TTL_S=60        # Probe cache freshness window (clamped 0-600)

# Decision log (P9). Append-only JSONL at ~/.maxim/util/lane_decisions.jsonl.
# Inspect with: maxim doctor --last-decision   (or tail/jq the file directly)
MAXIM_PROVENANCE_VERBOSITY=1     # 0=off, 1=compact, 2=verbose

# Substrate path (P1). Enables LinguisticEncoder → EC → ATL dual-write.
MAXIM_SUBSTRATE_PATH=1           # Enable substrate encoding path (Phase 1 dual-write)
MAXIM_CONCEPT_DECOMPOSITION=1    # Enable concept decomposition (noun-phrase extraction before EC). Requires spaCy + en_core_web_sm. Opt-in.

# HTTP client trace (Plan 1 R1)
MAXIM_HTTP_TRACE=1               # Bumps http_request events from DEBUG to INFO (every outbound call logged)
MAXIM_LOG_FILE=/tmp/maxim.jsonl  # Attaches a JSONL file handler via StructuredFormatter. Dual-format: stdout stays human-readable, file is machine-parseable. Root logger runs at DEBUG when this is set; stdout still applies its own verbosity filter.

# Peer backend trace (Plan 3 R2.5)
MAXIM_BACKEND_TRACE=1            # Bumps _MaximPeerBackend peer_backend_call events from DEBUG to INFO. Pair with MAXIM_LOG_FILE for per-call JSONL with full multi-agent context (agent_id/session_id/request_id/lane/input_tokens/output_tokens). Off by default.

# Heartbeat + trace (debug/diagnostics)
MAXIM_HEARTBEAT=1                # System health heartbeat every 10s (GPU/CPU/RAM/disk/WiFi + stall detection)
MAXIM_HEARTBEAT_INTERVAL_S=10    # Heartbeat sample interval
MAXIM_HEARTBEAT_STALL_S=30       # Warn after this many seconds with no LLM calls
MAXIM_LANE_TRACE=1               # Per-request LLM trace logs (also enables heartbeat)
MAXIM_PEER_LOG_REQUESTS=1        # JSON log per outbound peer call

# Drain routing (Plan 4 C4+C4.5+C4.6)
MAXIM_DRAIN_CACHE_TTL_S=1.0              # DrainConstraint mtime cache freshness (clamped 0-60)
MAXIM_AUTO_DRAIN_THRESHOLD=5             # Transient failure count before auto-drain (clamped 2-20; permanent=1)
MAXIM_AUTO_UNDRAIN_PROBE_INTERVAL_S=90   # Auto-undrain probe cycle interval (clamped 30-600)

# Embodiment resolution (component-level damage)
MAXIM_DEEP_EMBODIMENT=1          # Enable level-3 deep embodiment: sub-sensors exposed, damage_affinities active. Same as --deep-embodiment.

# Substrate-primary action selection (release_0_9_1.md Stage 0a)
MAXIM_NAC_MIN_CONFIDENCE=0.0     # Override propose_via_substrate's min_confidence threshold. Default 0.3. Set to 0.0 to bypass the cold-start gate for Roy-2c H1-vs-H2 disambiguation and Wire-A ablation. Invalid values fall back to 0.3 with a WARNING.

# Exp 37 cross-session graduation ablation arm 3 (docs/experiments/37_cross_session_graduation.md)
MAXIM_NAC_REWARD_BIAS_DISABLED=1  # Gate NAc reward-bias surfaces. When set to "1" / "true" / "yes" / "on" (case-insensitive, whitespace-tolerant — matches MAXIM_DISABLE_CLUSTER_BIAS_ANNOTATION parser), NAc.distribute_reward / decay_reward_biases / get_agent_tool_biases early-exit as no-ops. Tests whether bio-learning is load-bearing for the Exp 37 cross-session behavioral delta vs LLM in-context recall doing the work. Read ONCE at NAc construction; changes after construction are NOT picked up until the next NAc is built. Eligibility traces, causal links, and variance accumulation continue to flow — only the three named reward-bias surfaces are gated. Autouse env-scrub fixture lives at tests/conftest.py::_isolate_maxim_nac_reward_bias_disabled.

# EC activation instrumentation (release_0_9_1.md Stage 0d, cross_modal_substrate_binding.md Stage 1)
MAXIM_EC_TRACE_ACTIVATIONS=1     # Gate per-tick `sim_ec_activation` JSONL events from EntorhinalCortex.pattern_complete_or_separate. Fields: agent_id, tick (int second bucket), active_node_id, activation_strength, modality_tag (linguistic/drive/sensor), modality, is_new. Off by default — Roy-4 sets it in the runner environment for the cross-modal binding pre-implementation validation experiment (scripts/analyze_roy_4_coactivation.py is the post-hoc analyzer). Falsy values ("0", "false", "no", "off", empty) disable. The instrumentation fires even on cold-start when active_node_id is freshly allocated, so pattern-separation events are visible in the co-activation matrix.

# Wire-A cluster-bias annotation (release_0_9_1.md Stage 2)
MAXIM_DISABLE_CLUSTER_BIAS_ANNOTATION=1  # Disable Wire-A's cluster-bias annotation prompt section. Default OFF (annotation ON in 0.9.1 by design). Set this to "1" / "true" / "yes" / "on" in the Roy-3 ablation arm to compare annotation-on vs annotation-off on the engineered-overlap fixture. The agent-loop producer site at runtime/agent_loop.py reads the var per LLM submission; absent / empty → annotation populated. The PromptBuilder helper renders unconditionally — disabling at the producer keeps the prompt-builder unit tests env-var-free.

# Wire 1 risk-sensitive variance annotation (release_0_9_1.md Stage 4)
MAXIM_DISABLE_VARIANCE_ANNOTATION=1  # Disable Wire 1's variance-band felt-sensation annotation on tool descriptions. Default OFF (annotation ON in 0.9.1 by design). Mirrors MAXIM_DISABLE_CLUSTER_BIAS_ANNOTATION's parser. Set this to "1" / "true" / "yes" / "on" in the Roy-3 ablation arm to compare variance-annotation-on vs -off arms. The agent-loop producer site at runtime/agent_loop.py reads the var per LLM submission; absent / empty → annotation populated. Variance comes from CausalLink.variance_estimate (Welford online algorithm on the binary reward signal) via NAc.get_action_risk_profile.

# W2 imagination substrate-signal MVP (imagination_substrate_signals.md Hookup 1)
MAXIM_DISABLE_IMAGINATION_SUBSTRATE_SIGNAL=1  # Disable W2's substrate-aware scene-manifest enrichment. Default OFF (substrate-aware manifest ON). Mirrors Wire-A's MAXIM_DISABLE_CLUSTER_BIAS_ANNOTATION parser. Set to "1" / "true" / "yes" / "on" in Roy ablation arms to compare W2-on vs W2-off and measure the substrate→imagination gap closure independently of Wire-A's annotation gap closure. The sim orchestrator producer site reads the var once at scene-load; absent / empty → manifest sees NAc.get_agent_tool_biases via compose_cluster_bias_annotation_section (shared with Wire-A for substrate-voice consistency).

# Leader proxy admission control
MAXIM_PROXY_MAX_CONCURRENT=4     # Max in-flight requests to upstream (0=unlimited)
MAXIM_PROXY_RATE_LIMIT_RPM=0     # Per-peer requests/minute (0=unlimited)
MAXIM_PROXY_KEEPALIVE_INTERVAL_S=30   # SSE keepalive cadence during TTFT (llm_timeout_scalability.md Stage 3). Clamped 5-90; default 30. Defeats cloudflared's ~100s tunnel idle timeout when upstream's time-to-first-token exceeds the idle window (common on 30B+ models on edge hardware). Emits `: keepalive\n\n` comment frames between response headers and the first real chunk; stops on first chunk arrival. Only fires for upstream responses with Content-Type `text/event-stream`. Bumped at the LeaderProxy layer in `runtime/leader_proxy.py::_run_keepalive_emitter`.
MAXIM_PROXY_CONTEXT_ADMISSION=1  # Proxy-side context-overflow admission gate (llm_timeout_scalability.md follow-up). Default ON when `MAXIM_LLM_N_CTX` is resolvable; explicit OFF via `0`/`false`/`no`/`off` (case-insensitive). When ON, the leader proxy rejects `/v1/chat/completions` and `/v1/completions` requests whose estimated prompt + max_tokens + overhead exceed the upstream's context window with HTTP 413 + a typed `{"error": {"code": "context_overflow", ...}}` body. Prevents oversize prompts from silently hanging llama-cpp-server with an unactionable stream. Disabled with a startup WARNING when `llm.n_ctx` is unresolvable; passes through (existing pre-gate behaviour) so existing setups don't break.
MAXIM_PROXY_CONTEXT_OVERHEAD_TOKENS=256  # Safety margin (tokens) between estimated prompt size and the model's context window. Clamped 0-4096; default 256. Covers the char-based estimator's ~10-30% underestimate vs the actual tokenizer; tune up for code/markdown-heavy workloads (closer to 3 chars/token) or tune down for English-only chat (closer to 4 chars/token).

# Cloud provider integration
MAXIM_LLM_CLOUD_ENABLED=1       # Enable cloud dispatch (required for --cloud-* flags)
MAXIM_MAX_CLOUD_LANES=1          # Max lanes using cloud providers (default: 0)
MAXIM_LLM_REDACTION_POLICY=standard  # Redaction policy for cloud dispatch (standard/relaxed/strict)
MAXIM_CLOUD_SESSION_BUDGET=5.00  # Hard ceiling on cloud spending per session

# Peer/tier remote configuration (tier names: large, medium, small)
MAXIM_LANE_LARGE_REMOTE_URL=     # Override large tier to use remote server
MAXIM_LANE_LARGE_REMOTE_MODEL=   # Model name to request from remote server
MAXIM_LANE_LARGE_REMOTE_API_KEY= # Auth token for remote server
MAXIM_LANE_LARGE_TIMEOUT_S=      # Per-tier inference read timeout (seconds, strictly positive). llm_timeout_scalability.md Stage 2. Resolved via standard `resolve_setting('lanes.<tier>.timeout_s', ...)` precedence (CLI > env > config.json > backend default). Unset → backend default applies (`_MaximPeerBackend` 300s, `_OpenAIBackend` 60s). Threaded through `LaneConfig.remote_timeout_s` → `providers[provider_key]["timeout_s"]` so both backends pick it up via their existing `_provider_cfg().get("timeout_s", default)` reads. Same env-var family for medium / small tiers.
# Tier names only: large, medium, small (legacy infer/review/record removed in v1.0)

# Bio-system debug traces (also activatable via --debug <subsystem> or --debug all)
MAXIM_HIPPO_TRACE=1              # Enable per-operation Hippocampus trace events (store/recall/promote). memory/hippo_tracer.py
MAXIM_ATL_TRACE=1                # Enable per-operation ATL (Angular-Temporal Lobe) trace events. memory/atl_tracer.py
MAXIM_NAC_TRACE=1                # Enable per-operation NAc trace events (reward, causal links). decisions/nac_tracer.py
MAXIM_PAIN_CHAIN_TRACE=1         # Emit one structured pain-chain trace event per PainBus dispatch (publisher → subscriber → NAc). Off by default. proprioception/pain_bus.py

# Auto-spawn (llama-cpp-server + cloudflared tunnel; leader mode only)
MAXIM_AUTO_SPAWN_LLM_SERVER=1    # Set to 0/false/off to disable automatic llama-cpp-server spawn on GPU leaders. Default: enabled when GPU detected + large tier has a local profile. runtime/lane_backends.py
MAXIM_AUTO_SPAWN_PORT=8100       # Port for the auto-spawned llama-cpp-server (and the leader proxy's upstream). Default: 8100. runtime/lane_backends.py
MAXIM_AUTO_SPAWN_TIMEOUT_S=120.0 # How long to wait for the spawned server to become healthy. Default: 120s. runtime/lane_backends.py
MAXIM_AUTO_SPAWN_TUNNEL=1        # Set to 0/false/off to disable automatic cloudflared tunnel daemon spawn. Default: enabled in leader mode when cloudflared config exists. runtime/lane_backends.py
MAXIM_AUTO_SPAWN_N_CTX=0         # Legacy env (kept for in-place upgrades): override n_ctx passed to the auto-spawned llama-cpp-server. Superseded by MAXIM_LLM_N_CTX. Default: 0 (derive from model/config). runtime/lane_backends.py

# Leader proxy port
MAXIM_LEADER_PROXY_PORT=8099     # Port the LeaderProxy HTTP server listens on. Default: 8099. runtime/leader_proxy.py

# Context pool (multi-agent context window management)
MAXIM_CONTEXT_POOL_MAX_TOKENS=2000       # Max tokens in the pool before triggering summarization. Default: 2000. agents/context_pool.py
MAXIM_CONTEXT_POOL_SUMMARY_TOKENS=500    # Target token count for each summary. Default: 500. agents/context_pool.py
MAXIM_CONTEXT_POOL_MAX_ENTRIES=50        # Max pool entries before forced summarization. Default: 50. agents/context_pool.py
MAXIM_CONTEXT_POOL_KEEP_RECENT=5         # Number of recent entries always kept unsummarized. Default: 5. agents/context_pool.py
MAXIM_CONTEXT_POOL_AGENT_STATES=true     # Include agent states in pooled context. Default: true. agents/context_pool.py
MAXIM_CONTEXT_POOL_OUTCOMES=true         # Include tool outcomes in pooled context. Default: true. agents/context_pool.py
MAXIM_CONTEXT_POOL_ABSTRACTIONS=true     # Include abstraction stream in pooled context. Default: true. agents/context_pool.py
MAXIM_CONTEXT_POOL_PATH=                 # Persistence path for the context pool (None = no persistence). Default: unset. agents/context_pool.py

# NAc Wire-A cluster-bias decay tau (cluster_reward_bias_decay_tau_split.md)
MAXIM_NAC_CLUSTER_REWARD_BIAS_DECAY_TAU=300.0  # Decay tau (ticks) for Wire-A cluster_reward_bias per agent. Clamped [50, 1000]; invalid or out-of-range values fall back to 300.0 with a WARNING (deliberate: silently clamping would hide misconfiguration). Default: 300.0. decisions/nac.py
```

## Testing

```bash
# Full suite
python -m pytest tests/ -x -q -m "not slow" --ignore=tests/integration/test_memory_hub.py

# Specific test file
python -m pytest tests/unit/test_simulation_agent.py -v

# Just the module you changed (fast feedback)
python -m pytest tests/unit/test_lane_metrics.py -v
```

### Testing efficiently

**Run narrow first, then wide.** Test the specific module you changed before running the full suite (~3 min). The full suite has ~4,000 tests; don't wait for all of them on every edit.

**Kill stale sims before running tests.** A running `maxim --sim agent` process holds GPU + port resources and can cause test hangs:
```bash
pkill -f "maxim.*sim" 2>/dev/null; sleep 2
python -m pytest tests/ -x -q -m "not slow" --ignore=tests/integration/test_memory_hub.py
```

**Threading pitfalls (learned the hard way):**
- Use `threading.RLock` (not `Lock`) if a method acquires the lock and then calls another method that also acquires it (e.g. `snapshot()` calling `self.failure_rate`). Regular `Lock` deadlocks on re-entry.
- Thread-safety tests with many workers (8+ threads × 100 calls) can appear to hang if a deadlock exists — they're not slow, they're stuck.

**Don't run sims from tests.** Sims call real LLMs and can 2-3x test-suite runtime. The sim runner is for manual/CLI testing only (`maxim --sim agent`). Tests should mock LLM calls.

**Peer/tunnel testing requires the leader.** Use `curl -si -H "Authorization: Bearer $KEY" https://maxim.yourdomain.com/v1/models` for quick checks. See [docs/troubleshooting/](docs/troubleshooting/) for in-depth peer connectivity guides.

## Simulation Reports

Sim runs save to `~/.maxim/sessions/{session_id}/` (report.json, actions.jsonl, aut_hippocampus.json, aut_nac.json). Research protocol details and campaign execution flow are documented in `docs/simulation.md` and `docs/experiments/`.

## Python API (pymaxim)

Published to PyPI as `pymaxim` (import name stays `maxim`). 17 verb-based functions, all lazy-loaded from `src/maxim/api.py`. Key files: `api.py` (facades), `__init__.py` (lazy wiring), `simulation/introspection.py` (Observer).

**Rules for maintaining the API:**
- **Verbs are facades, not logic.** Delegate to existing internals. Don't put business logic in `api.py`.
- **Lazy imports only.** `import maxim` must not trigger loading of optional dependencies.
- **Return structured data, not prints.** `diagnose()` returns `DiagnosticReport`, `imagine()` returns `SimulationResult`, etc.
- **`introspect` is an alias for `observe`.** Don't add behavior to one without the other.
- **`Observer`** is the canonical name (no aliases).

**Package management:**
- **Package name:** `pymaxim` on PyPI, `maxim` as import
- **Core deps:** `numpy`, `scipy`, `pyyaml`, `json-repair` only. Everything else is optional extras.
- **Optional extras:** `llm-llama`, `llm-server`, `llm-torch`, `llm-anthropic`, `llm-openai`, `vision`, `audio`, `reachy`, `comms`, `search`, `temporal`, `training`, `tts`, `yolo`, `semantic`, `database`
- **Robot plugins:** Auto-discovered via `maxim.robots` entry-point group.
- **Build validation:** `python -m build && twine check dist/*` before any publish
- Publication guide: [publication_guide.md](docs/publication_guide.md)

## Active initiatives

See [docs/plans/README.md](docs/plans/README.md) for the roadmap index. Current version: v0.7.0 on PyPI as `pymaxim` ([publication guide](docs/publication_guide.md)).

**Recently shipped (2026-04-24):**
- **Affordance Concept Transfer** — Substrate-native cross-entity learning. Routes affordance names through existing bio-pipeline (LinguisticEncoder → EC → ATL → NAc reward_bias). Transfer via EC pattern completion — "flame" maps to same node as "fire" (cosine 0.785). SCN temporal coupling for eligibility traces (first SCN-substrate PoC). Pre-existing NAc per-tick decay bug fixed. `discover_tools` renamed to `sense_tools`. Entity ownership shipped (self vs scene separation).
- **Entity Ownership** — Self vs scene entity tools. Agent controls own body, observes others. Dragon bug fixed.

**Previously shipped (2026-04-20):**
- **0.7 Feature Completion** — Self-generating simulations. All tracks landed:
  - R0 Prerequisites: ComponentRegistry thread safety, sim-mode consolidation, TOOL_ALIASES lock
  - B3.1 Acting Coach: config + prompt section with bio-system modulation (NAc caution, pain anticipation, cerebellum predictions)
  - F3-F5 Agent Factory: sim orchestrator + Reachy + headless API migrated to `create_full_agent`
  - E2 Real LLM: foundry wired to real LLM with entity context injection + synonym generation
  - E2.5 ComponentIndex: two-layer semantic discovery (alias hash O(1) + embedding cosine similarity)
  - E3 Auto-Curation: `--auto-curate` CLI for pre-sim coverage gap filling via foundry
  - I1 Imagination Trigger: entity extraction → ComponentIndex lookup → DN arousal gate → design dispatch
  - I2 Real-time Design: ImaginationDesigner with quick validation + synonym generation
  - I3 Scene-scoped Tools: tool window with cap (20 scene tools), deactivation, executor gate
  - Integration wiring: ImaginationTrigger constructed in orchestrator AUT path, session-end cleanup (imagined link decay + ephemeral entity clearing)
- **Version bump to 0.7.0.** Experiment: [docs/experiments/07_imagination_wiring.md](docs/experiments/07_imagination_wiring.md).

**Previously shipped (2026-04-17):**
- Valence annotation, SEM Learning Loop, Behavioral convergence wiring, Experiments 1-4 (41/41 hypotheses confirmed). Version 0.3.0.

**Previously shipped (2026-04-11/12):**
- Foundations wave F0.1–F0.8, Reaction abstraction Phases 1–4, Cleanup wave C1–C4, Peer/leader flexibility P1–P9, Simulator upgrades S1–S4. All archived.

**Gating 1.0** (three focused substrate plans, split from the master plan):
- [substrate_p0_pilot.md](docs/plans/substrate_p0_pilot.md) — **COMPLETE** (2026-04-12). Baseline pinned at 78.5%. Results: [docs/experiments/p0_baseline_sweep.md](docs/experiments/p0_baseline_sweep.md).
- [substrate_recognition.md](docs/plans/substrate_recognition.md) — **COMPLETE** (2026-04-14). B1+P1 shipped 2026-04-12 at 91.7% collapse (`paraphrase-mpnet@0.40` + centroid update). P2 Stages 1+2 shipped via PR #100 (SEM pain cascade end-to-end on real `rusty_sword` + NAc `_context_similarity` directional fix + PainBus dual-layer rewrite). P2 Stage 3 shipped via PR #102 — real-embedding sweep at `paraphrase-mpnet@0.70, reward 2.0` cleared with **+56.0 ± 29.0 pp target gain / 0.0 ± 0.0 pp distractor drift / 94% monotone / 9-of-10 seeds**, after three metric pivots (node-count → raw pair-collapse → plurality-ownership self-collapse) + a fixture pivot. Results: [docs/experiments/p1_recognition_sweep.md](docs/experiments/p1_recognition_sweep.md) + [docs/experiments/p2_reward_modulation_sweep.md](docs/experiments/p2_reward_modulation_sweep.md) + [docs/experiments/p2_sem_pain_cascade.md](docs/experiments/p2_sem_pain_cascade.md). Reproduction runbook: [docs/experiments/protocols/p2_reward_modulation_reproduction.md](docs/experiments/protocols/p2_reward_modulation_reproduction.md). 0.3-minimum gate CLOSED.
- [substrate_binding_persistence.md](docs/plans/archive/substrate_binding_persistence.md) — **SPLIT COMPLETE + ARCHIVED.** Now a pure index. All four 0.3-target phases CLOSED. Per-phase plan files created for 0.5 track.

**Living practice docs (paired with substrate_plan):**
- [behavioral_convergence_practice.md](docs/plans/behavioral_convergence_practice.md) — does the agent actually get better across sessions? Living doc, not a gate.
- [memory_consolidation_practice.md](docs/plans/memory_consolidation_practice.md) — refines the P8 sleep-replay mechanism. Kicks in when P8 ships in 0.5.

**Parallel:**
- [tool_refinement_plan.md](docs/plans/tool_refinement_plan.md) — living doc for agent tool curation.

**Deferred (post-1.0, revive on trigger):** Bio-System Plugin Discovery, Unified Event Bus, Mother NPC Stimulus, Mother Maxim, Pecking Order Graph, Asset Foundry, DM Extensions. See [docs/plans/deferred/](docs/plans/deferred/).
