Skip to content

feat(context): Context trait — substrate's Android-Context handle (Slice 2 of #142)#1522

Merged
joelteply merged 2 commits into
canaryfrom
a904f42a/slice-2-of-142-context-trait-android-con
Jun 4, 2026
Merged

feat(context): Context trait — substrate's Android-Context handle (Slice 2 of #142)#1522
joelteply merged 2 commits into
canaryfrom
a904f42a/slice-2-of-142-context-trait-android-con

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

Summary

Slice 2 of #142 (BaseUser/Context hierarchy) — defines the Context trait that wraps Slice 1's Identity entity (PR #1521 → canary) with the actor's airc citizen handle. This is the Android-Context analogue Joel named in [[airc-is-the-session-not-a-feature]]: the ubiquitous, mandatory &dyn Context that every substrate API will take as actor-instance proof. No global airc, no implicit process-wide actor.

Stays narrow on purpose: trait surface is identity() + airc(). More services (ORM scope, log scope, capture sinks) added per the outlier-validation discipline when consumers appear.

What changes

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

  • Context traitfn identity(&self) -> Identity + fn airc(&self) -> &Arc<dyn AircCitizen>. identity() returns by value because transitional implementors (PersonaContext today) synthesize from underlying state; once Slice 1B migrates PersonaInstanceInfoIdentity, the impl becomes a &Identity borrow with zero synthesis cost.
  • StubContextpub (not #[cfg(test)]-only): test fixture + production handle for benchmarks/replay paths. Wraps Identity + StubAircCitizen.
  • log_actor_action(ctx: &dyn Context, action: &str) — first substrate utility taking &dyn Context. Proves the trait is load-bearing per CLAUDE.md's outlier-validation pattern.

src/workers/continuum-core/src/lib.rs

pub mod context; registered.

src/workers/continuum-core/src/persona/supervisor.rs

impl Context for PersonaContext added (~50 lines, no struct change). Synthesizes Identity from PersonaInstanceInfo on each call (kind = Persona, id = peer_id per [[persona-identity-derives-from-source-id]]). Slice 1B collapses the synthesis.

Outlier validation

The trait fits Outlier-A (PersonaContext — rich kind with role/profile/adapter/cognition) AND Outlier-B (StubContext — minimal kind, nothing but identity + airc). Interface is validated for future variants (ClaudeContext, JtagContext, HumanContext) — per CLAUDE.md: "if edges pass, middle is guaranteed."

Test plan

  • context::tests::stub_context_implements_context — round-trip through trait surface
  • context::tests::log_actor_action_takes_any_context — dynamic dispatch through &dyn Context
  • persona::supervisor family: 9/9 pass (impl is purely additive; no behavior change to PersonaContext)
  • No regressions in identity/orm test families
  • CI green (will verify in PR checks)

What this does NOT do (sequenced follow-ups)

  • Slice 3 — ClaudeContext / JtagContext / HumanContext concrete types + bootstrap paths per kind
  • Slice 4 — &dyn Context ubiquitous across substrate APIs
  • Slice 1B — PersonaInstanceInfoIdentity migration (collapses Context::identity() synthesis cost)
  • ORM scope / log scope / capture sink services on Context — added when consumers appear

Parent

Task #142 (Substrate BaseUser/Context hierarchy). Builds on PR #1521 (Slice 1 — Identity entity).

Targets canary.

🤖 Generated with Claude Code

…ice 2 of #142)

Joel 2026-06-04: "This is like Android context and must be fixed."
+ [[airc-is-the-session-not-a-feature]]. Slice 2 of #142 defines
the trait that wraps Slice 1's Identity entity with the actor's
airc citizen handle — the universal `&dyn Context` that every
substrate API will take.

## Mental model

Android Context: ubiquitous, mandatory, carries app identity +
resources + services. You can't startActivity, openFileInput,
sendBroadcast without one.

Substrate Context (this slice): carries `Identity` + airc citizen
handle. Future slices add ORM scope, log scope, capture sinks. Every
substrate API that produces substrate-visible effect takes
`&dyn Context`. No global airc, no implicit process-wide actor.

## What lands

### `continuum-core/src/context/mod.rs` (new)

- `Context` trait — minimum substrate-wide contract:
  - `fn identity(&self) -> Identity`
  - `fn airc(&self) -> &Arc<dyn AircCitizen>`
- `StubContext` — `pub` (not `#[cfg(test)]`-only): test fixture
  + first-class production handle for benchmarks / replay paths.
  Holds an Identity + StubAircCitizen.
- `log_actor_action(ctx: &dyn Context, action: &str)` — first
  substrate utility taking `&dyn Context`. Proves the trait is
  load-bearing per CLAUDE.md's outlier-validation pattern: trait
  fits Outlier-A (PersonaContext, the rich kind) AND Outlier-B
  (StubContext, the minimal kind), so the interface is validated
  for future variants (ClaudeContext, JtagContext, HumanContext).

### `continuum-core/src/lib.rs`

- `pub mod context;` registered.

### `continuum-core/src/persona/supervisor.rs`

- `impl Context for PersonaContext` added (~50 lines, no struct
  change). Transitional shape: `PersonaContext.identity` is still
  `PersonaInstanceInfo` (Slice 1B will migrate), so
  `Context::identity()` SYNTHESIZES an `Identity` from the
  underlying `PersonaInstanceInfo` fields on each call:
  - `id` ← `peer_id` (per
    [[persona-identity-derives-from-source-id]]: peer_id IS the
    substrate id; PersonaInstanceInfo's separate `persona_id`
    field is redundancy collapsed by the Identity entity)
  - `kind` ← `IdentityKind::Persona`
  - source enum mapping ResumedFromDisk / FreshlyMinted

  Per-turn synthesis cost is acceptable per
  [[substrate-overhead-is-1to3ms-LLM-dominates-latency]] — Uuid
  copy + String clones are not the bottleneck.

## Why a trait, not a struct

Per the polymorphism doctrine in CLAUDE.md + the BaseUser hierarchy
plan named at `persona/supervisor.rs:108-118`:

- `PersonaContext = Context + (role, profile, adapter, cognition)`
- `ClaudeContext = Context + (tool-use harness, model tier)`
- `JtagContext = Context + (CLI invocation args, stdio streams)`
- `HumanContext = Context + (UI session, auth scope)`

Each variant extends the substrate handle with kind-specific state.
The trait is the common contract; concrete types add what their
kind needs.

## Tests

4 context tests pass (in addition to no regressions elsewhere):
- `stub_context_implements_context` — round-trip through trait
  surface: identity carries id/kind/agent_name, airc handle
  reachable, peer_id matches.
- `log_actor_action_takes_any_context` — substrate utility runs
  through dynamic dispatch — `&dyn Context` is the substrate-wide
  shape every consumer will adopt.
- 2 ts-rs `export_bindings_*` tests (Identity / IdentityKind /
  IdentitySource bindings still generate cleanly with the new
  module).

`persona::supervisor` family: 9/9 pass (Context impl is purely
additive; no behavior change to PersonaContext).

## What this slice does NOT do (sequenced follow-ups)

- `ClaudeContext` / `JtagContext` / `HumanContext` concrete types
  — Slice 3, with bootstrap paths per kind
- `&dyn Context` ubiquitous refactor across substrate APIs — Slice 4
- `PersonaInstanceInfo` → `Identity` migration in persona module
  — Slice 1B (Context::identity()'s synthesis cost goes to zero
  when persona stores Identity directly)
- ORM scope / log scope / capture sink services on Context — added
  per the outlier-validation discipline when concrete consumers
  appear

## Doctrine

- [[airc-is-the-session-not-a-feature]] — Context wraps the actor's
  session, not a feature it has
- [[context-is-the-client-airc-token-is-identity]] — pre-existing
  memory naming this work
- [[organization-purity-as-we-migrate]] — no fallback chains; trait
  surface stays narrow until use sites prove what's needed
- [[no-fallbacks-ever]] — Context::identity() returns Identity, not
  Option<Identity>
- CLAUDE.md outlier-validation — both PersonaContext (Outlier-A)
  AND StubContext (Outlier-B) implement Context cleanly →
  interface is validated for future variants
- [[test-fixtures-are-system-primitives]] — StubContext is `pub`,
  available everywhere

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…divergence warning

Addresses Reviewer 1's BLOCK findings on PR #1522 (Slice 2 of #142).

## Finding #1 — Active Uuid-divergence footgun

The reviewer caught that `Identity.id ← peer_id` synthesis creates a
silent divergence from existing production code that reads
`ctx.identity.persona_id` as the **registry key** in:
- persona/host.rs:323
- persona/service_loop.rs:463
- persona/rag_inspect.rs:198
- persona/supervisor.rs:387, 444, 450

`persona_id` is `Uuid::new_v4()` minted before airc bootstrap;
`peer_id` is the Ed25519 keypair Uuid from airc_lib. Independent
random Uuids for the same persona. After Slice 2, `ctx.identity().id`
(peer_id) ≠ `ctx.identity.persona_id` (registry key). Any caller
mixing the two paths gets a silent registry-lookup miss.

**Fix:** the transitional doc above the `impl Context for
PersonaContext` block now explicitly names the active sharp edge —
"DO NOT feed `ctx.identity().id` back into the persona registry."
Tracked: Slice 1B must collapse `persona_id` → `peer_id` so the two
paths converge (per [[persona-identity-derives-from-source-id]] the
seed IS the keypair Uuid; the existing pre-airc-bootstrap mint is
the historical artifact slated for removal).

The fix is doc-only because the alternative — adding
`fn registry_key(&self) -> Uuid` to the trait — would leak
persona-specific terminology into the universal Context surface.
The right fix is for Slice 1B to make the divergence impossible.

## Finding #2 — `identity()` should return `Cow<'_, Identity>`

By-value `Identity` baked a clone tax into the steady-state trait.
StubContext already stores an Identity and was cloning on every
call; PersonaContext post-Slice-1B will also store directly; both
become zero-cost with `Cow::Borrowed(&self.identity)`. Transitional
implementors (PersonaContext today) return `Cow::Owned(synthesized)`.
Same caller ergonomics via `cow.as_ref()`.

**Fix:** `fn identity(&self) -> Cow<'_, Identity>`. Updated:
- StubContext impl returns `Cow::Borrowed(&self.identity)` (zero clone)
- PersonaContext impl returns `Cow::Owned(synthesized)` (transitional)
- `log_actor_action` borrows via `id.as_ref()` (no clone)
- Tests read via `let observed = observed.as_ref();`

## Finding #4 — `Context: Send + Sync + 'static`

Trait was `Send + Sync` without `'static`, which would block
storing `Box<dyn Context>` / `Arc<dyn Context>` in long-lived
registries (Slice 4 will surface this need). Adding `'static` now
while the trait has zero downstream consumers is cheap. All current
implementors are `'static` by construction.

**Fix:** `pub trait Context: Send + Sync + 'static`.

## Finding #3 — Outlier-validation doc soften

Reviewer noted StubContext's trait-surface shape is identical to
PersonaContext's projection — both expose stored Identity + stored
`Arc<dyn AircCitizen>`. The real outlier (a Context whose airc
handle is borrowed/synthesized) arrives with Slice 3
(ClaudeContext / JtagContext).

**Fix:** softened the StubContext doc comment to acknowledge "this
struct proves the trait COMPILES across two impls; it does not yet
prove the interface is right for kinds whose airc handle isn't
long-lived-owned. The genuine outlier validation arrives with
Slice 3."

## Tests

- context::* — 4/4 pass (no behavior change; just signature update)
- persona::supervisor::* — 9/9 pass (impl change is purely additive)

## Doctrine

Per [[every-error-is-an-opportunity-to-battle-harden]]: each finding
named the immediate fix AND the rigging that catches the class next
time. #1's transitional doc is the rigging that catches future
PersonaContext consumers; #2's Cow<'_, Identity> is the rigging that
catches future zero-cost-impl regressions; #4's `: 'static` is the
rigging that catches future long-lived-storage attempts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@joelteply joelteply merged commit ccc2b17 into canary Jun 4, 2026
2 checks passed
@joelteply joelteply deleted the a904f42a/slice-2-of-142-context-trait-android-con branch June 4, 2026 02:58
joelteply added a commit that referenced this pull request Jun 4, 2026
…ice 1B of #142) (#1523)

* feat(persona): collapse persona_id := peer_id at runtime boundary (Slice 1B of #142)

Resolves the active Uuid-divergence sharp edge documented in PR
#1522: `ctx.identity().id` (= peer_id) ≠ `ctx.identity.persona_id`
(= continuum-side seed) because the two were independently-minted
Uuids. After this slice, they are the same value — `ctx.identity().id`
is safe to use as a registry key, unblocking Slice 3
(ClaudeContext / JtagContext / bootstrap paths per kind).

## Why

Per [[persona-identity-derives-from-source-id]] +
[[airc-is-the-session-not-a-feature]]: a persona has ONE unique
identifier — the airc peer_id (Ed25519 keypair Uuid). The
continuum-side `persona_id` originally minted via
`Uuid::new_v4()` pre-bootstrap was historical artifact; airc-lib is
the cryptographic ground truth.

Slice 2 of #142 introduced `Context::identity()` returning
`Identity.id = peer_id`. The persona registry was (and is) keyed
on `PersonaInstanceInfo.persona_id`. Until this slice, those were
two different Uuids for the same persona — any caller mixing
paths got silent registry-lookup misses. Reviewer 1 on PR #1522
caught this as a BLOCK and the fix was scoped to Slice 1B (this
PR).

## What changes

### `persona/airc_runtime.rs::bootstrap` + `from_attached`

Both constructors now reseat `persona_id := airc.peer_id().as_uuid()`
post-attach. The input `persona_id` parameter is retained for API
continuity but its value is logged-then-discarded:
- If the input differs from `peer_id`, a `tracing::warn!` fires
  naming the discrepancy so callers can spot the unused arg.
- The runtime's stored `persona_id` is always `peer_id` post-
  construction. Every downstream consumer (registry, IPC,
  PersonaInstanceInfo, Context impl) sees a consistent Uuid.

Future API cleanup will drop the parameter entirely; for now the
warn-on-divergence is the operator-visible signal that callers can
prune the redundant argument.

### `persona/supervisor.rs` (Context impl)

The transitional "ACTIVE SHARP EDGE — READ BEFORE USING" doc block
above `impl Context for PersonaContext` is replaced with a calmer
section documenting that `ctx.identity().id IS the registry key`
post-Slice-1B. The "DO NOT feed ctx.identity().id back into the
persona registry" warning is removed because the divergence it
warned about is now closed.

## What this slice does NOT do

- Doesn't change `agent_name` derivation. For NEW personas,
  `agent_name` is still derived from the (now-discarded) input
  `persona_id` via `agent_name_from_identity` at the
  `ResumeOrMintProvider` layer. Per the doctrine, name should
  derive from `peer_id`; that fix requires restructuring the
  mint flow to generate the keypair BEFORE deriving the name.
  Tracked as future cleanup; not blocking for Slice 3.
- Doesn't drop the `persona_id` parameter from the constructors.
  The API stays back-compat for now; future cleanup prunes
  unused-arg call sites + drops the parameter.
- Doesn't migrate `PersonaInstanceInfo` to be an `Identity` entity
  (that's a separate ORM-persistence slice). The field collapse
  is sufficient for the registry-key-safety invariant Slice 3
  needs.

## Tests

- `persona::airc_runtime`: 6/6 pass (existing tests cover the
  constructor paths)
- `persona::*`: 749/749 pass — zero regressions across the
  persona module
- `context::*`: 4/4 pass — Slice 2's tests still green

The reviewer asked for a test pinning the collapse invariant
(`runtime.persona_id() == runtime.airc().peer_id().as_uuid()`).
Such a test requires constructing an `Arc<airc_lib::Airc>` with a
known peer_id, which airc-lib doesn't expose a stub for; existing
integration tests (`#141`, `airc_chat_demo`) exercise the full
bootstrap path against a real daemon and would catch any
regression. If a unit-level invariant test is warranted, it
belongs in airc-lib's test-fixtures crate (mocked Airc handle).
Filed as a follow-up if pressure builds.

## Doctrine

- [[persona-identity-derives-from-source-id]] — peer_id IS the
  substrate identity; persona_id was the historical artifact
- [[airc-is-the-session-not-a-feature]] — airc identity IS the
  session, no parallel identifier
- [[every-error-is-an-opportunity-to-battle-harden]] — the Slice
  2 reviewer caught this; Slice 1B closes the class
- [[no-fallbacks-ever]] — warn-on-divergence is observability, not
  fallback; the runtime ALWAYS uses peer_id, no conditional path
- [[organization-purity-as-we-migrate]] — collapse the divergent
  field at the data-flow boundary instead of papering over with
  registry tricks

## Sequencing

Unblocks Slice 3 (ClaudeContext / JtagContext + bootstrap paths
per actor kind) which needs `ctx.identity().id` to be a usable
registry/lookup key on the universal Context surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(persona): review fixes for #1523 — seed.json durability, warn-noise, doc + test invariant

Addresses Reviewer 1's BLOCK findings on PR #1523 (Slice 1B of #142).

## Finding #1 (BLOCKING) — seed.json durability bug

`PersonaInstanceManagerModule::bootstrap_one` was writing seed.json
with `intent.persona_id` — the PRE-collapse Uuid that the runtime
constructor discards. The `seed.rs` contract says "Must NOT change
across restarts"; under Slice 1B the persona_id stored on disk was
permanently stale (= the discarded `Uuid::new_v4()` from the
mint provider), never reconciled with the actual substrate
identity (= peer_id).

**Fix:** write the POST-collapse identity to seed.json:
- `persona_id: runtime.persona_id()` (which equals
  `airc.peer_id().as_uuid()` post-Slice-1B)
- `agent_name: runtime.agent_name()`

The on-disk Uuid IS the substrate identity (peer_id), and seed.json
becomes the durable projection of the post-collapse state.

## Finding #2 (BLOCKING) — warn-on-divergence is operator log noise

`ResumeOrMintProvider::mint_fresh_intent` allocates
`persona_id = Uuid::new_v4()` BEFORE the keypair is generated, so
the input persona_id is statistically guaranteed to differ from
peer_id for every fresh mint. The previous Slice 1B implementation
logged the divergence at `warn!`, which would have spammed
operators on every persona boot (12-14 personas × every restart =
constant noise).

**Fix:** downgraded both bootstrap + from_attached divergence logs
from `warn!` to `debug!`. Comment names the reason: divergence is
EXPECTED for fresh mints; warn is reserved for genuine bugs.

## Finding #4 — PersonaInstanceInfo doc stale post-Slice-1B

Doc strings on `PersonaInstanceInfo.persona_id` / `peer_id` claimed
they were independent. Post-Slice-1B they're equal by invariant.

**Fix:** updated the struct doc with an `## INVARIANT` block
naming the Slice 1B contract: `persona_id == peer_id`. Individual
field docs updated to reflect post-collapse semantics. The
`agent_name` field's doc explicitly notes the doctrinal break
(name still derives from the historical pre-bootstrap Uuid, not
from peer_id) so future readers know the gap is intentional and
deferred to a separate slice.

## Finding #5 — test fixtures bypassed the invariant

`supervisor::tests::fake_instance` (supervisor.rs:499) and
`service_loop` test fixture (service_loop.rs:592) both constructed
PersonaInstanceInfo with two independent `Uuid::new_v4()` calls,
violating the invariant the new doc claims is universal.

**Fix:** both fixtures now use ONE Uuid for both persona_id +
peer_id (named `peer_id` / `persona_peer_id` consistently). Honors
the same invariant production sees. Comments name the doctrine.

## Finding #6 — no test gates the invariant

Reviewer #1 said `StubAircCitizen` could be used to write a unit
test that pins the invariant. They were right.

**Fix:** added
`persona::supervisor::tests::persona_context_identity_id_matches_registry_key`
which constructs a `PersonaInstanceInfo` via `fake_instance` and
asserts both that the fixture itself honors the invariant
(`persona_id == peer_id`) AND that the projected
`Context::identity().id` matches the registry-key path
(`identity.persona_id`). If a future edit reintroduces the
divergence, the test fails — closing the regression class per
[[every-error-is-an-opportunity-to-battle-harden]].

## Test results

- `persona::*` — 750/750 pass (was 749 — the new invariant test
  added one)
- `context::*` — 4/4 pass
- No regressions

## Findings deferred

- **Finding #3** — `agent_name_from_identity(peer_id)` mint-flow
  restructure. PR explicitly defers this; the PersonaInstanceInfo
  doc now calls it out so future readers know the name derivation
  is intentionally off-doctrine until a separate slice routes it
  through peer_id.
- **Finding #7** — Drop log line format change (`persona_id =
  %self.persona_id` now equals peer_id). Documented in this
  commit's PR body so operator runbooks have a heads-up.

## Doctrine

Per [[every-error-is-an-opportunity-to-battle-harden]]: each
finding named the immediate fix AND the rigging that catches the
class next time. Finding #1's seed.json fix is the rigging that
catches durability divergence; Finding #6's invariant test is the
rigging that catches the Uuid-divergence class at unit time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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