feat(context): Context trait — substrate's Android-Context handle (Slice 2 of #142)#1522
Merged
joelteply merged 2 commits intoJun 4, 2026
Merged
Conversation
…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>
4 tasks
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>
6 tasks
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 2 of #142 (BaseUser/Context hierarchy) — defines the
Contexttrait that wraps Slice 1'sIdentityentity (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 Contextthat 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.rsContexttrait —fn 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 migratesPersonaInstanceInfo→Identity, the impl becomes a&Identityborrow with zero synthesis cost.StubContext—pub(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.rspub mod context;registered.src/workers/continuum-core/src/persona/supervisor.rsimpl Context for PersonaContextadded (~50 lines, no struct change). Synthesizes Identity from PersonaInstanceInfo on each call (kind =Persona, id =peer_idper[[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 surfacecontext::tests::log_actor_action_takes_any_context— dynamic dispatch through&dyn Contextpersona::supervisorfamily: 9/9 pass (impl is purely additive; no behavior change to PersonaContext)What this does NOT do (sequenced follow-ups)
ClaudeContext/JtagContext/HumanContextconcrete types + bootstrap paths per kind&dyn Contextubiquitous across substrate APIsPersonaInstanceInfo→Identitymigration (collapses Context::identity() synthesis cost)Parent
Task #142 (Substrate BaseUser/Context hierarchy). Builds on PR #1521 (Slice 1 — Identity entity).
Targets
canary.🤖 Generated with Claude Code