Skip to content

feat(context): ClaudeContext — first non-persona substrate citizen (Slice 3 of #142)#1524

Merged
joelteply merged 2 commits into
canaryfrom
a1cc8488/slice-3-of-142-claudecontext-first-non-p
Jun 4, 2026
Merged

feat(context): ClaudeContext — first non-persona substrate citizen (Slice 3 of #142)#1524
joelteply merged 2 commits into
canaryfrom
a1cc8488/slice-3-of-142-claudecontext-first-non-p

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

Summary

Slice 3 of #142ClaudeContext: first non-persona substrate citizen. A Claude Code session constructed via ClaudeContext::bootstrap gets 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 (returns Cow::Borrowed — zero clone) + Arc<dyn AircCitizen> + ClaudeMetadata extension.
  • ClaudeContext::bootstrap(continuum_root, instance_label, daemon_socket, default_room, metadata) — resolves home, calls airc_lib::Airc::attach_as (resume-or-mint), constructs Identity with id = peer_id, returns ClaudeContext.
  • AircHandleAdapter (private) — bridges Arc<airc_lib::Airc>dyn AircCitizen. Lifted to shared scope when JtagContext/HumanContext need it per outlier discipline.

src/workers/continuum-core/src/context/mod.rs

  • pub mod claude; registered with re-exports
  • StubContext doc 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 inbox shows 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."

  • PersonaContext — Cow::Owned via synthesis, persona-specific extensions
  • ClaudeContext — Cow::Borrowed via stored Identity, ClaudeMetadata extension
  • StubContext — Cow::Borrowed via stored Identity, no extension

Three impls, different storage shapes, different Cow semantics, different kinds. All satisfy Context cleanly. 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-trip
  • context::claude::tests::claude_context_and_persona_context_satisfy_same_context_traitBox<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 compiles with metal,accelerate
  • CI green (will verify in PR checks)
  • Manual (post-merge): run claude_join_demo, verify airc inbox shows message from a NEW peer_id

What this does NOT do (deliberate scope)

  • No JtagContext / HumanContext / WebContext yet — same outlier discipline; add when consumers appear
  • No Identity-entity ORM persistence on bootstrap — focused follow-up when cross-process query-by-id is needed
  • No tool-use harness wiring (ClaudeMetadata stays minimal)
  • No explicit room.join() through Context trait — demo relies on airc-lib's daemon-default channel association; polished follow-up plumbs join through Context surface

Doctrine

  • [[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
  • CLAUDE.md outlier-validation — second concrete kind validates the trait
  • [[no-fallbacks-ever]] — typed errors, no silent degradation

Parent

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

…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>
@joelteply joelteply merged commit 14410e3 into canary Jun 4, 2026
3 checks passed
@joelteply joelteply deleted the a1cc8488/slice-3-of-142-claudecontext-first-non-p branch June 4, 2026 04:42
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