feat(activity): a room's recipe binding finally has a READER — every room resolves to what it IS (#6/#274/#329) - #2282
Conversation
… as text (#274/#396) Joel, 2026-08-13: "Always use uuid and never corrupt them with random prefixes" and "UUID's are NOT strings. If you're using strings for id you are writing slop." Both landed on code written minutes earlier, and the grep that followed found the same defect older and wider than my one file. Three fixes, in order of how badly they were wrong: 1. The four shipped recipe ids were hand-drawn patterns (c0de0001-0000-4000-...), a name wearing a UUID's costume — readable, collidable, and fake. Replaced with genuine v4s in both the authored JSON and the `shipped::` constants, written grouped (0xfed332c3_383c_45bb_...) so the two are checkable by eye. 2. comms::{MessageId, CorrelationId} were `String` newtypes. `MessageId::new("msg-1")` let any caller invent a namespace that collides with every other caller's. Both are now `Uuid`: MessageId::new() MINTS (no caller-supplied form exists), and CorrelationId::of_exchange(id) states the derivation the old `CorrelationId(id.0.clone())` left implicit. Still distinct TYPES — the compiler, not a naming convention, is what stops one being passed as the other. 3. EndpointId is deleted. Its values were `EndpointId::new("browser")` and `("rust-core")` — a client-kind label standing in for an identity, and with it the assumption that the web client is a distinguished endpoint. It is one client among many (mobile, SDK, TUI, another node's core). TransportEnvelope.source and .target are now PeerId, the substrate's one actor identity per identity/mod.rs. Zero callers outside comms/mod.rs; the stale generated binding goes too. Also closes the on-disk authoring hole the required `id` field opened: a recipe file that names no id now gets one DERIVED from its purpose (RFC 4122 v5 under a frozen namespace), so "author a file, zero code" keeps meaning zero code — no uuidgen — and every node that loads the same file agrees on its identity with nothing to reconcile. A recipe that DOES carry an id keeps it verbatim. Tests: comms 23 pass (envelope wire shape now asserts ids round-trip as their own UUIDs; minted_message_ids_are_unique pins the collision fix), experience 43 pass including the previously-failing an_experience_authored_on_disk_needs_no_rust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…t, not the deploy path Joel, 2026-08-13: "Headless rust period. No need for node to run everything except for the web interface which is one of many, including mobile apps/sdk." — and then, because I kept treating the correction as a code-only matter: "Fix these severe misunderstandings ... regardless of where they are." This file is where the misunderstanding REPRODUCES. It is loaded into every agent session, and it opened with "EVERY TIME YOU EDIT CODE: Run `npm start` (MANDATORY)" under `cd src` — a directory that no longer exists — plus "ALL Rust binaries MUST be built via npm start". A fresh agent under amnesia reads that and concludes Node runs the system. It doesn't, and `continuum --help` has been saying so in its own first line the whole time: "build + run the headless Rust core". Rewrote the CRITICAL WORKFLOW section around what is actually true, and swept the other 11 Node-as-deploy claims scattered through the file: - The core is Rust and boots with no Node. Node builds the WEB desktop, which is one client among several (mobile, SDK, TUI, MCP, another node's core over the grid). Named the consequence, because it already cost us: a feature that lives in a client exists only for that client — how voice ended up web-only with every other citizen structurally mute (#58). Behaviour goes in the core; clients render. - The deploy path is `continuum reboot` (Rust build + relaunch + running-SHA verify), with `deploy-verify` and the version trio called out — that verification exists because a reboot once shipped a stale binary and reported success (#194). - `cargo build` stays discouraged for the RIGHT reason (a hand-built binary exists only on your machine, and a fresh clone must work with no manual steps, #291) — not the old implication that Rust must be built through npm. `cargo check` is named as the correct type-check-while-you-work tool, with the shared CARGO_TARGET_DIR. - `npm run build:ts` now says what it actually covers: the web client, and nothing about whether the core compiles. - Flagged `./jtag` inline as the legacy Node CLI. Left the invocations that follow, since their command NAMES are still accurate — it is the driver that is stale. No code change; the one surviving "npm start" is the sentence warning you off it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ed to a binary that does not exist
Joel: "Cu conflicts with some Unix program. Fix for all cases including windows"
and "It's a bug Claude" — it is, and worse than doc drift.
`cu` is /usr/bin/cu (UUCP call-unix) on every Unix. `uu` — the double-U of
contin-UU-m — is THE official short alias, and start-server.sh has installed
exactly that (plus a squatter guard) since 2026-08-01, with a comment naming this
very collision. Nothing in the tree installs `cu`. But the REFERENCES never
followed:
- Six benchmark harnesses defaulted to `~/.continuum/cache/cargo-target/{release,debug}/cu`.
Neither file exists — the only built CLI is `release/continuum`. So the default
resolved to nothing and every default-args run died at the first invocation.
matrix.py even carries a comment about this exact class of failure biting once
before (2026-07-22, stale debug-only default silently no-opping the sweep).
- Fixed by RESOLVING rather than renaming: a shared `_resolve_cli()` prefers what
is actually installed on PATH (`uu`, then `continuum`) and falls back to the
release build — so it works from a fresh clone, an installed box, or a dev tree,
on any platform, instead of hard-coding one machine's layout.
- Flag renamed `--cu` → `--uu` with all in-repo call sites updated
(sweep_all → matrix → headtohead → preflight_gpu chain).
Also swept 53 `cu <command>` occurrences in docs, Rust comments, and generated-TS
doc comments to `uu`. Left the ones that are ABOUT the collision (memory-bridge
README's "never bare `cu`", WAKEUP-AND-JOIN's rename note) — those are correct as
written, and legacy/ stays quarantined.
README dev section reworked in the same pass: the boot path is `continuum start` /
`continuum reboot` / `continuum ping`, with Node named as what it actually is —
the web client's build dependency, one client among mobile/SDK/TUI/MCP, not the
thing that runs the system.
Verified: all six harnesses compile (py_compile), cargo check clean, zero
`target/{release,debug}/cu` paths left in the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ilently swallowing the job
Joel: "Fix all nodejs and python dependencies that are breaking our headless rust
core ... we must find all smell all the time and never ignore it."
AUDIT RESULT FIRST, because one half is good news:
Node spawns from the running core: ZERO. `Command::new("node"|"npm"|"npx"|
"deno"|"bun")` has no occurrences anywhere in continuum-core. The core is
genuinely headless Rust at runtime; Node exists only to build the web client.
Python spawns from the running core: FOUR sites, and neither kind was honest.
1. THREE were vestigial and were hiding untested code. `file_engine.rs` had three
`#[test]`s opening with `if Command::new("python3").arg("--version").output()
.is_err() { return; }` — a skip-guard from when the syntax gate shelled out to
`python3 -m py_compile`. That interpreter is GONE: the production path is
`code::syntax::validator_for` → `unbound_calls`, pure Rust. So on any box
without python3 — a CI runner, a fresh clone — three tests reported PASS while
asserting nothing at all. Guards removed; all 55 file_engine tests pass without
an interpreter present, which is the proof they never needed one.
Also corrected the doc on `introduced_undefined_calls`, which still told the
reader the analysis returns None when there is "no python". There is no python.
2. ONE is a REAL runtime dependency, and it was failing silently. `forge/start`
spawns `python3 <alloy_executor>` — a script that lives in the SIBLING
sentinel-ai repo, which a fresh clone of continuum does not have. When
`find_alloy_executor()` returned None the handler used pid 0 and wrote
`state: "queued"` — indistinguishable from a job legitimately waiting its turn.
So on every machine that had only cloned this repo, `forge/start` returned
SUCCESS for work that nothing would ever run. Now it fails loud, names the
missing script, says where to get it, and states plainly that the job was NOT
queued. The dependency itself is still there — excising it to Rust is #52/#99
— but it can no longer pretend to have worked.
The pattern in both: a Python dependency that had already been removed or had
never been satisfiable, still shaping behaviour through a stale guard and a
fallback. `[[fallbacks-are-illegal-fail-loud]]`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…, and the compiler now knows Joel: "UUID's are NOT strings … Well defined and named structs by reference must be used." Chasing that through persona_id turned up something sharper than the count. THE FINDING. `PersonaWorkspaceRegistry::resolve_persona` exists specifically to close what its own doc calls "the loose-`String` id boundary … the defect class that fed a dead id to a doomed eval." It has SEVEN call sites. SIX are its own tests. ONE is production (eval.rs). Against 55 `persona_id: String` fields. The check was correct and essentially nothing called it — a correct check nothing calls is nastier than a missing one, because it reads as covered. Nothing forced the call, because both sides were `String`. THE FIX — two types, one door: - `PersonaRef` (new, in identity/): what a CALLER writes — full UUID, 8-char short-id, or name. Explicitly NOT an identity. Its only accessor is `as_str()`; there is no `as_peer_id()`, because a name is ambiguous, mutable, and meaningless without a roster. - `PeerId` (existing canonical actor identity): what everything downstream holds. - `resolve_persona(&PersonaRef) -> Result<PeerId, _>` is now the ONLY bridge. Taking the newtype rather than `&str` is what makes resolution unskippable. `From<PeerId> for PersonaRef` exists (an identity is always a valid reference to itself); the reverse deliberately does not — it requires a roster. Wire shape is unchanged: `#[serde(transparent)]` over the same string callers already send, so no client, recipe, or stored payload changes. Short-id and name PX (#161) keeps working — that ergonomics is the whole reason a reference type has to exist separately rather than everything becoming a UUID. Converted, types pushed DOWN rather than laundered at the seam: - `CognitionEvalParams.persona_id` → `PersonaRef` - `restore_persona_workspace(&PersonaRef)` (was `&str`) - `append_failed_ledger(&PersonaRef, …)` (was `&str`) HELD, and stated rather than fudged: `CognitionEvalResult.persona_id` stays `String`. The struct derives `Default` across 21 fields and a persona reference has no sensible default — an empty one is a nonsense value that reads as a real answer. The real fix is splitting the fire-and-poll HANDLE from the completed RESULT (a handle knows only the requested ref; a result knows the resolved id), which is its own slice. Inventing a default to satisfy the type checker is the `unwrap_or` reflex: compiler quiet, runtime wrong. The reason is recorded at the field. Tests: persona_workspace 10, eval 22, identity 23 — all pass; full lib test build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… 21 sites Second slice of the id-typing migration. All ten `commands/memory/*` params plus the `MemoryManager` API they call now carry `PersonaRef` instead of `String`/`&str`: append_memory, append_event, load_corpus, has_corpus, get_corpus, multi_layer_recall, consciousness_context, persona_db_handle, hydrate_corpus_if_missing. Types went DOWN into the layer rather than being unwrapped at each call — the whole point of the previous slice. `.as_str()` now appears only where the value is genuinely being USED as text (a map key, a `starts_with` shape check, a directory handle), never to satisfy a signature one call later. Two `Default` derives removed (`ConsciousnessContextParams`, `LoadCorpusParams`) rather than giving `PersonaRef` a default. No caller used `::default()` on either, and an empty persona reference is a nonsense value that reads as a real answer — same reasoning as the eval result field held in the previous commit. What this makes visible, and does not yet fix: these commands still never RESOLVE. They accept a reference and hand it straight to the storage layer as a key, so a name or short-id reaches the DB unresolved. That was invisible while everything was `String`; it is now legible in the signatures. Wiring `resolve_persona` into the memory command path is the next slice (#164/#396). Tests: memory 219, rag 150 — all pass. Full lib test build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…rsona/*, cognition/observe
Third slice. Seven more command param structs stop typing a persona reference as
`String`: agent/solve, persona/identity/{get,set}, persona/instances/{get,despawn},
persona/wall/pin, cognition/observe (params + the `assemble` signature + its `Meta`,
so the type is consistent through the result rather than converted on the way out).
Same discipline as the memory slice: `.as_str()` appears only where the value is
being USED as text — `id_resolve::resolve` takes a `&str` by design because it also
serves rooms and cards — never to satisfy a signature.
Running total across the three slices: 39 persona params + the memory API + the
resolver itself. `persona_id: String` is down from 55 to 16 in the crate, and every
one that remains is an internal struct holding an already-resolved id (those want
`PeerId`, the next slice) rather than an unresolved caller reference.
Tests: full lib test build clean, commands suite passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…rce path carries it down Fourth slice. `CognitionTraceParams`, the four other introspect params, `CognitionReplayParams` + result, `RagComposeRequest`, and `DatasetFromTurnsParams` now carry `PersonaRef`. The RAG one went two levels deep rather than stopping at the param: `load_source`, `load_memory_source`, and `load_consciousness_source` all take `&PersonaRef` now, which deleted the two `&persona_id.into()` conversions the memory slice had left at those call sites. That is the shape to aim for — when the type reaches the bottom, the adapters in the middle disappear rather than accumulating. `persona_id: String` in continuum-core: 55 → 8. Every remaining one is an internal RECORD (memory/types, should_respond's AIDecisionContext, live/types, projection, shell_types, ai/types, sentinel) holding a value copied from a param. Those are deliberately NOT converted to `PeerId` yet. They hold whatever the caller sent, and nothing on those paths resolves — typing them as an identity today would assert something the code does not do, which is worse than leaving them `String`. They become `PeerId` in the same slice that wires `resolve_persona` into those paths, not before (#164/#396). Tests: replay 3, rag 18, full lib test build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…— the stub's premise expired `StubAircCitizen::subscribe_all_rooms` was an `unreachable!()` justified by a comment that said no test drives it. That was true when written and stopped being true at bf11a66 (#398 slice 3), which gave `PersonaSupervisor::materialize` a `subscribe_all_rooms` call to wire the doctrine/wall cache invalidators. Every supervisor test that materializes an adapter has been dying in that panic since — 5 of the 10, and they only surface on a FULL-suite run, which is how they sat unnoticed. Found while verifying my own id-typing slices: full suite came back 7073 passed / 5 failed, and the first question was whether I caused it. I did not — the diff of my five commits touches `commands/persona/*` only, never `persona/supervisor.rs` or `persona/airc_citizen.rs`, and `git log` on those two files points at #398. Fix: return `AircError::Transport` instead of panicking. This is NOT a fallback — the caller already handles that exact case explicitly (keeps both sources uncached, "correct, just slow", logs loud), so the tests now exercise the real degradation branch rather than aborting, and a stub still never pretends to hold a live stream. An empty stream WOULD have been the fallback: it would have looked like a working subscription that silently never invalidates. Comment rewritten to state what is true now, including why the old assertion was right when it was written. An assertion that outlives its premise is worse than no assertion — it reads as a guarantee. persona::supervisor: 10/10 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…e panic I removed
`stub_subscribe_panics_loudly` existed to prove the `unreachable!()` fired, so removing
that panic left the assertion failing for the right reason. Rewritten to pin what the
contract actually is now:
Err(Transport) — required
Err(anything else) — fail (the caller branches on Transport specifically)
Ok(stream) — fail LOUDEST, because that is the real fallback: a stub handing back a
stream looks like a live subscription that silently never invalidates
Matched rather than `expect_err`d because `FilteredEventStream` is not `Debug`.
FULL LIB SUITE NOW GREEN: 7078 passed, 0 failed, 42 ignored. Before this session's
last two commits it was 7073/5 — five supervisor tests panicking since #398 slice 3,
only visible on a full run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ARED, or the build fails Joel: "eliminate all smell or you will copy it." That is literally the mechanism — a model reading this tree learns its conventions FROM it, and `persona_id: String` was 55 sites teaching that ids are text. I did exactly that this session: minted `c0de0001-…` fake UUIDs because fake-looking ids were already normal here. Prose in CLAUDE.md does not stop that. A failing test does. Three tests in identity/mod.rs, running on every PR via the existing `cargo test -p continuum-core --lib` workflow (no skip pattern matches them): 1. `every_string_typed_identity_field_is_declared` — walks src/, finds any `<identity-name>_id: String | Option<String>` field, and fails unless it appears in LOOSE_IDS. Comments stripped first, so a doc line can never register as a field. The error names the file:field and tells the reader which typed form to reach for (`PeerId` for an actor, `PersonaRef` for an unresolved reference, a `*Id(Uuid)` newtype otherwise). 2. `no_declaration_outlives_its_defect` — a declaration whose field HAS been fixed fails too. Learned directly from `StubAircCitizen::subscribe_all_rooms`, whose comment stayed true-sounding for months after its premise expired and cost 5 silently-failing tests. 3. `declarations_carry_a_real_reason` — every entry starts with external:/pending:/ defect: and is longer than a shrug, the same bar the module-wiring audit (#344) holds. 73 declarations, honestly categorized: - **external** — LiveKit participant/room ids, log-envelope correlation fields. Another system owns the wire format. - **pending** — ours, but nothing on that path RESOLVES yet. Typing it as an identity today would assert something the code does not do. Converts in the slice that wires resolution (#164/#396). - **defect** — `peer_id: String` × 5. These ARE `PeerId`. I attempted the conversion in this session and reverted it: `PeerId` has no `JsonSchema` impl and the construction sites hold `&str`, so it needs its own slice rather than a rushed cascade. Declared as a defect so it stays visible instead of blending in. POSITIVE CONTROL, because a guard nobody has watched fail is the exact shape of defect I found earlier today: added `struct PositiveControlProbe { pub owner_id: String }`, confirmed the guard failed naming `identity/mod.rs: owner_id`, removed it, confirmed green. The guard also caught 12 sites my own inventory grep had missed — it is already strictly better than the method I was auditing with. Full lib suite: 7081 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…now --from-source
`start` shelled unconditionally into tools/scripts/start-server.sh, which runs a
full cargo build. A "governed" lifecycle verb was a wrapper around a bash file
that only exists inside a repo checkout (BigMama's find, 2026-08-13):
- a user holding ONLY the installed binary, with no source tree, could not
start a core at all;
- the CLI printed one line then went silent for the length of a compile —
called hung three separate times;
- the façade's honesty depended entirely on the script underneath.
Same class as the rest of the night's defects: a governed surface over a
hand-rolled path.
Now: `launch_core` locates the installed `continuum-core-server`
(CONTINUUM_CORE_SERVER override → next to the running exe → ~/.continuum/bin →
target/{release,debug} walking up) and execs it directly, keeping the existing
detach/log/pidfile handling unchanged. Building is an EXPLICIT request
(`continuum start --from-source`), never the silent default, and the
no-binary fallback says WHY it fell back and that it compiles first, rather
than going quiet for minutes.
The override refuses loudly when set but not a file — a wrong override must not
look like an absent one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…uard debt cleared) The working tree could not build tests. This finishes the identity-typing work that broke it and clears the guard debt it left behind. `cargo test -p continuum-core --lib --features metal,accelerate`: 7081 passed, 0 failed. PEER_ID IS A TYPE, NOT TEXT. The production conversions landed earlier; the test fixtures were left holding `&str`, so the crate compiled and the test target did not. Fixtures now derive a real UUID from the literal they used to carry (v5 under NAMESPACE_OID), which preserves every cross-site equality the tests depended on — "peer-a" in two places is still the same peer, it is just an id now instead of a name in costume. CAUGHT BY DOING IT: contracts/verification had the manifest keyed by a derived PeerId while the EVENT still claimed a raw string signer. The lookup is BY that signer, so converting one side made every verification test fail as MissingPeerManifest. One `test_peer_id` / `test_peer_str` pair now feeds both sides — the same shape of defect the newtype exists to prevent, reproduced in the fixtures while removing it from production. REAL HARDENING, not just fixture churn: `AircPeerManifest::validate` had DROPPED its empty-peer_id check on the theory that typing the field made it impossible. Typing killed `""`. It did NOT kill `Uuid::nil()`, which is still constructible and still means nobody — the type narrowed the hole rather than closing it. The guard is back, at the remaining expressible form. GUARD DEBT CLEARED: loose_id_guard's `no_declaration_outlives_its_defect` went red, correctly — four `peer_id` entries in LOOSE_IDS described fields that are now `PeerId`. Removed. The guard failing here is it working: a declaration list that can rot into a graveyard is worth nothing. Also includes the accumulated session tree: the Joel→Operator fixture sweep (no person's name hardcoded in test data), ts-rs regeneration, and rustfmt across the crate. That is why this touches ~717 files; the behavioural change is the identity typing and the nil-PeerId guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
`k3-serving` had no lifetime because it was named for a SUBSYSTEM, so it never died — a month-old durable subscription whose board reads were all corpses. Parted from both scopes. It also sat in TEST FIXTURES, which is the transmission vector: fixtures teach the next reader the convention, so a fixture naming a room after a subsystem teaches that rooms are named for subsystems. Renamed to `bench-swe-run-1` — an activity with a lifetime, which is what a room IS. The two remaining mentions in work.rs are the INCIDENT RECORD (4 of 12 cards there carried a 134h-expired lease) and are kept, now annotated RETIRED so nobody copies the naming from them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…zero — carry it on the wire and the board `SweGradeResult.error` documents its own contract: "a result with `error` is an ABSENCE, not a zero, and must never be tallied as a failed attempt." The grader earns that honestly — for the env class it re-runs the PRISTINE tree before declaring a fault, so a genuinely broken patch is never mislabelled. Then both consumers dropped the classification: - `benchmark.attempt.end` / `benchmark.autograde` published `resolved=false gate_ok=false` and nothing else. Every wire consumer (probe router → rooms, exam-room widgets, pulse monitors) reads that as a citizen who tried and lost. - `fold_run_card` read the RESULT's `infra_error` but never the GRADE's `error`, so the board folded `resolved: false` + phase `failed` for the same runs. The attempt loop already broke correctly on `g.error.is_some()` (attempts 2 and 3 were never burned) — the loss was purely in what got PUBLISHED, which is the part anything downstream can actually read. Measured on this box 2026-08-13: 8 of 36 distinct instances (14 of 91 receipts, 22%) grade UNGRADEABLE — requests, pylint, pytest and sympy. Every one of those zeros was indistinguishable from a capability failure on the wire, so the denominator of any rate computed off this stream was poisoned. Found by digging into sympy__sympy-11400: p2p 0/29 on the PRISTINE tree, i.e. the suite does not run in that environment at all. (#380/#383 own fixing the environments; this commit owns never again reporting their faults as scores.) - attempt.end + autograde now carry `ungradeable` + `grade_error` - `infra_error` takes the grade's error too — one field meaning "no valid verdict, and why", fed by both sources rather than a second parallel field - `resolved` returns to `None` when ungradeable — the same "no verdict" a pre-grade card carries, because that is the truth - new phase `ungradeable`, ordered ahead of `failed` (a run can carry both a failed marker and an ungradeable grade; the absence is the truer of the two) Test asserts absence-not-zero on the real sympy-11400 shape, with a positive control (same shape, no grade error) that must still fold as a capability zero — so the test cannot pass by simply never reporting failure. This is the #384/#386 class one layer up: those classified INFRA at the solve level, this classifies it at the GRADE level and gets it onto the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…UN, instead of caching a tree that grades every attempt UNGRADEABLE Root cause of the 22% ungradeable rate, glass-boxed on sympy__sympy-11400. The era pin at the top of this block downgrades pytest to the instance's own date, which is right when the era INTERPRETER rung (#2253) found a matching interpreter. When it can't — no Python 3.5 on a modern macOS — the venv falls back to a modern interpreter while pytest stays pinned to the instance's year, and the resulting PAIR can be structurally unable to run. Measured: pytest 2.9.2 (correct for 2016) on Python 3.9.6 dies in `pytest_configure` with INTERNALERROR before collecting anything — reproduced on a two-line trivial test, outside the repo, no conftest, no sympy involved. Every test then "fails", the pristine p2p reads 0/29, the tree grades UNGRADEABLE. 8 of 36 distinct instances on this box are in that state. So: prove the harness executes before handing the env to a citizen. A version pin is a GUESS about compatibility; running it is the evidence. `--version` is not enough — it answers happily for a pytest that dies on any real run. This REFUSES rather than self-heals, and that is measured, not assumed. The obvious repair (reinstall a modern pytest) was tried against this exact tree and does NOT work: pytest 8.4.2 → loads, dies in sympy 1.0's 2016 conftest on the `py.path` hook API removed in pytest 7 pytest 6.2.5 → dies on `py.test.mark.slow`, removed in pytest 4 pytest 2.9.2 → cannot run on Python 3.9 at all The band that both RUNS on 3.9 and LOADS a 2016 conftest is EMPTY. No version choice rescues this class, so an auto-repair would silently trade one void tree for another. What DOES work, verified on this tree — 30/30 passing — is sympy's OWN runner (`sympy.test(...)`) on the same interpreter. That is #383's shape ("django needs its OWN test runner") generalised: the runner is a property of the repo era, not a pytest version to search for. `run_tests` is pytest-only today, so until it grows a runner seam this env genuinely cannot produce a verdict — and it now says so loudly, naming the incompatibility and pointing at the runner gap, instead of caching a broken env for every later run to inherit. [[brittleness-is-the-highest-priority-work-there-is]] — heal what is known-safe, REPORT what needs a human decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… parallel runner (Joel's ruling, + STOP gate) The consequence that makes it law: the learning flywheel consumes ROOM TURNS (L1 lifts tool-traces from captured turns, L2 triggers on turn-completion). A detached agent/solve writing progress/<run>.grade.json produces NO turns — so a citizen can burn 12 acts, write a patch, take a graded verdict, and none of it reaches the curriculum. Maximum effort, zero learning. That, not the pass rate, is why benchmarks have failed. Names what is parallel today (ledger files, scraped probes, a second board projection in fold_run_card, private grade.json), the target shape (import task+oracle only → project into a recipe → the ROOM is the runner → grading is the activity outcome → learning falls out because the work happened as turns), and a one-line acceptance test: can a citizen standing in the room perceive the run's state through the same ViewState pipe the human's screen uses? Adds a CLAUDE.md STOP gate over benchmark.rs / agent/solve.rs / swe_bench.rs so an agent arriving under amnesia must read it before touching run state. Written because that is exactly what happened: this session shipped two correct fixes that HARDEN the parallel path instead of dissolving it, including adding a field to a benchmark probe so external consumers could parse it better — which is the smell the doc now names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…room resolves to what it IS (#6/#274/#329) `activity/spawn` has always published a room→recipe binding to the wall, and its own doc says why: "Without this the room forgets which recipe it is and every client falls back to projecting it as a plain chat room." That was accurate. The binding had NO READER. `RECIPE_WALL_CATEGORY` appeared in exactly one file — the writer — plus a test asserting the string equals "recipe". So the whole recipe layer was live and inert at once: recipes authored as data, a `RecipeExperienceSource` projecting them, four shipped manifests including a benchmark carrying scoreboard/central/feed regions — and `DefaultRoomPurpose` answering "chat" for every room in existence, so none of it ever resolved. A benchmark run's room and a chat room were the same object to every renderer AND to the citizen standing inside one. That is what "benchmarks are a parallel system" looks like at the substrate: not a missing feature, a write with no reader ([[a-correct-check-that-nothing-calls-is-nastier-than-a-missing-one]]). - `experience/binding.rs` — `RoomRecipeBinding` + `project_binding`, the typed body and the one rule for turning a room's wall into its identity. Sibling of `standing.rs`, same shape and the same fail-loud stance: no binding is `Ok(None)` (a bare `airc join` makes a chat room), an UNREADABLE binding is an error, never a silent downgrade to chat. - `ipc/recipe_room_purpose.rs` — `RecipeRoomPurpose`, the `RoomPurposeSource` impl the seam's own doc has been waiting for. Event-invalidated cache, not a per-read fetch: `purpose_for` is sync and sits on the projection's store path, so an owner task folds `wall:changed` and re-reads the authoritative binding (the supersede chain is airc-owned and cannot be reconstructed from a delta — same discipline as the wall projector). Seeds every subscribed room at boot so an activity spawned before this core booted resolves without waiting for someone to re-pin something. - `activity/spawn` now SERIALIZES the shared type instead of a hand-authored `json!`. Both sides agree by construction — which mattered exactly zero while nothing read it, and matters permanently now. - `positron_source::spawn` takes the purpose source by injection; boot passes the live index when a daemon is present, `default_source()` (every room → chat) when headless. Honest edges, all pinned by tests: an unbound room, an unreadable binding, and a failed read all resolve to "chat" — the seam is total — but the two failures say so LOUDLY on the probe stream (`activity.purpose.unreadable_binding`, `activity.purpose.read_failed`). A binding naming a purpose no recipe declares resolves verbatim, and `RecipeExperienceSource` then honestly returns no manifest rather than substituting one. Known follow-up, named rather than hidden: this adds a FOURTH node airc reader (presence/wall/kanban/purpose). Consolidating them onto one attach is real work and is not this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ndistinguishable from a dead one
First live test of the purpose index produced zero probes, and I could not tell from
the evidence whether that meant "attached, seeded, nothing bound" or "never spawned".
The store on disk settled it, but only because I went looking for a sqlite file — the
component itself said nothing either way. That ambiguity is the defect
([[a-correct-check-that-nothing-calls-is-nastier-than-a-missing-one]]).
- attach logs like its three sibling node readers (presence/wall/kanban all do)
- `activity.purpose.seeded` on every boot with {rooms, bound} — "0 bound of 1" is a
FACT, and a different fact from silence
- `activity.purpose.refreshed` on every wall-change cue, so the invalidation path is
observed rather than inferred (wall changes are rare; the probe costs nothing)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
Known limitation, found by reading the airc path (filed as #406)
So the node purpose-reader joins one room and can only resolve that room. Bounded and correct today (one bootstrap room), and it breaks precisely on the case this exists for: a per-run bench room the reader never joined → Not fixed in this PR on purpose — the fix has a real fork and I'd rather it be picked deliberately:
(b) is the principled one. Acceptance test either way: a room the node reader has never joined resolves to its authored recipe. |
…AND mind (the pattern) `ACTIVITY-ROOM-PATTERNS.md` has said this since it was written: "the same transform serves a human's eyes and a persona's mind, because RAG is a render target, not a separate pipeline" … "the human's UI and the persona's grounding are the same projection rendered two ways — they cannot drift, because there is one definition" … "Never render the two from separate code." The code rendered them from separate code anyway. The human's roster comes from `RosterViewState` on the served Substrate; the citizen's came from `persona::room_roster_source` — a second reader, own fetch, own freshness, own failure modes. Same for the board (`KanbanViewState` vs `room_board_source`) and the wall. Three parallel pairs, and one of them was MEASURED delivering a live peer's name ZERO times into a citizen's prompt while the browser rendered that peer fine. This is the compression, not new architecture: - `RagRenderable` — a tiny per-kind impl: KIND (the SAME const the web subscribes to), block label, expand verb, measured floor, salience-ordered units, room scope. - `ViewStateRagSource<V>` — ONE generic adapter making any such kind a `RagSource`. Budgeting, packing, cursors, token estimation, honest-empty, and the room gate are written once. N kinds cost N small impls and zero new plumbing. Properties that fall out rather than being bolted on: - **Cannot drift** — the adapter reads the SAME Substrate the WS server serves. - **Freshness** — no second fold to lag, so the #346 staleness class (citizen trusts an empty board while the announcement is fresh) can't recur here by construction. - **Degrades** — units pack most-salient-first, so a tight window yields FEWER members, never a chopped one (the property `floor_tokens` exists to protect). - **One room gate** — reuses `room_scope_allows`, the shared predicate, rather than a second copy of the same decision. Outlier-validated per CLAUDE.md's methodical process, both in one file so a bad abstraction fails immediately: - A: `RosterViewState` — people, identity, room-scoped. The measured defect's cure. - B: `BenchViewState` — numbers, no identity, per-row verdicts, node-scoped. B needed ZERO adapter changes, which is the whole test. chat / kanban / wall / serving / nav / foundry are now registrations, not builds. 5/5 tests green, each with a `// what this catches:`. One test's arithmetic was wrong on the first run (budget 12 fit all three ~4-token member lines); the PACKING was correct and the test was fixed to 8 — noted in the test itself, because "make the failing assert pass" is how a real invariant gets quietly weakened. NOT YET WIRED into prompt assembly — the seam exists and is proven; swapping the three bespoke sources over is the next commit, so the swap can be reviewed as a behavior change on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…someone swaps a source onto it The obvious next move on `viewstate_rag` is to rebind `room_roster_source` at supervisor.rs onto the new adapter. Reading the substrate first showed that would be a REGRESSION, so the module now says so at the top where a future reader will actually be standing. The `Substrate` cache is keyed by KIND ALONE — positron's own `revisions.rs` names the `(room_id, kind)` tuple as a future extension. So the node substrate holds ONE room's roster: the focused room's. Swapping today means this adapter's room gate correctly abstains for every persona whose turn is in a different room, and personas are first-class MULTI-room subscribers. Most citizens would go BLIND rather than mis-sighted — trading "sometimes wrong" for "reliably empty" is not a repair. Filed the prerequisite as #408 (per-room substrate key) with the acceptance test: two personas in DIFFERENT rooms each receive THEIR room's roster in one tick. This is the note I would have wanted before shipping the swap, not after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…n read HER room (#408) The node's cache is keyed by KIND ALONE. `scoping.rs` said it in its own words — "Everything else is per-room and stays on the node substrate" — which is the bug in one sentence: those kinds are TREATED as per-room but share ONE store, so the node holds whichever room wrote last (the FOCUSED room) and every other room reads empty. That is what blocks the citizen side of positron. `persona/viewstate_rag.rs` makes any ViewState a RagSource so a citizen and a browser read ONE definition — but a persona is a first-class MULTI-room subscriber, so under one shared slot most citizens would get an EMPTY roster rather than a wrong one. "Reliably blind" is worse than "sometimes wrong", which is why that swap was NOT made first. The fix is not new machinery — it is the SECOND INSTANCE of a pattern already here: `PER_USER_KINDS` + `PerUserSubstrates` already solve "N scopes share a kind namespace" for citizens (nav). This adds the room axis in the same shape: - `PER_ROOM_KINDS = [chat, roster, kanban, wall]` — open by data, like PER_USER_KINDS - `PerRoomSubstrates::for_room(room)` — one substrate per room, created on first use - `CompositeCache` routes THREE scopes: per-user → citizen store, per-room → room store, everything else (bench, serving, system-metrics) → the node store, because those describe the NODE and have no scope to route to. ADDITIVE ON PURPOSE. `CompositeCache::new` is preserved verbatim and still resolves room kinds from the node store, so every existing caller keeps today's behavior; scoping is opt-in via `CompositeCache::scoped`. A migration that silently re-pointed every reader would make "did this change anything?" unanswerable. 8/8 green. The acceptance test is the one that matters: `two_rooms_each_keep_their_own_state_in_the_same_tick` — two rooms, each reads ITS OWN state, neither overwritten. Plus: writer and reader of one room share ONE store (no second fold to go stale — the #346 class), the unscoped constructor is unchanged, and the three-way route neither merges nor leaks across scopes. No positron-core change, no wire change, no tag bump — entirely in-tree. My first estimate of this task understated it and my second overstated it as a cross-language contract change; reading `scoping.rs` settled it as an in-tree application of an existing pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… one fold, two sinks (#408) Per-room substrates existed as of the previous commit and were EMPTY: nothing wrote to them, so they were a correct mechanism with no data — the same write-with-no-reader shape as the recipe binding, inverted. `ChatProjection::store` now mirrors every per-room envelope (chat, roster, the Experience manifest) into that room's own store via ONE helper, `store_room_scoped`. One place decides the dual-sink rule, so a future kind cannot be added to one sink and forgotten in the other. ONE FOLD, TWO SINKS — not two folds. The projection computes the view once and the SAME `StateEnvelope` (same revision) lands in both stores. A second FOLD is what goes stale (#346, where a citizen trusted an empty board while the announcement was fresh); a second SINK of one fold cannot drift from itself. Web behavior is untouched: the node substrate still receives everything exactly as before, so the focused-room session reads what it always read. The per-room stores are additive and, until a consumer names its room, unread. `rooms: None` in tests and headless keeps today's path. 19/19 green. The new test is the crux: two rooms speak, B last; the NODE ends on B (unchanged focused-room behavior) while room A's OWN store still holds A's view with A's message. That is precisely the state the single-slot cache used to destroy, and it is what `ViewStateRagSource` needs in order to hand a citizen HER room. Still not wired to a consumer — ipc/mod.rs must construct the registry and pass it in, then the roster source can flip. Kept separate so the wiring is reviewable as its own behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…n the browser renders (#408) The last two wires. The roster a persona grounds on is no longer a second reader of airc — it is `ViewStateRagSource<RosterViewState>` over HER room's own store, which is the SAME `RosterViewState` the web roster renders. - `ipc::global_room_substrates()` — the process-global `PerRoomSubstrates`, same `OnceLock` shape as `global_nav_focus`. The WRITER (the chat projection, in the WS boot block) and the READER (a persona's grounding, bound at spawn in supervisor) are constructed in different places and MUST land on one registry; two registries would be two stores, which is the exact defect being removed. - `positron_source::spawn` takes the registry and threads it to the projection. - `supervisor` binds the ViewState-backed roster instead of `RoomRosterSource`. This is the repair for the measured defect: a live peer's name appeared ZERO times in a citizen's prompt while the browser rendered that peer fine, because the two read different code. Now there is one definition and two render targets — eyes and mind cannot drift, because there is nothing to drift from. `room_roster_source` is left in the tree untouched (still used by the presence emitter and the experience resolver); it is simply no longer the persona's roster. No dead-code scaffolding was added to "preserve a rollback" — `git revert` is the rollback, and a dead fn kept for comfort is clutter. 24/24 green across positron_source + viewstate_rag. NOT yet live-verified — the acceptance test is a real turn's prompt capture containing a peer's name, which is the next step and the only evidence that counts here ([[never-blind]]: a fix I cannot prove reached the running binary is a fix I have not made). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…et reaches the server Two defects, both introduced by 7e0c546 ("`continuum start` execs the installed server"), both in the ONE function `start` and `reboot` share. The change was right for `start` and wrong for `reboot`, and `launch_core` had no way to tell them apart. 1. THE SOCKET WAS NEVER PASSED. The direct-exec path handed the socket to the server over CONTINUUM_CORE_SOCKET and omitted the positional argument. `main.rs` requires argv[1] and exits 1 with its usage text without it — so on any machine with an installed binary, every `start` and every `reboot` died ~2s in, having written Usage: continuum-core-server [--mode=<MODE>] <socket-path> into the start log and nothing else. Measured here tonight: the reboot killed the old core's claim on the swap, the new one never came up, and only the surviving old process kept the system answering. 2. REBOOT STOPPED BUILDING. `reboot` is THE deploy path ("edit → reboot → exercise"). Preferring the installed artifact made it structurally unable to ship an edit: it printed "building fresh binary, then swapping" and exec'd a binary from Jul 13. Every fix made since would have been invisible to the running core, and the only thing standing between that and a false success line was #194's provenance check. The fix makes the source policy an explicit argument instead of an ambient default, because the two callers want opposite things: `start` wants a core RUNNING (installed artifact is correct, and the no-source-tree user 7e0c546 was written for keeps working), `reboot` wants the core built FROM THIS CHECKOUT. `plan_launch` resolves (policy, env override, script?, artifact?) as a pure function — 7 tests, one per branch, each naming the failure it prevents. A reboot on an installed node with no checkout still restarts the artifact, but through a distinct variant that forces the CLI to SAY nothing was rebuilt rather than let a restart pass as a deploy. Server side: `continuum-core-server` was the one component in the tree hand-rolling its own socket resolution (argv or die) while `continuum`, `continuum-mcp` and every library caller go through `endpoint_paths::core_socket_path()`. argv[1] still wins; absent it, the server now agrees with everyone else instead of exiting. That disagreement is what let defect 1 exist at all — the launcher communicated over a channel the listener had never been told to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…very deploy `continuum start` execs the INSTALLED continuum-core-server (building is reserved for `reboot`), and the resolver checks ~/.continuum/bin BEFORE any cargo target dir. But that copy was written once by install.sh and never refreshed by a deploy — so on a machine that has been deploying for a month, `continuum start` silently boots a month-old core while the freshly-built one sits unused in the cache. Measured on the M5 tonight: installed artifact dated Jul 13, running build 4705, HEAD 4712. And it was not theoretical — a stray auto-start off that stale copy during a reboot is exactly what tripped the #194 deploy-provenance mismatch and cost an hour of misreading a deploy that had actually built fine. This script already publishes the CLI into ~/.local/bin on every deploy, with a comment arguing precisely this ("refreshes each deploy so PATH always points at the current build"). The core-server was simply omitted from that reasoning. Now it isn't. Placed AFTER the #194 freshness guard and before exec, so the installed artifact is only ever replaced by a binary just proven to match source — never a stale or half-built one. Atomic temp+mv so a concurrent `continuum start` cannot exec a half-written file. Non-fatal, and it says out loud when it cannot publish, because the consequence the operator needs to hear is "`continuum start` may boot an OLDER core than this one". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
|
Two deploy-path fixes landed on this branch (they blocked live-verifying the roster work, so they're here rather than a separate PR — say the word and I'll split them out):
Fix: source policy is an explicit Server side:
Verified live: |
…or the way every other RagSource does CI red on PR #2282, my defect. `cargo test -p continuum-core --lib` failed one test out of 7,042: cognition::context_budget::tests::no_new_hardcoded_context_or_prompt_size_constant_anywhere_in_the_crate The guard (context_budget.rs:356-365) matches any `const` whose name contains WINDOW|CONTEXT|CTX|TOKEN|PROMPT|CHARS, of an integer type, assigned a bare decimal literal. `const FLOOR_TOKENS: u32 = 10` (roster) and `= 18` (bench) matched on TOKEN. The number was never the problem — it is a per-UNIT content floor ("one roster line costs ~10 tokens"), not a context bound, and it does not scale with the served window. The problem is that I expressed it in a SHAPE nobody else uses. Every other source states the same fact as a function: room_board_source.rs:293 fn floor_tokens(&self) -> u32 { 32 } room_roster_source.rs:286 fn floor_tokens(&self) -> u32 { 0 } room_doctrine_source.rs:151 fn floor_tokens(&self) -> u32 { 0 } rag_budget.rs:475 fn floor_tokens(&self) -> u32; // the contract So `RagRenderable` grew a SECOND spelling of one contract — an associated const beside the trait method it feeds. That is the duplication the compression principle forbids, and it landed in the very file meant to be the template every future ViewState source gets copied from. The guard fired on the new shape, which is exactly its job. Fix: `const FLOOR_TOKENS: u32` becomes `fn floor_tokens() -> u32`, matching the established idiom. One contract, one spelling; the guard passes as a CONSEQUENCE of saying it the normal way rather than as the goal. Deliberately NOT done: no `// context-budget-exempt:` line (the escape hatch exists, but an exemption would preserve the second spelling — the actual defect), and no weakening of the guard. Guard test: 1 passed. viewstate_rag tests: 5 passed. Clean `cargo check --lib`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ranch refused it (#410) Measured today: every `airc msg` a human sends reaches EVERY citizen's subscribe stream (12/12 raw_events, sender 7711fe60) and is dropped as `no_continuum_body_hint`. Cause is in `realtime_wire::envelope_from_event` (:58-68) — it returns Ok(None) unless HEADER_FORGE_BODY_HINT == CONTINUUM_BODY_HINT, a stamp only continuum's OWN clients apply. The message is refused before its content is ever read, so a human talking to the room over the CLI is structurally unheard while the browser renders it fine (`airc.chat.projected` fires every time). The fix is one more arm in `room_turn_from_event`. It needs the CLI's actual body shape, and the probe could not supply it: `reason` names the BRANCH that refused the event, never the SHAPE that was refused. Attempts to recover the shape out-of-band failed — `airc events list --kind message` returns 0 in BOTH the project and machine-account scopes while returning 60 system-kind events, so the persisted view disagrees with what delivery demonstrably did. So instrument rather than guess. A decoder arm written against a GUESSED body is exactly how presence and control frames become fabricated perception — the hazard the existing named-skip contract exists to prevent. Next session reads one real body and writes the arm against a fact. Adds `event_kind` (the TranscriptKind, which IS the receive-side discriminator — note FrameKind lives on Frame and does NOT survive to TranscriptEvent) and a 160-char `body_preview` to the ALREADY-firing filtered_non_turn line. No new probe class, no new event, and the stream-chunk skip above stays deliberately unprobed — this cannot reintroduce the flood that skip removes. Behaviour unchanged: diagnostics only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… spike instrumentation, wire perf (#2284) * fix(ids): UUIDs are not strings — mint real ones, and stop typing ids as text (#274/#396) Joel, 2026-08-13: "Always use uuid and never corrupt them with random prefixes" and "UUID's are NOT strings. If you're using strings for id you are writing slop." Both landed on code written minutes earlier, and the grep that followed found the same defect older and wider than my one file. Three fixes, in order of how badly they were wrong: 1. The four shipped recipe ids were hand-drawn patterns (c0de0001-0000-4000-...), a name wearing a UUID's costume — readable, collidable, and fake. Replaced with genuine v4s in both the authored JSON and the `shipped::` constants, written grouped (0xfed332c3_383c_45bb_...) so the two are checkable by eye. 2. comms::{MessageId, CorrelationId} were `String` newtypes. `MessageId::new("msg-1")` let any caller invent a namespace that collides with every other caller's. Both are now `Uuid`: MessageId::new() MINTS (no caller-supplied form exists), and CorrelationId::of_exchange(id) states the derivation the old `CorrelationId(id.0.clone())` left implicit. Still distinct TYPES — the compiler, not a naming convention, is what stops one being passed as the other. 3. EndpointId is deleted. Its values were `EndpointId::new("browser")` and `("rust-core")` — a client-kind label standing in for an identity, and with it the assumption that the web client is a distinguished endpoint. It is one client among many (mobile, SDK, TUI, another node's core). TransportEnvelope.source and .target are now PeerId, the substrate's one actor identity per identity/mod.rs. Zero callers outside comms/mod.rs; the stale generated binding goes too. Also closes the on-disk authoring hole the required `id` field opened: a recipe file that names no id now gets one DERIVED from its purpose (RFC 4122 v5 under a frozen namespace), so "author a file, zero code" keeps meaning zero code — no uuidgen — and every node that loads the same file agrees on its identity with nothing to reconcile. A recipe that DOES carry an id keeps it verbatim. Tests: comms 23 pass (envelope wire shape now asserts ids round-trip as their own UUIDs; minted_message_ids_are_unique pins the collision fix), experience 43 pass including the previously-failing an_experience_authored_on_disk_needs_no_rust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * docs(CLAUDE.md): headless Rust core is the system — Node is ONE client, not the deploy path Joel, 2026-08-13: "Headless rust period. No need for node to run everything except for the web interface which is one of many, including mobile apps/sdk." — and then, because I kept treating the correction as a code-only matter: "Fix these severe misunderstandings ... regardless of where they are." This file is where the misunderstanding REPRODUCES. It is loaded into every agent session, and it opened with "EVERY TIME YOU EDIT CODE: Run `npm start` (MANDATORY)" under `cd src` — a directory that no longer exists — plus "ALL Rust binaries MUST be built via npm start". A fresh agent under amnesia reads that and concludes Node runs the system. It doesn't, and `continuum --help` has been saying so in its own first line the whole time: "build + run the headless Rust core". Rewrote the CRITICAL WORKFLOW section around what is actually true, and swept the other 11 Node-as-deploy claims scattered through the file: - The core is Rust and boots with no Node. Node builds the WEB desktop, which is one client among several (mobile, SDK, TUI, MCP, another node's core over the grid). Named the consequence, because it already cost us: a feature that lives in a client exists only for that client — how voice ended up web-only with every other citizen structurally mute (#58). Behaviour goes in the core; clients render. - The deploy path is `continuum reboot` (Rust build + relaunch + running-SHA verify), with `deploy-verify` and the version trio called out — that verification exists because a reboot once shipped a stale binary and reported success (#194). - `cargo build` stays discouraged for the RIGHT reason (a hand-built binary exists only on your machine, and a fresh clone must work with no manual steps, #291) — not the old implication that Rust must be built through npm. `cargo check` is named as the correct type-check-while-you-work tool, with the shared CARGO_TARGET_DIR. - `npm run build:ts` now says what it actually covers: the web client, and nothing about whether the core compiles. - Flagged `./jtag` inline as the legacy Node CLI. Left the invocations that follow, since their command NAMES are still accurate — it is the driver that is stale. No code change; the one surviving "npm start" is the sentence warning you off it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(cli): `uu` is the alias — `cu` is UUCP, and every harness defaulted to a binary that does not exist Joel: "Cu conflicts with some Unix program. Fix for all cases including windows" and "It's a bug Claude" — it is, and worse than doc drift. `cu` is /usr/bin/cu (UUCP call-unix) on every Unix. `uu` — the double-U of contin-UU-m — is THE official short alias, and start-server.sh has installed exactly that (plus a squatter guard) since 2026-08-01, with a comment naming this very collision. Nothing in the tree installs `cu`. But the REFERENCES never followed: - Six benchmark harnesses defaulted to `~/.continuum/cache/cargo-target/{release,debug}/cu`. Neither file exists — the only built CLI is `release/continuum`. So the default resolved to nothing and every default-args run died at the first invocation. matrix.py even carries a comment about this exact class of failure biting once before (2026-07-22, stale debug-only default silently no-opping the sweep). - Fixed by RESOLVING rather than renaming: a shared `_resolve_cli()` prefers what is actually installed on PATH (`uu`, then `continuum`) and falls back to the release build — so it works from a fresh clone, an installed box, or a dev tree, on any platform, instead of hard-coding one machine's layout. - Flag renamed `--cu` → `--uu` with all in-repo call sites updated (sweep_all → matrix → headtohead → preflight_gpu chain). Also swept 53 `cu <command>` occurrences in docs, Rust comments, and generated-TS doc comments to `uu`. Left the ones that are ABOUT the collision (memory-bridge README's "never bare `cu`", WAKEUP-AND-JOIN's rename note) — those are correct as written, and legacy/ stays quarantined. README dev section reworked in the same pass: the boot path is `continuum start` / `continuum reboot` / `continuum ping`, with Node named as what it actually is — the web client's build dependency, one client among mobile/SDK/TUI/MCP, not the thing that runs the system. Verified: all six harnesses compile (py_compile), cargo check clean, zero `target/{release,debug}/cu` paths left in the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(core): the last Python in the runtime path — one vestigial, one silently swallowing the job Joel: "Fix all nodejs and python dependencies that are breaking our headless rust core ... we must find all smell all the time and never ignore it." AUDIT RESULT FIRST, because one half is good news: Node spawns from the running core: ZERO. `Command::new("node"|"npm"|"npx"| "deno"|"bun")` has no occurrences anywhere in continuum-core. The core is genuinely headless Rust at runtime; Node exists only to build the web client. Python spawns from the running core: FOUR sites, and neither kind was honest. 1. THREE were vestigial and were hiding untested code. `file_engine.rs` had three `#[test]`s opening with `if Command::new("python3").arg("--version").output() .is_err() { return; }` — a skip-guard from when the syntax gate shelled out to `python3 -m py_compile`. That interpreter is GONE: the production path is `code::syntax::validator_for` → `unbound_calls`, pure Rust. So on any box without python3 — a CI runner, a fresh clone — three tests reported PASS while asserting nothing at all. Guards removed; all 55 file_engine tests pass without an interpreter present, which is the proof they never needed one. Also corrected the doc on `introduced_undefined_calls`, which still told the reader the analysis returns None when there is "no python". There is no python. 2. ONE is a REAL runtime dependency, and it was failing silently. `forge/start` spawns `python3 <alloy_executor>` — a script that lives in the SIBLING sentinel-ai repo, which a fresh clone of continuum does not have. When `find_alloy_executor()` returned None the handler used pid 0 and wrote `state: "queued"` — indistinguishable from a job legitimately waiting its turn. So on every machine that had only cloned this repo, `forge/start` returned SUCCESS for work that nothing would ever run. Now it fails loud, names the missing script, says where to get it, and states plainly that the job was NOT queued. The dependency itself is still there — excising it to Rust is #52/#99 — but it can no longer pretend to have worked. The pattern in both: a Python dependency that had already been removed or had never been satisfiable, still shaping behaviour through a stale guard and a fallback. `[[fallbacks-are-illegal-fail-loud]]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(identity): PersonaRef vs PeerId — a reference is not an identity, and the compiler now knows Joel: "UUID's are NOT strings … Well defined and named structs by reference must be used." Chasing that through persona_id turned up something sharper than the count. THE FINDING. `PersonaWorkspaceRegistry::resolve_persona` exists specifically to close what its own doc calls "the loose-`String` id boundary … the defect class that fed a dead id to a doomed eval." It has SEVEN call sites. SIX are its own tests. ONE is production (eval.rs). Against 55 `persona_id: String` fields. The check was correct and essentially nothing called it — a correct check nothing calls is nastier than a missing one, because it reads as covered. Nothing forced the call, because both sides were `String`. THE FIX — two types, one door: - `PersonaRef` (new, in identity/): what a CALLER writes — full UUID, 8-char short-id, or name. Explicitly NOT an identity. Its only accessor is `as_str()`; there is no `as_peer_id()`, because a name is ambiguous, mutable, and meaningless without a roster. - `PeerId` (existing canonical actor identity): what everything downstream holds. - `resolve_persona(&PersonaRef) -> Result<PeerId, _>` is now the ONLY bridge. Taking the newtype rather than `&str` is what makes resolution unskippable. `From<PeerId> for PersonaRef` exists (an identity is always a valid reference to itself); the reverse deliberately does not — it requires a roster. Wire shape is unchanged: `#[serde(transparent)]` over the same string callers already send, so no client, recipe, or stored payload changes. Short-id and name PX (#161) keeps working — that ergonomics is the whole reason a reference type has to exist separately rather than everything becoming a UUID. Converted, types pushed DOWN rather than laundered at the seam: - `CognitionEvalParams.persona_id` → `PersonaRef` - `restore_persona_workspace(&PersonaRef)` (was `&str`) - `append_failed_ledger(&PersonaRef, …)` (was `&str`) HELD, and stated rather than fudged: `CognitionEvalResult.persona_id` stays `String`. The struct derives `Default` across 21 fields and a persona reference has no sensible default — an empty one is a nonsense value that reads as a real answer. The real fix is splitting the fire-and-poll HANDLE from the completed RESULT (a handle knows only the requested ref; a result knows the resolved id), which is its own slice. Inventing a default to satisfy the type checker is the `unwrap_or` reflex: compiler quiet, runtime wrong. The reason is recorded at the field. Tests: persona_workspace 10, eval 22, identity 23 — all pass; full lib test build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * refactor(memory): the memory layer takes PersonaRef, not loose text — 21 sites Second slice of the id-typing migration. All ten `commands/memory/*` params plus the `MemoryManager` API they call now carry `PersonaRef` instead of `String`/`&str`: append_memory, append_event, load_corpus, has_corpus, get_corpus, multi_layer_recall, consciousness_context, persona_db_handle, hydrate_corpus_if_missing. Types went DOWN into the layer rather than being unwrapped at each call — the whole point of the previous slice. `.as_str()` now appears only where the value is genuinely being USED as text (a map key, a `starts_with` shape check, a directory handle), never to satisfy a signature one call later. Two `Default` derives removed (`ConsciousnessContextParams`, `LoadCorpusParams`) rather than giving `PersonaRef` a default. No caller used `::default()` on either, and an empty persona reference is a nonsense value that reads as a real answer — same reasoning as the eval result field held in the previous commit. What this makes visible, and does not yet fix: these commands still never RESOLVE. They accept a reference and hand it straight to the storage layer as a key, so a name or short-id reaches the DB unresolved. That was invisible while everything was `String`; it is now legible in the signatures. Wiring `resolve_persona` into the memory command path is the next slice (#164/#396). Tests: memory 219, rag 150 — all pass. Full lib test build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * refactor(commands): persona params carry PersonaRef — agent/solve, persona/*, cognition/observe Third slice. Seven more command param structs stop typing a persona reference as `String`: agent/solve, persona/identity/{get,set}, persona/instances/{get,despawn}, persona/wall/pin, cognition/observe (params + the `assemble` signature + its `Meta`, so the type is consistent through the result rather than converted on the way out). Same discipline as the memory slice: `.as_str()` appears only where the value is being USED as text — `id_resolve::resolve` takes a `&str` by design because it also serves rooms and cards — never to satisfy a signature. Running total across the three slices: 39 persona params + the memory API + the resolver itself. `persona_id: String` is down from 55 to 16 in the crate, and every one that remains is an internal struct holding an already-resolved id (those want `PeerId`, the next slice) rather than an unresolved caller reference. Tests: full lib test build clean, commands suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * refactor(rag/introspect): last persona PARAMS typed — and the RAG source path carries it down Fourth slice. `CognitionTraceParams`, the four other introspect params, `CognitionReplayParams` + result, `RagComposeRequest`, and `DatasetFromTurnsParams` now carry `PersonaRef`. The RAG one went two levels deep rather than stopping at the param: `load_source`, `load_memory_source`, and `load_consciousness_source` all take `&PersonaRef` now, which deleted the two `&persona_id.into()` conversions the memory slice had left at those call sites. That is the shape to aim for — when the type reaches the bottom, the adapters in the middle disappear rather than accumulating. `persona_id: String` in continuum-core: 55 → 8. Every remaining one is an internal RECORD (memory/types, should_respond's AIDecisionContext, live/types, projection, shell_types, ai/types, sentinel) holding a value copied from a param. Those are deliberately NOT converted to `PeerId` yet. They hold whatever the caller sent, and nothing on those paths resolves — typing them as an identity today would assert something the code does not do, which is worse than leaving them `String`. They become `PeerId` in the same slice that wires `resolve_persona` into those paths, not before (#164/#396). Tests: replay 3, rag 18, full lib test build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(test): 5 supervisor tests have been panicking since #398 slice 3 — the stub's premise expired `StubAircCitizen::subscribe_all_rooms` was an `unreachable!()` justified by a comment that said no test drives it. That was true when written and stopped being true at bf11a66a7 (#398 slice 3), which gave `PersonaSupervisor::materialize` a `subscribe_all_rooms` call to wire the doctrine/wall cache invalidators. Every supervisor test that materializes an adapter has been dying in that panic since — 5 of the 10, and they only surface on a FULL-suite run, which is how they sat unnoticed. Found while verifying my own id-typing slices: full suite came back 7073 passed / 5 failed, and the first question was whether I caused it. I did not — the diff of my five commits touches `commands/persona/*` only, never `persona/supervisor.rs` or `persona/airc_citizen.rs`, and `git log` on those two files points at #398. Fix: return `AircError::Transport` instead of panicking. This is NOT a fallback — the caller already handles that exact case explicitly (keeps both sources uncached, "correct, just slow", logs loud), so the tests now exercise the real degradation branch rather than aborting, and a stub still never pretends to hold a live stream. An empty stream WOULD have been the fallback: it would have looked like a working subscription that silently never invalidates. Comment rewritten to state what is true now, including why the old assertion was right when it was written. An assertion that outlives its premise is worse than no assertion — it reads as a guarantee. persona::supervisor: 10/10 pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(test): pin the stub's REFUSAL contract — the old test asserted the panic I removed `stub_subscribe_panics_loudly` existed to prove the `unreachable!()` fired, so removing that panic left the assertion failing for the right reason. Rewritten to pin what the contract actually is now: Err(Transport) — required Err(anything else) — fail (the caller branches on Transport specifically) Ok(stream) — fail LOUDEST, because that is the real fallback: a stub handing back a stream looks like a live subscription that silently never invalidates Matched rather than `expect_err`d because `FilteredEventStream` is not `Debug`. FULL LIB SUITE NOW GREEN: 7078 passed, 0 failed, 42 ignored. Before this session's last two commits it was 7073/5 — five supervisor tests panicking since #398 slice 3, only visible on a full run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * test(identity): CI guard — a String-typed identity field must be DECLARED, or the build fails Joel: "eliminate all smell or you will copy it." That is literally the mechanism — a model reading this tree learns its conventions FROM it, and `persona_id: String` was 55 sites teaching that ids are text. I did exactly that this session: minted `c0de0001-…` fake UUIDs because fake-looking ids were already normal here. Prose in CLAUDE.md does not stop that. A failing test does. Three tests in identity/mod.rs, running on every PR via the existing `cargo test -p continuum-core --lib` workflow (no skip pattern matches them): 1. `every_string_typed_identity_field_is_declared` — walks src/, finds any `<identity-name>_id: String | Option<String>` field, and fails unless it appears in LOOSE_IDS. Comments stripped first, so a doc line can never register as a field. The error names the file:field and tells the reader which typed form to reach for (`PeerId` for an actor, `PersonaRef` for an unresolved reference, a `*Id(Uuid)` newtype otherwise). 2. `no_declaration_outlives_its_defect` — a declaration whose field HAS been fixed fails too. Learned directly from `StubAircCitizen::subscribe_all_rooms`, whose comment stayed true-sounding for months after its premise expired and cost 5 silently-failing tests. 3. `declarations_carry_a_real_reason` — every entry starts with external:/pending:/ defect: and is longer than a shrug, the same bar the module-wiring audit (#344) holds. 73 declarations, honestly categorized: - **external** — LiveKit participant/room ids, log-envelope correlation fields. Another system owns the wire format. - **pending** — ours, but nothing on that path RESOLVES yet. Typing it as an identity today would assert something the code does not do. Converts in the slice that wires resolution (#164/#396). - **defect** — `peer_id: String` × 5. These ARE `PeerId`. I attempted the conversion in this session and reverted it: `PeerId` has no `JsonSchema` impl and the construction sites hold `&str`, so it needs its own slice rather than a rushed cascade. Declared as a defect so it stays visible instead of blending in. POSITIVE CONTROL, because a guard nobody has watched fail is the exact shape of defect I found earlier today: added `struct PositiveControlProbe { pub owner_id: String }`, confirmed the guard failed naming `identity/mod.rs: owner_id`, removed it, confirmed green. The guard also caught 12 sites my own inventory grep had missed — it is already strictly better than the method I was auditing with. Full lib suite: 7081 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(cli): `continuum start` execs the installed server — building is now --from-source `start` shelled unconditionally into tools/scripts/start-server.sh, which runs a full cargo build. A "governed" lifecycle verb was a wrapper around a bash file that only exists inside a repo checkout (BigMama's find, 2026-08-13): - a user holding ONLY the installed binary, with no source tree, could not start a core at all; - the CLI printed one line then went silent for the length of a compile — called hung three separate times; - the façade's honesty depended entirely on the script underneath. Same class as the rest of the night's defects: a governed surface over a hand-rolled path. Now: `launch_core` locates the installed `continuum-core-server` (CONTINUUM_CORE_SERVER override → next to the running exe → ~/.continuum/bin → target/{release,debug} walking up) and execs it directly, keeping the existing detach/log/pidfile handling unchanged. Building is an EXPLICIT request (`continuum start --from-source`), never the silent default, and the no-binary fallback says WHY it fell back and that it compiles first, rather than going quiet for minutes. The override refuses loudly when set but not a file — a wrong override must not look like an absent one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(core): tree is GREEN again — 7081/0 (peer_id → PeerId finished, guard debt cleared) The working tree could not build tests. This finishes the identity-typing work that broke it and clears the guard debt it left behind. `cargo test -p continuum-core --lib --features metal,accelerate`: 7081 passed, 0 failed. PEER_ID IS A TYPE, NOT TEXT. The production conversions landed earlier; the test fixtures were left holding `&str`, so the crate compiled and the test target did not. Fixtures now derive a real UUID from the literal they used to carry (v5 under NAMESPACE_OID), which preserves every cross-site equality the tests depended on — "peer-a" in two places is still the same peer, it is just an id now instead of a name in costume. CAUGHT BY DOING IT: contracts/verification had the manifest keyed by a derived PeerId while the EVENT still claimed a raw string signer. The lookup is BY that signer, so converting one side made every verification test fail as MissingPeerManifest. One `test_peer_id` / `test_peer_str` pair now feeds both sides — the same shape of defect the newtype exists to prevent, reproduced in the fixtures while removing it from production. REAL HARDENING, not just fixture churn: `AircPeerManifest::validate` had DROPPED its empty-peer_id check on the theory that typing the field made it impossible. Typing killed `""`. It did NOT kill `Uuid::nil()`, which is still constructible and still means nobody — the type narrowed the hole rather than closing it. The guard is back, at the remaining expressible form. GUARD DEBT CLEARED: loose_id_guard's `no_declaration_outlives_its_defect` went red, correctly — four `peer_id` entries in LOOSE_IDS described fields that are now `PeerId`. Removed. The guard failing here is it working: a declaration list that can rot into a graveyard is worth nothing. Also includes the accumulated session tree: the Joel→Operator fixture sweep (no person's name hardcoded in test data), ts-rs regeneration, and rustfmt across the crate. That is why this touches ~717 files; the behavioural change is the identity typing and the nil-PeerId guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(tests): a subsystem-named room does not belong in a fixture either `k3-serving` had no lifetime because it was named for a SUBSYSTEM, so it never died — a month-old durable subscription whose board reads were all corpses. Parted from both scopes. It also sat in TEST FIXTURES, which is the transmission vector: fixtures teach the next reader the convention, so a fixture naming a room after a subsystem teaches that rooms are named for subsystems. Renamed to `bench-swe-run-1` — an activity with a lifetime, which is what a room IS. The two remaining mentions in work.rs are the INCIDENT RECORD (4 of 12 cards there carried a 134h-expired lease) and are kept, now annotated RETIRED so nobody copies the naming from them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(benchmark): an UNGRADEABLE grade is an ABSENCE, not a capability zero — carry it on the wire and the board `SweGradeResult.error` documents its own contract: "a result with `error` is an ABSENCE, not a zero, and must never be tallied as a failed attempt." The grader earns that honestly — for the env class it re-runs the PRISTINE tree before declaring a fault, so a genuinely broken patch is never mislabelled. Then both consumers dropped the classification: - `benchmark.attempt.end` / `benchmark.autograde` published `resolved=false gate_ok=false` and nothing else. Every wire consumer (probe router → rooms, exam-room widgets, pulse monitors) reads that as a citizen who tried and lost. - `fold_run_card` read the RESULT's `infra_error` but never the GRADE's `error`, so the board folded `resolved: false` + phase `failed` for the same runs. The attempt loop already broke correctly on `g.error.is_some()` (attempts 2 and 3 were never burned) — the loss was purely in what got PUBLISHED, which is the part anything downstream can actually read. Measured on this box 2026-08-13: 8 of 36 distinct instances (14 of 91 receipts, 22%) grade UNGRADEABLE — requests, pylint, pytest and sympy. Every one of those zeros was indistinguishable from a capability failure on the wire, so the denominator of any rate computed off this stream was poisoned. Found by digging into sympy__sympy-11400: p2p 0/29 on the PRISTINE tree, i.e. the suite does not run in that environment at all. (#380/#383 own fixing the environments; this commit owns never again reporting their faults as scores.) - attempt.end + autograde now carry `ungradeable` + `grade_error` - `infra_error` takes the grade's error too — one field meaning "no valid verdict, and why", fed by both sources rather than a second parallel field - `resolved` returns to `None` when ungradeable — the same "no verdict" a pre-grade card carries, because that is the truth - new phase `ungradeable`, ordered ahead of `failed` (a run can carry both a failed marker and an ungradeable grade; the absence is the truer of the two) Test asserts absence-not-zero on the real sympy-11400 shape, with a positive control (same shape, no grade error) that must still fold as a capability zero — so the test cannot pass by simply never reporting failure. This is the #384/#386 class one layer up: those classified INFRA at the solve level, this classifies it at the GRADE level and gets it onto the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(swe): gold-gate the harness — refuse an env whose pytest cannot RUN, instead of caching a tree that grades every attempt UNGRADEABLE Root cause of the 22% ungradeable rate, glass-boxed on sympy__sympy-11400. The era pin at the top of this block downgrades pytest to the instance's own date, which is right when the era INTERPRETER rung (#2253) found a matching interpreter. When it can't — no Python 3.5 on a modern macOS — the venv falls back to a modern interpreter while pytest stays pinned to the instance's year, and the resulting PAIR can be structurally unable to run. Measured: pytest 2.9.2 (correct for 2016) on Python 3.9.6 dies in `pytest_configure` with INTERNALERROR before collecting anything — reproduced on a two-line trivial test, outside the repo, no conftest, no sympy involved. Every test then "fails", the pristine p2p reads 0/29, the tree grades UNGRADEABLE. 8 of 36 distinct instances on this box are in that state. So: prove the harness executes before handing the env to a citizen. A version pin is a GUESS about compatibility; running it is the evidence. `--version` is not enough — it answers happily for a pytest that dies on any real run. This REFUSES rather than self-heals, and that is measured, not assumed. The obvious repair (reinstall a modern pytest) was tried against this exact tree and does NOT work: pytest 8.4.2 → loads, dies in sympy 1.0's 2016 conftest on the `py.path` hook API removed in pytest 7 pytest 6.2.5 → dies on `py.test.mark.slow`, removed in pytest 4 pytest 2.9.2 → cannot run on Python 3.9 at all The band that both RUNS on 3.9 and LOADS a 2016 conftest is EMPTY. No version choice rescues this class, so an auto-repair would silently trade one void tree for another. What DOES work, verified on this tree — 30/30 passing — is sympy's OWN runner (`sympy.test(...)`) on the same interpreter. That is #383's shape ("django needs its OWN test runner") generalised: the runner is a property of the repo era, not a pytest version to search for. `run_tests` is pytest-only today, so until it grows a runner seam this env genuinely cannot produce a verdict — and it now says so loudly, naming the incompatibility and pointing at the runner gap, instead of caching a broken env for every later run to inherit. [[brittleness-is-the-highest-priority-work-there-is]] — heal what is known-safe, REPORT what needs a human decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * docs(arch): benchmarks are ADAPTERS into recipes/activities — never a parallel runner (Joel's ruling, + STOP gate) The consequence that makes it law: the learning flywheel consumes ROOM TURNS (L1 lifts tool-traces from captured turns, L2 triggers on turn-completion). A detached agent/solve writing progress/<run>.grade.json produces NO turns — so a citizen can burn 12 acts, write a patch, take a graded verdict, and none of it reaches the curriculum. Maximum effort, zero learning. That, not the pass rate, is why benchmarks have failed. Names what is parallel today (ledger files, scraped probes, a second board projection in fold_run_card, private grade.json), the target shape (import task+oracle only → project into a recipe → the ROOM is the runner → grading is the activity outcome → learning falls out because the work happened as turns), and a one-line acceptance test: can a citizen standing in the room perceive the run's state through the same ViewState pipe the human's screen uses? Adds a CLAUDE.md STOP gate over benchmark.rs / agent/solve.rs / swe_bench.rs so an agent arriving under amnesia must read it before touching run state. Written because that is exactly what happened: this session shipped two correct fixes that HARDEN the parallel path instead of dissolving it, including adding a field to a benchmark probe so external consumers could parse it better — which is the smell the doc now names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(activity): a room's recipe binding finally has a READER — every room resolves to what it IS (#6/#274/#329) `activity/spawn` has always published a room→recipe binding to the wall, and its own doc says why: "Without this the room forgets which recipe it is and every client falls back to projecting it as a plain chat room." That was accurate. The binding had NO READER. `RECIPE_WALL_CATEGORY` appeared in exactly one file — the writer — plus a test asserting the string equals "recipe". So the whole recipe layer was live and inert at once: recipes authored as data, a `RecipeExperienceSource` projecting them, four shipped manifests including a benchmark carrying scoreboard/central/feed regions — and `DefaultRoomPurpose` answering "chat" for every room in existence, so none of it ever resolved. A benchmark run's room and a chat room were the same object to every renderer AND to the citizen standing inside one. That is what "benchmarks are a parallel system" looks like at the substrate: not a missing feature, a write with no reader ([[a-correct-check-that-nothing-calls-is-nastier-than-a-missing-one]]). - `experience/binding.rs` — `RoomRecipeBinding` + `project_binding`, the typed body and the one rule for turning a room's wall into its identity. Sibling of `standing.rs`, same shape and the same fail-loud stance: no binding is `Ok(None)` (a bare `airc join` makes a chat room), an UNREADABLE binding is an error, never a silent downgrade to chat. - `ipc/recipe_room_purpose.rs` — `RecipeRoomPurpose`, the `RoomPurposeSource` impl the seam's own doc has been waiting for. Event-invalidated cache, not a per-read fetch: `purpose_for` is sync and sits on the projection's store path, so an owner task folds `wall:changed` and re-reads the authoritative binding (the supersede chain is airc-owned and cannot be reconstructed from a delta — same discipline as the wall projector). Seeds every subscribed room at boot so an activity spawned before this core booted resolves without waiting for someone to re-pin something. - `activity/spawn` now SERIALIZES the shared type instead of a hand-authored `json!`. Both sides agree by construction — which mattered exactly zero while nothing read it, and matters permanently now. - `positron_source::spawn` takes the purpose source by injection; boot passes the live index when a daemon is present, `default_source()` (every room → chat) when headless. Honest edges, all pinned by tests: an unbound room, an unreadable binding, and a failed read all resolve to "chat" — the seam is total — but the two failures say so LOUDLY on the probe stream (`activity.purpose.unreadable_binding`, `activity.purpose.read_failed`). A binding naming a purpose no recipe declares resolves verbatim, and `RecipeExperienceSource` then honestly returns no manifest rather than substituting one. Known follow-up, named rather than hidden: this adds a FOURTH node airc reader (presence/wall/kanban/purpose). Consolidating them onto one attach is real work and is not this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(activity): the purpose index must SAY it ran — a silent fold is indistinguishable from a dead one First live test of the purpose index produced zero probes, and I could not tell from the evidence whether that meant "attached, seeded, nothing bound" or "never spawned". The store on disk settled it, but only because I went looking for a sqlite file — the component itself said nothing either way. That ambiguity is the defect ([[a-correct-check-that-nothing-calls-is-nastier-than-a-missing-one]]). - attach logs like its three sibling node readers (presence/wall/kanban all do) - `activity.purpose.seeded` on every boot with {rooms, bound} — "0 bound of 1" is a FACT, and a different fact from silence - `activity.purpose.refreshed` on every wall-change cue, so the invalidation path is observed rather than inferred (wall changes are rare; the probe costs nothing) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(persona): RAG is a RenderTarget — one ViewState renders to eyes AND mind (the pattern) `ACTIVITY-ROOM-PATTERNS.md` has said this since it was written: "the same transform serves a human's eyes and a persona's mind, because RAG is a render target, not a separate pipeline" … "the human's UI and the persona's grounding are the same projection rendered two ways — they cannot drift, because there is one definition" … "Never render the two from separate code." The code rendered them from separate code anyway. The human's roster comes from `RosterViewState` on the served Substrate; the citizen's came from `persona::room_roster_source` — a second reader, own fetch, own freshness, own failure modes. Same for the board (`KanbanViewState` vs `room_board_source`) and the wall. Three parallel pairs, and one of them was MEASURED delivering a live peer's name ZERO times into a citizen's prompt while the browser rendered that peer fine. This is the compression, not new architecture: - `RagRenderable` — a tiny per-kind impl: KIND (the SAME const the web subscribes to), block label, expand verb, measured floor, salience-ordered units, room scope. - `ViewStateRagSource<V>` — ONE generic adapter making any such kind a `RagSource`. Budgeting, packing, cursors, token estimation, honest-empty, and the room gate are written once. N kinds cost N small impls and zero new plumbing. Properties that fall out rather than being bolted on: - **Cannot drift** — the adapter reads the SAME Substrate the WS server serves. - **Freshness** — no second fold to lag, so the #346 staleness class (citizen trusts an empty board while the announcement is fresh) can't recur here by construction. - **Degrades** — units pack most-salient-first, so a tight window yields FEWER members, never a chopped one (the property `floor_tokens` exists to protect). - **One room gate** — reuses `room_scope_allows`, the shared predicate, rather than a second copy of the same decision. Outlier-validated per CLAUDE.md's methodical process, both in one file so a bad abstraction fails immediately: - A: `RosterViewState` — people, identity, room-scoped. The measured defect's cure. - B: `BenchViewState` — numbers, no identity, per-row verdicts, node-scoped. B needed ZERO adapter changes, which is the whole test. chat / kanban / wall / serving / nav / foundry are now registrations, not builds. 5/5 tests green, each with a `// what this catches:`. One test's arithmetic was wrong on the first run (budget 12 fit all three ~4-token member lines); the PACKING was correct and the test was fixed to 8 — noted in the test itself, because "make the failing assert pass" is how a real invariant gets quietly weakened. NOT YET WIRED into prompt assembly — the seam exists and is proven; swapping the three bespoke sources over is the next commit, so the swap can be reviewed as a behavior change on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * docs(persona): name the substrate prerequisite IN the module, before someone swaps a source onto it The obvious next move on `viewstate_rag` is to rebind `room_roster_source` at supervisor.rs onto the new adapter. Reading the substrate first showed that would be a REGRESSION, so the module now says so at the top where a future reader will actually be standing. The `Substrate` cache is keyed by KIND ALONE — positron's own `revisions.rs` names the `(room_id, kind)` tuple as a future extension. So the node substrate holds ONE room's roster: the focused room's. Swapping today means this adapter's room gate correctly abstains for every persona whose turn is in a different room, and personas are first-class MULTI-room subscribers. Most citizens would go BLIND rather than mis-sighted — trading "sometimes wrong" for "reliably empty" is not a repair. Filed the prerequisite as #408 (per-room substrate key) with the acceptance test: two personas in DIFFERENT rooms each receive THEIR room's roster in one tick. This is the note I would have wanted before shipping the swap, not after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(positron): per-ROOM substrates — the keystone that lets a citizen read HER room (#408) The node's cache is keyed by KIND ALONE. `scoping.rs` said it in its own words — "Everything else is per-room and stays on the node substrate" — which is the bug in one sentence: those kinds are TREATED as per-room but share ONE store, so the node holds whichever room wrote last (the FOCUSED room) and every other room reads empty. That is what blocks the citizen side of positron. `persona/viewstate_rag.rs` makes any ViewState a RagSource so a citizen and a browser read ONE definition — but a persona is a first-class MULTI-room subscriber, so under one shared slot most citizens would get an EMPTY roster rather than a wrong one. "Reliably blind" is worse than "sometimes wrong", which is why that swap was NOT made first. The fix is not new machinery — it is the SECOND INSTANCE of a pattern already here: `PER_USER_KINDS` + `PerUserSubstrates` already solve "N scopes share a kind namespace" for citizens (nav). This adds the room axis in the same shape: - `PER_ROOM_KINDS = [chat, roster, kanban, wall]` — open by data, like PER_USER_KINDS - `PerRoomSubstrates::for_room(room)` — one substrate per room, created on first use - `CompositeCache` routes THREE scopes: per-user → citizen store, per-room → room store, everything else (bench, serving, system-metrics) → the node store, because those describe the NODE and have no scope to route to. ADDITIVE ON PURPOSE. `CompositeCache::new` is preserved verbatim and still resolves room kinds from the node store, so every existing caller keeps today's behavior; scoping is opt-in via `CompositeCache::scoped`. A migration that silently re-pointed every reader would make "did this change anything?" unanswerable. 8/8 green. The acceptance test is the one that matters: `two_rooms_each_keep_their_own_state_in_the_same_tick` — two rooms, each reads ITS OWN state, neither overwritten. Plus: writer and reader of one room share ONE store (no second fold to go stale — the #346 class), the unscoped constructor is unchanged, and the three-way route neither merges nor leaks across scopes. No positron-core change, no wire change, no tag bump — entirely in-tree. My first estimate of this task understated it and my second overstated it as a cross-language contract change; reading `scoping.rs` settled it as an in-tree application of an existing pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(positron): the chat projection writes per-ROOM as well as node — one fold, two sinks (#408) Per-room substrates existed as of the previous commit and were EMPTY: nothing wrote to them, so they were a correct mechanism with no data — the same write-with-no-reader shape as the recipe binding, inverted. `ChatProjection::store` now mirrors every per-room envelope (chat, roster, the Experience manifest) into that room's own store via ONE helper, `store_room_scoped`. One place decides the dual-sink rule, so a future kind cannot be added to one sink and forgotten in the other. ONE FOLD, TWO SINKS — not two folds. The projection computes the view once and the SAME `StateEnvelope` (same revision) lands in both stores. A second FOLD is what goes stale (#346, where a citizen trusted an empty board while the announcement was fresh); a second SINK of one fold cannot drift from itself. Web behavior is untouched: the node substrate still receives everything exactly as before, so the focused-room session reads what it always read. The per-room stores are additive and, until a consumer names its room, unread. `rooms: None` in tests and headless keeps today's path. 19/19 green. The new test is the crux: two rooms speak, B last; the NODE ends on B (unchanged focused-room behavior) while room A's OWN store still holds A's view with A's message. That is precisely the state the single-slot cache used to destroy, and it is what `ViewStateRagSource` needs in order to hand a citizen HER room. Still not wired to a consumer — ipc/mod.rs must construct the registry and pass it in, then the roster source can flip. Kept separate so the wiring is reviewable as its own behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * feat(persona): a citizen reads WHO IS PRESENT from the same projection the browser renders (#408) The last two wires. The roster a persona grounds on is no longer a second reader of airc — it is `ViewStateRagSource<RosterViewState>` over HER room's own store, which is the SAME `RosterViewState` the web roster renders. - `ipc::global_room_substrates()` — the process-global `PerRoomSubstrates`, same `OnceLock` shape as `global_nav_focus`. The WRITER (the chat projection, in the WS boot block) and the READER (a persona's grounding, bound at spawn in supervisor) are constructed in different places and MUST land on one registry; two registries would be two stores, which is the exact defect being removed. - `positron_source::spawn` takes the registry and threads it to the projection. - `supervisor` binds the ViewState-backed roster instead of `RoomRosterSource`. This is the repair for the measured defect: a live peer's name appeared ZERO times in a citizen's prompt while the browser rendered that peer fine, because the two read different code. Now there is one definition and two render targets — eyes and mind cannot drift, because there is nothing to drift from. `room_roster_source` is left in the tree untouched (still used by the presence emitter and the experience resolver); it is simply no longer the persona's roster. No dead-code scaffolding was added to "preserve a rollback" — `git revert` is the rollback, and a dead fn kept for comfort is clutter. 24/24 green across positron_source + viewstate_rag. NOT yet live-verified — the acceptance test is a real turn's prompt capture containing a peer's name, which is the next step and the only evidence that counts here ([[never-blind]]: a fix I cannot prove reached the running binary is a fix I have not made). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(cli): restore the deploy path — reboot builds again, and the socket reaches the server Two defects, both introduced by 7e0c5469a ("`continuum start` execs the installed server"), both in the ONE function `start` and `reboot` share. The change was right for `start` and wrong for `reboot`, and `launch_core` had no way to tell them apart. 1. THE SOCKET WAS NEVER PASSED. The direct-exec path handed the socket to the server over CONTINUUM_CORE_SOCKET and omitted the positional argument. `main.rs` requires argv[1] and exits 1 with its usage text without it — so on any machine with an installed binary, every `start` and every `reboot` died ~2s in, having written Usage: continuum-core-server [--mode=<MODE>] <socket-path> into the start log and nothing else. Measured here tonight: the reboot killed the old core's claim on the swap, the new one never came up, and only the surviving old process kept the system answering. 2. REBOOT STOPPED BUILDING. `reboot` is THE deploy path ("edit → reboot → exercise"). Preferring the installed artifact made it structurally unable to ship an edit: it printed "building fresh binary, then swapping" and exec'd a binary from Jul 13. Every fix made since would have been invisible to the running core, and the only thing standing between that and a false success line was #194's provenance check. The fix makes the source policy an explicit argument instead of an ambient default, because the two callers want opposite things: `start` wants a core RUNNING (installed artifact is correct, and the no-source-tree user 7e0c5469a was written for keeps working), `reboot` wants the core built FROM THIS CHECKOUT. `plan_launch` resolves (policy, env override, script?, artifact?) as a pure function — 7 tests, one per branch, each naming the failure it prevents. A reboot on an installed node with no checkout still restarts the artifact, but through a distinct variant that forces the CLI to SAY nothing was rebuilt rather than let a restart pass as a deploy. Server side: `continuum-core-server` was the one component in the tree hand-rolling its own socket resolution (argv or die) while `continuum`, `continuum-mcp` and every library caller go through `endpoint_paths::core_socket_path()`. argv[1] still wins; absent it, the server now agrees with everyone else instead of exiting. That disagreement is what let defect 1 exist at all — the launcher communicated over a channel the listener had never been told to read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(deploy): publish the verified core binary to the installed path every deploy `continuum start` execs the INSTALLED continuum-core-server (building is reserved for `reboot`), and the resolver checks ~/.continuum/bin BEFORE any cargo target dir. But that copy was written once by install.sh and never refreshed by a deploy — so on a machine that has been deploying for a month, `continuum start` silently boots a month-old core while the freshly-built one sits unused in the cache. Measured on the M5 tonight: installed artifact dated Jul 13, running build 4705, HEAD 4712. And it was not theoretical — a stray auto-start off that stale copy during a reboot is exactly what tripped the #194 deploy-provenance mismatch and cost an hour of misreading a deploy that had actually built fine. This script already publishes the CLI into ~/.local/bin on every deploy, with a comment arguing precisely this ("refreshes each deploy so PATH always points at the current build"). The core-server was simply omitted from that reasoning. Now it isn't. Placed AFTER the #194 freshness guard and before exec, so the installed artifact is only ever replaced by a binary just proven to match source — never a stale or half-built one. Atomic temp+mv so a concurrent `continuum start` cannot exec a half-written file. Non-fatal, and it says out loud when it cannot publish, because the consequence the operator needs to hear is "`continuum start` may boot an OLDER core than this one". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(persona): one contract, one spelling — a ViewState states its floor the way every other RagSource does CI red on PR #2282, my defect. `cargo test -p continuum-core --lib` failed one test out of 7,042: cognition::context_budget::tests::no_new_hardcoded_context_or_prompt_size_constant_anywhere_in_the_crate The guard (context_budget.rs:356-365) matches any `const` whose name contains WINDOW|CONTEXT|CTX|TOKEN|PROMPT|CHARS, of an integer type, assigned a bare decimal literal. `const FLOOR_TOKENS: u32 = 10` (roster) and `= 18` (bench) matched on TOKEN. The number was never the problem — it is a per-UNIT content floor ("one roster line costs ~10 tokens"), not a context bound, and it does not scale with the served window. The problem is that I expressed it in a SHAPE nobody else uses. Every other source states the same fact as a function: room_board_source.rs:293 fn floor_tokens(&self) -> u32 { 32 } room_roster_source.rs:286 fn floor_tokens(&self) -> u32 { 0 } room_doctrine_source.rs:151 fn floor_tokens(&self) -> u32 { 0 } rag_budget.rs:475 fn floor_tokens(&self) -> u32; // the contract So `RagRenderable` grew a SECOND spelling of one contract — an associated const beside the trait method it feeds. That is the duplication the compression principle forbids, and it landed in the very file meant to be the template every future ViewState source gets copied from. The guard fired on the new shape, which is exactly its job. Fix: `const FLOOR_TOKENS: u32` becomes `fn floor_tokens() -> u32`, matching the established idiom. One contract, one spelling; the guard passes as a CONSEQUENCE of saying it the normal way rather than as the goal. Deliberately NOT done: no `// context-budget-exempt:` line (the escape hatch exists, but an exemption would preserve the second spelling — the actual defect), and no weakening of the guard. Guard test: 1 passed. viewstate_rag tests: 5 passed. Clean `cargo check --lib`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * glass-box(persona): say WHAT the rejected event was, not just which branch refused it (#410) Measured today: every `airc msg` a human sends reaches EVERY citizen's subscribe stream (12/12 raw_events, sender 7711fe60) and is dropped as `no_continuum_body_hint`. Cause is in `realtime_wire::envelope_from_event` (:58-68) — it returns Ok(None) unless HEADER_FORGE_BODY_HINT == CONTINUUM_BODY_HINT, a stamp only continuum's OWN clients apply. The message is refused before its content is ever read, so a human talking to the room over the CLI is structurally unheard while the browser renders it fine (`airc.chat.projected` fires every time). The fix is one more arm in `room_turn_from_event`. It needs the CLI's actual body shape, and the probe could not supply it: `reason` names the BRANCH that refused the event, never the SHAPE that was refused. Attempts to recover the shape out-of-band failed — `airc events list --kind message` returns 0 in BOTH the project and machine-account scopes while returning 60 system-kind events, so the persisted view disagrees with what delivery demonstrably did. So instrument rather than guess. A decoder arm written against a GUESSED body is exactly how presence and control frames become fabricated perception — the hazard the existing named-skip contract exists to prevent. Next session reads one real body and writes the arm against a fact. Adds `event_kind` (the TranscriptKind, which IS the receive-side discriminator — note FrameKind lives on Frame and does NOT survive to TranscriptEvent) and a 160-char `body_preview` to the ALREADY-firing filtered_non_turn line. No new probe class, no new event, and the stream-chunk skip above stays deliberately unprobed — this cannot reintroduce the flood that skip removes. Behaviour unchanged: diagnostics only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(inference): a context overflow is a TYPE, not a prose string — and stop deep-cloning every wire body Two defects, one cause: information that was already structured got flattened into a String (or copied wholesale) at a boundary, and every consumer downstream had to guess or pay for it. 1. INFERENCE ERRORS WERE STRINGS. `ai/openai_adapter.rs` returns `Err(format!("... returned 400 ..."))`; there was no typed inference error anywhere in the core (grep: 0 hits for `enum InferenceError|AdapterError| GenerateError`). So `cognition::act_observe::settle` could not distinguish a transient backend wedge from a deterministic context overflow, and retried BOTH blind. Blind retry is right for the wedge (#386 measured ~2/3 recovery) and catastrophic for the overflow: same prompt, same slot, same 400, forever. Measured live 2026-08-13 on the Devstral lane — four overflows, each overshooting by a small margin, each retried to exhaustion, each burning a whole turn: request (15697 tokens) exceeds the available context size (15104 tokens) request (16751 tokens) exceeds the available context size (16384 tokens) llama-server hands us the machine-readable tag AND both numbers. We format!-ed them into prose and threw the structure away. `ai/inference_error.rs` parses that body ONCE, at the seam, into `ContextExceeded { requested, available }` / `Unavailable` / `Transient` / `Protocol`, and exposes the predicate the settle loop could not express: `is_retryable_unchanged()`. No consumer of this type ever matches on prose; if you want to, the missing thing is a variant. Cost: the JSON parse is gated on 400 — the only status that can BE an overflow — so every other failure classifies without touching the body, and nothing runs on the success path. 2. EVERY CONTINUUM WIRE BODY WAS DEEP-CLONED TO READ IT. `airc/realtime_wire.rs` did `serde_json::from_value(value.clone())` — `from_value` consumes by value, so the clone was reflexive. That is a full copy of the payload per event PER PERSONA, since every citizen's subscribe stream sees every event. `&Value` is itself a Deserializer, so the envelope is now read in place. This is the same O(personas x payload) waste the stream-chunk header guard directly above already exists to avoid (documented there: 2644 of 4776 filtered events were chunks, decoded-then-discarded by each of four personas). The header decides; the body is never copied to find out. NOT fixed here, deliberately: `SettleStep::InferenceFailed` still carries a String, so settle.rs:337 still retries overflows blind. Threading the type through `workspace.rs` changes the adapter trait signature (`Result<_, String>` -> `Result<_, InferenceError>`, 15 sites in adapter.rs alone) and is its own commit. This one lands the keystone + tests so that threading is a mechanical change against a proven type. Verified: cargo check clean (no new warnings); 7 new tests green, each with a `// what this catches:` naming the live defect or the over-eager-classification hazard it guards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * perf(wire): borrow-decode the two hottest payloads — stop deep-cloning the whole prompt per generation Same shape as 62547d09e, two more sites, found by auditing `from_value(x.clone())` on per-event and per-turn paths. `serde_json::from_value` consumes by value, so every one of these clones exists ONLY to satisfy that signature — `&Value` is itself a Deserializer and decodes in place. - `runtime/airc_interceptor.rs`: for `ai/generate` the command params ARE the TextGenerationRequest, i.e. the whole prompt. `params` is already `&Value` (line 114). We were deep-copying the largest payload in the system on EVERY generation, purely to hand it to `from_value`. - `airc/inbound_attach.rs`: `payload.inline.clone()?` copied the whole inline capacity payload per inbound event. `.as_ref()?` borrows instead. Two more of the identical shape remain, deliberately not touched here so this stays one reviewable idea: `persona/viewstate_rag.rs:179` (per RAG render) and `persona/service_loop.rs:1783` (per turn). Also worth a follow-up: `runtime/command_envelope.rs:47` shows `from_value(params.clone())` in a DOC COMMENT — the pattern is being taught, which is how it spread. Verified: cargo check clean, no new warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * glass-box(inference): classify the backend rejection at the seam and probe it — say WHICH failure this was Follow-on to 62547d09e. `ai/inference_error.rs` existed but nothing on the live path called it. Now the one site that still holds both the HTTP status and the raw body (openai_adapter.rs, the non-success arm of the streaming chat request) runs `InferenceError::from_http` and emits the classification as a probe: ai.request.rejected provider status retryable_unchanged requested_tokens available_tokens SCOPE, stated honestly: this is INSTRUMENTATION, not the retry fix. The trait's error type is still `String`, so `cognition::act_observe::settle` still cannot match on the variant and still retries every fault blind. What changes is that an operator reading the receipt can finally tell a transient wedge from a deterministic context overflow — and for an overflow, sees BOTH token counts instead of prose. Until the type reaches settle, this probe is the only place that distinction is visible anywhere in the system. Why that is worth landing alone: the #400 mechanism was misdiagnosed for days (Metal wedge -> window-number bug -> estimator undercount, all disproven) purely because every failure class looked identical downstream. The next 400 labels itself. `retryable_unchanged=false` on a receipt is the standing evidence that the blind retry is burning a turn — it is the exact predicate the threading commit will act on. Verified: cargo check clean, no new warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo * fix(inference): prefill is PROGRESS and queue wait is not SILENCE — stop killing healthy turns at 90s (#400/#385) MEASURED on the live lane, and it reframes #400 entirely: the backend was never wedged. WE were cancelling it. llama-server's own log, task 775: n_tokens= 2048 progress=0.12 290 tok/s n_tokens= 6144 progress=0.36 n_tokens=12288 progress=0.73 145 tok/s <- healthy, still climbing W srv stop: cancel task, id_task = 775 <- ~90s in, OUR cancel Task 776 identical. Each cancel also evicts the slot's prompt cache ("removing oldest entry (size = 2240.790 MiB)"), so the retry re-prefills from zero and can never do better — which is where #266's 96.3%-prefill / 5.5%-cache-hit shape comes from. TWO different silences were being policed by ONE 90s budget: 1. PREFILL. `return_progress` defaults false and we never set it, so the server says NOTHING between accepting the request and the first token. Measured: a ~20k-token prompt produced ZERO bytes for 286 SECONDS. A citizen at 89.5% window occupancy (delib.turn.demand: demand=25671 of context_window=28672) was structurally incapable of ever emitting a token. Fix: request `return_progress` (gated on the existing typed llamacpp_sampling_extensions capability, beside cache_prompt), parse the frame, and count a RISING `processed` as progress. Strictly `>`, so a frozen counter — the actual #385 wedge signature — still fails in 90s. The detector keeps its teeth; it just stops failing healthy work. 2. QUEUE WAIT. Measured positive control: a TINY 2,237-token prompt got its first byte of any kind at t=115.2s, then prefilled and answered within 7s. The slot was busy with a co-tenant for 115s. total_slots=1 with four citizens, so this expires the 90s budget routinely on a perfectly healthy lane — and it is contention, not death. Fix: before the slot starts our work the bound is PRE_STREAM_HEADER_TIMEOUT (300s), which already carries exactly this justification in its own doc …
The gap
`activity/spawn` has always published a room→recipe binding to the wall, and its own doc says why: "Without this the room forgets which recipe it is and every client falls back to projecting it as a plain chat room."
That was accurate. The binding had no reader. `RECIPE_WALL_CATEGORY` appeared in exactly one file — the writer — plus a test asserting the string equals `"recipe"`.
So the whole recipe layer was live and inert at once: recipes authored as data, `RecipeExperienceSource` projecting them, four shipped manifests including a benchmark carrying scoreboard/central/feed regions — and `DefaultRoomPurpose` answering `"chat"` for every room in existence, so none of it ever resolved. A benchmark run's room and a chat room were the same object to every renderer AND to the citizen standing inside one.
That is what "benchmarks are a parallel system" looks like at the substrate: not a missing feature, a write with no reader.
What landed
Live proof
Deployed at build 4705+. A persona pinned a `recipe` wall post to academy; the index fired:
```
activity.purpose.unreadable_binding
room_id: 3be59578… (academy)
error: "room recipe binding is present but unreadable
(key must be a string at line 1 column 2) — refusing to guess…"
```
The whole wire, end to end: wall post → `wall:changed` on the bus → room-scoped wall re-read → parse → loud refusal instead of a silent downgrade to chat. The body was malformed because the CLI mangled it (filed as task #405 — a String param cannot receive JSON text), so this is simultaneously the positive control on the read path and the negative control on the failure path.
Honest edges (all pinned by tests)
Unbound room, unreadable binding, and failed read all resolve to `"chat"` — the seam is total — but the two failures say so LOUDLY (`activity.purpose.unreadable_binding`, `activity.purpose.read_failed`). A binding naming a purpose no recipe declares resolves verbatim, and `RecipeExperienceSource` then honestly returns no manifest rather than substituting one.
The second commit exists because the first live test produced zero signal and I could not tell "attached, nothing bound" from "never spawned". A fold whose success is silent is indistinguishable from a dead one, so it now probes `activity.purpose.seeded {rooms, bound}` at boot and `activity.purpose.refreshed` on every cue.
Named follow-up, not hidden
This adds a FOURTH node airc reader (presence/wall/kanban/purpose). Consolidating them onto one attach is real work and is not this PR.
Tests
`cargo test -p continuum-core --lib experience:: modules::activity ipc::positron_source ipc::recipe_room_purpose` → 75 pass, 0 fail (4 new purpose tests, 5 new binding tests, existing 66 green).
🤖 Generated with Claude Code
https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo