diff --git a/src/shared/generated/persona/AdmissionCandidate.ts b/src/shared/generated/persona/AdmissionCandidate.ts new file mode 100644 index 000000000..61a72f595 --- /dev/null +++ b/src/shared/generated/persona/AdmissionCandidate.ts @@ -0,0 +1,46 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EngramKind } from "./EngramKind"; +import type { EngramOrigin } from "./EngramOrigin"; +import type { TrustState } from "./TrustState"; + +/** + * Pre-admission candidate — a unit of cognition that *might* become an + * `Engram` if both the structural gate and the policy recipe approve. + * + * Constructed by callers (typically by an AIRC inbox converter or by a + * chat/tool wrapper) from the source-side data. Does NOT carry an + * engram id — id assignment happens at admission time inside the + * `Admit` decision. + */ +export type AdmissionCandidate = { +/** + * The would-be engram content (text in v1; structured later). + */ +content: string, +/** + * Engram category to assign on admission (Episodic for an AIRC + * observation, Procedural for an admitted skill update, etc.). + */ +kind: EngramKind, +/** + * Where this candidate came from. Carries the protocol-compatible + * reference fields used for verification + later forensics. + */ +origin: EngramOrigin, +/** + * Trust tier of the source AT CANDIDATE TIME. The gate compares + * against `AdmissionConfig.trust_threshold` for the structural + * trust check; recipes may also re-inspect for finer-grained policy. + */ +trust_state: TrustState, +/** + * Free-text recall keys / tags to attach if admitted. + */ +recall_keys: Array, +/** + * SHA-256 of canonical content (caller computes — usually matches + * `origin`'s `content_hash`). Used by recipes for content-dedup. + * Required because dedup is a hot path and we don't want the recipe + * re-hashing on every evaluate. + */ +content_hash: string, }; diff --git a/src/shared/generated/persona/AdmissionConfig.ts b/src/shared/generated/persona/AdmissionConfig.ts new file mode 100644 index 000000000..ed4abeb52 --- /dev/null +++ b/src/shared/generated/persona/AdmissionConfig.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TrustState } from "./TrustState"; + +/** + * Admission gate configuration — thresholds the structural gate + * enforces and defaults the recipe pipeline can consult. + * + * Per-persona; multiple personas in one process each carry their own + * `AdmissionConfig`. Defaults via `AdmissionConfig::permissive_v1()` + * (suitable for fuzzy/agent personas just bootstrapping a memory) and + * `AdmissionConfig::strict_v1()` (suitable for SOC governance roles). + */ +export type AdmissionConfig = { +/** + * Minimum trust tier required for any admission. Sources below + * this threshold get `AdmissionError::TrustBoundaryRejected` — + * the recipe is not even consulted. + */ +trust_threshold: TrustState, +/** + * How long a quarantined candidate stays in the quarantine store + * before auto-dropping (epoch-ms span). Used by recipes when they + * emit `Quarantine` decisions. + */ +quarantine_ttl_ms: number, }; diff --git a/src/shared/generated/persona/index.ts b/src/shared/generated/persona/index.ts index c47c55534..9701412f6 100644 --- a/src/shared/generated/persona/index.ts +++ b/src/shared/generated/persona/index.ts @@ -6,6 +6,8 @@ export type { ActivateSkillResult } from './ActivateSkillResult'; export type { ActivityDomain } from './ActivityDomain'; export type { AdapterInfo } from './AdapterInfo'; export type { AdequacyResult } from './AdequacyResult'; +export type { AdmissionCandidate } from './AdmissionCandidate'; +export type { AdmissionConfig } from './AdmissionConfig'; export type { AdmissionDecision } from './AdmissionDecision'; export type { AdmissionDropReason } from './AdmissionDropReason'; export type { AdmissionError } from './AdmissionError'; diff --git a/src/workers/continuum-core/src/persona/admission.rs b/src/workers/continuum-core/src/persona/admission.rs new file mode 100644 index 000000000..c47966ae9 --- /dev/null +++ b/src/workers/continuum-core/src/persona/admission.rs @@ -0,0 +1,1225 @@ +//! Admission Gate + IsMemorable Recipe (continuum#1121 PR-2) +//! +//! Layers the admission policy machinery over the storage-shape types +//! shipped in PR-1 (`persona::engram`). Splits cleanly into two responsibilities: +//! +//! - **Gate (structural)** — `AdmissionGate::admit()` runs the prereqs that +//! are independent of any specific persona's policy: envelope structure +//! verification, trust-tier threshold check, replay protection. Failures +//! here return typed `AdmissionError` variants, never silent drops. +//! - **Recipe (policy)** — implementations of the `IsMemorable` trait +//! decide whether a candidate that *passed* the structural prereqs should +//! be admitted, dropped, or quarantined. Different personas plug in +//! different recipes (a fuzzy/agent persona may use a permissive +//! `HeuristicIsMemorable`; a SOC governance persona may use a strict +//! policy-driven recipe). The trait is the seam. +//! +//! # Design choices +//! +//! - **Stateless gate, injected stores.** `AdmissionGate::admit` is a free +//! function (no `Self`). State lives in `AdmissionContext`'s lookup +//! trait objects (`SeenContentLookup`, `SeenEventLookup`). Keeps the +//! gate trivially testable + composable; same shape as how `recorder` +//! takes the trace as parameter rather than owning it. +//! - **Caller stores admitted engrams.** The gate returns the +//! `AdmissionDecision`; the caller is responsible for inserting into +//! whatever engram store backs the persona. This keeps gate concerns +//! orthogonal to persistence (PR-3+ adds the ORM persistence path). +//! - **Trace seam emitted unconditionally.** Whether the call returns +//! `Ok(decision)` or `Err(error)`, a `SEAM_ADMISSION` entry is appended +//! to the trace. Forensics need to see the gate ran even on error, +//! matching `recorder.rs`'s always-call-record_turn discipline. +//! - **No panic-catching around recipes.** Recipes return `Result`. If +//! one panics, that's a bug — let it propagate so the caller sees it. +//! Same anti-fallback discipline as the rest of the cognition path. +//! - **Envelope verification is structural in v1.** Cryptographic +//! signature verification against the AIRC pubkey infrastructure is +//! deferred to a follow-up PR (airc#561 is formalizing the envelope +//! format). v1 enforces that signed origins have non-empty +//! signature/content_hash/schema_version fields; the cryptographic +//! verifier hook lives in [`verify_envelope`] for the real impl to +//! replace. +//! +//! Pairs with: +//! - [`persona::engram`] — storage-shape types this module operates over. +//! - [`persona::trace`] — `SEAM_ADMISSION` constant + `CognitionTrace`. +//! - `docs/grid/COGNITIVE-IMMUNE-MODEL.md` — defense posture this gate +//! participates in (apoptosis-cheaper-than-corruption, B-cell anergy, +//! forensic-not-destructive). + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +use super::engram::{ + AdmissionDecision, AdmissionDropReason, AdmissionError, AircMessageRef, Engram, EngramKind, + EngramOrigin, TrustState, +}; +use super::trace::{now_ms, CognitionTrace, SEAM_ADMISSION}; + +//============================================================================= +// CANDIDATE: input to the admission pipeline +//============================================================================= + +/// Pre-admission candidate — a unit of cognition that *might* become an +/// `Engram` if both the structural gate and the policy recipe approve. +/// +/// Constructed by callers (typically by an AIRC inbox converter or by a +/// chat/tool wrapper) from the source-side data. Does NOT carry an +/// engram id — id assignment happens at admission time inside the +/// `Admit` decision. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts( + export, + export_to = "../../../shared/generated/persona/AdmissionCandidate.ts" +)] +pub struct AdmissionCandidate { + /// The would-be engram content (text in v1; structured later). + pub content: String, + + /// Engram category to assign on admission (Episodic for an AIRC + /// observation, Procedural for an admitted skill update, etc.). + pub kind: EngramKind, + + /// Where this candidate came from. Carries the protocol-compatible + /// reference fields used for verification + later forensics. + pub origin: EngramOrigin, + + /// Trust tier of the source AT CANDIDATE TIME. The gate compares + /// against `AdmissionConfig.trust_threshold` for the structural + /// trust check; recipes may also re-inspect for finer-grained policy. + pub trust_state: TrustState, + + /// Free-text recall keys / tags to attach if admitted. + pub recall_keys: Vec, + + /// SHA-256 of canonical content (caller computes — usually matches + /// `origin`'s `content_hash`). Used by recipes for content-dedup. + /// Required because dedup is a hot path and we don't want the recipe + /// re-hashing on every evaluate. + pub content_hash: String, +} + +//============================================================================= +// CONFIG: gate-level thresholds + policy +//============================================================================= + +/// Admission gate configuration — thresholds the structural gate +/// enforces and defaults the recipe pipeline can consult. +/// +/// Per-persona; multiple personas in one process each carry their own +/// `AdmissionConfig`. Defaults via `AdmissionConfig::permissive_v1()` +/// (suitable for fuzzy/agent personas just bootstrapping a memory) and +/// `AdmissionConfig::strict_v1()` (suitable for SOC governance roles). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts( + export, + export_to = "../../../shared/generated/persona/AdmissionConfig.ts" +)] +pub struct AdmissionConfig { + /// Minimum trust tier required for any admission. Sources below + /// this threshold get `AdmissionError::TrustBoundaryRejected` — + /// the recipe is not even consulted. + pub trust_threshold: TrustState, + + /// How long a quarantined candidate stays in the quarantine store + /// before auto-dropping (epoch-ms span). Used by recipes when they + /// emit `Quarantine` decisions. + #[ts(type = "number")] + pub quarantine_ttl_ms: u64, +} + +impl AdmissionConfig { + /// Permissive defaults — appropriate for a fuzzy or agent persona + /// bootstrapping its memory. Accepts anything from an authenticated + /// (signature-verified) source upward; quarantines are 24h. + pub fn permissive_v1() -> Self { + Self { + trust_threshold: TrustState::Authenticated, + quarantine_ttl_ms: 24 * 60 * 60 * 1000, + } + } + + /// Strict defaults — appropriate for SOC governance personas. + /// Requires intragrid membership for any admission; quarantines + /// are 1h (faster auto-drop because review is faster in SOC ops). + pub fn strict_v1() -> Self { + Self { + trust_threshold: TrustState::IntragridMember, + quarantine_ttl_ms: 60 * 60 * 1000, + } + } +} + +//============================================================================= +// CONTEXT: per-call state + injected lookups +//============================================================================= + +/// Lookup trait for content-hash dedup. Implementors back this with whatever +/// engram store they use (in-memory map for tests, ORM-backed for prod). +pub trait SeenContentLookup: Send + Sync { + /// Return the existing engram id if a content hash is already in the + /// store. None means "novel content; safe to admit on dedup grounds." + fn find_by_content_hash(&self, hash: &str) -> Option; +} + +/// Lookup trait for wire-event replay protection. Distinct from content +/// dedup: this catches the same envelope re-arriving (potentially attacker- +/// replayed), not the same content from a different envelope. +pub trait SeenEventLookup: Send + Sync { + /// Return the epoch-ms timestamp of the first time this event id was + /// processed, if any. None means "novel event id; safe on replay grounds." + fn first_seen_ms(&self, event_id: &str) -> Option; +} + +/// Per-call admission context. Borrowed for the duration of one +/// `AdmissionGate::admit()` call; not stored. The lookup trait objects +/// allow the gate to consult external state without owning it. +pub struct AdmissionContext<'a> { + /// Gate thresholds + recipe defaults. + pub config: &'a AdmissionConfig, + /// Wall-clock (epoch ms) at the start of this admission attempt. + /// Recipes use this for `admitted_at_ms` + quarantine expiry. + pub now_ms: u64, + /// Content-hash dedup oracle (recipe consults). + pub seen_content: &'a dyn SeenContentLookup, + /// Wire-event replay oracle (gate consults). + pub seen_events: &'a dyn SeenEventLookup, +} + +impl<'a> AdmissionContext<'a> { + /// Convenience constructor; sets `now_ms` from the system clock. + pub fn new( + config: &'a AdmissionConfig, + seen_content: &'a dyn SeenContentLookup, + seen_events: &'a dyn SeenEventLookup, + ) -> Self { + Self { + config, + now_ms: now_ms(), + seen_content, + seen_events, + } + } +} + +//============================================================================= +// RECIPE: the IsMemorable trait +//============================================================================= + +/// Persona-specific policy: given a candidate that has passed structural +/// prereqs (envelope verification, trust threshold, replay check), decide +/// whether to admit it, drop it, or quarantine it. +/// +/// Single sync method (v1 recipes are heuristic / cheap). Async / LLM-backed +/// recipes for PR-3+ will get an `IsMemorableAsync` companion trait; +/// keeping this one sync means it's safe to call from anywhere without +/// runtime considerations. +/// +/// Send + Sync because personas live across `tokio::task` boundaries and +/// the recipe is shared. +pub trait IsMemorable: Send + Sync { + /// Stable identifier for this recipe (e.g., `"heuristic.v1"`, + /// `"soc-strict.v1"`, `"persona-trained.v3"`). Surfaces in the + /// `SEAM_ADMISSION` trace metadata + in `AdmissionError::RecipeFailure` + /// attribution. + fn id(&self) -> &'static str; + + /// Evaluate the candidate. Returns the policy decision + /// (`Admit`/`Drop`/`Quarantine`), or `Err` if the recipe itself + /// could not reach a decision (returns + /// `AdmissionError::RecipeFailure` typically). + fn evaluate( + &self, + candidate: &AdmissionCandidate, + ctx: &AdmissionContext<'_>, + ) -> Result; +} + +//============================================================================= +// GATE: orchestrator +//============================================================================= + +/// Admission gate orchestrator. Stateless (zero-sized struct); namespace +/// holder for the `admit()` associated function. Use as `AdmissionGate::admit(...)`. +pub struct AdmissionGate; + +impl AdmissionGate { + /// Run the full admission pipeline on a candidate. + /// + /// Pipeline: + /// 1. **Envelope structure** — for signed origins, verify the envelope + /// has non-empty signature/content_hash/schema_version. Returns + /// `EnvelopeVerificationFailed` if structural fields are missing. + /// (Cryptographic signature verification is deferred to a follow-up + /// PR — see [`verify_envelope`].) + /// 2. **Trust threshold** — `candidate.trust_state` must be >= the + /// configured threshold. Returns `TrustBoundaryRejected` otherwise. + /// 3. **Replay protection** — for origins that carry a wire event id + /// (Airc messages do), check the `seen_events` oracle. Returns + /// `ReplayDetected` if the event id was previously processed. + /// 4. **Recipe evaluation** — call `recipe.evaluate(...)`. Recipe + /// decides admit / drop / quarantine; any internal failure + /// propagates as `RecipeFailure`. + /// + /// In ALL paths (success and error), a `SEAM_ADMISSION` entry is + /// appended to the trace with the recipe id, structural outcome, and + /// final decision label. Forensics depend on this — even rejected + /// admissions must leave a trace entry. + pub fn admit( + candidate: &AdmissionCandidate, + recipe: &R, + ctx: &AdmissionContext<'_>, + trace: &mut CognitionTrace, + ) -> Result { + let started = now_ms(); + + // Step 1: Envelope structure + if let Err(err) = verify_envelope(&candidate.origin) { + record_seam(trace, recipe.id(), started, "EnvelopeVerificationFailed", None); + return Err(err); + } + + // Step 2: Trust threshold + if candidate.trust_state < ctx.config.trust_threshold { + let err = AdmissionError::TrustBoundaryRejected { + source_trust: candidate.trust_state, + threshold: ctx.config.trust_threshold, + }; + record_seam(trace, recipe.id(), started, "TrustBoundaryRejected", None); + return Err(err); + } + + // Step 3: Replay protection (only for origins with a wire event id) + if let Some(event_id) = wire_event_id(&candidate.origin) { + if let Some(prev_ms) = ctx.seen_events.first_seen_ms(&event_id) { + let err = AdmissionError::ReplayDetected { + event_id, + previously_seen_at_ms: prev_ms, + }; + record_seam(trace, recipe.id(), started, "ReplayDetected", None); + return Err(err); + } + } + + // Step 4: Recipe evaluation + match recipe.evaluate(candidate, ctx) { + Ok(decision) => { + let label = decision_label(&decision); + record_seam(trace, recipe.id(), started, "accepted", Some(label)); + Ok(decision) + } + Err(err) => { + record_seam(trace, recipe.id(), started, "RecipeError", None); + Err(err) + } + } + } +} + +//============================================================================= +// HEURISTIC RECIPE: v1 default IsMemorable impl +//============================================================================= + +/// Cheap heuristic recipe — the v1 default. Suitable as a starting point +/// for any persona; richer recipes can compose on top. +/// +/// Decision logic: +/// 1. **Dedup** — content_hash hit in `seen_content` → `Drop::Duplicate`. +/// 2. **Length** — content shorter than `min_content_length` chars → +/// `Drop::NotMemorable("content too short")`. +/// 3. **Noise phrases** — content (case-insensitive, trimmed) matches a +/// phrase in `noise_phrases` → `Drop::NotMemorable("noise phrase")`. +/// 4. Otherwise → `Admit` with a synthesized `Engram`. +/// +/// No `Quarantine` outcome from this recipe — quarantine is for uncertain +/// cases, and this recipe is binary on its inputs. A future +/// `SimilarityIsMemorable` recipe will be the first to use quarantine +/// (for content that's borderline-similar to existing engrams). +pub struct HeuristicIsMemorable { + /// Minimum content length to consider memorable. Chars, not bytes. + pub min_content_length: usize, + /// Phrases that, alone, are noise (e.g., "ack", "ok", "👍"). Stored + /// pre-normalized (lowercased, trimmed) so the per-call hot path + /// doesn't repeat the normalization for every candidate. Use + /// [`HeuristicIsMemorable::with_noise_phrases`] to construct with a + /// custom set rather than mutating directly. + pub noise_phrases: Vec, +} + +impl HeuristicIsMemorable { + /// v1 defaults — minimal length 16 chars, common ack phrases as noise. + /// Tuned for AIRC-style chatter where one-word acks dominate volume. + pub fn default_v1() -> Self { + Self::with_noise_phrases( + 16, + [ + "ack", "ok", "okay", "thanks", "thx", "got it", "+1", "👍", + ], + ) + } + + /// Construct with a custom minimum length + noise-phrase set. Phrases + /// are normalized once here (lowercased, trimmed) so the per-call + /// noise check is a plain string comparison — heuristic recipes are + /// the per-message hot path and re-lowercasing on every candidate + /// would be wasted work. + pub fn with_noise_phrases(min_content_length: usize, phrases: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let noise_phrases = phrases + .into_iter() + .map(|p| p.as_ref().trim().to_lowercase()) + .collect(); + Self { + min_content_length, + noise_phrases, + } + } +} + +impl IsMemorable for HeuristicIsMemorable { + fn id(&self) -> &'static str { + "heuristic.v1" + } + + fn evaluate( + &self, + candidate: &AdmissionCandidate, + ctx: &AdmissionContext<'_>, + ) -> Result { + // Dedup first — cheapest check, eliminates the most common drop case. + if let Some(existing) = ctx.seen_content.find_by_content_hash(&candidate.content_hash) { + return Ok(AdmissionDecision::Drop { + reason: AdmissionDropReason::Duplicate { + existing_engram_id: existing, + }, + }); + } + + // Length check + let char_count = candidate.content.chars().count(); + if char_count < self.min_content_length { + return Ok(AdmissionDecision::Drop { + reason: AdmissionDropReason::NotMemorable { + explanation: format!( + "content too short ({} < {} chars)", + char_count, self.min_content_length + ), + }, + }); + } + + // Noise phrase check. `noise_phrases` is pre-normalized + // (lowercased + trimmed) at construction time, so the per-call + // hot path is a plain string comparison. + let normalized = candidate.content.trim().to_lowercase(); + for phrase in &self.noise_phrases { + if normalized == *phrase { + return Ok(AdmissionDecision::Drop { + reason: AdmissionDropReason::NotMemorable { + explanation: format!("matches noise phrase: {phrase:?}"), + }, + }); + } + } + + // Admit + Ok(AdmissionDecision::Admit { + engram: build_engram_from_candidate(candidate, ctx), + why: format!( + "{} accepted (len={}, no dedup hit, no noise match)", + self.id(), + char_count + ), + }) + } +} + +//============================================================================= +// HELPERS +//============================================================================= + +/// Synthesize an `Engram` from a candidate + context. Caller (the recipe) +/// uses this when emitting `Admit` so id/timestamp/trust-snapshot wiring +/// stays consistent across recipes. Public so custom recipes can use it. +pub fn build_engram_from_candidate( + candidate: &AdmissionCandidate, + ctx: &AdmissionContext<'_>, +) -> Engram { + Engram { + id: Uuid::new_v4(), + kind: candidate.kind, + content: candidate.content.clone(), + origin: candidate.origin.clone(), + recall_keys: candidate.recall_keys.clone(), + admitted_at_ms: ctx.now_ms, + trust_state_at_admission: candidate.trust_state, + // admission_trace_id wiring lands in PR-3 alongside the recorder + // changes that surface a stable trace id from CognitionTrace. + admission_trace_id: None, + } +} + +/// Verify the envelope's structural fields. v1 = sanity check on the +/// signed-origin shape (signature/content_hash/schema_version non-empty). +/// Cryptographic signature verification is deferred — see module docs. +fn verify_envelope(origin: &EngramOrigin) -> Result<(), AdmissionError> { + match origin { + EngramOrigin::Airc(r) => verify_airc_envelope(r), + // Local-trust origins (chat/tool/self-reflection) don't carry + // signed envelopes; structural verification is trivially OK. + EngramOrigin::Chat(_) + | EngramOrigin::Tool(_) + | EngramOrigin::SelfReflection { .. } => Ok(()), + } +} + +/// AIRC-specific envelope structural check. Empty signature, content_hash, +/// or schema_version means the envelope was constructed without the +/// fields that admission relies on for verifiability. +fn verify_airc_envelope(r: &AircMessageRef) -> Result<(), AdmissionError> { + if r.signature.is_empty() { + return Err(AdmissionError::EnvelopeVerificationFailed { + detail: "AIRC envelope has empty signature".to_string(), + }); + } + if r.content_hash.is_empty() { + return Err(AdmissionError::EnvelopeVerificationFailed { + detail: "AIRC envelope has empty content_hash".to_string(), + }); + } + if r.schema_version.is_empty() { + return Err(AdmissionError::EnvelopeVerificationFailed { + detail: "AIRC envelope has empty schema_version".to_string(), + }); + } + // v1 admission only understands schema v1 envelopes. Future schema + // versions should be handled explicitly, not silently coerced. + if r.schema_version != "v1" { + return Err(AdmissionError::UnsupportedSchemaVersion { + schema_version: r.schema_version.clone(), + }); + } + Ok(()) +} + +/// Extract the wire event id used for replay protection. Only Airc +/// origins carry a wire event id (`message_id` in the envelope); other +/// origins return None so the gate skips the replay check. +fn wire_event_id(origin: &EngramOrigin) -> Option { + match origin { + EngramOrigin::Airc(r) => Some(r.message_id.clone()), + _ => None, + } +} + +/// Append a `SEAM_ADMISSION` entry to the trace. +fn record_seam( + trace: &mut CognitionTrace, + recipe_id: &str, + started_ms: u64, + structural: &str, + decision: Option<&'static str>, +) { + let duration_ms = now_ms().saturating_sub(started_ms); + let metadata = match decision { + Some(label) => serde_json::json!({ + "recipe": recipe_id, + "structural": structural, + "decision": label, + }), + None => serde_json::json!({ + "recipe": recipe_id, + "structural": structural, + }), + }; + trace.record(SEAM_ADMISSION, started_ms, duration_ms, metadata); +} + +/// Map an `AdmissionDecision` to a static label for trace metadata. +fn decision_label(decision: &AdmissionDecision) -> &'static str { + match decision { + AdmissionDecision::Admit { .. } => "Admit", + AdmissionDecision::Drop { .. } => "Drop", + AdmissionDecision::Quarantine { .. } => "Quarantine", + } +} + +//============================================================================= +// TESTS +//============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + const FIXED_NOW_MS: u64 = 1_715_625_600_000; + + // ── test doubles for the lookup oracles ───────────────────────────── + + #[derive(Default)] + struct InMemoryContent(Mutex>); + + impl SeenContentLookup for InMemoryContent { + fn find_by_content_hash(&self, hash: &str) -> Option { + self.0.lock().unwrap().get(hash).copied() + } + } + + #[derive(Default)] + struct InMemoryEvents(Mutex>); + + impl SeenEventLookup for InMemoryEvents { + fn first_seen_ms(&self, event_id: &str) -> Option { + self.0.lock().unwrap().get(event_id).copied() + } + } + + fn airc_ref(message_id: &str, sig: &str, hash: &str, schema: &str) -> AircMessageRef { + AircMessageRef { + transport: "airc".to_string(), + room_id: "cambriantech".to_string(), + message_id: message_id.to_string(), + sender_id: "airc-8a5e".to_string(), + sent_at_ms: FIXED_NOW_MS, + received_at_ms: FIXED_NOW_MS, + content_hash: hash.to_string(), + signature: sig.to_string(), + proof_refs: vec![], + schema_version: schema.to_string(), + client_name: Some("airc-bash".to_string()), + } + } + + fn candidate(content: &str, trust: TrustState, origin: EngramOrigin) -> AdmissionCandidate { + AdmissionCandidate { + content: content.to_string(), + kind: EngramKind::Episodic, + origin, + trust_state: trust, + recall_keys: vec!["test".to_string()], + content_hash: format!("sha256:fake-{}", content.len()), + } + } + + fn airc_candidate(content: &str, trust: TrustState, message_id: &str) -> AdmissionCandidate { + candidate( + content, + trust, + EngramOrigin::Airc(airc_ref(message_id, "sig", "hash", "v1")), + ) + } + + fn permissive_ctx<'a>( + config: &'a AdmissionConfig, + content: &'a InMemoryContent, + events: &'a InMemoryEvents, + ) -> AdmissionContext<'a> { + AdmissionContext { + config, + now_ms: FIXED_NOW_MS, + seen_content: content, + seen_events: events, + } + } + + // ── envelope verification ─────────────────────────────────────────── + + /// What this catches: empty signature on an Airc envelope is a + /// structural failure, not a recipe-policy decision. Returns + /// `EnvelopeVerificationFailed`, not `Drop` — the gate must fail + /// loud rather than silently rejecting. + #[test] + fn empty_signature_returns_envelope_verification_failed() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = candidate( + "interesting", + TrustState::ApprovedPeer, + EngramOrigin::Airc(airc_ref("msg-1", "", "hash", "v1")), + ); + + let result = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + match result { + Err(AdmissionError::EnvelopeVerificationFailed { detail }) => { + assert!(detail.contains("signature"), "detail: {detail}"); + } + other => panic!("expected EnvelopeVerificationFailed, got {other:?}"), + } + // Seam recorded even on error — forensics need it. + assert_eq!(trace.seam_count(), 1); + assert_eq!(trace.last_seam_name(), Some(SEAM_ADMISSION)); + } + + /// What this catches: empty content_hash on an Airc envelope is a + /// structural failure (the gate needs the hash for tamper detection + /// + dedup). Symmetric with the empty-signature test; same failure + /// class returned via `EnvelopeVerificationFailed`. Asymmetric + /// coverage between empty-signature/empty-content-hash/empty-schema + /// would let one of the three regress silently. + #[test] + fn empty_content_hash_returns_envelope_verification_failed() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = candidate( + "perfectly novel content of sufficient length", + TrustState::ApprovedPeer, + EngramOrigin::Airc(airc_ref("msg-x", "sig", "", "v1")), + ); + + match AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace) { + Err(AdmissionError::EnvelopeVerificationFailed { detail }) => { + assert!(detail.contains("content_hash"), "detail: {detail}"); + } + other => panic!("expected EnvelopeVerificationFailed, got {other:?}"), + } + assert_eq!(trace.seam_count(), 1); + } + + /// What this catches: empty schema_version is structurally invalid + /// (admission can't reason about a schema with no name). Distinct + /// from `UnsupportedSchemaVersion` which fires for unknown values + /// — empty is its own class returned via `EnvelopeVerificationFailed`. + /// Symmetric coverage with empty-signature/empty-content-hash. + #[test] + fn empty_schema_version_returns_envelope_verification_failed() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = candidate( + "perfectly novel content of sufficient length", + TrustState::ApprovedPeer, + EngramOrigin::Airc(airc_ref("msg-x", "sig", "hash", "")), + ); + + match AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace) { + Err(AdmissionError::EnvelopeVerificationFailed { detail }) => { + assert!(detail.contains("schema_version"), "detail: {detail}"); + } + other => panic!("expected EnvelopeVerificationFailed, got {other:?}"), + } + assert_eq!(trace.seam_count(), 1); + } + + /// What this catches: unsupported schema_version returns + /// `UnsupportedSchemaVersion`, not silent acceptance. Forward- + /// compatibility hinge: if a sender claims schema v2 we want to fail + /// loudly until the v2 admission code is shipped. + #[test] + fn unknown_schema_version_returns_unsupported_schema_version() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = candidate( + "novel content of sufficient length to be memorable", + TrustState::ApprovedPeer, + EngramOrigin::Airc(airc_ref("msg-x", "sig", "hash", "v2")), + ); + + let result = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + match result { + Err(AdmissionError::UnsupportedSchemaVersion { schema_version }) => { + assert_eq!(schema_version, "v2"); + } + other => panic!("expected UnsupportedSchemaVersion, got {other:?}"), + } + } + + /// What this catches: local-trust origins (chat / tool / self-reflection) + /// don't carry signed envelopes, so the structural envelope check + /// must pass-through rather than treating "no signature" as failure. + /// Otherwise admission of any internal-cognition engram would be + /// impossible. + #[test] + fn self_reflection_origin_passes_envelope_structure() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = AdmissionContext { + config: &cfg, + now_ms: FIXED_NOW_MS, + seen_content: &content, + seen_events: &events, + }; + let mut trace = CognitionTrace::new(); + + let parent = Uuid::new_v4(); + let cand = candidate( + "reflection on a prior engram which is sufficiently long", + TrustState::SelfTrust, + EngramOrigin::SelfReflection { + parent_engram_id: parent, + }, + ); + + let result = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace) + .expect("self-reflection should pass structural checks"); + match result { + AdmissionDecision::Admit { engram, .. } => { + assert_eq!(engram.trust_state_at_admission, TrustState::SelfTrust); + if let EngramOrigin::SelfReflection { parent_engram_id } = engram.origin { + assert_eq!(parent_engram_id, parent); + } else { + panic!("origin should round-trip as SelfReflection"); + } + } + other => panic!("expected Admit, got {other:?}"), + } + } + + // ── trust threshold ───────────────────────────────────────────────── + + /// What this catches: trust below the configured threshold returns + /// `TrustBoundaryRejected` BEFORE the recipe is consulted. A strict + /// gate must not let unauthenticated traffic reach the recipe at + /// all, even if the recipe would have rejected anyway — defense in + /// depth. + #[test] + fn untrusted_source_rejected_at_trust_boundary_before_recipe() { + let cfg = AdmissionConfig::strict_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + // ApprovedPeer is below IntragridMember (strict_v1's threshold). + let cand = airc_candidate("totally legitimate content here", TrustState::ApprovedPeer, "msg-2"); + + let result = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + match result { + Err(AdmissionError::TrustBoundaryRejected { + source_trust, + threshold, + }) => { + assert_eq!(source_trust, TrustState::ApprovedPeer); + assert_eq!(threshold, TrustState::IntragridMember); + } + other => panic!("expected TrustBoundaryRejected, got {other:?}"), + } + } + + /// What this catches: equal-tier source passes the threshold (>=, not >). + /// Off-by-one on the comparison would silently reject valid traffic. + #[test] + fn trust_threshold_uses_inclusive_comparison() { + let cfg = AdmissionConfig::strict_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + // IntragridMember == threshold; must pass. + let cand = airc_candidate( + "intragrid member message of sufficient length here", + TrustState::IntragridMember, + "msg-3", + ); + + let result = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace) + .expect("equal-tier source should pass threshold"); + assert!(matches!(result, AdmissionDecision::Admit { .. })); + } + + // ── replay protection ─────────────────────────────────────────────── + + /// What this catches: an event_id present in the seen-events oracle + /// returns `ReplayDetected`. The gate must consult the oracle and + /// reject before the recipe runs — replay protection is structural, + /// not policy. + #[test] + fn replayed_event_returns_replay_detected() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + events.0.lock().unwrap().insert("msg-replay".to_string(), 1_000_000); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = airc_candidate("perfectly novel content here", TrustState::ApprovedPeer, "msg-replay"); + + let result = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + match result { + Err(AdmissionError::ReplayDetected { + event_id, + previously_seen_at_ms, + }) => { + assert_eq!(event_id, "msg-replay"); + assert_eq!(previously_seen_at_ms, 1_000_000); + } + other => panic!("expected ReplayDetected, got {other:?}"), + } + } + + /// What this catches: non-Airc origins skip replay (no wire event id + /// to check). A SelfReflection candidate must not get + /// `ReplayDetected` even if an unrelated event id is in the oracle. + #[test] + fn non_airc_origin_skips_replay_check() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + events + .0 + .lock() + .unwrap() + .insert("some-airc-id".to_string(), 1_000_000); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = candidate( + "reflective thought of sufficient length to admit", + TrustState::SelfTrust, + EngramOrigin::SelfReflection { + parent_engram_id: Uuid::new_v4(), + }, + ); + + AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace) + .expect("non-airc origin should bypass replay check"); + } + + // ── HeuristicIsMemorable policy ───────────────────────────────────── + + /// What this catches: content shorter than `min_content_length` drops + /// with `NotMemorable` reason carrying the actual lengths. Operators + /// debugging admission funnels need the explanation string to be + /// informative, not opaque. + #[test] + fn heuristic_drops_short_content_with_explanation() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = airc_candidate("short", TrustState::ApprovedPeer, "msg-short"); + + match AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace).unwrap() { + AdmissionDecision::Drop { + reason: AdmissionDropReason::NotMemorable { explanation }, + } => { + assert!(explanation.contains("too short"), "explanation: {explanation}"); + assert!(explanation.contains("16"), "must mention threshold: {explanation}"); + } + other => panic!("expected Drop NotMemorable, got {other:?}"), + } + } + + /// What this catches: noise phrase match is case-insensitive and + /// trim-tolerant, so " ACK " drops the same as "ack". + #[test] + fn heuristic_drops_noise_phrase_case_insensitive() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + // " ACK " trimmed+lower = "ack" which is in noise_phrases. + // Must use a noise phrase that's >= 16 chars before normalization + // so the length check doesn't catch it first — but ACK is short. + // So we need: noise check happens AFTER length check passes. + // Pad the content with whitespace to clear the length check, then + // verify the noise check still fires after trim. + let padded = " ACK "; + let cand = airc_candidate(padded, TrustState::ApprovedPeer, "msg-noise"); + + match AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace).unwrap() { + AdmissionDecision::Drop { + reason: AdmissionDropReason::NotMemorable { explanation }, + } => { + assert!(explanation.contains("noise phrase"), "explanation: {explanation}"); + } + other => panic!("expected Drop NotMemorable for noise phrase, got {other:?}"), + } + } + + /// What this catches: dedup hit returns `Drop::Duplicate` with the + /// existing engram id surfaced. Recall surfaces depend on this id + /// being present so they can link the new arrival back to the + /// already-stored memory. + #[test] + fn heuristic_drops_duplicate_with_existing_engram_id() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let existing_id = Uuid::new_v4(); + content + .0 + .lock() + .unwrap() + .insert("sha256:fake-29".to_string(), existing_id); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + // content_hash = sha256:fake-{len}; pick a content with len 29 + // matching the seeded entry. + let cand = airc_candidate("twenty-nine character content", TrustState::ApprovedPeer, "msg-d"); + assert_eq!(cand.content_hash, "sha256:fake-29"); + + match AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace).unwrap() { + AdmissionDecision::Drop { + reason: AdmissionDropReason::Duplicate { existing_engram_id }, + } => { + assert_eq!(existing_engram_id, existing_id); + } + other => panic!("expected Drop Duplicate, got {other:?}"), + } + } + + /// What this catches: when the heuristic admits, the synthesized + /// `Engram` carries the full provenance + trust snapshot. A + /// regression that drops the trust_state_at_admission would silently + /// erase forensic context that later introspection needs. + #[test] + fn heuristic_admit_synthesizes_engram_with_full_provenance() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = airc_candidate( + "design discussion about cognitive immune model layers", + TrustState::IntragridMember, + "msg-admit-1", + ); + + match AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace).unwrap() { + AdmissionDecision::Admit { engram, why } => { + assert_eq!(engram.kind, EngramKind::Episodic); + assert_eq!(engram.trust_state_at_admission, TrustState::IntragridMember); + assert!(matches!(engram.origin, EngramOrigin::Airc(_))); + assert_eq!(engram.admitted_at_ms, FIXED_NOW_MS); + assert!(why.contains("heuristic.v1"), "why: {why}"); + } + other => panic!("expected Admit, got {other:?}"), + } + } + + // ── trace seam emission ───────────────────────────────────────────── + + /// What this catches: every admission attempt — success OR error — + /// emits exactly one `SEAM_ADMISSION` entry. Forensics and replay + /// tooling depend on this invariant; missing seams break the + /// "every gate decision is auditable" promise. + #[test] + fn every_admission_path_emits_exactly_one_seam() { + let cfg = AdmissionConfig::permissive_v1(); + let mut trace = CognitionTrace::new(); + + // Path 1: structural failure + { + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let cand = candidate( + "x", + TrustState::ApprovedPeer, + EngramOrigin::Airc(airc_ref("e1", "", "h", "v1")), + ); + let _ = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + } + assert_eq!(trace.seam_count(), 1); + + // Path 2: successful admit + { + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let cand = airc_candidate( + "well-formed candidate of sufficient length to admit", + TrustState::ApprovedPeer, + "e2", + ); + let _ = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + } + assert_eq!(trace.seam_count(), 2); + + // Path 3: drop (length) + { + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let cand = airc_candidate("short", TrustState::ApprovedPeer, "e3"); + let _ = AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace); + } + assert_eq!(trace.seam_count(), 3); + + // Each seam should be SEAM_ADMISSION. + for seam in &trace.seams { + assert_eq!(seam.name, SEAM_ADMISSION); + } + } + + /// What this catches: trace metadata on a successful admit includes + /// the recipe id + decision label. Operators reading the seam log + /// need to see WHICH recipe ran and WHAT it decided, without parsing + /// neighbouring data. + #[test] + fn admit_seam_metadata_carries_recipe_id_and_decision() { + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = airc_candidate( + "this is a meaningful design observation worth recalling", + TrustState::ApprovedPeer, + "msg-trace-1", + ); + + AdmissionGate::admit(&cand, &HeuristicIsMemorable::default_v1(), &ctx, &mut trace).unwrap(); + let seam = &trace.seams[0]; + assert_eq!(seam.metadata["recipe"], serde_json::json!("heuristic.v1")); + assert_eq!(seam.metadata["structural"], serde_json::json!("accepted")); + assert_eq!(seam.metadata["decision"], serde_json::json!("Admit")); + } + + // ── recipe error path ─────────────────────────────────────────────── + + /// What this catches: a recipe that returns `Err(AdmissionError::RecipeFailure)` + /// has its error propagated unchanged. Critical that the gate doesn't + /// silently coerce recipe errors into Drop (would hide bugs in the + /// recipe and turn loud failures into quiet drops). + #[test] + fn recipe_failure_propagates_as_recipe_failure() { + struct FailingRecipe; + impl IsMemorable for FailingRecipe { + fn id(&self) -> &'static str { + "test.failing" + } + fn evaluate( + &self, + _candidate: &AdmissionCandidate, + _ctx: &AdmissionContext<'_>, + ) -> Result { + Err(AdmissionError::RecipeFailure { + recipe_id: "test.failing".to_string(), + detail: "intentional test failure".to_string(), + }) + } + } + + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = airc_candidate( + "passes structural checks, recipe will explode", + TrustState::ApprovedPeer, + "msg-fail", + ); + + let result = AdmissionGate::admit(&cand, &FailingRecipe, &ctx, &mut trace); + match result { + Err(AdmissionError::RecipeFailure { recipe_id, detail }) => { + assert_eq!(recipe_id, "test.failing"); + assert!(detail.contains("intentional"), "detail: {detail}"); + } + other => panic!("expected RecipeFailure, got {other:?}"), + } + } + + /// What this catches: a recipe that emits `Quarantine` has the + /// decision propagated unchanged (the gate doesn't override the + /// recipe's quarantine choice). PR-3+ recipes will use this for + /// borderline-similarity content. + #[test] + fn recipe_quarantine_decision_propagates() { + struct QuarantineRecipe; + impl IsMemorable for QuarantineRecipe { + fn id(&self) -> &'static str { + "test.quarantine" + } + fn evaluate( + &self, + candidate: &AdmissionCandidate, + ctx: &AdmissionContext<'_>, + ) -> Result { + Ok(AdmissionDecision::Quarantine { + engram: build_engram_from_candidate(candidate, ctx), + reason: "borderline similarity to existing engram".to_string(), + expiry_ms: ctx.now_ms + ctx.config.quarantine_ttl_ms, + }) + } + } + + let cfg = AdmissionConfig::permissive_v1(); + let content = InMemoryContent::default(); + let events = InMemoryEvents::default(); + let ctx = permissive_ctx(&cfg, &content, &events); + let mut trace = CognitionTrace::new(); + + let cand = airc_candidate( + "borderline content that the recipe wants to quarantine", + TrustState::ApprovedPeer, + "msg-quar", + ); + + match AdmissionGate::admit(&cand, &QuarantineRecipe, &ctx, &mut trace).unwrap() { + AdmissionDecision::Quarantine { + engram, expiry_ms, .. + } => { + assert_eq!(engram.trust_state_at_admission, TrustState::ApprovedPeer); + assert_eq!(expiry_ms, FIXED_NOW_MS + cfg.quarantine_ttl_ms); + } + other => panic!("expected Quarantine, got {other:?}"), + } + // Trace metadata should carry the Quarantine decision label. + assert_eq!(trace.seams[0].metadata["decision"], serde_json::json!("Quarantine")); + } + + // ── AdmissionConfig presets ───────────────────────────────────────── + + /// What this catches: the two preset configs have the trust ordering + /// the docs claim (permissive accepts Authenticated; strict requires + /// IntragridMember). A regression in the preset values would silently + /// change the security posture of every persona using the defaults. + #[test] + fn admission_config_presets_have_documented_thresholds() { + let permissive = AdmissionConfig::permissive_v1(); + let strict = AdmissionConfig::strict_v1(); + assert_eq!(permissive.trust_threshold, TrustState::Authenticated); + assert_eq!(strict.trust_threshold, TrustState::IntragridMember); + assert!(strict.trust_threshold > permissive.trust_threshold); + // strict is shorter quarantine (faster auto-drop in SOC ops) + assert!(strict.quarantine_ttl_ms < permissive.quarantine_ttl_ms); + } + + // ── ts-rs binding tests ───────────────────────────────────────────── + + #[test] + fn export_bindings_admission_candidate() { + let cfg = ts_rs::Config::default(); + AdmissionCandidate::export_all(&cfg).unwrap(); + } + + #[test] + fn export_bindings_admission_config() { + let cfg = ts_rs::Config::default(); + AdmissionConfig::export_all(&cfg).unwrap(); + } +} diff --git a/src/workers/continuum-core/src/persona/mod.rs b/src/workers/continuum-core/src/persona/mod.rs index 4349b2efa..b3727f6e2 100644 --- a/src/workers/continuum-core/src/persona/mod.rs +++ b/src/workers/continuum-core/src/persona/mod.rs @@ -11,6 +11,7 @@ //! - channel_queue: Generic per-domain queue container //! - channel_registry: Domain-to-queue routing + service_cycle() +pub mod admission; pub mod allocator; pub mod channel_items; pub mod channel_queue; @@ -36,6 +37,10 @@ pub mod text_analysis; pub mod types; pub mod unified; +pub use admission::{ + build_engram_from_candidate, AdmissionCandidate, AdmissionConfig, AdmissionContext, + AdmissionGate, HeuristicIsMemorable, IsMemorable, SeenContentLookup, SeenEventLookup, +}; pub use allocator::{ allocate as allocate_personas, load_catalog, select_local_model, AllocationResult, PersonaAllocation, PersonaCatalogEntry, diff --git a/src/workers/continuum-core/src/persona/trace.rs b/src/workers/continuum-core/src/persona/trace.rs index 5dbaeb59c..47d20ad44 100644 --- a/src/workers/continuum-core/src/persona/trace.rs +++ b/src/workers/continuum-core/src/persona/trace.rs @@ -49,6 +49,10 @@ pub const SEAM_ANALYZE: &str = "analyze"; pub const SEAM_PROMPT_ASSEMBLY: &str = "prompt_assembly"; pub const SEAM_INFERENCE: &str = "inference"; pub const SEAM_POST_PROCESS: &str = "post_process"; +/// Admission gate seam — emitted by the IsMemorable Recipe pipeline +/// (see `persona::admission`). Metadata records the recipe id, structural +/// outcome (`accepted` / `rejected_`), and final decision label. +pub const SEAM_ADMISSION: &str = "admission"; /// One entry in the per-turn trace. Captures the seam's identity, when /// it ran, how long it took, and an open-vocabulary `metadata` blob