meerkat_mobkit/memory/capabilities.rs
1//! Judgment-plane capability traits (M4 de-weld).
2//!
3//! The full judgment plane — the §10.1 taint firewall controls, the Steward's
4//! dream read/write surface, and the console Memory panel's read API — used
5//! to be inherent methods on the bundled [`SqliteAgentMemoryStore`]; every
6//! consumer (StewardEngine, `memory_wiring`, the console panel, the builder)
7//! was welded to that concrete type. These traits promote that surface so the
8//! plane runs against ANY provider that advertises the capability, with the
9//! bundled store as just one implementor — the same shape the Distiller
10//! (`AgentMemoryProvider` + `TombstoneSource`) and the RecallCoordinator
11//! (`SelectedRecordFetch`) already have.
12//!
13//! Discovery is by capability accessor on [`AgentMemoryProvider`]
14//! (`as_taintable`, `as_steward_store`, `as_memory_panel_store`,
15//! `as_selected_record_fetch`, `as_tombstone_source`), replacing the deleted
16//! `as_sqlite_store()` downcast — the provider trait no longer names its own
17//! implementation.
18//!
19//! Trait cut, deliberately layered:
20//! - [`TaintableStore`] stands alone: firewall wiring happens before any
21//! engine exists and is required even when no engine is enabled.
22//! - [`StewardStore`] extends [`StagedMemoryStore`] (the dream stages and
23//! commits batches) and [`TombstoneSource`] (the orient phase renders
24//! recent tombstones).
25//! - [`MemoryPanelStore`] extends [`StewardStore`]: seven of the panel's
26//! fifteen reads are steward reads, and everything the panel renders
27//! (dream runs, promotions, proposals, harvests) is judgment-plane output
28//! — a provider that can serve the panel can serve the steward. The
29//! supertrait keeps the shared methods defined exactly once instead of
30//! duplicating names across two traits on the same implementor.
31//!
32//! All row types here are plain portable data (no SQLite types leak).
33//!
34//! [`SqliteAgentMemoryStore`]: crate::memory::sqlite_store::SqliteAgentMemoryStore
35//! [`AgentMemoryProvider`]: crate::identity_first::agent_memory::AgentMemoryProvider
36//! [`SelectedRecordFetch`]: crate::memory::selector::SelectedRecordFetch
37
38use std::sync::Arc;
39
40use async_trait::async_trait;
41
42use crate::identity_first::agent_memory::AgentMemoryError;
43use crate::memory::distiller::TombstoneSource;
44use crate::memory::events::MemoryEventSink;
45use crate::memory::records::{
46 InjectionLogEntry, MemoryAuthor, MemoryId, MemoryRecord, MemoryScope, NewMemoryRecord,
47 ProposalId,
48};
49use crate::memory::staged::{StageToken, StagedMemoryStore};
50use crate::memory::taint::LlmWriteGate;
51
52// ---------------------------------------------------------------------------
53// Evidence resolution (re-homed from sqlite_store.rs)
54// ---------------------------------------------------------------------------
55
56/// §10.2 P3: whether an [`EvidenceRef`] resolves against the persistent
57/// session store (session exists; a cited range lies within the persisted
58/// transcript). The semantic endorsement half of an `agent_verified` retier
59/// is the dream's judgment (recorded in the op rationale); this is the
60/// mechanical half.
61///
62/// [`EvidenceRef`]: crate::memory::records::EvidenceRef
63pub trait EvidenceRefResolver: Send + Sync {
64 fn resolves(&self, evidence: &crate::memory::records::EvidenceRef) -> Result<(), String>;
65}
66
67// ---------------------------------------------------------------------------
68// Firewall control surface (§10.1)
69// ---------------------------------------------------------------------------
70
71/// The §10.1 taint-firewall control surface: install the LLM write gate, the
72/// evidence-ref resolver, and the timeline event sink on a store. The
73/// `*_if_absent` variants are load-bearing for the classic builder path,
74/// which must never clobber a gate or sink an embedder installed before
75/// handing the store over.
76pub trait TaintableStore: Send + Sync {
77 /// Install the §10.1 LLM write gate. Wiring installs it at startup,
78 /// before any member can dispatch a write.
79 fn set_llm_write_gate(&self, gate: Arc<dyn LlmWriteGate>);
80
81 /// Install the §10.1 gate only when none is present. Returns whether
82 /// this call installed the gate.
83 fn set_llm_write_gate_if_absent(&self, gate: Arc<dyn LlmWriteGate>) -> bool;
84
85 /// Install the §10.2 evidence-ref resolver. The steward wiring installs
86 /// it at startup; from then on every staged retier to `agent_verified`
87 /// must cite evidence that resolves against the session store.
88 fn set_evidence_resolver(&self, resolver: Arc<dyn EvidenceRefResolver>);
89
90 /// Wire the §9.3 timeline sink for quarantined-write events.
91 fn set_event_sink(&self, sink: Arc<dyn MemoryEventSink>);
92
93 /// Wire the §9.3 sink only when none is present. Returns whether this
94 /// call installed the sink.
95 fn set_event_sink_if_absent(&self, sink: Arc<dyn MemoryEventSink>) -> bool;
96}
97
98// ---------------------------------------------------------------------------
99// Steward read/write rows (§8.5)
100// ---------------------------------------------------------------------------
101
102/// Per-scope store overview row for the dream's orient phase (§8.5) and
103/// the P3b console Memory panel.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ScopeOverview {
106 pub scope: MemoryScope,
107 pub active: u64,
108 pub quarantined: u64,
109 pub superseded: u64,
110 pub tombstoned: u64,
111 pub body_bytes: u64,
112}
113
114/// One pending (or held) mob/operator-scope proposal awaiting a dream
115/// verdict (§8.5 promotion).
116#[derive(Debug, Clone, PartialEq)]
117pub struct PendingProposal {
118 pub proposal_id: ProposalId,
119 pub scope: MemoryScope,
120 pub record: NewMemoryRecord,
121 pub author: MemoryAuthor,
122 pub status: String,
123 pub created_at_ms: u64,
124 /// §10.1 propose-time taint fact: `Some(reason)` when the write gate
125 /// would have quarantined this author at propose time. A plain steward
126 /// "accept" on a tainted proposal downgrades to an operator gate.
127 pub taint: Option<String>,
128}
129
130/// One retired identity awaiting an exit-interview harvest (§8.5).
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct PendingHarvest {
133 pub identity: String,
134 pub session_key: Option<String>,
135 pub cause: String,
136 pub retired_at_ms: u64,
137}
138
139/// One gated quarantine-promotion (§10.2): the staged batch commits only on
140/// gating approval.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct PendingPromotion {
143 pub pending_id: String,
144 pub stage_token: String,
145 pub record_id: MemoryId,
146 pub scope_kind: String,
147 pub scope_key: String,
148 pub rationale: Option<String>,
149 pub status: String,
150 pub created_at_ms: u64,
151}
152
153/// One persisted dream run (§8.5): the durable verdict sheet — phases,
154/// verdict counters, and skips as `DreamRun::detail()` JSON — written by the
155/// steward at the end of every pipeline run (one row per partition run).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct PersistedDreamRun {
158 pub run_id: String,
159 pub partition_label: String,
160 pub started_at_ms: u64,
161 pub completed_at_ms: u64,
162 pub ops_committed: u64,
163 /// `DreamRun::detail()` JSON text (phases, verdicts, skips).
164 pub detail: String,
165}
166
167/// One usage-audit verdict awaiting (or holding) operator review — the
168/// "memories you might want to correct" queue (§16 Q6). `resolved_at_ms`
169/// NULL = open.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct DreamAuditVerdict {
172 pub run_id: String,
173 pub record_id: String,
174 pub verdict: String,
175 pub rationale: String,
176 pub created_at_ms: u64,
177 pub resolved_at_ms: Option<u64>,
178 pub resolution: Option<String>,
179}
180
181// ---------------------------------------------------------------------------
182// Steward capability (§8.5)
183// ---------------------------------------------------------------------------
184
185/// The Steward's dream read/write surface (§8.5): orient aggregates, the
186/// proposal/quarantine/harvest queues, gated-promotion bookkeeping, and the
187/// durable dream ledger. [`StagedMemoryStore`] supplies stage/commit (and
188/// through it the provider recall/manifest surface); [`TombstoneSource`]
189/// supplies the orient phase's recent-tombstone render.
190#[async_trait]
191pub trait StewardStore: StagedMemoryStore + TombstoneSource {
192 /// The per-scope retention floors this store warns against (§7.3);
193 /// rendered into the dream's orient overview as floor pressure.
194 fn scope_floors(&self) -> (usize, usize);
195
196 /// Per-scope counts and byte totals for a realm — the orient phase's
197 /// one cheap aggregate.
198 async fn scope_overview(&self, realm: &str) -> Result<Vec<ScopeOverview>, AgentMemoryError>;
199
200 /// Pending/held proposals, oldest first (§8.5 promotion queue).
201 async fn pending_proposals(
202 &self,
203 realm: &str,
204 limit: usize,
205 ) -> Result<Vec<PendingProposal>, AgentMemoryError>;
206
207 /// Record a dream verdict on a proposal: `accepted`, `rejected`, or
208 /// `held` (held stays in the pending queue for the next dream).
209 async fn set_proposal_status(
210 &self,
211 realm: &str,
212 proposal_id: &str,
213 status: &str,
214 ) -> Result<(), AgentMemoryError>;
215
216 /// Quarantined records, newest first — the dream's review queue (§8.5).
217 /// The steward is the one stage that reads these bodies wholesale; the
218 /// caller renders them defanged.
219 async fn quarantined_records(
220 &self,
221 realm: &str,
222 limit: usize,
223 ) -> Result<Vec<MemoryRecord>, AgentMemoryError>;
224
225 /// Records by id, any status — the gather phase's bounded body fetch.
226 /// Missing ids are skipped (the model may cite stale ids).
227 async fn records_by_ids(
228 &self,
229 realm: &str,
230 ids: &[String],
231 ) -> Result<Vec<MemoryRecord>, AgentMemoryError>;
232
233 /// Most recently updated records in a realm, any scope, active or
234 /// quarantined — the gather phase filters (e.g. recent distillates by
235 /// author) host-side.
236 async fn recent_records(
237 &self,
238 realm: &str,
239 limit: usize,
240 ) -> Result<Vec<MemoryRecord>, AgentMemoryError>;
241
242 /// Newest-first injection-ledger rows for a realm (§9.2). Read surface
243 /// for the steward's usage audit and the console Memory panel.
244 async fn injection_log(
245 &self,
246 realm: &str,
247 limit: usize,
248 ) -> Result<Vec<InjectionLogEntry>, AgentMemoryError>;
249
250 /// Record a retired identity for the next dream's exit-interview
251 /// harvest (§8.5). Idempotent per (identity, retired_at_ms).
252 async fn record_pending_harvest(
253 &self,
254 realm: &str,
255 identity: &str,
256 session_key: Option<&str>,
257 cause: &str,
258 ) -> Result<(), AgentMemoryError>;
259
260 /// Pending exit-interview harvests, oldest first.
261 async fn pending_harvests(
262 &self,
263 realm: &str,
264 limit: usize,
265 ) -> Result<Vec<PendingHarvest>, AgentMemoryError>;
266
267 /// Mark one exit-interview harvest done.
268 async fn mark_harvest_complete(
269 &self,
270 realm: &str,
271 identity: &str,
272 retired_at_ms: u64,
273 ) -> Result<(), AgentMemoryError>;
274
275 /// Record a gated quarantine-promotion: gating `pending_id` → staged
276 /// batch token (§10.2). Only a gating approval commits the token.
277 async fn record_pending_promotion(
278 &self,
279 realm: &str,
280 promotion: PendingPromotion,
281 ) -> Result<(), AgentMemoryError>;
282
283 /// Look up a still-pending gated promotion by its gating pending id.
284 async fn pending_promotion_by_id(
285 &self,
286 realm: &str,
287 pending_id: &str,
288 ) -> Result<Option<PendingPromotion>, AgentMemoryError>;
289
290 /// All still-pending gated promotions (dream-start reconciliation).
291 async fn pending_promotions(
292 &self,
293 realm: &str,
294 ) -> Result<Vec<PendingPromotion>, AgentMemoryError>;
295
296 /// Resolve a gated promotion: `committed`, `denied`, or `expired`.
297 async fn resolve_pending_promotion(
298 &self,
299 realm: &str,
300 pending_id: &str,
301 status: &str,
302 ) -> Result<(), AgentMemoryError>;
303
304 /// Re-key a gated promotion after a gating escalation minted a
305 /// successor pending entry.
306 async fn rekey_pending_promotion(
307 &self,
308 realm: &str,
309 old_pending_id: &str,
310 new_pending_id: &str,
311 ) -> Result<(), AgentMemoryError>;
312
313 /// Discard a staged-but-uncommitted batch (denied/expired gated
314 /// promotions; §8.5 crash semantics keep this safe — an unapplied stage
315 /// row is never visible).
316 async fn discard_stage(&self, token: StageToken) -> Result<(), AgentMemoryError>;
317
318 /// Persist one completed dream run (idempotent on run_id).
319 async fn save_dream_run(
320 &self,
321 realm: &str,
322 run: PersistedDreamRun,
323 ) -> Result<(), AgentMemoryError>;
324
325 /// Record the usage-audit verdicts of one dream run. Only non-clean
326 /// verdicts belong here (the review queue); load-bearing records are
327 /// counted in the run detail, not queued.
328 async fn save_dream_audit_verdicts(
329 &self,
330 realm: &str,
331 run_id: &str,
332 verdicts: Vec<(String, String, String)>,
333 ) -> Result<(), AgentMemoryError>;
334}
335
336// ---------------------------------------------------------------------------
337// Console Memory panel rows and capability (§9.3, P3b)
338// ---------------------------------------------------------------------------
339
340/// One page of panel records: strictly-descending `(updated_at_ms,
341/// memory_id)` keyset pagination.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct PanelRecordsPage {
344 pub records: Vec<MemoryRecord>,
345 /// Pass back as `cursor` to continue; `None` when exhausted.
346 pub next_cursor: Option<(u64, String)>,
347}
348
349/// One steward dream run reconstructed from its audit rows (every committed
350/// op records its `Steward { run_id }` author in the audit `detail`).
351#[derive(Debug, Clone, Default, PartialEq, Eq)]
352pub struct DreamRunAudit {
353 pub run_id: String,
354 pub first_op_at_ms: u64,
355 pub last_op_at_ms: u64,
356 pub ops: u64,
357 /// op kind → count (create/supersede/tombstone/retier/set_rank).
358 pub op_kinds: std::collections::BTreeMap<String, u64>,
359 /// Ops that landed quarantined at the write seam.
360 pub quarantined_ops: u64,
361 /// Bounded sample of touched record ids, newest first.
362 pub memory_ids: Vec<String>,
363 /// Bounded sample of op rationales, newest first.
364 pub rationales: Vec<String>,
365}
366
367/// The console Memory panel's read API (§9.3): the ten read-only
368/// `mobkit/memory/panel/*` RPCs. Seven of the panel's reads are steward
369/// reads (overview, proposals, quarantine, promotions, harvests, injection
370/// ledger, scope floors) — the [`StewardStore`] supertrait carries those;
371/// this trait adds the panel-only listing, lineage, and dream-ledger reads.
372/// Everything the panel renders is judgment-plane output, so requiring the
373/// steward surface is the honest capability bar, not an over-ask.
374#[async_trait]
375pub trait MemoryPanelStore: StewardStore {
376 /// Realms with store state (panel realm picker).
377 async fn panel_realms(&self) -> Result<Vec<String>, AgentMemoryError>;
378
379 /// One record by id, any status.
380 async fn record_by_id(
381 &self,
382 realm: &str,
383 memory_id: &str,
384 ) -> Result<Option<MemoryRecord>, AgentMemoryError>;
385
386 /// Panel record listing: optional scope/status filters, newest-updated
387 /// first, keyset cursor. Any status is visible here — the panel is an
388 /// inspection surface and renders status explicitly.
389 async fn records_page(
390 &self,
391 realm: &str,
392 scope_kind: Option<&str>,
393 scope_key: Option<&str>,
394 status_kind: Option<&str>,
395 limit: usize,
396 cursor: Option<(u64, String)>,
397 ) -> Result<PanelRecordsPage, AgentMemoryError>;
398
399 /// Supersede lineage around one record, oldest first: ancestors via the
400 /// `supersedes` pointer, the record itself, then committed successors
401 /// via the `Superseded { by }` status link. When the tip has no
402 /// committed successor, records *claiming* to supersede it (e.g. a
403 /// quarantined supersede that left the prior active, §10.1) are
404 /// appended without recursing — claims are visible but never extend
405 /// the walk. Bounded by `max_len`, cycle-safe.
406 async fn supersede_chain(
407 &self,
408 realm: &str,
409 memory_id: &str,
410 max_len: usize,
411 ) -> Result<Vec<MemoryRecord>, AgentMemoryError>;
412
413 /// Newest-first injection-ledger rows for one record (panel usage view).
414 async fn injection_log_for_record(
415 &self,
416 realm: &str,
417 record_id: &str,
418 limit: usize,
419 ) -> Result<Vec<InjectionLogEntry>, AgentMemoryError>;
420
421 /// Persisted dream runs, newest first.
422 async fn dream_runs(
423 &self,
424 realm: &str,
425 limit: usize,
426 ) -> Result<Vec<PersistedDreamRun>, AgentMemoryError>;
427
428 /// Open (unresolved) audit verdicts, newest first — the operator review
429 /// queue. One row per (run, record); the console dedups by record.
430 async fn open_dream_audit_verdicts(
431 &self,
432 realm: &str,
433 limit: usize,
434 ) -> Result<Vec<DreamAuditVerdict>, AgentMemoryError>;
435
436 /// Dream-run summaries reconstructed from steward audit rows, newest
437 /// run first. Bounded scan; runs older than the scan window fall off
438 /// the panel, which is acceptable for a history summary surface.
439 async fn dream_history(
440 &self,
441 realm: &str,
442 max_runs: usize,
443 ) -> Result<Vec<DreamRunAudit>, AgentMemoryError>;
444}