Skip to content

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

Merged
joelteply merged 2 commits into
canaryfrom
1d0361f0/slice-1b-of-142-collapse-persona-id-peer
Jun 4, 2026
Merged

feat(persona): collapse persona_id := peer_id at runtime boundary (Slice 1B of #142)#1523
joelteply merged 2 commits into
canaryfrom
1d0361f0/slice-1b-of-142-collapse-persona-id-peer

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

Summary

Slice 1B of #142 — collapses PersonaInstanceInfo.persona_id == peer_id at 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().id is safe as a registry key. Unblocks Slice 3 (ClaudeContext / JtagContext + bootstrap paths per actor kind).

What changes

persona/airc_runtime.rs::bootstrap + from_attached

Both constructors 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 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 PersonaContext is replaced with a calmer note documenting that ctx.identity().id IS the registry key post-Slice-1B.

What this does NOT do (deliberate scope)

  • Doesn't change agent_name derivation. Still derived from the (now-discarded) input persona_id via agent_name_from_identity. Per the doctrine name should derive from peer_id; that requires restructuring the mint flow to generate the keypair BEFORE deriving the name. Future cleanup, not blocking for Slice 3.
  • Doesn't drop the persona_id parameter. API stays back-compat; future cleanup prunes unused-arg call sites.
  • Doesn't migrate 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 module
  • context::* — 4/4 pass — Slice 2's tests still green
  • CI green (will verify in PR checks)

A unit-level invariant test (runtime.persona_id() == runtime.airc().peer_id().as_uuid()) requires constructing a mocked Arc<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_id

Parent

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

…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>
@joelteply joelteply merged commit 33940e7 into canary Jun 4, 2026
3 checks passed
@joelteply joelteply deleted the 1d0361f0/slice-1b-of-142-collapse-persona-id-peer branch June 4, 2026 03:52
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