feat(persona): collapse persona_id := peer_id at runtime boundary (Slice 1B of #142)#1523
Merged
joelteply merged 2 commits intoJun 4, 2026
Merged
Conversation
…ice 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>
…se, 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>
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 1B of #142 — collapses
PersonaInstanceInfo.persona_id == peer_idat the runtime constructor boundary. Resolves the active Uuid-divergence sharp edge documented in PR #1522.Before this slice:
ctx.identity().id(= peer_id) ≠ctx.identity.persona_id(= continuum-side seed). Two independently-minted Uuids for the same persona. Anyone mixing paths got silent registry-lookup misses.After this slice: both are the same value.
ctx.identity().idis safe as a registry key. Unblocks Slice 3 (ClaudeContext / JtagContext + bootstrap paths per actor kind).What changes
persona/airc_runtime.rs::bootstrap+from_attachedBoth constructors reseat
persona_id := airc.peer_id().as_uuid()post-attach. The inputpersona_idparameter is retained for API continuity but its value is logged-then-discarded. If input ≠ peer_id,tracing::warn!fires so callers can spot the unused arg and prune later.persona/supervisor.rs(Context impl)The "ACTIVE SHARP EDGE — READ BEFORE USING" warning above
impl Context for PersonaContextis replaced with a calmer note documenting thatctx.identity().id IS the registry keypost-Slice-1B.What this does NOT do (deliberate scope)
agent_namederivation. Still derived from the (now-discarded) inputpersona_idviaagent_name_from_identity. Per the doctrine name should derive frompeer_id; that requires restructuring the mint flow to generate the keypair BEFORE deriving the name. Future cleanup, not blocking for Slice 3.persona_idparameter. API stays back-compat; future cleanup prunes unused-arg call sites.PersonaInstanceInfo→ Identity entity — that's a separate ORM-persistence slice. Field collapse is sufficient for the registry-key-safety invariant Slice 3 needs.Test plan
persona::airc_runtime— 6/6 pass (existing tests cover the constructor paths)persona::*— 749/749 pass — zero regressions across the persona modulecontext::*— 4/4 pass — Slice 2's tests still greenA unit-level invariant test (
runtime.persona_id() == runtime.airc().peer_id().as_uuid()) requires constructing a mockedArc<airc_lib::Airc>with a known peer_id, which airc-lib doesn't expose. Existing integration tests (#141,airc_chat_demo) exercise the full bootstrap path against a real daemon and would catch any regression. Filed as airc-lib follow-up if pressure builds.Doctrine
[[persona-identity-derives-from-source-id]]— peer_id IS the substrate identity[[airc-is-the-session-not-a-feature]]— airc identity IS the session[[every-error-is-an-opportunity-to-battle-harden]]— Slice 2 reviewer caught this; Slice 1B closes the class[[no-fallbacks-ever]]— warn-on-divergence is observability, not fallback; runtime ALWAYS uses peer_idParent
Task #142 (Substrate BaseUser/Context hierarchy). Builds on PR #1521 (Slice 1 — Identity entity) + PR #1522 (Slice 2 — Context trait). Unblocks Slice 3.
Targets
canary.🤖 Generated with Claude Code