Skip to content

feat(activity): a room's recipe binding finally has a READER — every room resolves to what it IS (#6/#274/#329) - #2282

Open
joelteply wants to merge 28 commits into
canaryfrom
feat/room-purpose-reads-the-recipe-binding
Open

feat(activity): a room's recipe binding finally has a READER — every room resolves to what it IS (#6/#274/#329)#2282
joelteply wants to merge 28 commits into
canaryfrom
feat/room-purpose-reads-the-recipe-binding

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

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

`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`.
`ipc/recipe_room_purpose.rs` `RecipeRoomPurpose` — the `RoomPurposeSource` impl the seam's own doc has been waiting for. Event-invalidated cache folding `wall:changed`, seeded from every subscribed room at boot.
`modules/activity.rs` `activity/spawn` now serializes the SHARED type instead of a hand-authored `json!`. Both sides agree by construction.
`ipc/positron_source.rs` `spawn` takes the purpose source by injection; boot passes the live index, headless keeps `default_source()`.

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

joelteply and others added 19 commits August 13, 2026 08:20
… 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
@joelteply

Copy link
Copy Markdown
Contributor Author

Known limitation, found by reading the airc path (filed as #406)

AircRecipeReader::recipe_posts resolves a room by scanning subscription_set() and refuses anything outside it. But the underlying read — wall_posts_inroom_transcripts_since (airc-lib work.rs:998) — is keyed on room.channel alone and, when daemon-attached, calls daemon_room_transcripts_since(room.channel, …). A subscription is not required to read a room's wall.

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 → activity.purpose.read_failed → resolves to "chat".

Not fixed in this PR on purpose — the fix has a real fork and I'd rather it be picked deliberately:

  • (a) construct a Room straight from the channel id (all fields pub, only channel is read) — cheapest, couples to an airc internal that can break silently
  • (b) an airc room-by-channel accessor — most correct, same shape as the existing wall_posts_in vs wall_posts split
  • (c) id→name via nav's durable room-set (Add 3-panel layout screenshot to root README #241) then Room::from_name — reuses existing machinery, but from_name derives the channel v5-from-name while subscriptions use derive_room_id(identity, channel); verify they agree first

(b) is the principled one. Acceptance test either way: a room the node reader has never joined resolves to its authored recipe.

joelteply and others added 7 commits August 13, 2026 16:49
…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
@joelteply

Copy link
Copy Markdown
Contributor Author

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):

e85541f5f — restore the deploy path. Both defects trace to 7e0c546, which changed the one function start and reboot share:

  1. The direct-exec path passed the socket only via CONTINUUM_CORE_SOCKET and omitted argv[1]. main.rs:316 requires the positional and exits 1 — so on any machine with an installed binary, every start/reboot died ~2s in writing only Usage: to the start log.
  2. reboot stopped building. It printed "building fresh binary, then swapping" and exec'd a month-old artifact; only Implement GAN-style Adversarial Training Loop #194's provenance check stood between that and a false success line.

Fix: source policy is an explicit LaunchSource argument instead of an ambient default, because the two callers want opposite things — start wants a core RUNNING (installed artifact correct; the no-source-tree case 7e0c546 was written for still works), reboot wants it built from THIS checkout. plan_launch(policy, env_override, have_script, have_installed) is pure with 7 tests, one per branch. Reboot on an installed node with no checkout restarts the artifact through a distinct variant that forces the CLI to SAY nothing was rebuilt.

Server side: continuum-core-server was the only component hand-rolling its own socket resolution (argv or die) while every client used endpoint_paths::core_socket_path(). argv[1] still wins; absent it, the server now agrees with everyone else instead of exiting.

fa1f720ed — publish the verified binary on every deploy. This script already refreshes the CLI into ~/.local/bin each deploy, with a comment arguing exactly why; the core-server was omitted from that reasoning. Result on this box: installed artifact dated Jul 13 while HEAD was 4712, and continuum start (which prefers the installed copy) silently booted the old core. Placed after the #194 freshness guard so only a binary just proven to match source is published; atomic temp+mv; non-fatal, and it says out loud when it can't publish.

Verified live: ✅ deploy verified: core is running build e85541f5f (== git HEAD of this checkout), version trio build 4712 / e85541f / 22:17:32Z. 17/17 CLI tests green.

…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
joelteply added a commit that referenced this pull request Aug 14, 2026
… 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
  …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant