Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/architecture/COGNITION-CACHE-HIERARCHY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,21 @@ The substrate is brain-shaped at the *algorithmic level* —
parallel independent regions on their own ticks, source/drain
balanced at every component, salience-modulated retention,
hippocampus-style consolidation, sleep-cadence pruning, attention
spreading across a connectivity graph. These shapes work because
spreading across a connectivity graph.

> **What "source/drain" means here.** A doctrine — every component
> that produces or accumulates state MUST have a paired draining
> mechanism. *Source* = whatever feeds it (new admissions, fresh
> turns, foundry-imported artifacts). *Drain* = the policy that
> retires what's no longer load-bearing (decay tick on engrams,
> LRU eviction on adapter cache, anti-amnesia floor on the
> permanent pin tier). A source without a drain is a leak;
> over time it spikes pressure on the host. The substrate
> applies source/drain at every cache tier (L1–L5), at the
> weights layer (foundry mints LoRA variants, Sentinel + cull
> retire losing ones), and at the resource layer (PressureBroker
> + lane refusals). Per [[source-drain-is-the-universal-pattern]]:
> for every new component, name the drain. These shapes work because
they evolved under constraints (limited working memory, energy
budget, parallel processing, lifelong learning) that the substrate
also faces — though at different scales.
Expand Down
231 changes: 231 additions & 0 deletions docs/architecture/LIFE-OF-A-PERSONA.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/workers/continuum-core/src/bin/airc_chat_demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
},
profile: profile.clone(),
adapter,
runtime: Some(runtime.clone()),
// PersonaAircRuntime impls AircCitizen — Arc auto-coerces.
runtime: runtime.clone(),
};
let mut conversation = AircPersonaConversation::new(runtime);

Expand Down
188 changes: 188 additions & 0 deletions src/workers/continuum-core/src/persona/airc_citizen.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! `AircCitizen` — the substrate's universal handle on any actor's
//! airc presence.
//!
//! ## Why this trait exists
//!
//! Pre-slice-13.5 the substrate held the persona's airc handle as
//! `Arc<PersonaAircRuntime>` everywhere. That was honest for production
//! but it forced tests + the supervisor's intermediate seams to carry
//! `Option<Arc<PersonaAircRuntime>>` because constructing a real
//! `PersonaAircRuntime` requires standing up the airc daemon.
//!
//! Per Joel 2026-06-02: "Or base user with airc props… or airc struct
//! even better inside it. As property. Token identity stuff. Maybe dot
//! identity." Per [[personas-are-citizens-airc-is-identity-provider]]:
//! every actor (persona, human, browser) IS an airc citizen. The
//! substrate's calling convention should reflect that with a trait —
//! not a concrete runtime type — because the citizen abstraction
//! transcends "which actor type".
//!
//! `AircCitizen` is that trait. Production implementations
//! ([`PersonaAircRuntime`](super::airc_runtime::PersonaAircRuntime))
//! delegate to the live airc daemon; test fixtures
//! ([`StubAircCitizen`]) hold an in-memory state machine. The
//! [`PersonaContext`](super::supervisor::PersonaContext) field that
//! used to be `Option<Arc<PersonaAircRuntime>>` is now
//! `Arc<dyn AircCitizen>` — no Option, no `.expect("None is test-only")`.
//!
//! This is the first concrete step toward task #142's BaseUser
//! hierarchy. When BaseUser lands, `AircCitizen` is the airc-side
//! interface every BaseUser variant carries; the persona variant adds
//! cognition/genome on top, the human variant adds WebAuthn/session.
//!
//! ## Surface (minimum viable)
//!
//! The trait surfaces only what real consumers call:
//!
//! - `peer_id()` — the airc-side identity (used for self-filtering
//! in the conversation projection + for tracing spans).
//! - `subscribe()` — open a live event stream on the citizen's room.
//! - `say(text)` — publish a text message under the citizen's
//! identity in her default room.
//!
//! Plus [`AircTranscriptReader`] as supertrait — every citizen can
//! page recent transcript events, which is what the RAG layer needs.
//! Rust 1.86+ stabilized trait_upcasting so `Arc<dyn AircCitizen>`
//! coerces directly to `Arc<dyn AircTranscriptReader>` at the use
//! site; no helper method, no double indirection.
//!
//! ## What's NOT on the trait
//!
//! `agent_name`, `home`, `default_room`, `persona_id`, `source` —
//! these are persona-substrate metadata, not airc-citizen surface.
//! They live on [`PersonaInstanceInfo`](crate::modules::persona_instance_manager::PersonaInstanceInfo)
//! and on `PersonaContext.identity`. The substrate carries metadata
//! via the identity struct; AircCitizen carries the *live handle*.
//! Two concerns, two types.

use crate::persona::airc_source::AircTranscriptReader;
use airc_lib::{AircError, EventId, EventStream};
use async_trait::async_trait;
use uuid::Uuid;

/// The substrate's universal airc handle. Implemented by
/// [`PersonaAircRuntime`](super::airc_runtime::PersonaAircRuntime) for
/// production and by [`StubAircCitizen`] for tests; future BaseUser
/// variants (human, browser) impl it via their own airc-lib wrappers.
///
/// `AircCitizen: AircTranscriptReader` — every citizen can page her
/// own transcript history. Rust 1.86+ trait_upcasting means
/// `Arc<dyn AircCitizen>` coerces directly to
/// `Arc<dyn AircTranscriptReader>`; no explicit conversion needed.
#[async_trait]
pub trait AircCitizen: AircTranscriptReader {
/// The airc-side peer identity (Ed25519 pubkey, formatted as Uuid).
/// Cognition uses this for self-loop filtering; the supervisor uses
/// it as part of the persona's tracing span.
fn peer_id(&self) -> Uuid;

/// Open a live event stream on the citizen's default room. The
/// stream yields every event the citizen sees — including her own
/// echoes; consumers self-filter via `peer_id()`. Used by
/// [`AircPersonaConversation::next_message`](super::airc_persona_conversation::AircPersonaConversation)
/// to drive the service loop.
async fn subscribe(&self) -> Result<EventStream, AircError>;

/// Publish a text message under the citizen's identity in her
/// default room. Returns the daemon-assigned event id so callers
/// can correlate with the subscribe stream's echo (not required
/// today, but the wire shape preserves it).
async fn say(&self, text: &str) -> Result<EventId, AircError>;
}

/// Test fixture implementing [`AircCitizen`] without standing up the
/// airc daemon. Holds the peer_id the test wants to project; subscribe
/// and say resolve to errors (the service-loop tests don't drive
/// either path — they use [`StubConversation`](super::service_loop)
/// instead). `page_recent` returns empty so RAG runs through cleanly.
///
/// This is the substrate's answer to "why was runtime an Option" —
/// instead of leaking the Option into production, tests get a typed
/// stub that satisfies the same interface. Per [[no-fallbacks-ever]]
/// — no Option, no expect, no silent substitution.
pub struct StubAircCitizen {
peer_id: Uuid,
}

impl StubAircCitizen {
/// Build a stub with the given peer_id. Tests usually want this to
/// match the `PersonaInstanceInfo::peer_id` on the same hosted
/// persona so cognition's self-filter behaves consistently.
pub fn new(peer_id: Uuid) -> Self {
Self { peer_id }
}
}

#[async_trait]
impl AircTranscriptReader for StubAircCitizen {
async fn page_recent(
&self,
_limit: usize,
) -> Result<Vec<airc_lib::TranscriptEvent>, AircError> {
Ok(vec![])
}
}

#[async_trait]
impl AircCitizen for StubAircCitizen {
fn peer_id(&self) -> Uuid {
self.peer_id
}

async fn subscribe(&self) -> Result<EventStream, AircError> {
// No service-loop test drives the stub's subscribe — the
// service loop receives messages through StubConversation
// directly, never through the citizen's stream. If a future
// test ever wires the stub into the conversation, this panics
// visibly per [[no-fallbacks-ever]] rather than silently
// returning an empty stream or fabricating an AircError
// variant that doesn't fit ("Transport"/"Route"/etc).
unreachable!(
"StubAircCitizen::subscribe must not be called — \
service-loop tests should drive the loop through \
StubConversation directly, not through the citizen handle"
);
}

async fn say(&self, _text: &str) -> Result<EventId, AircError> {
unreachable!(
"StubAircCitizen::say must not be called — \
service-loop tests should reply through StubConversation, \
not through the citizen handle"
);
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;

#[tokio::test]
async fn stub_returns_peer_id_and_empty_transcript() {
let peer = Uuid::new_v4();
let stub: Arc<dyn AircCitizen> = Arc::new(StubAircCitizen::new(peer));
assert_eq!(stub.peer_id(), peer);
let events = stub.page_recent(10).await.expect("empty page_recent ok");
assert!(events.is_empty());
}

#[tokio::test]
#[should_panic(expected = "service-loop tests should drive the loop")]
async fn stub_subscribe_panics_loudly() {
let stub: Arc<dyn AircCitizen> = Arc::new(StubAircCitizen::new(Uuid::new_v4()));
let _ = stub.subscribe().await;
}

/// The whole point of this refactor: `Arc<dyn AircCitizen>` should
/// coerce to `Arc<dyn AircTranscriptReader>` via trait_upcasting.
/// If this stops compiling, the substrate has regressed to the
/// pre-1.86 Rust pattern (manual conversion methods).
#[tokio::test]
async fn citizen_arc_upcoerces_to_transcript_reader() {
let stub: Arc<dyn AircCitizen> = Arc::new(StubAircCitizen::new(Uuid::new_v4()));
let reader: Arc<dyn AircTranscriptReader> = stub.clone();
let events = reader.page_recent(0).await.expect("page_recent");
assert!(events.is_empty());
}
}
33 changes: 18 additions & 15 deletions src/workers/continuum-core/src/persona/airc_persona_conversation.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
//! Production [`PersonaConversation`] impl wrapping
//! `Arc<PersonaAircRuntime>` — slice 11 of #133.
//! `Arc<dyn AircCitizen>` — slice 11 of #133, re-shaped in slice 13.5
//! around the [`AircCitizen`] trait.
//!
//! This is where the substrate's transport-agnostic loop
//! ([`super::service_loop::serve_persona_loop`]) meets the live airc
//! daemon. The trait stays the boundary; this struct is the one place
//! the substrate touches `airc_lib::Airc::subscribe` / `say` /
//! `page_recent` directly.
//! daemon. The conversation trait stays the loop's boundary; this
//! struct is the one place the substrate calls
//! [`AircCitizen::subscribe`] / [`AircCitizen::say`] /
//! [`AircTranscriptReader::page_recent`] directly. Holding
//! `Arc<dyn AircCitizen>` instead of the concrete runtime keeps the
//! production projection symmetric with whatever stub a future test
//! plugs in.
//!
//! ## Why slice 11 isn't in slice 10
//!
Expand Down Expand Up @@ -37,20 +42,20 @@
//! persona at boot, before any of them have necessarily attached to
//! their rooms yet.

use crate::persona::airc_runtime::PersonaAircRuntime;
use crate::persona::airc_citizen::AircCitizen;
use crate::persona::service_loop::{IncomingMessage, PersonaConversation};
use airc_lib::EventStream;
use async_trait::async_trait;
use futures::StreamExt;
use std::sync::Arc;

/// Wraps a [`PersonaAircRuntime`] and projects it onto the substrate's
/// Wraps an [`AircCitizen`] and projects it onto the substrate's
/// [`PersonaConversation`] contract. Owns the airc subscribe stream
/// across calls so successive `next_message` invocations are a
/// continuation (not a fresh resubscription that would drop in-flight
/// events).
pub struct AircPersonaConversation {
runtime: Arc<PersonaAircRuntime>,
runtime: Arc<dyn AircCitizen>,
/// The persona's own peer_id, captured at construction. Used by
/// `next_message` to skip self-loop echoes WITHIN the projection
/// — the service loop ALSO skips by persona's instance peer_id;
Expand All @@ -59,27 +64,27 @@ pub struct AircPersonaConversation {
own_peer_id: uuid::Uuid,
/// Lazy-initialized subscribe stream. `None` before the first
/// `next_message`; `Some` once the daemon attach succeeds. Per-
/// runtime stream — never shared across personas.
/// citizen stream — never shared across personas.
stream: Option<EventStream>,
}

impl AircPersonaConversation {
/// Construct without contacting the daemon. The subscribe stream
/// is built on first `next_message`; until then this is free.
pub fn new(runtime: Arc<PersonaAircRuntime>) -> Self {
let own_peer_id = runtime.airc().peer_id().as_uuid();
pub fn new(runtime: Arc<dyn AircCitizen>) -> Self {
let own_peer_id = runtime.peer_id();
Self {
runtime,
own_peer_id,
stream: None,
}
}

/// Borrow the underlying runtime — useful for the supervisor's
/// Borrow the underlying citizen — useful for the supervisor's
/// registry-eviction path (slice 12) where the supervisor needs
/// to look up the runtime back from the conversation for graceful
/// to look up the citizen back from the conversation for graceful
/// shutdown.
pub fn runtime(&self) -> &Arc<PersonaAircRuntime> {
pub fn runtime(&self) -> &Arc<dyn AircCitizen> {
&self.runtime
}
}
Expand All @@ -89,7 +94,6 @@ impl PersonaConversation for AircPersonaConversation {
async fn high_water_mark(&self, limit: usize) -> Result<u64, String> {
let events = self
.runtime
.airc()
.page_recent(limit)
.await
.map_err(|e| format!("page_recent failed: {e}"))?;
Expand All @@ -103,7 +107,6 @@ impl PersonaConversation for AircPersonaConversation {
if self.stream.is_none() {
let stream = self
.runtime
.airc()
.subscribe()
.await
.map_err(|e| format!("subscribe failed: {e}"))?;
Expand Down
31 changes: 31 additions & 0 deletions src/workers/continuum-core/src/persona/airc_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,37 @@ impl PersonaAircRuntime {
}
}

// AircCitizen + AircTranscriptReader impls — substrate's universal
// airc-handle interface, satisfied by the production runtime. Per
// [[personas-are-citizens-airc-is-identity-provider]] the trait IS the
// citizen surface; the concrete runtime is one impl among future
// peers (human, web, browser). See `persona::airc_citizen` for the
// trait definition + rationale.
#[async_trait::async_trait]
impl crate::persona::airc_source::AircTranscriptReader for PersonaAircRuntime {
async fn page_recent(
&self,
limit: usize,
) -> Result<Vec<airc_lib::TranscriptEvent>, AircError> {
self.airc.page_recent(limit).await
}
}

#[async_trait::async_trait]
impl crate::persona::airc_citizen::AircCitizen for PersonaAircRuntime {
fn peer_id(&self) -> Uuid {
self.airc.peer_id().as_uuid()
}

async fn subscribe(&self) -> Result<airc_lib::EventStream, AircError> {
self.airc.subscribe().await
}

async fn say(&self, text: &str) -> Result<EventId, AircError> {
self.airc.say(text).await
}
}

impl Drop for PersonaAircRuntime {
fn drop(&mut self) {
if let Some(handle) = self.inbound_handle.take() {
Expand Down
Loading
Loading