From 13ba61ca4cb6a563b8cc18939aa5a6cc4f7fcf1b Mon Sep 17 00:00:00 2001 From: joelteply Date: Wed, 3 Jun 2026 21:11:51 -0500 Subject: [PATCH] =?UTF-8?q?feat(identity):=20Identity=20entity=20=E2=80=94?= =?UTF-8?q?=20substrate's=20universal=20actor=20identity,=20ORM-backed=20(?= =?UTF-8?q?Slice=201=20of=20#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joel 2026-06-04 morning, in a sequence of escalations: - "The symmetry is important. Airc identities were supposed to be built into context for each persona each a different user." - "Each UNIQUE identity per persona, per you per me. Not shared." - "Yes airc is CORE LEVEL. this is the session etc." - "What differentiates each persons their own airc workspaces like you and codex is airc identity. This is like Android context and must be fixed." - "We agreed our base data type for anything storable would be rust base entity." The doctrine: airc identity IS the session abstraction. Every actor instance — persona, Claude Code session, Codex session, Joel terminal, jtag CLI invocation, web user — has its own UNIQUE airc identity (peer_id + keypair + home). Not shared. The substrate's universal handle is `Context` (Android-Context analogue): ubiquitous, mandatory, carries identity + services + captures. This commit lands the foundational data type: `Identity` as an ORM-backed entity, using the `#[derive(Entity)]` macro from #1519. Pattern A (canonical): `#[entity(primary_key)] id: Uuid` pulls in BaseEntity columns automatically. `id == airc peer_id` per [[persona-identity-derives-from-source-id]] — your airc cryptographic identity IS your substrate identity, not a separate continuum-side surrogate. ## What lands ### `continuum-core/src/identity/mod.rs` (new) - `IdentityKind` enum: Persona | Claude | Codex | Human | Jtag | Web. Every kind is a first-class substrate citizen per [[airc-is-the-session-not-a-feature]]; the tag lets downstream code branch when actor type matters. - `IdentitySource` enum: ResumedFromDisk | FreshlyMinted. Renamed from `PersonaIdentitySource` because the same enum now applies to every IdentityKind, not just Persona. - `Identity` struct: ORM entity carrying id (= peer_id), kind, agent_name, home_path, default_room, source. Foreign-keyable from every other entity that needs to record "which citizen did this." Derived via `#[derive(Entity)]`; schema IS the struct. ### `continuum-core/src/lib.rs` - `pub mod identity;` registered. ### `continuum-core/src/orm/store.rs` - Lifted `fresh_adapter` out of `#[cfg(test)] mod tests` to module-scope (still `#[cfg(test)]` gated, `pub(crate)`) so cross-module tests can lease the same fixture per [[test-fixtures-are-system-primitives]]. In-mod test callers rewritten to `super::fresh_adapter()`. ## Tests 8 identity tests pass: - `identity_schema_is_derived` — schema introspection: collection name, BaseEntity columns (`id`, `createdAt`, `updatedAt`), declared fields (camelCase via serde rename). - `identity_round_trips_through_orm` — save + find_by_id + find_all. Cross-kind: Persona + Claude rows persist, are decodable, can be manually filtered by kind. Foundation for query-by-room when the predicate-pushdown layer lands. - 3 ts-rs `export_bindings_*` tests for Identity / IdentityKind / IdentitySource — TS bindings generate cleanly. ORM family unchanged: 95 tests pass (the `fresh_adapter` lift doesn't regress anything). ## What this slice does NOT do (out of scope) - `Context` struct wrapping Identity + services + captures (Slice 2 of #142) - Bootstrap paths per IdentityKind — fresh Claude Code session minting its own Identity row + airc home; jtag CLI invocation minting ephemeral; etc. (Slice 3) - `&ctx` ubiquitous refactor across substrate APIs (Slice 4) - Migration of `PersonaInstanceInfo` callers to read from Identity table (Slice 1B, focused follow-up to keep this PR reviewable) ## Doctrine - [[airc-is-the-session-not-a-feature]] — Identity IS the session - [[no-sql-everything-through-orm-entities]] — entity, not JSON file - [[persona-identity-derives-from-source-id]] — peer_id IS the id - [[organization-purity-as-we-migrate]] — same enum across kinds - [[test-fixtures-are-system-primitives]] — fresh_adapter promoted Co-Authored-By: Claude Opus 4.7 --- .../continuum-core/src/identity/mod.rs | 273 ++++++++++++++++++ src/workers/continuum-core/src/lib.rs | 1 + src/workers/continuum-core/src/orm/store.rs | 64 ++-- 3 files changed, 311 insertions(+), 27 deletions(-) create mode 100644 src/workers/continuum-core/src/identity/mod.rs diff --git a/src/workers/continuum-core/src/identity/mod.rs b/src/workers/continuum-core/src/identity/mod.rs new file mode 100644 index 000000000..90013c149 --- /dev/null +++ b/src/workers/continuum-core/src/identity/mod.rs @@ -0,0 +1,273 @@ +//! `Identity` — the substrate's universal actor identity, ORM-backed. +//! +//! Per [[airc-is-the-session-not-a-feature]] (Joel 2026-06-04): +//! "airc is CORE LEVEL. this is the session etc." + "Each UNIQUE +//! identity per persona, per you per me. Not shared." + "This is +//! like Android context and must be fixed." +//! +//! ## What this IS +//! +//! Identity is the ORM-backed actor identity that EVERY citizen on +//! the substrate carries — personas, Claude Code sessions, Codex +//! sessions, Joel-at-a-terminal, jtag CLI invocations, future web +//! users. Same shape, same table, foreign-keyable from every other +//! entity (engrams, work cards, captures, the lot). +//! +//! Per the Android-Context analogue: Identity is what makes +//! `&ctx` mean "this specific actor instance" rather than "the +//! process's global airc handle." There is no global. Code that +//! acts on the substrate either takes a `&Context` (which carries +//! an `Identity`) or it's a pure function. +//! +//! ## Why this is an Entity (not a struct + JSON file) +//! +//! Per [[no-sql-everything-through-orm-entities]] EVERY storable +//! datum goes through the ORM + entity registration. The prior +//! shape — `PersonaInstanceInfo` struct persisted as +//! `~/.continuum/personas//seed.json` — predates the +//! `#[derive(Entity)]` macro that #1519 / task #166 landed. With +//! the derive macro available, hand-rolled JSON-on-disk is +//! technical debt: untyped, non-queryable, no FK, no schema +//! migration. +//! +//! Identity uses Pattern A of the derive macro (canonical) — +//! `#[entity(primary_key)] id: Uuid` pulls in BaseEntity columns +//! (`id`, `createdAt`, `updatedAt`, `version`) automatically. +//! +//! ## Why `id == airc peer_id` +//! +//! Per [[persona-identity-derives-from-source-id]]: a persona's +//! peer_id IS its substrate identity. Same applies to every actor +//! kind — the airc Ed25519 keypair is what makes you YOU on the +//! substrate. So Identity's `id` is the peer_id directly, not a +//! separate continuum-side surrogate. Other entities that need to +//! reference an actor FK on `Identity.id` and route via airc +//! automatically. +//! +//! ## Out of scope for this slice +//! +//! - `Context` struct wrapping Identity + services + captures +//! (Slice 2 of task #142) +//! - Bootstrap paths per IdentityKind (fresh Claude Code session +//! minting its own Identity row + airc home; jtag CLI invocation +//! minting ephemeral; etc. — Slice 3) +//! - `&ctx` ubiquitous refactor across substrate APIs (Slice 4) +//! - Migration of `PersonaInstanceInfo` callers to read from +//! Identity table (Slice 1B, follow-up PR) + +use continuum_orm_derive::Entity; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +/// What kind of actor this identity belongs to. The substrate +/// treats every kind symmetrically — same Identity entity, same +/// ORM table, same airc-peer routing — but the kind tag lets +/// downstream code branch when the actor type matters (e.g., +/// cognition pipeline runs for `Persona` but not for `Jtag`). +/// +/// Per [[airc-is-the-session-not-a-feature]] every value here is a +/// first-class substrate citizen; none is "second-class" or "for +/// internal use." +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/identity/IdentityKind.ts")] +pub enum IdentityKind { + /// An autonomous persona — has a name, cognition pipeline, + /// engrams, optional LoRA genome. Bootstraps via + /// `PersonaIdentityProvider` at substrate start. + Persona, + /// A Claude Code instance — one row per active session. + /// Mints fresh on session start; persists across the session; + /// retired when the session closes (or kept as ghost for + /// historical audit). + Claude, + /// A Codex (or other external-AI-agent) session. Same shape + /// as `Claude`, separate kind so cognition / tool-use surfaces + /// can specialize if needed. + Codex, + /// A human at a terminal / IDE — one row per active session + /// (one Joel-at-laptop, another Joel-at-iMac). Bootstrapped + /// via human-presence detection (login, IDE attach) or + /// explicit `airc init`. + Human, + /// A jtag CLI invocation. Can be ephemeral (mint on each + /// `jtag X` call, retire on exit) or long-lived (one identity + /// per user, persisted across invocations) — TBD by Slice 3. + Jtag, + /// A browser tab / web user. One row per tab session. + Web, +} + +/// How this identity came into being — resumed from prior state +/// or minted fresh. Telemetry-honest per +/// [[substrate-is-a-good-citizen-on-the-host]] so operators can +/// see at a glance whether the substrate is rehydrating prior +/// citizens or spawning new ones. +/// +/// Renamed from `PersonaIdentitySource` to `IdentitySource` per +/// the universal-kind shape — same enum now applies to every +/// `IdentityKind`, not just `Persona`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export, export_to = "../../../shared/generated/identity/IdentitySource.ts")] +pub enum IdentitySource { + /// Rehydrated from a prior session — keypair loaded from + /// `home_path/identity.key`, ORM row already existed. + ResumedFromDisk, + /// Freshly minted — new keypair generated, new home_path + /// carved out, new ORM row inserted. + FreshlyMinted, +} + +/// The substrate's universal actor identity, ORM-backed. +/// +/// One row per active actor instance. Foreign-keyable from every +/// other entity that needs to record "which citizen did this" — +/// engram authorship, work-card claims, capture-sink scopes, +/// audit trails. +/// +/// Pattern A of `#[derive(Entity)]` per #1519: `id` is both the +/// primary key AND the airc peer_id. The macro pulls in BaseEntity +/// columns (`createdAt`, `updatedAt`, `version`) automatically; +/// the struct only declares the kind-specific fields. +#[derive(Debug, Clone, Serialize, Deserialize, TS, Entity)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../../shared/generated/identity/Identity.ts")] +#[entity(collection = "identities")] +pub struct Identity { + /// Primary key AND airc peer_id. The substrate makes no + /// distinction — your airc cryptographic identity IS your + /// substrate identity. Other entities FK on this. + #[ts(type = "string")] + #[entity(primary_key)] + pub id: Uuid, + + /// What kind of actor this identity belongs to. Indexed so + /// queries like "show me all live Persona identities" or + /// "count active Claude sessions" stay O(log N). + #[entity(indexed)] + pub kind: IdentityKind, + + /// Human-readable label. For personas this is the persona + /// name (Maya, Niko, ...). For Claude/Codex sessions it's a + /// session identifier (e.g., "Claude-Opus-4.7-2026-06-04T..." + /// — runtime decides shape). For humans it's the operator's + /// chosen handle. Indexed for query-by-name workflows + /// (`airc whois ` and similar). + #[entity(indexed)] + pub agent_name: String, + + /// Absolute path to this identity's airc home dir on disk. + /// Carries the keypair (`identity.key`) and the airc state DB. + /// String (not PathBuf) because PathBuf doesn't have native + /// SQLite affinity; convert at the use site. + pub home_path: String, + + /// The room this identity defaults to subscribing to at + /// bootstrap. For personas it's the spawned-into room + /// (continuum's default_room). For Claude/Codex it's the + /// room their work coordinates in. Indexed for "show me + /// every identity in room X" queries. + #[ts(type = "string")] + #[entity(indexed)] + pub default_room: Uuid, + + /// Whether this identity was rehydrated from disk or minted + /// fresh during this bootstrap. Indexed for telemetry — + /// "what fraction of citizens are fresh this hour?" is a + /// meaningful operator question. + #[entity(indexed)] + pub source: IdentitySource, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::orm::{OrmEntity, OrmStore}; + use std::sync::Arc; + + /// Smoke test that the derive macro generated a valid OrmEntity + /// impl: schema parses, collection name matches `#[entity(collection)]`, + /// every declared field (serde camelCase-renamed) is present in + /// the schema, BaseEntity columns are auto-injected. + #[test] + fn identity_schema_is_derived() { + let schema = Identity::collection_schema(); + assert_eq!(schema.collection, "identities"); + let field_names: Vec<&str> = + schema.fields.iter().map(|f| f.name.as_str()).collect(); + // BaseEntity columns auto-injected by the derive when + // `#[entity(primary_key)]` is on `id: Uuid`. + assert!(field_names.contains(&"id"), "id missing"); + assert!(field_names.contains(&"createdAt"), "createdAt missing"); + assert!(field_names.contains(&"updatedAt"), "updatedAt missing"); + // Declared fields, camelCased via #[serde(rename_all = "camelCase")]. + assert!(field_names.contains(&"kind"), "kind missing"); + assert!(field_names.contains(&"agentName"), "agentName missing"); + assert!(field_names.contains(&"homePath"), "homePath missing"); + assert!(field_names.contains(&"defaultRoom"), "defaultRoom missing"); + assert!(field_names.contains(&"source"), "source missing"); + } + + /// Identity round-trips through OrmStore: save, find-by-id, + /// find-all. Proves the entity layer works for substrate's + /// universal actor identity, the foundation of task #142's + /// BaseUser/Context hierarchy. + /// + /// Query-by-kind / query-by-room (the predicate-pushdown API) + /// is exercised when the query-builder layer lands; for now + /// `find_all` + manual filter is the canonical read path. + #[tokio::test] + async fn identity_round_trips_through_orm() { + let (adapter, _tmp) = crate::orm::store::fresh_adapter().await; + let store: OrmStore = OrmStore::new(Arc::clone(&adapter)) + .await + .expect("new store"); + + let shared_room = Uuid::new_v4(); + let alice = Identity { + id: Uuid::new_v4(), + kind: IdentityKind::Persona, + agent_name: "Maya".to_string(), + home_path: "/tmp/test/maya/airc".to_string(), + default_room: shared_room, + source: IdentitySource::FreshlyMinted, + }; + let bob = Identity { + id: Uuid::new_v4(), + kind: IdentityKind::Claude, + agent_name: "Claude-Opus-4.7-session-X".to_string(), + home_path: "/tmp/test/claude-x/airc".to_string(), + default_room: shared_room, + source: IdentitySource::FreshlyMinted, + }; + + store.save(alice.id, &alice).await.expect("save alice"); + store.save(bob.id, &bob).await.expect("save bob"); + + let loaded_alice = store + .find_by_id(alice.id) + .await + .expect("find alice") + .expect("alice exists"); + assert_eq!(loaded_alice.agent_name, "Maya"); + assert_eq!(loaded_alice.kind, IdentityKind::Persona); + assert_eq!(loaded_alice.id, alice.id); + assert_eq!(loaded_alice.default_room, shared_room); + + let all = store.find_all().await.expect("find_all"); + assert_eq!(all.len(), 2, "both identities present"); + + // Manual filter by kind — exercises the round-trip end to + // end without depending on a query-builder API. When the + // predicate-pushdown layer lands, this becomes a single + // filter_eq call; until then this proves the data is there + // and decodable. + let personas: Vec<_> = all.iter().filter(|(_, i)| i.kind == IdentityKind::Persona).collect(); + assert_eq!(personas.len(), 1); + assert_eq!(personas[0].1.agent_name, "Maya"); + + let claudes: Vec<_> = all.iter().filter(|(_, i)| i.kind == IdentityKind::Claude).collect(); + assert_eq!(claudes.len(), 1); + assert_eq!(claudes[0].1.agent_name, "Claude-Opus-4.7-session-X"); + } +} diff --git a/src/workers/continuum-core/src/lib.rs b/src/workers/continuum-core/src/lib.rs index d4d571fdd..411d23c8c 100644 --- a/src/workers/continuum-core/src/lib.rs +++ b/src/workers/continuum-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod genome; pub mod governor; pub mod gpu; pub mod http; +pub mod identity; pub mod inference; pub mod inference_capability; pub mod ipc; diff --git a/src/workers/continuum-core/src/orm/store.rs b/src/workers/continuum-core/src/orm/store.rs index a2e1a5d73..da3edb847 100644 --- a/src/workers/continuum-core/src/orm/store.rs +++ b/src/workers/continuum-core/src/orm/store.rs @@ -263,6 +263,35 @@ fn unwrap_storage( } } +// ─── Shared test fixture (module-level, cfg-test gated) ──────────────── + +/// Build a fresh in-memory ORM adapter. Lives at module scope (not +/// inside `mod tests`) so cross-module tests — e.g., +/// `crate::identity::tests` — can lease the same helper instead of +/// re-implementing the 8-line setup. Per +/// [[test-fixtures-are-system-primitives]]: shared fixtures belong +/// at the substrate level, not duplicated per test module. +/// +/// Uses a per-test random db_path so concurrent cargo tests don't +/// collide via the SQLite shared-cache `:memory:` alias. Return the +/// `TempDir` alongside the adapter — caller owns its lifetime; drop +/// at test-end cleans up cleanly (no `/tmp` accumulation). +#[cfg(test)] +pub(crate) async fn fresh_adapter() -> (Arc, tempfile::TempDir) { + use crate::orm::adapter::AdapterConfig; + use crate::orm::sqlite::SqliteAdapter; + let mut adapter = SqliteAdapter::new(); + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("orm-store-test.sqlite"); + let mut config = AdapterConfig::default(); + config.connection_string = path.to_string_lossy().into_owned(); + adapter + .initialize(config) + .await + .expect("adapter initialize"); + (Arc::new(adapter), tmp) +} + // ─── Tests ────────────────────────────────────────────────────────────── #[cfg(test)] @@ -321,28 +350,9 @@ mod tests { } } - /// Build a fresh in-memory adapter. Uses a per-test random - /// db_path so concurrent cargo tests don't share state through - /// the SQLite shared-cache :memory: convention. - async fn fresh_adapter() -> (Arc, tempfile::TempDir) { - let mut adapter = SqliteAdapter::new(); - // Unique random path so parallel tests don't collide on the - // shared-cache :memory: alias. The tempfile sets up its own - // unique path; we delete after the test via TempDir Drop. - let tmp = tempfile::tempdir().expect("tempdir"); - let path = tmp.path().join("orm-store-test.sqlite"); - let mut config = AdapterConfig::default(); - config.connection_string = path.to_string_lossy().into_owned(); - adapter - .initialize(config) - .await - .expect("adapter initialize"); - // Return the tempdir alongside the adapter so the test - // function owns its lifetime. Dropping the returned tuple - // at test-end deletes the path cleanly (no /tmp accumulation - // the prior `mem::forget` caused, per Reviewer-2 #5). - (Arc::new(adapter), tmp) - } + // `fresh_adapter` was lifted to module scope above so cross-module + // tests (`crate::identity::tests`, future siblings) can lease it. + // In-mod tests below call it via `super::fresh_adapter()`. /// What this catches: save + find_by_id round-trip preserves /// every entity field. The foundation of the typed-store @@ -350,7 +360,7 @@ mod tests { /// loses data silently. #[tokio::test] async fn save_then_find_by_id_round_trips_every_field() { - let (adapter, _tmp) = fresh_adapter().await; + let (adapter, _tmp) = super::fresh_adapter().await; let store = OrmStore::::new(adapter).await.expect("store"); let id = Uuid::new_v4(); @@ -370,7 +380,7 @@ mod tests { /// failures is what every caller wants. #[tokio::test] async fn find_by_id_returns_none_for_missing_id() { - let (adapter, _tmp) = fresh_adapter().await; + let (adapter, _tmp) = super::fresh_adapter().await; let store = OrmStore::::new(adapter).await.expect("store"); let absent = Uuid::new_v4(); let result = store.find_by_id(absent).await.expect("find_by_id"); @@ -382,7 +392,7 @@ mod tests { /// every persona-state-style store needs. #[tokio::test] async fn find_all_returns_every_saved_row() { - let (adapter, _tmp) = fresh_adapter().await; + let (adapter, _tmp) = super::fresh_adapter().await; let store = OrmStore::::new(adapter).await.expect("store"); let ids = [Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()]; @@ -408,7 +418,7 @@ mod tests { /// silently fail to persist. #[tokio::test] async fn update_then_find_by_id_returns_new_payload() { - let (adapter, _tmp) = fresh_adapter().await; + let (adapter, _tmp) = super::fresh_adapter().await; let store = OrmStore::::new(adapter).await.expect("store"); let id = Uuid::new_v4(); @@ -431,7 +441,7 @@ mod tests { /// Models the cleanup paths a persistence layer needs. #[tokio::test] async fn delete_removes_row_and_signals_idempotently() { - let (adapter, _tmp) = fresh_adapter().await; + let (adapter, _tmp) = super::fresh_adapter().await; let store = OrmStore::::new(adapter).await.expect("store"); let id = Uuid::new_v4();