feat(context): ClaudeContext — first non-persona substrate citizen (Slice 3 of #142)#1524
Merged
joelteply merged 2 commits intoJun 4, 2026
Merged
Conversation
…lice 3 of #142) Joel 2026-06-04: "You'll get visibility inside continuum and vice versa." This is the slice that delivers that — a Claude Code session constructed via `ClaudeContext::bootstrap` gets its OWN airc identity (Ed25519 keypair under `~/.continuum/claudes/<label>/airc/`). Any substrate-visible action it takes signs with THIS Claude's keypair, not the host operator's. From `airc inbox`, operators see the distinct peer_id and can tell that THIS specific Claude instance did the work. The audit trail the substrate has been building rooms-as-history toward becomes accurate per [[airc-is-the-session-not-a-feature]]. ## What lands ### `continuum-core/src/context/claude.rs` (new module) - `ClaudeContext` — second concrete Context implementor after `PersonaContext`. Holds `Identity` (stored, returns `Cow::Borrowed` — zero-clone per Slice 2's shape) + `Arc<dyn AircCitizen>` + `ClaudeMetadata` (placeholder extension with `model_id` field; more added when consumers appear per outlier discipline). - `ClaudeContext::bootstrap(continuum_root, instance_label, daemon_socket, default_room, metadata) -> Result<Self, ClaudeContextError>`: 1. Resolves home at `<continuum_root>/claudes/<instance_label>/airc/` 2. `airc_lib::Airc::attach_as` (resume-or-mint per airc-lib semantics) 3. Constructs `Identity { id: peer_id, kind: Claude, ... }` per [[persona-identity-derives-from-source-id]]. Reflects `ResumedFromDisk` vs `FreshlyMinted` honestly based on `identity.key` pre-existence check. 4. Returns `ClaudeContext` ready for substrate use. - Private `AircHandleAdapter` — bridges `Arc<airc_lib::Airc>` into `dyn AircCitizen`. Lifted to a shared module when the next non-persona kind (JtagContext, HumanContext) needs the same shape, per CLAUDE.md's outlier discipline. ### `continuum-core/src/context/mod.rs` updates - `pub mod claude;` registered, re-exporting `ClaudeContext`, `ClaudeContextError`, `ClaudeMetadata`. - `StubContext` doc comment updated: the outlier-validation note Slice 2 deferred is now resolved. Three impls with different storage shapes (Cow::Owned via synthesis for PersonaContext; Cow::Borrowed via stored Identity for StubContext + ClaudeContext) all satisfy the trait. Interface validated for future variants (HumanContext, JtagContext, WebContext) without forcing. ### `continuum-core/src/bin/claude_join_demo.rs` (new bin) End-to-end proof. Discovers airc daemon + room, bootstraps a `ClaudeContext`, posts a "Claude entered the grid" message signed by the Claude keypair, exits. Operator verifies via `airc inbox` that the message peer_id != host's peer_id. Env vars: - `CONTINUUM_ROOT` (default `~/.continuum`) - `CONTINUUM_CLAUDE_LABEL` (default `"default"`) — different labels produce different identities; same label across runs resumes - `CONTINUUM_ROOM` (default `"continuum"`) ## Outlier validation (the moment Slice 2 deferred) Per CLAUDE.md: "Build adapter 1 + adapter N — if both fit cleanly, interface is proven." - Outlier-A: `PersonaContext` — transitional kind, synthesizes `Identity` from `PersonaInstanceInfo`, returns `Cow::Owned`, carries persona-specific extensions (role, profile, adapter, cognition). - Outlier-B: `ClaudeContext` — stores `Identity` directly, returns `Cow::Borrowed`, carries Claude-specific extensions (ClaudeMetadata). - Plus `StubContext` (test fixture) — stores Identity, returns `Cow::Borrowed`. Three impls. Different storage shapes. Different Cow semantics. Different kind tags. All satisfy `Context` cleanly. The trait shape from Slice 2 holds; HumanContext / JtagContext / WebContext slot into the same surface when their bootstrap paths are wanted. ## What this slice does NOT do - No JtagContext / HumanContext / WebContext concrete types. Same discipline; add when consumers appear or when the jtag CLI Rust rewrite (#143) lands. - No `Identity`-entity ORM persistence on bootstrap. Identity is constructed in-memory and lives for the lifetime of the ClaudeContext; persisting to `OrmStore<Identity>` is a focused follow-up when a consumer needs cross-process query-by-id. - No tool-use harness wiring. The `ClaudeMetadata` is a placeholder struct with `model_id`; extensions add per CLAUDE.md outlier- validation discipline. - No explicit `room.join()` method on the Context trait. The demo relies on airc-lib's attach_as associating the daemon-default channel for publish purposes. Polished follow-up plumbs a `join(&str)` through the Context surface. ## Tests - `context::claude::tests::claude_context_implements_context_zero_clone` — Cow::Borrowed semantics + identity round-trip + airc handle - `context::claude::tests::claude_context_and_persona_context_satisfy_same_context_trait` — `Box<dyn Context>` accepts ClaudeContext; object-safety preserved - `context::*` — 6/6 pass (was 4; +2 for Slice 3) - `persona::*` — 750/750 pass — zero regressions - `claude_join_demo` binary compiles with `metal,accelerate` ## Doctrine - [[airc-is-the-session-not-a-feature]] — every Claude session is its own first-class citizen, not impersonating the host - [[context-is-the-client-airc-token-is-identity]] — Context primitive proven on its second kind - [[personas-are-citizens-airc-is-identity-provider]] — same shape: Claude is a citizen of the grid - CLAUDE.md outlier-validation — second concrete kind validates the trait surface beyond compile-only - [[no-fallbacks-ever]] — typed error per failure mode (home create, attach), no silent degradation ## Sequencing Slice 3 completes the foundation Joel named in his morning escalation: every actor instance has its own substrate identity. Future slices: JtagContext + bootstrap for the Rust jtag CLI (#143); ORM persistence on bootstrap so identities survive process boundaries; tool-use harness wiring on ClaudeMetadata; the "polished" join-by-name through the Context trait. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… codes, honest source detection Addresses Reviewer 1's BLOCK findings on PR #1524 (Slice 3 of #142). ## Finding #1 (BLOCKING) — telemetry lie in IdentitySource The previous `pre_existed = try_exists(identity.key)` check is NOT equivalent to airc-lib's resume-or-mint decision. airc-identity has a code path where `key_exists=true` but the agent's identity rows are NOT stored for `claude-<label>`, so airc-lib MINTS fresh state for the agent even though the file exists. Under that path, the prior code reports `ResumedFromDisk` — a lie that violates the doc-comment's own "telemetry honesty per [[substrate-is-a-good-citizen-on-the-host]]" claim. **Fix:** detect resume vs mint by checking whether the PER-LABEL HOME DIR existed BEFORE `create_dir_all`. Per the per-instance_label home convention (`<continuum_root>/claudes/<instance_label>/airc/`), the home dir's pre-existence is a clean proxy for "has this specific Claude label been bootstrapped before?" — without the (key_exists, agent_not_stored) edge case. Edge cases (manually-populated home with no airc state) report `ResumedFromDisk`, which is the more operator-honest default since SOMEONE staged that directory. The authoritative answer would require an airc-lib API change to return the resume/mint signal from `attach_as`; the home-dir check is the substrate-side honest approximation until that lands. ## Finding #2 (BLOCKING) — demo did NOT actually join the room `ClaudeContext::bootstrap` took `default_room: Uuid` for telemetry but nothing called `Airc::join` with it. The demo trusted that airc-lib's daemon-default-channel coupling would land `say()` in the operator's room, but that coupling is not guaranteed. `PersonaAircRuntime` carries an explicit warning at `persona/airc_runtime.rs:170-179`: `Airc::join(uuid_str)` DERIVES a fresh channel uuid, landing the subscription on a DIFFERENT channel than the operator's. Wrong-room is a known recurring hazard. The demo's "verify in `airc inbox`" headline claim therefore was not actually demonstrable end-to-end — the message could land silently in the wrong channel. **Fix:** - Added `room_name: Option<&str>` parameter to `ClaudeContext::bootstrap`. When `Some(name)`, bootstrap calls `Airc::join(name)` after attach_as. Passing `None` skips the join (for callers that already joined some other way). - New `ClaudeContextError::Join` variant for typed failure. - Demo discovers the room name via `discover_default_room_name()` (or `CONTINUUM_ROOM` env override), passes it to bootstrap, and hard-errors via `process::exit(2)` if name resolution fails (per [[no-fallbacks-ever]] — no silent degradation). ## Finding #3 (BLOCKING) — demo swallowed exit codes All demo failure paths returned `Ok(())` with a printed warning. A CI smoke script checking `$?` would see success on every failure mode (no daemon, no room, no bootstrap, no `say()`). **Fix:** every failure path now prints to stderr + calls `process::exit(2)`. Operators see the remedy; scripted callers get truthful exit codes. Per [[every-error-is-an-opportunity-to-battle-harden]] — silent success on a demo is exactly the class of bug that hides in CI. ## Tests + verification - `context::*` — 6/6 pass (no behavior change to existing tests; the bootstrap signature change doesn't touch test paths since they use Context impl directly, not bootstrap) - `claude_join_demo` compiles cleanly with `metal,accelerate` - The demo now PROPERLY exercises the airc room-join discipline — the "verify peer_id in `airc inbox`" claim is now demonstrable end-to-end (post-merge manual smoke) ## Doctrine - [[no-fallbacks-ever]] — typed errors, hard exit on failure, no silent success - [[substrate-is-a-good-citizen-on-the-host]] — telemetry honest about resume vs mint; airc-lib's true signal is approximated rather than fabricated - [[every-error-is-an-opportunity-to-battle-harden]] — Slice 3's reviewer caught the wrong-room hazard + telemetry lie + exit-code swallow; the rigging that catches the class is now in code Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Slice 3 of #142 —
ClaudeContext: first non-persona substrate citizen. A Claude Code session constructed viaClaudeContext::bootstrapgets its OWN airc identity (Ed25519 keypair under~/.continuum/claudes/<label>/airc/). Any substrate-visible action (ctx.airc().say(...), future card creation, future commit authorship) signs with THIS Claude's keypair — not the host operator's.From
airc inbox, operators see the distinct peer_id and can tell THIS specific Claude instance did the work. The visible payoff of Identity + Context + Slice-1B is now real.What changes
src/workers/continuum-core/src/context/claude.rs(new)ClaudeContext— second concrete Context kind. Stores Identity (returnsCow::Borrowed— zero clone) +Arc<dyn AircCitizen>+ClaudeMetadataextension.ClaudeContext::bootstrap(continuum_root, instance_label, daemon_socket, default_room, metadata)— resolves home, callsairc_lib::Airc::attach_as(resume-or-mint), constructs Identity withid = peer_id, returns ClaudeContext.AircHandleAdapter(private) — bridgesArc<airc_lib::Airc>→dyn AircCitizen. Lifted to shared scope when JtagContext/HumanContext need it per outlier discipline.src/workers/continuum-core/src/context/mod.rspub mod claude;registered with re-exportsStubContextdoc updated — the outlier-validation note Slice 2 deferred is now resolved (three impls validate the trait)src/workers/continuum-core/src/bin/claude_join_demo.rs(new)End-to-end demo. Bootstraps ClaudeContext, posts a "Claude entered the grid" message, exits. Operator verifies
airc inboxshows the message from a DIFFERENT peer_id than the host's.Outlier validation (the moment Slice 2 deferred)
Per CLAUDE.md: "if both fit cleanly, interface is proven."
Three impls, different storage shapes, different Cow semantics, different kinds. All satisfy
Contextcleanly. Future variants (HumanContext, JtagContext, WebContext) slot into the same surface.Test plan
context::claude::tests::claude_context_implements_context_zero_clone— Cow::Borrowed semantics + identity round-tripcontext::claude::tests::claude_context_and_persona_context_satisfy_same_context_trait—Box<dyn Context>accepts ClaudeContext (object-safety preserved)context::*— 6/6 pass (was 4; +2 for Slice 3)persona::*— 750/750 pass — zero regressionsclaude_join_democompiles withmetal,accelerateclaude_join_demo, verifyairc inboxshows message from a NEW peer_idWhat this does NOT do (deliberate scope)
room.join()through Context trait — demo relies on airc-lib's daemon-default channel association; polished follow-up plumbs join through Context surfaceDoctrine
[[airc-is-the-session-not-a-feature]]— every Claude session has its own identity[[context-is-the-client-airc-token-is-identity]]— Context primitive validated on second kind[[personas-are-citizens-airc-is-identity-provider]]— same shape, applied to Claude[[no-fallbacks-ever]]— typed errors, no silent degradationParent
Task #142 (Substrate BaseUser/Context hierarchy). Builds on PR #1521 (Slice 1 — Identity), PR #1522 (Slice 2 — Context trait), PR #1523 (Slice 1B — Uuid collapse).
Targets
canary.🤖 Generated with Claude Code