From fb9d8753e1e7ae22bd88f93e201a9f92021adcf8 Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Sat, 15 Aug 2026 00:45:07 +0200 Subject: [PATCH] feat(V2-03): one logical write coordinator (ADR-070) Replaces the implicit `NativeMemoryStore::with_inner_write` closure authority with a single owner of the mutable product state: a BaseMyAIRuntime with a private ProductStateCell, a read-only ReadGateway, and one WriteCoordinator holding a non-clonable ProductWriteToken. All live mutation paths (memory, graph, import, purge, key/passphrase rotation) now submit closed WriteIntent values and are ordered by a canonical FIFO, with a phase-aware WAL outcome (Aborted/Durable/OutcomeUnknown) and a product visibility barrier that prevents observing a torn index state. ADR-067 remains the sole authority for physical publication; V2-03 only closes the logical write side per ADR-070 rev3. Co-Authored-By: Claude Sonnet 5 --- README.md | 8 +- crates/basemyai-engine/src/crypto.rs | 4 + crates/basemyai-engine/src/error.rs | 18 + crates/basemyai-engine/src/failpoint.rs | 13 + .../src/idx/graph/persistent.rs | 722 +++- crates/basemyai-engine/src/idx/memory/mod.rs | 5 +- .../src/idx/memory/persistent.rs | 1255 ++++++- crates/basemyai-engine/src/idx/vector/mod.rs | 5 +- .../src/idx/vector/persistent.rs | 332 +- crates/basemyai-engine/src/lib.rs | 10 +- crates/basemyai-engine/src/store/durable.rs | 6 + .../src/store/engine/background.rs | 168 +- crates/basemyai-engine/src/store/engine/io.rs | 4 + .../basemyai-engine/src/store/engine/mod.rs | 180 +- .../basemyai-engine/src/store/engine/open.rs | 4 + .../src/store/engine/rotate.rs | 227 +- .../basemyai-engine/src/store/engine/write.rs | 513 ++- crates/basemyai-engine/src/store/mod.rs | 7 +- crates/basemyai-engine/src/store/wal.rs | 167 + .../tests/crash/adr066_commit_outcome.rs | 217 +- crates/basemyai-mcp/src/error.rs | 44 + crates/basemyai-rest/src/http/error.rs | 68 + crates/basemyai/src/error.rs | 50 + crates/basemyai/src/lib.rs | 7 +- crates/basemyai/src/maintenance/epoch.rs | 296 ++ crates/basemyai/src/maintenance/mod.rs | 1 + crates/basemyai/src/memory/isolation.rs | 512 ++- crates/basemyai/src/memory/mod.rs | 57 +- crates/basemyai/src/runtime/mod.rs | 704 ++++ crates/basemyai/src/storage/integrity.rs | 138 +- .../src/storage/native_store/coordinator.rs | 3246 +++++++++++++++++ .../basemyai/src/storage/native_store/mod.rs | 538 +-- .../src/storage/native_store/porting.rs | 578 ++- .../src/storage/native_store/snapshot_ops.rs | 138 +- .../src/storage/native_store/trait_impl.rs | 1069 ++++-- .../isolation/p1_isolation_adversarial.rs | 28 +- crates/basemyai/tests/memory/events.rs | 7 +- crates/basemyai/tests/memory/memory_tests.rs | 39 +- .../tests/storage/plaintext_open_forbidden.rs | 24 +- docs/ADR.md | 3 +- ...9-structural-project-agent-memory-scope.md | 472 ++- .../ADR-070-one-logical-write-coordinator.md | 579 +++ docs/status.md | 35 +- xtask/src/v2_layering.rs | 489 ++- xtask/v2-layering-baseline.txt | 179 +- 45 files changed, 11875 insertions(+), 1291 deletions(-) create mode 100644 crates/basemyai/src/maintenance/epoch.rs create mode 100644 crates/basemyai/src/runtime/mod.rs create mode 100644 crates/basemyai/src/storage/native_store/coordinator.rs create mode 100644 docs/adr/ADR-070-one-logical-write-coordinator.md diff --git a/README.md b/README.md index a6d65e2..7051a5f 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,13 @@ forgetting, and encryption are part of the product contract. See - [x] Episode-to-fact consolidation via injected LLM (any local runner, no hard dependency) - [x] Hardware-aware provisioning — no silent model downloads, explicit setup command - [x] Encryption at rest via native envelope (ADR-030); centralized passphrase resolution (ADR-034) -- [x] Per-agent isolation enforced structurally by key layout — cross-agent leakage is a security invariant +- [x] Per-agent result isolation across retrieval surfaces — record, FTS and graph namespaces are structurally keyed by agent; the vector (ANN) index is currently searched globally and post-filtered by agent, full physical search-space isolation is targeted by V2-08 ([ADR-069](docs/adr/ADR-069-structural-project-agent-memory-scope.md)) - [x] MCP server (stdio + HTTP), CLI (`basemyai`), REST sidecar (axum), Python SDK (PyO3), Node SDK (NAPI-RS), native Rust crate

  P1 Public Proofs

- [Benchmark harness: BaseMyAI local vs Mem0 + Qdrant local](docs/benchmarks/local-memory-vs-mem0-qdrant.md) -- [Adversarial isolation test](crates/basemyai/tests/p1_isolation_adversarial.rs) +- [Adversarial isolation test](crates/basemyai/tests/p1_isolation_adversarial.rs) — proves no cross-agent leakage in returned *results* (record/FTS/vector/hybrid recall); does not prove ANN search-space isolation (see [Security](#security)) - [Temporal replacement demo](crates/basemyai/examples/temporal_replacement.rs) - [Zero network after setup](docs/zero-network-after-setup.md) - [BaseMyAI is not a vector DB](docs/not-a-vector-db.md) @@ -167,7 +167,7 @@ BaseMyAI is a **Cargo workspace** with two publishable crates (`basemyai-core`, **`basemyai-engine`** is the durable storage layer: crash-consistent LSM, LM-DiskANN vector index, inverted FTS index, graph traversal, and at-rest encryption — all in pure Rust, no libSQL/SQLite dependency. -**`basemyai`** is the memory product built on top: the four layers, temporal RAG, per-agent isolation enforced structurally in the key layout, and all language binding surfaces. +**`basemyai`** is the memory product built on top: the four layers, temporal RAG, per-agent result isolation (structural for records/FTS/graph, post-filtered today for the vector index — see [Security](#security)), and all language binding surfaces. Since **[ADR-032](docs/adr/ADR-032-native-only.md)** (2026-07-08), the native engine is the **only** active backend. libSQL/V1 compatibility paths have been removed from the workspace. @@ -609,7 +609,7 @@ runs is the failure mode this guard exists to make impossible. For security issues, kindly email us at [security@basemyai.com](mailto:security@basemyai.com) instead of posting a public issue on GitHub. - **100 % local** — no data leaves your machine, no telemetry by default -- **Per-agent isolation** — every access is scoped structurally by `agent_id`; cross-agent leakage is a security invariant, not a config option +- **Per-agent result isolation** — record, FTS and graph namespaces are scoped structurally by `agent_id`; cross-agent leakage of *results* is a security invariant, not a config option. The ANN vector index is currently searched globally and post-filtered by agent — the query's candidate set, beam and expansions are not yet scope-isolated. Full physical search-space isolation is targeted by V2-08 ([ADR-069](docs/adr/ADR-069-structural-project-agent-memory-scope.md)); see also the adversarial isolation test note below, which proves result-level non-leakage, not search-space isolation. - **Encrypted at rest** — native envelope (ADR-030); passphrase resolved per ADR-034, never stored in config - **No silent network** — the embedder receives a local model path and never auto-downloads diff --git a/crates/basemyai-engine/src/crypto.rs b/crates/basemyai-engine/src/crypto.rs index 84f6f1d..6e02d13 100644 --- a/crates/basemyai-engine/src/crypto.rs +++ b/crates/basemyai-engine/src/crypto.rs @@ -413,6 +413,10 @@ pub(crate) fn publish_staged_meta_tracked( if let Err(error) = crate::failpoint_result!("after_crypto_meta_write") { return DurablePublish::Unknown(error); } + tracker.directory_sync_started(); + if let Err(error) = crate::failpoint_result!("during_crypto_meta_directory_sync") { + return DurablePublish::Unknown(error); + } // ENG-DUR-003: see `crate::fs_util`. if let Err(error) = crate::fs_util::sync_dir(dir) { return DurablePublish::Unknown(error); diff --git a/crates/basemyai-engine/src/error.rs b/crates/basemyai-engine/src/error.rs index aaa00c8..aba6a72 100644 --- a/crates/basemyai-engine/src/error.rs +++ b/crates/basemyai-engine/src/error.rs @@ -74,6 +74,17 @@ pub enum EngineError { #[error("sequence space exhausted after {last_allocated}: cannot reserve {requested} contiguous mutation(s)")] SequenceSpaceExhausted { last_allocated: u64, requested: usize }, + /// A WAL write was attempted but its complete absence or durability + /// could not be proved. The writer is terminal until the store is + /// reopened; callers must not retry the logical mutation blindly. + #[error("WAL append outcome is unknown and the writer must be reopened: {cause}")] + WalAppendOutcomeUnknown { cause: String }, + + /// The WAL bytes were appended but their `sync_all` outcome could not be + /// proved. The writer is terminal until the store is reopened. + #[error("WAL sync outcome is unknown and the writer must be reopened: {cause}")] + WalSyncOutcomeUnknown { cause: String }, + /// A point-in-time read snapshot is process-local and cannot be used /// after the engine instance that created it has closed or dropped. #[error("read snapshot belongs to a closed store")] @@ -237,6 +248,13 @@ pub enum EngineError { #[error("memory record already exists for agent {agent:?}, id {id:?}")] DuplicateMemoryId { agent: String, id: String }, + /// The monotonic memory/vector-id allocator cannot represent the cursor + /// immediately after the requested contiguous range. Rejected before + /// staging reaches the WAL or mutates index RAM; wraparound and id reuse + /// are never permitted. + #[error("vector id space exhausted at next id {next}: cannot reserve {requested} contiguous id(s)")] + VectorIdSpaceExhausted { next: u64, requested: usize }, + /// A string handed to an FTS-index key encoder (`key::fts_index`) would /// overflow that field's `u32` length prefix. Sibling of /// [`EngineError::GraphKeyTooLong`]/[`EngineError::MemoryKeyTooLong`], diff --git a/crates/basemyai-engine/src/failpoint.rs b/crates/basemyai-engine/src/failpoint.rs index c4af183..5d9ce39 100644 --- a/crates/basemyai-engine/src/failpoint.rs +++ b/crates/basemyai-engine/src/failpoint.rs @@ -10,6 +10,10 @@ //! //! ```text //! after_wal_append after the WAL record's write_all, before fsync +//! before_wal_append_attempt immediately before ADR-070's phase-aware batch +//! write; errors here are provably `Aborted` +//! during_wal_sync after append succeeds, immediately before +//! `sync_all`; errors are `OutcomeUnknown(Sync)` //! after_wal_fsync after the WAL record's sync_all //! after_sst_tmp_write after the SST tmp file's write_all, before fsync //! after_sst_tmp_fsync after the SST tmp file's sync_all, before rename @@ -25,10 +29,16 @@ //! during_wal_segment_removal after catalog.meta publishes a new active //! WAL (old-segment unlink retried + counted) //! after_crypto_meta_write after crypto.meta's atomic replace (rotation) +//! during_crypto_meta_directory_sync after crypto.meta replacement, at the +//! directory-durability boundary (ADR-070) //! after_full_rotation_new_dek after the next generation's fresh DEK wrap //! after_full_rotation_sst_write after the merged SST is durable //! before_full_rotation_publish after all content fsyncs, before pointer rename //! after_full_rotation_publish after pointer rename, before old-generation GC +//! before_full_rotation_install after durable generation publication, before +//! the prebuilt RAM image is installed (ADR-070) +//! after_light_rotation_publish after crypto.meta publication is complete; +//! finalisation panic cannot demote Committed //! during_full_rotation_gc immediately before best-effort old-generation GC //! during_generation_gc_removal one simulated failed removal attempt per hit //! (retried up to 3x, then counted via @@ -59,6 +69,9 @@ //! root directory fsync (ADR-066) — same class as //! `after_catalog_rename`, on the pointer that selects //! which generation directory a reopen adopts +//! during_generation_directory_sync after generation.meta replacement, at +//! the root directory-durability boundary +//! (ADR-070) //! before_seal_candidate_wal_fsync immediately before the replacement WAL's //! initial fsync, with no reader-facing or //! durable-publication lock held (V2-01B) diff --git a/crates/basemyai-engine/src/idx/graph/persistent.rs b/crates/basemyai-engine/src/idx/graph/persistent.rs index c82d8e7..4ddba33 100644 --- a/crates/basemyai-engine/src/idx/graph/persistent.rs +++ b/crates/basemyai-engine/src/idx/graph/persistent.rs @@ -38,14 +38,10 @@ //! ## Atomicity //! //! [`PersistentGraph::upsert_entity`] and [`PersistentGraph::upsert_edge`] -//! each write exactly **one** KV record via a plain [`crate::store::Engine::put`]. -//! `Engine::put` is already durable and atomic per key (WAL-fsync-then- -//! memtable, see `store::engine`'s own doc) — wrapping a single `put` in a -//! `Batch`/`apply_batch` would add nothing here. This is the one structural -//! difference from the vector index's inserts, which always touch multiple -//! records (the new node plus every re-pruned neighbor plus shared -//! metadata) and therefore need `apply_batch`'s all-or-nothing guarantee; a -//! graph upsert never touches more than the one record it names. +//! each write exactly **one** KV record. Their legacy wrappers apply the +//! [`Batch`] produced by the corresponding `stage_*` primitive, so callers +//! that own a wider logical transaction can compose graph records into that +//! same commit without giving this stateless index a second write authority. //! [`PersistentGraph::traverse`] is read-only. use super::edge::{self, GraphEdgeMeta}; @@ -53,7 +49,161 @@ use super::entity::{self, GraphEntity}; use super::traverse::{self, GraphProvider, OutEdge, Reached}; use crate::error::{EngineError, Result}; use crate::key::graph_index; -use crate::store::{Engine, EngineRead}; +use crate::store::{Batch, Engine, EngineCommitOutcome, EngineRead, SequenceRange, WalCommitPhase}; + +/// Explicit admission bounds for one graph-purge WAL record. +#[doc(hidden)] +#[derive(Clone, Copy, Debug)] +pub struct GraphPurgeChunkOptions { + pub max_items: usize, + pub max_wal_bytes: usize, +} + +impl Default for GraphPurgeChunkOptions { + fn default() -> Self { + Self { + max_items: 256, + max_wal_bytes: 4 * 1024 * 1024, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GraphPurgePhase { + Entities, + Edges, +} + +/// Opaque resume point. It is process-local planning state, not a durable +/// idempotency key and not a caller-controlled physical key. +#[doc(hidden)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GraphPurgeCursor { + agent: String, + phase: GraphPurgePhase, + start: Vec, +} + +/// One owned bounded read-set. Keys and values stay private so callers can +/// neither widen the delete set nor forge revalidation evidence. +#[doc(hidden)] +#[derive(Debug)] +pub struct GraphPurgeChunkPlan { + agent: String, + phase: GraphPurgePhase, + range_start: Vec, + range_end: Vec, + records: Vec<(Vec, Vec)>, + resume: Option, + approx_wal_bytes: usize, +} + +impl GraphPurgeChunkPlan { + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + #[must_use] + pub fn approx_wal_bytes(&self) -> usize { + self.approx_wal_bytes + } + + #[must_use] + pub fn resume(&self) -> Option<&GraphPurgeCursor> { + self.resume.as_ref() + } +} + +#[doc(hidden)] +#[derive(Debug, thiserror::Error)] +pub enum GraphPurgePlanError { + #[error("graph purge chunk bounds must both be non-zero")] + ZeroBound, + #[error("graph purge cursor belongs to a different agent")] + WrongAgent, + #[error("graph purge cursor is outside its agent keyspace")] + InvalidCursor, + #[error("one graph purge tombstone needs approximately {needed} WAL bytes, above chunk cap {max}")] + ItemTooLarge { needed: usize, max: usize }, + #[error("graph purge read-set changed before staging")] + StaleReadSet, + #[error("a new graph record appeared inside the planned purge interval")] + PhantomInRange, + #[error(transparent)] + Engine(#[from] EngineError), +} + +/// Closed phase-preserving result for a staged graph purge. +#[doc(hidden)] +#[derive(Debug)] +pub enum GraphPurgeCommitOutcome { + Aborted { + burned_sequence_range: Option, + cause: Option, + }, + Committed { + sequence_range: SequenceRange, + removed: u64, + }, + OutcomeUnknown { + phase: WalCommitPhase, + cause: EngineError, + }, + DurableReopenRequired { + sequence_range: SequenceRange, + cause: EngineError, + }, +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedGraphPurgeChunk { + batch: Batch, + removed: u64, +} + +impl StagedGraphPurgeChunk { + #[doc(hidden)] + pub fn commit(self, engine: &mut Engine) -> GraphPurgeCommitOutcome { + let Self { batch, removed } = self; + match engine.commit_batch(batch) { + EngineCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => GraphPurgeCommitOutcome::Aborted { + burned_sequence_range, + cause, + }, + EngineCommitOutcome::Durable(receipt) => { + let sequence_range = receipt.sequence_range(); + match engine.install_committed_batch_with(receipt, |_| Ok(())) { + Ok(()) => GraphPurgeCommitOutcome::Committed { + sequence_range, + removed, + }, + Err(cause) => GraphPurgeCommitOutcome::DurableReopenRequired { sequence_range, cause }, + } + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => { + GraphPurgeCommitOutcome::OutcomeUnknown { phase, cause } + } + } + } +} + +fn prefix_end(prefix: &[u8]) -> Option> { + let mut end = prefix.to_vec(); + let index = end.iter().rposition(|byte| *byte != u8::MAX)?; + end[index] += 1; + end.truncate(index + 1); + Some(end) +} /// Stateless handle over the KV-persisted graph index — holds no cached /// state at all (see the module doc for why no metadata record is needed). @@ -69,19 +219,201 @@ impl PersistentGraph { Self } - /// Inserts or overwrites the entity `(agent, id)`: one durable - /// `Engine::put` (see the module doc's atomicity note). Takes a whole + /// Plans one bounded, read-only page of an agent graph purge. + /// + /// The returned plan owns the exact key/value read-set and an opaque + /// resume cursor. Planning never stages or commits a mutation. Because + /// the graph format has no scope epoch, this can only protect the + /// interval represented by this chunk; a coordinator-level mutation + /// epoch remains necessary to prove a whole multi-chunk purge had no + /// insertion behind an already-committed cursor. + #[doc(hidden)] + pub fn plan_purge_chunk( + &self, + source: &R, + agent: &str, + cursor: Option<&GraphPurgeCursor>, + options: GraphPurgeChunkOptions, + ) -> std::result::Result { + if options.max_items == 0 || options.max_wal_bytes == 0 { + return Err(GraphPurgePlanError::ZeroBound); + } + + let (phase, start) = match cursor { + Some(cursor) => { + if cursor.agent != agent { + return Err(GraphPurgePlanError::WrongAgent); + } + (cursor.phase, cursor.start.clone()) + } + None => (GraphPurgePhase::Entities, graph_index::entity_agent_prefix(agent)?), + }; + let prefix = match phase { + GraphPurgePhase::Entities => graph_index::entity_agent_prefix(agent)?, + GraphPurgePhase::Edges => graph_index::edge_agent_prefix(agent)?, + }; + let end = prefix_end(&prefix).ok_or(GraphPurgePlanError::InvalidCursor)?; + if start < prefix || start >= end { + return Err(GraphPurgePlanError::InvalidCursor); + } + + let page = source.scan_range_page(&start, &end, options.max_items)?; + let mut records = Vec::with_capacity(page.entries.len()); + let mut approx_wal_bytes = 0_usize; + let mut capped_resume = None; + for (key, value) in page.entries { + let mut item = Batch::new(); + item.delete(key.as_bytes()); + let needed = item.approx_wire_bytes(); + if records.is_empty() && needed > options.max_wal_bytes { + return Err(GraphPurgePlanError::ItemTooLarge { + needed, + max: options.max_wal_bytes, + }); + } + let next_bytes = approx_wal_bytes.saturating_add(needed); + if next_bytes > options.max_wal_bytes { + capped_resume = Some(key.as_bytes().to_vec()); + break; + } + approx_wal_bytes = next_bytes; + records.push((key.as_bytes().to_vec(), value)); + } + + let (range_end, resume) = if let Some(start) = capped_resume { + ( + start.clone(), + Some(GraphPurgeCursor { + agent: agent.to_string(), + phase, + start, + }), + ) + } else if let Some(start) = page.next_start { + ( + start.clone(), + Some(GraphPurgeCursor { + agent: agent.to_string(), + phase, + start, + }), + ) + } else if phase == GraphPurgePhase::Entities { + let edge_start = graph_index::edge_agent_prefix(agent)?; + ( + end, + Some(GraphPurgeCursor { + agent: agent.to_string(), + phase: GraphPurgePhase::Edges, + start: edge_start, + }), + ) + } else { + (end, None) + }; + + Ok(GraphPurgeChunkPlan { + agent: agent.to_string(), + phase, + range_start: start, + range_end, + records, + resume, + approx_wal_bytes, + }) + } + + /// Revalidates and stages one previously planned purge chunk. + /// + /// This method is inert: it returns an owned batch but never writes a + /// WAL record. Missing/changed planned members and new members inside + /// the exact planned interval are rejected before a batch can commit. + #[doc(hidden)] + pub fn stage_purge_chunk( + &self, + source: &R, + plan: GraphPurgeChunkPlan, + ) -> std::result::Result { + let expected_prefix = match plan.phase { + GraphPurgePhase::Entities => graph_index::entity_agent_prefix(&plan.agent)?, + GraphPurgePhase::Edges => graph_index::edge_agent_prefix(&plan.agent)?, + }; + let expected_end = prefix_end(&expected_prefix).ok_or(GraphPurgePlanError::InvalidCursor)?; + if plan.range_start < expected_prefix || plan.range_end > expected_end || plan.range_start > plan.range_end { + return Err(GraphPurgePlanError::InvalidCursor); + } + + let current = source.scan_range(&plan.range_start, &plan.range_end)?; + let mut expected = plan.records.iter(); + let mut observed = current.iter(); + loop { + match (expected.next(), observed.next()) { + (None, None) => break, + (Some(_), None) => return Err(GraphPurgePlanError::StaleReadSet), + (None, Some(_)) => return Err(GraphPurgePlanError::PhantomInRange), + (Some((expected_key, expected_value)), Some((observed_key, observed_value))) => { + match observed_key.as_bytes().cmp(expected_key) { + std::cmp::Ordering::Less => return Err(GraphPurgePlanError::PhantomInRange), + std::cmp::Ordering::Greater => return Err(GraphPurgePlanError::StaleReadSet), + std::cmp::Ordering::Equal if observed_value != expected_value => { + return Err(GraphPurgePlanError::StaleReadSet); + } + std::cmp::Ordering::Equal => {} + } + } + } + } + + let mut batch = Batch::new(); + for (key, _) in plan.records { + batch.delete(&key); + } + debug_assert_eq!(batch.approx_wire_bytes(), plan.approx_wal_bytes); + Ok(StagedGraphPurgeChunk { + removed: u64::try_from(batch.len()).unwrap_or(u64::MAX), + batch, + }) + } + + /// Stages an insert or overwrite of entity `(agent, id)` into `batch`. + /// No engine mutation occurs, so a caller can compose sibling-index + /// changes before one caller-owned commit. + pub fn stage_upsert_entity(&self, agent: &str, id: &str, entity: &GraphEntity, batch: &mut Batch) -> Result<()> { + let key = graph_index::entity_key(agent, id)?; + batch.put(key.as_bytes(), &entity::encode(entity)?); + Ok(()) + } + + /// Inserts or overwrites the entity `(agent, id)` in one durable batch. + /// Takes a whole /// [`GraphEntity`] rather than its individual fields, both to keep the /// argument count small and because "kind + label + validity" is /// exactly the block this writes — no partial-field update exists. pub fn upsert_entity(&self, engine: &mut Engine, agent: &str, id: &str, entity: GraphEntity) -> Result<()> { - let key = graph_index::entity_key(agent, id)?; - let value = entity::encode(&entity)?; - engine.put(key.as_bytes(), &value) + let mut batch = Batch::new(); + self.stage_upsert_entity(agent, id, &entity, &mut batch)?; + engine.apply_batch(&batch) + } + + /// Stages an insert or overwrite of directed edge + /// `(agent, src) --relation--> dst` into `batch`, without mutating the + /// engine. + pub fn stage_upsert_edge( + &self, + agent: &str, + src: &str, + relation: &str, + dst: &str, + meta: &GraphEdgeMeta, + batch: &mut Batch, + ) -> Result<()> { + let key = graph_index::edge_key(agent, src, relation, dst)?; + batch.put(key.as_bytes(), &edge::encode(meta)); + Ok(()) } /// Inserts or overwrites the directed edge `(agent, src) --relation--> dst`: - /// one durable `Engine::put`. `relation`/`dst` stay separate parameters + /// one durable batch. `relation`/`dst` stay separate parameters /// (they belong in the *key*, see `key::graph_index`), while the edge's /// own attributes travel together as a [`GraphEdgeMeta`]. pub fn upsert_edge( @@ -93,9 +425,9 @@ impl PersistentGraph { dst: &str, meta: GraphEdgeMeta, ) -> Result<()> { - let key = graph_index::edge_key(agent, src, relation, dst)?; - let value = edge::encode(&meta); - engine.put(key.as_bytes(), &value) + let mut batch = Batch::new(); + self.stage_upsert_edge(agent, src, relation, dst, &meta, &mut batch)?; + engine.apply_batch(&batch) } /// Breadth-first traversal — see [`traverse::run`] for the exact, @@ -202,25 +534,39 @@ impl PersistentGraph { Ok(Some(edge::decode(&bytes)?)) } - /// Removes **every** entity and edge of `agent`, in one atomic batch - /// (unlike the memory index's per-item purge, nothing here needs to ride - /// a vector-index mutation — plain KV deletes suffice). Returns the - /// number of records removed. A no-op (empty batch skipped) when the - /// agent has no graph. - pub fn purge_agent(&self, engine: &mut Engine, agent: &str) -> Result { - let mut batch = crate::store::Batch::new(); + /// Stages deletion of every entity and edge of `agent` into `batch` and + /// returns the number of graph records named by the current read view. + /// No mutation occurs until the caller applies the batch. + /// + /// This is the unbounded staging primitive retained for the legacy + /// whole-agent wrapper. A bounded coordinator must not call it for an + /// arbitrary agent: it must plan a capped page and submit one owned + /// purge chunk per logical commit, then resume from a fresh read view. + pub fn stage_purge_agent(&self, source: &R, agent: &str, batch: &mut Batch) -> Result { + let mut removed = 0_u64; for prefix in [ graph_index::entity_agent_prefix(agent)?, graph_index::edge_agent_prefix(agent)?, ] { - for (key, _) in engine.scan_prefix(&prefix)? { + for (key, _) in source.scan_prefix(&prefix)? { batch.delete(key.as_bytes()); + removed += 1; } } + Ok(removed) + } + + /// Removes **every** entity and edge of `agent`, in one atomic batch + /// (unlike the memory index's per-item purge, nothing here needs to ride + /// a vector-index mutation — plain KV deletes suffice). Returns the + /// number of records removed. A no-op (empty batch skipped) when the + /// agent has no graph. + pub fn purge_agent(&self, engine: &mut Engine, agent: &str) -> Result { + let mut batch = Batch::new(); + let removed = self.stage_purge_agent(engine, agent, &mut batch)?; if batch.is_empty() { return Ok(0); } - let removed = batch.len() as u64; engine.apply_batch(&batch)?; Ok(removed) } @@ -264,3 +610,325 @@ impl GraphProvider for EngineGraphProvider<'_, R> { Ok(out) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::idx::graph::GraphSource; + + fn entity(label: &str) -> GraphEntity { + GraphEntity { + kind: "person".to_string(), + label: label.to_string(), + valid_from: 10, + valid_until: None, + source: GraphSource::User, + } + } + + fn edge(weight: f64) -> GraphEdgeMeta { + GraphEdgeMeta { + weight, + valid_from: 10, + valid_until: None, + source: GraphSource::User, + } + } + + #[test] + fn staged_upserts_are_inert_then_commit_together_once() { + let directory = tempfile::tempdir().expect("temporary graph store"); + let mut engine = Engine::open(directory.path()).expect("engine opens"); + let graph = PersistentGraph::new(); + let mut batch = Batch::new(); + + graph + .stage_upsert_entity("agent-a", "alice", &entity("Alice"), &mut batch) + .expect("entity stages"); + graph + .stage_upsert_edge("agent-a", "alice", "knows", "bob", &edge(0.75), &mut batch) + .expect("edge stages"); + + assert!( + graph + .entity(&engine, "agent-a", "alice") + .expect("entity lookup") + .is_none() + ); + assert!( + graph + .edge_meta(&engine, "agent-a", "alice", "knows", "bob") + .expect("edge lookup") + .is_none() + ); + let before = engine.stats().expect("stats before commit").wal_records; + engine.apply_batch(&batch).expect("composed graph batch commits"); + let after = engine.stats().expect("stats after commit").wal_records; + + assert_eq!(after - before, 1, "both graph records must share one WAL commit"); + assert_eq!( + graph.entity(&engine, "agent-a", "alice").expect("entity lookup"), + Some(entity("Alice")) + ); + assert_eq!( + graph + .edge_meta(&engine, "agent-a", "alice", "knows", "bob") + .expect("edge lookup"), + Some(edge(0.75)) + ); + } + + #[test] + fn legacy_upsert_wrappers_keep_one_commit_and_behavior() { + let directory = tempfile::tempdir().expect("temporary graph store"); + let mut engine = Engine::open(directory.path()).expect("engine opens"); + let graph = PersistentGraph::new(); + let before = engine.stats().expect("initial stats").wal_records; + + graph + .upsert_entity(&mut engine, "agent-a", "alice", entity("Alice")) + .expect("legacy entity upsert"); + let after_entity = engine.stats().expect("entity stats").wal_records; + graph + .upsert_edge(&mut engine, "agent-a", "alice", "knows", "bob", edge(0.5)) + .expect("legacy edge upsert"); + let after_edge = engine.stats().expect("edge stats").wal_records; + + assert_eq!(after_entity - before, 1); + assert_eq!(after_edge - after_entity, 1); + assert_eq!( + graph.entity(&engine, "agent-a", "alice").expect("entity lookup"), + Some(entity("Alice")) + ); + assert_eq!( + graph + .edge_meta(&engine, "agent-a", "alice", "knows", "bob") + .expect("edge lookup"), + Some(edge(0.5)) + ); + } + + #[test] + fn staged_purge_is_inert_scoped_and_commits_once() { + let directory = tempfile::tempdir().expect("temporary graph store"); + let mut engine = Engine::open(directory.path()).expect("engine opens"); + let graph = PersistentGraph::new(); + graph + .upsert_entity(&mut engine, "agent-a", "alice", entity("Alice")) + .expect("first entity"); + graph + .upsert_edge(&mut engine, "agent-a", "alice", "knows", "bob", edge(1.0)) + .expect("first edge"); + graph + .upsert_entity(&mut engine, "agent-b", "alice", entity("Other Alice")) + .expect("other agent entity"); + + let mut batch = Batch::new(); + assert_eq!( + graph + .stage_purge_agent(&engine, "agent-a", &mut batch) + .expect("purge stages"), + 2 + ); + assert_eq!(graph.entities(&engine, "agent-a").expect("still visible").len(), 1); + assert_eq!(graph.edges(&engine, "agent-a").expect("still visible").len(), 1); + + let before = engine.stats().expect("stats before purge").wal_records; + engine.apply_batch(&batch).expect("purge batch commits"); + let after = engine.stats().expect("stats after purge").wal_records; + assert_eq!(after - before, 1); + assert!(graph.entities(&engine, "agent-a").expect("purged entities").is_empty()); + assert!(graph.edges(&engine, "agent-a").expect("purged edges").is_empty()); + assert_eq!( + graph.entities(&engine, "agent-b").expect("other agent retained").len(), + 1 + ); + } + + fn seed_purge_fixture(engine: &mut Engine, graph: PersistentGraph) { + for id in ["a", "b", "c"] { + graph + .upsert_entity(engine, "agent-a", id, entity(id)) + .expect("fixture entity"); + } + for dst in ["a", "b", "c"] { + graph + .upsert_edge(engine, "agent-a", "root", "knows", dst, edge(1.0)) + .expect("fixture edge"); + } + graph + .upsert_entity(engine, "agent-b", "retained", entity("retained")) + .expect("other agent fixture"); + } + + #[test] + fn purge_plan_honors_item_and_byte_caps() { + let directory = tempfile::tempdir().expect("temporary graph store"); + let mut engine = Engine::open(directory.path()).expect("engine opens"); + let graph = PersistentGraph::new(); + seed_purge_fixture(&mut engine, graph); + + let item_bounded = graph + .plan_purge_chunk( + &engine, + "agent-a", + None, + GraphPurgeChunkOptions { + max_items: 2, + max_wal_bytes: usize::MAX, + }, + ) + .expect("item-bounded plan"); + assert_eq!(item_bounded.len(), 2); + assert!(item_bounded.resume().is_some()); + + let first_key = graph_index::entity_key("agent-a", "a").expect("fixture key"); + let mut one_delete = Batch::new(); + one_delete.delete(first_key.as_bytes()); + let one_item_bytes = one_delete.approx_wire_bytes(); + let byte_bounded = graph + .plan_purge_chunk( + &engine, + "agent-a", + None, + GraphPurgeChunkOptions { + max_items: 10, + max_wal_bytes: one_item_bytes, + }, + ) + .expect("byte-bounded plan"); + assert_eq!(byte_bounded.len(), 1); + assert!(byte_bounded.approx_wal_bytes() <= one_item_bytes); + + let before = engine.stats().expect("stats before rejection").wal_records; + assert!(matches!( + graph.plan_purge_chunk( + &engine, + "agent-a", + None, + GraphPurgeChunkOptions { + max_items: 10, + max_wal_bytes: one_item_bytes - 1, + }, + ), + Err(GraphPurgePlanError::ItemTooLarge { .. }) + )); + assert_eq!(engine.stats().expect("stats after rejection").wal_records, before); + } + + #[test] + fn purge_staging_is_inert_and_rejects_stale_or_phantom_pre_wal() { + let directory = tempfile::tempdir().expect("temporary graph store"); + let mut engine = Engine::open(directory.path()).expect("engine opens"); + let graph = PersistentGraph::new(); + graph + .upsert_entity(&mut engine, "agent-a", "a", entity("old")) + .expect("fixture entity"); + let stale = graph + .plan_purge_chunk(&engine, "agent-a", None, GraphPurgeChunkOptions::default()) + .expect("stale candidate plan"); + graph + .upsert_entity(&mut engine, "agent-a", "a", entity("new")) + .expect("concurrent update"); + let before_stale = engine.stats().expect("stats before stale stage").wal_records; + assert!(matches!( + graph.stage_purge_chunk(&engine, stale), + Err(GraphPurgePlanError::StaleReadSet) + )); + assert_eq!( + engine.stats().expect("stats after stale stage").wal_records, + before_stale + ); + + let phantom = graph + .plan_purge_chunk(&engine, "agent-a", None, GraphPurgeChunkOptions::default()) + .expect("phantom candidate plan"); + graph + .upsert_entity(&mut engine, "agent-a", "b", entity("late")) + .expect("concurrent insert"); + let before_phantom = engine.stats().expect("stats before phantom stage").wal_records; + assert!(matches!( + graph.stage_purge_chunk(&engine, phantom), + Err(GraphPurgePlanError::PhantomInRange) + )); + assert_eq!( + engine.stats().expect("stats after phantom stage").wal_records, + before_phantom + ); + } + + #[test] + fn one_staged_purge_chunk_is_inert_then_exactly_one_wal() { + let directory = tempfile::tempdir().expect("temporary graph store"); + let mut engine = Engine::open(directory.path()).expect("engine opens"); + let graph = PersistentGraph::new(); + seed_purge_fixture(&mut engine, graph); + let plan = graph + .plan_purge_chunk( + &engine, + "agent-a", + None, + GraphPurgeChunkOptions { + max_items: 2, + max_wal_bytes: usize::MAX, + }, + ) + .expect("chunk plan"); + let staged = graph.stage_purge_chunk(&engine, plan).expect("chunk stages"); + assert_eq!(graph.entities(&engine, "agent-a").expect("still visible").len(), 3); + let before = engine.stats().expect("stats before chunk").wal_records; + let outcome = staged.commit(&mut engine); + let after = engine.stats().expect("stats after chunk").wal_records; + assert!(matches!(outcome, GraphPurgeCommitOutcome::Committed { removed: 2, .. })); + assert_eq!(after - before, 1); + assert_eq!(graph.entities(&engine, "agent-a").expect("remaining entities").len(), 1); + } + + #[test] + fn purge_cursor_resumes_to_legacy_parity_without_cross_agent_deletes() { + let chunked_directory = tempfile::tempdir().expect("chunked temporary store"); + let legacy_directory = tempfile::tempdir().expect("legacy temporary store"); + let mut chunked = Engine::open(chunked_directory.path()).expect("chunked engine opens"); + let mut legacy = Engine::open(legacy_directory.path()).expect("legacy engine opens"); + let graph = PersistentGraph::new(); + seed_purge_fixture(&mut chunked, graph); + seed_purge_fixture(&mut legacy, graph); + + let mut cursor = None; + loop { + let plan = graph + .plan_purge_chunk( + &chunked, + "agent-a", + cursor.as_ref(), + GraphPurgeChunkOptions { + max_items: 2, + max_wal_bytes: usize::MAX, + }, + ) + .expect("resumed plan"); + let next = plan.resume().cloned(); + if !plan.is_empty() { + let staged = graph.stage_purge_chunk(&chunked, plan).expect("resumed stage"); + assert!(matches!( + staged.commit(&mut chunked), + GraphPurgeCommitOutcome::Committed { .. } + )); + } + cursor = next; + if cursor.is_none() { + break; + } + } + + assert_eq!(graph.purge_agent(&mut legacy, "agent-a").expect("legacy purge"), 6); + assert_eq!( + chunked.scan_prefix(b"idx/graph/").expect("chunked graph scan"), + legacy.scan_prefix(b"idx/graph/").expect("legacy graph scan") + ); + assert_eq!( + graph.entities(&chunked, "agent-b").expect("other agent retained").len(), + 1 + ); + } +} diff --git a/crates/basemyai-engine/src/idx/memory/mod.rs b/crates/basemyai-engine/src/idx/memory/mod.rs index 2eda102..74b0950 100644 --- a/crates/basemyai-engine/src/idx/memory/mod.rs +++ b/crates/basemyai-engine/src/idx/memory/mod.rs @@ -18,6 +18,9 @@ pub mod persistent; pub mod record; pub mod vecmap; -pub use persistent::{ForgetBatchOptions, NewMemoryRecord, PersistentMemoryIndex}; +pub use persistent::{ + ForgetBatchOptions, MemoryCommitOutcome, NewMemoryRecord, PersistentMemoryIndex, PurgeChunkCommit, PurgeChunkPlan, + PurgeCursor, StagedForgetChunk, StagedMemoryBatch, StagedMemoryPut, StagedPurgeChunk, StagedPurgeFinalize, +}; pub use record::MemoryRecord; pub use vecmap::VecMapEntry; diff --git a/crates/basemyai-engine/src/idx/memory/persistent.rs b/crates/basemyai-engine/src/idx/memory/persistent.rs index 06db6e9..88c3955 100644 --- a/crates/basemyai-engine/src/idx/memory/persistent.rs +++ b/crates/basemyai-engine/src/idx/memory/persistent.rs @@ -34,9 +34,9 @@ use std::collections::HashSet; use crate::error::{EngineError, Result}; use crate::idx::fts::PersistentFts; -use crate::idx::vector::PersistentVectorIndex; +use crate::idx::vector::{PersistentVectorIndex, StagedVectorDelete, StagedVectorInsert}; use crate::key::{agent_registry, memory_index, temporal_index, vector_index}; -use crate::store::{Batch, Engine, EngineRead}; +use crate::store::{Batch, Engine, EngineCommitOutcome, EngineRead, WalCommitPhase}; use super::meta::{self, MemoryIndexMeta}; use super::record::{self, MemoryRecord}; @@ -76,6 +76,375 @@ pub struct ForgetBatchOptions { pub max_wal_bytes: usize, } +/// Owned preparation of one memory multi-insert. The embedded vector stage +/// owns the exact atomic KV batch; the allocator transition remains private +/// until the same durable callback installs both vector RAM and +/// `next_vec_id`. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedMemoryPut { + vector: StagedVectorInsert, + expected_next_vec_id: u64, + next_vec_id: u64, + allocated: Vec, +} + +/// Owned preparation of one bounded forget chunk. It is deliberately a +/// single vector stage and therefore a single WAL commit. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedForgetChunk { + vector: StagedVectorDelete, + removed: u64, +} + +/// One purge-only chunk carrying the mutation-epoch precondition through the +/// final pre-WAL boundary. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedPurgeChunk { + inner: StagedForgetChunk, + agent: String, + resume_after: Option, + expected_sequence: crate::store::SequenceNumber, +} + +/// Registry deletion prepared from an empty terminal purge plan. It retains +/// the same epoch until commit so a late insertion cannot be hidden by marker +/// removal. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedPurgeFinalize { + batch: Batch, + expected_sequence: crate::store::SequenceNumber, +} + +/// Owned read-set for one bounded, coordinator-sized agent purge commit. +/// Fields stay private so transports cannot forge `(id, vec_id, validity)` +/// tuples; only [`PersistentMemoryIndex::plan_purge_chunk`] constructs it. +#[doc(hidden)] +#[derive(Debug)] +pub struct PurgeChunkPlan { + agent: String, + records: Vec, + resume_after: Option, + exhausted: bool, + estimated_wal_bytes: usize, + expected_sequence: crate::store::SequenceNumber, +} + +/// Opaque, linear continuation of one live purge session. The global engine +/// sequence is deliberately used as a conservative mutation epoch: under the +/// single product owner, only the preceding purge chunk may advance it. Any +/// intervening mutation, including an insertion behind the id cursor, +/// invalidates the continuation before another WAL append. +#[doc(hidden)] +#[derive(Debug)] +pub struct PurgeCursor { + agent: String, + after_id: Option, + expected_sequence: crate::store::SequenceNumber, +} + +/// Successful result of exactly one purge chunk. The continuation is minted +/// only from the durable receipt, so callers cannot advance a purge cursor +/// after an aborted or ambiguous commit. +#[doc(hidden)] +#[derive(Debug)] +pub struct PurgeChunkCommit { + removed: u64, + cursor: PurgeCursor, +} + +impl PurgeChunkCommit { + #[must_use] + pub fn removed(&self) -> u64 { + self.removed + } + + #[must_use] + pub fn into_cursor(self) -> PurgeCursor { + self.cursor + } +} + +#[derive(Debug)] +struct PurgeChunkRecord { + id: String, + vec_id: u64, + valid_until: Option, +} + +impl PurgeChunkPlan { + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + #[must_use] + pub fn resume_after(&self) -> Option<&str> { + self.resume_after.as_deref() + } + + #[must_use] + pub fn exhausted(&self) -> bool { + self.exhausted + } + + #[must_use] + pub fn estimated_wal_bytes(&self) -> usize { + self.estimated_wal_bytes + } +} + +/// Owned record-only mutation (update/touch). No product RAM is published; +/// the type still closes direct access to its batch and commits through the +/// phase-aware engine lifecycle. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedMemoryBatch { + batch: Batch, +} + +/// Phase-preserving result exposed to the future product coordinator. Unlike +/// the legacy `Result`, it never flattens an unknown WAL phase or a durable +/// product-install failure. +#[doc(hidden)] +#[derive(Debug)] +pub enum MemoryCommitOutcome { + Aborted { + burned_sequence_range: Option, + cause: Option, + }, + Committed { + sequence_range: crate::store::SequenceRange, + value: T, + }, + OutcomeUnknown { + phase: WalCommitPhase, + cause: EngineError, + }, + DurableReopenRequired { + sequence_range: crate::store::SequenceRange, + cause: EngineError, + }, +} + +fn legacy_commit_error(phase: WalCommitPhase, cause: EngineError) -> EngineError { + match phase { + WalCommitPhase::Append => EngineError::WalAppendOutcomeUnknown { + cause: cause.to_string(), + }, + WalCommitPhase::Sync => EngineError::WalSyncOutcomeUnknown { + cause: cause.to_string(), + }, + } +} + +impl StagedMemoryPut { + #[doc(hidden)] + pub fn commit( + self, + engine: &mut Engine, + memory: &mut PersistentMemoryIndex, + vectors: &mut PersistentVectorIndex, + ) -> MemoryCommitOutcome> { + let Self { + vector, + expected_next_vec_id, + next_vec_id, + allocated, + } = self; + match vector.commit(engine) { + EngineCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => MemoryCommitOutcome::Aborted { + burned_sequence_range, + cause, + }, + EngineCommitOutcome::Durable(receipt) => { + let sequence_range = receipt.sequence_range(); + match engine.install_committed_batch_with(receipt, |vector_install| { + if memory.next_vec_id != expected_next_vec_id { + return Err(EngineError::InvalidOptions { + reason: "staged memory allocator installation no longer matches RAM state".to_owned(), + }); + } + vectors.install_staged_insert_many(vector_install)?; + memory.next_vec_id = next_vec_id; + Ok(()) + }) { + Ok(()) => MemoryCommitOutcome::Committed { + sequence_range, + value: allocated, + }, + Err(cause) => MemoryCommitOutcome::DurableReopenRequired { sequence_range, cause }, + } + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => { + MemoryCommitOutcome::OutcomeUnknown { phase, cause } + } + } + } +} + +impl StagedForgetChunk { + #[doc(hidden)] + pub fn commit(self, engine: &mut Engine, vectors: &mut PersistentVectorIndex) -> MemoryCommitOutcome { + let Self { vector, removed } = self; + match vector.commit(engine) { + EngineCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => MemoryCommitOutcome::Aborted { + burned_sequence_range, + cause, + }, + EngineCommitOutcome::Durable(receipt) => { + let sequence_range = receipt.sequence_range(); + match engine.install_committed_batch_with(receipt, |vector_install| { + vectors.install_staged_delete_many(vector_install).map(|_| ()) + }) { + Ok(()) => MemoryCommitOutcome::Committed { + sequence_range, + value: removed, + }, + Err(cause) => MemoryCommitOutcome::DurableReopenRequired { sequence_range, cause }, + } + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => { + MemoryCommitOutcome::OutcomeUnknown { phase, cause } + } + } + } +} + +fn stale_purge_epoch(expected: crate::store::SequenceNumber, actual: crate::store::SequenceNumber) -> EngineError { + EngineError::InvalidOptions { + reason: format!("stale purge mutation epoch: expected visible sequence {expected}, found {actual}"), + } +} + +impl StagedPurgeChunk { + #[doc(hidden)] + pub fn commit( + self, + engine: &mut Engine, + vectors: &mut PersistentVectorIndex, + ) -> MemoryCommitOutcome { + let actual = engine.snapshot().visible_sequence(); + if actual != self.expected_sequence { + return MemoryCommitOutcome::Aborted { + burned_sequence_range: None, + cause: Some(stale_purge_epoch(self.expected_sequence, actual)), + }; + } + let Self { + inner, + agent, + resume_after, + expected_sequence: _, + } = self; + match inner.commit(engine, vectors) { + MemoryCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => MemoryCommitOutcome::Aborted { + burned_sequence_range, + cause, + }, + MemoryCommitOutcome::Committed { + sequence_range, + value: removed, + } => MemoryCommitOutcome::Committed { + sequence_range, + value: PurgeChunkCommit { + removed, + cursor: PurgeCursor { + agent, + after_id: resume_after, + expected_sequence: sequence_range.last(), + }, + }, + }, + MemoryCommitOutcome::OutcomeUnknown { phase, cause } => { + MemoryCommitOutcome::OutcomeUnknown { phase, cause } + } + MemoryCommitOutcome::DurableReopenRequired { sequence_range, cause } => { + MemoryCommitOutcome::DurableReopenRequired { sequence_range, cause } + } + } + } +} + +impl StagedPurgeFinalize { + #[doc(hidden)] + pub fn commit(self, engine: &mut Engine) -> MemoryCommitOutcome<()> { + let actual = engine.snapshot().visible_sequence(); + if actual != self.expected_sequence { + return MemoryCommitOutcome::Aborted { + burned_sequence_range: None, + cause: Some(stale_purge_epoch(self.expected_sequence, actual)), + }; + } + StagedMemoryBatch { batch: self.batch }.commit(engine) + } +} + +impl StagedMemoryBatch { + #[doc(hidden)] + pub fn commit(self, engine: &mut Engine) -> MemoryCommitOutcome<()> { + match engine.commit_batch(self.batch) { + EngineCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => MemoryCommitOutcome::Aborted { + burned_sequence_range, + cause, + }, + EngineCommitOutcome::Durable(receipt) => { + let sequence_range = receipt.sequence_range(); + match engine.install_committed_batch_with(receipt, |_| Ok(())) { + Ok(()) => MemoryCommitOutcome::Committed { + sequence_range, + value: (), + }, + Err(cause) => MemoryCommitOutcome::DurableReopenRequired { sequence_range, cause }, + } + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => { + MemoryCommitOutcome::OutcomeUnknown { phase, cause } + } + } + } +} + +fn legacy_memory_outcome(outcome: MemoryCommitOutcome, aborted_value: T) -> Result { + match outcome { + MemoryCommitOutcome::Aborted { cause: None, .. } => Ok(aborted_value), + MemoryCommitOutcome::Aborted { cause: Some(cause), .. } + | MemoryCommitOutcome::DurableReopenRequired { cause, .. } => Err(cause), + MemoryCommitOutcome::Committed { value, .. } => Ok(value), + MemoryCommitOutcome::OutcomeUnknown { phase, cause } => Err(legacy_commit_error(phase, cause)), + } +} + +fn legacy_purge_chunk_outcome(outcome: MemoryCommitOutcome) -> Result> { + match outcome { + MemoryCommitOutcome::Aborted { cause: None, .. } => Ok(None), + MemoryCommitOutcome::Aborted { cause: Some(cause), .. } + | MemoryCommitOutcome::DurableReopenRequired { cause, .. } => Err(cause), + MemoryCommitOutcome::Committed { value, .. } => Ok(Some(value)), + MemoryCommitOutcome::OutcomeUnknown { phase, cause } => Err(legacy_commit_error(phase, cause)), + } +} + impl Default for ForgetBatchOptions { /// Order-of-magnitude defaults (same posture as the engine's block-cache /// default: a sane starting point, not a measured optimum): 256 memories @@ -117,6 +486,15 @@ pub struct PersistentMemoryIndex { } impl PersistentMemoryIndex { + const FORGET_CHUNK_OVERHEAD_BYTES: usize = 256; + + fn reserve_vector_ids(next: u64, requested: usize) -> Result { + let requested_u64 = + u64::try_from(requested).map_err(|_| EngineError::VectorIdSpaceExhausted { next, requested })?; + next.checked_add(requested_u64) + .ok_or(EngineError::VectorIdSpaceExhausted { next, requested }) + } + /// Opens the index stored in `engine`, or initializes an empty one. /// /// The allocator record is loaded when present and valid; **healed from @@ -152,7 +530,10 @@ impl PersistentMemoryIndex { max_seen = Some(max_seen.map_or(id, |m| m.max(id))); } } - Ok(max_seen.map_or(0, |m| m + 1)) + match max_seen { + Some(max) => Self::reserve_vector_ids(max, 1), + None => Ok(0), + } } /// The next id [`Self::put`] will allocate. Exposed for tests and @@ -215,8 +596,28 @@ impl PersistentMemoryIndex { agent: &str, items: &[(&str, NewMemoryRecord<'_>, Vec)], ) -> Result> { + let staged = self.stage_put_many(engine, vectors, fts, agent, items)?; + legacy_memory_outcome(staged.commit(engine, self, vectors), Vec::new()) + } + + /// Prepares one owned atomic memory/vector/FTS insert without publishing + /// vector RAM or advancing the in-RAM allocator. + #[doc(hidden)] + pub fn stage_put_many( + &self, + engine: &Engine, + vectors: &PersistentVectorIndex, + fts: &PersistentFts, + agent: &str, + items: &[(&str, NewMemoryRecord<'_>, Vec)], + ) -> Result { if items.is_empty() { - return Ok(Vec::new()); + return Ok(StagedMemoryPut { + vector: vectors.stage_insert_many_with(engine, Vec::new(), &Batch::new())?, + expected_next_vec_id: self.next_vec_id, + next_vec_id: self.next_vec_id, + allocated: Vec::new(), + }); } for (i, (id, _, _)) in items.iter().enumerate() { let record_key = memory_index::record_key(agent, id)?; @@ -229,11 +630,11 @@ impl PersistentMemoryIndex { } let first_vec_id = self.next_vec_id; + let next_vec_id = Self::reserve_vector_ids(first_vec_id, items.len())?; let mut extra = Batch::new(); let mut fts_docs: Vec<(u64, &str)> = Vec::with_capacity(items.len()); let mut vector_items: Vec<(u64, Vec)> = Vec::with_capacity(items.len()); - for (offset, (id, new, vector)) in items.iter().enumerate() { - let vec_id = first_vec_id + offset as u64; + for (vec_id, (id, new, vector)) in (first_vec_id..next_vec_id).zip(items.iter()) { let stored = MemoryRecord { layer: new.layer.to_string(), content: new.content.to_string(), @@ -257,17 +658,20 @@ impl PersistentMemoryIndex { fts_docs.push((vec_id, new.content)); vector_items.push((vec_id, vector.clone())); } - let next_vec_id = first_vec_id + items.len() as u64; extra.put(memory_index::META_KEY, &meta::encode(&MemoryIndexMeta { next_vec_id })?); // Agent-registry marker (ADR-041 §7.5): staged with every insert // batch — an idempotent overwrite of an empty value, so re-inserts // cost one no-op-shaped op rather than a read-before-write. extra.put(agent_registry::agent_key(agent)?.as_bytes(), &[]); fts.stage_insert_many(engine, agent, &fts_docs, &mut extra)?; - vectors.insert_many_with(engine, vector_items, &extra)?; - - self.next_vec_id = next_vec_id; - Ok((first_vec_id..next_vec_id).collect()) + let vector = vectors.stage_insert_many_with(engine, vector_items, &extra)?; + + Ok(StagedMemoryPut { + vector, + expected_next_vec_id: self.next_vec_id, + next_vec_id, + allocated: (first_vec_id..next_vec_id).collect(), + }) } /// The memory record `(agent, id)`, if any — regardless of its validity @@ -295,6 +699,18 @@ impl PersistentMemoryIndex { /// expiry). All of it — the record overwrite and the expiry delta — /// rides **one** atomic batch, never two separate writes. pub fn update(&self, engine: &mut Engine, agent: &str, id: &str, updated: &MemoryRecord) -> Result<()> { + legacy_memory_outcome(self.stage_update(engine, agent, id, updated)?.commit(engine), ()) + } + + /// Stages one record update and its temporal-index delta. + #[doc(hidden)] + pub fn stage_update( + &self, + engine: &Engine, + agent: &str, + id: &str, + updated: &MemoryRecord, + ) -> Result { let key = memory_index::record_key(agent, id)?; let mut batch = Batch::new(); batch.put(key.as_bytes(), &record::encode(updated)?); @@ -303,7 +719,7 @@ impl PersistentMemoryIndex { stage_expiry_delete(&mut batch, agent, id, previous_valid_until)?; stage_expiry_insert(&mut batch, agent, id, updated.valid_until)?; } - engine.apply_batch(&batch) + Ok(StagedMemoryBatch { batch }) } /// Rewrites `last_access = now` on every existing `(agent, id)` of `ids` @@ -316,6 +732,21 @@ impl PersistentMemoryIndex { ids: impl IntoIterator, now: i64, ) -> Result<()> { + legacy_memory_outcome( + self.stage_touch_last_access(engine, agent, ids, now)?.commit(engine), + (), + ) + } + + /// Stages one atomic touch batch; absent ids remain no-ops. + #[doc(hidden)] + pub fn stage_touch_last_access<'a>( + &self, + engine: &Engine, + agent: &str, + ids: impl IntoIterator, + now: i64, + ) -> Result { let mut batch = Batch::new(); for id in ids { if let Some(mut stored) = self.get(engine, agent, id)? { @@ -324,10 +755,7 @@ impl PersistentMemoryIndex { batch.put(key.as_bytes(), &record::encode(&stored)?); } } - if batch.is_empty() { - return Ok(()); - } - engine.apply_batch(&batch) + Ok(StagedMemoryBatch { batch }) } /// Physically forgets the memory `(agent, id)`: record, reverse mapping @@ -349,14 +777,8 @@ impl PersistentMemoryIndex { let Some(stored) = self.get(engine, agent, id)? else { return Ok(false); }; - let record_key = memory_index::record_key(agent, id)?; - let mut extra = Batch::new(); - extra.delete(record_key.as_bytes()); - extra.delete(memory_index::vecmap_key(stored.vec_id).as_bytes()); - stage_expiry_delete(&mut extra, agent, id, stored.valid_until)?; - fts.stage_delete(engine, agent, stored.vec_id, &mut extra)?; - vectors.delete_with(engine, stored.vec_id, &extra)?; - Ok(true) + let staged = Self::stage_forget_chunk(engine, vectors, fts, agent, &[(id, stored.vec_id, stored.valid_until)])?; + legacy_memory_outcome(staged.commit(engine, vectors), 0).map(|_| true) } /// Physically forgets **several** memories of one `agent`, in bounded @@ -389,14 +811,12 @@ impl PersistentMemoryIndex { let max_items = options.max_items.max(1); // Fixed per-chunk overhead: the vector META rewrite + the FTS stats // record the chunk stages once regardless of its item count. - const CHUNK_OVERHEAD_BYTES: usize = 256; - let mut removed = 0u64; let mut seen: HashSet<&str> = HashSet::with_capacity(ids.len()); // The pending chunk: (id, vec_id, valid_until) of each memory, plus // its running byte estimate. let mut chunk: Vec<(&str, u64, Option)> = Vec::new(); - let mut chunk_bytes = CHUNK_OVERHEAD_BYTES; + let mut chunk_bytes = Self::FORGET_CHUNK_OVERHEAD_BYTES; for &id in ids { if !seen.insert(id) { @@ -419,7 +839,7 @@ impl PersistentMemoryIndex { if !chunk.is_empty() && (chunk.len() >= max_items || chunk_bytes + item_bytes > options.max_wal_bytes) { removed += Self::apply_forget_chunk(engine, vectors, *fts, agent, &chunk)?; chunk.clear(); - chunk_bytes = CHUNK_OVERHEAD_BYTES; + chunk_bytes = Self::FORGET_CHUNK_OVERHEAD_BYTES; } chunk.push((id, stored.vec_id, stored.valid_until)); chunk_bytes += item_bytes; @@ -440,6 +860,26 @@ impl PersistentMemoryIndex { agent: &str, chunk: &[(&str, u64, Option)], ) -> Result { + let staged = Self::stage_forget_chunk(engine, vectors, &fts, agent, chunk)?; + legacy_memory_outcome(staged.commit(engine, vectors), 0) + } + + /// Prepares exactly one bounded forget chunk. It never loops or commits; + /// the future coordinator can admit each returned chunk independently. + /// + /// This workspace-internal surface consumes the trusted planner's owned + /// read-set `(id, vec_id, valid_until)`. Transports must never construct + /// these triples directly: the live handler first resolves records under + /// its product read gate, then submits this one bounded chunk for + /// revalidation/commit. + #[doc(hidden)] + pub fn stage_forget_chunk( + engine: &Engine, + vectors: &PersistentVectorIndex, + fts: &PersistentFts, + agent: &str, + chunk: &[(&str, u64, Option)], + ) -> Result { let mut extra = Batch::new(); let mut vec_ids: Vec = Vec::with_capacity(chunk.len()); for &(id, vec_id, valid_until) in chunk { @@ -449,8 +889,145 @@ impl PersistentMemoryIndex { vec_ids.push(vec_id); } fts.stage_delete_many(engine, agent, &vec_ids, &mut extra)?; - vectors.delete_many_with(engine, &vec_ids, &extra)?; - Ok(chunk.len() as u64) + let vector = vectors.stage_delete_many_with(engine, &vec_ids, &extra)?; + Ok(StagedForgetChunk { + vector, + removed: chunk.len() as u64, + }) + } + + /// Plans at most one bounded purge chunk from a stable id cursor. This is + /// read-only: it neither stages a batch nor commits. A single item larger + /// than the byte target is admitted alone so every cursor can progress. + #[doc(hidden)] + pub fn plan_purge_chunk( + &self, + engine: &Engine, + vectors: &PersistentVectorIndex, + fts: &PersistentFts, + agent: &str, + cursor: Option, + options: ForgetBatchOptions, + ) -> Result { + let expected_sequence = engine.snapshot().visible_sequence(); + let after_id = match cursor { + Some(cursor) => { + if cursor.agent != agent { + return Err(EngineError::InvalidOptions { + reason: "purge cursor belongs to a different agent".to_owned(), + }); + } + if cursor.expected_sequence != expected_sequence { + return Err(stale_purge_epoch(cursor.expected_sequence, expected_sequence)); + } + cursor.after_id + } + None => None, + }; + let max_items = options.max_items.max(1); + let page = self.scan_agent_page(engine, agent, after_id.as_deref(), max_items)?; + let page_exhausted = page.len() < max_items; + let mut estimated_wal_bytes = Self::FORGET_CHUNK_OVERHEAD_BYTES; + let mut records = Vec::with_capacity(page.len()); + let mut stopped_for_bytes = false; + for (id, stored) in page { + let record_key = memory_index::record_key(agent, &id)?; + let mut item_bytes = record_key.as_bytes().len() + + memory_index::vecmap_key(stored.vec_id).as_bytes().len() + + vectors.approx_tombstone_wire_bytes() + + fts.delete_footprint(engine, agent, stored.vec_id)?; + if let Some(until) = stored.valid_until { + item_bytes = item_bytes.saturating_add(temporal_index::expiry_key(agent, until, &id)?.as_bytes().len()); + } + if !records.is_empty() && estimated_wal_bytes.saturating_add(item_bytes) > options.max_wal_bytes { + stopped_for_bytes = true; + break; + } + estimated_wal_bytes = estimated_wal_bytes.saturating_add(item_bytes); + records.push(PurgeChunkRecord { + id, + vec_id: stored.vec_id, + valid_until: stored.valid_until, + }); + } + let resume_after = records.last().map(|record| record.id.clone()).or(after_id); + Ok(PurgeChunkPlan { + agent: agent.to_owned(), + records, + resume_after, + exhausted: page_exhausted && !stopped_for_bytes, + estimated_wal_bytes, + expected_sequence, + }) + } + + /// Revalidates and stages exactly one owned purge plan. No loop or commit + /// is hidden here; a stale read-set aborts before any WAL mutation. + #[doc(hidden)] + pub fn stage_purge_chunk( + &self, + engine: &Engine, + vectors: &PersistentVectorIndex, + fts: &PersistentFts, + plan: PurgeChunkPlan, + ) -> Result { + if plan.records.is_empty() { + return Err(EngineError::InvalidOptions { + reason: "an empty purge plan may only finalize the registry".to_owned(), + }); + } + let actual_sequence = engine.snapshot().visible_sequence(); + if actual_sequence != plan.expected_sequence { + return Err(stale_purge_epoch(plan.expected_sequence, actual_sequence)); + } + for expected in &plan.records { + match self.get(engine, &plan.agent, &expected.id)? { + Some(actual) if actual.vec_id == expected.vec_id && actual.valid_until == expected.valid_until => {} + _ => { + return Err(EngineError::InvalidOptions { + reason: format!("stale purge read-set for memory {:?}", expected.id), + }); + } + } + } + let chunk = plan + .records + .iter() + .map(|record| (record.id.as_str(), record.vec_id, record.valid_until)) + .collect::>(); + Ok(StagedPurgeChunk { + inner: Self::stage_forget_chunk(engine, vectors, fts, &plan.agent, &chunk)?, + agent: plan.agent, + resume_after: plan.resume_after, + expected_sequence: plan.expected_sequence, + }) + } + + /// Stages the final registry-marker deletion only from an empty, + /// exhausted plan whose opaque mutation epoch still matches. The plan is + /// consumed, so a non-empty page can never double as finalization proof. + #[doc(hidden)] + pub fn stage_finalize_purge(&self, engine: &Engine, plan: PurgeChunkPlan) -> Result { + if !plan.records.is_empty() || !plan.exhausted { + return Err(EngineError::InvalidOptions { + reason: "purge finalization requires an empty exhausted plan".to_owned(), + }); + } + let actual_sequence = engine.snapshot().visible_sequence(); + if actual_sequence != plan.expected_sequence { + return Err(stale_purge_epoch(plan.expected_sequence, actual_sequence)); + } + if !self.scan_agent_page(engine, &plan.agent, None, 1)?.is_empty() { + return Err(EngineError::InvalidOptions { + reason: format!("cannot finalize purge for non-empty agent {:?}", plan.agent), + }); + } + let mut batch = Batch::new(); + batch.delete(agent_registry::agent_key(&plan.agent)?.as_bytes()); + Ok(StagedPurgeFinalize { + batch, + expected_sequence: plan.expected_sequence, + }) } /// Every agent id present in the registry (ADR-041 §7.5), in ascending @@ -625,22 +1202,23 @@ impl PersistentMemoryIndex { agent: &str, ) -> Result { let mut purged = 0u64; - for (id, stored) in self.scan_agent(engine, agent)? { - let record_key = memory_index::record_key(agent, &id)?; - let mut extra = Batch::new(); - extra.delete(record_key.as_bytes()); - extra.delete(memory_index::vecmap_key(stored.vec_id).as_bytes()); - stage_expiry_delete(&mut extra, agent, &id, stored.valid_until)?; - fts.stage_delete(engine, agent, stored.vec_id, &mut extra)?; - vectors.delete_with(engine, stored.vec_id, &extra)?; - purged += 1; + let mut cursor = None; + loop { + let plan = self.plan_purge_chunk(engine, vectors, fts, agent, cursor, ForgetBatchOptions::default())?; + if plan.is_empty() { + // Registry entry last (ADR-041 §7.5). The empty plan and its + // epoch remain the finalization proof all the way to WAL. + legacy_memory_outcome(self.stage_finalize_purge(engine, plan)?.commit(engine), ())?; + return Ok(purged); + } + let staged = self.stage_purge_chunk(engine, vectors, fts, plan)?; + if let Some(committed) = legacy_purge_chunk_outcome(staged.commit(engine, vectors))? { + purged = purged.saturating_add(committed.removed()); + cursor = Some(committed.into_cursor()); + } else { + cursor = None; + } } - // Registry entry last (ADR-041 §7.5): a crash anywhere above leaves - // the entry in place, and re-running the purge (the documented - // recovery, ADR-027 §6) removes it — never the reverse order, which - // could leave un-purged memories invisible to registry consumers. - engine.delete(agent_registry::agent_key(agent)?.as_bytes())?; - Ok(purged) } } @@ -678,6 +1256,206 @@ mod tests { (engine, vectors, memory, PersistentFts::new()) } + #[test] + fn staged_put_does_not_publish_allocator_or_vector_ram_before_commit_callback() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + let record = new_record("staged", "episodic"); + let items = [("m1", record, vec_for(1))]; + + let staged = memory + .stage_put_many(&engine, &vectors, &fts, "agent-a", &items) + .expect("stage put"); + assert_eq!(memory.next_vec_id(), 0); + assert_eq!(vectors.len(), 0); + assert!( + memory + .get(&engine, "agent-a", "m1") + .expect("read before commit") + .is_none() + ); + + let outcome = staged.commit(&mut engine, &mut memory, &mut vectors); + let allocated = match outcome { + MemoryCommitOutcome::Committed { sequence_range, value } => { + assert_eq!(sequence_range.first(), 1); + assert_eq!(sequence_range.last(), engine.snapshot().visible_sequence()); + value + } + other => panic!("expected committed memory put, got {other:?}"), + }; + assert_eq!(allocated, vec![0]); + assert_eq!(memory.next_vec_id(), 1); + assert_eq!(vectors.len(), 1); + assert!( + memory + .get(&engine, "agent-a", "m1") + .expect("read after commit") + .is_some() + ); + } + + #[test] + fn vector_id_exhaustion_is_rejected_before_wal_or_ram_mutation() { + let dir = tempfile::tempdir().expect("tempdir"); + let (engine, vectors, mut memory, fts) = open_all(dir.path()); + memory.next_vec_id = u64::MAX; + let record = new_record("exhausted", "episodic"); + let items = [("m-max", record, vec_for(1))]; + let before = engine.stats().expect("stats before exhausted staging"); + + let error = memory + .stage_put_many(&engine, &vectors, &fts, "agent-a", &items) + .expect_err("an allocator at u64::MAX cannot persist a successor cursor"); + assert!(matches!( + error, + EngineError::VectorIdSpaceExhausted { + next: u64::MAX, + requested: 1 + } + )); + let after = engine.stats().expect("stats after exhausted staging"); + assert_eq!(after.wal_records, before.wal_records); + assert_eq!(after.wal_bytes, before.wal_bytes); + assert_eq!(memory.next_vec_id(), u64::MAX); + assert_eq!(vectors.len(), 0); + assert!( + memory + .get(&engine, "agent-a", "m-max") + .expect("rejected record lookup") + .is_none() + ); + } + + #[test] + fn vector_id_max_minus_one_commits_once_then_exhausts_without_retry_append() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + memory.next_vec_id = u64::MAX - 1; + let first = [("m-last", new_record("last", "episodic"), vec_for(1))]; + let allocated = memory + .put_many(&mut engine, &mut vectors, &fts, "agent-a", &first) + .expect("the final representable allocation with a successor commits"); + assert_eq!(allocated, vec![u64::MAX - 1]); + assert_eq!(memory.next_vec_id(), u64::MAX); + assert_eq!(vectors.len(), 1); + + let before = engine.stats().expect("stats before exhausted retry"); + let second = [("m-overflow", new_record("overflow", "episodic"), vec_for(2))]; + let error = memory + .stage_put_many(&engine, &vectors, &fts, "agent-a", &second) + .expect_err("the next allocation is exhausted"); + assert!(matches!( + error, + EngineError::VectorIdSpaceExhausted { next: u64::MAX, .. } + )); + let after = engine.stats().expect("stats after exhausted retry"); + assert_eq!(after.wal_records, before.wal_records); + assert_eq!(after.wal_bytes, before.wal_bytes); + assert_eq!(memory.next_vec_id(), u64::MAX); + assert_eq!(vectors.len(), 1); + } + + #[test] + fn allocator_healing_refuses_a_maximum_observed_vector_id() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open(dir.path()).expect("open engine"); + engine + .put(vector_index::node_key(u64::MAX).as_bytes(), b"opaque") + .expect("seed maximum observed vector id"); + + let error = + PersistentMemoryIndex::open(&engine).expect_err("healing cannot wrap one past the maximum observed id"); + assert!(matches!( + error, + EngineError::VectorIdSpaceExhausted { + next: u64::MAX, + requested: 1 + } + )); + } + + #[test] + fn staged_put_callback_error_leaves_engine_terminal() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + let record = new_record("stale", "episodic"); + let items = [("m1", record, vec_for(1))]; + let staged = memory + .stage_put_many(&engine, &vectors, &fts, "agent-a", &items) + .expect("stage put"); + memory.next_vec_id = 7; + + let error = legacy_memory_outcome(staged.commit(&mut engine, &mut memory, &mut vectors), Vec::new()) + .expect_err("stale allocator must reject install"); + assert!(matches!(error, EngineError::InvalidOptions { .. })); + assert!( + matches!( + engine.put(b"after-terminal", b"blocked"), + Err(EngineError::WriterReconcileRequired { .. }) + ), + "failed product callback must inherit the engine's terminal obligation" + ); + } + + #[test] + fn explicit_forget_chunk_is_one_wal_record_and_matches_legacy_state() { + let legacy_dir = tempfile::tempdir().expect("legacy tempdir"); + let staged_dir = tempfile::tempdir().expect("staged tempdir"); + let (mut legacy_engine, mut legacy_vectors, mut legacy_memory, legacy_fts) = open_all(legacy_dir.path()); + let (mut staged_engine, mut staged_vectors, mut staged_memory, staged_fts) = open_all(staged_dir.path()); + for (engine, vectors, memory, fts) in [ + (&mut legacy_engine, &mut legacy_vectors, &mut legacy_memory, &legacy_fts), + (&mut staged_engine, &mut staged_vectors, &mut staged_memory, &staged_fts), + ] { + let items = [ + ("m1", new_record("one", "episodic"), vec_for(1)), + ("m2", new_record("two", "episodic"), vec_for(2)), + ]; + memory + .put_many(engine, vectors, fts, "agent-a", &items) + .expect("seed memories"); + } + + let legacy_before = legacy_engine.stats().expect("legacy stats").wal_records; + let removed = legacy_memory + .forget_many( + &mut legacy_engine, + &mut legacy_vectors, + &legacy_fts, + "agent-a", + &["m1", "m2"], + ForgetBatchOptions { + max_items: 2, + max_wal_bytes: usize::MAX, + }, + ) + .expect("legacy forget chunk"); + assert_eq!(removed, 2); + assert_eq!( + legacy_engine.stats().expect("legacy stats").wal_records - legacy_before, + 1 + ); + + let chunk = [("m1", 0, None), ("m2", 1, None)]; + let staged_before = staged_engine.stats().expect("staged stats").wal_records; + let staged = + PersistentMemoryIndex::stage_forget_chunk(&staged_engine, &staged_vectors, &staged_fts, "agent-a", &chunk) + .expect("stage forget chunk"); + assert_eq!( + legacy_memory_outcome(staged.commit(&mut staged_engine, &mut staged_vectors), 0).expect("commit chunk"), + 2 + ); + assert_eq!( + staged_engine.stats().expect("staged stats").wal_records - staged_before, + 1 + ); + assert_eq!( + legacy_engine.scan_prefix(b"").expect("legacy scan"), + staged_engine.scan_prefix(b"").expect("staged scan") + ); + } + #[test] fn put_get_search_resolve_roundtrip() { let dir = tempfile::tempdir().expect("tempdir"); @@ -981,6 +1759,393 @@ mod tests { assert_eq!(vectors.search_scored(&engine, &vec_for(3), 1).expect("search").len(), 1); } + #[test] + fn purge_planner_commits_one_bounded_chunk_per_wal_record() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + for (id, seed) in [("m1", 1), ("m2", 2), ("m3", 3)] { + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + id, + &new_record(id, "episodic"), + vec_for(seed), + ) + .expect("seed memory"); + } + + let plan = memory + .plan_purge_chunk( + &engine, + &vectors, + &fts, + "agent-a", + None, + ForgetBatchOptions { + max_items: 2, + max_wal_bytes: usize::MAX, + }, + ) + .expect("plan first bounded chunk"); + assert_eq!(plan.len(), 2); + assert_eq!(plan.resume_after(), Some("m2")); + assert!(!plan.exhausted(), "a full page conservatively requires another plan"); + assert_eq!( + memory.scan_agent(&engine, "agent-a").expect("staging is inert").len(), + 3 + ); + + let before = engine.stats().expect("stats before chunk").wal_records; + let staged = memory + .stage_purge_chunk(&engine, &vectors, &fts, plan) + .expect("stage exact purge read-set"); + let committed = legacy_purge_chunk_outcome(staged.commit(&mut engine, &mut vectors)) + .expect("commit chunk") + .expect("chunk is committed"); + assert_eq!(committed.removed(), 2); + assert_eq!(engine.stats().expect("stats after chunk").wal_records - before, 1); + assert_eq!(memory.scan_agent(&engine, "agent-a").expect("one remains").len(), 1); + + let tiny = memory + .plan_purge_chunk( + &engine, + &vectors, + &fts, + "agent-a", + None, + ForgetBatchOptions { + max_items: 256, + max_wal_bytes: 0, + }, + ) + .expect("oversized item progresses alone"); + assert_eq!( + tiny.len(), + 1, + "byte target never creates an unbounded batch or stalls progress" + ); + } + + #[test] + fn purge_registry_marker_is_stageable_only_after_agent_is_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "m1", + &new_record("one", "episodic"), + vec_for(1), + ) + .expect("seed memory"); + let non_empty = memory + .plan_purge_chunk(&engine, &vectors, &fts, "agent-a", None, ForgetBatchOptions::default()) + .expect("plan non-empty agent"); + assert!(matches!( + memory.stage_finalize_purge(&engine, non_empty), + Err(EngineError::InvalidOptions { .. }) + )); + assert_eq!(memory.list_agents(&engine).expect("marker retained"), vec!["agent-a"]); + + let plan = memory + .plan_purge_chunk(&engine, &vectors, &fts, "agent-a", None, ForgetBatchOptions::default()) + .expect("plan only chunk"); + let staged = memory + .stage_purge_chunk(&engine, &vectors, &fts, plan) + .expect("stage only chunk"); + let committed = legacy_purge_chunk_outcome(staged.commit(&mut engine, &mut vectors)) + .expect("commit only chunk") + .expect("chunk committed"); + assert!(memory.scan_agent(&engine, "agent-a").expect("empty").is_empty()); + assert_eq!( + memory.list_agents(&engine).expect("marker still present"), + vec!["agent-a"] + ); + + let empty = memory + .plan_purge_chunk( + &engine, + &vectors, + &fts, + "agent-a", + Some(committed.into_cursor()), + ForgetBatchOptions::default(), + ) + .expect("plan terminal empty page"); + let final_stage = memory + .stage_finalize_purge(&engine, empty) + .expect("empty agent may finalize marker"); + assert_eq!( + memory.list_agents(&engine).expect("final staging inert"), + vec!["agent-a"] + ); + let before = engine.stats().expect("stats before marker commit").wal_records; + legacy_memory_outcome(final_stage.commit(&mut engine), ()).expect("commit marker deletion"); + assert_eq!( + engine.stats().expect("stats after marker commit").wal_records - before, + 1 + ); + assert!(memory.list_agents(&engine).expect("marker removed").is_empty()); + } + + #[test] + fn purge_chunk_revalidates_its_exact_read_set_before_wal() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "m1", + &record_until("one", "episodic", Some(10)), + vec_for(1), + ) + .expect("seed memory"); + let plan = memory + .plan_purge_chunk(&engine, &vectors, &fts, "agent-a", None, ForgetBatchOptions::default()) + .expect("plan purge"); + + let mut changed = memory.get(&engine, "agent-a", "m1").expect("read").expect("present"); + changed.valid_until = Some(20); + memory + .update(&mut engine, "agent-a", "m1", &changed) + .expect("concurrent logical change"); + let before = engine.stats().expect("stats before stale stage").wal_records; + assert!(matches!( + memory.stage_purge_chunk(&engine, &vectors, &fts, plan), + Err(EngineError::InvalidOptions { .. }) + )); + assert_eq!(engine.stats().expect("stats after stale stage").wal_records, before); + assert!(memory.get(&engine, "agent-a", "m1").expect("retained").is_some()); + } + + #[test] + fn purge_cursor_rejects_an_insertion_behind_the_committed_scan() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + for (id, seed) in [("m2", 2), ("m3", 3)] { + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + id, + &new_record(id, "episodic"), + vec_for(seed), + ) + .expect("seed memory"); + } + let first = memory + .plan_purge_chunk( + &engine, + &vectors, + &fts, + "agent-a", + None, + ForgetBatchOptions { + max_items: 1, + max_wal_bytes: usize::MAX, + }, + ) + .expect("plan first chunk"); + let committed = legacy_purge_chunk_outcome( + memory + .stage_purge_chunk(&engine, &vectors, &fts, first) + .expect("stage first chunk") + .commit(&mut engine, &mut vectors), + ) + .expect("commit first chunk") + .expect("first chunk committed"); + + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "m1", + &new_record("late behind cursor", "episodic"), + vec_for(1), + ) + .expect("concurrent insertion"); + let before = engine.stats().expect("stats before rejected resume").wal_records; + assert!(matches!( + memory.plan_purge_chunk( + &engine, + &vectors, + &fts, + "agent-a", + Some(committed.into_cursor()), + ForgetBatchOptions::default(), + ), + Err(EngineError::InvalidOptions { .. }) + )); + assert_eq!(engine.stats().expect("stats after rejected resume").wal_records, before); + assert!( + memory + .get(&engine, "agent-a", "m1") + .expect("late member retained") + .is_some() + ); + } + + #[test] + fn purge_epoch_is_rechecked_after_staging_and_before_wal() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "m2", + &new_record("planned", "episodic"), + vec_for(2), + ) + .expect("seed memory"); + let plan = memory + .plan_purge_chunk(&engine, &vectors, &fts, "agent-a", None, ForgetBatchOptions::default()) + .expect("plan purge"); + let staged = memory + .stage_purge_chunk(&engine, &vectors, &fts, plan) + .expect("stage purge"); + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "m1", + &new_record("phantom", "episodic"), + vec_for(1), + ) + .expect("insert after staging"); + let before = engine.stats().expect("stats before stale commit").wal_records; + assert!(matches!( + staged.commit(&mut engine, &mut vectors), + MemoryCommitOutcome::Aborted { + cause: Some(EngineError::InvalidOptions { .. }), + .. + } + )); + assert_eq!(engine.stats().expect("stats after stale commit").wal_records, before); + assert_eq!(memory.scan_agent(&engine, "agent-a").expect("both retained").len(), 2); + } + + #[test] + fn purge_finalization_epoch_rejects_a_late_insert_without_hiding_registry() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "m1", + &new_record("first", "episodic"), + vec_for(1), + ) + .expect("seed marker"); + memory + .forget(&mut engine, &mut vectors, &fts, "agent-a", "m1") + .expect("make agent empty while retaining marker"); + let empty = memory + .plan_purge_chunk(&engine, &vectors, &fts, "agent-a", None, ForgetBatchOptions::default()) + .expect("empty terminal plan"); + let staged = memory + .stage_finalize_purge(&engine, empty) + .expect("stage marker deletion"); + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + "late", + &new_record("late", "episodic"), + vec_for(2), + ) + .expect("late insert"); + let before = engine.stats().expect("stats before stale finalize").wal_records; + assert!(matches!( + staged.commit(&mut engine), + MemoryCommitOutcome::Aborted { + cause: Some(EngineError::InvalidOptions { .. }), + .. + } + )); + assert_eq!(engine.stats().expect("stats after stale finalize").wal_records, before); + assert_eq!(memory.list_agents(&engine).expect("registry retained"), vec!["agent-a"]); + } + + #[test] + fn purge_restarts_from_a_fresh_plan_after_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + { + let (mut engine, mut vectors, mut memory, fts) = open_all(dir.path()); + for (id, seed) in [("m1", 1), ("m2", 2), ("m3", 3)] { + memory + .put( + &mut engine, + &mut vectors, + &fts, + "agent-a", + id, + &new_record(id, "episodic"), + vec_for(seed), + ) + .expect("seed memory"); + } + let first = memory + .plan_purge_chunk( + &engine, + &vectors, + &fts, + "agent-a", + None, + ForgetBatchOptions { + max_items: 1, + max_wal_bytes: usize::MAX, + }, + ) + .expect("plan one chunk"); + assert!(matches!( + memory + .stage_purge_chunk(&engine, &vectors, &fts, first) + .expect("stage one chunk") + .commit(&mut engine, &mut vectors), + MemoryCommitOutcome::Committed { .. } + )); + engine.close().expect("close interrupted purge"); + } + + let (mut engine, mut vectors, memory, fts) = open_all(dir.path()); + assert_eq!( + memory + .purge_agent(&mut engine, &mut vectors, &fts, "agent-a") + .expect("fresh purge resumes"), + 2 + ); + assert!( + memory + .scan_agent(&engine, "agent-a") + .expect("purged after reopen") + .is_empty() + ); + assert!(memory.list_agents(&engine).expect("registry finalized").is_empty()); + } + #[test] fn allocator_is_monotonic_across_reopen_and_forgets() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/basemyai-engine/src/idx/vector/mod.rs b/crates/basemyai-engine/src/idx/vector/mod.rs index 50d07a1..79f8069 100644 --- a/crates/basemyai-engine/src/idx/vector/mod.rs +++ b/crates/basemyai-engine/src/idx/vector/mod.rs @@ -39,5 +39,8 @@ pub mod trace; pub use graph::VectorIndex; pub use meta::{EncodingMigrationState, VectorIndexMeta, VectorIndexParams, VectorMetric}; pub use node::{NodeEncoding, VectorNode}; -pub use persistent::{PersistentVectorIndex, VectorCacheStats}; +pub use persistent::{ + PersistentVectorIndex, StagedVectorDelete, StagedVectorInsert, VectorCacheStats, VectorDeleteInstallToken, + VectorInsertInstallToken, +}; pub use trace::VectorInsertTrace; diff --git a/crates/basemyai-engine/src/idx/vector/persistent.rs b/crates/basemyai-engine/src/idx/vector/persistent.rs index b400346..2c3aa44 100644 --- a/crates/basemyai-engine/src/idx/vector/persistent.rs +++ b/crates/basemyai-engine/src/idx/vector/persistent.rs @@ -89,7 +89,7 @@ use super::node::{self, NodeEncoding, VectorNode}; use super::trace::VectorInsertTrace; use crate::error::{EngineError, Result}; use crate::key::vector_index::{META_KEY, NODE_PREFIX, node_id, node_key}; -use crate::store::{Batch, Engine, ReadSnapshot}; +use crate::store::{Batch, Engine, EngineCommitOutcome, ReadSnapshot, WalCommitPhase}; /// Default byte budget for the decoded-node read-through cache (ADR-060 /// groundwork). Replaces the old fixed-entry-count `CACHE_CAP: usize = 4096` @@ -420,6 +420,70 @@ struct ReadSnapshotProvider<'a> { snapshot: &'a ReadSnapshot, } +/// Prepared vector insertion that has not crossed the engine durability +/// boundary. Deliberately non-clonable and non-constructible by callers. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedVectorInsert { + batch: Batch, + install: VectorInsertInstallToken, +} + +impl StagedVectorInsert { + /// Commits the exact staged batch while carrying the private RAM plan + /// inside the engine-bound receipt. Only a durable receipt can release + /// the install token through `Engine::install_committed_batch_with`. + #[doc(hidden)] + pub fn commit(self, engine: &mut Engine) -> EngineCommitOutcome { + engine.commit_batch_with(self.batch, self.install) + } +} + +/// Prepared vector deletion; see [`StagedVectorInsert`]. +#[doc(hidden)] +#[derive(Debug)] +pub struct StagedVectorDelete { + batch: Batch, + install: VectorDeleteInstallToken, +} + +impl StagedVectorDelete { + #[doc(hidden)] + pub fn commit(self, engine: &mut Engine) -> EngineCommitOutcome { + engine.commit_batch_with(self.batch, self.install) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct VectorRamPrecondition { + params: VectorIndexParams, + entry_point: Option, + epoch: u64, + count: u64, + encoding: NodeEncoding, + policy_persisted: bool, +} + +/// Opaque proof payload released only after the engine installs the durable +/// batch bound to its receipt. It cannot be forged or cloned. +#[doc(hidden)] +#[derive(Debug)] +pub struct VectorInsertInstallToken { + expected: VectorRamPrecondition, + added: u64, + entry_point: Option, + changed: HashMap, +} + +/// Opaque deletion counterpart of [`VectorInsertInstallToken`]. +#[doc(hidden)] +#[derive(Debug)] +pub struct VectorDeleteInstallToken { + expected: VectorRamPrecondition, + removed: u64, + changed: Vec<(u64, VectorNode)>, +} + impl NodeProvider for ReadSnapshotProvider<'_> { fn node(&mut self, id: u64) -> Result> { let Some(bytes) = self.snapshot.get(node_key(id).as_bytes())? else { @@ -648,6 +712,27 @@ impl PersistentVectorIndex { self.epoch } + fn ram_precondition(&self) -> VectorRamPrecondition { + VectorRamPrecondition { + params: self.params, + entry_point: self.entry_point, + epoch: self.epoch, + count: self.count, + encoding: self.encoding, + policy_persisted: self.policy_persisted, + } + } + + fn validate_install_precondition(&self, expected: VectorRamPrecondition) -> Result<()> { + if self.ram_precondition() == expected { + Ok(()) + } else { + Err(EngineError::InvalidOptions { + reason: "staged vector installation no longer matches the index RAM state".to_owned(), + }) + } + } + /// Inserts `vector` under `id`, durably: the new node block, every /// re-pruned neighbor block, and the refreshed metadata record travel in /// **one** [`Engine::apply_batch`] — after a crash the whole insert is @@ -801,11 +886,49 @@ impl PersistentVectorIndex { /// holds on the error path too. An empty `items` applies only `extra` /// (itself a no-op when empty). pub fn insert_many_with(&mut self, engine: &mut Engine, items: Vec<(u64, Vec)>, extra: &Batch) -> Result<()> { - if items.is_empty() { - if !extra.is_empty() { - engine.apply_batch(extra)?; + let staged = self.stage_insert_many_with(engine, items, extra)?; + match staged.commit(engine) { + EngineCommitOutcome::Aborted { cause: None, .. } => Ok(()), + EngineCommitOutcome::Aborted { cause: Some(cause), .. } => { + self.cache.clear(); + Err(cause) + } + EngineCommitOutcome::Durable(receipt) => { + engine.install_committed_batch_with(receipt, |install| self.install_staged_insert_many(install)) + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => { + self.cache.clear(); + Err(match phase { + WalCommitPhase::Append => EngineError::WalAppendOutcomeUnknown { + cause: cause.to_string(), + }, + WalCommitPhase::Sync => EngineError::WalSyncOutcomeUnknown { + cause: cause.to_string(), + }, + }) } - return Ok(()); + } + } + + /// Stages one atomic multi-insert without publishing planned RAM state. + #[doc(hidden)] + pub fn stage_insert_many_with( + &self, + engine: &Engine, + items: Vec<(u64, Vec)>, + extra: &Batch, + ) -> Result { + let expected = self.ram_precondition(); + if items.is_empty() { + return Ok(StagedVectorInsert { + batch: extra.clone(), + install: VectorInsertInstallToken { + expected, + added: 0, + entry_point: self.entry_point, + changed: HashMap::new(), + }, + }); } let added = items.len() as u64; let mut pending: HashMap = HashMap::new(); @@ -837,12 +960,29 @@ impl PersistentVectorIndex { }; batch.put(META_KEY, &meta::encode(&new_meta)?); batch.extend_from(extra); - engine.apply_batch(&batch)?; - self.count += added; - self.entry_point = entry_point; - self.policy_persisted = true; - for (node_id, node) in pending { + Ok(StagedVectorInsert { + batch, + install: VectorInsertInstallToken { + expected, + added, + entry_point, + changed: pending, + }, + }) + } + + /// Installs vector RAM only from the opaque payload released after the + /// engine has validated and installed its durable receipt. + #[doc(hidden)] + pub fn install_staged_insert_many(&mut self, install: VectorInsertInstallToken) -> Result<()> { + self.validate_install_precondition(install.expected)?; + self.count += install.added; + self.entry_point = install.entry_point; + if install.added > 0 { + self.policy_persisted = true; + } + for (node_id, node) in install.changed { self.cache.put(node_id, node); } Ok(()) @@ -930,6 +1070,39 @@ impl PersistentVectorIndex { /// the caller's companion deletes must not survive a no-op tombstone /// pass. Returns how many ids *this call* tombstoned. pub fn delete_many_with(&mut self, engine: &mut Engine, ids: &[u64], extra: &Batch) -> Result { + let staged = self.stage_delete_many_with(engine, ids, extra)?; + match staged.commit(engine) { + EngineCommitOutcome::Aborted { cause: None, .. } => Ok(0), + EngineCommitOutcome::Aborted { cause: Some(cause), .. } => { + self.cache.clear(); + Err(cause) + } + EngineCommitOutcome::Durable(receipt) => { + let mut removed = None; + engine.install_committed_batch_with(receipt, |install| { + removed = Some(self.install_staged_delete_many(install)?); + Ok(()) + })?; + Ok(removed.expect("successful vector delete installation records its count")) + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => { + self.cache.clear(); + Err(match phase { + WalCommitPhase::Append => EngineError::WalAppendOutcomeUnknown { + cause: cause.to_string(), + }, + WalCommitPhase::Sync => EngineError::WalSyncOutcomeUnknown { + cause: cause.to_string(), + }, + }) + } + } + } + + /// Stages one atomic multi-delete without publishing tombstones/count. + #[doc(hidden)] + pub fn stage_delete_many_with(&self, engine: &Engine, ids: &[u64], extra: &Batch) -> Result { + let expected = self.ram_precondition(); let mut pending: Vec<(u64, VectorNode)> = Vec::new(); let mut seen: HashSet = HashSet::with_capacity(ids.len()); for &id in ids { @@ -950,10 +1123,14 @@ impl PersistentVectorIndex { } } if pending.is_empty() { - if !extra.is_empty() { - engine.apply_batch(extra)?; - } - return Ok(0); + return Ok(StagedVectorDelete { + batch: extra.clone(), + install: VectorDeleteInstallToken { + expected, + removed: 0, + changed: Vec::new(), + }, + }); } let removed = pending.len() as u64; @@ -974,14 +1151,30 @@ impl PersistentVectorIndex { }; batch.put(META_KEY, &meta::encode(&new_meta)?); batch.extend_from(extra); - engine.apply_batch(&batch)?; - self.count = self.count.saturating_sub(removed); - self.policy_persisted = true; - for (id, tombstoned) in pending { + Ok(StagedVectorDelete { + batch, + install: VectorDeleteInstallToken { + expected, + removed, + changed: pending, + }, + }) + } + + /// Installs delete RAM only after the engine has released the durable + /// receipt's opaque payload. + #[doc(hidden)] + pub fn install_staged_delete_many(&mut self, install: VectorDeleteInstallToken) -> Result { + self.validate_install_precondition(install.expected)?; + self.count = self.count.saturating_sub(install.removed); + if install.removed > 0 { + self.policy_persisted = true; + } + for (id, tombstoned) in install.changed { self.cache.put(id, tombstoned); } - Ok(removed) + Ok(install.removed) } /// Approximate wire bytes one tombstone rewrite stages (node key + the @@ -1365,6 +1558,107 @@ fn nearest_live_in_snapshot(nodes: &HashMap, reference: Option< None => live.map(|(&id, _)| id).min(), } } +#[cfg(test)] +mod staging_tests { + use super::*; + use tempfile::tempdir; + + fn params() -> VectorIndexParams { + VectorIndexParams::with_dim(2) + } + + fn items() -> Vec<(u64, Vec)> { + vec![(1, vec![1.0, 0.0]), (2, vec![0.0, 1.0]), (3, vec![0.7, 0.7])] + } + + fn install_durable_payload( + engine: &mut Engine, + outcome: EngineCommitOutcome, + install: impl FnOnce(T) -> Result<()>, + ) { + match outcome { + EngineCommitOutcome::Durable(receipt) => { + engine + .install_committed_batch_with(receipt, install) + .expect("install engine receipt and product RAM"); + } + other => panic!("expected durable outcome, got {other:?}"), + } + } + + #[test] + fn staging_does_not_publish_ram_before_durable_receipt_install() { + let dir = tempdir().expect("tempdir"); + let mut engine = Engine::open(dir.path()).expect("open engine"); + let mut index = PersistentVectorIndex::open(&mut engine, params()).expect("open index"); + + let staged = index + .stage_insert_many_with(&engine, items(), &Batch::new()) + .expect("stage insert"); + assert_eq!(index.len(), 0); + assert_eq!(index.entry_point, None); + assert_eq!(index.node_cache_stats().resident_bytes, 0); + assert!( + engine + .get(node_key(1).as_bytes()) + .expect("read before commit") + .is_none() + ); + + let outcome = staged.commit(&mut engine); + assert_eq!(index.len(), 0, "WAL durability must not publish vector RAM"); + assert_eq!(index.node_cache_stats().resident_bytes, 0); + install_durable_payload(&mut engine, outcome, |install| { + index.install_staged_insert_many(install) + }); + assert_eq!(index.len(), 3); + assert!(index.entry_point.is_some()); + assert!(index.node_cache_stats().resident_bytes > 0); + } + + #[test] + fn legacy_wrappers_match_explicit_stage_commit_install() { + let legacy_dir = tempdir().expect("legacy tempdir"); + let staged_dir = tempdir().expect("staged tempdir"); + let mut legacy_engine = Engine::open(legacy_dir.path()).expect("open legacy engine"); + let mut staged_engine = Engine::open(staged_dir.path()).expect("open staged engine"); + let mut legacy = PersistentVectorIndex::open(&mut legacy_engine, params()).expect("open legacy index"); + let mut staged = PersistentVectorIndex::open(&mut staged_engine, params()).expect("open staged index"); + + legacy + .insert_many_with(&mut legacy_engine, items(), &Batch::new()) + .expect("legacy insert"); + let prepared = staged + .stage_insert_many_with(&staged_engine, items(), &Batch::new()) + .expect("stage insert"); + let outcome = prepared.commit(&mut staged_engine); + install_durable_payload(&mut staged_engine, outcome, |token| { + staged.install_staged_insert_many(token) + }); + + let legacy_removed = legacy + .delete_many_with(&mut legacy_engine, &[1, 3, 3, 99], &Batch::new()) + .expect("legacy delete"); + let prepared = staged + .stage_delete_many_with(&staged_engine, &[1, 3, 3, 99], &Batch::new()) + .expect("stage delete"); + let outcome = prepared.commit(&mut staged_engine); + let mut staged_removed = None; + install_durable_payload(&mut staged_engine, outcome, |token| { + staged_removed = Some(staged.install_staged_delete_many(token)?); + Ok(()) + }); + let staged_removed = staged_removed.expect("successful staged delete records its count"); + + assert_eq!(legacy_removed, staged_removed); + assert_eq!(legacy.len(), staged.len()); + assert_eq!( + legacy_engine.scan_prefix(b"").expect("scan legacy"), + staged_engine.scan_prefix(b"").expect("scan staged") + ); + } +} + #[cfg(test)] mod cache_poison_tests { use std::panic::{AssertUnwindSafe, catch_unwind}; diff --git a/crates/basemyai-engine/src/lib.rs b/crates/basemyai-engine/src/lib.rs index 4e2f838..709672e 100644 --- a/crates/basemyai-engine/src/lib.rs +++ b/crates/basemyai-engine/src/lib.rs @@ -110,8 +110,10 @@ pub use idx::memory::{ForgetBatchOptions, MemoryRecord, NewMemoryRecord, Persist pub use idx::vector::{PersistentVectorIndex, VectorCacheStats, VectorIndex, VectorIndexParams, VectorInsertTrace}; pub use key::Key; pub use store::{ - Batch, CompactionJob, DEFAULT_BLOCK_SIZE, DEFAULT_METADATA_CACHE_CAPACITY_BYTES, Engine, EngineOptions, EngineRead, - EngineStats, IntegrityIssue, InternalKey, IssueKind, LatencySummary, ReadSnapshot, RebuildReport, RepairAction, - RepairPlan, ScanPage, SequenceNumber, Snapshot, Value, ValueKind, VerifyMode, VerifyReport, plan_repair, - rebuild_indexes, verify_store, verify_store_with_passphrase, + Batch, CommitReceipt, CompactionJob, DEFAULT_BLOCK_SIZE, DEFAULT_METADATA_CACHE_CAPACITY_BYTES, Engine, + EngineCommitOutcome, EngineOptions, EngineRead, EngineStats, EngineTerminalHandle, IntegrityIssue, InternalKey, + IssueKind, LatencySummary, ReadSnapshot, RebuildReport, RepairAction, RepairPlan, ScanPage, SequenceNumber, + SequenceRange, Snapshot, StructuralCandidateOwnership, StructuralCommitOutcome, StructuralCommitPhase, + StructuralReceipt, Value, ValueKind, VerifyMode, VerifyReport, WalCommitPhase, plan_repair, rebuild_indexes, + verify_store, verify_store_with_passphrase, }; diff --git a/crates/basemyai-engine/src/store/durable.rs b/crates/basemyai-engine/src/store/durable.rs index f88bbd2..7c1fe76 100644 --- a/crates/basemyai-engine/src/store/durable.rs +++ b/crates/basemyai-engine/src/store/durable.rs @@ -53,6 +53,12 @@ use crate::error::EngineError; /// state can diverge there. pub(crate) trait DurablePublicationTracker { fn pointer_replaced(&self); + + /// Marks entry into the directory-durability phase after the pointer has + /// already been replaced. Most publishers only need the panic-aware + /// `pointer_replaced` transition; live structural rotations additionally + /// observe this boundary to report ADR-070's closed structural phase. + fn directory_sync_started(&self) {} } impl DurablePublicationTracker for () { diff --git a/crates/basemyai-engine/src/store/engine/background.rs b/crates/basemyai-engine/src/store/engine/background.rs index 4e5ad01..d76daca 100644 --- a/crates/basemyai-engine/src/store/engine/background.rs +++ b/crates/basemyai-engine/src/store/engine/background.rs @@ -83,6 +83,67 @@ pub(super) struct DurablePublicationGuard<'a> { pointer_replaced: Cell, } +/// One-way capability used by the logical writer owner to make this exact +/// engine instance terminal (ADR-070 §6). +/// +/// This is deliberately workspace-private and non-forgeable: its constructor +/// is private to this module and [`Engine`](super::Engine) creates the sole +/// capability from the same publication lock and sticky health already used +/// by every [`DurablePublicationGuard`]. It therefore introduces neither a +/// second lock nor a second terminal verdict. +/// +/// Terminalisation linearises while holding `catalog_commit_lock`, then wakes +/// workers only after releasing it. A publisher that already owns a guard may +/// finish before that point; every later guard acquisition observes terminal +/// health under the same lock and is refused before `atomic_replace`. +#[doc(hidden)] +pub struct EngineTerminalHandle { + catalog_commit_lock: Arc>, + background: Arc, +} + +impl EngineTerminalHandle { + fn new(catalog_commit_lock: Arc>, background: Arc) -> Self { + Self { + catalog_commit_lock, + background, + } + } + + /// Escalates product/runtime failure to the engine's existing terminal + /// health. This method must be called outside a `DurablePublicationGuard`: + /// both intentionally serialize on the same non-reentrant mutex. + #[doc(hidden)] + pub fn enter_reconcile_required(&self, cause: impl Into) -> EngineError { + enter_reconcile_required(&self.catalog_commit_lock, &self.background, cause) + } +} + +/// Shared one-way terminal transition used by both the externally transferred +/// logical-writer handle and a durable receipt's private install obligation. +/// Keeping the lock acquisition here guarantees that neither path can race a +/// catalogue replacement admitted under [`DurablePublicationGuard`]. +pub(super) fn enter_reconcile_required( + catalog_commit_lock: &Mutex<()>, + background: &BackgroundError, + cause: impl Into, +) -> EngineError { + let cause = cause.into(); + match catalog_commit_lock.lock() { + Ok(_guard) => background.record_reconcile_required_without_wake(cause.clone()), + Err(poisoned) => { + // Do not recover or clear publication-lock poison. The + // PoisonError still owns the mutex guard, so terminal health can + // be armed before it is dropped, while every publisher remains + // excluded. + background.record_reconcile_required_without_wake(cause.clone()); + drop(poisoned); + } + } + background.wake_waiters(); + EngineError::WriterReconcileRequired { cause } +} + impl<'a> DurablePublicationGuard<'a> { pub(super) fn acquire( lock: &'a Mutex<()>, @@ -164,6 +225,10 @@ impl BackgroundError { Arc::new(Self::default()) } + pub(super) fn terminal_handle(self: &Arc, catalog_commit_lock: Arc>) -> EngineTerminalHandle { + EngineTerminalHandle::new(catalog_commit_lock, Arc::clone(self)) + } + /// Subscribes a worker to failure wake-ups. Held weakly on purpose (see /// the module doc): a dropped worker simply stops being notified. pub(super) fn register(&self, waker: Weak) { @@ -178,12 +243,20 @@ impl BackgroundError { /// /// The caller must **not** hold any worker's state lock (module doc). pub(super) fn record(&self, cause: String) { + self.record_cause(cause); + self.wake_waiters(); + } + + fn record_cause(&self, cause: String) { { let mut slot = self.cause.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if slot.is_none() { *slot = Some(cause); } } + } + + fn wake_waiters(&self) { // Cause lock released before waking: `wake_on_background_error` takes // worker state locks, and a worker checks `cause()` under its own // state lock. Holding both here, in the opposite order, would deadlock. @@ -212,8 +285,13 @@ impl BackgroundError { /// Idempotent, and never downgrades: once terminal, always terminal, even /// if an ordinary failure is recorded afterwards. pub(super) fn record_reconcile_required(&self, cause: String) { + self.record_reconcile_required_without_wake(cause); + self.wake_waiters(); + } + + fn record_reconcile_required_without_wake(&self, cause: String) { self.reconcile_required.store(true, Ordering::SeqCst); - self.record(cause); + self.record_cause(cause); } /// Records `cause` as an ambiguous durable publication and returns the @@ -295,3 +373,91 @@ impl BackgroundError { } } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Barrier, Mutex}; + + use super::{BackgroundError, DurablePublicationGuard}; + use crate::error::EngineError; + + #[test] + fn terminal_handle_linearises_after_an_admitted_publisher_and_refuses_the_next() { + let lock = Arc::new(Mutex::new(())); + let background = BackgroundError::new(); + let terminal = background.terminal_handle(Arc::clone(&lock)); + let publisher_admitted = Arc::new(Barrier::new(2)); + let release_publisher = Arc::new(Barrier::new(2)); + let replace_happened = Arc::new(AtomicBool::new(false)); + + let publisher = { + let lock = Arc::clone(&lock); + let background = Arc::clone(&background); + let publisher_admitted = Arc::clone(&publisher_admitted); + let release_publisher = Arc::clone(&release_publisher); + let replace_happened = Arc::clone(&replace_happened); + std::thread::spawn(move || { + let _guard = DurablePublicationGuard::acquire(&lock, &background, "ADR-070 test publisher") + .expect("publisher is admitted while the engine is healthy"); + publisher_admitted.wait(); + release_publisher.wait(); + // Models the publisher's atomic_replace boundary while it + // still owns the real ADR-067 authority. + replace_happened.store(true, Ordering::SeqCst); + }) + }; + + publisher_admitted.wait(); + let terminal_thread = std::thread::spawn(move || { + let error = terminal.enter_reconcile_required("product writer failed"); + assert!(matches!(error, EngineError::WriterReconcileRequired { .. })); + assert!( + replace_happened.load(Ordering::SeqCst), + "the admitted publisher must finish before terminality linearises" + ); + }); + + release_publisher.wait(); + publisher.join().expect("publisher thread"); + terminal_thread.join().expect("terminal thread"); + + let refused = match DurablePublicationGuard::acquire(&lock, &background, "late publisher") { + Ok(_) => panic!("no publisher may be admitted after the terminal point"), + Err(error) => error, + }; + assert!(matches!(refused, EngineError::WriterReconcileRequired { .. })); + } + + #[test] + fn terminal_handle_escalates_a_poisoned_publication_lock_without_recovering_it() { + let lock = Arc::new(Mutex::new(())); + let background = BackgroundError::new(); + let terminal = background.terminal_handle(Arc::clone(&lock)); + + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe({ + let lock = Arc::clone(&lock); + let background = Arc::clone(&background); + move || { + let _guard = DurablePublicationGuard::acquire(&lock, &background, "poison witness") + .expect("publisher starts healthy"); + panic!("panic before replacement poisons the publication lock"); + } + })); + assert!(unwound.is_err(), "the witness must poison the lock"); + assert!(lock.is_poisoned(), "control: publication lock is poisoned"); + + let error = terminal.enter_reconcile_required("product panic after lock poison"); + assert!(matches!(error, EngineError::WriterReconcileRequired { .. })); + assert!( + lock.is_poisoned(), + "terminalisation must never clear publication poison" + ); + + let refused = match DurablePublicationGuard::acquire(&lock, &background, "late publisher") { + Ok(_) => panic!("poisoned terminal authority cannot admit a publisher"), + Err(error) => error, + }; + assert!(matches!(refused, EngineError::WriterReconcileRequired { .. })); + } +} diff --git a/crates/basemyai-engine/src/store/engine/io.rs b/crates/basemyai-engine/src/store/engine/io.rs index fd008c9..878b318 100644 --- a/crates/basemyai-engine/src/store/engine/io.rs +++ b/crates/basemyai-engine/src/store/engine/io.rs @@ -60,6 +60,10 @@ pub(super) fn publish_generation_tracked( if let Err(error) = crate::failpoint_result!("after_generation_rename") { return DurablePublish::Unknown(error); } + tracker.directory_sync_started(); + if let Err(error) = crate::failpoint_result!("during_generation_directory_sync") { + return DurablePublish::Unknown(error); + } // ENG-DUR-003/004: the generation pointer's rename must be durable // before any caller (`rotate_key_full`) acts on the strength of it — // notably the old-generation GC that follows immediately after. diff --git a/crates/basemyai-engine/src/store/engine/mod.rs b/crates/basemyai-engine/src/store/engine/mod.rs index e13d6c6..16fa297 100644 --- a/crates/basemyai-engine/src/store/engine/mod.rs +++ b/crates/basemyai-engine/src/store/engine/mod.rs @@ -44,6 +44,9 @@ mod rotate; mod test_support; mod write; +#[doc(hidden)] +pub use background::EngineTerminalHandle; + pub use compact::CompactionJob; pub(crate) use io::gc_old_generation as gc_retired_generation; @@ -182,6 +185,153 @@ pub struct Batch { ops: Vec<(Key, Option)>, } +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SequenceRange { + first: SequenceNumber, + last: SequenceNumber, +} + +impl SequenceRange { + #[must_use] + pub fn first(self) -> SequenceNumber { + self.first + } + + #[must_use] + pub fn last(self) -> SequenceNumber { + self.last + } +} + +#[doc(hidden)] +pub struct CommitReceipt { + sequence_range: SequenceRange, + bytes_written: u64, + batch: Option, + install_token: InstallToken, + obligation: ReceiptInstallObligation, + payload: Option, +} + +impl CommitReceipt { + #[must_use] + pub fn sequence_range(&self) -> SequenceRange { + self.sequence_range + } + + #[must_use] + pub fn bytes_written(&self) -> u64 { + self.bytes_written + } +} + +impl std::fmt::Debug for CommitReceipt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CommitReceipt") + .field("sequence_range", &self.sequence_range) + .field("bytes_written", &self.bytes_written) + .field("install_pending", &self.obligation.armed) + .finish_non_exhaustive() + } +} + +#[derive(Debug)] +struct InstallToken { + sequence_range: SequenceRange, + engine_identity: Arc<()>, +} + +struct ReceiptInstallObligation { + catalog_commit_lock: Arc>, + background: Arc, + armed: bool, +} + +impl ReceiptInstallObligation { + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for ReceiptInstallObligation { + fn drop(&mut self) { + if self.armed { + let _ = background::enter_reconcile_required( + &self.catalog_commit_lock, + &self.background, + "durable commit receipt was abandoned before product installation", + ); + } + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub enum EngineCommitOutcome { + Aborted { + burned_sequence_range: Option, + cause: Option, + }, + Durable(CommitReceipt), + OutcomeUnknown { + phase: crate::store::wal::WalCommitPhase, + cause: EngineError, + }, +} + +/// Structural publication phase that made a live rotation's durable outcome +/// impossible to reconcile in the current engine instance (ADR-070 section +/// 4.1). This is deliberately separate from WAL commit phases: structural +/// rotations never invent a logical sequence range. +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructuralCommitPhase { + CatalogReplace, + DirectorySync, + CryptoGeneration, +} + +/// Ownership verdict for artifacts which a structural publication may have +/// made authoritative. Recovery, not stale RAM state, decides their lifecycle. +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructuralCandidateOwnership { + RetainForRecovery, +} + +/// Proof that a live structural operation completed its durable publication +/// and matching RAM installation. It intentionally carries no WAL sequence. +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StructuralReceipt { + _private: (), +} + +impl StructuralReceipt { + fn committed() -> Self { + Self { _private: () } + } +} + +/// Closed engine-level result for a live structural rotation. Legacy rotation +/// methods flatten this to `Result<()>`; the V2-03 coordinator consumes this +/// form so ambiguity can never be mistaken for an ordinary retryable error. +#[doc(hidden)] +#[derive(Debug)] +pub enum StructuralCommitOutcome { + Aborted { + cause: EngineError, + }, + Committed(StructuralReceipt), + StructuralReopenRequired { + phase: StructuralCommitPhase, + candidate_ownership: StructuralCandidateOwnership, + cause: EngineError, + }, +} + impl Batch { /// Creates an empty batch. #[must_use] @@ -501,6 +651,11 @@ pub struct Engine { /// readers. Writers publish once with Release after an entire batch; /// readers load once with Acquire at operation entry (ADR-046 I2/I3). visible_sequence: AtomicU64, + /// Runtime-only identity and the at-most-one durable receipt awaiting RAM + /// installation. A pending receipt blocks every later logical mutation; + /// it is cleared only after the exact engine-bound receipt is consumed. + engine_identity: Arc<()>, + pending_install: Option, /// The published, immutable set of live SSTs (ADR-043 §2 amended, J3). /// Replaced wholesale by [`compact::apply_version_edit`] — never mutated /// in place (INV-VS-1/2). [`Self::snapshot`] pins it by cloning the @@ -509,6 +664,10 @@ pub struct Engine { /// Serialises durable catalogue transactions without holding /// `view_gate` across filesystem I/O. catalog_commit_lock: Arc>, + /// One-way ADR-070 capability over the same publication lock and sticky + /// terminal health. Created at open and later handed to the logical + /// writer owner without exposing either authority independently. + terminal_handle: Option, /// Coordinates the `(visible_sequence, current SuperVersion)` capture /// with batch publication and flush/compaction swaps. view_gate: Arc>, @@ -621,6 +780,14 @@ impl Engine { Arc::clone(&self.published().current) } + /// Transfers the engine-bound terminal capability to its logical writer + /// owner. Exactly one caller can take it; the opaque handle has no public + /// constructor and is not clonable. + #[doc(hidden)] + pub fn take_terminal_handle(&mut self) -> Option { + self.terminal_handle.take() + } + #[cfg(test)] fn catalog_snapshot(&self) -> CatalogState { self.published().catalog.clone() @@ -824,7 +991,18 @@ impl Engine { /// that `close()` must not publish from a RAM state that may already be /// stale, and it no longer can. pub fn close(mut self) -> Result<()> { - let flush = self.flush(); + // A durable-but-uninstalled receipt makes a healthy flush unsafe: its + // WAL bytes must be recovered on reopen, never retired by publishing + // the older RAM state. Consuming close still joins both workers. + let flush = match self.pending_install { + Some(range) => Err(EngineError::WriterReconcileRequired { + cause: format!( + "cannot close cleanly while durable sequence range {}..={} awaits installation", + range.first, range.last + ), + }), + None => self.flush(), + }; // Compaction first: an in-flight merge still needs `catalog_commit_lock` // and a healthy flush pipeline to commit. Stopping the flush worker // first would fail that commit for a reason unrelated to any fault. diff --git a/crates/basemyai-engine/src/store/engine/open.rs b/crates/basemyai-engine/src/store/engine/open.rs index 2ad0a7d..c0e4575 100644 --- a/crates/basemyai-engine/src/store/engine/open.rs +++ b/crates/basemyai-engine/src/store/engine/open.rs @@ -372,6 +372,7 @@ impl Engine { // One sticky failure for both workers (ADR-049 §1), created before // either so each can register its wake-up on it at spawn. let background = super::background::BackgroundError::new(); + let terminal_handle = background.terminal_handle(Arc::clone(&catalog_commit_lock)); let merge_census = super::compact::MergeCensus::new(); let compaction_trigger = super::compaction_worker::CompactionTrigger::new(options.auto_compact_on_flush); let snapshots = SnapshotRegistry::new(); @@ -421,8 +422,11 @@ impl Engine { memtable, last_allocated_sequence: last_sequence, visible_sequence: AtomicU64::new(last_sequence), + engine_identity: Arc::new(()), + pending_install: None, published, catalog_commit_lock, + terminal_handle: Some(terminal_handle), view_gate, next_file_id, next_flush_job_id: 1, diff --git a/crates/basemyai-engine/src/store/engine/rotate.rs b/crates/basemyai-engine/src/store/engine/rotate.rs index e306d1a..883ae6c 100644 --- a/crates/basemyai-engine/src/store/engine/rotate.rs +++ b/crates/basemyai-engine/src/store/engine/rotate.rs @@ -8,6 +8,7 @@ //! intrinsically one all-or-nothing "new generation" unit, not a sequence //! of independently reusable phases. +use std::cell::Cell; use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -19,7 +20,7 @@ use crate::fail_point; use crate::format::catalog::{CatalogState, SstMeta, WalMeta}; use crate::format::generation_meta; use crate::store::catalog as catalog_io; -use crate::store::durable::DurablePublish; +use crate::store::durable::{DurablePublicationTracker, DurablePublish}; use crate::store::memtable::Memtable; use crate::store::sst_block::BlockSstFile; use crate::store::version::{GenerationLease, SstHandle, SuperVersion, Version}; @@ -29,7 +30,79 @@ use crate::store::{InternalKey, Value}; use super::background::DurablePublicationGuard; use super::compact::CaptureContext; use super::io::{generation_dir, publish_generation_tracked}; -use super::{Engine, PreparedPublishedInstall}; +use super::{ + Engine, PreparedPublishedInstall, StructuralCandidateOwnership, StructuralCommitOutcome, StructuralCommitPhase, + StructuralReceipt, +}; + +/// Observes ADR-067's existing publication guard without owning any lock or +/// health state itself. The only extra state is a local phase label used to +/// translate `DurablePublish::Unknown` into ADR-070's structural outcome. +struct StructuralPublicationTracker<'guard, 'lock> { + guard: &'guard DurablePublicationGuard<'lock>, + progress: &'guard StructuralProgress, + pointer_phase: StructuralCommitPhase, +} + +impl<'guard, 'lock> StructuralPublicationTracker<'guard, 'lock> { + fn new( + guard: &'guard DurablePublicationGuard<'lock>, + progress: &'guard StructuralProgress, + pointer_phase: StructuralCommitPhase, + ) -> Self { + Self { + guard, + progress, + pointer_phase, + } + } +} + +impl DurablePublicationTracker for StructuralPublicationTracker<'_, '_> { + fn pointer_replaced(&self) { + self.guard.pointer_replaced(); + self.progress.phase.set(Some(self.pointer_phase)); + } + + fn directory_sync_started(&self) { + self.guard.directory_sync_started(); + self.progress.phase.set(Some(StructuralCommitPhase::DirectorySync)); + } +} + +#[derive(Default)] +struct StructuralProgress { + phase: Cell>, + committed: Cell, +} + +fn classify_structural(result: Result<()>, progress: &StructuralProgress) -> StructuralCommitOutcome { + if progress.committed.get() { + return StructuralCommitOutcome::Committed(StructuralReceipt::committed()); + } + match (result, progress.phase.get()) { + (Ok(()), None) => StructuralCommitOutcome::Committed(StructuralReceipt::committed()), + (Err(cause), Some(phase)) => StructuralCommitOutcome::StructuralReopenRequired { + phase, + candidate_ownership: StructuralCandidateOwnership::RetainForRecovery, + cause, + }, + (Err(cause), None) => StructuralCommitOutcome::Aborted { cause }, + (Ok(()), Some(_)) => unreachable!("structural progress must be committed after successful installation"), + } +} + +fn run_phase_aware(operation: impl FnOnce() -> Result<()>) -> std::thread::Result> { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation)) +} + +fn flatten_structural(outcome: StructuralCommitOutcome) -> Result<()> { + match outcome { + StructuralCommitOutcome::Committed(_) => Ok(()), + StructuralCommitOutcome::Aborted { cause } + | StructuralCommitOutcome::StructuralReopenRequired { cause, .. } => Err(cause), + } +} /// Complete successor-generation RAM image, allocated and validated before /// `generation.meta` crosses its point of no return. @@ -87,6 +160,43 @@ impl PreparedFullRotation { } impl Engine { + fn classify_phase_aware_run( + &self, + run: std::thread::Result>, + progress: &StructuralProgress, + ) -> StructuralCommitOutcome { + match run { + Ok(result) => classify_structural(result, progress), + Err(_payload) if progress.committed.get() => { + // Publication and RAM installation already completed. Any + // later panic belongs to best-effort finalisation and cannot + // demote a durable structural commit. + StructuralCommitOutcome::Committed(StructuralReceipt::committed()) + } + Err(_payload) if progress.phase.get().is_some() => { + // The real ADR-067 guard has already armed terminal health in + // Drop while unwinding. This layer only preserves its phase + // and candidate-ownership verdict for the coordinator. + let cause = match self.background.check_not_terminal() { + Err(cause) => cause, + Ok(()) => { + let invariant = EngineError::CryptoFailure { + reason: "structural publication panicked after pointer replacement without terminal health" + .to_string(), + }; + self.background.enter_reconcile_required(&invariant) + } + }; + StructuralCommitOutcome::StructuralReopenRequired { + phase: progress.phase.get().expect("phase checked above"), + candidate_ownership: StructuralCandidateOwnership::RetainForRecovery, + cause, + } + } + Err(payload) => std::panic::resume_unwind(payload), + } + } + /// Rotates the user key **in place** (ADR-030 §4): the store's DEK is /// re-wrapped under a KEK derived from `new_key` (fresh salt) and /// `crypto.meta` is atomically replaced (tmp + fsync + rename). O(1) — @@ -106,7 +216,30 @@ impl Engine { /// encryption (nothing to rotate — parity with ADR-007's posture); /// otherwise I/O errors from the atomic replace. pub fn rotate_key(&mut self, new_key: &[u8]) -> Result<()> { - self.rotate_key_with_mode(new_key, crypto::KeyMode::RawKey, crypto::Argon2idProfile::Default) + let progress = StructuralProgress::default(); + let result = self.rotate_key_with_mode( + new_key, + crypto::KeyMode::RawKey, + crypto::Argon2idProfile::Default, + &progress, + ); + flatten_structural(classify_structural(result, &progress)) + } + + /// Phase-aware V2-03 entry point. Hidden from the product API: only the + /// live structural coordinator should consume its closed outcome. + #[doc(hidden)] + pub fn rotate_key_phase_aware(&mut self, new_key: &[u8]) -> StructuralCommitOutcome { + let progress = StructuralProgress::default(); + let run = run_phase_aware(|| { + self.rotate_key_with_mode( + new_key, + crypto::KeyMode::RawKey, + crypto::Argon2idProfile::Default, + &progress, + ) + }); + self.classify_phase_aware_run(run, &progress) } /// Passphrase counterpart to [`Self::rotate_key`]. The existing DEK is @@ -123,7 +256,23 @@ impl Engine { new_passphrase: &[u8], profile: crypto::Argon2idProfile, ) -> Result<()> { - self.rotate_key_with_mode(new_passphrase, crypto::KeyMode::Passphrase, profile) + let progress = StructuralProgress::default(); + let result = self.rotate_key_with_mode(new_passphrase, crypto::KeyMode::Passphrase, profile, &progress); + flatten_structural(classify_structural(result, &progress)) + } + + /// Phase-aware passphrase counterpart to [`Self::rotate_key_phase_aware`]. + #[doc(hidden)] + pub fn rotate_passphrase_phase_aware( + &mut self, + new_passphrase: &[u8], + profile: crypto::Argon2idProfile, + ) -> StructuralCommitOutcome { + let progress = StructuralProgress::default(); + let run = run_phase_aware(|| { + self.rotate_key_with_mode(new_passphrase, crypto::KeyMode::Passphrase, profile, &progress) + }); + self.classify_phase_aware_run(run, &progress) } fn rotate_key_with_mode( @@ -131,6 +280,7 @@ impl Engine { new_key: &[u8], mode: crypto::KeyMode, profile: crypto::Argon2idProfile, + progress: &StructuralProgress, ) -> Result<()> { self.background.check_not_terminal()?; self.flush_worker.ensure_healthy()?; @@ -160,11 +310,22 @@ impl Engine { DurablePublicationGuard::acquire(&commit_lock, &publication_background, "light crypto rotation")?; #[cfg(any(test, feature = "test-util"))] drop(publication_waiter); - match crypto::publish_staged_meta_tracked(&self.dir, &staged, &publication_guard) { + let publication = { + let tracker = StructuralPublicationTracker::new( + &publication_guard, + progress, + StructuralCommitPhase::CryptoGeneration, + ); + crypto::publish_staged_meta_tracked(&self.dir, &staged, &tracker) + }; + match publication { DurablePublish::Published(()) => { // A light rotation re-wraps the same in-memory DEK, so no RAM // object changes after the pointer is durable. publication_guard.complete(); + drop(publication_guard); + progress.committed.set(true); + fail_point!("after_light_rotation_publish"); Ok(()) } // The previous wrapping is still the committed one; the old key @@ -189,7 +350,29 @@ impl Engine { /// generation. Before pointer publication an error leaves this instance /// and the active generation unchanged. pub fn rotate_key_full(&mut self, new_key: &[u8]) -> Result<()> { - self.rotate_full(new_key, crypto::KeyMode::RawKey, crypto::Argon2idProfile::Default) + let progress = StructuralProgress::default(); + let result = self.rotate_full( + new_key, + crypto::KeyMode::RawKey, + crypto::Argon2idProfile::Default, + &progress, + ); + flatten_structural(classify_structural(result, &progress)) + } + + /// Phase-aware full-generation rotation for the live structural handler. + #[doc(hidden)] + pub fn rotate_key_full_phase_aware(&mut self, new_key: &[u8]) -> StructuralCommitOutcome { + let progress = StructuralProgress::default(); + let run = run_phase_aware(|| { + self.rotate_full( + new_key, + crypto::KeyMode::RawKey, + crypto::Argon2idProfile::Default, + &progress, + ) + }); + self.classify_phase_aware_run(run, &progress) } /// Passphrase counterpart to [`Self::rotate_key_full`]. The fresh DEK is @@ -204,10 +387,30 @@ impl Engine { new_passphrase: &[u8], profile: crypto::Argon2idProfile, ) -> Result<()> { - self.rotate_full(new_passphrase, crypto::KeyMode::Passphrase, profile) + let progress = StructuralProgress::default(); + let result = self.rotate_full(new_passphrase, crypto::KeyMode::Passphrase, profile, &progress); + flatten_structural(classify_structural(result, &progress)) } - fn rotate_full(&mut self, new_key: &[u8], mode: crypto::KeyMode, profile: crypto::Argon2idProfile) -> Result<()> { + /// Phase-aware full passphrase rotation for the live structural handler. + #[doc(hidden)] + pub fn rotate_passphrase_full_phase_aware( + &mut self, + new_passphrase: &[u8], + profile: crypto::Argon2idProfile, + ) -> StructuralCommitOutcome { + let progress = StructuralProgress::default(); + let run = run_phase_aware(|| self.rotate_full(new_passphrase, crypto::KeyMode::Passphrase, profile, &progress)); + self.classify_phase_aware_run(run, &progress) + } + + fn rotate_full( + &mut self, + new_key: &[u8], + mode: crypto::KeyMode, + profile: crypto::Argon2idProfile, + progress: &StructuralProgress, + ) -> Result<()> { // Terminal publication state has precedence over every operation- // specific validation. In particular, a plaintext engine made // reconcile-required by a caught seal panic must not mask that state @@ -449,7 +652,8 @@ impl Engine { return Err(error); } }; - match publish_generation_tracked(&self.root_dir, prepared.generation_id, &commit_guard) { + let tracker = StructuralPublicationTracker::new(&commit_guard, progress, StructuralCommitPhase::CatalogReplace); + match publish_generation_tracked(&self.root_dir, prepared.generation_id, &tracker) { DurablePublish::Published(()) => {} DurablePublish::NotReplaced(error) => { // The active generation is still the previous one and this @@ -483,8 +687,13 @@ impl Engine { // The generation pointer is live. Installation consumes only the // prebuilt successor image and returns superseded owners for cleanup. + // A panic here remains a structural catalogue ambiguity: RAM may be + // only partially installed and recovery owns the candidate generation. + progress.phase.set(Some(StructuralCommitPhase::CatalogReplace)); + fail_point!("before_full_rotation_install"); let retired = prepared.install(self); commit_guard.complete(); + progress.committed.set(true); drop(commit_guard); self.counters.fsync_count += retired.wal.fsync_count(); self.counters.wal_remove_failures += retired.wal.remove_failures(); diff --git a/crates/basemyai-engine/src/store/engine/write.rs b/crates/basemyai-engine/src/store/engine/write.rs index 2a8b4fa..e456217 100644 --- a/crates/basemyai-engine/src/store/engine/write.rs +++ b/crates/basemyai-engine/src/store/engine/write.rs @@ -3,6 +3,7 @@ //! before the memtable is touched, ADR-025), plus the auto-flush trigger //! and the WAL-record counter bookkeeping every write op shares. +use std::sync::Arc; use std::sync::atomic::Ordering; use crate::error::{EngineError, Result}; @@ -10,12 +11,14 @@ use crate::format::wal::{self, BatchOp, WalOp}; use crate::key::Key; use crate::store::SequenceNumber; -use super::{Batch, Engine}; +use super::{Batch, CommitReceipt, Engine, EngineCommitOutcome, InstallToken, SequenceRange}; +use crate::store::wal::{WalCommitPhase, WalUnwindState, WalWriteOutcome}; impl Engine { /// Inserts or overwrites `key`. Durable once this returns `Ok` — the WAL /// record is fsynced before the memtable is updated. pub fn put(&mut self, key: &[u8], value: &[u8]) -> Result<()> { + self.ensure_no_pending_install()?; self.background.check_not_terminal()?; self.flush_worker.ensure_healthy()?; self.seal_recovered_tail_before_write()?; @@ -36,6 +39,7 @@ impl Engine { /// Deletes `key` (a no-op if it wasn't present). Durable once this /// returns `Ok`. pub fn delete(&mut self, key: &[u8]) -> Result<()> { + self.ensure_no_pending_install()?; self.background.check_not_terminal()?; self.flush_worker.ensure_healthy()?; self.seal_recovered_tail_before_write()?; @@ -67,16 +71,65 @@ impl Engine { /// already relies on for single `put`/`delete` records, just covering /// the whole batch's bytes instead of one op's. pub fn apply_batch(&mut self, batch: &Batch) -> Result<()> { + match self.commit_batch(batch.clone()) { + EngineCommitOutcome::Aborted { cause: None, .. } => Ok(()), + EngineCommitOutcome::Aborted { cause: Some(cause), .. } => Err(cause), + EngineCommitOutcome::Durable(receipt) => self.install_committed_batch_with(receipt, |_| Ok(())), + EngineCommitOutcome::OutcomeUnknown { phase, cause } => match phase { + WalCommitPhase::Append => Err(EngineError::WalAppendOutcomeUnknown { + cause: cause.to_string(), + }), + WalCommitPhase::Sync => Err(EngineError::WalSyncOutcomeUnknown { + cause: cause.to_string(), + }), + }, + } + } + + /// ADR-070's closed engine commit primitive. It reserves the exact + /// sequence range and establishes WAL durability, but deliberately does + /// not install product-visible RAM state; the receipt is the sole token + /// accepted by `install_committed_batch`. + #[doc(hidden)] + pub fn commit_batch(&mut self, batch: Batch) -> EngineCommitOutcome { + self.commit_batch_with(batch, ()) + } + + /// Commits `batch` while carrying an owned, non-persisted installation + /// plan through the durability boundary. The payload can only be + /// recovered by consuming the exact engine-bound durable receipt in + /// [`Self::install_committed_batch`]. + #[doc(hidden)] + pub fn commit_batch_with(&mut self, batch: Batch, payload: T) -> EngineCommitOutcome { // Terminal writer state has precedence over the empty-batch no-op: // every mutating API is also an admission surface and must report // that this engine can only recover by reopening. - self.background.check_not_terminal()?; + if let Err(cause) = self + .ensure_no_pending_install() + .and_then(|()| self.background.check_not_terminal()) + { + return EngineCommitOutcome::Aborted { + burned_sequence_range: None, + cause: Some(cause), + }; + } if batch.is_empty() { - return Ok(()); + return EngineCommitOutcome::Aborted { + burned_sequence_range: None, + cause: None, + }; + } + if let Err(cause) = self + .flush_worker + .ensure_healthy() + .and_then(|()| self.seal_recovered_tail_before_write()) + .and_then(|()| self.preflight_batch(&batch)) + { + return EngineCommitOutcome::Aborted { + burned_sequence_range: None, + cause: Some(cause), + }; } - self.flush_worker.ensure_healthy()?; - self.seal_recovered_tail_before_write()?; - self.preflight_batch(batch)?; let wal_ops: Vec = batch .ops .iter() @@ -86,30 +139,142 @@ impl Engine { value: value.clone(), }) .collect(); - let (base_sequence, last_sequence) = self.reserve_sequences(batch.len())?; - let written = self.wal.append_batch(base_sequence, &wal_ops)?; - self.note_wal_record(written); + let (base_sequence, last_sequence) = match self.reserve_sequences(batch.len()) { + Ok(range) => range, + Err(cause) => { + return EngineCommitOutcome::Aborted { + burned_sequence_range: None, + cause: Some(cause), + }; + } + }; + let sequence_range = SequenceRange { + first: base_sequence, + last: last_sequence, + }; + let background = &self.background; + match self.wal.commit_batch(base_sequence, &wal_ops, |state| { + let cause = match state { + WalUnwindState::Append => "WAL append panicked after write attempt", + WalUnwindState::Sync => "WAL sync panicked after append", + WalUnwindState::Durable => "WAL commit panicked after durability was confirmed", + }; + background.record_reconcile_required(cause.to_string()); + }) { + WalWriteOutcome::Aborted(cause) => EngineCommitOutcome::Aborted { + burned_sequence_range: Some(sequence_range), + cause: Some(cause), + }, + WalWriteOutcome::OutcomeUnknown { phase, cause } => { + self.background + .record_reconcile_required(format!("WAL {phase:?} outcome unknown: {cause}")); + EngineCommitOutcome::OutcomeUnknown { phase, cause } + } + WalWriteOutcome::Durable { bytes_written } => { + self.note_wal_record(bytes_written); + debug_assert!( + self.pending_install.is_none(), + "admission rejected a second pending receipt" + ); + self.pending_install = Some(sequence_range); + EngineCommitOutcome::Durable(CommitReceipt { + sequence_range, + bytes_written, + batch: Some(batch), + install_token: InstallToken { + sequence_range, + engine_identity: Arc::clone(&self.engine_identity), + }, + obligation: super::ReceiptInstallObligation { + catalog_commit_lock: Arc::clone(&self.catalog_commit_lock), + background: Arc::clone(&self.background), + armed: true, + }, + payload: Some(payload), + }) + } + } + } + #[doc(hidden)] + pub fn install_committed_batch_with( + &mut self, + mut receipt: CommitReceipt, + install_product: impl FnOnce(T) -> Result<()>, + ) -> Result<()> { + let sequence_range = receipt.sequence_range; + let identity_matches = Arc::ptr_eq(&self.engine_identity, &receipt.install_token.engine_identity); + let pending_matches = + self.pending_install == Some(sequence_range) && receipt.install_token.sequence_range == sequence_range; + if !identity_matches || !pending_matches { + return Err(super::background::enter_reconcile_required( + &self.catalog_commit_lock, + &self.background, + "durable commit receipt belongs to another engine or pending installation", + )); + } + let batch = receipt + .batch + .take() + .expect("an armed durable receipt always owns its batch"); + let payload = receipt + .payload + .take() + .expect("an armed durable receipt always owns its installation payload"); + if receipt.bytes_written == 0 || sequence_range.last - sequence_range.first + 1 != batch.len() as u64 { + return Err(super::background::enter_reconcile_required( + &self.catalog_commit_lock, + &self.background, + "durable commit receipt failed its internal range validation", + )); + } { let _gate = self.view_gate.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - for (ordinal, (key, value)) in batch.ops.iter().enumerate() { - let sequence = base_sequence + for (ordinal, (key, value)) in batch.ops.into_iter().enumerate() { + let sequence = sequence_range + .first .checked_add(u64::try_from(ordinal).expect("batch length is bounded to 10,000")) .expect("reserve_sequences validated the complete contiguous range"); match value { Some(v) => self .memtable - .insert_value(key.clone(), sequence, v.clone()) + .insert_value(key, sequence, v) .expect("freshly allocated sequence must be unique and nonzero"), None => self .memtable - .insert_tombstone(key.clone(), sequence) + .insert_tombstone(key, sequence) .expect("freshly allocated sequence must be unique and nonzero"), } } - self.visible_sequence.store(last_sequence, Ordering::Release); + self.visible_sequence.store(sequence_range.last, Ordering::Release); + } + // `pending_install` and the receipt obligation deliberately remain + // armed while product RAM is installed. An error or unwind drops the + // armed receipt, serialising terminalisation with catalogue publish. + install_product(payload)?; + self.pending_install = None; + receipt.obligation.disarm(); + // Durability and complete product installation already establish the + // caller's Committed verdict. Opportunistic seal/flush work may make + // the engine sticky for the *next* admission, but must never demote + // this completed mutation into a retryable error. + if let Err(error) = self.maybe_flush() { + self.background + .record(format!("post-commit opportunistic flush failed: {error}")); + } + Ok(()) + } + + fn ensure_no_pending_install(&self) -> Result<()> { + match self.pending_install { + Some(range) => Err(EngineError::WriterReconcileRequired { + cause: format!( + "durable sequence range {}..={} is awaiting mandatory RAM installation", + range.first, range.last + ), + }), + None => Ok(()), } - self.maybe_flush() } /// Seals the mutable memtable when it crosses its threshold and enqueues @@ -259,6 +424,7 @@ impl Engine { #[cfg(test)] mod tests { use super::*; + use crate::failpoint::{self, Action}; use crate::store::engine::test_support::KEY; fn bounded_options() -> super::super::EngineOptions { @@ -374,6 +540,323 @@ mod tests { ); } + #[test] + fn adr070_commit_outcomes_are_phase_aware_exact_and_terminal_when_ambiguous() { + struct ClearFailpoints; + impl Drop for ClearFailpoints { + fn drop(&mut self) { + failpoint::clear_all(); + } + } + + let _clear = ClearFailpoints; + failpoint::clear_all(); + + // A failure before any write attempt burns the reserved range but + // leaves both WAL and writer reusable. + let aborted_dir = tempfile::tempdir().expect("tempdir"); + let mut aborted_engine = Engine::open_with_options(aborted_dir.path(), bounded_options()).expect("open"); + let mut one = Batch::new(); + one.put(b"a", b"1"); + let before = aborted_engine.stats().expect("stats before aborted commit"); + failpoint::set("before_wal_append_attempt", Action::Error); + match aborted_engine.commit_batch(one.clone()) { + EngineCommitOutcome::Aborted { + burned_sequence_range: Some(range), + cause: Some(_), + } => assert_eq!(range, SequenceRange { first: 1, last: 1 }), + other => panic!("expected pre-write Aborted, got {other:?}"), + } + failpoint::remove("before_wal_append_attempt"); + assert_eq!( + aborted_engine.stats().expect("stats after aborted commit").wal_bytes, + before.wal_bytes, + "pre-write Aborted must not touch the WAL" + ); + aborted_engine.apply_batch(&one).expect("writer remains reusable"); + assert_eq!( + aborted_engine.visible_sequence(), + 2, + "the burned sequence remains a gap" + ); + + // A normal commit returns the exact range and remains invisible until + // its non-forgeable receipt is consumed by installation. + let durable_dir = tempfile::tempdir().expect("tempdir"); + let mut durable_engine = Engine::open_with_options(durable_dir.path(), bounded_options()).expect("open"); + let mut two = Batch::new(); + two.put(b"a", b"1"); + two.put(b"b", b"2"); + let receipt = match durable_engine.commit_batch(two) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected Durable receipt, got {other:?}"), + }; + assert_eq!(receipt.sequence_range, SequenceRange { first: 1, last: 2 }); + assert!(receipt.bytes_written > 0); + assert_eq!(durable_engine.visible_sequence(), 0); + assert_eq!(durable_engine.get(b"a").expect("not installed"), None); + durable_engine + .install_committed_batch_with(receipt, |_| Ok(())) + .expect("receipt belongs to this engine"); + assert_eq!(durable_engine.visible_sequence(), 2); + + // An error after write_all is Append-unknown and immediately arms + // sticky fail-stop health; no later append is admitted. + for (site, expected_phase) in [ + ("after_wal_append", WalCommitPhase::Append), + ("during_wal_sync", WalCommitPhase::Sync), + ] { + failpoint::clear_all(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_with_options(dir.path(), bounded_options()).expect("open"); + failpoint::set(site, Action::Error); + match engine.commit_batch(one.clone()) { + EngineCommitOutcome::OutcomeUnknown { phase, .. } => assert_eq!(phase, expected_phase), + other => panic!("expected phase-aware OutcomeUnknown at {site}, got {other:?}"), + } + failpoint::remove(site); + let bytes_after_unknown = engine.stats().expect("stats after unknown").wal_bytes; + let error = engine + .apply_batch(&one) + .expect_err("sticky terminal state refuses the next append"); + assert!(matches!(error, EngineError::WriterReconcileRequired { .. })); + assert_eq!( + engine.stats().expect("stats after refused retry").wal_bytes, + bytes_after_unknown, + "terminal writer must not append again" + ); + } + + // Unwinding cannot bypass the returned-outcome path and accidentally + // leave a writer healthy after a WAL attempt. + for site in ["after_wal_append", "during_wal_sync", "after_wal_fsync"] { + failpoint::clear_all(); + let panic_dir = tempfile::tempdir().expect("tempdir"); + let mut panic_engine = Engine::open_with_options(panic_dir.path(), bounded_options()).expect("open"); + failpoint::set(site, Action::Panic); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = panic_engine.commit_batch(one.clone()); + })); + assert!(panic.is_err(), "injected panic at {site} must unwind"); + failpoint::remove(site); + let bytes_after_panic = panic_engine.stats().expect("stats after panic").wal_bytes; + assert!(matches!( + panic_engine.apply_batch(&one), + Err(EngineError::WriterReconcileRequired { .. }) + )); + assert_eq!( + panic_engine + .stats() + .expect("stats after refused post-panic append") + .wal_bytes, + bytes_after_panic + ); + } + + // Once sync_all returned success, an error-style probe at the crash + // boundary cannot demote the result from Durable. + failpoint::clear_all(); + let confirmed_dir = tempfile::tempdir().expect("tempdir"); + let mut confirmed = Engine::open_with_options(confirmed_dir.path(), bounded_options()).expect("open"); + failpoint::set("after_wal_fsync", Action::Error); + assert!(matches!(confirmed.commit_batch(one), EngineCommitOutcome::Durable(_))); + } + + #[test] + fn adr070_pending_receipt_blocks_mutation_until_exact_install() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_with_options(dir.path(), bounded_options()).expect("open"); + let mut first = Batch::new(); + first.put(b"one", b"1"); + let receipt = match engine.commit_batch(first) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected durable receipt, got {other:?}"), + }; + + let mut second = Batch::new(); + second.put(b"two", b"2"); + assert!(matches!( + engine.commit_batch(second.clone()), + EngineCommitOutcome::Aborted { + cause: Some(EngineError::WriterReconcileRequired { .. }), + .. + } + )); + assert!(matches!( + engine.put(b"put", b"blocked"), + Err(EngineError::WriterReconcileRequired { .. }) + )); + assert!(matches!( + engine.delete(b"one"), + Err(EngineError::WriterReconcileRequired { .. }) + )); + + engine + .install_committed_batch_with(receipt, |_| Ok(())) + .expect("the exact engine consumes its receipt"); + engine + .apply_batch(&second) + .expect("next commit is admitted after install"); + assert_eq!(engine.get(b"one").expect("read first"), Some(b"1".to_vec())); + assert_eq!(engine.get(b"two").expect("read second"), Some(b"2".to_vec())); + } + + #[test] + fn adr070_dropped_receipt_terminalises_its_source_engine() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_with_options(dir.path(), bounded_options()).expect("open"); + let mut batch = Batch::new(); + batch.put(b"dur", b"pend"); + let receipt = match engine.commit_batch(batch) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected durable receipt, got {other:?}"), + }; + + drop(receipt); + assert!(matches!( + engine.put(b"later", b"blocked"), + Err(EngineError::WriterReconcileRequired { .. }) + )); + } + + #[test] + fn adr070_cross_engine_receipt_terminalises_both_engines() { + let source_dir = tempfile::tempdir().expect("source tempdir"); + let target_dir = tempfile::tempdir().expect("target tempdir"); + let mut source = Engine::open_with_options(source_dir.path(), bounded_options()).expect("open source"); + let mut target = Engine::open_with_options(target_dir.path(), bounded_options()).expect("open target"); + let mut batch = Batch::new(); + batch.put(b"src", b"dur"); + let receipt = match source.commit_batch(batch) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected durable receipt, got {other:?}"), + }; + + assert!(matches!( + target.install_committed_batch_with(receipt, |_| Ok(())), + Err(EngineError::WriterReconcileRequired { .. }) + )); + assert!(matches!( + target.put(b"target", b"blocked"), + Err(EngineError::WriterReconcileRequired { .. }) + )); + assert!(matches!( + source.put(b"source", b"blocked"), + Err(EngineError::WriterReconcileRequired { .. }) + )); + } + + #[test] + fn adr070_close_with_pending_receipt_skips_clean_flush_and_reopen_recovers() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_with_options(dir.path(), bounded_options()).expect("open"); + let mut batch = Batch::new(); + batch.put(b"dur", b"play"); + let receipt = match engine.commit_batch(batch) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected durable receipt, got {other:?}"), + }; + + assert!(matches!( + engine.close(), + Err(EngineError::WriterReconcileRequired { .. }) + )); + drop(receipt); + + let reopened = Engine::open_with_options(dir.path(), bounded_options()).expect("reopen from retained WAL"); + assert_eq!(reopened.get(b"dur").expect("replayed read"), Some(b"play".to_vec())); + } + + #[test] + fn adr070_product_install_error_or_panic_keeps_engine_terminal() { + for panic in [false, true] { + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_with_options(dir.path(), bounded_options()).expect("open"); + let mut batch = Batch::new(); + batch.put(b"dur", b"plan"); + let receipt = match engine.commit_batch_with(batch, 7_u8) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected durable receipt, got {other:?}"), + }; + + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + engine.install_committed_batch_with(receipt, |payload| { + assert_eq!(payload, 7); + assert!(!panic, "injected product RAM install panic"); + Err(EngineError::InvalidCatalogState { + reason: "injected product RAM install error".into(), + }) + }) + })); + if panic { + assert!(outcome.is_err(), "the injected install panic must unwind"); + } else { + assert!(matches!(outcome, Ok(Err(EngineError::InvalidCatalogState { .. })))); + } + assert!(matches!( + engine.put(b"next", b"blocked"), + Err(EngineError::WriterReconcileRequired { .. }) + )); + } + } + + #[test] + fn adr070_post_install_seal_failure_preserves_success_and_is_sticky() { + struct ClearFailpoints; + impl Drop for ClearFailpoints { + fn drop(&mut self) { + failpoint::clear_all(); + } + } + + let _clear = ClearFailpoints; + failpoint::clear_all(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut options = bounded_options(); + options.memtable_flush_threshold = 1; + let mut engine = Engine::open_with_options(dir.path(), options).expect("open"); + + let mut committed = Batch::new(); + committed.put(b"one", b"1"); + let receipt = match engine.commit_batch_with(committed, 7_u8) { + EngineCommitOutcome::Durable(receipt) => receipt, + other => panic!("expected durable receipt, got {other:?}"), + }; + failpoint::set("before_seal_candidate_wal_fsync", Action::Error); + engine + .install_committed_batch_with(receipt, |payload| { + assert_eq!(payload, 7); + Ok(()) + }) + .expect("post-install seal failure must not demote a committed product install"); + failpoint::remove("before_seal_candidate_wal_fsync"); + + assert_eq!(engine.get(b"one").expect("committed read"), Some(b"1".to_vec())); + let wal_records = engine.stats().expect("stats after committed install").wal_records; + assert!(matches!( + engine.put(b"two", b"2"), + Err(EngineError::BackgroundFlush { .. } | EngineError::WriterReconcileRequired { .. }) + )); + assert_eq!( + engine.stats().expect("stats after refused next write").wal_records, + wal_records, + "the sticky post-commit error must refuse the next append" + ); + assert_eq!(engine.get(b"two").expect("refused key read"), None); + + drop(engine); + let reopened = Engine::open_with_options(dir.path(), options).expect("reopen retained WAL"); + assert_eq!( + reopened.get(b"one").expect("replayed committed key"), + Some(b"1".to_vec()) + ); + assert_eq!(reopened.get(b"two").expect("refused key remains absent"), None); + let visible = reopened.scan_prefix(b"one").expect("single logical committed value"); + assert_eq!(visible.len(), 1); + assert_eq!(visible[0].0.as_bytes(), b"one"); + assert_eq!(visible[0].1, b"1"); + } + #[test] fn sequence_overflow_is_typed_and_precedes_wal_or_memtable_mutation() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/basemyai-engine/src/store/mod.rs b/crates/basemyai-engine/src/store/mod.rs index d410e69..ad72269 100644 --- a/crates/basemyai-engine/src/store/mod.rs +++ b/crates/basemyai-engine/src/store/mod.rs @@ -52,8 +52,9 @@ pub mod writer_runtime; pub(crate) mod writer_runtime; pub use engine::{ - Batch, CompactionJob, DEFAULT_BLOCK_CACHE_CAPACITY_BYTES, DEFAULT_BLOCK_SIZE, - DEFAULT_METADATA_CACHE_CAPACITY_BYTES, Engine, EngineOptions, ScanPage, + Batch, CommitReceipt, CompactionJob, DEFAULT_BLOCK_CACHE_CAPACITY_BYTES, DEFAULT_BLOCK_SIZE, + DEFAULT_METADATA_CACHE_CAPACITY_BYTES, Engine, EngineCommitOutcome, EngineOptions, EngineTerminalHandle, ScanPage, + SequenceRange, StructuralCandidateOwnership, StructuralCommitOutcome, StructuralCommitPhase, StructuralReceipt, }; // Réservé à N13/R8 (ADR-050 §4.2) — jamais publié. `CommitPolicy` est lu par // `WriterRuntime::new`, compilé même hors tests ; `GroupCommitOptions` n'est @@ -69,6 +70,8 @@ pub use repair::{RebuildReport, RepairAction, RepairPlan, plan_repair, rebuild_i pub use stats::{EngineStats, LatencySummary}; pub use verify::{IntegrityIssue, IssueKind, VerifyMode, VerifyReport, verify_store, verify_store_with_passphrase}; pub use version::{ReadSnapshot, Snapshot}; +#[doc(hidden)] +pub use wal::WalCommitPhase; /// A stored value. Kept as a plain alias (not a newtype) since, unlike /// [`crate::key::Key`], nothing about its ordering or encoding is diff --git a/crates/basemyai-engine/src/store/wal.rs b/crates/basemyai-engine/src/store/wal.rs index 251bffc..5a865c9 100644 --- a/crates/basemyai-engine/src/store/wal.rs +++ b/crates/basemyai-engine/src/store/wal.rs @@ -70,6 +70,73 @@ pub(crate) struct Wal { remove_failures: u64, } +/// Closed WAL outcome used by ADR-070's internal commit primitive. It keeps +/// failures before the first attempted write separate from failures after an +/// append or sync attempt, where absence/durability can no longer be inferred +/// from a flat `io::Error`. +pub(crate) enum WalWriteOutcome { + Aborted(EngineError), + Durable { bytes_written: u64 }, + OutcomeUnknown { phase: WalCommitPhase, cause: EngineError }, +} + +#[doc(hidden)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WalCommitPhase { + Append, + Sync, +} + +struct WalAmbiguityGuard +where + F: FnOnce(WalUnwindState), +{ + state: WalUnwindState, + on_ambiguous: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum WalUnwindState { + Append, + Sync, + Durable, +} + +impl WalAmbiguityGuard +where + F: FnOnce(WalUnwindState), +{ + fn new(on_ambiguous: F) -> Self { + Self { + state: WalUnwindState::Append, + on_ambiguous: Some(on_ambiguous), + } + } + + fn enter_sync(&mut self) { + self.state = WalUnwindState::Sync; + } + + fn confirm_durable(&mut self) { + self.state = WalUnwindState::Durable; + } + + fn disarm(mut self) { + self.on_ambiguous = None; + } +} + +impl Drop for WalAmbiguityGuard +where + F: FnOnce(WalUnwindState), +{ + fn drop(&mut self) { + if let Some(on_ambiguous) = self.on_ambiguous.take() { + on_ambiguous(self.state); + } + } +} + /// Owns a newly-created WAL until its initial fsync succeeds. An error or /// unwind before that boundary must not leave a reusable canonical file id /// behind: `create_new` deliberately refuses to overwrite such a path. @@ -370,6 +437,7 @@ impl Wal { /// crash can only ever leave either every sub-operation absent (record /// never made it) or every sub-operation present (record fully synced) /// — never a partial subset. Returns the on-disk bytes written. + #[cfg(test)] pub(crate) fn append_batch(&mut self, base_sequence: SequenceNumber, ops: &[BatchOp]) -> Result { if ops.len() > wal::MAX_BATCH_OPS { return Err(EngineError::WalBatchTooLarge { @@ -381,6 +449,105 @@ impl Wal { self.write_record(base_sequence, WalOp::Batch, &[], Some(&payload)) } + /// Phase-aware batch append for ADR-070. Unlike the legacy wrapper above, + /// this never erases whether a failure happened before or after the WAL + /// ambiguity boundary. + pub(crate) fn commit_batch( + &mut self, + base_sequence: SequenceNumber, + ops: &[BatchOp], + on_ambiguous_unwind: impl FnOnce(WalUnwindState), + ) -> WalWriteOutcome { + if ops.len() > wal::MAX_BATCH_OPS { + return WalWriteOutcome::Aborted(EngineError::WalBatchTooLarge { + len: ops.len(), + max: wal::MAX_BATCH_OPS, + }); + } + let payload = match wal::encode_batch(ops) { + Ok(payload) => payload, + Err(error) => return WalWriteOutcome::Aborted(error), + }; + self.write_record_phase_aware(base_sequence, WalOp::Batch, &[], Some(&payload), on_ambiguous_unwind) + } + + fn write_record_phase_aware( + &mut self, + base_sequence: SequenceNumber, + op: WalOp, + key: &[u8], + value: Option<&[u8]>, + on_ambiguous_unwind: impl FnOnce(WalUnwindState), + ) -> WalWriteOutcome { + let offset = match self.file.seek(SeekFrom::End(0)) { + Ok(offset) => offset, + Err(error) => return WalWriteOutcome::Aborted(EngineError::io(self.path.clone(), error)), + }; + let record = match wal::encode_with_sequence(op, offset, base_sequence, key, value) { + Ok(record) => record, + Err(error) => return WalWriteOutcome::Aborted(error), + }; + let sealed; + let on_disk: &[u8] = match &self.crypto { + Some(crypto) => { + let aad = envelope::wal_envelope_aad_v3(self.store_id, self.wal_epoch, self.file_id, offset); + let encrypted = match crypto.seal(&record, &aad) { + Ok(encrypted) => encrypted, + Err(error) => return WalWriteOutcome::Aborted(error), + }; + sealed = match envelope::encode_wal_envelope(&encrypted.nonce, &encrypted.ciphertext) { + Ok(sealed) => sealed, + Err(error) => return WalWriteOutcome::Aborted(error), + }; + &sealed + } + None => &record, + }; + + if let Err(error) = crate::failpoint_result!("before_wal_append_attempt") { + return WalWriteOutcome::Aborted(error); + } + let mut ambiguity = WalAmbiguityGuard::new(on_ambiguous_unwind); + if let Err(error) = self.file.write_all(on_disk) { + ambiguity.disarm(); + return WalWriteOutcome::OutcomeUnknown { + phase: WalCommitPhase::Append, + cause: EngineError::io(self.path.clone(), error), + }; + } + if let Err(error) = crate::failpoint_result!("after_wal_append") { + ambiguity.disarm(); + return WalWriteOutcome::OutcomeUnknown { + phase: WalCommitPhase::Append, + cause: error, + }; + } + ambiguity.enter_sync(); + if let Err(error) = crate::failpoint_result!("during_wal_sync") { + ambiguity.disarm(); + return WalWriteOutcome::OutcomeUnknown { + phase: WalCommitPhase::Sync, + cause: error, + }; + } + if let Err(error) = self.file.sync_all() { + ambiguity.disarm(); + return WalWriteOutcome::OutcomeUnknown { + phase: WalCommitPhase::Sync, + cause: EngineError::io(self.path.clone(), error), + }; + } + self.fsync_count += 1; + ambiguity.confirm_durable(); + // `Error` after a confirmed fsync cannot revoke durability. Abort and + // panic actions still fire at this exact crash-consistency boundary. + let _ = crate::failpoint_result!("after_wal_fsync"); + ambiguity.disarm(); + WalWriteOutcome::Durable { + bytes_written: on_disk.len() as u64, + } + } + /// Explicitly seeks to end-of-file first (see [`Wal::open_for_append`] /// for why this isn't `O_APPEND`) — the returned position is this /// record's `record_offset` (ADR-044 §3), known before encoding since it diff --git a/crates/basemyai-engine/tests/crash/adr066_commit_outcome.rs b/crates/basemyai-engine/tests/crash/adr066_commit_outcome.rs index b9b6dbf..88669a4 100644 --- a/crates/basemyai-engine/tests/crash/adr066_commit_outcome.rs +++ b/crates/basemyai-engine/tests/crash/adr066_commit_outcome.rs @@ -26,7 +26,10 @@ use std::sync::{Mutex, MutexGuard, OnceLock}; use std::thread; use basemyai_engine::failpoint::{self, Action}; -use basemyai_engine::{Batch, Engine, EngineError, EngineOptions}; +use basemyai_engine::{ + Batch, Engine, EngineError, EngineOptions, StructuralCandidateOwnership, StructuralCommitOutcome, + StructuralCommitPhase, +}; const KEY: &[u8] = b"adr066 commit outcome passphrase"; const REPLACEMENT_KEY: &[u8] = b"adr066 replacement passphrase"; @@ -81,6 +84,26 @@ fn concurrent_publish_options() -> EngineOptions { } } +fn assert_structural_reopen(outcome: StructuralCommitOutcome, expected_phase: StructuralCommitPhase) -> EngineError { + match outcome { + StructuralCommitOutcome::StructuralReopenRequired { + phase, + candidate_ownership, + cause, + } => { + assert_eq!(phase, expected_phase); + assert_eq!( + candidate_ownership, + StructuralCandidateOwnership::RetainForRecovery, + "an ambiguous structural publication must transfer candidate lifecycle to reopen" + ); + assert_terminal(&cause, "phase-aware structural outcome"); + cause + } + other => panic!("expected StructuralReopenRequired({expected_phase:?}), got {other:?}"), + } +} + /// Produces `count` distinct, durable SSTs without allowing automatic /// compaction. The caller can then exercise the inline compaction commit /// boundary without racing a background job it did not request. @@ -472,6 +495,125 @@ fn ambiguous_generation_publication_is_terminal() { ); } +#[test] +fn phase_aware_full_rotation_reports_catalog_replace_and_retains_generation() { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + engine.put(b"durable", b"before").expect("pre-rotation write"); + engine.flush().expect("settle before rotation"); + + failpoint::set("after_generation_rename", Action::Error); + let outcome = engine.rotate_key_full_phase_aware(REPLACEMENT_KEY); + failpoint::remove("after_generation_rename"); + + let _cause = assert_structural_reopen(outcome, StructuralCommitPhase::CatalogReplace); + assert!( + dir.path().join("gen-1").is_dir(), + "the candidate generation may be authoritative and must remain for recovery" + ); + assert_terminal( + &engine + .put(b"after", b"must-not-append") + .expect_err("the next mutation must be blocked"), + "mutation after catalog-replace ambiguity", + ); +} + +#[test] +fn phase_aware_full_rotation_reports_directory_sync_and_retains_generation() { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + engine.put(b"durable", b"before").expect("pre-rotation write"); + engine.flush().expect("settle before rotation"); + + failpoint::set("during_generation_directory_sync", Action::Error); + let outcome = engine.rotate_key_full_phase_aware(REPLACEMENT_KEY); + failpoint::remove("during_generation_directory_sync"); + + let _cause = assert_structural_reopen(outcome, StructuralCommitPhase::DirectorySync); + assert!( + dir.path().join("gen-1").is_dir(), + "directory-sync ambiguity must retain the complete candidate generation" + ); + assert_terminal( + &engine + .put(b"after", b"must-not-append") + .expect_err("the next mutation must be blocked"), + "mutation after directory-sync ambiguity", + ); +} + +#[test] +fn phase_aware_full_rotation_classifies_panics_at_replace_sync_and_install() { + for (site, expected_phase) in [ + ("after_generation_rename", StructuralCommitPhase::CatalogReplace), + ("during_generation_directory_sync", StructuralCommitPhase::DirectorySync), + ("before_full_rotation_install", StructuralCommitPhase::CatalogReplace), + ] { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = + Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + engine.put(b"durable", b"before").expect("pre-rotation write"); + engine.flush().expect("settle before rotation"); + + failpoint::set(site, Action::Panic); + let outcome = engine.rotate_key_full_phase_aware(REPLACEMENT_KEY); + failpoint::remove(site); + + let _cause = assert_structural_reopen(outcome, expected_phase); + assert!( + dir.path().join("gen-1").is_dir(), + "panic at {site} must retain the candidate generation for recovery" + ); + assert_terminal( + &engine + .put(b"after", b"must-not-append") + .expect_err("the next mutation must be blocked"), + "mutation after structural panic", + ); + } +} + +#[test] +fn phase_aware_full_rotation_preserves_committed_after_finalisation_panic() { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + engine.put(b"durable", b"before").expect("pre-rotation write"); + engine.flush().expect("settle before rotation"); + + failpoint::set("after_full_rotation_publish", Action::Panic); + let outcome = engine.rotate_key_full_phase_aware(REPLACEMENT_KEY); + failpoint::remove("after_full_rotation_publish"); + + assert!( + matches!(outcome, StructuralCommitOutcome::Committed(_)), + "a finalisation panic after guard completion cannot demote the structural commit: {outcome:?}" + ); + engine + .put(b"after", b"still-admitted") + .expect("the committed engine remains usable after finalisation panic"); + assert_eq!( + engine.get(b"after").expect("read after committed outcome").as_deref(), + Some(&b"still-admitted"[..]) + ); +} + /// Same-instance unwind proof for the pointer with the widest blast radius: /// `generation.meta`. The atomic replacement already selects the staged /// generation when the panic fires, while every Engine field still describes @@ -588,6 +730,79 @@ fn ambiguous_crypto_meta_replacement_is_terminal() { ); } +#[test] +fn phase_aware_light_rotation_reports_crypto_generation_and_blocks_next_mutation() { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + engine.put(b"durable", b"before").expect("pre-rotation write"); + + failpoint::set("after_crypto_meta_write", Action::Error); + let outcome = engine.rotate_key_phase_aware(REPLACEMENT_KEY); + failpoint::remove("after_crypto_meta_write"); + + let _cause = assert_structural_reopen(outcome, StructuralCommitPhase::CryptoGeneration); + assert!( + dir.path().join("crypto.meta").is_file(), + "the replaced crypto generation must remain authoritative for reopen" + ); + assert_terminal( + &engine + .put(b"after", b"must-not-append") + .expect_err("the next mutation must be blocked"), + "mutation after crypto-generation ambiguity", + ); +} + +#[test] +fn phase_aware_light_rotation_classifies_replace_panic_as_crypto_generation() { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + engine.put(b"durable", b"before").expect("pre-rotation write"); + + failpoint::set("after_crypto_meta_write", Action::Panic); + let outcome = engine.rotate_key_phase_aware(REPLACEMENT_KEY); + failpoint::remove("after_crypto_meta_write"); + + let _cause = assert_structural_reopen(outcome, StructuralCommitPhase::CryptoGeneration); + assert_terminal( + &engine + .put(b"after", b"must-not-append") + .expect_err("the next mutation must be blocked"), + "mutation after crypto-generation panic", + ); +} + +#[test] +fn phase_aware_light_rotation_preserves_committed_after_finalisation_panic() { + let _serial = lock(); + let _clear = ClearOnDrop; + failpoint::clear_all(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut engine = Engine::open_encrypted_with_options(dir.path(), KEY, manual_options()).expect("open encrypted"); + + failpoint::set("after_light_rotation_publish", Action::Panic); + let outcome = engine.rotate_key_phase_aware(REPLACEMENT_KEY); + failpoint::remove("after_light_rotation_publish"); + + assert!( + matches!(outcome, StructuralCommitOutcome::Committed(_)), + "a post-complete light-rotation panic cannot demote the commit: {outcome:?}" + ); + engine + .put(b"after", b"still-admitted") + .expect("publication authority must not remain poisoned after completion"); + engine.flush().expect("a later catalogue publication remains healthy"); +} + /// Catching an unwind after `crypto.meta` was atomically replaced must not /// resurrect the same Engine as a writer. The in-process DEK is still valid, /// which makes this case deceptively survivable; terminality is nevertheless diff --git a/crates/basemyai-mcp/src/error.rs b/crates/basemyai-mcp/src/error.rs index 0213e7f..3f42b28 100644 --- a/crates/basemyai-mcp/src/error.rs +++ b/crates/basemyai-mcp/src/error.rs @@ -4,6 +4,13 @@ use thiserror::Error; +// JSON-RPC réserve -32000..=-32099 aux erreurs serveur définies par +// l'application. Ces valeurs figent les catégories ADR-069 qui n'ont pas +// d'équivalent dans les codes standard JSON-RPC. +const UNAUTHENTICATED: rmcp::model::ErrorCode = rmcp::model::ErrorCode(-32016); +const PERMISSION_DENIED: rmcp::model::ErrorCode = rmcp::model::ErrorCode(-32007); +const FAILED_PRECONDITION: rmcp::model::ErrorCode = rmcp::model::ErrorCode(-32009); + /// Erreur du serveur MCP basemyai. #[derive(Debug, Error)] #[non_exhaustive] @@ -94,6 +101,16 @@ impl From for rmcp::ErrorData { fn memory_error_data(inner: &basemyai::MemoryError, msg: &str) -> rmcp::ErrorData { use basemyai::MemoryError as M; match inner { + M::InvalidScope(_) => rmcp::ErrorData::invalid_params("invalid scope identifier", None), + M::Unauthenticated => rmcp::ErrorData::new(UNAUTHENTICATED, "authentication required", None), + M::UnauthorizedScope => { + rmcp::ErrorData::new(PERMISSION_DENIED, "operation not authorized for this scope", None) + } + M::ScopeOperationUnsupported(_) => rmcp::ErrorData::new( + FAILED_PRECONDITION, + "scope operation not supported in legacy mode", + None, + ), M::MissingAgent | M::UnknownLayer(_) | M::Extraction(_) @@ -161,4 +178,31 @@ mod tests { assert!(!data.message.is_empty()); assert_ne!(data.message, "internal error"); } + + #[test] + fn scope_errors_map_to_stable_json_rpc_codes_without_payload_leaks() { + let canary = "private-project/secret-agent"; + let cases = [ + ( + basemyai::MemoryError::InvalidScope(canary.to_string()), + rmcp::model::ErrorCode::INVALID_PARAMS, + ), + (basemyai::MemoryError::Unauthenticated, UNAUTHENTICATED), + (basemyai::MemoryError::UnauthorizedScope, PERMISSION_DENIED), + ( + basemyai::MemoryError::ScopeOperationUnsupported(canary.to_string()), + FAILED_PRECONDITION, + ), + ]; + + for (memory_error, expected_code) in cases { + let data: rmcp::ErrorData = McpError::Memory(memory_error).into(); + assert_eq!(data.code, expected_code); + assert_eq!(data.data, None); + assert!( + !data.message.contains(canary), + "MCP error message leaked the canary payload" + ); + } + } } diff --git a/crates/basemyai-rest/src/http/error.rs b/crates/basemyai-rest/src/http/error.rs index cac0a53..0af2287 100644 --- a/crates/basemyai-rest/src/http/error.rs +++ b/crates/basemyai-rest/src/http/error.rs @@ -183,6 +183,35 @@ impl RestError { "plaintext store cannot be encrypted in place".to_string(), None, ), + // ADR-069 §5 : taxonomie d'erreurs d'autorisation observables + // sans oracle d'existence. `UnauthorizedScope` ne porte aucun + // identifiant de scope étranger dans son message — ne jamais + // enrichir ce bras avec `e.to_string()` ou un `details` + // dérivé de la requête, sous peine de leak cross-scope. + M::InvalidScope(_) => ( + StatusCode::BAD_REQUEST, + "invalid_scope", + "invalid scope identifier".to_string(), + None, + ), + M::Unauthenticated => ( + StatusCode::UNAUTHORIZED, + "unauthenticated", + "authentication required".to_string(), + None, + ), + M::UnauthorizedScope => ( + StatusCode::FORBIDDEN, + "unauthorized_scope", + "operation not authorized for this scope".to_string(), + None, + ), + M::ScopeOperationUnsupported(_) => ( + StatusCode::CONFLICT, + "scope_operation_unsupported", + "scope operation not supported in legacy mode".to_string(), + None, + ), _ => { tracing::error!(error = %e, "internal error in REST handler"); ( @@ -283,4 +312,43 @@ mod tests { let with_field = RestError::Validation("importance must be finite".to_string()).with_field("importance"); assert_eq!(with_field.details, Some(serde_json::json!({ "field": "importance" }))); } + + #[test] + fn scope_errors_map_to_stable_statuses_and_codes_without_payload_leaks() { + let canary = "private-project/secret-agent"; + let cases = [ + ( + MemoryError::InvalidScope(canary.to_string()), + StatusCode::BAD_REQUEST, + "invalid_scope", + ), + ( + MemoryError::Unauthenticated, + StatusCode::UNAUTHORIZED, + "unauthenticated", + ), + ( + MemoryError::UnauthorizedScope, + StatusCode::FORBIDDEN, + "unauthorized_scope", + ), + ( + MemoryError::ScopeOperationUnsupported(canary.to_string()), + StatusCode::CONFLICT, + "scope_operation_unsupported", + ), + ]; + + for (memory_error, expected_status, expected_code) in cases { + let error = RestError::Memory(memory_error); + let (status, code, message, details) = error.parts(); + assert_eq!(status, expected_status); + assert_eq!(code, expected_code); + assert_eq!(details, None); + assert!( + !message.contains(canary), + "REST error message leaked the canary payload" + ); + } + } } diff --git a/crates/basemyai/src/error.rs b/crates/basemyai/src/error.rs index 6928291..b63f132 100644 --- a/crates/basemyai/src/error.rs +++ b/crates/basemyai/src/error.rs @@ -111,7 +111,57 @@ pub enum MemoryError { /// Limite publique acceptee. max: usize, }, + + /// Identifiant ou scope de requête mal formé (grammaire/format invalide), + /// sans lien avec l'existence ou l'autorisation d'un scope donné + /// (ADR-069 §5). Reste générique : ne doit pas répéter tel quel un + /// identifiant étranger complet si cela pouvait leaker une info + /// cross-scope. + #[error("invalid scope identifier")] + InvalidScope(String), + + /// Aucun credential fourni, ou credential fourni mais non vérifiable + /// (ADR-069 §5). Ne porte aucune donnée : la distinction avec + /// `UnauthorizedScope` doit rester binaire côté transport (401 vs 403). + #[error("authentication required")] + Unauthenticated, + + /// Principal authentifié mais sans le grant exact requis pour le scope + /// demandé (ADR-069 §5). Message volontairement générique : ne doit + /// jamais révéler l'identifiant du scope demandé ni s'il existe + /// réellement — un principal autorisé recevant « absent » et un + /// principal non autorisé recevant « interdit » est acceptable, mais + /// l'inverse (leak d'existence) ne l'est pas. + #[error("operation not authorized for this scope")] + UnauthorizedScope, + + /// Scope ou opération non encore activé en mode legacy (ADR-069 §5) : + /// limitation de fonctionnalité, pas un refus d'autorisation. Le payload + /// reste réservé au diagnostic interne : `Display` est volontairement + /// constant afin qu'un binding qui stringify l'erreur ne le divulgue pas. + #[error("scope operation not supported in legacy mode")] + ScopeOperationUnsupported(String), } /// Alias de résultat de la couche mémoire. pub type Result = core::result::Result; + +#[cfg(test)] +mod tests { + use super::MemoryError; + + #[test] + fn scope_error_display_does_not_expose_diagnostic_payloads() { + let canary = "private-project/secret-agent"; + + for error in [ + MemoryError::InvalidScope(canary.to_string()), + MemoryError::ScopeOperationUnsupported(canary.to_string()), + ] { + assert!( + !error.to_string().contains(canary), + "scope error Display must not expose its diagnostic payload" + ); + } + } +} diff --git a/crates/basemyai/src/lib.rs b/crates/basemyai/src/lib.rs index 1eba82e..0e6f900 100644 --- a/crates/basemyai/src/lib.rs +++ b/crates/basemyai/src/lib.rs @@ -21,6 +21,7 @@ pub mod maintenance; mod memory; pub mod provision; mod retrieval; +mod runtime; pub mod storage; pub mod temporal; @@ -46,9 +47,9 @@ pub use maintenance::{ #[cfg(feature = "test-util")] pub use memory::HashEmbedder; pub use memory::{ - AgentId, AgentStats, ConversationTurn, ImportReport, MAX_TEXT_LEN, Memory, MemoryEvent, MemoryEventKind, - MemoryLayer, MemorySubscription, RecallOptions, Record, SOURCE_CONSOLIDATION, SOURCE_IMPORT, SOURCE_USER, - TrustLevel, + AgentId, AgentStats, CallerPrincipal, ConversationTurn, ImportReport, MAX_TEXT_LEN, Memory, MemoryEvent, + MemoryEventKind, MemoryId, MemoryLayer, MemoryScope, MemorySubscription, PrincipalIssuer, PrincipalSubject, + ProjectId, QualifiedMemoryId, RecallOptions, Record, SOURCE_CONSOLIDATION, SOURCE_IMPORT, SOURCE_USER, TrustLevel, }; pub use storage::BMAI_FORMAT_VERSION; diff --git a/crates/basemyai/src/maintenance/epoch.rs b/crates/basemyai/src/maintenance/epoch.rs new file mode 100644 index 0000000..3d2bfcc --- /dev/null +++ b/crates/basemyai/src/maintenance/epoch.rs @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Dormant V2-03 maintenance planning preconditions (ADR-070 §9). +//! +//! These are value types only: this module deliberately owns no atomic +//! counter and does not wrap [`crate::storage::MemoryStore`]. The live V1 +//! store cannot yet provide a scope mutation epoch or an exact per-member +//! version, and incrementing a second counter outside the product writer's +//! publication gate would create the very second authority ADR-070 forbids. +//! +//! At cutover the coordinator owns the epoch and increments it under the +//! same exclusive product gate as every successful logical publication. A +//! planner then reads `epoch_before`, performs all of its bounded scans, +//! reads `epoch_after`, and may emit exactly one [`MaintenanceChunk`] only +//! when both values match. The handler revalidates that epoch and every +//! member version before staging a deletion. Until those two engine-bound +//! seams exist, no live adaptive-forgetting or expired-GC path constructs +//! these types or claims phantom protection. + +use std::collections::HashSet; + +/// Runtime-local mutation generation for one exact semantic scope. +/// +/// It is intentionally neither serializable nor public. Reopen starts a new +/// runtime-local lineage; this value is not an idempotency key. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ScopeMutationEpoch(u64); + +impl ScopeMutationEpoch { + pub(crate) const INITIAL: Self = Self(0); + + pub(crate) const fn from_raw(value: u64) -> Self { + Self(value) + } + + pub(crate) const fn raw(self) -> u64 { + self.0 + } + + pub(crate) fn checked_next(self) -> Option { + self.0.checked_add(1).map(Self) + } +} + +/// Exact version observed for a member while planning. +/// +/// The eventual native adapter derives this opaque value from the same +/// coherent read state used to hydrate the member. It must change whenever a +/// mutation can invalidate the planner's decision; timestamps or caller ids +/// are not substitutes. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct MaintenanceMemberVersion(u64); + +impl MaintenanceMemberVersion { + pub(crate) const fn from_raw(value: u64) -> Self { + Self(value) + } +} + +/// One member and the exact version used by the maintenance decision. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct VersionedMaintenanceMember { + id: String, + version: MaintenanceMemberVersion, +} + +impl VersionedMaintenanceMember { + pub(crate) fn new(id: String, version: MaintenanceMemberVersion) -> Option { + (!id.is_empty()).then_some(Self { id, version }) + } + + pub(crate) fn id(&self) -> &str { + &self.id + } + + pub(crate) const fn version(&self) -> MaintenanceMemberVersion { + self.version + } +} + +/// Closed maintenance classes that currently produce deletion chunks. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MaintenancePlanKind { + AdaptiveForgetting, + ExpiredGc, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum MaintenancePlanError { + ZeroCapacity, + EpochChanged, + DuplicateMember, + MemberSetChanged, + MemberVersionChanged, +} + +/// Bounded builder kept across the planner's scans. +/// +/// Once full it ignores later victims but the planner must still finish its +/// scan and call [`Self::finish`] with `epoch_after`. Thus memory remains +/// `O(max_items)` without weakening phantom detection. +pub(crate) struct MaintenanceChunkBuilder { + kind: MaintenancePlanKind, + epoch_before: ScopeMutationEpoch, + max_items: usize, + members: Vec, + selected_ids: HashSet, +} + +impl MaintenanceChunkBuilder { + pub(crate) fn new( + kind: MaintenancePlanKind, + epoch_before: ScopeMutationEpoch, + max_items: usize, + ) -> Result { + if max_items == 0 { + return Err(MaintenancePlanError::ZeroCapacity); + } + Ok(Self { + kind, + epoch_before, + max_items, + members: Vec::with_capacity(max_items.min(256)), + selected_ids: HashSet::with_capacity(max_items.min(256)), + }) + } + + /// Offers one victim selected from the coherent scan. Returns `true` + /// when retained in this chunk and `false` once the one-chunk bound is + /// full. Duplicate selected ids are rejected rather than silently + /// weakening the read-set. + pub(crate) fn offer(&mut self, member: VersionedMaintenanceMember) -> Result { + if self.members.len() == self.max_items { + return Ok(false); + } + if !self.selected_ids.insert(member.id.clone()) { + return Err(MaintenancePlanError::DuplicateMember); + } + self.members.push(member); + Ok(true) + } + + /// Seals at most one chunk only after the planner's final epoch read. + pub(crate) fn finish( + self, + epoch_after: ScopeMutationEpoch, + ) -> Result, MaintenancePlanError> { + if epoch_after != self.epoch_before { + return Err(MaintenancePlanError::EpochChanged); + } + if self.members.is_empty() { + return Ok(None); + } + Ok(Some(MaintenanceChunk { + kind: self.kind, + expected_epoch: self.epoch_before, + members: self.members, + })) + } +} + +/// One bounded, runtime-local maintenance intent payload. +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct MaintenanceChunk { + kind: MaintenancePlanKind, + expected_epoch: ScopeMutationEpoch, + members: Vec, +} + +impl MaintenanceChunk { + pub(crate) const fn kind(&self) -> MaintenancePlanKind { + self.kind + } + + pub(crate) const fn expected_epoch(&self) -> ScopeMutationEpoch { + self.expected_epoch + } + + pub(crate) fn members(&self) -> &[VersionedMaintenanceMember] { + &self.members + } + + /// Revalidates immediately before commit under the coordinator's product + /// gate. `current` is the exact, freshly hydrated read-set for the ids in + /// this chunk; missing, extra, duplicate or changed members abort it. + pub(crate) fn revalidate( + &self, + current_epoch: ScopeMutationEpoch, + current: &[VersionedMaintenanceMember], + ) -> Result<(), MaintenancePlanError> { + if current_epoch != self.expected_epoch { + return Err(MaintenancePlanError::EpochChanged); + } + if current.len() != self.members.len() { + return Err(MaintenancePlanError::MemberSetChanged); + } + let mut seen = HashSet::with_capacity(current.len()); + for expected in &self.members { + let Some(actual) = current.iter().find(|member| member.id == expected.id) else { + return Err(MaintenancePlanError::MemberSetChanged); + }; + if !seen.insert(actual.id.as_str()) { + return Err(MaintenancePlanError::MemberSetChanged); + } + if actual.version != expected.version { + return Err(MaintenancePlanError::MemberVersionChanged); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn member(id: &str, version: u64) -> VersionedMaintenanceMember { + VersionedMaintenanceMember::new(id.to_owned(), MaintenanceMemberVersion::from_raw(version)) + .expect("non-empty test member id") + } + + #[test] + fn changed_epoch_discards_the_entire_planned_chunk() { + let before = ScopeMutationEpoch::from_raw(7); + let mut builder = + MaintenanceChunkBuilder::new(MaintenancePlanKind::ExpiredGc, before, 2).expect("non-zero bound"); + assert!(builder.offer(member("a", 1)).expect("first member")); + + assert_eq!( + builder.finish(ScopeMutationEpoch::from_raw(8)), + Err(MaintenancePlanError::EpochChanged) + ); + } + + #[test] + fn planner_emits_only_one_bounded_chunk_but_still_seals_after_scan() { + let epoch = ScopeMutationEpoch::INITIAL; + let mut builder = + MaintenanceChunkBuilder::new(MaintenancePlanKind::AdaptiveForgetting, epoch, 2).expect("non-zero bound"); + assert!(builder.offer(member("a", 1)).expect("first member")); + assert!(builder.offer(member("b", 2)).expect("second member")); + assert!(!builder.offer(member("c", 3)).expect("full chunk ignores later victim")); + + let chunk = builder.finish(epoch).expect("stable scan").expect("non-empty chunk"); + assert_eq!(chunk.kind(), MaintenancePlanKind::AdaptiveForgetting); + assert_eq!(chunk.expected_epoch().raw(), 0); + assert_eq!(chunk.members(), &[member("a", 1), member("b", 2)]); + } + + #[test] + fn duplicate_selected_member_is_rejected() { + let epoch = ScopeMutationEpoch::INITIAL; + let mut builder = + MaintenanceChunkBuilder::new(MaintenancePlanKind::ExpiredGc, epoch, 2).expect("non-zero bound"); + assert!(builder.offer(member("a", 1)).expect("first member")); + assert_eq!( + builder.offer(member("a", 1)), + Err(MaintenancePlanError::DuplicateMember) + ); + } + + #[test] + fn handler_revalidation_checks_epoch_set_and_exact_versions() { + let epoch = ScopeMutationEpoch::from_raw(11); + let mut builder = + MaintenanceChunkBuilder::new(MaintenancePlanKind::ExpiredGc, epoch, 2).expect("non-zero bound"); + assert!(builder.offer(member("a", 4)).expect("first member")); + assert!(builder.offer(member("b", 5)).expect("second member")); + let chunk = builder.finish(epoch).expect("stable scan").expect("chunk"); + + assert_eq!(chunk.revalidate(epoch, &[member("b", 5), member("a", 4)]), Ok(())); + assert_eq!( + chunk.revalidate(epoch, &[member("a", 4)]), + Err(MaintenancePlanError::MemberSetChanged) + ); + assert_eq!( + chunk.revalidate(epoch, &[member("a", 9), member("b", 5)]), + Err(MaintenancePlanError::MemberVersionChanged) + ); + assert_eq!( + chunk.revalidate(ScopeMutationEpoch::from_raw(12), &[member("a", 4), member("b", 5)]), + Err(MaintenancePlanError::EpochChanged) + ); + } + + #[test] + fn epoch_overflow_is_explicit_and_empty_plan_is_noop() { + assert_eq!(ScopeMutationEpoch::from_raw(u64::MAX).checked_next(), None); + let epoch = ScopeMutationEpoch::INITIAL; + let builder = MaintenanceChunkBuilder::new(MaintenancePlanKind::ExpiredGc, epoch, 1).expect("non-zero bound"); + assert_eq!(builder.finish(epoch), Ok(None)); + assert!(matches!( + MaintenanceChunkBuilder::new(MaintenancePlanKind::ExpiredGc, epoch, 0), + Err(MaintenancePlanError::ZeroCapacity) + )); + } +} diff --git a/crates/basemyai/src/maintenance/mod.rs b/crates/basemyai/src/maintenance/mod.rs index 65432a6..f913cdc 100644 --- a/crates/basemyai/src/maintenance/mod.rs +++ b/crates/basemyai/src/maintenance/mod.rs @@ -20,6 +20,7 @@ //! store partagé injecté par le worker. pub(crate) mod adaptive_forgetting; +pub(crate) mod epoch; pub(crate) mod expired_gc; pub use adaptive_forgetting::{ diff --git a/crates/basemyai/src/memory/isolation.rs b/crates/basemyai/src/memory/isolation.rs index 53d0f10..3331822 100644 --- a/crates/basemyai/src/memory/isolation.rs +++ b/crates/basemyai/src/memory/isolation.rs @@ -1,18 +1,90 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Isolation multi-agent. Chaque ligne porte un `agent_id` ; **toute** lecture -//! et écriture sont filtrées par lui **au niveau SQL** (ADR-006). Une fuite -//! cross-agent est un incident de sécurité, pas un bug fonctionnel. +//! Isolation sémantique des namespaces mémoire. Les clés legacy natives portent +//! encore un `agent_id`; toute fuite cross-agent reste un incident de sécurité, +//! pas un bug fonctionnel. L'isolation physique multi-project arrive au hard +//! cut V2-08 et n'est pas revendiquée par cette tranche. +//! +//! ADR-069 ajoute les types sémantiques canoniques Project/Agent/MemoryScope +//! (§1) : `ProjectId`, `MemoryId`, `MemoryScope`, `QualifiedMemoryId`, +//! `PrincipalIssuer`, `PrincipalSubject` et `CallerPrincipal`. Cette tranche +//! (V2-02A) fige la sémantique et les frontières d'autorité ; elle n'introduit +//! aucun format physique, aucune isolation vectorielle structurelle et aucun +//! `ScopeKey` (cf. ADR-069 §8-9). -/// Identifiant d'agent (tenant logique d'une mémoire). +/// Grammaire ASCII partagée par `AgentId` et `ProjectId` (ADR-069 §1) : +/// `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`, 1..=128 octets. +fn is_valid_ascii_namespace_grammar(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.is_empty() || bytes.len() > 128 { + return false; + } + let first = bytes[0]; + if !first.is_ascii_alphanumeric() { + return false; + } + bytes[1..] + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// Validation partagée par `MemoryId`, `PrincipalIssuer` et `PrincipalSubject` +/// (ADR-069 §1) : UTF-8 non vide dans `1..=max_bytes` octets, sans NUL ni +/// caractère de contrôle. Aucune normalisation Unicode ni case folding. +fn is_valid_opaque_utf8(s: &str, max_bytes: usize) -> bool { + let len = s.len(); + if len == 0 || len > max_bytes { + return false; + } + !s.chars().any(|c| c.is_control()) +} + +/// Identifiant de projet (namespace produit, ADR-069 §1). Grammaire ASCII +/// `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`, 1..=128 octets. Égalité byte-exacte et +/// sensible à la casse (dérivée). Construire un `ProjectId` n'accorde aucun +/// droit — cf. `MemoryScope` et l'authorizer pour l'autorisation. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProjectId(String); + +impl ProjectId { + /// Construit un `ProjectId`. `None` si la grammaire ASCII n'est pas + /// respectée (vide, > 128 octets, premier caractère non alphanumérique, + /// caractère hors `[A-Za-z0-9._-]`). + #[must_use] + pub fn new(id: impl Into) -> Option { + let id = id.into(); + if is_valid_ascii_namespace_grammar(&id) { + Some(Self(id)) + } else { + None + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Identifiant d'agent (tenant logique d'une mémoire). Grammaire ASCII +/// `[A-Za-z0-9][A-Za-z0-9._-]{0,127}`, 1..=128 octets (ADR-069 §1, resserre +/// l'ancienne validation "non vide"). Égalité byte-exacte et sensible à la +/// casse (dérivée). Construire un `AgentId` n'accorde aucun droit — le même +/// `AgentId` dans deux `ProjectId` désigne deux scopes différents. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct AgentId(String); impl AgentId { - /// Construit un `AgentId`. Vide => `None` (un agent valide est requis). + /// Construit un `AgentId`. `None` si la grammaire ASCII n'est pas + /// respectée (vide, > 128 octets, premier caractère non alphanumérique, + /// caractère hors `[A-Za-z0-9._-]`). #[must_use] pub fn new(id: impl Into) -> Option { let id = id.into(); - if id.is_empty() { None } else { Some(Self(id)) } + if is_valid_ascii_namespace_grammar(&id) { + Some(Self(id)) + } else { + None + } } #[must_use] @@ -20,3 +92,431 @@ impl AgentId { &self.0 } } + +/// Identifiant de mémoire (ADR-069 §1). UTF-8, 1..=512 octets (mesurés sur la +/// représentation UTF-8, pas en nombre de caractères), sans NUL ni caractère +/// de contrôle. Unique seulement dans son `MemoryScope` — toute référence +/// transverse utilise `QualifiedMemoryId`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MemoryId(String); + +impl MemoryId { + /// Construit un `MemoryId`. `None` si la chaîne est vide, dépasse 512 + /// octets, ou contient un NUL/caractère de contrôle. + #[must_use] + pub fn new(id: impl Into) -> Option { + let id = id.into(); + if is_valid_opaque_utf8(&id, 512) { + Some(Self(id)) + } else { + None + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Scope exact d'une mémoire (ADR-069 §1). Il n'existe aucun scope global : +/// `Private(project, agent)` appartient à un agent dans un projet précis (le +/// même `AgentId` dans deux projets désigne deux scopes différents) ; +/// `Project(project)` appartient au projet, jamais à un agent implicite. +/// +/// `#[non_exhaustive]` : de nouvelles variantes de scope pourront être +/// ajoutées par un futur ADR sans casser les consommateurs existants. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum MemoryScope { + /// Scope privé : appartient à `agent` au sein de `project`. + Private { project: ProjectId, agent: AgentId }, + /// Scope projet : appartient au projet, sans agent implicite. + Project { project: ProjectId }, +} + +/// Identifiant de mémoire qualifié par son scope (ADR-069 §1). Un +/// `MemoryId` nu n'est unique que dans son scope ; `QualifiedMemoryId` porte +/// les deux et sert de référence transverse. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct QualifiedMemoryId { + pub scope: MemoryScope, + pub memory_id: MemoryId, +} + +/// Issuer opaque d'un credential authentifié (ADR-069 §1-2). UTF-8, 1..=256 +/// octets, sans NUL ni caractère de contrôle. Comparaison byte-exacte ; le +/// runtime BaseMyAI ne renormalise jamais issuer/subject après coup — la +/// normalisation spécifique au protocole (OIDC, etc.) est la responsabilité +/// de l'adaptateur transport avant construction d'un `CallerPrincipal`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PrincipalIssuer(String); + +impl PrincipalIssuer { + /// Construit un `PrincipalIssuer`. `None` si la chaîne est vide, dépasse + /// 256 octets, ou contient un NUL/caractère de contrôle. + #[must_use] + pub fn new(id: impl Into) -> Option { + let id = id.into(); + if is_valid_opaque_utf8(&id, 256) { + Some(Self(id)) + } else { + None + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Subject opaque d'un credential authentifié (ADR-069 §1-2). Mêmes bornes et +/// discipline de validation que `PrincipalIssuer`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PrincipalSubject(String); + +impl PrincipalSubject { + /// Construit un `PrincipalSubject`. `None` si la chaîne est vide, dépasse + /// 256 octets, ou contient un NUL/caractère de contrôle. + #[must_use] + pub fn new(id: impl Into) -> Option { + let id = id.into(); + if is_valid_opaque_utf8(&id, 256) { + Some(Self(id)) + } else { + None + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Identité authentifiée du caller sous la paire opaque `(issuer, subject)` +/// (ADR-069 §2). Ce n'est ni un namespace, ni une clé persistée, ni un +/// porteur de grants, ni une preuve cryptographique interprétée par +/// l'engine. +/// +/// Champs privés par construction : seul un adaptateur de transport ayant +/// effectué les vérifications applicables au credential (ou une autorité +/// opérateur locale de confiance pour stdio/in-process) peut construire un +/// `CallerPrincipal`. Un body REST, argument MCP ou DTO de binding ne peut +/// jamais en fournir un directement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallerPrincipal { + issuer: PrincipalIssuer, + subject: PrincipalSubject, +} + +impl CallerPrincipal { + /// Construit un `CallerPrincipal` à partir d'un credential déjà vérifié. + /// + /// # Invariant appelant + /// + /// L'appelant de cette fonction doit avoir déjà effectué les + /// vérifications applicables au credential (signature, expiration, + /// format spécifique au protocole d'authentification, etc.) — ce + /// constructeur ne réalise lui-même aucune vérification cryptographique, + /// ce n'est pas le rôle de ce type. Ce nom explicite marque la frontière + /// de confiance : seul un adaptateur de transport vérifié ou une + /// autorité opérateur locale de confiance doit l'appeler (ADR-069 §2). + #[must_use] + pub fn from_verified_credential(issuer: PrincipalIssuer, subject: PrincipalSubject) -> Self { + Self { issuer, subject } + } + + #[must_use] + pub fn issuer(&self) -> &PrincipalIssuer { + &self.issuer + } + + #[must_use] + pub fn subject(&self) -> &PrincipalSubject { + &self.subject + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- AgentId / ProjectId grammar --- + + #[test] + fn agent_id_rejects_empty() { + assert!(AgentId::new("").is_none()); + } + + #[test] + fn project_id_rejects_empty() { + assert!(ProjectId::new("").is_none()); + } + + #[test] + fn agent_id_rejects_non_alphanumeric_first_char() { + assert!(AgentId::new("-agent").is_none()); + assert!(AgentId::new(".agent").is_none()); + assert!(AgentId::new("_agent").is_none()); + } + + #[test] + fn agent_id_rejects_disallowed_chars() { + assert!(AgentId::new("agent x").is_none()); + assert!(AgentId::new("agent/x").is_none()); + assert!(AgentId::new("agent@x").is_none()); + } + + #[test] + fn agent_id_accepts_valid_grammar() { + assert!(AgentId::new("agent-1").is_some()); + assert!(AgentId::new("Agent_1.2-3").is_some()); + assert!(AgentId::new("a").is_some()); + } + + #[test] + fn project_id_accepts_valid_grammar() { + assert!(ProjectId::new("project-1").is_some()); + assert!(ProjectId::new("P1").is_some()); + } + + #[test] + fn namespace_ids_reject_non_ascii_and_are_case_sensitive() { + assert!(AgentId::new("agent-é").is_none()); + assert!(ProjectId::new("équipe").is_none()); + + let lower_agent = AgentId::new("agent-a").expect("valid lowercase agent id"); + let upper_agent = AgentId::new("Agent-a").expect("valid uppercase agent id"); + assert_ne!(lower_agent, upper_agent); + + let lower_project = ProjectId::new("project-a").expect("valid lowercase project id"); + let upper_project = ProjectId::new("Project-a").expect("valid uppercase project id"); + assert_ne!(lower_project, upper_project); + } + + #[test] + fn agent_id_accepts_exactly_128_bytes() { + let id = format!("a{}", "b".repeat(127)); + assert_eq!(id.len(), 128); + assert!(AgentId::new(id).is_some()); + } + + #[test] + fn agent_id_rejects_129_bytes() { + let id = format!("a{}", "b".repeat(128)); + assert_eq!(id.len(), 129); + assert!(AgentId::new(id).is_none()); + } + + #[test] + fn project_id_accepts_exactly_128_bytes() { + let id = format!("p{}", "q".repeat(127)); + assert_eq!(id.len(), 128); + assert!(ProjectId::new(id).is_some()); + } + + #[test] + fn project_id_rejects_129_bytes() { + let id = format!("p{}", "q".repeat(128)); + assert_eq!(id.len(), 129); + assert!(ProjectId::new(id).is_none()); + } + + // --- MemoryId --- + + #[test] + fn memory_id_rejects_nul() { + assert!(MemoryId::new("abc\0def").is_none()); + } + + #[test] + fn memory_id_rejects_control_char() { + assert!(MemoryId::new("abc\ndef").is_none()); + assert!(MemoryId::new("abc\tdef").is_none()); + assert!(MemoryId::new("abc\u{7f}def").is_none()); + } + + #[test] + fn memory_id_accepts_512_bytes() { + let id = "x".repeat(512); + assert_eq!(id.len(), 512); + assert!(MemoryId::new(id).is_some()); + } + + #[test] + fn memory_id_rejects_513_bytes() { + let id = "x".repeat(513); + assert_eq!(id.len(), 513); + assert!(MemoryId::new(id).is_none()); + } + + #[test] + fn memory_id_counts_multibyte_utf8_in_bytes_not_chars() { + // 'é' encodes as 2 bytes in UTF-8; 300 of them = 600 bytes > 512. + let id = "é".repeat(300); + assert_eq!(id.chars().count(), 300); + assert!(id.len() > 512); + assert!(MemoryId::new(id).is_none()); + + // 250 of them = 500 bytes <= 512, should be accepted. + let id_ok = "é".repeat(250); + assert_eq!(id_ok.len(), 500); + assert!(MemoryId::new(id_ok).is_some()); + } + + #[test] + fn memory_id_rejects_empty() { + assert!(MemoryId::new("").is_none()); + } + + // --- MemoryScope --- + + #[test] + fn memory_scope_private_differs_by_project() { + let agent = AgentId::new("agent-x").expect("valid agent id"); + let p1 = ProjectId::new("p1").expect("valid project id"); + let p2 = ProjectId::new("p2").expect("valid project id"); + let scope1 = MemoryScope::Private { + project: p1, + agent: agent.clone(), + }; + let scope2 = MemoryScope::Private { project: p2, agent }; + assert_ne!(scope1, scope2); + } + + #[test] + fn memory_scope_private_differs_from_project() { + let agent = AgentId::new("agent-x").expect("valid agent id"); + let project = ProjectId::new("p1").expect("valid project id"); + let private = MemoryScope::Private { + project: project.clone(), + agent, + }; + let project_scope = MemoryScope::Project { project }; + assert_ne!(private, project_scope); + } + + // --- QualifiedMemoryId --- + + #[test] + fn qualified_memory_id_equality_by_scope_and_id() { + let project = ProjectId::new("p1").expect("valid project id"); + let scope = MemoryScope::Project { + project: project.clone(), + }; + let memory_id = MemoryId::new("mem-1").expect("valid memory id"); + let q1 = QualifiedMemoryId { + scope: scope.clone(), + memory_id: memory_id.clone(), + }; + let q2 = QualifiedMemoryId { scope, memory_id }; + assert_eq!(q1, q2); + + let other_memory_id = MemoryId::new("mem-2").expect("valid memory id"); + let q3 = QualifiedMemoryId { + scope: MemoryScope::Project { project }, + memory_id: other_memory_id, + }; + assert_ne!(q1, q3); + } + + #[test] + fn qualified_memory_id_differs_by_scope() { + let memory_id = MemoryId::new("mem-1").expect("valid memory id"); + let project = ProjectId::new("p1").expect("valid project id"); + let agent = AgentId::new("agent-x").expect("valid agent id"); + let q_private = QualifiedMemoryId { + scope: MemoryScope::Private { + project: project.clone(), + agent, + }, + memory_id: memory_id.clone(), + }; + let q_project = QualifiedMemoryId { + scope: MemoryScope::Project { project }, + memory_id, + }; + assert_ne!(q_private, q_project); + } + + // --- PrincipalIssuer / PrincipalSubject --- + + #[test] + fn principal_issuer_rejects_empty_nul_and_control() { + assert!(PrincipalIssuer::new("").is_none()); + assert!(PrincipalIssuer::new("abc\0def").is_none()); + assert!(PrincipalIssuer::new("abc\ndef").is_none()); + } + + #[test] + fn principal_issuer_accepts_256_bytes_rejects_257() { + let ok = "x".repeat(256); + assert!(PrincipalIssuer::new(ok).is_some()); + let too_long = "x".repeat(257); + assert!(PrincipalIssuer::new(too_long).is_none()); + } + + #[test] + fn principal_subject_rejects_empty_nul_and_control() { + assert!(PrincipalSubject::new("").is_none()); + assert!(PrincipalSubject::new("abc\0def").is_none()); + assert!(PrincipalSubject::new("abc\ndef").is_none()); + } + + #[test] + fn principal_subject_accepts_256_bytes_rejects_257() { + let ok = "x".repeat(256); + assert!(PrincipalSubject::new(ok).is_some()); + let too_long = "x".repeat(257); + assert!(PrincipalSubject::new(too_long).is_none()); + } + + #[test] + fn principal_bounds_are_measured_in_utf8_bytes() { + let exact = "é".repeat(128); + assert_eq!(exact.len(), 256); + assert!(PrincipalIssuer::new(exact.clone()).is_some()); + assert!(PrincipalSubject::new(exact).is_some()); + + let too_long = "é".repeat(129); + assert_eq!(too_long.len(), 258); + assert!(PrincipalIssuer::new(too_long.clone()).is_none()); + assert!(PrincipalSubject::new(too_long).is_none()); + } + + // --- CallerPrincipal --- + + #[test] + fn caller_principal_equal_when_issuer_and_subject_match() { + let issuer = PrincipalIssuer::new("issuer-a").expect("valid issuer"); + let subject = PrincipalSubject::new("subject-a").expect("valid subject"); + let p1 = CallerPrincipal::from_verified_credential(issuer.clone(), subject.clone()); + let p2 = CallerPrincipal::from_verified_credential(issuer, subject); + assert_eq!(p1, p2); + } + + #[test] + fn caller_principal_differs_by_issuer_or_subject() { + let issuer_a = PrincipalIssuer::new("issuer-a").expect("valid issuer"); + let issuer_b = PrincipalIssuer::new("issuer-b").expect("valid issuer"); + let subject = PrincipalSubject::new("subject-a").expect("valid subject"); + let p1 = CallerPrincipal::from_verified_credential(issuer_a.clone(), subject.clone()); + let p2 = CallerPrincipal::from_verified_credential(issuer_b, subject); + assert_ne!(p1, p2); + + let subject_b = PrincipalSubject::new("subject-b").expect("valid subject"); + let p3 = CallerPrincipal::from_verified_credential(issuer_a, subject_b); + assert_ne!(p1, p3); + } + + #[test] + fn caller_principal_getters_return_expected_values() { + let issuer = PrincipalIssuer::new("issuer-a").expect("valid issuer"); + let subject = PrincipalSubject::new("subject-a").expect("valid subject"); + let principal = CallerPrincipal::from_verified_credential(issuer.clone(), subject.clone()); + assert_eq!(principal.issuer(), &issuer); + assert_eq!(principal.subject(), &subject); + } +} diff --git a/crates/basemyai/src/memory/mod.rs b/crates/basemyai/src/memory/mod.rs index 86d48e2..152dc16 100644 --- a/crates/basemyai/src/memory/mod.rs +++ b/crates/basemyai/src/memory/mod.rs @@ -12,7 +12,9 @@ mod testutil; mod trust; pub use event::{MemoryEvent, MemoryEventKind, MemorySubscription}; -pub use isolation::AgentId; +pub use isolation::{ + AgentId, CallerPrincipal, MemoryId, MemoryScope, PrincipalIssuer, PrincipalSubject, ProjectId, QualifiedMemoryId, +}; pub use layer::{AgentStats, MemoryLayer, Record}; pub use porting::ImportReport; #[cfg(feature = "test-util")] @@ -1109,6 +1111,32 @@ mod tests { Arc::new(NativeMemoryStore::open_ephemeral().expect("open ephemeral store")) } + /// Dropping the last writer handle is deliberately non-blocking — the OS + /// file lock is released by a detached reaper thread, not synchronously + /// by `Drop` itself (see + /// `storage::native_store::coordinator::tests:: + /// dropping_last_writer_handle_is_non_blocking_and_reaper_releases_the_store`). + /// A reopen immediately after `drop` can therefore transiently observe + /// `StoreLocked` — retry for a bounded window instead of asserting on the + /// very first attempt. + async fn retry_after_drop(mut attempt: F) -> Result + where + F: FnMut() -> Fut, + Fut: std::future::Future>, + { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match attempt().await { + Err(MemoryError::Core(basemyai_core::CoreError::StoreLocked)) + if std::time::Instant::now() < deadline => + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + other => return other, + } + } + } + struct TestEmbedder { model: &'static str, dim: usize, @@ -1173,15 +1201,17 @@ mod tests { drop(memory); let raw = basemyai_core::EncryptionKey::raw("memory passphrase"); - let Err(err) = Memory::open_native( - dir.path(), - &raw, - Box::new(TestEmbedder { - model: "test-model-a", - dim: crate::EMBEDDING_DIM, - }), - AgentId::new("raw-agent").expect("valid agent"), - ) + let Err(err) = retry_after_drop(|| { + Memory::open_native( + dir.path(), + &raw, + Box::new(TestEmbedder { + model: "test-model-a", + dim: crate::EMBEDDING_DIM, + }), + AgentId::new("raw-agent").expect("valid agent"), + ) + }) .await else { panic!("raw key must not open a passphrase memory") @@ -1231,10 +1261,13 @@ mod tests { drop(store); assert!( - NativeMemoryStore::open_encrypted(dir.path(), "old full-rotation key").is_err(), + retry_after_drop(|| async { NativeMemoryStore::open_encrypted(dir.path(), "old full-rotation key") }) + .await + .is_err(), "the old key must not open the published generation" ); - NativeMemoryStore::open_encrypted(dir.path(), "new full-rotation key") + retry_after_drop(|| async { NativeMemoryStore::open_encrypted(dir.path(), "new full-rotation key") }) + .await .expect("the new key opens the published generation"); } diff --git a/crates/basemyai/src/runtime/mod.rs b/crates/basemyai/src/runtime/mod.rs new file mode 100644 index 0000000..069561f --- /dev/null +++ b/crates/basemyai/src/runtime/mod.rs @@ -0,0 +1,704 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Runtime authorization boundary for ADR-069. +//! +//! This module is deliberately private. Product APIs accept semantic requests; +//! they never accept or return the capabilities defined here. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::memory::{CallerPrincipal, MemoryScope}; +use crate::{MemoryError, Result}; + +static NEXT_RUNTIME_INSTANCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ScopeAction { + Read, + Create, + Update, + Delete, + Export, + Import, + Maintain, + Declassify, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum OperationClass { + Recall, + Watch, + Create, + Update, + Upsert, + Delete, + Export, + ImportInsertOnly, + ImportReplace, + VerifyReadOnly, + RepairOrRebuildOrGc, + Declassify, +} + +impl OperationClass { + const fn required_actions(self) -> &'static [ScopeAction] { + use ScopeAction::{Create, Delete, Export, Import, Maintain, Read, Update}; + match self { + Self::Recall | Self::Watch => &[Read], + Self::Create => &[Create], + Self::Update => &[Update], + Self::Upsert => &[Create, Update], + Self::Delete => &[Delete], + Self::Export => &[Read, Export], + Self::ImportInsertOnly => &[Import, Create], + Self::ImportReplace => &[Import, Create, Update], + Self::VerifyReadOnly => &[Read, Maintain], + // This class may recreate, rewrite, or remove records. The full + // mutation superset is fixed before the store is inspected. + Self::RepairOrRebuildOrGc => &[Maintain, Create, Update, Delete], + Self::Declassify => &[], + } + } + + const fn is_long_lived(self) -> bool { + matches!(self, Self::Watch) + } +} + +#[derive(Debug, Eq, PartialEq)] +struct RuntimeInstanceId(u64); + +impl RuntimeInstanceId { + fn fresh() -> Self { + let id = NEXT_RUNTIME_INSTANCE + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| current.checked_add(1)) + .expect("runtime instance identifier space exhausted"); + Self(id) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ScopeGrant { + principal: CallerPrincipal, + scope: MemoryScope, + action: ScopeAction, +} + +#[derive(Debug, Eq, PartialEq)] +struct ExactGrant { + scope: MemoryScope, + action: ScopeAction, +} + +#[derive(Debug, Eq, PartialEq)] +struct DeclassificationEdge { + source: MemoryScope, + destination: MemoryScope, +} + +#[derive(Debug, Eq, PartialEq)] +enum AuthorizationLifetime { + Snapshot, + Lease { policy_epoch: u64 }, +} + +#[derive(Debug)] +pub(crate) struct AuthorizedOperation { + runtime: RuntimeInstanceId, + principal: CallerPrincipal, + class: OperationClass, + grants: Vec, + declassifications: Vec, + lifetime: AuthorizationLifetime, +} + +impl AuthorizedOperation { + pub(crate) fn reduce_to_scopes(self, scopes: &[MemoryScope]) -> Result { + if scopes + .iter() + .any(|scope| !self.grants.iter().any(|grant| &grant.scope == scope)) + { + return Err(MemoryError::UnauthorizedScope); + } + let grants = self + .grants + .into_iter() + .filter(|grant| scopes.contains(&grant.scope)) + .collect(); + let declassifications = self + .declassifications + .into_iter() + .filter(|edge| scopes.contains(&edge.source) && scopes.contains(&edge.destination)) + .collect(); + Ok(Self { + grants, + declassifications, + ..self + }) + } + + pub(crate) fn reduce_to_grants(self, grants: &[(MemoryScope, ScopeAction)]) -> Result { + if grants.iter().any(|(scope, action)| { + !self + .grants + .iter() + .any(|grant| grant.scope == *scope && grant.action == *action) + }) { + return Err(MemoryError::UnauthorizedScope); + } + let retained = self + .grants + .into_iter() + .filter(|grant| { + grants + .iter() + .any(|(scope, action)| grant.scope == *scope && grant.action == *action) + }) + .collect(); + Ok(Self { + grants: retained, + // Declassification has its own exact edge authority. A generic + // grant reduction must never retain that independent authority. + declassifications: Vec::new(), + ..self + }) + } +} + +#[derive(Debug)] +pub(crate) struct ScopeAuthorizer { + runtime: RuntimeInstanceId, + policy_epoch: u64, + grants: Vec, +} + +impl ScopeAuthorizer { + pub(crate) fn new() -> Self { + Self { + runtime: RuntimeInstanceId::fresh(), + policy_epoch: 0, + grants: Vec::new(), + } + } + + pub(crate) fn grant(&mut self, principal: CallerPrincipal, scope: MemoryScope, action: ScopeAction) { + let grant = ScopeGrant { + principal, + scope, + action, + }; + if !self.grants.contains(&grant) { + self.grants.push(grant); + self.bump_policy_epoch(); + } + } + + pub(crate) fn revoke(&mut self, principal: &CallerPrincipal, scope: &MemoryScope, action: ScopeAction) { + let old_len = self.grants.len(); + self.grants + .retain(|grant| &grant.principal != principal || &grant.scope != scope || grant.action != action); + if self.grants.len() != old_len { + self.bump_policy_epoch(); + } + } + + pub(crate) fn authorize( + &self, + principal: &CallerPrincipal, + class: OperationClass, + scopes: &[MemoryScope], + ) -> Result { + if class == OperationClass::Declassify { + return Err(MemoryError::ScopeOperationUnsupported("declassification".into())); + } + if scopes.is_empty() { + return Err(MemoryError::UnauthorizedScope); + } + let mut exact = Vec::with_capacity(scopes.len().saturating_mul(class.required_actions().len())); + for scope in scopes { + for &action in class.required_actions() { + if !self.has_grant(principal, scope, action) { + return Err(MemoryError::UnauthorizedScope); + } + exact.push(ExactGrant { + scope: scope.clone(), + action, + }); + } + } + let lifetime = if class.is_long_lived() { + AuthorizationLifetime::Lease { + policy_epoch: self.policy_epoch, + } + } else { + AuthorizationLifetime::Snapshot + }; + Ok(AuthorizedOperation { + runtime: RuntimeInstanceId(self.runtime.0), + principal: principal.clone(), + class, + grants: exact, + declassifications: Vec::new(), + lifetime, + }) + } + + pub(crate) fn validate_for_operation( + &self, + operation: &AuthorizedOperation, + class: OperationClass, + grants: &[(MemoryScope, ScopeAction)], + ) -> Result<()> { + if operation.runtime != self.runtime + || operation.class != class + || grants.iter().any(|(scope, action)| { + !operation + .grants + .iter() + .any(|grant| grant.scope == *scope && grant.action == *action) + }) + { + return Err(MemoryError::UnauthorizedScope); + } + Ok(()) + } + + pub(crate) fn revalidate_lease(&self, operation: AuthorizedOperation) -> Result { + if operation.runtime != self.runtime { + return Err(MemoryError::UnauthorizedScope); + } + match operation.lifetime { + AuthorizationLifetime::Snapshot => Ok(operation), + AuthorizationLifetime::Lease { policy_epoch } if policy_epoch == self.policy_epoch => Ok(operation), + AuthorizationLifetime::Lease { .. } => { + if operation + .grants + .iter() + .any(|grant| !self.has_grant(&operation.principal, &grant.scope, grant.action)) + { + return Err(MemoryError::UnauthorizedScope); + } + Ok(AuthorizedOperation { + lifetime: AuthorizationLifetime::Lease { + policy_epoch: self.policy_epoch, + }, + ..operation + }) + } + } + } + + fn bump_policy_epoch(&mut self) { + self.policy_epoch = self + .policy_epoch + .checked_add(1) + .expect("authorization policy epoch exhausted"); + } + + fn has_grant(&self, principal: &CallerPrincipal, scope: &MemoryScope, action: ScopeAction) -> bool { + self.grants + .iter() + .any(|grant| &grant.principal == principal && &grant.scope == scope && grant.action == action) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StoreAction { + VerifyStructure, + RepairStructure, + RebuildIndexes, + RotateEncryption, + Migrate, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct StoreGrant { + principal: CallerPrincipal, + action: StoreAction, +} + +#[derive(Debug)] +pub(crate) struct AuthorizedStoreOperation { + runtime: RuntimeInstanceId, + action: StoreAction, +} + +#[derive(Debug)] +pub(crate) struct StoreAuthorizer { + runtime: RuntimeInstanceId, + grants: Vec, +} + +impl StoreAuthorizer { + pub(crate) fn new() -> Self { + Self { + runtime: RuntimeInstanceId::fresh(), + grants: Vec::new(), + } + } + + pub(crate) fn grant(&mut self, principal: CallerPrincipal, action: StoreAction) { + let grant = StoreGrant { principal, action }; + if !self.grants.contains(&grant) { + self.grants.push(grant); + } + } + + pub(crate) fn authorize( + &self, + principal: &CallerPrincipal, + action: StoreAction, + ) -> Result { + if !self + .grants + .iter() + .any(|grant| &grant.principal == principal && grant.action == action) + { + return Err(MemoryError::UnauthorizedScope); + } + Ok(AuthorizedStoreOperation { + runtime: RuntimeInstanceId(self.runtime.0), + action, + }) + } + + pub(crate) fn validate_for_action(&self, operation: &AuthorizedStoreOperation, action: StoreAction) -> Result<()> { + if operation.runtime != self.runtime || operation.action != action { + return Err(MemoryError::UnauthorizedScope); + } + Ok(()) + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct LegacyPrivateScope(String); + +#[derive(Debug)] +pub(crate) struct LegacyAuthorizedOperation { + runtime: RuntimeInstanceId, + scope: LegacyPrivateScope, + class: OperationClass, +} + +#[derive(Debug)] +pub(crate) struct LegacyScopeAdapter { + runtime: RuntimeInstanceId, + scope: LegacyPrivateScope, +} + +impl LegacyScopeAdapter { + pub(crate) fn for_agent_bytes(agent: String) -> Result { + if agent.is_empty() { + return Err(MemoryError::InvalidScope("invalid legacy agent namespace".into())); + } + Ok(Self { + runtime: RuntimeInstanceId::fresh(), + scope: LegacyPrivateScope(agent), + }) + } + + pub(crate) fn authorize(&self, class: OperationClass) -> Result { + if class == OperationClass::Declassify { + return Err(MemoryError::ScopeOperationUnsupported("declassification".into())); + } + Ok(LegacyAuthorizedOperation { + runtime: RuntimeInstanceId(self.runtime.0), + scope: LegacyPrivateScope(self.scope.0.clone()), + class, + }) + } + + pub(crate) fn validate_for_operation( + &self, + operation: &LegacyAuthorizedOperation, + class: OperationClass, + ) -> Result<()> { + if operation.runtime != self.runtime || operation.scope != self.scope || operation.class != class { + return Err(MemoryError::UnauthorizedScope); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::{AgentId, PrincipalIssuer, PrincipalSubject, ProjectId}; + + fn principal(name: &str) -> CallerPrincipal { + CallerPrincipal::from_verified_credential( + PrincipalIssuer::new("local").expect("valid issuer"), + PrincipalSubject::new(name).expect("valid subject"), + ) + } + + fn private(project: &str, agent: &str) -> MemoryScope { + MemoryScope::Private { + project: ProjectId::new(project).expect("valid project"), + agent: AgentId::new(agent).expect("valid agent"), + } + } + + #[test] + fn operation_classes_have_static_exact_grants() { + assert_eq!( + OperationClass::Upsert.required_actions(), + &[ScopeAction::Create, ScopeAction::Update] + ); + assert_eq!( + OperationClass::ImportReplace.required_actions(), + &[ScopeAction::Import, ScopeAction::Create, ScopeAction::Update] + ); + assert_eq!( + OperationClass::VerifyReadOnly.required_actions(), + &[ScopeAction::Read, ScopeAction::Maintain] + ); + assert_eq!( + OperationClass::RepairOrRebuildOrGc.required_actions(), + &[ + ScopeAction::Maintain, + ScopeAction::Create, + ScopeAction::Update, + ScopeAction::Delete + ] + ); + } + + #[test] + fn exact_missing_grant_fails_closed() { + let who = principal("alice"); + let scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + auth.grant(who.clone(), scope.clone(), ScopeAction::Create); + assert!(matches!( + auth.authorize(&who, OperationClass::Upsert, &[scope]), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn project_and_private_grants_do_not_imply_each_other() { + let who = principal("alice"); + let project = ProjectId::new("p").expect("valid project"); + let project_scope = MemoryScope::Project { project }; + let private_scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + auth.grant(who.clone(), project_scope.clone(), ScopeAction::Read); + assert!( + auth.authorize(&who, OperationClass::Recall, std::slice::from_ref(&private_scope)) + .is_err() + ); + let mut auth = ScopeAuthorizer::new(); + auth.grant(who.clone(), private_scope, ScopeAction::Read); + assert!(auth.authorize(&who, OperationClass::Recall, &[project_scope]).is_err()); + } + + #[test] + fn capability_is_bound_to_runtime_and_reduction_cannot_expand() { + let who = principal("alice"); + let first = private("p", "a"); + let second = private("p", "b"); + let mut auth_a = ScopeAuthorizer::new(); + let auth_b = ScopeAuthorizer::new(); + auth_a.grant(who.clone(), first.clone(), ScopeAction::Read); + let capability = auth_a + .authorize(&who, OperationClass::Recall, std::slice::from_ref(&first)) + .expect("authorized"); + assert!(matches!( + auth_b.validate_for_operation( + &capability, + OperationClass::Recall, + &[(first.clone(), ScopeAction::Read)], + ), + Err(MemoryError::UnauthorizedScope) + )); + assert!(matches!( + capability.reduce_to_scopes(&[first, second]), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn read_capability_cannot_authorize_write_or_another_class() { + let who = principal("alice"); + let scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + auth.grant(who.clone(), scope.clone(), ScopeAction::Read); + let capability = auth + .authorize(&who, OperationClass::Recall, std::slice::from_ref(&scope)) + .expect("recall authorized"); + + assert!( + auth.validate_for_operation( + &capability, + OperationClass::Recall, + &[(scope.clone(), ScopeAction::Read)], + ) + .is_ok() + ); + assert!(matches!( + auth.validate_for_operation( + &capability, + OperationClass::Recall, + &[(scope.clone(), ScopeAction::Update)], + ), + Err(MemoryError::UnauthorizedScope) + )); + assert!(matches!( + auth.validate_for_operation(&capability, OperationClass::Update, &[(scope, ScopeAction::Read)],), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn grant_reduction_can_remove_but_never_add_an_action() { + let who = principal("alice"); + let scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + auth.grant(who.clone(), scope.clone(), ScopeAction::Read); + auth.grant(who.clone(), scope.clone(), ScopeAction::Export); + let capability = auth + .authorize(&who, OperationClass::Export, std::slice::from_ref(&scope)) + .expect("export authorized"); + let reduced = capability + .reduce_to_grants(&[(scope.clone(), ScopeAction::Read)]) + .expect("read is a subset"); + + assert!(matches!( + auth.validate_for_operation( + &reduced, + OperationClass::Export, + &[(scope.clone(), ScopeAction::Export)], + ), + Err(MemoryError::UnauthorizedScope) + )); + assert!(matches!( + reduced.reduce_to_grants(&[(scope, ScopeAction::Update)]), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn snapshot_survives_revocation_but_watch_lease_does_not() { + let who = principal("alice"); + let scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + auth.grant(who.clone(), scope.clone(), ScopeAction::Read); + let snapshot = auth + .authorize(&who, OperationClass::Recall, std::slice::from_ref(&scope)) + .expect("authorized"); + let lease = auth + .authorize(&who, OperationClass::Watch, std::slice::from_ref(&scope)) + .expect("authorized"); + auth.revoke(&who, &scope, ScopeAction::Read); + assert!(auth.revalidate_lease(snapshot).is_ok()); + assert!(matches!( + auth.revalidate_lease(lease), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn watch_lease_is_bound_to_the_original_principal() { + let alice = principal("alice"); + let bob = principal("bob"); + let scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + auth.grant(alice.clone(), scope.clone(), ScopeAction::Read); + let lease = auth + .authorize(&alice, OperationClass::Watch, std::slice::from_ref(&scope)) + .expect("watch authorized"); + auth.grant(bob, scope.clone(), ScopeAction::Read); + auth.revoke(&alice, &scope, ScopeAction::Read); + + assert!(matches!( + auth.revalidate_lease(lease), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn import_and_maintenance_classes_require_their_static_supersets() { + let who = principal("operator"); + let scope = private("p", "a"); + let mut auth = ScopeAuthorizer::new(); + for action in [ScopeAction::Import, ScopeAction::Create] { + auth.grant(who.clone(), scope.clone(), action); + } + assert!( + auth.authorize(&who, OperationClass::ImportInsertOnly, std::slice::from_ref(&scope)) + .is_ok() + ); + assert!(matches!( + auth.authorize(&who, OperationClass::ImportReplace, std::slice::from_ref(&scope)), + Err(MemoryError::UnauthorizedScope) + )); + + for action in [ + ScopeAction::Read, + ScopeAction::Maintain, + ScopeAction::Update, + ScopeAction::Delete, + ] { + auth.grant(who.clone(), scope.clone(), action); + } + assert!( + auth.authorize(&who, OperationClass::VerifyReadOnly, std::slice::from_ref(&scope)) + .is_ok() + ); + assert!( + auth.authorize(&who, OperationClass::RepairOrRebuildOrGc, &[scope]) + .is_ok() + ); + } + + #[test] + fn store_authority_is_separate_and_runtime_bound() { + let who = principal("operator"); + let mut first = StoreAuthorizer::new(); + let second = StoreAuthorizer::new(); + first.grant(who.clone(), StoreAction::VerifyStructure); + let capability = first.authorize(&who, StoreAction::VerifyStructure).expect("authorized"); + assert_eq!(capability.action, StoreAction::VerifyStructure); + assert!(matches!( + first.validate_for_action(&capability, StoreAction::RepairStructure), + Err(MemoryError::UnauthorizedScope) + )); + assert!(matches!( + second.validate_for_action(&capability, StoreAction::VerifyStructure), + Err(MemoryError::UnauthorizedScope) + )); + } + + #[test] + fn legacy_preserves_out_of_grammar_bytes_and_rejects_declassification() { + let adapter = LegacyScopeAdapter::for_agent_bytes("legacy agent/with spaces".into()).expect("legacy namespace"); + let capability = adapter.authorize(OperationClass::Recall).expect("legacy operation"); + assert_eq!(capability.scope.0, "legacy agent/with spaces"); + assert_eq!(capability.class, OperationClass::Recall); + assert!(adapter.authorize(OperationClass::Declassify).is_err()); + } + + #[test] + fn legacy_preserves_nul_bytes_accepted_by_the_old_namespace() { + let adapter = + LegacyScopeAdapter::for_agent_bytes("legacy\0agent".into()).expect("legacy namespace bytes are opaque"); + let capability = adapter.authorize(OperationClass::Recall).expect("legacy operation"); + assert_eq!(capability.scope.0.as_bytes(), b"legacy\0agent"); + } + + #[test] + fn legacy_capability_is_runtime_and_operation_bound() { + let first = LegacyScopeAdapter::for_agent_bytes("agent-a".into()).expect("legacy namespace"); + let second = LegacyScopeAdapter::for_agent_bytes("agent-a".into()).expect("legacy namespace"); + let capability = first.authorize(OperationClass::Recall).expect("legacy recall"); + + assert!(matches!( + first.validate_for_operation(&capability, OperationClass::Update), + Err(MemoryError::UnauthorizedScope) + )); + assert!(matches!( + second.validate_for_operation(&capability, OperationClass::Recall), + Err(MemoryError::UnauthorizedScope) + )); + } +} diff --git a/crates/basemyai/src/storage/integrity.rs b/crates/basemyai/src/storage/integrity.rs index 8e630d1..aac7c91 100644 --- a/crates/basemyai/src/storage/integrity.rs +++ b/crates/basemyai/src/storage/integrity.rs @@ -259,18 +259,88 @@ pub async fn reembed_all_container( #[cfg(test)] mod tests { use super::*; + use crate::memory::HashEmbedder; use crate::storage::NativeMemoryStore; + fn assert_store_locked(result: Result) { + assert!( + matches!(result, Err(MemoryError::Core(basemyai_core::CoreError::StoreLocked))), + "a live writer must make the offline opener fail closed" + ); + } + + /// Dropping the last writer handle is deliberately non-blocking — the OS + /// file lock is released by a detached reaper thread, not synchronously + /// by `Drop` itself (see + /// `native_store::coordinator::tests:: + /// dropping_last_writer_handle_is_non_blocking_and_reaper_releases_the_store`). + /// An offline opener immediately after `drop` can therefore transiently + /// observe `StoreLocked` — retry for a bounded window instead of + /// asserting on the very first attempt. + async fn retry_after_drop(mut attempt: F) -> Result + where + F: FnMut() -> Fut, + Fut: std::future::Future>, + { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match attempt().await { + Err(MemoryError::Core(basemyai_core::CoreError::StoreLocked)) + if std::time::Instant::now() < deadline => + { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + other => return other, + } + } + } + + fn artifact_snapshot(root: &Path) -> Vec<(std::path::PathBuf, Vec)> { + fn visit(root: &Path, directory: &Path, files: &mut Vec<(std::path::PathBuf, Vec)>) { + let mut entries = std::fs::read_dir(directory) + .expect("read store directory") + .collect::, _>>() + .expect("read store entries"); + entries.sort_by_key(std::fs::DirEntry::path); + for entry in entries { + let path = entry.path(); + let metadata = entry.metadata().expect("read artifact metadata"); + if metadata.is_dir() { + visit(root, &path, files); + } else if path.file_name().is_some_and(|name| name == ".basemyai.lock") { + // Windows denies reads while the writer owns the byte-range + // lock. The empty coordination file is not a published + // store artifact; its continued lock is asserted by every + // StoreLocked outcome above. + continue; + } else { + files.push(( + path.strip_prefix(root) + .expect("artifact stays below root") + .to_path_buf(), + std::fs::read(&path).expect("read artifact bytes"), + )); + } + } + } + + let mut files = Vec::new(); + visit(root, root, &mut files); + files + } + #[tokio::test] async fn passphrase_store_supports_verify_rebuild_and_compact() { let dir = tempfile::tempdir().expect("tempdir"); drop(NativeMemoryStore::open_with_passphrase(dir.path(), "human passphrase").expect("create store")); - let report = verify_container( - dir.path(), - EncryptionKey::passphrase("human passphrase"), - VerifyMode::FullLogical, - ) + let report = retry_after_drop(|| { + verify_container( + dir.path(), + EncryptionKey::passphrase("human passphrase"), + VerifyMode::FullLogical, + ) + }) .await .expect("verify passphrase store"); assert!(report.healthy, "verify errors: {:?}", report.errors); @@ -283,6 +353,48 @@ mod tests { .expect("compact passphrase store"); } + #[tokio::test] + async fn live_writer_refuses_second_opener_and_every_offline_container_opener_without_publication() { + const KEY: &str = "integrity live writer key"; + + let dir = tempfile::tempdir().expect("fresh temporary directory"); + let _live = NativeMemoryStore::open_encrypted(dir.path(), KEY).expect("first writer creates fresh store"); + let before = artifact_snapshot(dir.path()); + + assert_store_locked(NativeMemoryStore::open_encrypted(dir.path(), KEY)); + assert_store_locked(verify_container(dir.path(), EncryptionKey::raw(KEY), VerifyMode::FullLogical).await); + assert_store_locked(rebuild_indexes_container(dir.path(), EncryptionKey::raw(KEY)).await); + assert_store_locked(compact_container(dir.path(), EncryptionKey::raw(KEY)).await); + assert_store_locked( + reembed_missing_container(dir.path(), EncryptionKey::raw(KEY), Box::new(HashEmbedder::new())).await, + ); + assert_store_locked( + reembed_ids_container( + dir.path(), + EncryptionKey::raw(KEY), + "agent-a", + vec!["memory-a".to_string()], + Box::new(HashEmbedder::new()), + ) + .await, + ); + assert_store_locked( + reembed_all_container( + dir.path(), + EncryptionKey::raw(KEY), + "agent-a", + Box::new(HashEmbedder::new()), + ) + .await, + ); + + assert_eq!( + artifact_snapshot(dir.path()), + before, + "every refused opener must fail before publishing or growing any store artifact" + ); + } + /// N13/R7 — décision de cadrage explicite (pas une course concurrente, /// voir `docs/architecture/ENGINE-TARGET-ARCHITECTURE.md` §R7) : /// `verify_container` contourne délibérément le moteur vivant @@ -356,8 +468,20 @@ mod tests { assert_eq!(before.edges.len(), 1); // À froid, aucun écrivain actif : verify_store en FullLogical - // certifie le même état disque que celui capturé ci-dessus. - let report = basemyai_engine::verify_store(dir.path(), None, VerifyMode::FullLogical).expect("verify"); + // certifie le même état disque que celui capturé ci-dessus. Le + // verrou fichier du store abandonné plus haut se libère sur un + // thread reaper détaché (`Drop` non-bloquant) — pas forcément avant + // ce point, donc quelques tentatives peuvent transitoirement voir + // `StoreLocked` (même raison que `retry_after_drop`). + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let report = loop { + match basemyai_engine::verify_store(dir.path(), None, VerifyMode::FullLogical) { + Err(basemyai_engine::EngineError::StoreLocked { .. }) if std::time::Instant::now() < deadline => { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + other => break other.expect("verify"), + } + }; assert!(report.healthy, "verify errors: {:?}", report.errors); // Réouverture fraîche (WAL replay) : l'export doit reconstruire diff --git a/crates/basemyai/src/storage/native_store/coordinator.rs b/crates/basemyai/src/storage/native_store/coordinator.rs new file mode 100644 index 0000000..7b09149 --- /dev/null +++ b/crates/basemyai/src/storage/native_store/coordinator.rs @@ -0,0 +1,3246 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Dormant V2-03 product-owner topology. +//! +//! This module deliberately has no route from [`super::NativeMemoryStore`] +//! yet. Constructing it moves the complete [`super::NativeInner`] into one +//! product state cell, while the live V1 store keeps using its existing +//! owner. Keeping the constructor private and unrouted makes the preparatory +//! tranche useful without creating two write authorities before the vertical +//! cutover required by ADR-070. +//! +//! This is not the V2-03 cutover: live reads are not routed through +//! `ReadGateway` and no product handler is reachable from +//! `NativeMemoryStore`. The dormant owner does exercise the final ownership +//! topology, including consuming `NativeInner` for clean `Engine::close` or +//! terminal no-flush drop; activating it still has to land atomically with +//! removal of the legacy `with_inner*` paths. + +use std::collections::HashMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; +use std::thread::JoinHandle; + +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot}; + +use super::NativeInner; +use crate::memory::{CallerPrincipal, PrincipalIssuer, PrincipalSubject}; +use crate::runtime::{ + AuthorizedStoreOperation, LegacyAuthorizedOperation, LegacyScopeAdapter, OperationClass, StoreAction, + StoreAuthorizer, +}; +use basemyai_engine::idx::memory::MemoryCommitOutcome; +use basemyai_engine::{ + Argon2idProfile, Batch, EngineCommitOutcome, GraphEdgeMeta, GraphEntity, MemoryRecord, NewMemoryRecord, + SequenceRange, StructuralCandidateOwnership, StructuralCommitOutcome, StructuralCommitPhase, WalCommitPhase, +}; + +const OPEN: u8 = 0; +const CLOSING: u8 = 1; +const DRAINING: u8 = 2; +const CLOSED: u8 = 3; +const TERMINAL_DRAINING: u8 = 4; +const TERMINAL_CLOSED: u8 = 5; +const MAX_INTENT_ITEMS: usize = 256; +const MAX_INTENT_BYTES: usize = 8 * 1024 * 1024; +const MAX_ROTATION_SECRET_BYTES: usize = 64 * 1024; + +/// Cellule produit partagée par le read gateway et l'unique owner writer. +struct ProductStateCell { + state: RwLock>, + token_identity: Arc<()>, +} + +impl ProductStateCell { + fn new(state: NativeInner) -> (Arc, ProductWriteToken) { + let token_identity = Arc::new(()); + let token = ProductWriteToken { + identity: Arc::clone(&token_identity), + }; + ( + Arc::new(Self { + state: RwLock::new(Some(state)), + token_identity, + }), + token, + ) + } + + fn lock_for_owner<'a>( + &'a self, + token: &mut ProductWriteToken, + ) -> Result>, CoordinatorError> { + if !Arc::ptr_eq(&self.token_identity, &token.identity) { + return Err(CoordinatorError::WrongRuntime); + } + self.state.write().map_err(|_| CoordinatorError::Terminal) + } + + fn take_for_clean_shutdown(&self, token: &mut ProductWriteToken) -> Result { + if !Arc::ptr_eq(&self.token_identity, &token.identity) { + return Err(CoordinatorError::WrongRuntime); + } + let mut guard = self.state.write().map_err(|_| CoordinatorError::Terminal)?; + guard.take().ok_or(CoordinatorError::Closed) + } + + /// Terminal-only ownership salvage. A poisoned guard is never reused for + /// reads or writes: after the one-way engine terminal transition, it is + /// consumed solely so dropping `NativeInner` can join workers and release + /// the writer lock without publishing. + fn take_for_abort_shutdown(&self, token: &mut ProductWriteToken) -> Result, CoordinatorError> { + if !Arc::ptr_eq(&self.token_identity, &token.identity) { + return Err(CoordinatorError::WrongRuntime); + } + let mut guard = match self.state.write() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + Ok(guard.take()) + } +} + +/// Capability de mutation créée avec la cellule, non clonable et non +/// reconstructible hors de ce module. +struct ProductWriteToken { + identity: Arc<()>, +} + +/// Façade read-only. La borne `'static` empêche une référence/guard emprunté +/// à l'état produit de survivre à l'appel. +#[derive(Clone)] +pub(super) struct ReadGateway { + cell: Arc, + core: std::sync::Weak, +} + +impl ReadGateway { + fn read_sync(&self, operation: impl FnOnce(&NativeInner) -> T) -> Result { + let core = self.core.upgrade().ok_or(CoordinatorError::Terminal)?; + lifecycle_result(core.lifecycle.load(Ordering::Acquire))?; + let guard = self.cell.state.read().map_err(|_| CoordinatorError::Terminal)?; + // Recheck after the product guard acquisition: a close or terminal + // transition racing the first check must not start a new read. + lifecycle_result(core.lifecycle.load(Ordering::Acquire))?; + let state = guard.as_ref().ok_or(CoordinatorError::Closed)?; + // The shared guard plus the post-acquisition Open check linearises + // this read before a racing close. Once admitted it completes on the + // old coherent state; close waits for the guard instead of rewriting + // the result to Closed afterwards. + Ok(operation(state)) + } + + pub(super) async fn read( + &self, + operation: impl FnOnce(&NativeInner) -> T + Send + 'static, + ) -> Result { + let gateway = self.clone(); + tokio::task::spawn_blocking(move || gateway.read_sync(operation)) + .await + .map_err(|_| CoordinatorError::Terminal)? + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct WriteRequestId { + runtime: u64, + ordinal: u64, +} + +#[derive(Debug)] +pub(super) enum CoordinatorOutcome { + Aborted { + request: WriteRequestId, + burned_sequence_range: Option, + cause: Option, + }, + Committed { + request: WriteRequestId, + sequence_range: Option, + value: WriteValue, + }, + OutcomeUnknown { + request: WriteRequestId, + phase: WalCommitPhase, + cause: String, + }, + DurableReopenRequired { + request: WriteRequestId, + sequence_range: SequenceRange, + cause: String, + }, + StructuralReopenRequired { + request: WriteRequestId, + phase: StructuralCommitPhase, + candidate_ownership: StructuralCandidateOwnership, + cause: String, + }, +} + +impl CoordinatorOutcome { + fn is_terminal(&self) -> bool { + matches!( + self, + Self::OutcomeUnknown { .. } | Self::DurableReopenRequired { .. } | Self::StructuralReopenRequired { .. } + ) + } +} + +#[derive(Debug)] +pub(super) enum WriteValue { + None, + VectorIds(Vec), + Count(u64), + Present(bool), + Text(String), + ImportChunk(ImportChunkReport), + PurgeStep(PurgeStep), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum CoordinatorError { + Closed, + Terminal, + WrongRuntime, + IntentTooLarge { items: usize, bytes: usize }, + InvalidIntent, + Unauthorized, + CloseFailed(String), +} + +fn lifecycle_result(lifecycle: u8) -> Result<(), CoordinatorError> { + match lifecycle { + OPEN => Ok(()), + CLOSING | DRAINING | CLOSED => Err(CoordinatorError::Closed), + TERMINAL_DRAINING | TERMINAL_CLOSED => Err(CoordinatorError::Terminal), + _ => Err(CoordinatorError::Terminal), + } +} + +/// Capability privée liée à l'authorizer exact du coordinateur. Elle ne +/// peut être construite qu'après la validation ADR-069 et est revalidée par +/// le thread owner avant toute lecture dépendante du store. +struct BoundAuthorization { + coordinator_runtime: u64, + agent: String, + adapter: LegacyScopeAdapter, + operation: LegacyAuthorizedOperation, +} + +struct BoundStoreAuthorization { + coordinator_runtime: u64, + action: StoreAction, + operation: AuthorizedStoreOperation, +} + +/// Queue-owned rotation material. Debug never reveals bytes and Drop wipes +/// the initialized allocation before releasing it. +struct OwnedRotationSecret(Vec); + +impl OwnedRotationSecret { + fn new(bytes: Vec) -> Result { + if bytes.is_empty() || bytes.len() > MAX_ROTATION_SECRET_BYTES { + return Err(CoordinatorError::InvalidIntent); + } + Ok(Self(bytes)) + } + + fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +impl std::fmt::Debug for OwnedRotationSecret { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OwnedRotationSecret") + .field("len", &self.0.len()) + .finish_non_exhaustive() + } +} + +impl Drop for OwnedRotationSecret { + fn drop(&mut self) { + self.0.fill(0); + } +} + +#[derive(Debug)] +enum LiveStructural { + KeyLight { + secret: OwnedRotationSecret, + }, + PassphraseLight { + secret: OwnedRotationSecret, + profile: Argon2idProfile, + }, + KeyFull { + secret: OwnedRotationSecret, + }, + PassphraseFull { + secret: OwnedRotationSecret, + profile: Argon2idProfile, + }, +} + +impl LiveStructural { + fn secret_len(&self) -> usize { + match self { + Self::KeyLight { secret } + | Self::PassphraseLight { secret, .. } + | Self::KeyFull { secret } + | Self::PassphraseFull { secret, .. } => secret.0.len(), + } + } +} + +#[derive(Debug)] +pub(super) struct OwnedMemoryPut { + pub(super) id: String, + pub(super) layer: String, + pub(super) content: String, + pub(super) source: String, + pub(super) valid_from: i64, + pub(super) valid_until: Option, + pub(super) importance: f64, + pub(super) last_access: i64, + pub(super) embedding: Vec, +} + +#[derive(Debug)] +pub(super) struct OwnedForgetRecord { + pub(super) id: String, + pub(super) vec_id: u64, + pub(super) valid_until: Option, +} + +#[derive(Debug)] +pub(super) struct OwnedGraphEntity { + pub(super) id: String, + pub(super) entity: GraphEntity, +} + +#[derive(Debug)] +pub(super) struct OwnedGraphEdge { + pub(super) src: String, + pub(super) relation: String, + pub(super) dst: String, + pub(super) meta: GraphEdgeMeta, +} + +#[derive(Debug)] +pub(super) struct OwnedGraphEdgeUpsert { + pub(super) src: String, + pub(super) relation: String, + pub(super) dst: String, + pub(super) weight: f64, + pub(super) now: i64, + pub(super) source: basemyai_engine::GraphSource, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct ImportChunkReport { + pub(super) inserted: usize, + pub(super) skipped: usize, +} + +#[derive(Debug)] +pub(super) enum AgentPurgeCursor { + Memory { + cursor: Option, + epoch: u64, + }, + Graph { + cursor: Option, + epoch: u64, + }, + Finalize { + epoch: u64, + }, +} + +#[derive(Debug)] +pub(super) enum PurgeStep { + Continue(AgentPurgeCursor), + Complete, +} + +/// Union fermée : aucun `FnOnce` arbitraire et aucun stage emprunté ne +/// traverse la queue. Chaque variante possède toutes ses données. +enum WriteIntent { + Fence, + MemoryPutBatch { + authorization: BoundAuthorization, + agent: String, + items: Vec, + }, + MemoryUpdate { + authorization: BoundAuthorization, + agent: String, + id: String, + record: MemoryRecord, + }, + MemoryTouch { + authorization: BoundAuthorization, + agent: String, + ids: Vec, + now: i64, + }, + MemoryForgetSingle { + authorization: BoundAuthorization, + agent: String, + id: String, + }, + MemoryForgetChunk { + authorization: BoundAuthorization, + agent: String, + records: Vec, + }, + MemoryPurgeChunk { + authorization: BoundAuthorization, + agent: String, + cursor: Option, + expected_epoch: Option, + }, + GraphPurgeChunk { + authorization: BoundAuthorization, + agent: String, + cursor: Option, + expected_epoch: u64, + }, + MemoryPurgeFinalize { + authorization: BoundAuthorization, + agent: String, + expected_epoch: u64, + }, + GraphEntityBatch { + authorization: BoundAuthorization, + agent: String, + entities: Vec, + }, + GraphEdgeBatch { + authorization: BoundAuthorization, + agent: String, + edges: Vec, + }, + GraphEdgeUpsert { + authorization: BoundAuthorization, + agent: String, + edge: OwnedGraphEdgeUpsert, + }, + ImportMemoryBatch { + authorization: BoundAuthorization, + agent: String, + items: Vec, + }, + ImportGraphEntityBatch { + authorization: BoundAuthorization, + agent: String, + entities: Vec, + }, + ImportGraphEdgeBatch { + authorization: BoundAuthorization, + agent: String, + edges: Vec, + }, + MetaEnsure { + authorization: BoundStoreAuthorization, + key: Vec, + value: String, + }, + LiveStructural { + authorization: BoundStoreAuthorization, + operation: LiveStructural, + }, + #[cfg(test)] + TestRecord(TestRecordIntent), +} + +impl WriteIntent { + fn authorization(&self) -> Option<&BoundAuthorization> { + match self { + Self::MemoryPutBatch { authorization, .. } + | Self::MemoryUpdate { authorization, .. } + | Self::MemoryTouch { authorization, .. } + | Self::MemoryForgetSingle { authorization, .. } + | Self::MemoryForgetChunk { authorization, .. } + | Self::MemoryPurgeChunk { authorization, .. } + | Self::GraphPurgeChunk { authorization, .. } + | Self::MemoryPurgeFinalize { authorization, .. } + | Self::GraphEntityBatch { authorization, .. } + | Self::GraphEdgeBatch { authorization, .. } + | Self::GraphEdgeUpsert { authorization, .. } + | Self::ImportMemoryBatch { authorization, .. } + | Self::ImportGraphEntityBatch { authorization, .. } + | Self::ImportGraphEdgeBatch { authorization, .. } => Some(authorization), + Self::Fence | Self::LiveStructural { .. } | Self::MetaEnsure { .. } => None, + #[cfg(test)] + Self::TestRecord(_) => None, + } + } + + fn store_authorization(&self) -> Option<&BoundStoreAuthorization> { + match self { + Self::LiveStructural { authorization, .. } | Self::MetaEnsure { authorization, .. } => Some(authorization), + _ => None, + } + } + + fn footprint(&self) -> (usize, usize) { + match self { + Self::Fence => (0, 0), + Self::MemoryPutBatch { agent, items, .. } => ( + items.len(), + saturating_size( + agent.len(), + items.iter().map(|item| { + saturating_size( + 64, + [ + item.id.len(), + item.layer.len(), + item.content.len(), + item.source.len(), + item.embedding.len().saturating_mul(size_of::()), + ], + ) + }), + ), + ), + Self::MemoryUpdate { agent, id, record, .. } => ( + 1, + saturating_size( + 96, + [ + agent.len(), + id.len(), + record.content.len(), + record.layer.len(), + record.source.len(), + ], + ), + ), + Self::MemoryTouch { agent, ids, .. } => ( + ids.len(), + saturating_size( + agent.len().saturating_add(ids.len().saturating_mul(32)), + ids.iter().map(String::len), + ), + ), + Self::MemoryForgetSingle { agent, id, .. } => (1, saturating_size(64, [agent.len(), id.len()])), + Self::MemoryForgetChunk { agent, records, .. } => ( + records.len(), + saturating_size( + agent.len(), + records.iter().map(|record| record.id.len().saturating_add(64)), + ), + ), + Self::MemoryPurgeChunk { agent, .. } + | Self::GraphPurgeChunk { agent, .. } + | Self::MemoryPurgeFinalize { agent, .. } => (1, agent.len().saturating_add(128)), + Self::GraphEntityBatch { agent, entities, .. } => ( + entities.len(), + saturating_size( + agent.len(), + entities.iter().map(|item| { + saturating_size(96, [item.id.len(), item.entity.kind.len(), item.entity.label.len()]) + }), + ), + ), + Self::GraphEdgeBatch { agent, edges, .. } => ( + edges.len(), + saturating_size( + agent.len(), + edges + .iter() + .map(|item| saturating_size(96, [item.src.len(), item.relation.len(), item.dst.len()])), + ), + ), + Self::GraphEdgeUpsert { agent, edge, .. } => ( + 1, + saturating_size(96, [agent.len(), edge.src.len(), edge.relation.len(), edge.dst.len()]), + ), + Self::ImportMemoryBatch { agent, items, .. } => ( + items.len(), + saturating_size( + agent.len(), + items.iter().map(|item| { + saturating_size( + 64, + [ + item.id.len(), + item.layer.len(), + item.content.len(), + item.source.len(), + item.embedding.len().saturating_mul(size_of::()), + ], + ) + }), + ), + ), + Self::ImportGraphEntityBatch { agent, entities, .. } => ( + entities.len(), + saturating_size( + agent.len(), + entities.iter().map(|item| { + saturating_size(96, [item.id.len(), item.entity.kind.len(), item.entity.label.len()]) + }), + ), + ), + Self::ImportGraphEdgeBatch { agent, edges, .. } => ( + edges.len(), + saturating_size( + agent.len(), + edges + .iter() + .map(|item| saturating_size(96, [item.src.len(), item.relation.len(), item.dst.len()])), + ), + ), + Self::MetaEnsure { key, value, .. } => (1, key.len().saturating_add(value.len()).saturating_add(64)), + Self::LiveStructural { operation, .. } => (1, operation.secret_len()), + #[cfg(test)] + Self::TestRecord(_) => (1, 0), + } + } + + fn expected_class(&self) -> Option { + match self { + Self::MemoryPutBatch { .. } + | Self::GraphEntityBatch { .. } + | Self::GraphEdgeBatch { .. } + | Self::GraphEdgeUpsert { .. } => Some(OperationClass::Upsert), + Self::ImportMemoryBatch { .. } + | Self::ImportGraphEntityBatch { .. } + | Self::ImportGraphEdgeBatch { .. } => Some(OperationClass::ImportInsertOnly), + Self::MemoryUpdate { .. } | Self::MemoryTouch { .. } => Some(OperationClass::Update), + Self::MemoryForgetSingle { .. } | Self::MemoryForgetChunk { .. } => Some(OperationClass::Delete), + Self::MemoryPurgeChunk { .. } | Self::GraphPurgeChunk { .. } | Self::MemoryPurgeFinalize { .. } => { + Some(OperationClass::RepairOrRebuildOrGc) + } + Self::Fence | Self::LiveStructural { .. } | Self::MetaEnsure { .. } => None, + #[cfg(test)] + Self::TestRecord(_) => None, + } + } + + fn agent(&self) -> Option<&str> { + match self { + Self::MemoryPutBatch { agent, .. } + | Self::MemoryUpdate { agent, .. } + | Self::MemoryTouch { agent, .. } + | Self::MemoryForgetSingle { agent, .. } + | Self::MemoryForgetChunk { agent, .. } + | Self::MemoryPurgeChunk { agent, .. } + | Self::GraphPurgeChunk { agent, .. } + | Self::MemoryPurgeFinalize { agent, .. } + | Self::GraphEntityBatch { agent, .. } + | Self::GraphEdgeBatch { agent, .. } + | Self::GraphEdgeUpsert { agent, .. } + | Self::ImportMemoryBatch { agent, .. } + | Self::ImportGraphEntityBatch { agent, .. } + | Self::ImportGraphEdgeBatch { agent, .. } => Some(agent), + Self::Fence | Self::LiveStructural { .. } | Self::MetaEnsure { .. } => None, + #[cfg(test)] + Self::TestRecord(_) => None, + } + } + + fn validate_shape(&self) -> Result<(), CoordinatorError> { + if let Self::LiveStructural { operation, .. } = self + && (operation.secret_len() == 0 || operation.secret_len() > MAX_ROTATION_SECRET_BYTES) + { + return Err(CoordinatorError::InvalidIntent); + } + if let Self::MemoryForgetChunk { records, .. } = self { + let mut ids = std::collections::HashSet::with_capacity(records.len()); + if records.iter().any(|record| !ids.insert(record.id.as_str())) { + return Err(CoordinatorError::InvalidIntent); + } + } + Ok(()) + } + + fn requires_finalization(&self) -> bool { + match self { + Self::Fence => false, + #[cfg(test)] + Self::TestRecord(_) => false, + _ => true, + } + } + + fn expected_store_action(&self) -> Option { + match self { + Self::LiveStructural { .. } => Some(StoreAction::RotateEncryption), + Self::MetaEnsure { .. } => Some(StoreAction::Migrate), + _ => None, + } + } +} + +fn saturating_size(scalar: usize, parts: impl IntoIterator) -> usize { + parts.into_iter().fold(scalar, |total, part| total.saturating_add(part)) +} + +#[cfg(test)] +struct TestRecordIntent { + marker: u64, + log: Arc>>, + entered: Option>, + release: Option>, + panic: bool, +} + +#[cfg(test)] +struct TestFinalizationSink { + receiver_present: std::sync::atomic::AtomicBool, + revoked: std::sync::atomic::AtomicBool, + fail: std::sync::atomic::AtomicBool, + panic: std::sync::atomic::AtomicBool, + attempts: Mutex>, + entered: Mutex>>, + release: Mutex>>, +} + +#[cfg(test)] +impl TestFinalizationSink { + fn deliver(&self, manifest: &FinalizationManifest) -> Result<(), ()> { + if !self.receiver_present.load(Ordering::Acquire) || self.revoked.load(Ordering::Acquire) { + return Ok(()); + } + self.attempts + .lock() + .expect("finalization attempts lock remains healthy") + .push(manifest.request); + if let Some(entered) = self + .entered + .lock() + .expect("finalization entered lock remains healthy") + .as_ref() + { + let _ = entered.send(manifest.request); + } + if let Some(release) = self + .release + .lock() + .expect("finalization release lock remains healthy") + .take() + { + let _ = release.recv(); + } + assert!(!self.panic.load(Ordering::Acquire), "injected finalization sink panic"); + if self.fail.load(Ordering::Acquire) { + Err(()) + } else { + Ok(()) + } + } +} + +struct AdmittedIntent { + request: WriteRequestId, + intent: WriteIntent, + completion: oneshot::Sender>, + // Le slot reste détenu de l'admission à l'outcome terminal, même si le + // caller abandonne son ticket. + _permit: OwnedSemaphorePermit, + // Réservé avant admission/WAL et transféré au manifeste uniquement après + // `Committed`. Tous les autres verdicts libèrent ce slot par RAII. + finalization: Option, +} + +struct FinalizationReservation { + _permit: OwnedSemaphorePermit, +} + +struct FinalizationManifest { + request: WriteRequestId, + _reservation: FinalizationReservation, +} + +#[derive(Clone)] +enum FinalizationSink { + /// Runtime dormant : aucun bus produit n'est routé, donc absence de + /// receiver est un résultat normal et ne livre rien. + Dormant, + #[cfg(test)] + Test(Arc), +} + +impl FinalizationSink { + fn deliver(&self, manifest: &FinalizationManifest) -> Result<(), ()> { + match self { + Self::Dormant => { + let _ = manifest; + Ok(()) + } + #[cfg(test)] + Self::Test(sink) => sink.deliver(manifest), + } + } +} + +struct FinalizationOwner { + sender: std::sync::mpsc::Sender, + handle: JoinHandle<()>, +} + +impl FinalizationOwner { + fn spawn(sink: FinalizationSink) -> Self { + let (sender, receiver) = std::sync::mpsc::channel(); + let handle = std::thread::Builder::new() + .name("basemyai-finalizer".into()) + .spawn(move || finalizer_loop(receiver, sink)) + .expect("the bounded finalization worker must start"); + Self { sender, handle } + } + + fn enqueue(&self, manifest: FinalizationManifest) { + // A stopped sink cannot change an already-Committed verdict. SendError + // returns and drops the manifest, releasing its reservation. + let _ = self.sender.send(manifest); + } + + fn shutdown(self) { + drop(self.sender); + let _ = self.handle.join(); + } +} + +fn finalizer_loop(receiver: std::sync::mpsc::Receiver, sink: FinalizationSink) { + while let Ok(manifest) = receiver.recv() { + // Sink absence, revocation, error and panic are finalization results, + // never writer-health transitions. Manifest Drop always returns the + // reserved capacity exactly once. + let _ = catch_unwind(AssertUnwindSafe(|| sink.deliver(&manifest))); + } +} + +enum OwnerCommand { + Intent(Box), + Close, +} + +struct AdmissionState { + open: bool, + next_ordinal: u64, + sender: mpsc::UnboundedSender, +} + +struct CoordinatorCore { + runtime: u64, + store_principal: CallerPrincipal, + store_authorizer: StoreAuthorizer, + permits: Arc, + finalization_permits: Arc, + admission: Mutex, + lifecycle: AtomicU8, + close_result: Mutex>>, + lifecycle_changed: Notify, + owner: Mutex>>, +} + +impl Drop for CoordinatorCore { + fn drop(&mut self) { + if let Ok(admission) = self.admission.get_mut() + && admission.open + { + admission.open = false; + self.lifecycle.store(CLOSING, Ordering::Release); + let _ = admission.sender.send(OwnerCommand::Close); + } + if let Ok(owner) = self.owner.get_mut() + && let Some(owner) = owner.take() + { + // Drop ne joint jamais le thread owner (et ne bloque donc jamais + // un worker Tokio). Le reaper est aussi sûr si Drop se produit + // indirectement depuis l'owner : il n'y a aucun self-join. + let _ = std::thread::Builder::new() + .name("basemyai-writer-reaper".into()) + .spawn(move || { + let _ = owner.join(); + }); + } + } +} + +#[derive(Clone)] +pub(super) struct WriteCoordinator { + core: Arc, +} + +impl WriteCoordinator { + pub(super) fn dormant(state: NativeInner, capacity: usize) -> (ReadGateway, Self) { + Self::dormant_with_sink(state, capacity, capacity, FinalizationSink::Dormant) + } + + fn dormant_with_sink( + mut state: NativeInner, + capacity: usize, + finalization_capacity: usize, + finalization_sink: FinalizationSink, + ) -> (ReadGateway, Self) { + assert!(capacity > 0, "writer admission capacity must be non-zero"); + assert!(finalization_capacity > 0, "finalization capacity must be non-zero"); + static NEXT_RUNTIME: AtomicU64 = AtomicU64::new(1); + let runtime = NEXT_RUNTIME.fetch_add(1, Ordering::Relaxed); + let terminal_handle = state + .engine + .take_terminal_handle() + .expect("dormant coordinator must take the sole engine terminal handle once"); + let store_principal = CallerPrincipal::from_verified_credential( + PrincipalIssuer::new("basemyai-local-runtime").expect("static store principal issuer is valid"), + PrincipalSubject::new("dormant-v2-coordinator").expect("static store principal subject is valid"), + ); + let mut store_authorizer = StoreAuthorizer::new(); + store_authorizer.grant(store_principal.clone(), StoreAction::RotateEncryption); + store_authorizer.grant(store_principal.clone(), StoreAction::Migrate); + let (cell, token) = ProductStateCell::new(state); + let (sender, receiver) = mpsc::unbounded_channel(); + let core = Arc::new(CoordinatorCore { + runtime, + store_principal, + store_authorizer, + permits: Arc::new(Semaphore::new(capacity)), + finalization_permits: Arc::new(Semaphore::new(finalization_capacity)), + admission: Mutex::new(AdmissionState { + open: true, + next_ordinal: 0, + sender, + }), + lifecycle: AtomicU8::new(OPEN), + close_result: Mutex::new(None), + lifecycle_changed: Notify::new(), + owner: Mutex::new(None), + }); + let owner_core = Arc::downgrade(&core); + let gateway = ReadGateway { + cell: Arc::clone(&cell), + core: Arc::downgrade(&core), + }; + let finalization = FinalizationOwner::spawn(finalization_sink); + let owner = std::thread::Builder::new() + .name("basemyai-product-writer".into()) + .spawn(move || owner_loop(cell, token, terminal_handle, receiver, finalization, owner_core)) + .expect("the dedicated product writer thread must start"); + core.owner + .lock() + .expect("owner handle lock is initialized") + .replace(owner); + (gateway, Self { core }) + } + + fn authorize(&self, agent: &str, class: OperationClass) -> Result { + let adapter = + LegacyScopeAdapter::for_agent_bytes(agent.to_owned()).map_err(|_| CoordinatorError::Unauthorized)?; + let operation = adapter.authorize(class).map_err(|_| CoordinatorError::Unauthorized)?; + adapter + .validate_for_operation(&operation, class) + .map_err(|_| CoordinatorError::Unauthorized)?; + Ok(BoundAuthorization { + coordinator_runtime: self.core.runtime, + agent: agent.to_owned(), + adapter, + operation, + }) + } + + fn authorize_store(&self, action: StoreAction) -> Result { + let operation = self + .core + .store_authorizer + .authorize(&self.core.store_principal, action) + .map_err(|_| CoordinatorError::Unauthorized)?; + self.core + .store_authorizer + .validate_for_action(&operation, action) + .map_err(|_| CoordinatorError::Unauthorized)?; + Ok(BoundStoreAuthorization { + coordinator_runtime: self.core.runtime, + action, + operation, + }) + } + + fn authorize_rotation(&self) -> Result { + self.authorize_store(StoreAction::RotateEncryption) + } + + fn validate_authorization(&self, intent: &WriteIntent) -> Result<(), CoordinatorError> { + if let Some(authorization) = intent.store_authorization() { + let Some(action) = intent.expected_store_action() else { + return Err(CoordinatorError::Unauthorized); + }; + if authorization.coordinator_runtime != self.core.runtime || authorization.action != action { + return Err(CoordinatorError::WrongRuntime); + } + return self + .core + .store_authorizer + .validate_for_action(&authorization.operation, action) + .map_err(|_| CoordinatorError::Unauthorized); + } + match (intent.authorization(), intent.expected_class(), intent.agent()) { + (Some(authorization), Some(class), Some(agent)) + if authorization.coordinator_runtime == self.core.runtime && authorization.agent == agent => + { + authorization + .adapter + .validate_for_operation(&authorization.operation, class) + .map_err(|_| CoordinatorError::Unauthorized) + } + (None, None, None) => Ok(()), + _ => Err(CoordinatorError::Unauthorized), + } + } + + async fn submit_outcome(&self, intent: WriteIntent) -> Result { + self.submit(intent).await?.outcome().await + } + + pub(super) async fn memory_put_batch( + &self, + agent: String, + items: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::MemoryPutBatch { + authorization: self.authorize(&agent, OperationClass::Upsert)?, + agent, + items, + }) + .await + } + + pub(super) async fn memory_update( + &self, + agent: String, + id: String, + record: MemoryRecord, + ) -> Result { + self.submit_outcome(WriteIntent::MemoryUpdate { + authorization: self.authorize(&agent, OperationClass::Update)?, + agent, + id, + record, + }) + .await + } + + pub(super) async fn memory_touch( + &self, + agent: String, + ids: Vec, + now: i64, + ) -> Result { + self.submit_outcome(WriteIntent::MemoryTouch { + authorization: self.authorize(&agent, OperationClass::Update)?, + agent, + ids, + now, + }) + .await + } + + pub(super) async fn memory_forget_single( + &self, + agent: String, + id: String, + ) -> Result { + self.submit_outcome(WriteIntent::MemoryForgetSingle { + authorization: self.authorize(&agent, OperationClass::Delete)?, + agent, + id, + }) + .await + } + + pub(super) async fn memory_forget_chunk( + &self, + agent: String, + records: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::MemoryForgetChunk { + authorization: self.authorize(&agent, OperationClass::Delete)?, + agent, + records, + }) + .await + } + + pub(super) async fn purge_agent(&self, agent: String) -> Result<(), CoordinatorError> { + let mut cursor = AgentPurgeCursor::Memory { cursor: None, epoch: 0 }; + loop { + let intent = match cursor { + AgentPurgeCursor::Memory { cursor, epoch } => WriteIntent::MemoryPurgeChunk { + authorization: self.authorize(&agent, OperationClass::RepairOrRebuildOrGc)?, + agent: agent.clone(), + cursor, + expected_epoch: if epoch == 0 { None } else { Some(epoch) }, + }, + AgentPurgeCursor::Graph { cursor, epoch } => WriteIntent::GraphPurgeChunk { + authorization: self.authorize(&agent, OperationClass::RepairOrRebuildOrGc)?, + agent: agent.clone(), + cursor, + expected_epoch: epoch, + }, + AgentPurgeCursor::Finalize { epoch } => WriteIntent::MemoryPurgeFinalize { + authorization: self.authorize(&agent, OperationClass::RepairOrRebuildOrGc)?, + agent: agent.clone(), + expected_epoch: epoch, + }, + }; + match self.submit_outcome(intent).await? { + CoordinatorOutcome::Committed { + value: WriteValue::PurgeStep(PurgeStep::Continue(next)), + .. + } => cursor = next, + CoordinatorOutcome::Committed { + value: WriteValue::PurgeStep(PurgeStep::Complete), + .. + } => return Ok(()), + CoordinatorOutcome::Aborted { cause: Some(cause), .. } + if cause.contains("stale") || cause.contains("appeared") => + { + cursor = AgentPurgeCursor::Memory { cursor: None, epoch: 0 }; + } + CoordinatorOutcome::Aborted { .. } => return Err(CoordinatorError::InvalidIntent), + CoordinatorOutcome::OutcomeUnknown { .. } + | CoordinatorOutcome::DurableReopenRequired { .. } + | CoordinatorOutcome::StructuralReopenRequired { .. } => return Err(CoordinatorError::Terminal), + CoordinatorOutcome::Committed { .. } => return Err(CoordinatorError::InvalidIntent), + } + } + } + + pub(super) async fn graph_entity_batch( + &self, + agent: String, + entities: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::GraphEntityBatch { + authorization: self.authorize(&agent, OperationClass::Upsert)?, + agent, + entities, + }) + .await + } + + pub(super) async fn graph_edge_batch( + &self, + agent: String, + edges: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::GraphEdgeBatch { + authorization: self.authorize(&agent, OperationClass::Upsert)?, + agent, + edges, + }) + .await + } + + pub(super) async fn graph_edge_upsert( + &self, + agent: String, + edge: OwnedGraphEdgeUpsert, + ) -> Result { + self.submit_outcome(WriteIntent::GraphEdgeUpsert { + authorization: self.authorize(&agent, OperationClass::Upsert)?, + agent, + edge, + }) + .await + } + + pub(super) async fn import_memory_batch( + &self, + agent: String, + items: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::ImportMemoryBatch { + authorization: self.authorize(&agent, OperationClass::ImportInsertOnly)?, + agent, + items, + }) + .await + } + + pub(super) async fn import_graph_entity_batch( + &self, + agent: String, + entities: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::ImportGraphEntityBatch { + authorization: self.authorize(&agent, OperationClass::ImportInsertOnly)?, + agent, + entities, + }) + .await + } + + pub(super) async fn import_graph_edge_batch( + &self, + agent: String, + edges: Vec, + ) -> Result { + self.submit_outcome(WriteIntent::ImportGraphEdgeBatch { + authorization: self.authorize(&agent, OperationClass::ImportInsertOnly)?, + agent, + edges, + }) + .await + } + + pub(super) async fn meta_ensure( + &self, + name: String, + value: String, + ) -> Result { + self.submit_outcome(WriteIntent::MetaEnsure { + authorization: self.authorize_store(StoreAction::Migrate)?, + key: super::bmai_meta_key(&name), + value, + }) + .await + } + + pub(super) async fn rotate_key(&self, secret: Vec, full: bool) -> Result { + let secret = OwnedRotationSecret::new(secret)?; + let operation = if full { + LiveStructural::KeyFull { secret } + } else { + LiveStructural::KeyLight { secret } + }; + self.submit_outcome(WriteIntent::LiveStructural { + authorization: self.authorize_rotation()?, + operation, + }) + .await + } + + pub(super) async fn rotate_passphrase( + &self, + secret: Vec, + profile: Argon2idProfile, + full: bool, + ) -> Result { + let secret = OwnedRotationSecret::new(secret)?; + let operation = if full { + LiveStructural::PassphraseFull { secret, profile } + } else { + LiveStructural::PassphraseLight { secret, profile } + }; + self.submit_outcome(WriteIntent::LiveStructural { + authorization: self.authorize_rotation()?, + operation, + }) + .await + } + + async fn submit(&self, intent: WriteIntent) -> Result { + self.validate_authorization(&intent)?; + intent.validate_shape()?; + let (items, bytes) = intent.footprint(); + if items > MAX_INTENT_ITEMS || bytes > MAX_INTENT_BYTES { + return Err(CoordinatorError::IntentTooLarge { items, bytes }); + } + let requires_finalization = intent.requires_finalization(); + let permit = Arc::clone(&self.core.permits) + .acquire_owned() + .await + .map_err(|_| CoordinatorError::Closed)?; + let finalization = if requires_finalization { + Some(FinalizationReservation { + _permit: Arc::clone(&self.core.finalization_permits) + .acquire_owned() + .await + .map_err(|_| CoordinatorError::Closed)?, + }) + } else { + None + }; + let (completion, receiver) = oneshot::channel(); + let request = { + // Admission, ordinal allocation and enqueue are one synchronous + // critical section. No guard crosses an await. + let mut admission = self.core.admission.lock().map_err(|_| CoordinatorError::Terminal)?; + if !admission.open { + return Err(CoordinatorError::Closed); + } + let request = WriteRequestId { + runtime: self.core.runtime, + ordinal: admission.next_ordinal, + }; + admission.next_ordinal = admission + .next_ordinal + .checked_add(1) + .ok_or(CoordinatorError::Terminal)?; + admission + .sender + .send(OwnerCommand::Intent(Box::new(AdmittedIntent { + request, + intent, + completion, + _permit: permit, + finalization, + }))) + .map_err(|_| CoordinatorError::Terminal)?; + request + }; + Ok(WriteTicket { + request, + completion: receiver, + }) + } + + async fn close(&self) -> Result<(), CoordinatorError> { + { + let mut admission = self.core.admission.lock().map_err(|_| CoordinatorError::Terminal)?; + if admission.open { + admission.open = false; + self.core.lifecycle.store(CLOSING, Ordering::Release); + admission + .sender + .send(OwnerCommand::Close) + .map_err(|_| CoordinatorError::Terminal)?; + } + } + loop { + let changed = self.core.lifecycle_changed.notified(); + match self.core.lifecycle.load(Ordering::Acquire) { + CLOSED | TERMINAL_CLOSED => { + return self + .core + .close_result + .lock() + .map_err(|_| CoordinatorError::Terminal)? + .clone() + .unwrap_or(Err(CoordinatorError::Terminal)); + } + _ => changed.await, + } + } + } +} + +struct WriteTicket { + request: WriteRequestId, + completion: oneshot::Receiver>, +} + +impl WriteTicket { + async fn outcome(self) -> Result { + self.completion.await.unwrap_or(Err(CoordinatorError::Terminal)) + } +} + +fn owner_loop( + cell: Arc, + mut token: ProductWriteToken, + terminal_handle: basemyai_engine::EngineTerminalHandle, + mut receiver: mpsc::UnboundedReceiver, + finalization: FinalizationOwner, + core: std::sync::Weak, +) { + let owner = catch_unwind(AssertUnwindSafe(|| { + owner_commands(&cell, &mut token, &terminal_handle, &mut receiver, &finalization, &core) + })); + match owner { + Ok(exit) => { + // Closing the sender and joining drains every previously enqueued + // Committed manifest in FIFO order before state shutdown. + finalization.shutdown(); + match exit { + OwnerExit::Clean => clean_shutdown(&cell, &mut token, &terminal_handle, &core), + OwnerExit::Terminal(cause) => { + abort_shutdown(&cell, &mut token, &terminal_handle, Some(&mut receiver), &core, cause); + } + } + } + Err(_) => { + finalization.shutdown(); + abort_shutdown( + &cell, + &mut token, + &terminal_handle, + Some(&mut receiver), + &core, + "product writer owner panicked during command or shutdown".to_owned(), + ); + } + } +} + +enum OwnerExit { + Clean, + Terminal(String), +} + +fn owner_commands( + cell: &ProductStateCell, + token: &mut ProductWriteToken, + terminal_handle: &basemyai_engine::EngineTerminalHandle, + receiver: &mut mpsc::UnboundedReceiver, + finalization_owner: &FinalizationOwner, + core: &std::sync::Weak, +) -> OwnerExit { + let mut agent_epochs = HashMap::new(); + loop { + let Some(command) = receiver.blocking_recv() else { + // Last runtime handle dropped: channel EOF is the same ordered + // clean-close request as an explicit Close command. + return OwnerExit::Clean; + }; + match command { + OwnerCommand::Intent(admitted) => { + let AdmittedIntent { + request, + intent, + completion, + _permit: permit, + mut finalization, + } = *admitted; + let outcome = catch_unwind(AssertUnwindSafe(|| { + execute(cell, token, core, request, intent, &mut agent_epochs) + })) + .unwrap_or(Err(CoordinatorError::Terminal)); + let terminal = matches!(&outcome, Err(CoordinatorError::Terminal)) + || matches!(&outcome, Ok(value) if value.is_terminal()); + let cause = terminal.then(|| match &outcome { + Ok(CoordinatorOutcome::OutcomeUnknown { phase, cause, .. }) => { + format!("product write outcome unknown during {phase:?}: {cause}") + } + Ok(CoordinatorOutcome::DurableReopenRequired { cause, .. }) => { + format!("durable product installation requires reopen: {cause}") + } + Ok(CoordinatorOutcome::StructuralReopenRequired { phase, cause, .. }) => { + format!("structural publication requires reopen during {phase:?}: {cause}") + } + _ => "product writer handler panicked or lost terminal state".to_owned(), + }); + if let Some(cause) = &cause { + let _ = terminal_handle.enter_reconcile_required(cause.clone()); + close_admission(core, TERMINAL_DRAINING); + } + if matches!(&outcome, Ok(CoordinatorOutcome::Committed { .. })) + && let Some(reservation) = finalization.take() + { + finalization_owner.enqueue(FinalizationManifest { + request, + _reservation: reservation, + }); + } + let _ = completion.send(outcome); + drop(permit); + if let Some(cause) = cause { + return OwnerExit::Terminal(cause); + } + } + OwnerCommand::Close => return OwnerExit::Clean, + } + } +} + +fn close_admission(core: &std::sync::Weak, lifecycle: u8) { + if let Some(core) = core.upgrade() { + if let Ok(mut admission) = core.admission.lock() { + admission.open = false; + } + core.lifecycle.store(lifecycle, Ordering::Release); + } +} + +fn publish_close(core: &std::sync::Weak, lifecycle: u8, result: Result<(), CoordinatorError>) { + if let Some(core) = core.upgrade() { + if let Ok(mut stored) = core.close_result.lock() + && stored.is_none() + { + *stored = Some(result); + } + core.lifecycle.store(lifecycle, Ordering::Release); + core.lifecycle_changed.notify_waiters(); + } +} + +fn clean_shutdown( + cell: &ProductStateCell, + token: &mut ProductWriteToken, + terminal_handle: &basemyai_engine::EngineTerminalHandle, + core: &std::sync::Weak, +) { + close_admission(core, DRAINING); + let state = match cell.take_for_clean_shutdown(token) { + Ok(state) => state, + Err(cause) => { + abort_shutdown( + cell, + token, + terminal_handle, + None, + core, + format!("clean product shutdown could not consume state: {cause:?}"), + ); + return; + } + }; + let super::NativeInner { + engine, + vectors, + memory, + graph, + fts, + } = state; + drop((vectors, memory, graph, fts)); + let (lifecycle, result) = match engine.close() { + Ok(()) => (CLOSED, Ok(())), + Err(cause) => { + let lifecycle = if matches!(cause, basemyai_engine::EngineError::WriterReconcileRequired { .. }) { + TERMINAL_CLOSED + } else { + CLOSED + }; + (lifecycle, Err(CoordinatorError::CloseFailed(cause.to_string()))) + } + }; + publish_close(core, lifecycle, result); +} + +fn abort_shutdown( + cell: &ProductStateCell, + token: &mut ProductWriteToken, + terminal_handle: &basemyai_engine::EngineTerminalHandle, + receiver: Option<&mut mpsc::UnboundedReceiver>, + core: &std::sync::Weak, + cause: String, +) { + let _ = terminal_handle.enter_reconcile_required(cause); + close_admission(core, TERMINAL_DRAINING); + if let Some(receiver) = receiver { + while let Ok(command) = receiver.try_recv() { + if let OwnerCommand::Intent(admitted) = command { + let _ = admitted.completion.send(Err(CoordinatorError::Terminal)); + } + } + } + if let Ok(state) = cell.take_for_abort_shutdown(token) { + // Direct drop is intentional: Engine::Drop joins both workers but + // performs no flush or catalogue publication. + drop(state); + } + publish_close(core, TERMINAL_CLOSED, Err(CoordinatorError::Terminal)); +} + +fn execute( + cell: &ProductStateCell, + token: &mut ProductWriteToken, + core: &std::sync::Weak, + request: WriteRequestId, + intent: WriteIntent, + agent_epochs: &mut HashMap, +) -> Result { + if let Some(authorization) = intent.store_authorization() { + let Some(core) = core.upgrade() else { + return Err(CoordinatorError::Terminal); + }; + let Some(action) = intent.expected_store_action() else { + return Err(CoordinatorError::Unauthorized); + }; + if authorization.coordinator_runtime != core.runtime || authorization.action != action { + return Err(CoordinatorError::WrongRuntime); + } + core.store_authorizer + .validate_for_action(&authorization.operation, action) + .map_err(|_| CoordinatorError::Unauthorized)?; + } else if let Some(authorization) = intent.authorization() { + let Some(core) = core.upgrade() else { + return Err(CoordinatorError::Terminal); + }; + let Some(class) = intent.expected_class() else { + return Err(CoordinatorError::Unauthorized); + }; + let Some(agent) = intent.agent() else { + return Err(CoordinatorError::Unauthorized); + }; + if authorization.coordinator_runtime != core.runtime || authorization.agent != agent { + return Err(CoordinatorError::WrongRuntime); + } + authorization + .adapter + .validate_for_operation(&authorization.operation, class) + .map_err(|_| CoordinatorError::Unauthorized)?; + } + + let mutation_agent = intent.agent().map(str::to_owned); + let mut guard = cell.lock_for_owner(token)?; + let state = guard.as_mut().ok_or(CoordinatorError::Closed)?; + let outcome = match intent { + WriteIntent::Fence => committed(request, None, WriteValue::None), + WriteIntent::MemoryPutBatch { agent, items, .. } => { + let borrowed = items + .iter() + .map(|item| { + ( + item.id.as_str(), + NewMemoryRecord { + layer: &item.layer, + content: &item.content, + source: &item.source, + valid_from: item.valid_from, + valid_until: item.valid_until, + importance: item.importance, + last_access: item.last_access, + }, + item.embedding.clone(), + ) + }) + .collect::>(); + match state + .memory + .stage_put_many(&state.engine, &state.vectors, &state.fts, &agent, &borrowed) + { + Ok(stage) => map_memory_outcome( + request, + stage.commit(&mut state.engine, &mut state.memory, &mut state.vectors), + WriteValue::VectorIds, + ), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + WriteIntent::MemoryUpdate { + agent, id, mut record, .. + } => { + let existing = match state.memory.get(&state.engine, &agent, &id) { + Ok(Some(existing)) => existing, + Ok(None) => { + return Ok(aborted( + request, + None, + Some(format!("memory {id:?} does not exist for update")), + )); + } + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + }; + // `vec_id` is engine-owned identity, never caller-owned update + // material. Rebind it from the authoritative record under the + // product write gate before staging the replacement. + record.vec_id = existing.vec_id; + match state.memory.stage_update(&state.engine, &agent, &id, &record) { + Ok(stage) => map_memory_outcome(request, stage.commit(&mut state.engine), |_| WriteValue::None), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + WriteIntent::MemoryTouch { agent, ids, now, .. } => { + match state + .memory + .stage_touch_last_access(&state.engine, &agent, ids.iter().map(String::as_str), now) + { + Ok(stage) => map_memory_outcome(request, stage.commit(&mut state.engine), |_| WriteValue::None), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + WriteIntent::MemoryForgetSingle { agent, id, .. } => { + let stored = match state.memory.get(&state.engine, &agent, &id) { + Ok(stored) => stored, + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + }; + let Some(stored) = stored else { + return Ok(committed(request, None, WriteValue::Present(false))); + }; + let chunk = [(id.as_str(), stored.vec_id, stored.valid_until)]; + match basemyai_engine::PersistentMemoryIndex::stage_forget_chunk( + &state.engine, + &state.vectors, + &state.fts, + &agent, + &chunk, + ) { + Ok(stage) => { + map_memory_outcome(request, stage.commit(&mut state.engine, &mut state.vectors), |count| { + WriteValue::Present(count != 0) + }) + } + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + WriteIntent::MemoryForgetChunk { agent, records, .. } => { + for expected in &records { + match state.memory.get(&state.engine, &agent, &expected.id) { + Ok(Some(actual)) + if actual.vec_id == expected.vec_id && actual.valid_until == expected.valid_until => {} + Ok(_) => { + return Ok(aborted( + request, + None, + Some(format!("stale forget read-set for {:?}", expected.id)), + )); + } + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + } + } + let chunk = records + .iter() + .map(|record| (record.id.as_str(), record.vec_id, record.valid_until)) + .collect::>(); + match basemyai_engine::PersistentMemoryIndex::stage_forget_chunk( + &state.engine, + &state.vectors, + &state.fts, + &agent, + &chunk, + ) { + Ok(stage) => map_memory_outcome( + request, + stage.commit(&mut state.engine, &mut state.vectors), + WriteValue::Count, + ), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + WriteIntent::MemoryPurgeChunk { + agent, + cursor, + expected_epoch, + .. + } => { + let current_epoch = *agent_epochs.get(&agent).unwrap_or(&0); + if expected_epoch.is_some_and(|expected| current_epoch != expected) { + aborted(request, None, Some("stale agent purge mutation epoch".into())) + } else { + match state.memory.plan_purge_chunk( + &state.engine, + &state.vectors, + &state.fts, + &agent, + cursor, + basemyai_engine::idx::memory::ForgetBatchOptions::default(), + ) { + Ok(plan) if plan.is_empty() && plan.exhausted() => committed( + request, + None, + WriteValue::PurgeStep(PurgeStep::Continue(AgentPurgeCursor::Graph { + cursor: None, + epoch: current_epoch, + })), + ), + Ok(plan) => match state + .memory + .stage_purge_chunk(&state.engine, &state.vectors, &state.fts, plan) + { + Ok(stage) => { + map_memory_outcome(request, stage.commit(&mut state.engine, &mut state.vectors), |commit| { + WriteValue::PurgeStep(PurgeStep::Continue(AgentPurgeCursor::Memory { + cursor: Some(commit.into_cursor()), + epoch: current_epoch.saturating_add(1), + })) + }) + } + Err(cause) => aborted(request, None, Some(cause.to_string())), + }, + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + } + WriteIntent::GraphPurgeChunk { + agent, + cursor, + expected_epoch, + .. + } => { + let current_epoch = *agent_epochs.get(&agent).unwrap_or(&0); + if current_epoch != expected_epoch { + aborted(request, None, Some("stale agent purge mutation epoch".into())) + } else { + let options = basemyai_engine::idx::graph::persistent::GraphPurgeChunkOptions::default(); + match state + .graph + .plan_purge_chunk(&state.engine, &agent, cursor.as_ref(), options) + { + Ok(plan) => { + let next = plan.resume().cloned(); + if plan.is_empty() { + let cursor = match next { + Some(cursor) => AgentPurgeCursor::Graph { + cursor: Some(cursor), + epoch: current_epoch, + }, + None => AgentPurgeCursor::Finalize { epoch: current_epoch }, + }; + committed(request, None, WriteValue::PurgeStep(PurgeStep::Continue(cursor))) + } else { + match state.graph.stage_purge_chunk(&state.engine, plan) { + Ok(stage) => map_graph_purge_outcome( + request, + stage.commit(&mut state.engine), + next, + current_epoch, + ), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + } + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + } + WriteIntent::MemoryPurgeFinalize { + agent, expected_epoch, .. + } => { + let current_epoch = *agent_epochs.get(&agent).unwrap_or(&0); + if current_epoch != expected_epoch { + aborted(request, None, Some("stale agent purge mutation epoch".into())) + } else { + match state.memory.plan_purge_chunk( + &state.engine, + &state.vectors, + &state.fts, + &agent, + None, + basemyai_engine::idx::memory::ForgetBatchOptions::default(), + ) { + Ok(plan) if plan.is_empty() && plan.exhausted() => { + match state.memory.stage_finalize_purge(&state.engine, plan) { + Ok(stage) => map_memory_outcome(request, stage.commit(&mut state.engine), |_| { + WriteValue::PurgeStep(PurgeStep::Complete) + }), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + Ok(_) => aborted(request, None, Some("memory appeared before purge finalization".into())), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + } + WriteIntent::GraphEntityBatch { agent, entities, .. } => { + let count = entities.len() as u64; + let mut batch = Batch::new(); + for item in entities { + if let Err(cause) = state + .graph + .stage_upsert_entity(&agent, &item.id, &item.entity, &mut batch) + { + return Ok(aborted(request, None, Some(cause.to_string()))); + } + } + commit_batch(state, request, batch, WriteValue::Count(count)) + } + WriteIntent::GraphEdgeBatch { agent, edges, .. } => { + let count = edges.len() as u64; + let mut batch = Batch::new(); + for item in edges { + if let Err(cause) = + state + .graph + .stage_upsert_edge(&agent, &item.src, &item.relation, &item.dst, &item.meta, &mut batch) + { + return Ok(aborted(request, None, Some(cause.to_string()))); + } + } + commit_batch(state, request, batch, WriteValue::Count(count)) + } + WriteIntent::GraphEdgeUpsert { agent, edge, .. } => { + let meta = match state + .graph + .edge_meta(&state.engine, &agent, &edge.src, &edge.relation, &edge.dst) + { + Ok(Some(existing)) => GraphEdgeMeta { + weight: edge.weight, + ..existing + }, + Ok(None) => GraphEdgeMeta { + weight: edge.weight, + valid_from: edge.now, + valid_until: None, + source: edge.source, + }, + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + }; + let mut batch = Batch::new(); + if let Err(cause) = + state + .graph + .stage_upsert_edge(&agent, &edge.src, &edge.relation, &edge.dst, &meta, &mut batch) + { + aborted(request, None, Some(cause.to_string())) + } else { + commit_batch(state, request, batch, WriteValue::None) + } + } + WriteIntent::ImportMemoryBatch { agent, items, .. } => { + let mut seen = std::collections::HashSet::with_capacity(items.len()); + let mut fresh = Vec::with_capacity(items.len()); + let mut skipped = 0usize; + for item in &items { + match state.memory.get(&state.engine, &agent, &item.id) { + Ok(Some(_)) => skipped += 1, + Ok(None) if seen.insert(item.id.as_str()) => fresh.push(item), + Ok(None) => skipped += 1, + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + } + } + if fresh.is_empty() { + committed( + request, + None, + WriteValue::ImportChunk(ImportChunkReport { inserted: 0, skipped }), + ) + } else { + let borrowed = fresh + .iter() + .map(|item| { + ( + item.id.as_str(), + NewMemoryRecord { + layer: &item.layer, + content: &item.content, + source: &item.source, + valid_from: item.valid_from, + valid_until: item.valid_until, + importance: item.importance, + last_access: item.last_access, + }, + item.embedding.clone(), + ) + }) + .collect::>(); + match state + .memory + .stage_put_many(&state.engine, &state.vectors, &state.fts, &agent, &borrowed) + { + Ok(stage) => map_memory_outcome( + request, + stage.commit(&mut state.engine, &mut state.memory, &mut state.vectors), + |ids| { + WriteValue::ImportChunk(ImportChunkReport { + inserted: ids.len(), + skipped, + }) + }, + ), + Err(cause) => aborted(request, None, Some(cause.to_string())), + } + } + } + WriteIntent::ImportGraphEntityBatch { agent, entities, .. } => { + let mut seen = std::collections::HashSet::with_capacity(entities.len()); + let mut batch = Batch::new(); + let mut report = ImportChunkReport::default(); + for mut item in entities { + item.entity.source = basemyai_engine::GraphSource::Import; + match state.graph.entity(&state.engine, &agent, &item.id) { + Ok(Some(_)) => report.skipped += 1, + Ok(None) if seen.insert(item.id.clone()) => { + if let Err(cause) = state + .graph + .stage_upsert_entity(&agent, &item.id, &item.entity, &mut batch) + { + return Ok(aborted(request, None, Some(cause.to_string()))); + } + report.inserted += 1; + } + Ok(None) => report.skipped += 1, + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + } + } + commit_batch_or_noop(state, request, batch, WriteValue::ImportChunk(report)) + } + WriteIntent::ImportGraphEdgeBatch { agent, edges, .. } => { + let mut seen = std::collections::HashSet::with_capacity(edges.len()); + let mut batch = Batch::new(); + let mut report = ImportChunkReport::default(); + for mut item in edges { + item.meta.source = basemyai_engine::GraphSource::Import; + let identity = (item.src.clone(), item.relation.clone(), item.dst.clone()); + match state + .graph + .edge_meta(&state.engine, &agent, &item.src, &item.relation, &item.dst) + { + Ok(Some(_)) => report.skipped += 1, + Ok(None) if seen.insert(identity) => { + if let Err(cause) = state.graph.stage_upsert_edge( + &agent, + &item.src, + &item.relation, + &item.dst, + &item.meta, + &mut batch, + ) { + return Ok(aborted(request, None, Some(cause.to_string()))); + } + report.inserted += 1; + } + Ok(None) => report.skipped += 1, + Err(cause) => return Ok(aborted(request, None, Some(cause.to_string()))), + } + } + commit_batch_or_noop(state, request, batch, WriteValue::ImportChunk(report)) + } + WriteIntent::MetaEnsure { key, value, .. } => match state.engine.get(&key) { + Ok(Some(existing)) => match String::from_utf8(existing) { + Ok(existing) => committed(request, None, WriteValue::Text(existing)), + Err(cause) => aborted(request, None, Some(cause.to_string())), + }, + Ok(None) => { + let mut batch = Batch::new(); + batch.put(&key, value.as_bytes()); + commit_batch(state, request, batch, WriteValue::Text(value)) + } + Err(cause) => aborted(request, None, Some(cause.to_string())), + }, + WriteIntent::LiveStructural { operation, .. } => { + let outcome = match operation { + LiveStructural::KeyLight { secret } => state.engine.rotate_key_phase_aware(secret.as_bytes()), + LiveStructural::PassphraseLight { secret, profile } => { + state.engine.rotate_passphrase_phase_aware(secret.as_bytes(), profile) + } + LiveStructural::KeyFull { secret } => state.engine.rotate_key_full_phase_aware(secret.as_bytes()), + LiveStructural::PassphraseFull { secret, profile } => state + .engine + .rotate_passphrase_full_phase_aware(secret.as_bytes(), profile), + }; + map_structural_outcome(request, outcome) + } + #[cfg(test)] + WriteIntent::TestRecord(intent) => { + if let Some(entered) = intent.entered { + let _ = entered.send(()); + } + if let Some(release) = intent.release { + let _ = release.recv(); + } + assert!(!intent.panic, "injected product handler panic"); + intent + .log + .lock() + .expect("test log lock must remain healthy") + .push(intent.marker); + committed(request, None, WriteValue::None) + } + }; + if let ( + Some(agent), + CoordinatorOutcome::Committed { + sequence_range: Some(_), + .. + }, + ) = (mutation_agent, &outcome) + { + let epoch = agent_epochs.entry(agent).or_default(); + *epoch = epoch.checked_add(1).ok_or(CoordinatorError::Terminal)?; + } + Ok(outcome) +} + +fn aborted( + request: WriteRequestId, + burned_sequence_range: Option, + cause: Option, +) -> CoordinatorOutcome { + CoordinatorOutcome::Aborted { + request, + burned_sequence_range, + cause, + } +} + +fn committed(request: WriteRequestId, sequence_range: Option, value: WriteValue) -> CoordinatorOutcome { + CoordinatorOutcome::Committed { + request, + sequence_range, + value, + } +} + +fn map_structural_outcome(request: WriteRequestId, outcome: StructuralCommitOutcome) -> CoordinatorOutcome { + match outcome { + StructuralCommitOutcome::Aborted { cause } => aborted(request, None, Some(cause.to_string())), + StructuralCommitOutcome::Committed(_) => committed(request, None, WriteValue::None), + StructuralCommitOutcome::StructuralReopenRequired { + phase, + candidate_ownership, + cause, + } => CoordinatorOutcome::StructuralReopenRequired { + request, + phase, + candidate_ownership, + cause: cause.to_string(), + }, + } +} + +fn map_memory_outcome( + request: WriteRequestId, + outcome: MemoryCommitOutcome, + value: impl FnOnce(T) -> WriteValue, +) -> CoordinatorOutcome { + match outcome { + MemoryCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => aborted(request, burned_sequence_range, cause.map(|cause| cause.to_string())), + MemoryCommitOutcome::Committed { + sequence_range, + value: result, + } => committed(request, Some(sequence_range), value(result)), + MemoryCommitOutcome::OutcomeUnknown { phase, cause } => CoordinatorOutcome::OutcomeUnknown { + request, + phase, + cause: cause.to_string(), + }, + MemoryCommitOutcome::DurableReopenRequired { sequence_range, cause } => { + CoordinatorOutcome::DurableReopenRequired { + request, + sequence_range, + cause: cause.to_string(), + } + } + } +} + +fn map_graph_purge_outcome( + request: WriteRequestId, + outcome: basemyai_engine::idx::graph::persistent::GraphPurgeCommitOutcome, + next: Option, + current_epoch: u64, +) -> CoordinatorOutcome { + use basemyai_engine::idx::graph::persistent::GraphPurgeCommitOutcome; + match outcome { + GraphPurgeCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => aborted(request, burned_sequence_range, cause.map(|cause| cause.to_string())), + GraphPurgeCommitOutcome::Committed { sequence_range, .. } => { + let epoch = current_epoch.saturating_add(1); + let cursor = match next { + Some(cursor) => AgentPurgeCursor::Graph { + cursor: Some(cursor), + epoch, + }, + None => AgentPurgeCursor::Finalize { epoch }, + }; + committed( + request, + Some(sequence_range), + WriteValue::PurgeStep(PurgeStep::Continue(cursor)), + ) + } + GraphPurgeCommitOutcome::OutcomeUnknown { phase, cause } => CoordinatorOutcome::OutcomeUnknown { + request, + phase, + cause: cause.to_string(), + }, + GraphPurgeCommitOutcome::DurableReopenRequired { sequence_range, cause } => { + CoordinatorOutcome::DurableReopenRequired { + request, + sequence_range, + cause: cause.to_string(), + } + } + } +} + +fn commit_batch( + state: &mut NativeInner, + request: WriteRequestId, + batch: Batch, + value: WriteValue, +) -> CoordinatorOutcome { + match state.engine.commit_batch(batch) { + EngineCommitOutcome::Aborted { + burned_sequence_range, + cause, + } => aborted(request, burned_sequence_range, cause.map(|cause| cause.to_string())), + EngineCommitOutcome::Durable(receipt) => { + let sequence_range = receipt.sequence_range(); + match state.engine.install_committed_batch_with(receipt, |_| Ok(())) { + Ok(()) => committed(request, Some(sequence_range), value), + Err(cause) => CoordinatorOutcome::DurableReopenRequired { + request, + sequence_range, + cause: cause.to_string(), + }, + } + } + EngineCommitOutcome::OutcomeUnknown { phase, cause } => CoordinatorOutcome::OutcomeUnknown { + request, + phase, + cause: cause.to_string(), + }, + } +} + +fn commit_batch_or_noop( + state: &mut NativeInner, + request: WriteRequestId, + batch: Batch, + value: WriteValue, +) -> CoordinatorOutcome { + if batch.is_empty() { + committed(request, None, value) + } else { + commit_batch(state, request, batch, value) + } +} + +#[cfg(test)] +mod tests { + use std::sync::OnceLock; + use std::time::{Duration, Instant}; + + use basemyai_engine::failpoint::{self, Action}; + use basemyai_engine::{ + Engine, GraphSource, PersistentFts, PersistentGraph, PersistentMemoryIndex, PersistentVectorIndex, + }; + + use super::*; + + const INITIAL_ROTATION_KEY: &[u8] = b"coordinator initial encryption key"; + const NEXT_ROTATION_KEY: &[u8] = b"coordinator replacement encryption key"; + + async fn failpoint_lock() -> tokio::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())).lock().await + } + + fn inner() -> (tempfile::TempDir, NativeInner) { + let directory = tempfile::tempdir().expect("temporary engine directory"); + let mut engine = Engine::open(directory.path()).expect("test engine opens"); + let vectors = PersistentVectorIndex::open( + &mut engine, + basemyai_engine::VectorIndexParams::with_dim(crate::EMBEDDING_DIM), + ) + .expect("vector index opens"); + let memory = PersistentMemoryIndex::open(&engine).expect("memory index opens"); + ( + directory, + NativeInner { + engine, + vectors, + memory, + graph: PersistentGraph::new(), + fts: PersistentFts::new(), + }, + ) + } + + fn encrypted_inner() -> (tempfile::TempDir, NativeInner) { + let directory = tempfile::tempdir().expect("temporary encrypted engine directory"); + let mut engine = + Engine::open_encrypted(directory.path(), INITIAL_ROTATION_KEY).expect("encrypted engine opens"); + let vectors = PersistentVectorIndex::open( + &mut engine, + basemyai_engine::VectorIndexParams::with_dim(crate::EMBEDDING_DIM), + ) + .expect("vector index opens"); + let memory = PersistentMemoryIndex::open(&engine).expect("memory index opens"); + ( + directory, + NativeInner { + engine, + vectors, + memory, + graph: PersistentGraph::new(), + fts: PersistentFts::new(), + }, + ) + } + + fn structural_intent(writer: &WriteCoordinator, operation: LiveStructural) -> WriteIntent { + WriteIntent::LiveStructural { + authorization: writer + .authorize_rotation() + .expect("rotation capability is runtime-bound"), + operation, + } + } + + fn secret() -> OwnedRotationSecret { + OwnedRotationSecret::new(NEXT_ROTATION_KEY.to_vec()).expect("test rotation secret is bounded") + } + + fn graph_intent(writer: &WriteCoordinator, id: &str) -> WriteIntent { + WriteIntent::GraphEntityBatch { + authorization: writer + .authorize("agent-a", OperationClass::Upsert) + .expect("graph upsert authorized"), + agent: "agent-a".into(), + entities: vec![OwnedGraphEntity { + id: id.into(), + entity: GraphEntity { + kind: "test".into(), + label: id.into(), + valid_from: 0, + valid_until: None, + source: GraphSource::User, + }, + }], + } + } + + fn test_finalization_sink( + receiver_present: bool, + fail: bool, + panic: bool, + entered: Option>, + release: Option>, + ) -> Arc { + Arc::new(TestFinalizationSink { + receiver_present: std::sync::atomic::AtomicBool::new(receiver_present), + revoked: std::sync::atomic::AtomicBool::new(false), + fail: std::sync::atomic::AtomicBool::new(fail), + panic: std::sync::atomic::AtomicBool::new(panic), + attempts: Mutex::new(Vec::new()), + entered: Mutex::new(entered), + release: Mutex::new(release), + }) + } + + fn record(marker: u64, log: &Arc>>) -> WriteIntent { + WriteIntent::TestRecord(TestRecordIntent { + marker, + log: Arc::clone(log), + entered: None, + release: None, + panic: false, + }) + } + + fn authorized(state: NativeInner, class: OperationClass) -> (WriteCoordinator, BoundAuthorization) { + let (_reads, writer) = WriteCoordinator::dormant(state, 4); + let binding = writer + .authorize("agent-a", class) + .expect("legacy capability bound to coordinator runtime"); + (writer, binding) + } + + #[tokio::test] + async fn owner_executes_admitted_intents_in_fifo_order() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 4); + let log = Arc::new(Mutex::new(Vec::new())); + let first = writer.submit(record(1, &log)).await.expect("first admitted"); + let second = writer.submit(record(2, &log)).await.expect("second admitted"); + let third = writer.submit(record(3, &log)).await.expect("third admitted"); + assert_eq!(first.request.ordinal, 0); + assert_eq!(second.request.ordinal, 1); + assert_eq!(third.request.ordinal, 2); + first.outcome().await.expect("first completes"); + second.outcome().await.expect("second completes"); + third.outcome().await.expect("third completes"); + assert_eq!(*log.lock().expect("test log"), vec![1, 2, 3]); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn admission_is_bounded_until_the_internal_outcome() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 1); + let log = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let first = writer + .submit(WriteIntent::TestRecord(TestRecordIntent { + marker: 1, + log: Arc::clone(&log), + entered: Some(entered_tx), + release: Some(release_rx), + panic: false, + })) + .await + .expect("first admitted"); + entered_rx.recv().expect("owner entered first intent"); + assert!( + tokio::time::timeout(Duration::from_millis(25), writer.submit(record(2, &log))) + .await + .is_err() + ); + release_tx.send(()).expect("release owner"); + first.outcome().await.expect("first completes"); + writer + .submit(record(2, &log)) + .await + .expect("capacity returned") + .outcome() + .await + .expect("second completes"); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn dropping_a_ticket_does_not_cancel_or_release_its_intent() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 1); + let log = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let ticket = writer + .submit(WriteIntent::TestRecord(TestRecordIntent { + marker: 7, + log: Arc::clone(&log), + entered: Some(entered_tx), + release: Some(release_rx), + panic: false, + })) + .await + .expect("intent admitted"); + entered_rx.recv().expect("owner entered abandoned intent"); + drop(ticket); + assert!( + tokio::time::timeout(Duration::from_millis(25), writer.submit(record(8, &log))) + .await + .is_err(), + "caller cancellation must not return the runtime-owned slot" + ); + release_tx.send(()).expect("release abandoned intent"); + writer.close().await.expect("close drains abandoned ticket"); + assert_eq!(*log.lock().expect("test log"), vec![7]); + assert!(matches!( + writer.submit(WriteIntent::Fence).await, + Err(CoordinatorError::Closed) + )); + } + + #[tokio::test] + async fn close_is_fifo_idempotent_and_refuses_late_admission() { + let (_directory, state) = inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 2); + reads.read_sync(|_| ()).expect("read gateway works before close"); + let log = Arc::new(Mutex::new(Vec::new())); + let ticket = writer.submit(record(1, &log)).await.expect("intent admitted"); + let second_close = writer.clone(); + let (left, right) = tokio::join!(writer.close(), second_close.close()); + left.expect("first close succeeds"); + right.expect("second close observes same result"); + ticket.outcome().await.expect("admitted intent drained"); + assert_eq!(*log.lock().expect("test log"), vec![1]); + assert_eq!(reads.read_sync(|_| ()), Err(CoordinatorError::Closed)); + assert!(matches!( + writer.submit(WriteIntent::Fence).await, + Err(CoordinatorError::Closed) + )); + } + + #[tokio::test] + async fn close_waits_for_an_admitted_reader_and_reader_finishes_normally() { + let (_directory, state) = inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 2); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + reads.read_sync(|_| { + entered_tx.send(()).expect("signal reader entry"); + release_rx.recv().expect("reader released"); + 7_u8 + }) + }); + entered_rx.recv().expect("reader holds product gate"); + + let closer = tokio::spawn(async move { writer.close().await }); + assert!( + tokio::time::timeout(Duration::from_millis(25), async { + while !closer.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .is_err(), + "close must wait for the shared read guard" + ); + release_tx.send(()).expect("release reader"); + assert_eq!(reader.join().expect("reader thread joins"), Ok(7)); + closer.await.expect("closer task joins").expect("clean close"); + } + + #[tokio::test] + async fn concurrent_closers_observe_the_same_completed_result() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 2); + let second = writer.clone(); + let third = writer.clone(); + let (first, second, third) = tokio::join!(writer.close(), second.close(), third.close()); + assert_eq!(first, Ok(())); + assert_eq!(second, first); + assert_eq!(third, first); + } + + #[tokio::test] + async fn handler_panic_terminalizes_queued_and_late_intents() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 2); + let log = Arc::new(Mutex::new(Vec::new())); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let panicking = writer + .submit(WriteIntent::TestRecord(TestRecordIntent { + marker: 1, + log: Arc::clone(&log), + entered: Some(entered_tx), + release: Some(release_rx), + panic: true, + })) + .await + .expect("panicking intent admitted"); + entered_rx.recv().expect("owner entered panicking intent"); + let queued = writer.submit(record(2, &log)).await.expect("second intent queued"); + release_tx.send(()).expect("trigger injected panic"); + + assert!(matches!(panicking.outcome().await, Err(CoordinatorError::Terminal))); + assert!(matches!(queued.outcome().await, Err(CoordinatorError::Terminal))); + assert!(matches!( + writer.submit(WriteIntent::Fence).await, + Err(CoordinatorError::Closed | CoordinatorError::Terminal) + )); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + assert!(log.lock().expect("test log").is_empty()); + assert_eq!(writer.core.permits.available_permits(), 2); + } + + #[tokio::test] + async fn oversized_intent_is_refused_before_ordinal_or_permit_consumption() { + let (_directory, state) = inner(); + let (writer, authorization) = authorized(state, OperationClass::Update); + let ids = (0..=MAX_INTENT_ITEMS).map(|index| format!("m-{index}")).collect(); + + assert!(matches!( + writer + .submit(WriteIntent::MemoryTouch { + authorization, + agent: "agent-a".into(), + ids, + now: 42, + }) + .await, + Err(CoordinatorError::IntentTooLarge { items, .. }) if items == MAX_INTENT_ITEMS + 1 + )); + assert_eq!(writer.core.permits.available_permits(), 4); + let fence = writer.submit(WriteIntent::Fence).await.expect("fence admitted"); + assert_eq!(fence.request.ordinal, 0, "rejected intent must not burn an ordinal"); + fence.outcome().await.expect("fence completes"); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn legacy_capability_rejects_a_different_agent_before_store_access() { + let (_directory, state) = inner(); + let (writer, authorization) = authorized(state, OperationClass::Update); + assert!(matches!( + writer + .submit(WriteIntent::MemoryTouch { + authorization, + agent: "agent-b".into(), + ids: vec!["secret".into()], + now: 42, + }) + .await, + Err(CoordinatorError::Unauthorized) + )); + assert_eq!(writer.core.permits.available_permits(), 4); + let fence = writer.submit(WriteIntent::Fence).await.expect("fence admitted"); + assert_eq!(fence.request.ordinal, 0, "authorization refusal must precede admission"); + fence.outcome().await.expect("fence completes"); + writer.close().await.expect("authorization refusal is not terminal"); + } + + #[tokio::test] + async fn capability_class_is_revalidated_before_admission() { + let (_directory, state) = inner(); + let (writer, authorization) = authorized(state, OperationClass::Upsert); + assert!(matches!( + writer + .submit(WriteIntent::ImportGraphEntityBatch { + authorization, + agent: "agent-a".into(), + entities: Vec::new(), + }) + .await, + Err(CoordinatorError::Unauthorized) + )); + let fence = writer.submit(WriteIntent::Fence).await.expect("fence admitted"); + assert_eq!(fence.request.ordinal, 0); + fence.outcome().await.expect("fence completes"); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn duplicate_forget_read_set_is_rejected_before_admission() { + let (_directory, state) = inner(); + let (writer, authorization) = authorized(state, OperationClass::Delete); + let duplicate = || OwnedForgetRecord { + id: "m1".into(), + vec_id: 7, + valid_until: None, + }; + assert!(matches!( + writer + .submit(WriteIntent::MemoryForgetChunk { + authorization, + agent: "agent-a".into(), + records: vec![duplicate(), duplicate()], + }) + .await, + Err(CoordinatorError::InvalidIntent) + )); + assert_eq!(writer.core.permits.available_permits(), 4); + let fence = writer.submit(WriteIntent::Fence).await.expect("fence admitted"); + assert_eq!(fence.request.ordinal, 0); + fence.outcome().await.expect("fence completes"); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn meta_ensure_race_is_insert_only_inside_the_owner() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 4); + let second = writer.clone(); + let (first, second) = tokio::join!( + writer.meta_ensure("model".into(), "first".into()), + second.meta_ensure("model".into(), "second".into()) + ); + let value = |outcome: Result| match outcome.expect("meta outcome") { + CoordinatorOutcome::Committed { + value: WriteValue::Text(value), + .. + } => value, + other => panic!("unexpected meta outcome: {other:?}"), + }; + let first = value(first); + let second = value(second); + assert_eq!(first, second, "both callers observe the owner-selected value"); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn import_memory_is_multi_agent_insert_only_and_reports_duplicates() { + let (_directory, state) = inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 4); + let item = |id: &str| OwnedMemoryPut { + id: id.into(), + layer: "episodic".into(), + content: id.into(), + source: "import".into(), + valid_from: 1, + valid_until: None, + importance: 1.0, + last_access: 1, + embedding: vec![0.0; crate::EMBEDDING_DIM], + }; + for agent in ["agent-a", "agent-b"] { + let outcome = writer + .import_memory_batch(agent.into(), vec![item("same"), item("same")]) + .await + .expect("import outcome"); + assert!(matches!( + outcome, + CoordinatorOutcome::Committed { + value: WriteValue::ImportChunk(ImportChunkReport { + inserted: 1, + skipped: 1 + }), + .. + } + )); + } + let counts = reads + .read(|state| { + ( + state.memory.scan_agent(&state.engine, "agent-a").expect("scan a").len(), + state.memory.scan_agent(&state.engine, "agent-b").expect("scan b").len(), + ) + }) + .await + .expect("read both agents"); + assert_eq!(counts, (1, 1)); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn graph_edge_upsert_preserves_owner_observed_metadata() { + let (_directory, state) = inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 4); + let edge = |weight, now, source| OwnedGraphEdgeUpsert { + src: "a".into(), + relation: "knows".into(), + dst: "b".into(), + weight, + now, + source, + }; + assert!(matches!( + writer + .graph_edge_upsert("agent-a".into(), edge(1.0, 7, GraphSource::User)) + .await, + Ok(CoordinatorOutcome::Committed { .. }) + )); + assert!(matches!( + writer + .graph_edge_upsert("agent-a".into(), edge(2.0, 99, GraphSource::Import)) + .await, + Ok(CoordinatorOutcome::Committed { .. }) + )); + let meta = reads + .read(|state| { + state + .graph + .edge_meta(&state.engine, "agent-a", "a", "knows", "b") + .expect("edge read") + .expect("edge exists") + }) + .await + .expect("gateway read"); + assert_eq!(meta.weight, 2.0); + assert_eq!(meta.valid_from, 7); + assert_eq!(meta.source, GraphSource::User); + writer.close().await.expect("clean close"); + assert_eq!(reads.read(|_| ()).await, Err(CoordinatorError::Closed)); + } + + #[tokio::test] + async fn import_unknown_preserves_phase_and_terminalizes() { + let _serial = failpoint_lock().await; + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 2); + failpoint::set("after_wal_append", Action::Error); + let outcome = writer + .import_graph_entity_batch( + "agent-a".into(), + vec![OwnedGraphEntity { + id: "alice".into(), + entity: GraphEntity { + kind: "person".into(), + label: "Alice".into(), + valid_from: 0, + valid_until: None, + source: GraphSource::Import, + }, + }], + ) + .await + .expect("phase-aware import outcome"); + failpoint::clear_all(); + assert!(matches!( + outcome, + CoordinatorOutcome::OutcomeUnknown { + phase: WalCommitPhase::Append, + .. + } + )); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + } + + fn purge_entity(id: usize) -> OwnedGraphEntity { + OwnedGraphEntity { + id: format!("entity-{id:04}"), + entity: GraphEntity { + kind: "test".into(), + label: format!("Entity {id}"), + valid_from: 0, + valid_until: None, + source: GraphSource::User, + }, + } + } + + #[tokio::test] + async fn purge_agent_is_multi_chunk_bounded_and_finalizes_only_target_marker() { + let (_directory, state) = inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 8); + let memory = |agent: &str| OwnedMemoryPut { + id: "memory".into(), + layer: "episodic".into(), + content: agent.into(), + source: "test".into(), + valid_from: 0, + valid_until: None, + importance: 1.0, + last_access: 0, + embedding: vec![0.0; crate::EMBEDDING_DIM], + }; + assert!(matches!( + writer.memory_put_batch("agent-a".into(), vec![memory("a")]).await, + Ok(CoordinatorOutcome::Committed { .. }) + )); + assert!(matches!( + writer.memory_put_batch("agent-b".into(), vec![memory("b")]).await, + Ok(CoordinatorOutcome::Committed { .. }) + )); + for chunk in [0..256, 256..300] { + assert!(matches!( + writer + .graph_entity_batch("agent-a".into(), chunk.map(purge_entity).collect()) + .await, + Ok(CoordinatorOutcome::Committed { .. }) + )); + } + assert!(matches!( + writer + .graph_entity_batch("agent-b".into(), vec![purge_entity(999)]) + .await, + Ok(CoordinatorOutcome::Committed { .. }) + )); + let before = reads + .read(|state| state.engine.stats().expect("stats before purge").wal_records) + .await + .expect("read stats"); + writer + .purge_agent("agent-a".into()) + .await + .expect("bounded purge completes"); + let after = reads + .read(|state| { + let agents = state.memory.list_agents(&state.engine).expect("agent registry"); + let a_graph = state.graph.entities(&state.engine, "agent-a").expect("agent a graph"); + let b_graph = state.graph.entities(&state.engine, "agent-b").expect("agent b graph"); + let b_memory = state + .memory + .scan_agent(&state.engine, "agent-b") + .expect("agent b memory"); + ( + state.engine.stats().expect("stats after purge").wal_records, + agents, + a_graph.len(), + b_graph.len(), + b_memory.len(), + ) + }) + .await + .expect("read purged state"); + assert_eq!(after.0 - before, 4, "memory, two graph chunks, registry finalize"); + assert!(!after.1.contains(&"agent-a".to_string())); + assert!(after.1.contains(&"agent-b".to_string())); + assert_eq!((after.2, after.3, after.4), (0, 1, 1)); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn purge_cursor_rejects_intervening_same_agent_insertion() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 8); + for chunk in [0..256, 256..300] { + writer + .graph_entity_batch("agent-a".into(), chunk.map(purge_entity).collect()) + .await + .expect("seed graph chunk"); + } + let first = writer + .submit_outcome(WriteIntent::MemoryPurgeChunk { + authorization: writer + .authorize("agent-a", OperationClass::RepairOrRebuildOrGc) + .expect("purge auth"), + agent: "agent-a".into(), + cursor: None, + expected_epoch: None, + }) + .await + .expect("memory phase"); + let CoordinatorOutcome::Committed { + value: WriteValue::PurgeStep(PurgeStep::Continue(AgentPurgeCursor::Graph { cursor, epoch })), + .. + } = first + else { + panic!("memory phase must enter graph purge"); + }; + let first_graph = writer + .submit_outcome(WriteIntent::GraphPurgeChunk { + authorization: writer + .authorize("agent-a", OperationClass::RepairOrRebuildOrGc) + .expect("purge auth"), + agent: "agent-a".into(), + cursor, + expected_epoch: epoch, + }) + .await + .expect("first graph chunk"); + let CoordinatorOutcome::Committed { + value: WriteValue::PurgeStep(PurgeStep::Continue(AgentPurgeCursor::Graph { cursor, epoch })), + .. + } = first_graph + else { + panic!("first graph chunk must continue"); + }; + writer + .graph_entity_batch("agent-a".into(), vec![purge_entity(1)]) + .await + .expect("intervening insertion"); + let stale = writer + .submit_outcome(WriteIntent::GraphPurgeChunk { + authorization: writer + .authorize("agent-a", OperationClass::RepairOrRebuildOrGc) + .expect("purge auth"), + agent: "agent-a".into(), + cursor, + expected_epoch: epoch, + }) + .await + .expect("typed stale outcome"); + assert!(matches!(stale, CoordinatorOutcome::Aborted { .. })); + writer + .purge_agent("agent-a".into()) + .await + .expect("restart is resumable"); + writer.close().await.expect("clean close"); + } + + #[tokio::test] + async fn purge_unknown_is_terminal_and_never_advances_cursor() { + let _serial = failpoint_lock().await; + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 4); + writer + .graph_entity_batch("agent-a".into(), vec![purge_entity(1)]) + .await + .expect("seed graph"); + failpoint::set("after_wal_append", Action::Error); + let result = writer.purge_agent("agent-a".into()).await; + failpoint::clear_all(); + assert_eq!(result, Err(CoordinatorError::Terminal)); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + } + + #[tokio::test] + async fn graph_handler_preserves_unknown_wal_phase_and_terminalizes_owner() { + let _serial = failpoint_lock().await; + let (directory, state) = inner(); + let (writer, authorization) = authorized(state, OperationClass::Upsert); + failpoint::set("after_wal_append", Action::Error); + let ticket = writer + .submit(WriteIntent::GraphEntityBatch { + authorization, + agent: "agent-a".into(), + entities: vec![OwnedGraphEntity { + id: "alice".into(), + entity: GraphEntity { + kind: "person".into(), + label: "Alice".into(), + valid_from: 0, + valid_until: None, + source: GraphSource::User, + }, + }], + }) + .await + .expect("graph intent admitted"); + let outcome = ticket.outcome().await.expect("phase-aware outcome delivered"); + failpoint::clear_all(); + + assert!(matches!( + outcome, + CoordinatorOutcome::OutcomeUnknown { + phase: WalCommitPhase::Append, + .. + } + )); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + drop(writer); + let reopened = Engine::open(directory.path()).expect("terminal shutdown releases writer lock without flush"); + reopened.close().expect("reopened engine closes"); + } + + #[tokio::test] + async fn finalization_saturation_blocks_before_wal_and_close_drains_after_caller_drop() { + let (_directory, state) = inner(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let sink = test_finalization_sink(true, false, false, Some(entered_tx), Some(release_rx)); + let (reads, writer) = + WriteCoordinator::dormant_with_sink(state, 2, 1, FinalizationSink::Test(Arc::clone(&sink))); + + let first = writer + .submit(graph_intent(&writer, "first")) + .await + .expect("first admitted"); + drop(first); + entered_rx.recv().expect("first committed manifest entered sink"); + let wal_before = reads + .read_sync(|state| state.engine.stats().expect("stats before saturation").wal_records) + .expect("read before saturation"); + + assert!( + tokio::time::timeout( + Duration::from_millis(25), + writer.submit(graph_intent(&writer, "second")) + ) + .await + .is_err(), + "a saturated finalization reservation must block before admission" + ); + let wal_after = reads + .read_sync(|state| state.engine.stats().expect("stats after saturation").wal_records) + .expect("read after saturation"); + assert_eq!( + wal_after, wal_before, + "saturation must refuse before any second WAL append" + ); + + let closer = tokio::spawn(async move { writer.close().await }); + assert!( + tokio::time::timeout(Duration::from_millis(25), async { + while !closer.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .is_err(), + "clean close must drain the committed manifest" + ); + release_tx.send(()).expect("release finalizer"); + closer.await.expect("closer task joins").expect("clean close succeeds"); + assert_eq!(sink.attempts.lock().expect("attempts").len(), 1); + } + + #[tokio::test] + async fn dormant_no_receiver_is_normal_and_sink_failure_does_not_demote_committed() { + let (_directory, state) = inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 2); + let outcome = writer + .submit(graph_intent(&writer, "no-receiver")) + .await + .expect("intent admitted") + .outcome() + .await + .expect("outcome delivered"); + assert!(matches!(outcome, CoordinatorOutcome::Committed { .. })); + writer.close().await.expect("no receiver is a normal clean close"); + + let (_directory, state) = inner(); + let sink = test_finalization_sink(true, true, false, None, None); + let (_reads, writer) = + WriteCoordinator::dormant_with_sink(state, 2, 1, FinalizationSink::Test(Arc::clone(&sink))); + let first = writer + .submit(graph_intent(&writer, "sink-fails")) + .await + .expect("first admitted") + .outcome() + .await + .expect("first outcome delivered"); + assert!(matches!(first, CoordinatorOutcome::Committed { .. })); + let second = writer + .submit(graph_intent(&writer, "writer-stays-healthy")) + .await + .expect("second admitted after sink failure") + .outcome() + .await + .expect("second outcome delivered"); + assert!(matches!(second, CoordinatorOutcome::Committed { .. })); + writer.close().await.expect("sink failure never terminalizes writer"); + assert_eq!(sink.attempts.lock().expect("attempts").len(), 2); + } + + #[tokio::test] + async fn terminal_shutdown_keeps_committed_verdict_and_releases_queued_reservations() { + let _serial = failpoint_lock().await; + failpoint::clear_all(); + let (_directory, state) = encrypted_inner(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let sink = test_finalization_sink(true, false, false, Some(entered_tx), Some(release_rx)); + let (_reads, writer) = + WriteCoordinator::dormant_with_sink(state, 4, 3, FinalizationSink::Test(Arc::clone(&sink))); + + let committed = writer + .submit(graph_intent(&writer, "committed-before-terminal")) + .await + .expect("first admitted") + .outcome() + .await + .expect("committed outcome delivered before sink completion"); + assert!(matches!(committed, CoordinatorOutcome::Committed { .. })); + entered_rx.recv().expect("committed manifest paused in sink"); + + failpoint::set("before_full_rotation_publish", Action::Pause); + let terminal = writer + .submit(structural_intent(&writer, LiveStructural::KeyFull { secret: secret() })) + .await + .expect("terminal rotation admitted"); + failpoint::wait_until_hit("before_full_rotation_publish"); + let queued = writer + .submit(graph_intent(&writer, "queued-after-terminal-intent")) + .await + .expect("queued intent admitted before terminal point"); + failpoint::set("after_generation_rename", Action::Error); + failpoint::resume("before_full_rotation_publish"); + failpoint::remove("before_full_rotation_publish"); + + assert!(matches!( + terminal.outcome().await.expect("typed terminal outcome"), + CoordinatorOutcome::StructuralReopenRequired { .. } + )); + release_tx.send(()).expect("release committed finalization"); + assert!(matches!(queued.outcome().await, Err(CoordinatorError::Terminal))); + failpoint::clear_all(); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + assert_eq!(sink.attempts.lock().expect("attempts").len(), 1); + assert_eq!(writer.core.finalization_permits.available_permits(), 3); + } + + #[tokio::test] + async fn live_structural_executes_all_four_owned_rotation_variants() { + let operations = [ + LiveStructural::KeyLight { secret: secret() }, + LiveStructural::PassphraseLight { + secret: secret(), + profile: Argon2idProfile::LowMemory, + }, + LiveStructural::KeyFull { secret: secret() }, + LiveStructural::PassphraseFull { + secret: secret(), + profile: Argon2idProfile::LowMemory, + }, + ]; + + for operation in operations { + let (_directory, state) = encrypted_inner(); + let (_reads, writer) = WriteCoordinator::dormant(state, 2); + let outcome = writer + .submit(structural_intent(&writer, operation)) + .await + .expect("structural intent admitted") + .outcome() + .await + .expect("structural outcome delivered"); + assert!(matches!( + outcome, + CoordinatorOutcome::Committed { + sequence_range: None, + .. + } + )); + writer.close().await.expect("committed rotation closes cleanly"); + } + } + + #[tokio::test] + async fn structural_unknown_maps_phase_retains_candidate_and_shuts_reads() { + let _serial = failpoint_lock().await; + failpoint::clear_all(); + let (_directory, state) = encrypted_inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 2); + + failpoint::set("during_generation_directory_sync", Action::Error); + let outcome = writer + .submit(structural_intent(&writer, LiveStructural::KeyFull { secret: secret() })) + .await + .expect("full rotation admitted") + .outcome() + .await + .expect("structural terminal outcome delivered"); + failpoint::clear_all(); + + assert!(matches!( + outcome, + CoordinatorOutcome::StructuralReopenRequired { + phase: StructuralCommitPhase::DirectorySync, + candidate_ownership: StructuralCandidateOwnership::RetainForRecovery, + .. + } + )); + assert_eq!(reads.read_sync(|_| ()), Err(CoordinatorError::Terminal)); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + } + + #[tokio::test] + async fn structural_panics_are_typed_for_light_and_full_rotations() { + let _serial = failpoint_lock().await; + for (site, operation, expected_phase) in [ + ( + "after_crypto_meta_write", + LiveStructural::KeyLight { secret: secret() }, + StructuralCommitPhase::CryptoGeneration, + ), + ( + "before_full_rotation_install", + LiveStructural::KeyFull { secret: secret() }, + StructuralCommitPhase::CatalogReplace, + ), + ] { + failpoint::clear_all(); + let (_directory, state) = encrypted_inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 2); + failpoint::set(site, Action::Panic); + let outcome = writer + .submit(structural_intent(&writer, operation)) + .await + .expect("rotation admitted") + .outcome() + .await + .expect("panic classified rather than escaping owner"); + failpoint::clear_all(); + + assert!(matches!( + outcome, + CoordinatorOutcome::StructuralReopenRequired { + phase, + candidate_ownership: StructuralCandidateOwnership::RetainForRecovery, + .. + } if phase == expected_phase + )); + assert_eq!(reads.read_sync(|_| ()), Err(CoordinatorError::Terminal)); + assert_eq!(writer.close().await, Err(CoordinatorError::Terminal)); + } + } + + #[tokio::test] + async fn store_rotation_capability_is_exact_runtime_bound_and_secret_is_capped() { + assert!(matches!( + OwnedRotationSecret::new(Vec::new()), + Err(CoordinatorError::InvalidIntent) + )); + assert!(matches!( + OwnedRotationSecret::new(vec![0_u8; MAX_ROTATION_SECRET_BYTES + 1]), + Err(CoordinatorError::InvalidIntent) + )); + + let (_first_directory, first_state) = encrypted_inner(); + let (_second_directory, second_state) = encrypted_inner(); + let (_first_reads, first) = WriteCoordinator::dormant(first_state, 2); + let (_second_reads, second) = WriteCoordinator::dormant(second_state, 2); + let foreign = first.authorize_rotation().expect("first runtime authorizes rotation"); + + assert!(matches!( + second + .submit(WriteIntent::LiveStructural { + authorization: foreign, + operation: LiveStructural::KeyLight { secret: secret() }, + }) + .await, + Err(CoordinatorError::WrongRuntime) + )); + first.close().await.expect("first coordinator closes"); + second.close().await.expect("second coordinator closes"); + } + + #[test] + fn dropping_last_writer_handle_is_non_blocking_and_reaper_releases_the_store() { + let (directory, state) = inner(); + let (reads, writer) = WriteCoordinator::dormant(state, 2); + let started = Instant::now(); + drop(writer); + assert!( + started.elapsed() < Duration::from_millis(100), + "Drop must transfer the blocking shutdown to the reaper" + ); + drop(reads); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match Engine::open(directory.path()) { + Ok(engine) => { + engine.close().expect("reopened engine closes"); + break; + } + Err(_) if Instant::now() < deadline => std::thread::yield_now(), + Err(cause) => panic!("reaper did not release writer lock: {cause}"), + } + } + } + + #[test] + fn product_write_token_from_another_cell_is_rejected() { + let (_first_directory, first) = inner(); + let (_second_directory, second) = inner(); + let (first_cell, _first_token) = ProductStateCell::new(first); + let (_second_cell, mut second_token) = ProductStateCell::new(second); + + assert_eq!( + first_cell.lock_for_owner(&mut second_token).map(|_| ()), + Err(CoordinatorError::WrongRuntime) + ); + } +} diff --git a/crates/basemyai/src/storage/native_store/mod.rs b/crates/basemyai/src/storage/native_store/mod.rs index 4a9b9be..c002fe9 100644 --- a/crates/basemyai/src/storage/native_store/mod.rs +++ b/crates/basemyai/src/storage/native_store/mod.rs @@ -82,6 +82,7 @@ //! pas lui-même une vérification, un merge ramène toujours le compte de SST //! sous le seuil. +mod coordinator; mod inner; mod porting; mod snapshot_ops; @@ -91,11 +92,12 @@ pub use porting::NativeExportRows; pub(crate) use porting::{NativeImportEdge, NativeImportEntity, NativeImportMemory}; use std::path::Path; -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::Arc; use basemyai_engine::{Engine, PersistentFts, PersistentGraph, PersistentMemoryIndex, PersistentVectorIndex}; use crate::Result; +use trait_impl::{coordinator_error, coordinator_unit, coordinator_value}; /// Préfixe KV des métadonnées conteneur (équivalent sémantique `bmai_meta`, ADR-019). /// Paires clé/valeur UTF-8 brutes que `basemyai` possède (contrat embedding, @@ -162,12 +164,8 @@ const OVERSAMPLE: usize = 8; /// (coût quasi nul) et la bascule finale (coût O(1)) touchent brièvement le /// verrou — voir le doc du module. pub struct NativeMemoryStore { - inner: Arc>, - /// Premier échec permanent d'une tâche interne. `OnceLock` est - /// volontairement utilisé plutôt qu'un mutex : cet état fail-stop ne - /// peut lui-même ni être empoisonné ni être remplacé par une erreur plus - /// récente qui masquerait la cause initiale. - background_error: Arc>, + reads: coordinator::ReadGateway, + writer: coordinator::WriteCoordinator, /// Garde de vie du répertoire temporaire d'[`Self::open_ephemeral`] — /// supprimé au drop du store (store éphémère test-only). #[cfg(any(test, feature = "test-util"))] @@ -182,35 +180,6 @@ struct NativeInner { fts: PersistentFts, } -#[derive(Debug)] -struct BackgroundFailure { - cause: String, -} - -impl BackgroundFailure { - fn as_error(&self) -> crate::MemoryError { - crate::MemoryError::BackgroundError { - cause: self.cause.clone(), - } - } -} - -fn record_background_error(state: &OnceLock, cause: String) -> crate::MemoryError { - let _ = state.set(BackgroundFailure { cause }); - state.get().expect("background failure was just initialized").as_error() -} - -impl NativeInner { - /// Les caches ne sont jamais une source de vérité. Après récupération - /// d'un `RwLock` empoisonné, les vider empêche une entrée partiellement - /// mise à jour avant le panic de survivre ; les lectures suivantes les - /// reconstruisent depuis l'état publié du moteur. - fn clear_reconstructible_caches(&self) { - self.engine.clear_block_cache(); - self.vectors.clear_cache(); - } -} - /// Mappe une erreur du backend natif (ou du pont async) en /// [`crate::MemoryError`]. fn storage(e: impl std::fmt::Display) -> crate::MemoryError { @@ -349,15 +318,17 @@ impl NativeMemoryStore { let vectors = PersistentVectorIndex::open(&mut engine, params).map_err(storage)?; let memory = PersistentMemoryIndex::open(&engine).map_err(storage)?; ensure_container_meta(&mut engine)?; + let state = NativeInner { + engine, + vectors, + memory, + graph: PersistentGraph::new(), + fts: PersistentFts::new(), + }; + let (reads, writer) = coordinator::WriteCoordinator::dormant(state, 256); Ok(Self { - inner: Arc::new(RwLock::new(NativeInner { - engine, - vectors, - memory, - graph: PersistentGraph::new(), - fts: PersistentFts::new(), - })), - background_error: Arc::new(OnceLock::new()), + reads, + writer, #[cfg(any(test, feature = "test-util"))] _tempdir: None, }) @@ -382,17 +353,17 @@ impl NativeMemoryStore { /// brute et conserve le secret dans un buffer zeroizable jusqu'à la fin /// de la closure bloquante. pub async fn rotate_with_key(&self, new_key: basemyai_core::EncryptionKey) -> Result<()> { - self.with_inner(move |inner| { - let result = match new_key.mode() { - basemyai_core::EncryptionKeyMode::RawKey => inner.engine.rotate_key(new_key.expose().as_bytes()), - basemyai_core::EncryptionKeyMode::Passphrase => { - inner.engine.rotate_passphrase(new_key.expose().as_bytes()) - } - _ => return Err(storage("unsupported encryption key mode")), - }; - result.map_err(map_engine_error) - }) - .await + let secret = new_key.expose().as_bytes().to_vec(); + let outcome = match new_key.mode() { + basemyai_core::EncryptionKeyMode::RawKey => self.writer.rotate_key(secret, false).await, + basemyai_core::EncryptionKeyMode::Passphrase => { + self.writer + .rotate_passphrase(secret, basemyai_engine::Argon2idProfile::default(), false) + .await + } + _ => return Err(storage("unsupported encryption key mode")), + }; + coordinator_unit(outcome) } /// Re-scelle la DEK avec une passphrase et un profil Argon2id explicite. @@ -406,29 +377,27 @@ impl NativeMemoryStore { if new_passphrase.mode() != basemyai_core::EncryptionKeyMode::Passphrase { return Err(storage("Argon2id profiles require a passphrase encryption key")); } - self.with_inner(move |inner| { - inner - .engine - .rotate_passphrase_with_profile(new_passphrase.expose().as_bytes(), profile) - .map_err(map_engine_error) - }) - .await + coordinator_unit( + self.writer + .rotate_passphrase(new_passphrase.expose().as_bytes().to_vec(), profile, false) + .await, + ) } /// Ré-encrypte tous les enregistrements vivants sous une nouvelle DEK, /// publie atomiquement la génération résultante puis collecte l'ancienne. pub async fn rotate_key_full(&self, new_key: basemyai_core::EncryptionKey) -> Result<()> { - self.with_inner(move |inner| { - let result = match new_key.mode() { - basemyai_core::EncryptionKeyMode::RawKey => inner.engine.rotate_key_full(new_key.expose().as_bytes()), - basemyai_core::EncryptionKeyMode::Passphrase => { - inner.engine.rotate_passphrase_full(new_key.expose().as_bytes()) - } - _ => return Err(storage("unsupported encryption key mode")), - }; - result.map_err(map_engine_error) - }) - .await + let secret = new_key.expose().as_bytes().to_vec(); + let outcome = match new_key.mode() { + basemyai_core::EncryptionKeyMode::RawKey => self.writer.rotate_key(secret, true).await, + basemyai_core::EncryptionKeyMode::Passphrase => { + self.writer + .rotate_passphrase(secret, basemyai_engine::Argon2idProfile::default(), true) + .await + } + _ => return Err(storage("unsupported encryption key mode")), + }; + coordinator_unit(outcome) } /// Rotation complète de DEK avec un profil Argon2id explicite. @@ -440,13 +409,11 @@ impl NativeMemoryStore { if new_passphrase.mode() != basemyai_core::EncryptionKeyMode::Passphrase { return Err(storage("Argon2id profiles require a passphrase encryption key")); } - self.with_inner(move |inner| { - inner - .engine - .rotate_passphrase_full_with_profile(new_passphrase.expose().as_bytes(), profile) - .map_err(map_engine_error) - }) - .await + coordinator_unit( + self.writer + .rotate_passphrase(new_passphrase.expose().as_bytes().to_vec(), profile, true) + .await, + ) } /// Store natif jetable dans un répertoire temporaire, supprimé au drop @@ -488,19 +455,22 @@ impl NativeMemoryStore { /// # Errors /// Erreur de stockage si le scan échoue. pub async fn container_metadata(&self) -> Result> { - self.with_inner_read(|inner| { - let entries = inner.engine.scan_prefix(BMAI_META_PREFIX).map_err(storage)?; - let mut out = Vec::with_capacity(entries.len()); - for (key, value) in entries { - let name = String::from_utf8(key.as_bytes()[BMAI_META_PREFIX.len()..].to_vec()) - .map_err(|e| storage(format!("nom de méta consommateur non UTF-8 : {e}")))?; - let value = String::from_utf8(value).map_err(|e| storage(format!("valeur de méta non UTF-8 : {e}")))?; - out.push((name, value)); - } - out.sort(); - Ok(out) - }) - .await + self.reads + .read(|inner| { + let entries = inner.engine.scan_prefix(BMAI_META_PREFIX).map_err(storage)?; + let mut out = Vec::with_capacity(entries.len()); + for (key, value) in entries { + let name = String::from_utf8(key.as_bytes()[BMAI_META_PREFIX.len()..].to_vec()) + .map_err(|e| storage(format!("nom de méta consommateur non UTF-8 : {e}")))?; + let value = + String::from_utf8(value).map_err(|e| storage(format!("valeur de méta non UTF-8 : {e}")))?; + out.push((name, value)); + } + out.sort(); + Ok(out) + }) + .await + .map_err(coordinator_error)? } /// Nombre total de souvenirs, **toutes couches et tous agents confondus** @@ -510,131 +480,10 @@ impl NativeMemoryStore { /// # Errors /// Erreur de stockage si le scan échoue. pub async fn total_memory_count(&self) -> Result { - self.with_inner_read(|inner| inner.memory.count_all(&inner.engine).map_err(storage)) + self.reads + .read(|inner| inner.memory.count_all(&inner.engine).map_err(storage)) .await - } - - /// Exécute `f` sur l'état natif dans le pool bloquant de tokio sous - /// verrou d'**écriture** (exclusif), pris à l'intérieur de la closure - /// (jamais à travers un `.await`) — les mutations (`put_memory*`, - /// `invalidate`, `forget`, `purge_agent`, `graph_upsert_*`, - /// `rotate_key`, et le `touch` des chemins hybrides) passent par ici. - /// - /// **La compaction ne passe plus par ici (R6.1, ADR-049 §1).** Ce point - /// portait jusqu'ici le déclenchement *et l'exécution* d'une passe de - /// compaction : `Engine::compaction_pending` était observé sous le verrou - /// d'écriture, puis `run_pending_compaction` était `await`é dans la future - /// de la mutation. Le verrou était bien relâché pour le merge (ADR-043 - /// CONC-P1) — mais le **temps** restait facturé à un appelant qui n'avait - /// rien demandé, ce qu'ADR-049 §Contexte 3 nomme comme le défaut à - /// corriger : « E5 est satisfait pour les lecteurs, pas pour le modèle - /// d'exécution ». - /// - /// Le moteur possède désormais son propre worker de compaction, déclenché - /// par le worker de flush au moment où celui-ci publie une SST. Une - /// mutation produit ne fait plus rien pour la compaction : ni la - /// déclencher, ni l'attendre, ni la joindre. L'erreur sticky d'un échec de - /// compaction vit maintenant dans le moteur (`EngineError::BackgroundFlush`, - /// partagée avec le flush) et refuse les mutations suivantes par le même - /// chemin que tout autre échec de fond. - async fn with_inner(&self, f: F) -> Result - where - T: Send + 'static, - F: FnOnce(&mut NativeInner) -> Result + Send + 'static, - { - self.with_inner_write(f).await - } - - fn background_error(&self) -> Option { - self.background_error.get().map(BackgroundFailure::as_error) - } - - fn record_background_error(&self, cause: impl Into) -> crate::MemoryError { - record_background_error(&self.background_error, cause.into()) - } - - /// Primitive partagée derrière [`Self::with_inner`] : exécute `f` sous le - /// verrou d'écriture, dans le pool bloquant de tokio. - /// - /// Ne fait **rien** pour la compaction depuis R6.1 : elle appartient au - /// worker possédé du moteur (ADR-049 §1). Avant, cette primitive rapportait - /// aussi `Engine::compaction_pending` à son appelant pour qu'il déclenche - /// et attende un merge — c'est précisément le couplage qu'ADR-049 supprime. - async fn with_inner_write(&self, f: F) -> Result - where - T: Send + 'static, - F: FnOnce(&mut NativeInner) -> Result + Send + 'static, - { - if let Some(error) = self.background_error() { - return Err(error); - } - let inner = Arc::clone(&self.inner); - let background_error = Arc::clone(&self.background_error); - let task = tokio::task::spawn_blocking(move || -> Result { - if let Some(error) = background_error.get().map(BackgroundFailure::as_error) { - return Err(error); - } - let mut guard = match inner.write() { - Ok(guard) => guard, - Err(poisoned) => { - let guard = poisoned.into_inner(); - guard.clear_reconstructible_caches(); - inner.clear_poison(); - return Err(record_background_error( - &background_error, - "native store write lock was poisoned by a panicked internal task".to_string(), - )); - } - }; - // Une erreur peut devenir sticky pendant l'attente du verrou. - if let Some(error) = background_error.get().map(BackgroundFailure::as_error) { - return Err(error); - } - f(&mut guard) - }); - match task.await { - Ok(result) => result, - Err(error) => { - Err(self - .record_background_error(format!("native store write task panicked or was interrupted: {error}"))) - } - } - } - - /// [`Self::with_inner`], sous verrou de **lecture** partagé (N5.5) : `f` - /// n'a droit qu'à `&NativeInner` — plusieurs lectures peuvent s'exécuter - /// concurremment tant qu'aucune écriture n'est en cours. Réservé aux - /// chemins qui ne mutent rien (ni les index, ni `last_access`). - async fn with_inner_read(&self, f: F) -> Result - where - T: Send + 'static, - F: FnOnce(&NativeInner) -> Result + Send + 'static, - { - let inner = Arc::clone(&self.inner); - let background_error = Arc::clone(&self.background_error); - let task = tokio::task::spawn_blocking(move || { - let guard = match inner.read() { - Ok(guard) => guard, - Err(poisoned) => { - let guard = poisoned.into_inner(); - guard.clear_reconstructible_caches(); - inner.clear_poison(); - let _ = record_background_error( - &background_error, - "native store state lock was poisoned by a panicked internal task".to_string(), - ); - guard - } - }; - f(&guard) - }); - match task.await { - Ok(result) => result, - Err(error) => { - Err(self - .record_background_error(format!("native store read task panicked or was interrupted: {error}"))) - } - } + .map_err(coordinator_error)? } /// Les identifiants d'agents du registre natif (ADR-041 §7.5), triés en @@ -651,8 +500,10 @@ impl NativeMemoryStore { /// # Errors /// Erreur de stockage si le scan échoue. pub async fn list_agents(&self) -> Result> { - self.with_inner_read(|inner| inner.memory.list_agents(&inner.engine).map_err(storage)) + self.reads + .read(|inner| inner.memory.list_agents(&inner.engine).map_err(storage)) .await + .map_err(coordinator_error)? } /// Prend un instantané figé de l'état courant du moteur (R7, ADR-046) : @@ -680,8 +531,10 @@ impl NativeMemoryStore { /// # Errors /// Erreur de stockage si le verrou interne est empoisonné. pub async fn read_snapshot(&self) -> Result> { - self.with_inner_read(|inner| Ok(Arc::new(inner.engine.snapshot()))) + self.reads + .read(|inner| Ok(Arc::new(inner.engine.snapshot()))) .await + .map_err(coordinator_error)? } /// Sémantique `INSERT OR IGNORE` puis lecture sur la méta consommateur @@ -689,229 +542,30 @@ impl NativeMemoryStore { /// écrasée) ; sinon écrit `value` et la renvoie. Brique du contrat /// embedding (`ensure_embedding_contract`). pub(crate) async fn meta_ensure(&self, name: &str, value: &str) -> Result { - let (name, value) = (name.to_string(), value.to_string()); - self.with_inner(move |inner| { - let key = bmai_meta_key(&name); - if let Some(existing) = inner.engine.get(&key).map_err(storage)? { - return String::from_utf8(existing) - .map_err(|e| storage(format!("méta consommateur {name:?} non UTF-8 : {e}"))); - } - inner.engine.put(&key, value.as_bytes()).map_err(storage)?; - Ok(value) - }) - .await - } -} - -#[cfg(test)] -mod tests { - use std::sync::OnceLock; - - use basemyai_engine::failpoint::{self, Action}; - - use super::*; - - async fn failpoint_lock() -> tokio::sync::MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| tokio::sync::Mutex::new(())).lock().await - } - - struct ClearFailpoints; - - impl Drop for ClearFailpoints { - fn drop(&mut self) { - failpoint::clear_all(); - } - } - - fn background_cause(error: crate::MemoryError) -> String { - match error { - crate::MemoryError::BackgroundError { cause } => cause, - other => panic!("expected typed background error, got {other:?}"), - } - } - - /// Drives the store to a state where the owned compaction worker has - /// nothing in flight and nothing pending — a condvar handshake, never a - /// sleep. Loops because settling can itself publish an SST and re-arm the - /// trigger; it terminates because each round strictly reduces the live - /// SST count towards the threshold. - async fn settle_compaction(store: &NativeMemoryStore) -> Result<()> { - for _ in 0..16 { - let pending = store - .with_inner(|inner| { - // Both workers, in dependency order. Flush first: it is - // the producer of the SSTs that make a compaction due, and - // it publishes asynchronously — draining compaction while - // a flush is still in flight would observe "nothing due" - // and return before the merge this test is about even - // exists. - inner.engine.flush().map_err(map_engine_error)?; - inner.engine.wait_for_compaction_idle().map_err(map_engine_error)?; - Ok(inner.engine.compaction_pending()) - }) - .await?; - if !pending { - return Ok(()); - } - } - panic!("compaction never settled"); - } - - fn compaction_options() -> basemyai_engine::EngineOptions { - basemyai_engine::EngineOptions { - memtable_flush_threshold: 1, - memtable_target_bytes: usize::MAX, - compaction_sst_threshold: 1, - ..basemyai_engine::EngineOptions::default() - } - } - - #[tokio::test] - async fn compaction_error_becomes_sticky_and_blocks_later_mutations() { - let _lock = failpoint_lock().await; - let _clear = ClearFailpoints; - let dir = tempfile::tempdir().expect("tempdir"); - let store = NativeMemoryStore::open_with_engine_options(dir.path(), compaction_options()).expect("open store"); - // Settle first. Opening the store writes its own container metadata - // and index roots, and with these options every write flushes — so - // compactions are already due before the test says anything. Arming - // the failpoint on an unsettled store would inject into whichever - // merge happened to be running, which is exactly the timing - // dependency this rewrite removes. - settle_compaction(&store).await.expect("a healthy store settles"); - - failpoint::set("during_compaction", Action::Error); - store - .with_inner(|inner| { - inner - .engine - .put(b"durable-before-error", b"value") - .map_err(map_engine_error) - }) + let outcome = self + .writer + .meta_ensure(name.to_string(), value.to_string()) .await - .expect("the triggering write was already durable"); - // R6.1: the merge runs on the owned worker, so the triggering write - // returns before it fails — that is the point of the milestone, and - // it is what makes this write's `Ok` meaningful. Synchronise on the - // worker going idle: it publishes its sticky failure *before* - // clearing `running`, so this is a handshake, not a sleep. - let drained = settle_compaction(&store).await; - assert!( - matches!(drained, Err(crate::MemoryError::BackgroundError { .. })), - "the drained worker must report the injected compaction failure, got {drained:?}" - ); - failpoint::clear_all(); - - let value = store - .with_inner_read(|inner| inner.engine.get(b"durable-before-error").map_err(map_engine_error)) - .await - .expect("reads continue after background error"); - assert_eq!(value.as_deref(), Some(&b"value"[..])); - - let first = background_cause( - store - .with_inner(|inner| inner.engine.put(b"must-not-write", b"x").map_err(map_engine_error)) - .await - .expect_err("later mutation must fail closed"), - ); - let second = background_cause( - store - .with_inner(|inner| inner.engine.put(b"also-blocked", b"x").map_err(map_engine_error)) - .await - .expect_err("sticky error must persist"), - ); - assert_eq!(first, second, "the first background cause stays sticky"); - assert!( - first.contains("during_compaction"), - "cause must not be swallowed: {first}" - ); - assert_eq!( - store - .with_inner_read(|inner| inner.engine.get(b"must-not-write").map_err(map_engine_error)) - .await - .expect("read blocked key"), - None - ); - } - - #[tokio::test] - async fn compaction_panic_is_captured_and_reads_continue() { - let _lock = failpoint_lock().await; - let _clear = ClearFailpoints; - let dir = tempfile::tempdir().expect("tempdir"); - let store = NativeMemoryStore::open_with_engine_options(dir.path(), compaction_options()).expect("open store"); - settle_compaction(&store).await.expect("a healthy store settles"); - - failpoint::set("during_compaction", Action::Panic); - store - .with_inner(|inner| { - inner - .engine - .put(b"durable-before-panic", b"value") - .map_err(map_engine_error) - }) - .await - .expect("the triggering write was already durable"); - // The panic unwinds on the compaction worker thread, is caught there - // and recorded before `running` is cleared — so draining is a - // deterministic handshake with the dying worker, not a race. - let drained = settle_compaction(&store).await; - assert!( - matches!(drained, Err(crate::MemoryError::BackgroundError { .. })), - "the drained worker must report the intercepted panic, got {drained:?}" - ); - failpoint::clear_all(); - - assert_eq!( - store - .with_inner_read(|inner| inner.engine.get(b"durable-before-panic").map_err(map_engine_error)) - .await - .expect("published state remains readable") - .as_deref(), - Some(&b"value"[..]) - ); - let cause = background_cause( - store - .with_inner(|inner| inner.engine.put(b"blocked-after-panic", b"x").map_err(map_engine_error)) - .await - .expect_err("panic must poison writes through sticky state"), - ); - assert!(cause.contains("panicked"), "panic must remain observable: {cause}"); - } - - #[tokio::test] - async fn poisoned_state_lock_is_recovered_for_reads_but_writes_fail_closed() { - let dir = tempfile::tempdir().expect("tempdir"); - let store = NativeMemoryStore::open(dir.path()).expect("open store"); - - let first = background_cause( - store - .with_inner::<(), _>(|_| -> Result<()> { - panic!("injected native state panic"); - }) - .await - .expect_err("task panic must surface typed"), - ); - assert!(first.contains("panicked"), "panic must remain observable: {first}"); - - let format = store - .with_inner_read(|inner| inner.engine.get(&bmai_meta_key("format")).map_err(map_engine_error)) - .await - .expect("poisoned lock is recovered for published reads"); - assert_eq!(format.as_deref(), Some(&b"basemyai-memory"[..])); - - let second = background_cause( - store - .with_inner(|inner| { - inner - .engine - .put(b"blocked-after-poison", b"x") - .map_err(map_engine_error) - }) - .await - .expect_err("mutations remain blocked"), - ); - assert_eq!(first, second, "lock recovery must not replace the first sticky cause"); + .map_err(coordinator_error)?; + match coordinator_value(outcome)? { + coordinator::WriteValue::Text(value) => Ok(value), + other => Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } } } + +// The pre-ADR-070 `#[cfg(test)] mod tests` that lived here exercised the +// removed `with_inner`/`with_inner_read` escape hatch directly (raw +// `Engine::put`/`get`/`flush` against `&mut NativeInner`) to probe sticky +// background-worker failures (`during_compaction` failpoint) and state-lock +// poisoning. That access point no longer exists by design (see the module +// doc above) — coverage now lives where it is actually exercisable: +// - sticky `during_compaction` Error/Panic failures against the raw engine: +// `crates/basemyai-engine/tests/crash/failpoints.rs` +// (`during_compaction_failure_keeps_every_pre_compaction_sst_readable`) +// and `crates/basemyai-engine/tests/engine/r6_owned_compaction.rs`. +// - a panic inside a coordinator write handler terminalising both the +// panicking and any queued intents (the modern replacement for "poisoned +// lock" semantics, which changed from *recovered* to *terminal*): +// `WriteCoordinator`'s own `#[cfg(test)] mod tests` in `coordinator.rs`, +// `handler_panic_terminalizes_queued_and_late_intents`. diff --git a/crates/basemyai/src/storage/native_store/porting.rs b/crates/basemyai/src/storage/native_store/porting.rs index 0ffed3d..449d678 100644 --- a/crates/basemyai/src/storage/native_store/porting.rs +++ b/crates/basemyai/src/storage/native_store/porting.rs @@ -3,10 +3,17 @@ //! totale (`importance`/`last_access` préservés, contrairement à //! `put_memory_batch` qui applique les défauts d'un souvenir neuf). +use std::collections::VecDeque; use std::sync::Arc; -use basemyai_engine::{EngineRead, NewMemoryRecord, PersistentGraph, PersistentMemoryIndex, ReadSnapshot}; +use basemyai_engine::{ + EngineRead, GraphEdgeMeta, GraphEntity, GraphSource, PersistentGraph, PersistentMemoryIndex, ReadSnapshot, +}; +use super::coordinator::{ + CoordinatorOutcome, ImportChunkReport, OwnedGraphEdge, OwnedGraphEntity, OwnedMemoryPut, WriteValue, +}; +use super::trait_impl::coordinator_error; use super::{NativeInner, NativeMemoryStore, storage}; use crate::{AgentId, MemoryLayer, Result}; @@ -54,6 +61,193 @@ pub(crate) struct NativeImportEdge { pub valid_until: Option, } +const IMPORT_CHUNK_MAX_ITEMS: usize = 128; +const IMPORT_CHUNK_MAX_BYTES: usize = 4 * 1024 * 1024; + +/// Owned, pull-based import plan. It retains the already-owned decoded rows, +/// but exposes only one bounded unit at a time to the future coordinator. +pub(super) struct NativeImportPlan { + memories: VecDeque, + entities: VecDeque, + edges: VecDeque, +} + +pub(super) enum NativeImportChunk { + Memories(Vec), + Entities(Vec), + Edges(Vec), +} + +/// Coordinator-ready owned payload. It contains no engine borrow, batch or +/// commit capability; the future façade can submit exactly one variant as +/// one bounded coordinator intent. +pub(super) enum CoordinatorImportChunk { + Memories(Vec), + Entities(Vec), + Edges(Vec), +} + +impl CoordinatorImportChunk { + pub(super) fn family(&self) -> ImportFamily { + match self { + Self::Memories(_) => ImportFamily::Memories, + Self::Entities(_) => ImportFamily::Entities, + Self::Edges(_) => ImportFamily::Edges, + } + } +} + +#[derive(Clone, Copy)] +pub(super) enum ImportFamily { + Memories, + Entities, + Edges, +} + +impl NativeImportChunk { + pub(super) fn into_coordinator(self) -> CoordinatorImportChunk { + match self { + Self::Memories(rows) => CoordinatorImportChunk::Memories( + rows.into_iter() + .map(|row| OwnedMemoryPut { + id: row.id, + layer: row.layer.table().to_owned(), + content: row.content, + source: row.source, + valid_from: row.valid_from, + valid_until: row.valid_until, + importance: row.importance, + last_access: row.last_access.unwrap_or(row.valid_from), + embedding: row.vector, + }) + .collect(), + ), + Self::Entities(rows) => CoordinatorImportChunk::Entities( + rows.into_iter() + .map(|row| OwnedGraphEntity { + id: row.id, + entity: GraphEntity { + kind: row.kind, + label: row.label, + valid_from: row.valid_from, + valid_until: row.valid_until, + source: GraphSource::Import, + }, + }) + .collect(), + ), + Self::Edges(rows) => CoordinatorImportChunk::Edges( + rows.into_iter() + .map(|row| OwnedGraphEdge { + src: row.src, + relation: row.relation, + dst: row.dst, + meta: GraphEdgeMeta { + weight: row.weight, + valid_from: row.valid_from, + valid_until: row.valid_until, + source: GraphSource::Import, + }, + }) + .collect(), + ), + } + } +} + +pub(super) fn aggregate_import_chunk(report: &mut crate::ImportReport, family: ImportFamily, chunk: ImportChunkReport) { + match family { + ImportFamily::Memories => { + report.memories += chunk.inserted; + report.memories_skipped += chunk.skipped; + } + ImportFamily::Entities => { + report.entities += chunk.inserted; + report.entities_skipped += chunk.skipped; + } + ImportFamily::Edges => { + report.edges += chunk.inserted; + report.edges_skipped += chunk.skipped; + } + } +} + +impl NativeImportPlan { + pub(super) fn new( + memories: Vec, + entities: Vec, + edges: Vec, + ) -> Self { + Self { + memories: memories.into(), + entities: entities.into(), + edges: edges.into(), + } + } + + pub(super) fn next_chunk(&mut self) -> Option { + if !self.memories.is_empty() { + return Some(NativeImportChunk::Memories(take_bounded( + &mut self.memories, + memory_footprint, + ))); + } + if !self.entities.is_empty() { + return Some(NativeImportChunk::Entities(take_bounded( + &mut self.entities, + entity_footprint, + ))); + } + if !self.edges.is_empty() { + return Some(NativeImportChunk::Edges(take_bounded(&mut self.edges, edge_footprint))); + } + None + } +} + +fn take_bounded(rows: &mut VecDeque, footprint: impl Fn(&T) -> usize) -> Vec { + let mut chunk = Vec::with_capacity(rows.len().min(IMPORT_CHUNK_MAX_ITEMS)); + let mut bytes = 0usize; + while chunk.len() < IMPORT_CHUNK_MAX_ITEMS { + let Some(next) = rows.front() else { + break; + }; + let next_bytes = footprint(next); + if !chunk.is_empty() && bytes.saturating_add(next_bytes) > IMPORT_CHUNK_MAX_BYTES { + break; + } + bytes = bytes.saturating_add(next_bytes); + chunk.push(rows.pop_front().expect("front row remains present")); + } + chunk +} + +fn memory_footprint(row: &NativeImportMemory) -> usize { + row.id + .len() + .saturating_add(row.layer.table().len()) + .saturating_add(row.content.len()) + .saturating_add(row.source.len()) + .saturating_add(row.vector.len().saturating_mul(size_of::())) + .saturating_add(128) +} + +fn entity_footprint(row: &NativeImportEntity) -> usize { + row.id + .len() + .saturating_add(row.kind.len()) + .saturating_add(row.label.len()) + .saturating_add(96) +} + +fn edge_footprint(row: &NativeImportEdge) -> usize { + row.src + .len() + .saturating_add(row.dst.len()) + .saturating_add(row.relation.len()) + .saturating_add(96) +} + /// Cœur partagé de [`NativeMemoryStore::export_rows`] et /// [`NativeMemoryStore::export_rows_at`] (R7) : mêmes 3 lectures, même tri /// déterministe, seule la source diffère (`&Engine` = « latest », ou un @@ -80,6 +274,30 @@ fn build_export_rows( }) } +fn committed_import_report(outcome: CoordinatorOutcome) -> Result { + match outcome { + CoordinatorOutcome::Committed { + value: WriteValue::ImportChunk(report), + .. + } => Ok(report), + CoordinatorOutcome::Committed { .. } => Err(storage("coordinator returned a non-import value for import")), + CoordinatorOutcome::Aborted { cause, .. } => { + Err(storage(cause.unwrap_or_else(|| { + "import chunk aborted before publication".to_owned() + }))) + } + CoordinatorOutcome::OutcomeUnknown { phase, cause, .. } => { + Err(storage(format!("import WAL outcome unknown during {phase:?}: {cause}"))) + } + CoordinatorOutcome::DurableReopenRequired { cause, .. } => Err(storage(format!( + "durable import requires reopen before a final report is knowable: {cause}" + ))), + CoordinatorOutcome::StructuralReopenRequired { phase, cause, .. } => Err(storage(format!( + "unexpected structural import outcome during {phase:?}: {cause}" + ))), + } +} + impl NativeMemoryStore { /// Tout ce qui appartient à `agent`, en lignes brutes du moteur — la /// brique de l'export JSONL (ADR-032). Tri déterministe pour l'export @@ -88,13 +306,15 @@ impl NativeMemoryStore { /// identique octet pour octet quel que soit le backend. pub async fn export_rows(&self, agent: &AgentId) -> Result { let agent = agent.clone(); - self.with_inner_read(move |inner| { - let NativeInner { - engine, memory, graph, .. - } = inner; - build_export_rows(engine, memory, *graph, agent.as_str()) - }) - .await + self.reads + .read(move |inner| { + let NativeInner { + engine, memory, graph, .. + } = inner; + build_export_rows(engine, memory, *graph, agent.as_str()) + }) + .await + .map_err(coordinator_error)? } /// [`Self::export_rows`] contre un instantané figé plutôt que « latest » @@ -109,22 +329,24 @@ impl NativeMemoryStore { /// Erreur de stockage si le scan échoue. pub async fn export_rows_at(&self, agent: &AgentId, snapshot: &Arc) -> Result { let (agent, snapshot) = (agent.clone(), Arc::clone(snapshot)); - self.with_inner_read(move |inner| { - let NativeInner { memory, graph, .. } = inner; - build_export_rows(snapshot.as_ref(), memory, *graph, agent.as_str()) - }) - .await + self.reads + .read(move |inner| { + let NativeInner { memory, graph, .. } = inner; + build_export_rows(snapshot.as_ref(), memory, *graph, agent.as_str()) + }) + .await + .map_err(coordinator_error)? } /// Import idempotent de lignes complètes (ADR-032) : les souvenirs - /// nouveaux partent en **un seul** batch WAL tout-ou-rien - /// (`put_many`, N5.5) avec leur fidélité complète + /// nouveaux partent en chunks bornés, chacun dans **un seul** batch WAL + /// tout-ou-rien (`stage_put_many`, N5.5), avec leur fidélité complète /// (`importance`/`last_access` préservés — contrairement à /// `put_memory_batch` qui applique les défauts d'un souvenir neuf) ; /// les ids déjà présents (dans le store **ou** plus haut dans le même /// fichier) sont comptés `*_skipped` et laissés intacts — la sémantique - /// Sémantique insert-or-ignore sur la méta consommateur. Entités et arêtes suivent en - /// upserts individuels durables : l'import natif est **idempotent et + /// Sémantique insert-or-ignore sur la méta consommateur. Entités et arêtes + /// suivent en batches bornés : l'import natif est **idempotent et /// reprennable**, pas globalement atomique (écart assumé, ADR-032 §3 — /// même classe que `purge_agent`, ADR-027 §6). pub(crate) async fn import_rows( @@ -134,114 +356,234 @@ impl NativeMemoryStore { entities: Vec, edges: Vec, ) -> Result { - let agent = agent.clone(); - self.with_inner(move |inner| { - let mut report = crate::ImportReport::default(); - - let NativeInner { - engine, - vectors, - memory, - graph, - fts, - } = &mut *inner; - - // ── Souvenirs : filtre des présents, un batch pour les neufs ── - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut fresh: Vec<&NativeImportMemory> = Vec::new(); - for m in &memories { - let exists = memory.get(engine, agent.as_str(), &m.id).map_err(storage)?.is_some(); - if exists || !seen.insert(m.id.as_str()) { - report.memories_skipped += 1; - } else { - fresh.push(m); + let agent = agent.as_str().to_string(); + let mut plan = NativeImportPlan::new(memories, entities, edges); + let mut report = crate::ImportReport::default(); + // Chaque chunk borné (ADR-032 §3) devient sa propre intention + // d'écriture coordinateur — plus de closure `&mut NativeInner` + // unique parcourant tout le plan (ADR-070) : la boucle vit ici, + // côté façade, et soumet un chunk à la fois. + while let Some(chunk) = plan.next_chunk() { + let coordinator_chunk = chunk.into_coordinator(); + let family = coordinator_chunk.family(); + let outcome = match coordinator_chunk { + CoordinatorImportChunk::Memories(items) => self.writer.import_memory_batch(agent.clone(), items).await, + CoordinatorImportChunk::Entities(items) => { + self.writer.import_graph_entity_batch(agent.clone(), items).await } + CoordinatorImportChunk::Edges(items) => self.writer.import_graph_edge_batch(agent.clone(), items).await, } - let entries: Vec<(&str, NewMemoryRecord<'_>, Vec)> = fresh - .iter() - .map(|m| { - ( - m.id.as_str(), - NewMemoryRecord { - layer: m.layer.table(), - content: &m.content, - source: &m.source, - valid_from: m.valid_from, - valid_until: m.valid_until, - importance: m.importance, - last_access: m.last_access.unwrap_or(m.valid_from), - }, - m.vector.clone(), - ) - }) - .collect(); - if !entries.is_empty() { - memory - .put_many(engine, vectors, fts, agent.as_str(), &entries) - .map_err(storage)?; - report.memories += entries.len(); + .map_err(coordinator_error)?; + let chunk_report = committed_import_report(outcome)?; + aggregate_import_chunk(&mut report, family, chunk_report); + } + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn memory(id: usize, content_bytes: usize) -> NativeImportMemory { + NativeImportMemory { + id: format!("m{id:04}"), + layer: MemoryLayer::Episodic, + content: "x".repeat(content_bytes), + source: "import".to_owned(), + valid_from: id as i64, + valid_until: None, + importance: 1.0, + last_access: Some(id as i64), + vector: vec![0.0; 8], + } + } + + fn entity(id: usize) -> NativeImportEntity { + NativeImportEntity { + id: format!("e{id:04}"), + kind: "person".to_owned(), + label: format!("Entity {id}"), + valid_from: id as i64, + valid_until: None, + } + } + + fn edge(id: usize) -> NativeImportEdge { + NativeImportEdge { + src: format!("e{id:04}"), + dst: format!("e{:04}", id + 1), + relation: "next".to_owned(), + weight: 1.0, + valid_from: id as i64, + valid_until: None, + } + } + + fn import_fixture() -> (Vec, Vec, Vec) { + let mut first_memory = memory(0, 8); + first_memory.vector = vec![0.0; crate::EMBEDDING_DIM]; + first_memory.vector[0] = 1.0; + let mut duplicate_memory = memory(0, 8); + duplicate_memory.vector = vec![0.0; crate::EMBEDDING_DIM]; + duplicate_memory.vector[0] = 1.0; + + let mut entities = (0..=IMPORT_CHUNK_MAX_ITEMS).map(entity).collect::>(); + entities.push(entity(0)); + (vec![first_memory, duplicate_memory], entities, vec![edge(0), edge(0)]) + } + + #[test] + fn import_plan_caps_items_and_preserves_family_order() { + let mut plan = NativeImportPlan::new( + (0..IMPORT_CHUNK_MAX_ITEMS + 1).map(|id| memory(id, 1)).collect(), + vec![entity(0)], + vec![edge(0)], + ); + assert!(matches!( + plan.next_chunk(), + Some(NativeImportChunk::Memories(rows)) if rows.len() == IMPORT_CHUNK_MAX_ITEMS + )); + assert!(matches!( + plan.next_chunk(), + Some(NativeImportChunk::Memories(rows)) if rows.len() == 1 + )); + assert!(matches!(plan.next_chunk(), Some(NativeImportChunk::Entities(rows)) if rows.len() == 1)); + assert!(matches!(plan.next_chunk(), Some(NativeImportChunk::Edges(rows)) if rows.len() == 1)); + assert!(plan.next_chunk().is_none()); + } + + #[test] + fn import_plan_caps_bytes_but_oversized_single_row_progresses() { + let mut plan = NativeImportPlan::new( + vec![memory(0, IMPORT_CHUNK_MAX_BYTES), memory(1, IMPORT_CHUNK_MAX_BYTES)], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + plan.next_chunk(), + Some(NativeImportChunk::Memories(rows)) if rows.len() == 1 + )); + assert!(matches!( + plan.next_chunk(), + Some(NativeImportChunk::Memories(rows)) if rows.len() == 1 + )); + assert!(plan.next_chunk().is_none()); + } + + #[test] + fn coordinator_payload_conversion_preserves_owned_import_semantics() { + let mut row = memory(7, 12); + row.last_access = None; + let memory_valid_from = row.valid_from; + let memory_vector = row.vector.clone(); + let converted = NativeImportChunk::Memories(vec![row]).into_coordinator(); + let CoordinatorImportChunk::Memories(rows) = converted else { + panic!("memory payload keeps its family"); + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].layer, MemoryLayer::Episodic.table()); + assert_eq!(rows[0].last_access, memory_valid_from); + assert_eq!(rows[0].embedding, memory_vector); + + let converted = NativeImportChunk::Entities(vec![entity(3)]).into_coordinator(); + let CoordinatorImportChunk::Entities(rows) = converted else { + panic!("entity payload keeps its family"); + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].entity.source, GraphSource::Import); + + let converted = NativeImportChunk::Edges(vec![edge(4)]).into_coordinator(); + let CoordinatorImportChunk::Edges(rows) = converted else { + panic!("edge payload keeps its family"); + }; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].meta.source, GraphSource::Import); + } + + #[test] + fn chunk_report_aggregation_preserves_insert_only_family_counts() { + let mut report = crate::ImportReport::default(); + aggregate_import_chunk( + &mut report, + ImportFamily::Memories, + ImportChunkReport { + inserted: 2, + skipped: 3, + }, + ); + aggregate_import_chunk( + &mut report, + ImportFamily::Entities, + ImportChunkReport { + inserted: 5, + skipped: 7, + }, + ); + aggregate_import_chunk( + &mut report, + ImportFamily::Edges, + ImportChunkReport { + inserted: 11, + skipped: 13, + }, + ); + assert_eq!( + report, + crate::ImportReport { + memories: 2, + memories_skipped: 3, + entities: 5, + entities_skipped: 7, + edges: 11, + edges_skipped: 13, } + ); + } - // ── Entités : `INSERT OR IGNORE` — jamais d'écrasement ──────── - for e in entities { - if graph.entity(engine, agent.as_str(), &e.id).map_err(storage)?.is_some() { - report.entities_skipped += 1; - continue; - } - graph - .upsert_entity( - engine, - agent.as_str(), - &e.id, - basemyai_engine::GraphEntity { - kind: e.kind, - label: e.label, - valid_from: e.valid_from, - valid_until: e.valid_until, - // ADR-045 (AGENT-MEM-1) : toujours `Import`, - // jamais lu depuis la ligne importée — même - // discipline anti-spoof qu'ADR-036 pour les - // souvenirs. `NativeImportEntity` n'a d'ailleurs - // aucun champ `source` à lire. - source: basemyai_engine::GraphSource::Import, - }, - ) - .map_err(storage)?; - report.entities += 1; + #[tokio::test] + async fn chunked_import_preserves_insert_only_report_and_resume() { + let store = NativeMemoryStore::open_ephemeral().expect("ephemeral store"); + let agent = AgentId::new("chunked-import").expect("valid agent"); + + let (memories, entities, edges) = import_fixture(); + let first = store + .import_rows(&agent, memories, entities, edges) + .await + .expect("first import"); + assert_eq!( + first, + crate::ImportReport { + memories: 1, + memories_skipped: 1, + entities: IMPORT_CHUNK_MAX_ITEMS + 1, + entities_skipped: 1, + edges: 1, + edges_skipped: 1, } + ); - // ── Arêtes : idem, la méta complète de l'export est préservée ─ - for e in edges { - if graph - .edge_meta(engine, agent.as_str(), &e.src, &e.relation, &e.dst) - .map_err(storage)? - .is_some() - { - report.edges_skipped += 1; - continue; - } - graph - .upsert_edge( - engine, - agent.as_str(), - &e.src, - &e.relation, - &e.dst, - basemyai_engine::GraphEdgeMeta { - weight: e.weight, - valid_from: e.valid_from, - valid_until: e.valid_until, - // ADR-045 (AGENT-MEM-1): always `Import`, same - // anti-spoof discipline as the entity above. - source: basemyai_engine::GraphSource::Import, - }, - ) - .map_err(storage)?; - report.edges += 1; + let (memories, entities, edges) = import_fixture(); + let resumed = store + .import_rows(&agent, memories, entities, edges) + .await + .expect("resumed import"); + assert_eq!( + resumed, + crate::ImportReport { + memories_skipped: 2, + entities_skipped: IMPORT_CHUNK_MAX_ITEMS + 2, + edges_skipped: 2, + ..crate::ImportReport::default() } + ); - Ok(report) - }) - .await + let exported = store.export_rows(&agent).await.expect("export imported rows"); + assert_eq!(exported.memories.len(), 1); + assert_eq!(exported.entities.len(), IMPORT_CHUNK_MAX_ITEMS + 1); + assert_eq!(exported.edges.len(), 1); + assert_eq!(exported.memories[0].1.importance, 1.0); + assert_eq!(exported.memories[0].1.last_access, 0); + assert_eq!(exported.edges[0].3.source, GraphSource::Import); } } diff --git a/crates/basemyai/src/storage/native_store/snapshot_ops.rs b/crates/basemyai/src/storage/native_store/snapshot_ops.rs index 66e08ff..9aa2557 100644 --- a/crates/basemyai/src/storage/native_store/snapshot_ops.rs +++ b/crates/basemyai/src/storage/native_store/snapshot_ops.rs @@ -21,10 +21,84 @@ use std::sync::Arc; use basemyai_engine::ReadSnapshot; -use super::{NativeMemoryStore, storage}; +use super::coordinator::{CoordinatorError, CoordinatorOutcome}; +use super::trait_impl::read_hydrate_from; +use super::{NativeInner, NativeMemoryStore}; use crate::storage::HydratedRecord; -use crate::temporal::Validity; -use crate::{AgentId, MemoryLayer, Result}; +use crate::{AgentId, Result}; + +fn coordinator_error(error: CoordinatorError) -> crate::MemoryError { + match error { + CoordinatorError::Terminal => basemyai_core::CoreError::WriterReconcileRequired { + cause: "native product coordinator entered terminal state".into(), + } + .into(), + CoordinatorError::Unauthorized | CoordinatorError::WrongRuntime => crate::MemoryError::UnauthorizedScope, + CoordinatorError::Closed => basemyai_core::CoreError::Storage("native store is closed".into()).into(), + CoordinatorError::IntentTooLarge { items, bytes } => basemyai_core::CoreError::Storage(format!( + "native write intent is too large: {items} items, {bytes} bytes" + )) + .into(), + CoordinatorError::InvalidIntent => { + basemyai_core::CoreError::Storage("invalid native write intent".into()).into() + } + CoordinatorError::CloseFailed(cause) => basemyai_core::CoreError::Storage(cause).into(), + } +} + +fn committed_touch(outcome: CoordinatorOutcome) -> Result<()> { + match outcome { + CoordinatorOutcome::Committed { .. } | CoordinatorOutcome::Aborted { cause: None, .. } => Ok(()), + CoordinatorOutcome::Aborted { cause: Some(cause), .. } => Err(basemyai_core::CoreError::Storage(cause).into()), + CoordinatorOutcome::OutcomeUnknown { phase, cause, .. } => { + Err(basemyai_core::CoreError::WriterReconcileRequired { + cause: format!("WAL {phase:?} outcome unknown: {cause}"), + } + .into()) + } + CoordinatorOutcome::DurableReopenRequired { cause, .. } + | CoordinatorOutcome::StructuralReopenRequired { cause, .. } => { + Err(basemyai_core::CoreError::WriterReconcileRequired { cause }.into()) + } + } +} + +pub(super) fn read_vector_ranking_ids_at( + inner: &NativeInner, + snapshot: &ReadSnapshot, + agent: &AgentId, + query: &[f32], + k: usize, + now: i64, + include_procedural: bool, +) -> Result> { + Ok(inner + .search_filtered_at(snapshot, agent, query, k, None, now, include_procedural)? + .into_iter() + .map(|(id, _, _)| id) + .collect()) +} + +pub(super) fn read_keyword_ranking_ids_at( + inner: &NativeInner, + snapshot: &ReadSnapshot, + agent: &AgentId, + match_expr: &str, + k: usize, + now: i64, + include_procedural: bool, +) -> Result> { + inner.keyword_ranked_ids_at(snapshot, agent, match_expr, k, now, include_procedural) +} + +pub(super) fn read_hydrate_at( + inner: &NativeInner, + snapshot: &ReadSnapshot, + agent: &AgentId, + ids: &[String], +) -> Result> { + read_hydrate_from(inner, snapshot, agent, ids) +} impl NativeMemoryStore { /// [`crate::storage::MemoryStore::vector_ranking_ids`] contre `snapshot` @@ -44,14 +118,10 @@ impl NativeMemoryStore { include_procedural: bool, ) -> Result> { let (snapshot, agent, query) = (Arc::clone(snapshot), agent.clone(), query.to_vec()); - self.with_inner_read(move |inner| { - Ok(inner - .search_filtered_at(&snapshot, &agent, &query, k, None, now, include_procedural)? - .into_iter() - .map(|(id, _, _)| id) - .collect()) - }) - .await + self.reads + .read(move |inner| read_vector_ranking_ids_at(inner, &snapshot, &agent, &query, k, now, include_procedural)) + .await + .map_err(coordinator_error)? } /// [`crate::storage::MemoryStore::keyword_ranking_ids`] contre `snapshot` @@ -69,10 +139,12 @@ impl NativeMemoryStore { include_procedural: bool, ) -> Result> { let (snapshot, agent, match_expr) = (Arc::clone(snapshot), agent.clone(), match_expr.to_string()); - self.with_inner_read(move |inner| { - inner.keyword_ranked_ids_at(&snapshot, &agent, &match_expr, k, now, include_procedural) - }) - .await + self.reads + .read(move |inner| { + read_keyword_ranking_ids_at(inner, &snapshot, &agent, &match_expr, k, now, include_procedural) + }) + .await + .map_err(coordinator_error)? } /// [`crate::storage::MemoryStore::hydrate`] contre `snapshot` plutôt que @@ -91,34 +163,18 @@ impl NativeMemoryStore { ) -> Result> { let (snapshot_read, agent2, ids2) = (Arc::clone(snapshot), agent.clone(), ids.to_vec()); let out = self - .with_inner_read(move |inner| { - let mut out = Vec::with_capacity(ids2.len()); - for id in &ids2 { - // Parité : pas de filtre de validité ici (l'original n'en - // a pas) ; un id absent ou d'un autre agent est omis. - if let Some(record) = inner - .memory - .get(snapshot_read.as_ref(), agent2.as_str(), id) - .map_err(storage)? - { - out.push(HydratedRecord { - id: id.clone(), - text: record.content, - layer: MemoryLayer::from_table(&record.layer)?, - source: record.source, - validity: Validity { - valid_from: record.valid_from, - valid_until: record.valid_until, - }, - }); - } - } - Ok(out) - }) - .await?; + .reads + .read(move |inner| read_hydrate_at(inner, &snapshot_read, &agent2, &ids2)) + .await + .map_err(coordinator_error)??; let agent = agent.clone(); let touched: Vec = out.iter().map(|r| r.id.clone()).collect(); - self.with_inner(move |inner| inner.touch(&agent, &touched, now)).await?; + committed_touch( + self.writer + .memory_touch(agent.as_str().to_owned(), touched, now) + .await + .map_err(coordinator_error)?, + )?; Ok(out) } } diff --git a/crates/basemyai/src/storage/native_store/trait_impl.rs b/crates/basemyai/src/storage/native_store/trait_impl.rs index f18e800..a3f525d 100644 --- a/crates/basemyai/src/storage/native_store/trait_impl.rs +++ b/crates/basemyai/src/storage/native_store/trait_impl.rs @@ -6,12 +6,74 @@ use basemyai_core::Metric; +use super::coordinator::{ + CoordinatorError, CoordinatorOutcome, OwnedForgetRecord, OwnedGraphEdgeUpsert, OwnedGraphEntity, OwnedMemoryPut, + WriteValue, +}; use super::{NativeInner, NativeMemoryStore, OVERSAMPLE, record_valid_at, storage}; use crate::cognition::Reached; use crate::storage::{HydratedRecord, MemoryStore, NewMemory}; use crate::temporal::Validity; use crate::{AgentId, AgentStats, MemoryLayer, Record, Result}; +pub(super) fn coordinator_error(error: CoordinatorError) -> crate::MemoryError { + storage(format!("product write coordinator rejected operation: {error:?}")) +} + +fn terminal_write(cause: String) -> crate::MemoryError { + basemyai_core::CoreError::WriterReconcileRequired { cause }.into() +} + +pub(super) fn coordinator_value(outcome: CoordinatorOutcome) -> Result { + match outcome { + CoordinatorOutcome::Committed { value, .. } => Ok(value), + CoordinatorOutcome::Aborted { cause: Some(cause), .. } => Err(storage(cause)), + CoordinatorOutcome::Aborted { cause: None, .. } => Ok(WriteValue::None), + CoordinatorOutcome::OutcomeUnknown { phase, cause, .. } => Err(terminal_write(format!( + "write outcome unknown during {phase:?}: {cause}" + ))), + CoordinatorOutcome::DurableReopenRequired { + sequence_range, cause, .. + } => Err(terminal_write(format!( + "durable write {:?}..={:?} requires reopen: {cause}", + sequence_range.first(), + sequence_range.last() + ))), + CoordinatorOutcome::StructuralReopenRequired { phase, cause, .. } => Err(terminal_write(format!( + "structural publication requires reopen during {phase:?}: {cause}" + ))), + } +} + +pub(super) fn expect_none(outcome: CoordinatorOutcome) -> Result<()> { + match coordinator_value(outcome)? { + WriteValue::None => Ok(()), + other => Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } +} + +/// Bridges a raw coordinator submission (`Result`) to a plain `Result<()>` for callers that expect +/// `WriteValue::None` on success — the common shape for rotate/meta-style +/// writes outside the [`super::trait_impl`] `MemoryStore` surface itself. +pub(super) fn coordinator_unit(outcome: std::result::Result) -> Result<()> { + expect_none(outcome.map_err(coordinator_error)?) +} + +fn owned_put(item: &OwnedNewMemory) -> OwnedMemoryPut { + OwnedMemoryPut { + id: item.id.clone(), + layer: item.layer.table().to_string(), + content: item.text.clone(), + source: item.source.clone(), + valid_from: item.validity.valid_from, + valid_until: item.validity.valid_until, + importance: item.importance, + last_access: item.validity.valid_from, + embedding: item.vector.clone(), + } +} + /// [`NewMemory`] possédé — le pont `spawn_blocking` exige des closures /// `'static`, or `NewMemory` emprunte texte/vecteur/source à l'appelant. struct OwnedNewMemory { @@ -66,18 +128,355 @@ fn retain_latest(records: &mut Vec<(String, basemyai_engine::MemoryRecord)>, lim records.truncate(limit); } +#[derive(Debug)] +pub(super) struct RecallRead { + pub(super) records: Vec, + pub(super) touch_ids: Vec, +} + +pub(super) fn read_layer_of(inner: &NativeInner, agent: &AgentId, id: &str) -> Result> { + inner + .memory + .get(&inner.engine, agent.as_str(), id) + .map_err(storage)? + .map(|record| MemoryLayer::from_table(&record.layer)) + .transpose() +} + +pub(super) fn read_list_memories( + inner: &NativeInner, + agent: &AgentId, + layer: Option, + limit: usize, + include_invalid: bool, + now: i64, +) -> Result> { + let NativeInner { engine, memory, .. } = inner; + if limit == 0 { + return Ok(Vec::new()); + } + let mut records = Vec::new(); + let mut cursor = None; + loop { + let page = memory + .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) + .map_err(storage)?; + let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; + let next_cursor = page.last().map(|(id, _)| id.clone()); + records.extend(page.into_iter().filter(|(_, record)| { + (include_invalid || record_valid_at(record, now)) + && layer.is_none_or(|wanted| record.layer == wanted.table()) + })); + retain_latest(&mut records, limit); + if exhausted { + break; + } + cursor = next_cursor; + } + records + .into_iter() + .map(|(id, record)| { + Ok(crate::storage::ListedRecord { + id, + layer: MemoryLayer::from_table(&record.layer)?, + content: record.content, + valid_from: record.valid_from, + valid_until: record.valid_until, + }) + }) + .collect() +} + +pub(super) fn read_scan_for_forgetting( + inner: &NativeInner, + agent: &AgentId, + now: i64, + after_id: Option<&str>, + limit: usize, +) -> Result> { + let NativeInner { engine, memory, .. } = inner; + let mut out = Vec::new(); + if limit == 0 { + return Ok(out); + } + let mut cursor = after_id.map(str::to_string); + loop { + let page = memory + .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), limit) + .map_err(storage)?; + let raw_len = page.len(); + let last_raw = page.last().map(|(id, _)| id.clone()); + for (id, record) in page { + if record_valid_at(&record, now) { + out.push(crate::storage::ForgetCandidate { + id, + importance: record.importance, + last_access: record.last_access, + }); + if out.len() == limit { + return Ok(out); + } + } + } + if raw_len < limit { + return Ok(out); + } + cursor = last_raw; + } +} + +pub(super) fn read_scan_expired( + inner: &NativeInner, + agent: &AgentId, + now: i64, + after_id: Option<&str>, + limit: usize, +) -> Result> { + let NativeInner { engine, memory, .. } = inner; + let mut candidates = memory.scan_expiring(engine, agent.as_str(), now).map_err(storage)?; + candidates.sort_by(|(a_id, _), (b_id, _)| a_id.cmp(b_id)); + let mut out: Vec<_> = candidates + .into_iter() + .filter(|(id, _)| after_id.is_none_or(|cursor| id.as_str() > cursor)) + .map(|(id, valid_until)| crate::storage::ExpiredCandidate { id, valid_until }) + .collect(); + out.truncate(limit); + Ok(out) +} + +fn records_and_touch_ids(found: Vec<(String, basemyai_engine::MemoryRecord, f32)>) -> Result { + let touch_ids = found.iter().map(|(id, _, _)| id.clone()).collect(); + let records = found + .into_iter() + .map(|(id, record, distance)| { + Ok(Record { + id, + text: record.content, + layer: MemoryLayer::from_table(&record.layer)?, + score: distance, + source: record.source, + validity: Validity { + valid_from: record.valid_from, + valid_until: record.valid_until, + }, + }) + }) + .collect::>>()?; + Ok(RecallRead { records, touch_ids }) +} + +pub(super) fn read_recall_vector( + inner: &NativeInner, + agent: &AgentId, + query: &[f32], + k: usize, + layer: Option, + now: i64, + include_procedural: bool, +) -> Result { + records_and_touch_ids(inner.search_filtered(agent, query, k, layer, now, include_procedural)?) +} + +pub(super) fn read_recall_graph_filtered( + inner: &NativeInner, + agent: &AgentId, + query: &[f32], + k: usize, + now: i64, + include_procedural: bool, + include_imported: bool, +) -> Result { + let labels: Vec = inner + .graph + .entities(&inner.engine, agent.as_str()) + .map_err(storage)? + .into_iter() + .filter(|(_, entity)| entity.valid_until.is_none_or(|until| until > now)) + .filter(|(_, entity)| include_imported || entity.source != basemyai_engine::GraphSource::Import) + .map(|(_, entity)| entity.label) + .collect(); + let found = inner + .search_filtered( + agent, + query, + k.saturating_mul(OVERSAMPLE), + None, + now, + include_procedural, + )? + .into_iter() + .filter(|(_, record, _)| labels.iter().any(|label| record.content.contains(label.as_str()))) + .take(k) + .collect(); + records_and_touch_ids(found) +} + +pub(super) fn read_vector_ranking_ids( + inner: &NativeInner, + agent: &AgentId, + query: &[f32], + k: usize, + now: i64, + include_procedural: bool, +) -> Result> { + Ok(inner + .search_filtered(agent, query, k, None, now, include_procedural)? + .into_iter() + .map(|(id, _, _)| id) + .collect()) +} + +pub(super) fn read_keyword_ranking_ids( + inner: &NativeInner, + agent: &AgentId, + match_expr: &str, + k: usize, + now: i64, + include_procedural: bool, +) -> Result> { + inner.keyword_ranked_ids(agent, match_expr, k, now, include_procedural) +} + +pub(super) fn read_hydrate_from( + inner: &NativeInner, + source: &R, + agent: &AgentId, + ids: &[String], +) -> Result> { + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(record) = inner.memory.get(source, agent.as_str(), id).map_err(storage)? { + out.push(HydratedRecord { + id: id.clone(), + text: record.content, + layer: MemoryLayer::from_table(&record.layer)?, + source: record.source, + validity: Validity { + valid_from: record.valid_from, + valid_until: record.valid_until, + }, + }); + } + } + Ok(out) +} + +pub(super) fn read_hydrate(inner: &NativeInner, agent: &AgentId, ids: &[String]) -> Result> { + read_hydrate_from(inner, &inner.engine, agent, ids) +} + +pub(super) fn read_agent_stats(inner: &NativeInner, agent: &AgentId, now: i64) -> Result { + let NativeInner { engine, memory, .. } = inner; + let mut stats = AgentStats::default(); + let mut cursor = None; + loop { + let page = memory + .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) + .map_err(storage)?; + let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; + let next_cursor = page.last().map(|(id, _)| id.clone()); + for (_, record) in page { + if !record_valid_at(&record, now) { + continue; + } + match record.layer.as_str() { + "short_term" => stats.short_term += 1, + "episodic" => stats.episodic += 1, + "procedural" => stats.procedural += 1, + "semantic" => stats.semantic += 1, + _ => {} + } + } + if exhausted { + break; + } + cursor = next_cursor; + } + Ok(stats) +} + +pub(super) fn read_graph_traverse( + inner: &NativeInner, + agent: &AgentId, + start: &str, + max_depth: u32, + now: i64, +) -> Result> { + Ok(inner + .graph + .traverse(&inner.engine, agent.as_str(), start, max_depth, now) + .map_err(storage)? + .into_iter() + .map(|reached| Reached { + id: reached.id, + kind: reached.kind, + label: reached.label, + depth: reached.depth, + }) + .collect()) +} + +pub(super) fn read_recent_episodes( + inner: &NativeInner, + agent: &AgentId, + limit: usize, + now: i64, +) -> Result> { + if limit == 0 { + return Ok(Vec::new()); + } + let mut episodes = Vec::new(); + let mut cursor = None; + loop { + let page = inner + .memory + .scan_agent_page(&inner.engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) + .map_err(storage)?; + let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; + let next_cursor = page.last().map(|(id, _)| id.clone()); + episodes.extend( + page.into_iter() + .filter(|(_, record)| record.layer == MemoryLayer::Episodic.table() && record_valid_at(record, now)), + ); + retain_latest(&mut episodes, limit); + if exhausted { + break; + } + cursor = next_cursor; + } + Ok(episodes.into_iter().map(|(_, record)| record.content).collect()) +} + +pub(super) fn read_exact_fact_exists(inner: &NativeInner, agent: &AgentId, content: &str, at: i64) -> Result { + let mut cursor = None; + loop { + let page = inner + .memory + .scan_agent_page(&inner.engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) + .map_err(storage)?; + let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; + let next_cursor = page.last().map(|(id, _)| id.clone()); + if page.into_iter().any(|(_, record)| { + record.layer == MemoryLayer::Semantic.table() && record.content == content && record_valid_at(&record, at) + }) { + return Ok(true); + } + if exhausted { + return Ok(false); + } + cursor = next_cursor; + } +} + #[async_trait::async_trait] impl MemoryStore for NativeMemoryStore { async fn layer_of(&self, agent: &AgentId, id: &str) -> Result> { let (agent, id) = (agent.clone(), id.to_string()); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - match inner.memory.get(&inner.engine, agent.as_str(), &id).map_err(storage)? { - Some(record) => Ok(Some(MemoryLayer::from_table(&record.layer)?)), - None => Ok(None), - } - }) - .await + self.reads + .read(move |inner| read_layer_of(inner, &agent, &id)) + .await + .map_err(coordinator_error)? } async fn list_memories( @@ -90,45 +489,10 @@ impl MemoryStore for NativeMemoryStore { ) -> Result> { let agent = agent.clone(); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - let NativeInner { engine, memory, .. } = inner; - if limit == 0 { - return Ok(Vec::new()); - } - let mut records: Vec<(String, basemyai_engine::MemoryRecord)> = Vec::new(); - let mut cursor: Option = None; - loop { - let page = memory - .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) - .map_err(storage)?; - let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; - let next_cursor = page.last().map(|(id, _)| id.clone()); - records.extend(page.into_iter().filter(|(_, record)| { - (include_invalid || record_valid_at(record, now)) - && layer.is_none_or(|wanted| record.layer == wanted.table()) - })); - // Au pic, cette collection contient au plus `limit` éléments - // conservés + une page brute. - retain_latest(&mut records, limit); - if exhausted { - break; - } - cursor = next_cursor; - } - records - .into_iter() - .map(|(id, record)| { - Ok(crate::storage::ListedRecord { - id, - layer: MemoryLayer::from_table(&record.layer)?, - content: record.content, - valid_from: record.valid_from, - valid_until: record.valid_until, - }) - }) - .collect() - }) - .await + self.reads + .read(move |inner| read_list_memories(inner, &agent, layer, limit, include_invalid, now)) + .await + .map_err(coordinator_error)? } async fn scan_for_forgetting( @@ -154,40 +518,10 @@ impl MemoryStore for NativeMemoryStore { // que le contrat « page courte ⇔ agent épuisé » reste vrai vu du // consommateur (les invalides sautés ne raccourcissent jamais une // page). - self.with_inner_read(move |inner| { - let NativeInner { engine, memory, .. } = inner; - let mut out: Vec = Vec::new(); - if limit == 0 { - return Ok(out); - } - let mut cursor = after_id; - loop { - let page = memory - .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), limit) - .map_err(storage)?; - let raw_len = page.len(); - let last_raw = page.last().map(|(id, _)| id.clone()); - for (id, record) in page { - if record_valid_at(&record, now) { - out.push(crate::storage::ForgetCandidate { - id, - importance: record.importance, - last_access: record.last_access, - }); - if out.len() == limit { - // Les bruts restants (> dernier candidat renvoyé) - // seront relus à l'appel suivant via le curseur. - return Ok(out); - } - } - } - if raw_len < limit { - return Ok(out); - } - cursor = last_raw; - } - }) - .await + self.reads + .read(move |inner| read_scan_for_forgetting(inner, &agent, now, after_id.as_deref(), limit)) + .await + .map_err(coordinator_error)? } async fn scan_expired( @@ -207,19 +541,10 @@ impl MemoryStore for NativeMemoryStore { // connue est refermé. Le tri par id + curseur restent en mémoire // (contrat public inchangé, `after_id`), mais désormais sur le seul // ensemble déjà filtré aux souvenirs expirés — jamais tout l'agent. - self.with_inner_read(move |inner| { - let NativeInner { engine, memory, .. } = inner; - let mut candidates = memory.scan_expiring(engine, agent.as_str(), now).map_err(storage)?; - candidates.sort_by(|(a_id, _), (b_id, _)| a_id.cmp(b_id)); - let mut out: Vec = candidates - .into_iter() - .filter(|(id, _)| after_id.as_deref().is_none_or(|cursor| id.as_str() > cursor)) - .map(|(id, valid_until)| crate::storage::ExpiredCandidate { id, valid_until }) - .collect(); - out.truncate(limit); - Ok(out) - }) - .await + self.reads + .read(move |inner| read_scan_expired(inner, &agent, now, after_id.as_deref(), limit)) + .await + .map_err(coordinator_error)? } async fn put_memory( @@ -243,23 +568,40 @@ impl MemoryStore for NativeMemoryStore { source: source.to_string(), importance, }; - self.with_inner(move |inner| inner.put_one(&agent, &item.borrowed())) + let outcome = self + .writer + .memory_put_batch(agent.as_str().to_string(), vec![owned_put(&item)]) .await + .map_err(coordinator_error)?; + match coordinator_value(outcome)? { + WriteValue::VectorIds(_) => Ok(()), + other => Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } } async fn set_importance(&self, agent: &AgentId, id: &str, importance: f64) -> Result<()> { let (agent, id) = (agent.clone(), id.to_string()); - self.with_inner(move |inner| { - let NativeInner { engine, memory, .. } = &mut *inner; - // Parité UPDATE : no-op silencieux si absent / autre agent — - // même discipline que `invalidate`. - if let Some(mut record) = memory.get(engine, agent.as_str(), &id).map_err(storage)? { - record.importance = importance; - memory.update(engine, agent.as_str(), &id, &record).map_err(storage)?; - } - Ok(()) - }) - .await + let (read_agent, read_id) = (agent.clone(), id.clone()); + let Some(mut record) = self + .reads + .read(move |inner| { + inner + .memory + .get(&inner.engine, read_agent.as_str(), &read_id) + .map_err(storage) + }) + .await + .map_err(coordinator_error)?? + else { + return Ok(()); + }; + record.importance = importance; + expect_none( + self.writer + .memory_update(agent.as_str().to_string(), id, record) + .await + .map_err(coordinator_error)?, + ) } async fn put_memory_batch(&self, agent: &AgentId, items: &[NewMemory<'_>]) -> Result<()> { @@ -269,12 +611,16 @@ impl MemoryStore for NativeMemoryStore { // Tout-ou-rien (N5.5) : un seul batch atomique côté moteur — voir // `NativeInner::put_many`. let agent = agent.clone(); - let owned_items: Vec = items.iter().map(owned).collect(); - self.with_inner(move |inner| { - let borrowed: Vec> = owned_items.iter().map(OwnedNewMemory::borrowed).collect(); - inner.put_many(&agent, &borrowed) - }) - .await + let owned_items = items.iter().map(owned).map(|item| owned_put(&item)).collect(); + let outcome = self + .writer + .memory_put_batch(agent.as_str().to_string(), owned_items) + .await + .map_err(coordinator_error)?; + match coordinator_value(outcome)? { + WriteValue::VectorIds(_) => Ok(()), + other => Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } } async fn recall_vector( @@ -301,27 +647,19 @@ impl MemoryStore for NativeMemoryStore { // seul sous verrou d'écriture bref — jamais toute la recherche sous // verrou exclusif. let (agent2, query2) = (agent.clone(), query.clone()); - let found = self - .with_inner_read(move |inner| inner.search_filtered(&agent2, &query2, k, layer, now, include_procedural)) - .await?; - let ids: Vec = found.iter().map(|(id, _, _)| id.clone()).collect(); - self.with_inner(move |inner| inner.touch(&agent, &ids, now)).await?; - found - .into_iter() - .map(|(id, record, distance)| { - Ok(Record { - id, - text: record.content, - layer: MemoryLayer::from_table(&record.layer)?, - score: distance, - source: record.source, - validity: Validity { - valid_from: record.valid_from, - valid_until: record.valid_until, - }, - }) - }) - .collect() + let read = self + .reads + .read(move |inner| read_recall_vector(inner, &agent2, &query2, k, layer, now, include_procedural)) + .await + .map_err(coordinator_error)??; + let RecallRead { records, touch_ids } = read; + expect_none( + self.writer + .memory_touch(agent.as_str().to_string(), touch_ids, now) + .await + .map_err(coordinator_error)?, + )?; + Ok(records) } async fn recall_graph_filtered( @@ -336,58 +674,21 @@ impl MemoryStore for NativeMemoryStore { let (agent, query) = (agent.clone(), query.to_vec()); // Même découpage lecture/écriture que `recall_vector` (N5.5). let (agent2, query2) = (agent.clone(), query.clone()); - let found = self - .with_inner_read(move |inner| { - // Les labels des entités valides de l'agent (comme l'EXISTS - // original, seule `valid_until` gate la visibilité d'une entité) - // — et, par défaut, jamais celles réimportées (ADR-045, - // AGENT-MEM-1) : un label empoisonné via un export forgé - // réimporté ne doit pas influencer silencieusement le - // classement, sauf demande explicite (`include_imported`). - let labels: Vec = inner - .graph - .entities(&inner.engine, agent2.as_str()) - .map_err(storage)? - .into_iter() - .filter(|(_, e)| e.valid_until.is_none_or(|until| until > now)) - .filter(|(_, e)| include_imported || e.source != basemyai_engine::GraphSource::Import) - .map(|(_, e)| e.label) - .collect(); - // Oversample large puis filtre « le contenu mentionne un - // label » (l'`instr(content, entity.label) > 0` original). - let candidates = inner.search_filtered( - &agent2, - &query2, - k.saturating_mul(OVERSAMPLE), - None, - now, - include_procedural, - )?; - Ok(candidates - .into_iter() - .filter(|(_, record, _)| labels.iter().any(|label| record.content.contains(label.as_str()))) - .take(k) - .collect::>()) - }) - .await?; - let ids: Vec = found.iter().map(|(id, _, _)| id.clone()).collect(); - self.with_inner(move |inner| inner.touch(&agent, &ids, now)).await?; - found - .into_iter() - .map(|(id, record, distance)| { - Ok(Record { - id, - text: record.content, - layer: MemoryLayer::from_table(&record.layer)?, - score: distance, - source: record.source, - validity: Validity { - valid_from: record.valid_from, - valid_until: record.valid_until, - }, - }) + let read = self + .reads + .read(move |inner| { + read_recall_graph_filtered(inner, &agent2, &query2, k, now, include_procedural, include_imported) }) - .collect() + .await + .map_err(coordinator_error)??; + let RecallRead { records, touch_ids } = read; + expect_none( + self.writer + .memory_touch(agent.as_str().to_string(), touch_ids, now) + .await + .map_err(coordinator_error)?, + )?; + Ok(records) } async fn vector_ranking_ids( @@ -401,14 +702,10 @@ impl MemoryStore for NativeMemoryStore { let (agent, query) = (agent.clone(), query.to_vec()); // Lecture pure — aucun `touch` (parité avec l'original, ADR-027 // §6) — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - Ok(inner - .search_filtered(&agent, &query, k, None, now, include_procedural)? - .into_iter() - .map(|(id, _, _)| id) - .collect()) - }) - .await + self.reads + .read(move |inner| read_vector_ranking_ids(inner, &agent, &query, k, now, include_procedural)) + .await + .map_err(coordinator_error)? } async fn keyword_ranking_ids( @@ -421,8 +718,10 @@ impl MemoryStore for NativeMemoryStore { ) -> Result> { let (agent, match_expr) = (agent.clone(), match_expr.to_string()); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| inner.keyword_ranked_ids(&agent, &match_expr, k, now, include_procedural)) + self.reads + .read(move |inner| read_keyword_ranking_ids(inner, &agent, &match_expr, k, now, include_procedural)) .await + .map_err(coordinator_error)? } async fn hydrate(&self, agent: &AgentId, ids: &[String], now: i64) -> Result> { @@ -430,63 +729,56 @@ impl MemoryStore for NativeMemoryStore { // Même découpage lecture/écriture que `recall_vector` (N5.5). let (agent2, ids2) = (agent.clone(), ids.clone()); let out = self - .with_inner_read(move |inner| { - let mut out = Vec::with_capacity(ids2.len()); - for id in &ids2 { - // Parité : pas de filtre de validité ici (l'original n'en - // a pas) ; un id absent ou d'un autre agent est omis. - if let Some(record) = inner.memory.get(&inner.engine, agent2.as_str(), id).map_err(storage)? { - out.push(HydratedRecord { - id: id.clone(), - text: record.content, - layer: MemoryLayer::from_table(&record.layer)?, - source: record.source, - validity: Validity { - valid_from: record.valid_from, - valid_until: record.valid_until, - }, - }); - } - } - Ok(out) - }) - .await?; + .reads + .read(move |inner| read_hydrate(inner, &agent2, &ids2)) + .await + .map_err(coordinator_error)??; let touched: Vec = out.iter().map(|r| r.id.clone()).collect(); - self.with_inner(move |inner| inner.touch(&agent, &touched, now)).await?; + expect_none( + self.writer + .memory_touch(agent.as_str().to_string(), touched, now) + .await + .map_err(coordinator_error)?, + )?; Ok(out) } async fn invalidate(&self, agent: &AgentId, id: &str, now: i64) -> Result<()> { let (agent, id) = (agent.clone(), id.to_string()); - self.with_inner(move |inner| { - let NativeInner { engine, memory, .. } = &mut *inner; - // Parité UPDATE : no-op silencieux si absent / autre agent. - if let Some(mut record) = memory.get(engine, agent.as_str(), &id).map_err(storage)? { - record.valid_until = Some(now); - memory.update(engine, agent.as_str(), &id, &record).map_err(storage)?; - } - Ok(()) - }) - .await + let (read_agent, read_id) = (agent.clone(), id.clone()); + let Some(mut record) = self + .reads + .read(move |inner| { + inner + .memory + .get(&inner.engine, read_agent.as_str(), &read_id) + .map_err(storage) + }) + .await + .map_err(coordinator_error)?? + else { + return Ok(()); + }; + record.valid_until = Some(now); + expect_none( + self.writer + .memory_update(agent.as_str().to_string(), id, record) + .await + .map_err(coordinator_error)?, + ) } async fn forget(&self, agent: &AgentId, id: &str) -> Result<()> { let (agent, id) = (agent.clone(), id.to_string()); - self.with_inner(move |inner| { - let NativeInner { - engine, - vectors, - memory, - fts, - .. - } = &mut *inner; - // Parité DELETE : no-op silencieux si absent (bool ignoré). - memory - .forget(engine, vectors, fts, agent.as_str(), &id) - .map_err(storage)?; - Ok(()) - }) - .await + let outcome = self + .writer + .memory_forget_single(agent.as_str().to_string(), id) + .await + .map_err(coordinator_error)?; + match coordinator_value(outcome)? { + WriteValue::Present(_) => Ok(()), + other => Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } } async fn forget_many( @@ -498,87 +790,101 @@ impl MemoryStore for NativeMemoryStore { if ids.is_empty() { return Ok(0); } - let (agent, ids) = (agent.clone(), ids.to_vec()); - self.with_inner(move |inner| { - let NativeInner { - engine, - vectors, - memory, - fts, - .. - } = &mut *inner; - let borrowed: Vec<&str> = ids.iter().map(String::as_str).collect(); - memory - .forget_many( - engine, - vectors, - fts, - agent.as_str(), - &borrowed, - basemyai_engine::ForgetBatchOptions { - max_items: options.max_items, - max_wal_bytes: options.max_wal_bytes, - }, - ) - .map_err(storage) - }) - .await + // Épuise `ids` en lots bornés (`max_items`/`max_wal_bytes`, + // ADR-070) : chaque lot construit son `Vec` + // (id, vec_id, valid_until — le triplet que le handler + // `MemoryForgetChunk` revalide avant de committer, coordinator.rs + // ~1664) sous le verrou de lecture partagé, puis se soumet comme sa + // propre transaction moteur avant que le lot suivant ne soit + // construit — jamais toute la liste sous un seul verrou. Un id + // absent, déjà supprimé par un lot précédent de ce même appel, ou en + // double dans `ids` est silencieusement ignoré (parité DELETE + // documentée par le trait), sans jamais bloquer la progression : + // chaque tour avance d'au moins un id. + let max_items = options.max_items.max(1); + let max_bytes = options.max_wal_bytes; + let mut total_removed = 0u64; + let mut pos = 0usize; + while pos < ids.len() { + let read_agent = agent.clone(); + let remaining = ids[pos..].to_vec(); + let (records, advanced) = self + .reads + .read(move |inner| -> Result<(Vec, usize)> { + let mut records = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut bytes = 0usize; + let mut advanced = 0usize; + for id in &remaining { + if records.len() >= max_items { + break; + } + if !seen.insert(id.as_str()) { + advanced += 1; + continue; + } + let Some(stored) = inner + .memory + .get(&inner.engine, read_agent.as_str(), id) + .map_err(storage)? + else { + advanced += 1; + continue; + }; + let item_bytes = id.len().saturating_add(64); + if !records.is_empty() && bytes.saturating_add(item_bytes) > max_bytes { + break; + } + bytes = bytes.saturating_add(item_bytes); + advanced += 1; + records.push(OwnedForgetRecord { + id: id.clone(), + vec_id: stored.vec_id, + valid_until: stored.valid_until, + }); + } + // An id whose own footprint alone exceeds `max_wal_bytes` + // must still leave, alone, in its own chunk (per-item + // atomicity is the documented floor) — never stall. + if advanced == 0 { + advanced = 1; + } + Ok((records, advanced)) + }) + .await + .map_err(coordinator_error)??; + pos += advanced; + if records.is_empty() { + continue; + } + let outcome = self + .writer + .memory_forget_chunk(agent.as_str().to_string(), records) + .await + .map_err(coordinator_error)?; + match coordinator_value(outcome)? { + WriteValue::Count(n) => total_removed += n, + other => return Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } + } + Ok(total_removed) } async fn purge_agent(&self, agent: &AgentId) -> Result<()> { let agent = agent.clone(); - self.with_inner(move |inner| { - let NativeInner { - engine, - vectors, - memory, - graph, - fts, - } = &mut *inner; - memory - .purge_agent(engine, vectors, fts, agent.as_str()) - .map_err(storage)?; - graph.purge_agent(engine, agent.as_str()).map_err(storage)?; - Ok(()) - }) - .await + self.writer + .purge_agent(agent.as_str().to_string()) + .await + .map_err(coordinator_error) } async fn agent_stats(&self, agent: &AgentId, now: i64) -> Result { let agent = agent.clone(); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - let NativeInner { engine, memory, .. } = inner; - let mut stats = AgentStats::default(); - let mut cursor: Option = None; - loop { - let page = memory - .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) - .map_err(storage)?; - let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; - let next_cursor = page.last().map(|(id, _)| id.clone()); - for (_, record) in page { - if !record_valid_at(&record, now) { - continue; - } - // Parité GROUP BY : une couche inconnue est ignorée, - // jamais une erreur. - match record.layer.as_str() { - "short_term" => stats.short_term += 1, - "episodic" => stats.episodic += 1, - "procedural" => stats.procedural += 1, - "semantic" => stats.semantic += 1, - _ => {} - } - } - if exhausted { - break; - } - cursor = next_cursor; - } - Ok(stats) - }) - .await + self.reads + .read(move |inner| read_agent_stats(inner, &agent, now)) + .await + .map_err(coordinator_error)? } async fn graph_upsert_entity( @@ -598,15 +904,18 @@ impl MemoryStore for NativeMemoryStore { valid_until: validity.valid_until, source, }; - self.with_inner(move |inner| { - let NativeInner { engine, graph, .. } = &mut *inner; - // Parité upsert entité : kind/label/valid_* préservés si l'id existe. - // écrasement complet. - graph - .upsert_entity(engine, agent.as_str(), &id, entity) - .map_err(storage) - }) - .await + // Parité upsert entité : écrasement complet (pas de préservation de + // méta existante, contrairement à l'arête ci-dessous — ADR-045 ne + // s'applique qu'aux arêtes). + let outcome = self + .writer + .graph_entity_batch(agent.as_str().to_string(), vec![OwnedGraphEntity { id, entity }]) + .await + .map_err(coordinator_error)?; + match coordinator_value(outcome)? { + WriteValue::Count(_) => Ok(()), + other => Err(storage(format!("unexpected coordinator outcome: {other:?}"))), + } } async fn graph_upsert_edge( @@ -620,105 +929,55 @@ impl MemoryStore for NativeMemoryStore { source: basemyai_engine::GraphSource, ) -> Result<()> { let (agent, src, relation, dst) = (agent.clone(), src.to_string(), relation.to_string(), dst.to_string()); - self.with_inner(move |inner| { - let NativeInner { engine, graph, .. } = &mut *inner; - // Parité upsert arête : seul le poids est mis à jour si l'arête existe. - // `source` de l'arête existante est préservée (ADR-045) — ne - // s'applique qu'à une création. - let meta = match graph - .edge_meta(engine, agent.as_str(), &src, &relation, &dst) - .map_err(storage)? - { - Some(existing) => basemyai_engine::GraphEdgeMeta { weight, ..existing }, - None => basemyai_engine::GraphEdgeMeta { - weight, - valid_from: now, - valid_until: None, - source, - }, - }; - graph - .upsert_edge(engine, agent.as_str(), &src, &relation, &dst, meta) - .map_err(storage) - }) - .await + // Parité upsert arête : seul le poids est mis à jour si l'arête existe, + // `source`/`valid_from` de l'arête existante préservés (ADR-045) — ne + // s'appliquent qu'à une création. Le handler `WriteIntent::GraphEdgeUpsert` + // (coordinator.rs) implémente déjà exactement cette lecture-puis-décision + // sous le verrou d'écriture du coordinateur — pas besoin de la refaire ici + // en deux passes séparées (voir le test + // `graph_edge_upsert_preserves_owner_observed_metadata`). + expect_none( + self.writer + .graph_edge_upsert( + agent.as_str().to_string(), + OwnedGraphEdgeUpsert { + src, + relation, + dst, + weight, + now, + source, + }, + ) + .await + .map_err(coordinator_error)?, + ) } async fn graph_traverse(&self, agent: &AgentId, start: &str, max_depth: u32, now: i64) -> Result> { let (agent, start) = (agent.clone(), start.to_string()); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - let NativeInner { engine, graph, .. } = inner; - Ok(graph - .traverse(engine, agent.as_str(), &start, max_depth, now) - .map_err(storage)? - .into_iter() - .map(|r| Reached { - id: r.id, - kind: r.kind, - label: r.label, - depth: r.depth, - }) - .collect()) - }) - .await + self.reads + .read(move |inner| read_graph_traverse(inner, &agent, &start, max_depth, now)) + .await + .map_err(coordinator_error)? } async fn recent_episodes(&self, agent: &AgentId, limit: usize, now: i64) -> Result> { let agent = agent.clone(); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - let NativeInner { engine, memory, .. } = inner; - if limit == 0 { - return Ok(Vec::new()); - } - let mut episodes: Vec<(String, basemyai_engine::MemoryRecord)> = Vec::new(); - let mut cursor: Option = None; - loop { - let page = memory - .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) - .map_err(storage)?; - let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; - let next_cursor = page.last().map(|(id, _)| id.clone()); - episodes.extend(page.into_iter().filter(|(_, record)| { - record.layer == MemoryLayer::Episodic.table() && record_valid_at(record, now) - })); - retain_latest(&mut episodes, limit); - if exhausted { - break; - } - cursor = next_cursor; - } - Ok(episodes.into_iter().map(|(_, record)| record.content).collect()) - }) - .await + self.reads + .read(move |inner| read_recent_episodes(inner, &agent, limit, now)) + .await + .map_err(coordinator_error)? } async fn exact_fact_exists(&self, agent: &AgentId, content: &str, at: i64) -> Result { let (agent, content) = (agent.clone(), content.to_string()); // Lecture pure — verrou de lecture partagé (N5.5). - self.with_inner_read(move |inner| { - let NativeInner { engine, memory, .. } = inner; - let mut cursor: Option = None; - loop { - let page = memory - .scan_agent_page(engine, agent.as_str(), cursor.as_deref(), PRODUCT_SCAN_PAGE_SIZE) - .map_err(storage)?; - let exhausted = page.len() < PRODUCT_SCAN_PAGE_SIZE; - let next_cursor = page.last().map(|(id, _)| id.clone()); - if page.into_iter().any(|(_, record)| { - record.layer == MemoryLayer::Semantic.table() - && record.content == content - && record_valid_at(&record, at) - }) { - return Ok(true); - } - if exhausted { - return Ok(false); - } - cursor = next_cursor; - } - }) - .await + self.reads + .read(move |inner| read_exact_fact_exists(inner, &agent, &content, at)) + .await + .map_err(coordinator_error)? } } diff --git a/crates/basemyai/tests/isolation/p1_isolation_adversarial.rs b/crates/basemyai/tests/isolation/p1_isolation_adversarial.rs index 69bb79f..c1da477 100644 --- a/crates/basemyai/tests/isolation/p1_isolation_adversarial.rs +++ b/crates/basemyai/tests/isolation/p1_isolation_adversarial.rs @@ -1,10 +1,11 @@ //! Public adversarial isolation proof for P1 market differentiation. //! -//! This test intentionally uses hostile-looking `agent_id`, text, FTS -//! queries, known foreign ids, and graph ids. The invariant under test is -//! simple: knowing another agent's identifiers or injecting SQL-looking text -//! must not bypass the structural per-agent isolation boundary (ADR-006, -//! ADR-027 §2 — key-prefix isolation on the native backend). +//! This test intentionally uses a rejected hostile-looking `agent_id`, hostile +//! text and FTS queries, known foreign ids, and graph ids. The invariant under +//! test is simple: knowing another agent's identifiers or injecting +//! SQL-looking text must not bypass the native per-agent isolation boundary. +//! Record/FTS/graph keys are structurally scoped; vector candidates remain +//! post-filtered until V2-08 (ADR-006, ADR-027 §2, ADR-069). use basemyai::temporal::Validity; use basemyai::{AgentId, Memory, MemoryLayer}; @@ -46,7 +47,7 @@ impl Embedder for FakeEmbedder { } fn agent(id: &str) -> AgentId { - AgentId::new(id).expect("non-empty agent id") + AgentId::new(id).expect("valid agent id") } #[tokio::test] @@ -78,10 +79,17 @@ async fn hostile_agent_id_query_and_known_ids_do_not_cross_tenant_boundary() { .await .expect("edge"); - let hostile = "agent-b' OR '1'='1"; - let mem_b = Memory::from_native_store(std::sync::Arc::clone(&store), Box::new(FakeEmbedder), agent(hostile)) - .await - .expect("open hostile memory"); + assert!( + AgentId::new("agent-b' OR '1'='1").is_none(), + "injection-shaped agent identifiers must be rejected at parsing" + ); + let mem_b = Memory::from_native_store( + std::sync::Arc::clone(&store), + Box::new(FakeEmbedder), + agent("agent-b.adversarial-1"), + ) + .await + .expect("open adversarial memory"); mem_b .remember("public token SABLE-000 belongs only to agent B", MemoryLayer::Semantic) .await diff --git a/crates/basemyai/tests/memory/events.rs b/crates/basemyai/tests/memory/events.rs index 04f2094..f9ab300 100644 --- a/crates/basemyai/tests/memory/events.rs +++ b/crates/basemyai/tests/memory/events.rs @@ -47,7 +47,7 @@ impl Embedder for FakeEmbedder { } fn agent(id: &str) -> AgentId { - AgentId::new(id).expect("non-empty agent id") + AgentId::new(id).expect("valid agent id") } async fn open_memory(agent_id: &str) -> Memory { @@ -70,8 +70,9 @@ async fn recv_soon(sub: &mut basemyai::MemorySubscription) -> Option(mut attempt: impl FnMut() -> basemyai::Result) -> basemyai::Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match attempt() { + Err(basemyai::MemoryError::Core(basemyai_core::CoreError::StoreLocked)) + if std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + other => return other, + } + } +} + /// Enregistre un backend : génère un `#[tokio::test]` qui rejoue **tous** les /// scénarios de `memory_tests::scenarios::all()` contre une instance fraîche /// du backend nommé `$backend`, construite par `$make`. @@ -327,7 +349,8 @@ async fn native_rotate_key_preserves_data_and_invalidates_old_key() { ); // La nouvelle clé rouvre et retrouve le souvenir intact. - let store = NativeMemoryStore::open_encrypted(dir.path(), "nouvelle-clé").expect("reopen nouvelle clé"); + let store = retry_after_drop(|| NativeMemoryStore::open_encrypted(dir.path(), "nouvelle-clé")) + .expect("reopen nouvelle clé"); let got = store .recall_vector(&agent, &vector, 5, None, Metric::Cosine, 0, true) .await @@ -376,8 +399,10 @@ async fn native_full_rotation_preserves_passphrase_mode_and_data() { } assert!(NativeMemoryStore::open_encrypted(dir.path(), "nouvelle passphrase").is_err()); - let store = NativeMemoryStore::open_with_key(dir.path(), &EncryptionKey::passphrase("nouvelle passphrase")) - .expect("reopen passphrase generation"); + let store = retry_after_drop(|| { + NativeMemoryStore::open_with_key(dir.path(), &EncryptionKey::passphrase("nouvelle passphrase")) + }) + .expect("reopen passphrase generation"); let got = store .recall_vector(&agent, &vector, 5, None, Metric::Cosine, 0, true) .await @@ -616,7 +641,8 @@ async fn native_reads_are_not_blocked_for_the_duration_of_a_concurrent_compactio ..EngineOptions::default() }; let store = Arc::new( - NativeMemoryStore::open_with_engine_options(dir.path(), compact_options).expect("reopen for compaction"), + retry_after_drop(|| NativeMemoryStore::open_with_engine_options(dir.path(), compact_options)) + .expect("reopen for compaction"), ); let stop = Arc::new(AtomicBool::new(false)); @@ -784,7 +810,8 @@ async fn native_writer_progresses_while_concurrent_compaction_builds_off_lock() ..EngineOptions::default() }; let store = Arc::new( - NativeMemoryStore::open_with_engine_options(dir.path(), compact_options).expect("reopen for compaction"), + retry_after_drop(|| NativeMemoryStore::open_with_engine_options(dir.path(), compact_options)) + .expect("reopen for compaction"), ); let agent = basemyai::AgentId::new("compaction-writer-agent").expect("agent id"); let trigger_vector = memory_tests::vec_for(255); @@ -860,7 +887,7 @@ fn native_wrong_encryption_key_maps_to_typed_core_error() { let dir = tempfile::tempdir().expect("tempdir"); NativeMemoryStore::open_encrypted(dir.path(), "bonne-clé").expect("open chiffré"); - let Err(err) = NativeMemoryStore::open_encrypted(dir.path(), "mauvaise-clé") else { + let Err(err) = retry_after_drop(|| NativeMemoryStore::open_encrypted(dir.path(), "mauvaise-clé")) else { panic!("mauvaise clé aurait dû échouer"); }; match err { diff --git a/crates/basemyai/tests/storage/plaintext_open_forbidden.rs b/crates/basemyai/tests/storage/plaintext_open_forbidden.rs index 97acae4..f3c4b3b 100644 --- a/crates/basemyai/tests/storage/plaintext_open_forbidden.rs +++ b/crates/basemyai/tests/storage/plaintext_open_forbidden.rs @@ -18,6 +18,28 @@ fn open_encrypted_is_always_available() { NativeMemoryStore::open_encrypted(dir.path(), "contract-test-key").expect("open_encrypted is the production API"); } +/// Dropping the last writer handle is deliberately non-blocking — the OS +/// file lock is released by a detached reaper thread, not synchronously by +/// `Drop` itself (see +/// `basemyai::storage::native_store::coordinator::tests:: +/// dropping_last_writer_handle_is_non_blocking_and_reaper_releases_the_store`). +/// A reopen immediately after the temporary store above is dropped can +/// therefore transiently observe `StoreLocked` — retry for a bounded window +/// instead of asserting on the very first attempt. +fn retry_after_drop(mut attempt: impl FnMut() -> basemyai::Result) -> basemyai::Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match attempt() { + Err(basemyai::MemoryError::Core(basemyai_core::CoreError::StoreLocked)) + if std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + other => return other, + } + } +} + #[test] fn passphrase_mode_is_explicit_and_never_falls_back_to_raw_key() { let dir = tempfile::tempdir().expect("tempdir"); @@ -25,7 +47,7 @@ fn passphrase_mode_is_explicit_and_never_falls_back_to_raw_key() { NativeMemoryStore::open_with_key(dir.path(), &passphrase).expect("open passphrase store"); let raw = basemyai_core::EncryptionKey::raw("human contract secret"); - let Err(err) = NativeMemoryStore::open_with_key(dir.path(), &raw) else { + let Err(err) = retry_after_drop(|| NativeMemoryStore::open_with_key(dir.path(), &raw)) else { panic!("raw key must not open passphrase store"); }; assert!(matches!( diff --git a/docs/ADR.md b/docs/ADR.md index 6dcaeef..77d34c5 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -99,4 +99,5 @@ Deux conséquences qui ont déjà prêté à confusion : | [ADR-066](adr/ADR-066-durable-publication-outcome-and-fail-stop.md) | **V2-01** — Issue de publication durable phase-aware (`DurablePublish::{NotReplaced,Published,Unknown}`, sans `Try`) et writer fail-stop : après un remplacement atomique ambigu de `catalog.meta`/`generation.meta`/`crypto.meta`, plus aucune mutation, seal, flush, compaction, rotation ni `close()` propre ; réouverture exigée, aucun retry. Aucun format | ✅ Accepted | | [ADR-067](adr/ADR-067-durable-publication-authority-and-panic-aware-fail-stop.md) | Suivi V2-01 — autorité de publication durable partagée (`catalog.meta`/`crypto.meta`/`generation.meta`), guard et poison phase-aware, ownership des fichiers candidats, priorité du fail-stop sur les no-op. Aucun format | ✅ Accepted | | [ADR-068](adr/ADR-068-v2-layering-and-type-ownership.md) | **V2-00** — layering et ownership des nouveaux types V2 : façade sémantique, runtime privé, engine spécialisé, `ReadPoint` opaque, gel des seams legacy ; packaging différé à V2-12. Aucun code, aucun format | ✅ Accepted | -| [ADR-069](adr/ADR-069-structural-project-agent-memory-scope.md) | **V2-02** — séparation Project/Agent/MemoryScope, principal distinct, capability d'autorisation privée et transformation vers un `ScopeKey` engine opaque ; sémantique mono-scope avant activation physique au hard cut V2-08 | 🟡 Proposed | +| [ADR-069](adr/ADR-069-structural-project-agent-memory-scope.md) | **V2-02** — séparation Project/Agent/MemoryScope, principal distinct, capability d'autorisation `AuthorizedOperation` privée (par classe d'opération, pas par action unique), autorité `StoreAction` distincte, transformation vers un `ScopeKey` engine opaque ; sémantique mono-scope avant activation physique au hard cut V2-08 | ✅ Accepted (rev 3) | +| [ADR-070](adr/ADR-070-one-logical-write-coordinator.md) | **V2-03** — coordinateur logique unique possédant tout l'état mutable produit, outcomes engine/runtime phase-aware, barrière de visibilité produit, owner bloquant dédié, intents fermés, FIFO canonique, convergence avec ADR-067, shutdown/reaper et finalisation bornée. Aucun format | ✅ Accepted (rev 3) | diff --git a/docs/adr/ADR-069-structural-project-agent-memory-scope.md b/docs/adr/ADR-069-structural-project-agent-memory-scope.md index 5b73f3a..6db733a 100644 --- a/docs/adr/ADR-069-structural-project-agent-memory-scope.md +++ b/docs/adr/ADR-069-structural-project-agent-memory-scope.md @@ -1,12 +1,63 @@ # ADR-069 — Project, Agent et MemoryScope structurels -**Statut** : 🟡 Proposed — révision 2, 2026-08-12. Non ratifié : ce document -n'autorise encore aucun code de production. +**Statut** : ✅ Accepted — révision 3, ratifiée le 2026-08-13. Autorise +l'implémentation de production de V2-02A (sémantique + autorisation, aucun +format, aucune isolation physique — cf. §9 et Exit criteria). **Jalon** : V2-02 dans la séquence normative d'ADR-065. **Dépendance** : ADR-068/V2-00 ✅ Accepted. **Formats** : aucun changement de format dans la tranche V2-02A. Le préfixage physique et le bump consolidé restent exclusivement sous V2-08. +## Révision 3 — ce qui change par rapport à la révision 2 + +Révision 2 n'a pas été ratifiée : une revue architecturale a confirmé la +direction générale (séparation identité/namespace/autorisation/clé physique, +`AuthorizedScopeSet` non forgeable, fail-closed avant accès store, +`LegacyPrivateScope`, distinction V2-02A sémantique / V2-08 physique) mais a +identifié des contradictions de contrat qui auraient coûté cher une fois +V2-03/V2-05/V2-08 construites dessus. Cette révision garde l'architecture +d'ensemble et corrige neuf points précis : + +1. La capability n'est plus liée à une action unique (contradiction avec les + opérations multi-actions déjà listées en §3) — elle devient + `AuthorizedOperation`, liée à une classe d'opération portant plusieurs + grants exacts (§2). +2. Les cas où l'autorisation dépendait implicitement d'un lookup préalable + (import target vide/remplaçable, create/update/upsert, maintenance + « chaque action réellement possible ») deviennent statiquement + décidables avant toute lecture (§3). +3. Une autorité `StoreAction` distincte est introduite pour les opérations + structurelles du conteneur (verify/repair/rebuild/rotate/migrate) au lieu + de les forcer dans `MemoryScope` (§4, nouvelle section). +4. Le contrat `ScopeKey`/seam inter-crates est corrigé : `#[doc(hidden)] pub + fn` n'est pas une frontière de sécurité en Rust, l'invariant est réécrit + pour protéger la surface produit/bindings, pas prétendre qu'aucune + fonction Rust publique n'existe (§8). +5. La durée de vie de l'autorisation pour `watch` et les opérations + long-lived est explicitement décidée (§7, nouvelle section). +6. Le `ProjectId` synthétique legacy est retiré au profit de + `LegacyPrivateScope` porté jusqu'au bout — un `ProjectId` synthétique + reste dans la même grammaire qu'un `ProjectId` utilisateur réel (§9). +7. La règle de déclassification renonce à une algèbre d'« intersection des + scopes » (`MemoryScope` n'est pas un lattice de confidentialité) au + profit de la seule règle de non-élargissement déjà correcte (§6). +8. Un `RuntimeInstanceId` explicite remplace toute dépendance implicite à + une identité de pointeur/`Arc` pour le binding capability↔runtime/store + (§2). +9. Le vocabulaire « refus avant toute lecture, allocation ou mutation » est + précisé en « admission/allocation de ressource dépendante du + store/de l'opération », pour ne pas être violé au sens littéral par un + simple `String::from` côté parsing DTO (§2). + +Un gate V2-08 supplémentaire est ajouté (point d'entrée ANN scope-local, § +Tests normatifs) et une précision mineure sur `PrincipalIssuer`/ +`PrincipalSubject` (§1). + +Indépendamment de cette révision, la sur-promesse d'isolation vectorielle +dans `README.md` (« per-agent isolation enforced structurally ») a été +corrigée dans le même lot que cette révision — voir le commit associé — pour +ne plus contredire le constat du §Problem ci-dessous. + ## Problem L'actuel `AgentId` remplit simultanément trois rôles incompatibles : namespace @@ -61,6 +112,13 @@ chaînes opaques UTF-8 de 1..=256 octets, sans NUL ni caractère de contrôle, changement de représentation de clé physique n'altère jamais leur égalité sémantique. +`PrincipalIssuer`/`PrincipalSubject` restent opaques **après** validation +spécifique au protocole d'authentification côté adaptateur transport (un +adaptateur OIDC applique les règles OIDC — normalisation d'URL d'issuer, +format de subject — avant de construire `CallerPrincipal`). Le runtime +BaseMyAI ne renormalise jamais lui-même issuer/subject après coup ; il les +traite comme deux chaînes déjà décidées, byte-exactes. + Le hard cut V2-08 doit refuser ou mapper explicitement, dans son manifest de migration, tout `AgentId` legacy hors grammaire. Il ne normalise, tronque ni fusionne silencieusement deux identités. @@ -86,29 +144,59 @@ applicables au credential, ou une autorité opérateur locale de confiance pour stdio/in-process, peut le construire. Un body REST, argument MCP ou DTO de binding ne peut jamais fournir directement un `CallerPrincipal` autoritatif. -Le runtime privé appelle un `ScopeAuthorizer` configuré avec ce principal, -l'action demandée et les scopes demandés. Une décision positive produit un -`AuthorizedScopeSet` : +Le runtime privé appelle un `ScopeAuthorizer` configuré avec ce principal, une +**classe d'opération** fermée (§3) et les scopes demandés. Une décision +positive produit un `AuthorizedOperation` : - type runtime privé, champs et constructeur privés ; - non sérialisable et non reconstructible depuis `ProjectId`, `AgentId`, `MemoryScope` ou des DTOs de binding ; -- lié à une instance de runtime/store, à une action fermée et à une décision - d'autorisation ; -- porte uniquement des grants exacts `(MemoryScope, ScopeAction)` et, pour la - déclassification, une arête exacte `(source, destination)` ; +- lié à une `RuntimeInstanceId` opaque (créée à l'initialisation du + runtime/store, jamais dérivée d'une identité de pointeur ou d'`Arc` + implicite) et à une classe d'opération fermée ; +- porte un ensemble exact et fini de grants `(MemoryScope, ScopeAction)` + requis par cette classe d'opération et, pour la déclassification + seulement, un ensemble d'arêtes exactes `(source, destination)` ; - tout sous-ensemble est dérivé par réduction, jamais par élargissement. -Une API publique n'accepte jamais un `AuthorizedScopeSet` fourni par +**Pourquoi une opération, pas une action unique** : plusieurs opérations +exigent plusieurs actions simultanées — `export = Read + Export`, +`verify = Read + Maintain`, `import replace = Import + Create + Update`, +`déclassification = Read(source) + Create/Update(destination) + +Declassify(source, destination)`. Une capability liée à une seule +`ScopeAction` ne peut pas représenter ces opérations sans élargissement +implicite après coup. `AuthorizedOperation` porte donc le jeu complet et exact +de grants qu'une classe d'opération donnée requiert, jamais un sac de scopes +réutilisable entre classes d'opération différentes — une capability obtenue +pour `Export` ne peut pas être présentée à un site qui exécute `Repair`, même +si les deux exigent un grant `Read` sur le même scope. + +`RuntimeInstanceId` sert exclusivement à empêcher qu'une capability produite +par un runtime/store A soit acceptée par un runtime/store B : chaque opération +engine-bound vérifie la correspondance d'instance avant d'utiliser +l'`AuthorizedOperation`, sans dépendre d'une comparaison d'adresse mémoire ou +de `Arc::ptr_eq`. + +Une API publique n'accepte jamais un `AuthorizedOperation` fourni par l'appelant. Elle accepte une requête sémantique ; le runtime authentifie, autorise, réduit puis construit l'opération. Les bindings traduisent des DTOs, mais ne fabriquent aucune capability. +**Précision de vocabulaire** : « refus avant toute lecture, allocation ou +mutation » (formulation de la révision précédente) est trop absolu au sens +littéral — parser un body JSON alloue déjà des `String`, et l'authorizer +lui-même alloue. L'invariant réel est : refus avant toute **admission ou +allocation de ressource dépendante du store ou de l'opération** — acquisition +d'un `ReadPoint`, admission mémoire, recherche vectorielle/BM25/graphe, +résolution d'identifiant, WAL, cache admission, working-set de requête, import, +maintenance ou métriques contenant des données de scope. L'allocation générique +côté parsing/transport n'est pas concernée par cet invariant. + Cette frontière protège les consumers honnêtes et les transports contre les confusions de scope. Elle ne prétend pas isoler du code hostile exécuté dans le même processus et disposant des mêmes secrets ou accès filesystem. -### 3. Actions fermées et matrice d'autorisation +### 3. Actions fermées, classes d'opération et matrice d'autorisation ```text ScopeAction = Read | Create | Update | Delete | Export | Import | @@ -116,45 +204,97 @@ ScopeAction = Read | Create | Update | Delete | Export | Import | ``` Il n'existe ni wildcard, ni union implicite, ni `Admin` absorbant toutes les -actions. L'authorizer peut produire plusieurs grants exacts, y compris dans -plusieurs projets, mais l'absence du couple exact est toujours un refus. - -Chaque opération est classée avant toute lecture, allocation ou mutation : - -| Scope cible | Lecture | Écriture | Condition minimale | -|---|---:|---:|---| -| `Private(P,A)` | explicite | explicite | grant exact pour `P/A` et action | -| `Project(P)` | explicite | explicite | grant exact project ; une appartenance seule ne vaut pas écriture | -| tout scope sans grant exact | refus | refus | aucun fallback, wildcard ou correspondance par `AgentId` | +actions. L'authorizer peut produire un `AuthorizedOperation` couvrant +plusieurs scopes (y compris plusieurs projets), mais l'absence d'un grant exact +requis par la classe d'opération est toujours un refus. + +**Toute classe d'opération déclare, avant définition de son handler, l'ensemble +exact et statique de grants qu'elle requiert — jamais déduit par lookup.** +C'est la correction principale de cette révision : la version précédente +laissait certains cas dépendre implicitement du contenu du store pour savoir +quels droits demander (« target vide vs remplaçable », « create vs update », +« chaque action réellement possible »), ce qui contredit le fail-closed +« avant toute lecture ». Les classes d'opération concrètes : + +| Classe d'opération | Grants exigés (statiques) | +|---|---| +| `Recall` (search, hydrate, traverse, stats, recall_*) | `Read` | +| `Watch` | `Read` (voir §7 pour la durée de vie) | +| `Create` (remember, insertion pure) | `Create` | +| `Update` (touch, importance, invalidate, mutation d'un id existant) | `Update` | +| `Upsert` (l'appelant ne garantit pas l'absence préalable) | `Create` + `Update` | +| `Delete` (forget, purge, redaction) | `Delete` | +| `Export` | `Read` + `Export` | +| `ImportInsertOnly` (échoue sur collision, ne bascule jamais en update) | `Import` + `Create` | +| `ImportReplace` (peut remplacer un id existant) | `Import` + `Create` + `Update` | +| `VerifyReadOnly` | `Read` + `Maintain` | +| `RepairOrRebuildOrGc` | `Maintain` + le superset statique de toutes les mutations que cette classe peut effectuer, jamais un sous-ensemble découvert par lookup | +| `Declassify` | règle exacte de la §6 | + +`ImportInsertOnly` échoue explicitement sur collision plutôt que de basculer +silencieusement vers un comportement d'update — c'est le mécanisme qui rend +la distinction target-vide/target-remplaçable statiquement décidable : +l'appelant choisit la classe d'opération (donc les grants requis) avant tout +lookup, et `InsertOnly` porte la contrainte comme postcondition vérifiée par +le store, pas comme une question résolue par un pré-lookup d'autorisation. +De même, `Create`/`Update`/`Upsert` sont trois contrats distincts : un +`remember` qui ne sait pas si l'id existe déjà doit être appelé comme +`Upsert` (droits maximaux demandés d'emblée), jamais résolu en `Create` ou +`Update` après une lecture d'existence. + +`RepairOrRebuildOrGc` demande le superset des mutations qu'une passe de +maintenance *peut* effectuer (déterminé par le code de la classe, pas par +l'état du store au moment de l'appel) — une maintenance qui ne finit par +toucher qu'un sous-ensemble de ce superset reste correctement autorisée ; +l'inverse (autoriser moins que ce qui pourrait être nécessaire, puis +découvrir un besoin en cours de route) est ce que cette règle interdit. + +Le refus est fail-closed et intervient avant toute admission/allocation +dépendante du store ou de l'opération (§2). Les erreurs ne révèlent pas si un +objet existe dans un scope non autorisé. Un grant de lecture n'implique jamais l'écriture. Un grant project n'implique pas l'accès aux scopes privés des agents du projet. Un grant privé n'implique pas l'accès au scope project. -Le refus est fail-closed et intervient avant : acquisition d'un `ReadPoint`, -admission mémoire, recherche vectorielle/BM25/graphe, résolution d'identifiant, -WAL, import, maintenance ou métriques contenant des données de scope. Les -erreurs ne révèlent pas si un objet existe dans un scope non autorisé. +Une nouvelle classe d'opération doit être classée explicitement avec son +ensemble statique de grants ; l'absence de classification est une erreur de +build/test, jamais un défaut vers `Read` ou `Update`. -La classification minimale des surfaces est : +### 4. `StoreAction` : autorité distincte pour les opérations structurelles -| Surface | Grants exigés | -|---|---| -| search, hydrate, traverse, stats, watch | `Read` | -| remember/création | `Create` | -| touch, importance, invalidate, mutation | `Update` | -| forget, purge, redaction | `Delete` | -| export | `Read` + `Export` | -| import vers cible vide | `Import` + `Create` | -| import pouvant remplacer | `Import` + `Create` + `Update` | -| verify read-only | `Read` + `Maintain` | -| repair, rebuild, GC | `Maintain` + chaque action de mutation réellement possible | -| déclassification | règle exacte de la section suivante | - -Une nouvelle surface doit être classée explicitement ; l'absence de mapping -est une erreur de build/test, jamais un défaut vers `Read` ou `Update`. - -### 4. Erreurs observables sans oracle d'existence +`MemoryScope` qualifie une mémoire ; il ne qualifie pas les opérations +d'administration du **conteneur** lui-même. BaseMyAI possède déjà des +opérations structurelles qui ne portent pas sur une mémoire scopée : `verify` +(le format `.bmai` documente déjà cette responsabilité au niveau conteneur), +`repair`, `rebuild-indexes`, rotation de clé/passphrase, migration, +compaction, GC de bas niveau. Forcer ces opérations dans +`MemoryScope::Private`/`Project` recréerait un faux scope global caché — soit +en inventant un scope fictif, soit en dupliquant l'opération par scope alors +qu'elle porte réellement sur tout le conteneur. + +```text +StoreAction = VerifyStructure | RepairStructure | RebuildIndexes | + RotateEncryption | Migrate +``` + +`StoreAction` a sa propre autorité d'authorizer, distincte de +`ScopeAuthorizer`/`MemoryScope`/`ScopeAction`. Une opération classée +`StoreAction` : + +- ne référence aucun `MemoryScope` ; +- suit la même discipline fail-closed que `ScopeAction` (refus avant + admission/allocation dépendante du store) ; +- produit une classe d'`AuthorizedOperation` distincte, non interchangeable + avec les classes portant des grants `(MemoryScope, ScopeAction)`. + +Cette section n'existait pas en révision 2 ; c'est un ajout, pas une +correction de texte existant — aucun `StoreAction` n'existe non plus dans le +code aujourd'hui (aucune opération structurelle actuelle n'est autorisée du +tout ; V2-02A introduit le contrat, pas encore le branchement production, cf. +§9). + +### 5. Erreurs observables sans oracle d'existence Le runtime distingue les classes suivantes sans consulter le store : @@ -170,7 +310,7 @@ principal autorisé recevant « absent » et un principal non autorisé recevant « interdit » est acceptable ; l'erreur interdite reste identique que l'objet étranger existe ou non. -### 5. Déclassification : copie explicite, jamais élargissement implicite +### 6. Déclassification : copie explicite, jamais élargissement implicite Le passage `Private(P,A) → Project(P)` est une déclassification, pas un rename ni une mutation in-place. Il exige simultanément : @@ -183,19 +323,56 @@ ni une mutation in-place. Il exige simultanément : La déclassification cross-project est interdite. Aucun backlink, edge, cache, memo ou index dérivé privé ne devient visible par transitivité. -Plus généralement, toute sortie dérivée a une visibilité inférieure ou égale -à l'intersection des scopes de toutes ses sources. Tout élargissement exige une -arête `Declassify(source,destination)` distincte pour chaque source, en plus des -grants `Read(source)` et `Create`/`Update(destination)`. Cela interdit qu'une -agrégation, un résumé ou une maintenance serve de confused deputy et blanchisse -une source privée. +**Règle de non-élargissement (corrigée en révision 3)** : une sortie dérivée +ne peut être écrite dans un scope plus large qu'une de ses sources sans un +`Declassify(source, destination)` exact pour **chacune** de ces sources, en +plus des grants `Read(source)` et `Create`/`Update(destination)` déjà exigés. +La révision précédente formulait cette règle comme « toute sortie dérivée a +une visibilité inférieure ou égale à l'**intersection** des scopes de toutes +ses sources » — formulation abandonnée : `MemoryScope` n'est pas un lattice +de confidentialité (`Private(P,A) ∩ Project(P)` ou `Private(P,A) ∩ +Private(P,B)` n'ont aucune valeur correspondante dans l'énumération). La règle +de non-élargissement par arête `Declassify` exacte, elle, ne dépend d'aucune +algèbre de scopes et suffit à interdire qu'une agrégation, un résumé ou une +maintenance serve de confused deputy et blanchisse une source privée. Une +éventuelle notion d'audience/visibilité dérivée plus riche (au-delà de la +simple non-élargissement par source) est explicitement laissée à V2-10 +(provenance/lineage), pas construite ici. V2-02 fixe ces règles mais **n'active aucune API de déclassification ni d'écriture dérivée cross-scope**. Leur activation dépend du schéma de provenance/lineage V2-10 et du layout V2-08 ; avant eux, la seule implémentation correcte est `ScopeOperationUnsupported` avant lecture de la source. -### 6. Transformation privée vers `ScopeKey` +### 7. Durée de vie de l'autorisation : opérations courtes vs long-lived + +Une décision d'autorisation figée au moment de l'appel convient à une +opération courte (`Recall`, `Create`, quelques dizaines de millisecondes). +Elle est insuffisante pour `Watch` et toute opération long-lived : SSE, +WebSocket, notifications MCP, callbacks Node, async iterator Python — surfaces +déjà exposées par BaseMyAI aujourd'hui. Sans décision explicite, chaque +binding inventerait sa propre réponse à la question « le watcher continue-t-il +à recevoir des événements après révocation du grant sous-jacent ? ». + +V2-02 tranche la sémantique par défaut, sans exiger son implémentation +complète dans cette tranche : + +- une opération classée `Recall`/`Create`/`Update`/`Delete`/`Export`/`Import` + reçoit une **autorisation snapshot** : valide pour la durée de cette seule + opération, jamais réévaluée en cours de route ; +- une opération classée `Watch` (ou toute future classe long-lived) reçoit + une **autorisation à bail** (`lease`) liée à une politique/epoch : elle doit + être réévaluée à intervalle borné ou à changement de policy détecté, et un + watcher dont le grant sous-jacent est révoqué doit cesser de recevoir des + événements pour ce scope au plus tard à la réévaluation suivante — jamais + indéfiniment sur la seule foi de la décision prise à la création du watch. + +Le mécanisme concret de réévaluation (polling borné, notification de +révocation, epoch de policy incrémenté) est un détail d'implémentation +différé ; la sémantique — snapshot pour le court, bail réévalué pour le +long-lived — est, elle, normative dès V2-02A. + +### 8. Transformation privée vers `ScopeKey` La cible V2-08 donne à l'engine un `ScopeKey` physique opaque. Seul l'adaptateur runtime de confiance transforme alors un `MemoryScope` déjà autorisé en @@ -204,7 +381,7 @@ runtime de confiance transforme alors un `MemoryScope` déjà autorisé en ```text CallerPrincipal + request ↓ ScopeAuthorizer -AuthorizedScopeSet +AuthorizedOperation ↓ reduce to exact operation scopes MemoryScope ── private mapping ──> ScopeKey ↓ @@ -212,13 +389,43 @@ specialized engine ``` V2-02A n'introduit aucun `ScopeKey` concret, aucun codec et aucune persistance. -À V2-08, il n'existe aucun `From for ScopeKey`, getter de bytes -physiques, sérialisation générique ou conversion dans un binding. Comme le -runtime et l'engine sont deux crates Rust distinctes, leur seam de confiance -peut techniquement nécessiter une fonction publique `#[doc(hidden)]`; elle -n'est ni réexportée par l'API produit, ni une capability d'autorisation. + +**Contrat `ScopeKey` corrigé (révision 3)** : la révision précédente +affirmait qu'une fonction de seam `#[doc(hidden)] pub fn` entre `basemyai` et +`basemyai-engine` (deux crates distinctes) constituait, de fait, une +protection suffisante contre l'exposition de `ScopeKey`. C'est incorrect en +Rust : `#[doc(hidden)]` masque un item de la documentation générée, il ne +retire **aucune** accessibilité — n'importe quel crate dépendant peut appeler +une fonction `pub` marquée `#[doc(hidden)]`. Il n'existe pas de +`pub(friend_crate = "basemyai")` en Rust ; `pub(in path)` ne traverse jamais +une frontière de crate. + +L'invariant réel, reformulé : + +```text +ScopeKey MUST NOT be: +- exposed by the basemyai public product API (crate root, public modules) ; +- exposed by REST/MCP/Python/Node bindings ; +- constructible from a transport DTO or binding argument ; +- accepted anywhere in place of an authorization decision. + +The basemyai <-> basemyai-engine seam MAY expose a technically-public, +non-documented internal engine function to perform this transformation. +That function is layering, not a security boundary : a consumer depending +directly on basemyai-engine, or using unsafe, is already outside the +in-process authorization threat model (cf. §2 sur le threat model). +``` + +Comme le runtime et l'engine sont deux crates Rust distinctes, la seam de +confiance peut donc techniquement passer par une fonction publique +`#[doc(hidden)]` — mais les gates de non-exposition (§Tests normatifs) +protègent la **surface produit et bindings**, pas l'existence de cette +fonction interne engine, qu'ils ne prétendent plus faire disparaître. L'engine compare et préfixe des `ScopeKey`, mais ne connaît ni `ProjectId`, ni -`AgentId`, ni `CallerPrincipal`, ni les règles d'autorisation. +`AgentId`, ni `CallerPrincipal`, ni les règles d'autorisation — ce principe de +layering (cohérent avec ADR-045 : mécanisme dans l'engine, sens côté +consommateur) reste inchangé et suffit ; il n'a jamais eu besoin de la +protection Rust illusoire de la révision précédente pour être correct. Un consumer hostile qui dépend directement de la crate engine ou utilise `unsafe` sort du threat model de l'autorisation in-process. La sécurité contre @@ -228,18 +435,23 @@ L'alias legacy `idx::graph::ram::ScopeKey = (String, String)` doit être renomm avant l'introduction du newtype canonique ; il désigne actuellement une paire locale `(agent, entity)` et ne peut servir de qualification V2. -### 7. Deux phases sans fausse promesse multi-project +### 9. Deux phases sans fausse promesse multi-project **V2-02A — sémantique et autorisation, aucun format** : - introduire les types sémantiques, l'authorizer privé et les tests négatifs ; -- configurer l'adaptateur legacy avec un `ProjectId` synthétique explicite ; - chaque opération/vue legacy autorise exactement un scope privé, tandis qu'un - même store peut continuer à contenir plusieurs agents ; -- conserver les agents V1 déjà acceptés hors grammaire via un - `LegacyPrivateScope` runtime opaque qui garde leurs bytes exacts. Il n'est ni - public, ni constructible par une nouvelle API V2, ni convertible en - `AgentId` ; il utilise les clés legacy existantes jusqu'au hard cut ; +- configurer l'adaptateur legacy **sans** `ProjectId` synthétique — un + `ProjectId` synthétique (`"legacy"`, `"default"`, ...) reste dans la même + grammaire ASCII qu'un `ProjectId` utilisateur réel et peut donc entrer en + collision avec un vrai projet au moment du mapping V2-08. L'adaptateur + legacy porte exclusivement un `LegacyPrivateScope` runtime opaque, jamais un + `MemoryScope::Private` construit avec un `ProjectId` fictif ; chaque + opération/vue legacy autorise exactement ce scope privé, tandis qu'un même + store peut continuer à contenir plusieurs agents ; +- `LegacyPrivateScope` garde les bytes exacts des agents V1 déjà acceptés hors + grammaire. Il n'est ni public, ni constructible par une nouvelle API V2, ni + convertible en `AgentId` ou `ProjectId` ; il utilise les clés legacy + existantes jusqu'au hard cut ; - refuser `Project`, plusieurs scopes et toute déclassification sur le vector store legacy ; - ne modifier ni les clés persistées, ni `format.lock`, ni le graphe ANN @@ -255,8 +467,10 @@ locale `(agent, entity)` et ne peut servir de qualification V2. les oracles d'isolation structurelle sont verts ; - un ancien store est exporté/importé par le pont décidé, jamais lu dans un monde mixte. Le pont exige un mapping explicite pour chaque - `LegacyPrivateScope` hors grammaire ; il refuse collisions, troncature et - normalisation silencieuse. + `LegacyPrivateScope` hors grammaire — le mapping décide alors, et alors + seulement, quel `ProjectId`/`AgentId` réel (le cas échéant) représente + chaque scope legacy ; il refuse collisions, troncature et normalisation + silencieuse. La ratification de V2-02 décide le contrat. Elle ne permet pas de déclarer l'isolation vectorielle physique terminée avant V2-08. @@ -266,12 +480,14 @@ l'isolation vectorielle physique terminée avant V2-08. - V2-03 décide le `WriteCoordinator`, ses lanes et l'ownership WAL/sequence ; V2-02 ne crée aucune seconde autorité d'écriture. - V2-04 décide permits, accounting, fairness et budgets ; V2-02 exige seulement - qu'un refus arrive avant admission/allocation. + qu'un refus arrive avant admission/allocation dépendante du store. - V2-05 décide `ReadPoint` et `RecallOperation` ; V2-02 ne définit pas leur - durée de vie ni leur politique temporelle. + durée de vie ni leur politique temporelle (à l'exception de la sémantique + snapshot/bail de §7, qui borne l'autorisation, pas le `ReadPoint` lui-même). - V2-08 décide l'encodage sur disque et le hard cut consolidé. - V2-10 décide les records de source et lineage nécessaires à la - déclassification activable. + déclassification activable, ainsi qu'une éventuelle algèbre de + visibilité dérivée plus riche que la règle de non-élargissement de §6. - V2-12 décide packaging et API finale ; les owners logiques d'ADR-068 restent normatifs entre-temps. @@ -296,6 +512,19 @@ l'isolation vectorielle physique terminée avant V2-08. doivent borner et rendre cette dette observable. 8. **Autorisation locale sur-vendue.** Un host in-process malveillant n'est pas contenu par des newtypes Rust ; le threat model doit rester public. +9. **Classe d'opération mal classée.** Une classe d'opération dont + l'ensemble statique de grants est incomplet (découvert seulement à + l'usage) reproduirait exactement le défaut de lookup implicite que cette + révision corrige ; chaque nouvelle classe doit être revue explicitement, + pas ajoutée par analogie rapide. +10. **`StoreAction` réutilisé comme scope global déguisé.** Si une future + passe laisse une opération `StoreAction` accéder incidemment à des + données scopées sans passer par `ScopeAuthorizer`, la séparation des + deux autorités (§4) devient cosmétique plutôt que réelle. +11. **Bail d'autorisation `Watch` jamais réévalué en pratique.** Décider la + sémantique (§7) sans qu'aucune implémentation ne l'honore réellement + laisserait un watcher continuer indéfiniment après révocation — le risque + n'est fermé qu'une fois un gate normatif l'exerce (voir Tests normatifs). ## Tests normatifs @@ -307,35 +536,63 @@ l'isolation vectorielle physique terminée avant V2-08. engine ; 3. principal/`AgentId` forgés et DTO binding manipulé ne produisent aucune capability ; -4. capability d'un runtime/store refusée par un autre et capability réduite - impossible à élargir ; +4. capability d'un runtime/store refusée par un autre `RuntimeInstanceId` + (test dédié : deux runtimes distincts, capability produite par l'un + rejetée explicitement par l'autre) et capability réduite impossible à + élargir ; 5. aucune API publique ne construit, sérialise ou expose - `AuthorizedScopeSet`/`ScopeKey` ; `cargo xtask v2-layering` le garde sous + `AuthorizedOperation`/`ScopeKey` ; `cargo xtask v2-layering` le garde sous configurations défaut et `test-util` ; -6. mode legacy accepte un seul scope privé et refuse project, multi-scope et - déclassification sans changer `format.lock` ; +6. mode legacy accepte un seul `LegacyPrivateScope` et refuse project, + multi-scope et déclassification sans changer `format.lock`, et sans + qu'aucun `ProjectId` synthétique constructible par un utilisateur + n'apparaisse nulle part dans le chemin legacy ; 7. les erreurs non autorisées sont indistinguables entre objet absent et objet présent hors scope ; aucune métrique/log ne divulgue l'identifiant étranger ; 8. fixtures compile-fail/source négatives réelles pour constructeur, - `From`, `Deserialize`, re-export ou duplication d'`AuthorizedScopeSet` et + `From`, `Deserialize`, re-export ou duplication d'`AuthorizedOperation` et des types sémantiques ; omettre l'autorisation doit faire échouer le gate ; 9. property tests de collision pour délimiteurs, préfixes, casse, Unicode, longueurs limites et couples `(project,agent,memory)` distincts ; 10. matrice consumer REST, MCP, Python et Node : codes d'erreur cohérents, principal remote non forgeable et aucun type runtime/engine exposé ; -11. le guard V2-02A affirme l'**absence** de `ScopeKey` concret/codec ; ses - fixtures de non-exposition ne sont activées qu'à V2-08 quand le type existe. +11. le guard V2-02A affirme l'**absence** de `ScopeKey` concret/codec dans la + surface produit/bindings ; ses fixtures de non-exposition ne sont + activées qu'à V2-08 quand le type existe — le guard ne prétend plus + qu'aucune fonction Rust publique n'existe côté seam interne engine (§8) ; +12. `ImportInsertOnly` échoue sur collision sans jamais basculer en update + silencieux ; `ImportReplace` exige les trois grants exacts avant toute + lecture de la cible ; aucune des deux classes ne dérive ses grants d'un + lookup préalable ; +13. `RepairOrRebuildOrGc`/`VerifyReadOnly` sont autorisées sur le superset + statique déclaré par la classe, avant toute lecture du store, y compris + quand l'exécution réelle ne touche qu'un sous-ensemble de ce superset ; +14. une opération `StoreAction` n'accepte, ne référence et ne dérive aucun + `MemoryScope` ; son autorisation est vérifiée par un authorizer distinct + de `ScopeAuthorizer` ; +15. une capability `Watch` réévaluée après révocation simulée du grant + sous-jacent cesse de délivrer des événements pour ce scope au plus tard à + la réévaluation suivante ; une capability `Recall`/`Create` reste, elle, + figée pour la durée de la seule opération qui l'a produite (pas de + réévaluation en cours de route pour les classes courtes). ### Gates d'activation physique V2-08 1. population étrangère massive : mêmes résultats, candidats, nœuds uniques, expansions et octets logiques que sans cette population ; -2. mêmes IDs dans deux projets et scopes privé/project : aucun record, hit, +2. **point d'entrée de traversée scope-local** (ajout révision 3) : l'oracle + de population étrangère massive couvre explicitement l'entry point ANN, le + candidate heap, le visited set, le node cache, l'adjacency, le vecmap et le + rerank — pas seulement les records effectivement traversés. Un index dont + les listes d'adjacence sont préfixées par scope mais dont l'entry point + reste choisi dans un scope étranger recrée un canal dès la première + expansion ; ce gate doit le faire échouer explicitement ; +3. mêmes IDs dans deux projets et scopes privé/project : aucun record, hit, backlink, edge, cache ou meta croisé ; -3. le negative control sans préfixe `ScopeKey` échoue ; -4. rebuild, repair, verify, import, suppression et maintenance conservent la +4. le negative control sans préfixe `ScopeKey` échoue ; +5. rebuild, repair, verify, import, suppression et maintenance conservent la même isolation ; -5. l'I/O et le cache physique partagé sont bornés par les règles de fairness de +6. l'I/O et le cache physique partagé sont bornés par les règles de fairness de V2-04, sans starvation par cardinalité étrangère. ## Exit criteria @@ -344,15 +601,23 @@ V2-02A peut être déclarée appliquée lorsque : 1. cette ADR est ratifiée individuellement ; 2. les types sémantiques ont un owner unique conforme à ADR-068 ; -3. l'authorizer et `AuthorizedScopeSet` restent privés et les tests de - non-forgeabilité/refus passent ; -4. chaque nouvelle opération scope-aware autorise avant lecture, allocation ou - mutation ; -5. l'adaptateur legacy reste mono-scope privé et aucune feature multi-project +3. l'authorizer et `AuthorizedOperation` restent privés et les tests de + non-forgeabilité/refus passent, y compris le test de binding + `RuntimeInstanceId` (gate 4) ; +4. chaque nouvelle opération scope-aware déclare son ensemble statique de + grants et autorise avant toute admission/allocation dépendante du + store/de l'opération (§2, §3) ; +5. chaque opération `StoreAction` est autorisée par une autorité distincte de + `ScopeAuthorizer`, sans référencer de `MemoryScope` (§4) ; +6. la sémantique snapshot/bail de §7 est actée et testée (gate 15), même si + son mécanisme de réévaluation complet reste un détail d'implémentation + ouvert ; +7. l'adaptateur legacy reste mono-scope privé via `LegacyPrivateScope`, sans + `ProjectId` synthétique constructible, et aucune feature multi-project n'est exposée ; -6. `cargo xtask check`, `cargo xtask test`, les bindings affectés et +8. `cargo xtask check`, `cargo xtask test`, les bindings affectés et `cargo xtask v2-layering` terminent avec exit 0 ; -7. `format.lock` est inchangé. +9. `format.lock` est inchangé. La clôture V2-02A débloque V2-03. L'obligation d'isolation physique et ses gates sont transférés à V2-08 ; ils ne maintiennent pas V2-02A ouverte. Entre les @@ -373,3 +638,30 @@ activation physique différée », et jamais « multi-project isolé ». lecture, écriture et déclassification sont des décisions distinctes. - **Déclassifier par mutation de scope.** Rejeté : cela rendrait privées des références déjà publiées ou publiques des dérivations sans lineage. +- **Capability liée à une `ScopeAction` unique (révision 2).** Rejeté en + révision 3 : contredit directement les opérations multi-actions déjà + listées (export, verify, import replace, déclassification), qui ne peuvent + pas être représentées par un seul couple `(scope, action)`. +- **Déduire les grants requis par un lookup préalable (révision 2, implicite + pour import/update/maintenance).** Rejeté en révision 3 : contredit + « refus avant toute lecture » — remplacé par des classes d'opération à + grants statiques (`ImportInsertOnly`/`ImportReplace`, `Create`/`Update`/ + `Upsert`, superset statique de maintenance). +- **Forcer les opérations structurelles du conteneur dans `MemoryScope` + (révision 2, implicite).** Rejeté en révision 3 : recréerait un scope + global caché ; remplacé par l'autorité `StoreAction` distincte (§4). +- **`#[doc(hidden)] pub fn` comme frontière de sécurité inter-crates + (révision 2).** Rejeté en révision 3 : faux en Rust — `#[doc(hidden)]` ne + retire aucune accessibilité cross-crate. Remplacé par un contrat qui + protège la surface produit/bindings et traite la seam interne engine comme + du layering, pas de la sécurité (§8). +- **Algèbre d'intersection de scopes pour la visibilité dérivée (révision + 2).** Rejeté en révision 3 : `MemoryScope` n'est pas un lattice de + confidentialité, aucune valeur d'intersection n'existe dans l'énumération. + Remplacé par la règle de non-élargissement par arête `Declassify` exacte, + suffisante pour V2-02 ; toute algèbre plus riche est différée à V2-10. +- **`ProjectId` synthétique explicite pour l'adaptateur legacy (révision + 2).** Rejeté en révision 3 : un `ProjectId` synthétique reste dans la même + grammaire qu'un `ProjectId` utilisateur réel, risque de collision au hard + cut. Remplacé par `LegacyPrivateScope` porté jusqu'au bout, sans + représentation `ProjectId` fictive. diff --git a/docs/adr/ADR-070-one-logical-write-coordinator.md b/docs/adr/ADR-070-one-logical-write-coordinator.md new file mode 100644 index 0000000..ea61e8c --- /dev/null +++ b/docs/adr/ADR-070-one-logical-write-coordinator.md @@ -0,0 +1,579 @@ +# ADR-070 — Autorité logique d'écriture unique + +**Statut** : ✅ Accepted — révision 3, ratifiée le 2026-08-13 après trois +passes adversariales indépendantes. Autorise l'implémentation V2-03, sans +changement de format ni activation multi-project physique. +**Jalon** : V2-03 dans la séquence normative d'ADR-065. +**Dépendances** : ADR-066/067 (publication durable et fail-stop), ADR-068 +(ownership/layering) et ADR-069 rev3 (scope/autorisation) sont Accepted. +**Formats** : aucun changement de format. `format.lock` doit rester inchangé. + +## Révision 2 — corrections après revue adversariale + +La révision 1 n'a pas été ratifiée. Trois revues indépendantes ont confirmé +l'owner complet et les intents fermés, mais ont trouvé deux P0 et plusieurs +contrats non falsifiables. Cette révision décide explicitement : + +1. une issue WAL phase-aware et terminale dès qu'un append tenté n'est plus + prouvé absent ou durable ; +2. un `CommitReceipt` engine fermé et une barrière de visibilité produit qui + interdit d'observer `visible_sequence=N` avant l'installation des états RAM + produit de N ; +3. la topologie read/write concrète sans exposer de write side générique ; +4. le point de linéarisation atomique admission/ordinal/enqueue/close ; +5. un chunk borné comme unité d'intent pour import/purge/maintenance ; +6. une finalisation bornée réservée avant commit, exactly-once seulement pour + sa tentative in-process, et livraison watch best-effort ; +7. le close sain via consommation de `WriterState`/`Engine::close`, le close + terminal sans publication, et le comportement de `Drop` ; +8. une table exacte des exceptions offline/live et une portée honnête du + bypass guard malgré le legacy `NativeEngine::inner_mut` gelé jusqu'à V2-12. + +## Révision 3 — fermeture de la seconde passe + +La révision 2 n'a pas été ratifiée. Cette révision distingue le commit durable +mais non installé d'un outcome inconnu, sépare les outcomes engine/runtime et +les familles de handlers, étend la barrière à toute la lecture, définit la +terminalisation engine one-way sous le lock ADR-067, choisit un owner bloquant +dédié avec completion async, rend les epochs maintenance non auto-invalidants, +ferme le transfert RAII des capacités et impose un cutover sans double writer. + +## 1. Problème constaté dans le code + +L'autorité logique produit est aujourd'hui implicite : +`NativeMemoryStore::with_inner_write` prend `RwLock::write` puis +laisse une closure arbitraire recevoir `&mut NativeInner`. Cet agrégat possède +ensemble `Engine`, index mémoire/vectoriel/graphe/FTS et leurs allocateurs et +états RAM. `Engine::{put, delete, apply_batch}` réserve les séquences, append +et fsync le WAL, installe la memtable puis publie `visible_sequence`. + +Le `WriterRuntime` présent dans `basemyai-engine` n'est pas cette autorité : il +est limité à `test`/`test-util`, ne possède qu'un `Mutex`, n'intègre +aucun index produit, n'offre que `Immediate` et remet un ticket déjà résolu. +Son ordre de commit dépend du scheduler du mutex et son `runtime_epoch` est +constant. Il doit être absorbé ou supprimé ; son nom n'est pas une décision. + +La publication physique, elle, possède déjà une autorité correcte et unique : +`catalog_commit_lock` + `DurablePublicationGuard` + état sticky partagé par +seal, flush, compaction et rotations (ADR-067). V2-03 ne doit ni la dupliquer, +ni la déplacer derrière une queue concurrente, ni affaiblir son point de +non-retour. + +Une migration partielle où un coordinateur possède seulement `Engine` tandis +que les index restent mutables ailleurs créerait deux autorités logiques : la +préparation et l'installation RAM des index peuvent dépendre atomiquement du +batch WAL. La frontière doit donc englober l'agrégat mutable produit complet. + +## 2. Décision : owner unique de `WriterState` + +Le runtime privé `basemyai` possède un `BaseMyAIRuntime` partagé. Celui-ci +contient une cellule privée `ProductStateCell`, un `ReadGateway` et un unique +`WriteCoordinator`. La cellule encapsule l'actuel `NativeInner` complet. Seul +le coordinateur possède un `ProductWriteToken` non clonable, créé avec la +cellule et impossible à reconstruire ; ce token est exigé par la méthode write +privée. Aucun autre module ne reçoit le write side, `&mut NativeInner`, +`&mut Engine` ou closure générique. + +```text +WriteCoordinator + ├─ admission + FIFO canonique + lifecycle + ├─ WriterHealth unique + ├─ WriterState (owner exclusif) + │ └─ NativeInner + │ ├─ Engine (WAL, sequences, memtable, visible sequence) + │ ├─ memory/vector/FTS/graph mutable state + │ └─ allocators and reconstructible caches + └─ ordered finalization queue + +NativeMemoryStore + └─ Arc + ├─ ProductStateCell (private) + ├─ ReadGateway → full-operation shared product gate + └─ WriteCoordinator(ProductWriteToken) → sole write capability +``` + +`WriterState` n'extrait pas artificiellement WAL ou memtable d'`Engine` : +l'engine reste propriétaire de ses primitives physiques. L'ownership runtime +signifie que nul autre chemin produit ne peut obtenir `&mut NativeInner` ou +`&mut Engine`, pas que les champs physiques changent de crate. + +Les lectures passent par des méthodes fermées du `ReadGateway`, qui ne +retournent aucun `Engine`, `NativeInner`, guard ou référence capable de +survivre à l'appel. Le product read guard est détenu pendant toute la closure +synchrone de lecture et ne traverse aucun `.await`. Le writer prend le guard +exclusif avant commit et attend le drain des readers préexistants. Une lecture +voit donc entièrement l'ancien ou le nouvel état et ne reçoit jamais le token +writer. + +Le `WriterState` s'exécute sur un unique thread OS bloquant possédé par le +runtime. WAL/fsync et attente engine ne bloquent jamais un worker Tokio. Les +callers async acquièrent cancellation-safely les permits, font l'admission +atomique, puis attendent une completion async possédée par le runtime. Aucun +`std::sync::MutexGuard` ne traverse `.await`, aucun ticket bloquant n'est +attendu sur Tokio et aucun `spawn_blocking` par intent ne concurrence autour +d'un mutex partagé. + +La garantie V2-03 est limitée à la façade/runtime live `basemyai` : aucun de +ses chemins produit ne contourne le coordinateur et un second opener est +refusé. `basemyai_core::NativeEngine::inner_mut` reste une seam publique legacy +gelée par ADR-068 jusqu'à V2-12 ; V2-03 ne prétend pas empêcher un consumer qui +ouvre séparément un store fermé via cette API. + +## 3. Intents fermés, possédés et bornés + +Les appelants ne soumettent jamais une closure `FnOnce(&mut NativeInner)`. +Ils produisent un enum privé fermé `WriteIntent`, ou des familles fermées +équivalentes, couvrant explicitement : + +- mémoire : create/batch, touch, invalidate, forget, forget-many, purge ; +- graphe : upsert entity/edge ; +- import : insert-only ou replace, jamais bascule implicite ; +- maintenance/GC : intent issu d'un plan read-only avec read-set ; +- opérations structurelles live autorisées : rotations et demandes de + publication, sans leur donner un second owner logique. + +Chaque intent porte : + +- une `AuthorizedOperation` ou `AuthorizedStoreOperation` privée liée à la + bonne instance et à la classe exacte ; +- les scopes sémantiques exacts, sans `ScopeKey` fourni par le transport ; +- les données possédées nécessaires au commit ; +- un read-set/préconditions explicites lorsque la décision dépend d'un état + observé ; +- une estimation conservatrice de taille et un plafond propre au type. + +L'unité d'ordre et d'atomicité est un **chunk borné** : import, purge, +forget-many, GC et maintenance agrègent plusieurs tickets/chunks et conservent +leur sémantique publique partielle/reprenable actuelle. V2-03 ne transforme +pas silencieusement une opération paginée en transaction globale. + +V2-03 impose des plafonds par intent et deux capacités en nombre : intents +queued+in-flight et manifestes de finalisation. Un `AdmissionPermit` opaque +réserve ces capacités avant le point durable et les restitue par RAII. V2-04 +pourra lui ajouter octets, working-set et quotas sans créer une autre queue. +Un intent dépassant son plafond est refusé avant enqueue ; le coordinateur ne +promet pas encore une borne mémoire globale V2-04. + +Sous un même mutex d'admission, une transition atomique décide : état +`Open/Closing`, consommation du permit, attribution de l'ordinal et insertion +FIFO. C'est l'unique point de linéarisation. L'ordre n'est garanti qu'après ce +point ; aucune fairness entre producteurs attendant avant admission n'est +promise avant V2-04. Annulation avant linéarisation restitue le permit et ne +consomme aucun ordinal ; après linéarisation elle n'affecte plus l'intent. + +## 4. Ordre canonique et protocole de commit + +Une seule file FIFO assignant `WriteOrdinal` à l'enqueue accepté définit +l'ordre canonique. Pour chaque intent : + +```text +Prepared → Admitted(ordinal) → Revalidated → Committing + → Published(sequence range) → Finalizing → Completed + ↘ Aborted + ↘ OutcomeUnknown / Terminal +``` + +L'ordinal d'admission définit l'ordre des mutations et barrières de contrôle. +`sequence_range: Option` et manifeste de finalisation sont +optionnels : no-op, rotation, barrière et `Aborted` peuvent n'avoir aucune +séquence ou finalisation. Lorsqu'ils existent, leur ordre suit l'ordinal. Les +publications physiques asynchrones ne reçoivent pas un `WriteOrdinal` et ne +prétendent pas suivre cet ordre logique ; elles restent sous ADR-067. + +Les handlers appartiennent à une union privée fermée : + +```text +LogicalBatch → EngineCommitOutcome +LiveStructural → ADR-067 StructuralReceipt (rotation/lifecycle autorisé) +BarrierOrNoop → aucun WAL ni publication physique +``` + +Les trois sont ordonnés par le coordinateur, mais seul `LogicalBatch` emprunte +le protocole WAL. Une demande de flush/compaction n'est pas déguisée en batch. + +Pour un `LogicalBatch`, le coordinateur : + +1. revalide health, intégrité de la capability, runtime/store/classe/scopes et + préconditions ; l'autorisation courte ADR-069 reste un snapshot et la + policy/grants ne sont pas réinterrogés après attente ; +2. vérifie les plafonds/admission encore applicables ; +3. prépare les mutations RAM reconstructibles sans les rendre visibles ; +4. appelle l'unique primitive engine fermée `commit_batch`, qui réserve les + séquences et append le WAL puis retourne un `CommitReceipt` phase-aware ; +5. installe les états RAM associés exactement une fois ; +6. publie le résultat et enfile la finalisation dans le même ordre. + +`CommitReceipt` contient au minimum la plage exacte (gaps brûlés permis), la +durabilité confirmée et un token d'installation non forgeable. Le wrapper +legacy `apply_batch -> Result<()>` peut rester stable, mais le coordinateur ne +déduit jamais l'issue depuis des snapshots avant/après. + +### 4.1 Issue WAL phase-aware + +Le commit engine et le coordinator ont deux issues fermées distinctes : + +```text +EngineCommitOutcome = + Aborted { burned_sequence_range? } + | Durable(CommitReceipt { sequence_range, install_token }) + | OutcomeUnknown { phase: Append | Sync } + +CoordinatorOutcome = + Aborted + | Committed { sequence_range? } + | OutcomeUnknown { phase: Append | Sync } + | DurableReopenRequired { + sequence_range, + cause: ProductInstall | VisibilitySwap + } + | StructuralReopenRequired { + phase: CatalogReplace | DirectorySync | CryptoGeneration, + candidate_ownership: RetainForRecovery + } +``` + +- refus avant réservation : `Aborted`, writer réutilisable ; +- erreur après réservation mais prouvée avant le premier octet WAL : + `Aborted`, plage éventuellement brûlée ; +- dès qu'un write WAL a été tenté : `OutcomeUnknown(Append)`, sauf si un + rollback `truncate + sync` a entièrement réussi et est prouvé ; +- erreur/panic de `sync_all` : `OutcomeUnknown(Sync)` ; +- fsync confirmé : `Durable`; l'installation produit correspondante devient + obligatoire et ne peut plus devenir `Aborted` ; +- panic/erreur après fsync mais avant installation/swap produit complet : + `DurableReopenRequired`, plage conservée, writer terminal et reopen. Le + caller sait que la mutation durable sera rejouée et ne doit pas la retry + naïvement ; +- après publication logique complète : `Committed`; une erreur de finalisation + ne change jamais ce verdict. + +Un handler `LiveStructural` traduit directement l'issue ADR-067 : succès vers +`Committed { sequence_range: None }`, remplacement structurel ambigu vers +`StructuralReopenRequired` sans inventer de plage WAL. Ce verdict conserve les +candidats potentiellement référencés, terminalise la même autorité et exige +reopen ; il n'est jamais converti en `OutcomeUnknown(Append|Sync)`. + +Tout `OutcomeUnknown` et `DurableReopenRequired` est terminal, conserve les +candidats/bytes concernés, +interdit tout append suivant et exige reopen. Il n'est jamais aplati en +`Io`/`Storage`, transformé en `Ok` ou retrié dans la même instance. Un guard +WAL phase-aware analogue au guard ADR-067 arme la fenêtre dès le premier write. + +### 4.2 Barrière de visibilité produit + +L'actuel `Engine::apply_batch` publie `visible_sequence` avant que certains +index n'installent `count`, `entry_point` et caches ; le `RwLock` +masque aujourd'hui cette fenêtre. Le runtime cible conserve une barrière +équivalente : le writer détient le product gate exclusif du début du commit +engine jusqu'à l'installation de tous les états produit et au swap logique. +Les readers détiennent le guard partagé pendant toute leur opération. Un +reader déjà entré finit sur l'ancien état avant le début du writer ; un nouveau +reader attend puis voit le nouveau complet, jamais `visible_sequence=N` avec +métadonnées N-1. + +Un panic après durabilité engine mais avant swap produit rend l'instance +terminale et interdit toute nouvelle lecture same-instance susceptible de voir +un état incohérent ; reopen reconstruit depuis WAL/records durables. + +La référence correctness est `Immediate`, un intent par commit. Le group +commit d'ADR-050 reste Proposed : il pourra devenir une `CommitPolicy` interne +au même coordinateur après preuve, jamais une deuxième queue ou autorité. + +## 5. Deux lanes, une seule autorité + +La lane logique et la publication physique ont des responsabilités distinctes +mais pas des owners concurrents : + +- la lane logique ordonne intents, séquences, WAL et état mutable produit ; +- les workers physiques peuvent construire/fsync des candidats hors de la + lane logique ; +- toute publication de catalogue/génération/crypto live passe exclusivement + par le `DurablePublicationGuard` ADR-067 existant ; +- aucun worker physique ne réserve de séquence logique, n'append au WAL + logique ou ne fabrique de `WriteIntent` committable ; +- aucun second verrou de publication ni second sticky terminal n'est ajouté. + +Le coordinateur n'exécute pas toute I/O de flush/compaction sur son thread : +cela créerait head-of-line blocking et cycles avec la capacité flush. Il +déclenche ou observe les workers possédés, tandis que leur point de publication +reste gouverné par ADR-067. + +## 6. Santé, panic et `Unknown` + +La santé terminale ADR-067/engine reste la source durable. Le lifecycle du +coordinateur est un overlay monotone (`Open/Closing/Closed`) qui observe et +peut faire progresser cette même source vers terminal, mais ne possède aucun +verdict durable concurrent. `NativeMemoryStore::background_error` devient une +projection seule ou est supprimé ; il ne décide jamais indépendamment. + +États minimum : + +```text +Healthy | Closing | Closed | Terminal(ReconcileRequired | Panic | Background) +``` + +- panic prouvé avant toute mutation durable et après rollback complet : intent + `Aborted`; le writer peut continuer seulement si le test de phase le prouve ; +- panic après mutation dont rollback complet n'est pas prouvé : terminal ; +- panic/erreur après remplacement durable ambigu : `ReconcileRequired` ; +- phase inconnue : terminal fail-closed ; +- un mutex empoisonné n'est jamais repris aveuglément avec `into_inner`. + +L'engine crée à l'ouverture un `EngineTerminalHandle` minimal, one-way, +workspace-private et non forgeable, partagé avec le runtime. Pour terminaliser +une panne produit, il acquiert le `catalog_commit_lock`, pose la santé terminale +engine, libère le lock puis réveille les waiters. Tout publisher revalide la +santé sous ce même lock immédiatement avant `atomic_replace`. Un publisher qui +avait déjà acquis le guard finit avant le point linéaire terminal ; aucun +replace ne commence après. Le runtime n'appelle jamais ce handle en détenant +lui-même un `DurablePublicationGuard`. + +Après terminalité : fermeture immédiate de l'admission. Les intents queued ou +in-flight non durables reçoivent le même verdict terminal ; un intent durable +mais non installé reçoit `DurableReopenRequired`; un intent déjà `Committed` +conserve son succès. Aucun nouvel append/seal/flush/compaction/ +rotation/close propre, candidats potentiellement catalogués conservés, et +reopen obligatoire. `Unknown` n'est jamais retrié dans la même instance. + +## 7. Shutdown et drain + +Le lifecycle est explicite : + +```text +Open → Closing → Draining → Closed + ↘ Terminal +``` + +`close()` est linéarisable et idempotent : un appel gagne `Open→Closing`, les +autres attendent le même résultat. Les submitters sans ordinal perdent ou +gagnent atomiquement la course au point d'admission. + +Close sain : fermer admission → drainer intents → drainer manifestes de +finalisation → empêcher toute nouvelle capture de read state → consommer +`WriterState` → appeler `Engine::close(self)` (flush sain autorisé) → joindre +compaction puis flush → `Closed`. Après retour, aucun intent admis ne peut +appender ou publier. + +Close terminal : publier d'abord la terminalité ADR-067, réveiller admission +et workers, résoudre les intents non publiés avec le même verdict, puis +stop/join/drop sans `flush()` ni nouvelle publication. Les manifestes déjà +enfilés pour des intents `Committed` sont tentés FIFO avant le join du +finalizer ; les réservations non publiées sont libérées sans manifeste. + +V2-03 n'ajoute pas d'API publique `close`. Le dernier handle runtime droppé +ferme l'admission et transfère l'owner thread, le file lock et les joins à un +thread reaper dédié : `Drop` ne bloque pas Tokio et ne peut pas se self-join. +Le reaper exécute le close sain seulement si la santé est saine ; en terminal +il suit le chemin sans publication et journalise l'erreur non retournable. + +Chaque intent admis atteint exactement un outcome terminal. Un ticket abandonné +ne « reçoit » matériellement rien mais n'annule pas l'intent. `close()` attend +la résolution interne, jamais que le caller consomme la réponse. + +Le slot intent est détenu de l'admission à l'outcome terminal. Le slot +finalisation est réservé avant commit : sur `Aborted`/`OutcomeUnknown` il est +libéré sans manifeste ; sur `Committed` son ownership est transféré +atomiquement au manifeste ; il est libéré après la tentative, panic compris. +Le drop du ticket caller ne libère aucune capacité runtime. + +## 8. Finalisation et événements + +Les événements `watch` et autres effets post-commit ne sont plus émis depuis +le caller après retour du storage. La capacité du manifeste est réservée avant +commit ; un `Committed` consomme cette réservation et enfile un manifeste +fermé, sans callback utilisateur arbitraire : + +- exactement une transition/tentative in-process par entrée du manifeste (un + batch contient une entrée par mutation sémantique publiée) ; +- aucune pour `Aborted` ou non-publiée ; +- ordre identique au commit ; +- exécution hors section critique de commit ; +- panic capturé et erreur comptée/journalisée, sans changer le verdict durable + ni rendre le writer terminal ; +- l'absence de receiver, `Lagged`, révocation et annulation du caller sont des + résultats normaux. + +La livraison broadcast est best-effort, ordonnée dans l'instance et non +durable : 0..1 livraison par subscription encore autorisée, aucune garantie +cross-crash sans outbox persistante. Le finaliseur route vers le bus de +l'instance `Memory` qui a soumis l'intent, préservant la sémantique actuelle : +deux instances partageant un store ne reçoivent pas implicitement leurs +événements mutuels. Le manifeste porte un adaptateur fermé de cette instance, +pas un callback arbitraire. Chaque subscription, pas le writer, possède sa +lease `Watch` liée au +principal/runtime/scope et la réévalue avant livraison. La file de finalisation +n'est pas une capability d'écriture. + +## 9. Maintenance et arbitrage + +Les planners maintenance/GC sont read-only. Un read-set par membres ne détecte +pas les phantoms ; V2-03 utilise donc un `scope_mutation_epoch` runtime-local. +Le planner lit `epoch_before`, scanne sous un read state cohérent puis lit +`epoch_after`; il n'émet un intent que si les deux sont égaux. Un plan produit +**un seul chunk borné**. L'intent porte `epoch_after` et ses versions exactes ; +le coordinateur revalide epoch + membres. Le commit incrémente l'epoch sous le +même product gate que l'installation. Après succès, le planner reprend depuis +un nouveau snapshot/epoch. Toute mutation pendant le scan ou avant drain force +un replan, sans auto-invalidation de chunks frères. + +Les schedulers tokio détachés sont inventoriés : leur owner conserve un handle +d'arrêt/join, ou ils reçoivent `Closed` et ne peuvent plus produire d'intent. +Ils ne sont jamais confondus avec les workers physiques engine. + +V2-03 commence avec une FIFO canonique unique. Les classes foreground et +maintenance peuvent avoir des limites d'admission distinctes, mais convergent +avant commit vers le même arbitre et le même ordre. V2-04 décidera quotas, +fairness et working-set global. V2-09 activera la maintenance vectorielle dans +ce protocole ; aucune queue de publication autonome n'est permise entre-temps. + +## 10. Identité de requête et reopen + +`WriteRequestId` est runtime-local : `(RuntimeInstanceId, ordinal)`. Il n'est +ni persisté, ni présenté comme clé d'idempotence cross-reopen. Deux instances +ne partagent jamais leur `RuntimeInstanceId`, et une capability/ticket d'une +instance est rejeté par une autre. + +L'idempotence persistée d'ingestion appartient à V2-10. V2-03 ne réutilise pas +le `runtime_epoch = 0` du scaffold et ne promet aucun retry automatique après +reopen ou `Unknown`. + +## 11. Entrées live et exceptions offline fermées + +| Entrée | Mode normatif | +|---|---| +| remember/forget/invalidate/touch/graph/import/GC/maintenance | live, coordinator obligatoire | +| rotation live | `AuthorizedStoreOperation`, coordinator obligatoire | +| verify container | offline read-only, refus si writer vivant | +| rebuild/reembed/compact offline | `OfflineExclusive`, verrou fichier détenu jusqu'au close/drop | +| bootstrap/open/recovery | avant publication du runtime uniquement | +| candidats flush/compaction | construction non autoritative ; publication ADR-067 | +| migration V2-08 | future, aucun bypass V2-03 autorisé | + +Les seules mutations hors coordinateur live sont donc : + +- bootstrap/open/recovery avant publication de l'instance ; +- construction de candidats physiques non autoritatifs par workers ; +- outils `OfflineExclusive` après acquisition prouvée du verrou de fichier et + en absence de runtime vivant. + +Chaque exception est inventoriée par fonction et mode de lock dans un guard +AST. Le helper de verrouillage couvre aussi le répertoire neuf ; aucun direct +file write n'échappe par absence initiale du lock file. Import et rotation live +ne sont jamais classés offline par commodité. + +### 11.1 Cutover sans double autorité + +La migration est verticale : primitives engine et runtime sont d'abord +dormants, sans route produit. Tous les intents/handlers sont préparés derrière +ce runtime dormant. Puis un cutover unique remplace `NativeMemoryStore.inner`, +migre tous les mutateurs et supprime simultanément `with_inner*`, les closures +write et le scaffold engine `WriterRuntime`. Aucun état destiné à merge ne +contient ancien et nouveau write paths actifs ensemble. + +## 12. Tests normatifs + +1. Guard AST/call graph : zéro mutation logique live de la façade `basemyai` + hors coordinator ; aucun + planner/worker ne peut nommer une capability de commit ou obtenir + `&mut Engine`/`&mut NativeInner`. + Le guard interdit `with_inner_write`, `FnOnce(&mut NativeInner)`, write-lock + hors gateway, destructuration mutable, appel live direct des mutateurs + engine et présence du scaffold `WriterRuntime`; chaque exception offline + exige une whitelist fonction+mode de lock et un contrôle négatif AST. +2. Ordre déterministe : N intents pausés après admission produisent ordinals, + plages de séquences, résultats, événements et état après reopen dans le + même ordre. Contrôle négatif du vieux mutex concurrent obligatoire. +3. Atomicité produit : mémoire/vector/FTS/graphe et leurs états RAM restent + cohérents sur succès, erreur et panic à chaque frontière. +4. Shutdown admis-non-démarré : `close()` attend la requête ; après retour, + zéro append/publication et tous les tickets sont résolus. +5. Matrice panic/Unknown : préparation, revalidation, réservation, WAL append + partiel, après-write, sync error/panic, après-sync, installation RAM, + barrière de visibilité, publication physique et notification ; seules les + phases pré-durables avec rollback prouvé reprennent. + Après fsync, le contrôle attend `DurableReopenRequired`, jamais `Unknown` ou + `Aborted`; reopen retrouve la mutation exactement une fois. +6. Collision logique/physique : foreground avec seal/flush/compaction/rotation + pausés, sans deadlock, catalogue stale ou reader lock pendant fsync. +7. Backpressure : files saturées, aucun lock produit tenu pendant attente, + intents sous plafond, capacité rendue après erreur/panic/shutdown. +8. Maintenance stale : mutation d'un membre et insert/delete phantom pendant + epoch-before/scan/epoch-after ou entre plan et drain → abandon total et + replan d'un chunk unique. +9. Finalisation : saturation avant commit reste bornée ; caller annulé, + receiver absent/lagged, finalizer panic → une tentative de manifeste au + plus, aucun double dispatch, writer non terminal ; révocation Watch coupe + la livraison au prochain contrôle. +10. Runtime binding : IDs, tickets et capabilities d'une instance sont refusés + par une autre ; aucune identité n'est réutilisée comme idempotency key. +11. Future scopes : test pur capability/intent avec mêmes IDs dans deux scopes ; + le chemin legacy live refuse project/multi-scope avant enqueue, sans + persister `ScopeKey` ni prétendre à l'isolation physique. +12. Offline exceptions : second opener refusé et outils offline absents du + call graph live. +13. Course submit/close et deux closes concurrents ; annulation avant/après + linéarisation ; tous les outcomes internes terminaux. +14. API baseline défaut + `test-util` inchangée pour `MemoryStore`, + `NativeMemoryStore` et les surfaces publiques ; aucune nouvelle API close + publique dans V2-03. +15. Reader pausé au milieu d'une opération pendant un commit : ancien état + cohérent puis writer, ou writer bloqué ; aucun mix logique. +16. Publisher pausé après acquisition du guard/avant replace puis terminalité + produit : replace avant le point terminal ou aucun replace après. +17. Thread owner/reaper : aucun fsync/join sur Tokio, aucune garde sync à + travers await, annulation permit sans fuite et aucun self-join. +18. Rotation live à chaque failpoint ADR-067 : une ambiguïté catalogue/ + directory-sync/génération produit `StructuralReopenRequired`, sans plage + WAL, conserve les candidats et bloque tout handler suivant jusqu'au reopen. + +## 13. Exit criteria + +V2-03 est clos lorsque : + +1. cette ADR a passé une revue adversariale et est ratifiée ; +2. tous les chemins live de mutation passent par un unique + `WriteCoordinator` privé possédant le `WriterState` complet ; +3. la primitive `CommitReceipt`/outcome WAL et la barrière de visibilité + produit satisfont leurs matrices de failpoints ; +4. le scaffold `writer_runtime.rs` est absorbé ou supprimé, jamais laissé comme + deuxième autorité ; +5. ADR-067 reste l'unique autorité physique et health terminal ; +6. shutdown, panic, backpressure, ordre et finalisation satisfont les gates ; +7. les guards `v2-layering`/source et leurs contrôles négatifs passent ; +8. `cargo xtask check`, `cargo xtask test`, + `cargo xtask test-crash-consistency` et `cargo xtask test-adr067-release` + terminent exit 0 sur les plateformes requises ; +9. `format.lock` est inchangé et aucune isolation physique multi-project n'est + revendiquée. + +La baseline API est une nouvelle section `[product-api.default]` et +`[product-api.test-util]` de `xtask/v2-layering-baseline.txt`, produite par le +parseur Rust existant pour les items publics de `MemoryStore`, +`NativeMemoryStore` et façades concernées. Toute différence non ratifiée fait +échouer `cargo xtask v2-layering`. + +## 14. Non-goals + +- group commit ou batching adaptatif (preuve ultérieure depuis ADR-050) ; +- governor global/fairness en octets (V2-04) ; +- `ReadPoint`/`RecallOperation` (V2-05) ; +- `ScopeKey` et formats multi-project (V2-08) ; +- maintenance vectorielle activée (V2-09) ; +- idempotence/lineage persistés (V2-10) ; +- packaging public final (V2-12). + +## 15. Alternatives rejetées + +- **Mutex autour d'`Engine` uniquement.** Rejeté : les états RAM et + allocateurs des index resteraient une seconde autorité logique. +- **Conserver `with_inner_write` pour les cas complexes.** Rejeté : une closure + arbitraire est une capability write non auditable. +- **Deux queues logique/physique indépendantes.** Rejeté : ordre divergent, + publication stale et deux états terminaux. +- **Toute I/O physique sur le thread logique.** Rejeté : head-of-line blocking + et cycles de backpressure avec flush/compaction. +- **Reprendre un mutex empoisonné.** Rejeté : la phase de mutation est inconnue. +- **Faire de `WriteRequestId` une idempotency key cross-reopen.** Rejeté : aucun + epoch persistant sûr n'existe ; V2-10 décide l'idempotence durable. +- **Émettre les événements depuis le caller après retour.** Rejeté : + l'annulation peut perdre la finalisation d'un commit publié. diff --git a/docs/status.md b/docs/status.md index a3dbd4b..f52b6f6 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,5 +1,30 @@ # BaseMyAI — Implementation Status Matrix +**Mise à jour 2026-08-13 — V2-02A appliqué localement sur Windows.** +ADR-069 révision 3 est ratifié. Les types canoniques `ProjectId`/`AgentId`/ +`MemoryId`/`MemoryScope`/`QualifiedMemoryId` et `CallerPrincipal` sont publics ; +l'authorizer, `AuthorizedOperation`, les capabilities store et +`LegacyPrivateScope` restent privés et sont gardés par `v2-layering`. Les +grants sont exacts et statiques par classe d'opération, les capabilities sont +liées au runtime et à leur classe, les réductions ne peuvent pas élargir les +droits, et les leases `Watch` sont réévaluées après changement de policy. +Les erreurs scope/auth sont typées et redacted sur REST et MCP. + +Preuves sur le worktree Windows : tests ciblés isolation **29/29**, runtime +**13/13**, erreurs core/REST/MCP **1/1 + 4/4 + 4/4**, +`cargo test -p xtask` **38 passés + 1 maintainer-only ignoré**, +`cargo xtask v2-layering` **exit 0**, `cargo xtask check` **exit 0 en 136,3 s** +et `cargo xtask test` **exit 0 en 592,2 s**. Deux premières matrices ont +correctement échoué sur des fixtures d'`AgentId` devenues invalides avec la +grammaire ADR-069 ; les fixtures ont été corrigées sans affaiblir leurs +oracles, puis la matrice complète a été rejouée. `format.lock` est inchangé. + +Cette clôture concerne exclusivement **V2-02A sémantique + autorisation** : +aucun format, `ScopeKey` ou isolation physique multi-project n'est activé. Le +runtime produit reste legacy mono-scope ; l'isolation vectorielle physique +reste due à V2-08. La séquence normative passe à **V2-03 writer**, puis V2-04 +governor. + **Mise à jour 2026-08-12 — ADR-067 ratifié, V2-01/V2-01B clos et V2-00 appliqué.** Le run bloquant [`31561069323`](https://github.com/BaseMyAI/basemyai/actions/runs/31561069323) @@ -14,13 +39,9 @@ actionlint et `Required checks` verts. ADR-067 révision 6 est donc ✅ **Accepted**. L'ordre normatif est désormais débloqué à **V2-02 scope/autorisation**, puis V2-03 writer et V2-04 governor. -- **V2-02 ouvert, pas encore ratifié.** ADR-069 révision 2 est 🟡 Proposed. Il - sépare `ProjectId`, `AgentId`, `MemoryScope`, principal et capability - effective ; maintient le runtime/authorizer privés et l'engine sur un - `ScopeKey` opaque. La première tranche reste legacy mono-scope, sans format - ni promesse multi-project ; l'isolation physique vector/FTS/graphe/caches est - explicitement différée au hard cut V2-08 et devra réussir l'oracle de - population étrangère massive. +- **Historique au 2026-08-12 :** V2-02 était alors ouvert et ADR-069 révision 2 + encore Proposed. Cet état est supersédé par la mise à jour du 2026-08-13 + ci-dessus. **Suivi adversarial V2-01 et application de V2-00.** Ce bloc **supersède la clôture V2-01 déclarée dans la mise à jour historique du diff --git a/xtask/src/v2_layering.rs b/xtask/src/v2_layering.rs index 3254c60..eb2f57c 100644 --- a/xtask/src/v2_layering.rs +++ b/xtask/src/v2_layering.rs @@ -1,4 +1,4 @@ -//! ADR-068 revision 2 structural guard. +//! ADR-068 revision 2 and ADR-070 revision 3 structural guard. //! //! This deliberately parses Rust syntax and Cargo metadata. Text searches are //! insufficient here: a private `use basemyai_engine::ReadSnapshot as Snap` @@ -15,18 +15,45 @@ use syn::visit::{self, Visit}; use syn::{Attribute, Item, ItemImpl, ItemTrait, Meta, TraitItem, Type, UseTree, Visibility}; const BASELINE_PATH: &str = "xtask/v2-layering-baseline.txt"; +const V2_03_CUTOVER_MARKER: &str = "xtask/v2-03-cutover-complete"; +/// Public product owners frozen before the V2-03 cutover. +/// +/// This guard is active immediately. The source-authority invariants from +/// ADR-070 section 12 (`with_inner_write`, direct engine mutators and the +/// legacy `WriterRuntime`) are intentionally cutover-only: their current +/// occurrences must be migrated atomically before they can become zero-tolerance +/// checks. Do not turn the pre-cutover inventory into a false green invariant. +const PRODUCT_API_OWNERS: &[&str] = &["Memory", "MemoryStore", "NativeMemoryStore"]; const SEMANTIC_TYPES: &[&str] = &[ "ProjectId", "AgentId", + "MemoryId", "MemoryScope", "QualifiedMemoryId", "ReadPoint", "CallerPrincipal", + "PrincipalIssuer", + "PrincipalSubject", "RecallOperation", ]; const RUNTIME_TYPES: &[&str] = &[ "BaseMyAIRuntime", - "AuthorizedScopeSet", + "ScopeAction", + "OperationClass", + "RuntimeInstanceId", + "ScopeGrant", + "ExactGrant", + "DeclassificationEdge", + "AuthorizationLifetime", + "AuthorizedOperation", + "ScopeAuthorizer", + "StoreAction", + "StoreGrant", + "AuthorizedStoreOperation", + "StoreAuthorizer", + "LegacyPrivateScope", + "LegacyAuthorizedOperation", + "LegacyScopeAdapter", "ReadState", "WriteCoordinator", "WriterState", @@ -57,7 +84,10 @@ struct TypeDefinition { struct Snapshot { api_default: Vec, api_test_util: Vec, + product_api_default: Vec, + product_api_test_util: Vec, freeze: Vec, + cutover_debt: Vec, } pub(crate) fn check_or_exit() { @@ -75,6 +105,10 @@ pub(crate) fn check_or_exit() { errors.extend(native_engine_escape_errors(&root, &source_files)); let actual = build_snapshot(&root, &source_files, &mut errors); + errors.extend(cutover_marker_errors( + root.join(V2_03_CUTOVER_MARKER).exists(), + &actual.cutover_debt, + )); let baseline_path = root.join(BASELINE_PATH); match std::fs::read_to_string(&baseline_path) { Ok(contents) => { @@ -85,7 +119,14 @@ pub(crate) fn check_or_exit() { } if errors.is_empty() { - println!("v2-layering : OK (graphe Cargo, ownership, gel legacy, API défaut + test-util)"); + if root.join(V2_03_CUTOVER_MARKER).exists() { + println!("v2-layering : OK (cutover V2-03 actif, dette runtime zéro)"); + } else { + println!( + "v2-layering : OK (pré-cutover, {} occurrences inventoriées dans [v2-03-cutover-debt])", + actual.cutover_debt.len() + ); + } return; } @@ -448,6 +489,23 @@ fn build_snapshot(root: &Path, files: &[PathBuf], errors: &mut Vec) -> S ApiConfiguration::Default => snapshot.api_default = lines, ApiConfiguration::TestUtil => snapshot.api_test_util = lines, } + + let mut product_lines = Vec::new(); + for path in files + .iter() + .filter(|path| path.starts_with(root.join("crates/basemyai/src"))) + { + match parse_file(path) { + Ok(file) => product_lines.extend(product_api_surface(&file, &relative(root, path), configuration)), + Err(error) => errors.push(error), + } + } + product_lines.sort(); + product_lines.dedup(); + match configuration { + ApiConfiguration::Default => snapshot.product_api_default = product_lines, + ApiConfiguration::TestUtil => snapshot.product_api_test_util = product_lines, + } } let frozen = [ @@ -463,9 +521,290 @@ fn build_snapshot(root: &Path, files: &[PathBuf], errors: &mut Vec) -> S } } snapshot.freeze.sort(); + for path in files + .iter() + .filter(|path| path.starts_with(root.join("crates/basemyai/src"))) + { + match parse_file(path) { + Ok(file) => snapshot + .cutover_debt + .extend(cutover_debt_in_file(&file, &relative(root, path))), + Err(error) => errors.push(error), + } + } + snapshot.cutover_debt.sort(); snapshot } +fn cutover_debt_in_file(file: &syn::File, path: &str) -> Vec { + let mut debt = Vec::new(); + collect_cutover_debt(&file.items, path, "", &mut debt); + debt +} + +fn cutover_marker_errors(marker_active: bool, debt: &[String]) -> Vec { + if marker_active && !debt.is_empty() { + vec![format!( + "marqueur V2-03 actif mais {} bypass runtime restent (la dette doit être exactement zéro)", + debt.len() + )] + } else { + Vec::new() + } +} + +fn collect_cutover_debt(items: &[Item], path: &str, module: &str, debt: &mut Vec) { + for item in items { + if !cfg_enabled(item_attrs(item), ApiConfiguration::Default) { + continue; + } + match item { + Item::Mod(item) => { + if let Some((_, nested)) = &item.content { + let nested_module = if module.is_empty() { + item.ident.to_string() + } else { + format!("{module}::{}", item.ident) + }; + collect_cutover_debt(nested, path, &nested_module, debt); + } + } + Item::Impl(item) => { + let owner = type_name(&item.self_ty).unwrap_or_else(|| normalize_tokens(&item.self_ty)); + for member in &item.items { + if let syn::ImplItem::Fn(method) = member + && cfg_enabled(&method.attrs, ApiConfiguration::Default) + { + let owner = qualified_owner(module, &format!("{owner}::{}", method.sig.ident)); + let mut visitor = CutoverDebtVisitor::new(path, &owner); + if method.sig.ident.to_string().starts_with("with_inner") { + visitor.record("with-inner-definition", &method.sig.ident.to_string()); + } + visitor.visit_signature(&method.sig); + visitor.visit_block(&method.block); + debt.extend(visitor.debt); + } + } + } + Item::Fn(item) => { + let owner = qualified_owner(module, &item.sig.ident.to_string()); + let mut visitor = CutoverDebtVisitor::new(path, &owner); + visitor.visit_signature(&item.sig); + visitor.visit_block(&item.block); + debt.extend(visitor.debt); + } + _ => { + let owner = qualified_owner(module, &item_kind(item)); + let mut visitor = CutoverDebtVisitor::new(path, &owner); + visitor.visit_item(item); + debt.extend(visitor.debt); + } + } + } +} + +fn qualified_owner(module: &str, owner: &str) -> String { + if module.is_empty() { + owner.to_string() + } else { + format!("{module}::{owner}") + } +} + +fn item_kind(item: &Item) -> String { + match item { + Item::Const(item) => format!("const {}", item.ident), + Item::Enum(item) => format!("enum {}", item.ident), + Item::Static(item) => format!("static {}", item.ident), + Item::Struct(item) => format!("struct {}", item.ident), + Item::Trait(item) => format!("trait {}", item.ident), + Item::Type(item) => format!("type {}", item.ident), + Item::Union(item) => format!("union {}", item.ident), + Item::Use(_) => "use".to_string(), + _ => "item".to_string(), + } +} + +struct CutoverDebtVisitor<'a> { + path: &'a str, + owner: &'a str, + occurrences: BTreeMap<(String, String), usize>, + debt: Vec, +} + +impl<'a> CutoverDebtVisitor<'a> { + fn new(path: &'a str, owner: &'a str) -> Self { + Self { + path, + owner, + occurrences: BTreeMap::new(), + debt: Vec::new(), + } + } + + fn record(&mut self, kind: &str, detail: &str) { + let occurrence = self + .occurrences + .entry((kind.to_string(), detail.to_string())) + .or_default(); + *occurrence += 1; + self.debt.push(format!( + "{}|{}|{}|{}#{}", + self.path, self.owner, kind, detail, occurrence + )); + } +} + +impl<'ast> Visit<'ast> for CutoverDebtVisitor<'_> { + fn visit_item_struct(&mut self, item: &'ast syn::ItemStruct) { + if item.ident == "WriterRuntime" { + self.record("legacy-writer-runtime", "WriterRuntime"); + } + visit::visit_item_struct(self, item); + } + + fn visit_item_enum(&mut self, item: &'ast syn::ItemEnum) { + if item.ident == "WriterRuntime" { + self.record("legacy-writer-runtime", "WriterRuntime"); + } + visit::visit_item_enum(self, item); + } + + fn visit_item_type(&mut self, item: &'ast syn::ItemType) { + if item.ident == "WriterRuntime" { + self.record("legacy-writer-runtime", "WriterRuntime"); + } + visit::visit_item_type(self, item); + } + + fn visit_expr_method_call(&mut self, call: &'ast syn::ExprMethodCall) { + let method = call.method.to_string(); + if method.starts_with("with_inner") { + self.record("with-inner-call", &method); + } + if matches!(method.as_str(), "put" | "delete" | "apply_batch") && engine_receiver(&call.receiver) { + self.record("direct-engine-mutation", &method); + } + visit::visit_expr_method_call(self, call); + } + + fn visit_expr_call(&mut self, call: &'ast syn::ExprCall) { + if let syn::Expr::Path(path) = &*call.func { + let segments = path + .path + .segments + .iter() + .map(|part| part.ident.to_string()) + .collect::>(); + if segments.len() >= 2 + && segments[segments.len() - 2] == "Engine" + && matches!( + segments.last().map(String::as_str), + Some("put" | "delete" | "apply_batch") + ) + { + self.record("direct-engine-mutation", segments.last().expect("method exists")); + } + } + visit::visit_expr_call(self, call); + } + + fn visit_type_param_bound(&mut self, bound: &'ast syn::TypeParamBound) { + if let syn::TypeParamBound::Trait(bound) = bound + && bound + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "FnOnce" && fn_once_takes_mut_native_inner(&segment.arguments)) + { + self.record("native-inner-closure", "FnOnce(&mut NativeInner)"); + } + visit::visit_type_param_bound(self, bound); + } + + fn visit_path(&mut self, path: &'ast syn::Path) { + if path.segments.iter().any(|segment| segment.ident == "WriterRuntime") { + self.record("legacy-writer-runtime", "WriterRuntime"); + } + visit::visit_path(self, path); + } +} + +fn fn_once_takes_mut_native_inner(arguments: &syn::PathArguments) -> bool { + let syn::PathArguments::Parenthesized(arguments) = arguments else { + return false; + }; + arguments.inputs.iter().any(|input| { + let Type::Reference(reference) = input else { + return false; + }; + reference.mutability.is_some() && type_name(&reference.elem).as_deref() == Some("NativeInner") + }) +} + +fn engine_receiver(expression: &syn::Expr) -> bool { + match expression { + syn::Expr::Field(field) => matches!(&field.member, syn::Member::Named(name) if name == "engine"), + syn::Expr::Path(path) => path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "engine"), + syn::Expr::Paren(paren) => engine_receiver(&paren.expr), + syn::Expr::Reference(reference) => engine_receiver(&reference.expr), + _ => false, + } +} + +fn product_api_surface(file: &syn::File, path: &str, configuration: ApiConfiguration) -> Vec { + let mut lines = Vec::new(); + for item in &file.items { + if !cfg_enabled(item_attrs(item), configuration) { + continue; + } + match item { + Item::Trait(trait_item) + if is_public(&trait_item.vis) + && PRODUCT_API_OWNERS.contains(&trait_item.ident.to_string().as_str()) => + { + let owner = trait_item.ident.to_string(); + for member in &trait_item.items { + if let TraitItem::Fn(method) = member + && cfg_enabled(&method.attrs, configuration) + { + lines.push(format!( + "{path}|{owner}::{}|{}", + method.sig.ident, + normalize_tokens(&method.sig) + )); + } + } + } + Item::Impl(implementation) + if implementation.trait_.is_none() + && type_name(&implementation.self_ty) + .is_some_and(|owner| PRODUCT_API_OWNERS.contains(&owner.as_str())) => + { + let owner = type_name(&implementation.self_ty).expect("owner vérifié ci-dessus"); + for member in &implementation.items { + if let syn::ImplItem::Fn(method) = member + && is_public(&method.vis) + && cfg_enabled(&method.attrs, configuration) + { + lines.push(format!( + "{path}|{owner}::{}|{}", + method.sig.ident, + normalize_tokens(&method.sig) + )); + } + } + } + _ => {} + } + } + lines +} + fn api_leaks_in_file(file: &syn::File, path: &str, configuration: ApiConfiguration) -> Vec { let mut aliases = BTreeMap::new(); collect_imports(&file.items, &mut aliases); @@ -751,7 +1090,10 @@ fn format_snapshot(snapshot: &Snapshot) -> String { for (section, lines) in [ ("api.default", &snapshot.api_default), ("api.test-util", &snapshot.api_test_util), + ("product-api.default", &snapshot.product_api_default), + ("product-api.test-util", &snapshot.product_api_test_util), ("freeze", &snapshot.freeze), + ("v2-03-cutover-debt", &snapshot.cutover_debt), ] { output.push_str(&format!("[{section}]\n")); for line in lines { @@ -967,13 +1309,19 @@ fn parse_snapshot(contents: &str) -> Snapshot { match section { "api.default" => snapshot.api_default.push(line.to_string()), "api.test-util" => snapshot.api_test_util.push(line.to_string()), + "product-api.default" => snapshot.product_api_default.push(line.to_string()), + "product-api.test-util" => snapshot.product_api_test_util.push(line.to_string()), "freeze" => snapshot.freeze.push(line.to_string()), + "v2-03-cutover-debt" => snapshot.cutover_debt.push(line.to_string()), _ => {} } } snapshot.api_default.sort(); snapshot.api_test_util.sort(); + snapshot.product_api_default.sort(); + snapshot.product_api_test_util.sort(); snapshot.freeze.sort(); + snapshot.cutover_debt.sort(); snapshot } @@ -982,7 +1330,18 @@ fn snapshot_errors(expected: &Snapshot, actual: &Snapshot) -> Vec { for (label, expected, actual) in [ ("api.default", &expected.api_default, &actual.api_default), ("api.test-util", &expected.api_test_util, &actual.api_test_util), + ( + "product-api.default", + &expected.product_api_default, + &actual.product_api_default, + ), + ( + "product-api.test-util", + &expected.product_api_test_util, + &actual.product_api_test_util, + ), ("freeze", &expected.freeze, &actual.freeze), + ("v2-03-cutover-debt", &expected.cutover_debt, &actual.cutover_debt), ] { let expected: BTreeSet<_> = expected.iter().collect(); let actual: BTreeSet<_> = actual.iter().collect(); @@ -1097,7 +1456,7 @@ pub use basemyai_engine::Engine as PublicEngine; fn a_runtime_capability_alias_in_public_api_is_rejected() { let file = syn::parse_file( r#" -use crate::runtime::AuthorizedScopeSet as ScopeSet; +use crate::runtime::AuthorizedOperation as ScopeSet; pub fn leaked(_: ScopeSet) {} "#, ) @@ -1110,14 +1469,14 @@ pub fn leaked(_: ScopeSet) {} assert!( errors .iter() - .any(|error| { error.contains("fixture.rs|fn|leaked|runtime::crate::runtime::AuthorizedScopeSet") }) + .any(|error| { error.contains("fixture.rs|fn|leaked|runtime::crate::runtime::AuthorizedOperation") }) ); } #[test] fn a_direct_runtime_capability_in_public_api_is_rejected() { let file = - syn::parse_file("pub fn leaked(_: crate::runtime::AuthorizedScopeSet) {}").expect("fixture Rust valide"); + syn::parse_file("pub fn leaked(_: crate::runtime::AuthorizedOperation) {}").expect("fixture Rust valide"); let actual = Snapshot { api_default: api_leaks_in_file(&file, "fixture.rs", ApiConfiguration::Default), ..Snapshot::default() @@ -1126,7 +1485,7 @@ pub fn leaked(_: ScopeSet) {} assert!( errors .iter() - .any(|error| { error.contains("fixture.rs|fn|leaked|runtime::crate::runtime::AuthorizedScopeSet") }) + .any(|error| { error.contains("fixture.rs|fn|leaked|runtime::crate::runtime::AuthorizedOperation") }) ); } @@ -1166,6 +1525,66 @@ pub fn only_in_tests(_: EngineOptions) {} assert!(errors.iter().any(|error| error.contains("MemoryStore::v2_scope"))); } + #[test] + fn product_api_rejects_a_new_public_close_before_v2_03_exposes_one() { + let original = syn::parse_file("impl NativeMemoryStore { pub fn open() -> Self { todo!() } }") + .expect("fixture originale valide"); + let changed = syn::parse_file( + "impl NativeMemoryStore { pub fn open() -> Self { todo!() } pub async fn close(&self) {} }", + ) + .expect("fixture modifiée valide"); + let expected = Snapshot { + product_api_default: product_api_surface(&original, "fixture.rs", ApiConfiguration::Default), + ..Snapshot::default() + }; + let actual = Snapshot { + product_api_default: product_api_surface(&changed, "fixture.rs", ApiConfiguration::Default), + ..Snapshot::default() + }; + let errors = snapshot_errors(&expected, &actual); + assert!(errors.iter().any(|error| { + error.contains("baseline product-api.default") && error.contains("NativeMemoryStore::close") + })); + } + + #[test] + fn product_api_rejects_a_removed_or_changed_signature() { + let original = + syn::parse_file("pub trait MemoryStore { fn put(&self, value: &str); }").expect("fixture originale valide"); + let changed = syn::parse_file("pub trait MemoryStore { fn put(&self, value: String); }") + .expect("fixture modifiée valide"); + let expected = Snapshot { + product_api_default: product_api_surface(&original, "fixture.rs", ApiConfiguration::Default), + ..Snapshot::default() + }; + let actual = Snapshot { + product_api_default: product_api_surface(&changed, "fixture.rs", ApiConfiguration::Default), + ..Snapshot::default() + }; + let errors = snapshot_errors(&expected, &actual); + assert!(errors.iter().any(|error| error.contains("entrée nouvelle"))); + assert!(errors.iter().any(|error| error.contains("entrée retirée"))); + } + + #[test] + fn product_api_tracks_default_and_test_util_separately() { + let file = syn::parse_file( + r#" +impl Memory { + pub fn stable(&self) {} + #[cfg(feature = "test-util")] + pub fn open_in_memory() -> Self { todo!() } +} +"#, + ) + .expect("fixture Rust valide"); + let default = product_api_surface(&file, "fixture.rs", ApiConfiguration::Default); + let test_util = product_api_surface(&file, "fixture.rs", ApiConfiguration::TestUtil); + assert_eq!(default.len(), 1); + assert_eq!(test_util.len(), 2); + assert!(test_util.iter().any(|line| line.contains("Memory::open_in_memory"))); + } + #[test] fn parser_detects_inner_escape_calls() { let file = syn::parse_file("fn bypass(engine: NativeEngine) { engine.inner(); }").expect("fixture Rust valide"); @@ -1174,6 +1593,62 @@ pub fn only_in_tests(_: EngineOptions) {} assert_eq!(visitor.calls, vec![".inner()"]); } + #[test] + fn cutover_ast_detects_every_legacy_write_bypass_shape() { + let file = syn::parse_file( + r#" +struct NativeInner { engine: Engine } +struct WriterRuntime; +struct Store; +impl Store { + fn with_inner(&self, operation: impl FnOnce(&mut NativeInner) -> T) { + self.with_inner_write(operation); + } + fn bypass(&self, inner: &mut NativeInner) { + self.with_inner(|_| {}); + inner.engine.put(b"k", b"v"); + inner.engine.delete(b"k"); + inner.engine.apply_batch(&Batch::new()); + } +} +"#, + ) + .expect("fixture Rust valide"); + let debt = cutover_debt_in_file(&file, "fixture.rs"); + for expected in [ + "legacy-writer-runtime", + "with-inner-definition", + "native-inner-closure", + "with-inner-call", + "direct-engine-mutation|put", + "direct-engine-mutation|delete", + "direct-engine-mutation|apply_batch", + ] { + assert!( + debt.iter().any(|line| line.contains(expected)), + "détection manquante pour {expected}: {debt:#?}" + ); + } + } + + #[test] + fn cutover_debt_is_baselined_before_marker_and_forbidden_after_marker() { + let debt = vec!["fixture.rs|Store::bypass|with-inner-call|with_inner#1".to_string()]; + assert!(cutover_marker_errors(false, &debt).is_empty()); + assert_eq!(cutover_marker_errors(true, &debt).len(), 1); + assert!(cutover_marker_errors(true, &[]).is_empty()); + + let actual = Snapshot { + cutover_debt: debt, + ..Snapshot::default() + }; + assert!( + snapshot_errors(&Snapshot::default(), &actual) + .iter() + .any(|error| error.contains("baseline v2-03-cutover-debt: entrée nouvelle")) + ); + } + #[test] fn checked_in_baseline_matches_the_current_tree() { let root = workspace_root(); diff --git a/xtask/v2-layering-baseline.txt b/xtask/v2-layering-baseline.txt index 2e81c34..8195a5c 100644 --- a/xtask/v2-layering-baseline.txt +++ b/xtask/v2-layering-baseline.txt @@ -1,5 +1,6 @@ -# ADR-068 revision 2 -- generated from the Rust AST, reviewed and checked in. -# Exact sorted public engine leaks under both supported configurations. +# ADR-068 revision 2 + ADR-070 revision 3 -- generated from the Rust AST, +# reviewed and checked in. Exact sorted engine leaks and product facade APIs +# under both supported configurations. [api.default] crates/basemyai-core/src/storage/native.rs|method|NativeEngine::inner_mut|basemyai_engine::Engine @@ -77,6 +78,180 @@ crates/basemyai/src/storage/native_store/snapshot_ops.rs|method|NativeMemoryStor crates/basemyai/src/storage/native_store/snapshot_ops.rs|method|NativeMemoryStore::keyword_ranking_ids_at|basemyai_engine::ReadSnapshot crates/basemyai/src/storage/native_store/snapshot_ops.rs|method|NativeMemoryStore::vector_ranking_ids_at|basemyai_engine::ReadSnapshot +[product-api.default] +crates/basemyai/src/context/mod.rs|Memory::compile_context_with_estimator|async fn compile_context_with_estimator (& self , request : ContextRequest < '_ > , estimator : & dyn TokenEstimator ,) -> Result < ContextBundle > +crates/basemyai/src/context/mod.rs|Memory::compile_context|async fn compile_context (& self , request : ContextRequest < '_ >) -> Result < ContextBundle > +crates/basemyai/src/memory/mod.rs|Memory::adaptive_forget|async fn adaptive_forget (& self , policy : crate :: maintenance :: AdaptiveForgettingPolicy ,) -> Result < crate :: maintenance :: ForgettingReport > +crates/basemyai/src/memory/mod.rs|Memory::agent|fn agent (& self) -> & AgentId +crates/basemyai/src/memory/mod.rs|Memory::expired_gc|async fn expired_gc (& self , page_size : usize) -> Result < crate :: maintenance :: ExpiredGcReport > +crates/basemyai/src/memory/mod.rs|Memory::forget|async fn forget (& self , id : & str) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::from_native_store|async fn from_native_store (store : Arc < NativeMemoryStore > , embedder : Box < dyn Embedder > , agent : AgentId ,) -> Result < Self > +crates/basemyai/src/memory/mod.rs|Memory::graph|fn graph (& self) -> crate :: Graph +crates/basemyai/src/memory/mod.rs|Memory::invalidate|async fn invalidate (& self , id : & str) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::observe|async fn observe (& self , turns : & [ConversationTurn]) -> Result < Vec < String > > +crates/basemyai/src/memory/mod.rs|Memory::open_native|async fn open_native (path : impl AsRef < std :: path :: Path > , key : & basemyai_core :: EncryptionKey , embedder : Box < dyn Embedder > , agent : AgentId ,) -> Result < Self > +crates/basemyai/src/memory/mod.rs|Memory::purge_agent|async fn purge_agent (& self) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::recall_by_layer|async fn recall_by_layer (& self , query : & str , layer : MemoryLayer , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_hybrid_with_options_at|async fn recall_hybrid_with_options_at (& self , snapshot : & Arc < basemyai_engine :: ReadSnapshot > , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_hybrid_with_options|async fn recall_hybrid_with_options (& self , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_hybrid|async fn recall_hybrid (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_keyword_with_options_at|async fn recall_keyword_with_options_at (& self , snapshot : & Arc < basemyai_engine :: ReadSnapshot > , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_keyword_with_options|async fn recall_keyword_with_options (& self , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_keyword|async fn recall_keyword (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_with_metric_options|async fn recall_with_metric_options (& self , query : & str , k : usize , metric : Metric , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_with_metric|async fn recall_with_metric (& self , query : & str , k : usize , metric : Metric) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_with_options|async fn recall_with_options (& self , query : & str , k : usize , options : RecallOptions) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall|async fn recall (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::remember_batch_with|async fn remember_batch_with (& self , texts : & [String] , layer : MemoryLayer , validity : Validity ,) -> Result < Vec < String > > +crates/basemyai/src/memory/mod.rs|Memory::remember_batch|async fn remember_batch (& self , texts : & [String] , layer : MemoryLayer) -> Result < Vec < String > > +crates/basemyai/src/memory/mod.rs|Memory::remember_with_importance|async fn remember_with_importance (& self , text : & str , layer : MemoryLayer , validity : Validity , importance : f64 ,) -> Result < String > +crates/basemyai/src/memory/mod.rs|Memory::remember_with|async fn remember_with (& self , text : & str , layer : MemoryLayer , validity : Validity) -> Result < String > +crates/basemyai/src/memory/mod.rs|Memory::remember|async fn remember (& self , text : & str , layer : MemoryLayer) -> Result < String > +crates/basemyai/src/memory/mod.rs|Memory::rotate_key_full|async fn rotate_key_full (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::rotate_key|async fn rotate_key (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::rotate_passphrase_full_with_profile|async fn rotate_passphrase_full_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : crate :: storage :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::rotate_passphrase_with_profile|async fn rotate_passphrase_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : crate :: storage :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::search_graph|async fn search_graph (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::set_importance|async fn set_importance (& self , id : & str , importance : f64) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::stats|async fn stats (& self) -> Result < AgentStats > +crates/basemyai/src/memory/mod.rs|Memory::watch|fn watch (& self , agent_id : & str , layer : Option < MemoryLayer >) -> MemorySubscription +crates/basemyai/src/memory/porting.rs|Memory::export_jsonl_at|async fn export_jsonl_at (& self , snapshot : & std :: sync :: Arc < basemyai_engine :: ReadSnapshot >) -> Result < String > +crates/basemyai/src/memory/porting.rs|Memory::export_jsonl|async fn export_jsonl (& self) -> Result < String > +crates/basemyai/src/memory/porting.rs|Memory::import_jsonl_with_options|async fn import_jsonl_with_options (& self , jsonl : & str , trusted : bool) -> Result < ImportReport > +crates/basemyai/src/memory/porting.rs|Memory::import_jsonl|async fn import_jsonl (& self , jsonl : & str) -> Result < ImportReport > +crates/basemyai/src/storage/mod.rs|MemoryStore::agent_stats|async fn agent_stats (& self , agent : & AgentId , now : i64) -> Result < AgentStats > +crates/basemyai/src/storage/mod.rs|MemoryStore::exact_fact_exists|async fn exact_fact_exists (& self , agent : & AgentId , content : & str , at : i64) -> Result < bool > +crates/basemyai/src/storage/mod.rs|MemoryStore::forget_many|async fn forget_many (& self , agent : & AgentId , ids : & [String] , options : ForgetBatchOptions) -> Result < u64 > +crates/basemyai/src/storage/mod.rs|MemoryStore::forget|async fn forget (& self , agent : & AgentId , id : & str) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::graph_traverse|async fn graph_traverse (& self , agent : & AgentId , start : & str , max_depth : u32 , now : i64) -> Result < Vec < Reached > > +crates/basemyai/src/storage/mod.rs|MemoryStore::graph_upsert_edge|async fn graph_upsert_edge (& self , agent : & AgentId , src : & str , relation : & str , dst : & str , weight : f64 , now : i64 , source : basemyai_engine :: GraphSource ,) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::graph_upsert_entity|async fn graph_upsert_entity (& self , agent : & AgentId , id : & str , kind : & str , label : & str , validity : Validity , source : basemyai_engine :: GraphSource ,) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::hydrate|async fn hydrate (& self , agent : & AgentId , ids : & [String] , now : i64) -> Result < Vec < HydratedRecord > > +crates/basemyai/src/storage/mod.rs|MemoryStore::invalidate|async fn invalidate (& self , agent : & AgentId , id : & str , now : i64) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::keyword_ranking_ids|async fn keyword_ranking_ids (& self , agent : & AgentId , match_expr : & str , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > +crates/basemyai/src/storage/mod.rs|MemoryStore::layer_of|async fn layer_of (& self , agent : & AgentId , id : & str) -> Result < Option < MemoryLayer > > +crates/basemyai/src/storage/mod.rs|MemoryStore::list_memories|async fn list_memories (& self , agent : & AgentId , layer : Option < MemoryLayer > , limit : usize , include_invalid : bool , now : i64 ,) -> Result < Vec < ListedRecord > > +crates/basemyai/src/storage/mod.rs|MemoryStore::purge_agent|async fn purge_agent (& self , agent : & AgentId) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::put_memory_batch|async fn put_memory_batch (& self , agent : & AgentId , items : & [NewMemory < '_ >]) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::put_memory|async fn put_memory (& self , id : & str , agent : & AgentId , layer : MemoryLayer , text : & str , validity : Validity , vector : & [f32] , source : & str , importance : f64 ,) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::recall_graph_filtered|async fn recall_graph_filtered (& self , agent : & AgentId , query : & [f32] , k : usize , now : i64 , include_procedural : bool , include_imported : bool ,) -> Result < Vec < Record > > +crates/basemyai/src/storage/mod.rs|MemoryStore::recall_vector|async fn recall_vector (& self , agent : & AgentId , query : & [f32] , k : usize , layer : Option < MemoryLayer > , metric : Metric , now : i64 , include_procedural : bool ,) -> Result < Vec < Record > > +crates/basemyai/src/storage/mod.rs|MemoryStore::recent_episodes|async fn recent_episodes (& self , agent : & AgentId , limit : usize , now : i64) -> Result < Vec < String > > +crates/basemyai/src/storage/mod.rs|MemoryStore::scan_expired|async fn scan_expired (& self , agent : & AgentId , now : i64 , after_id : Option < & str > , limit : usize ,) -> Result < Vec < ExpiredCandidate > > +crates/basemyai/src/storage/mod.rs|MemoryStore::scan_for_forgetting|async fn scan_for_forgetting (& self , agent : & AgentId , now : i64 , after_id : Option < & str > , limit : usize ,) -> Result < Vec < ForgetCandidate > > +crates/basemyai/src/storage/mod.rs|MemoryStore::set_importance|async fn set_importance (& self , agent : & AgentId , id : & str , importance : f64) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::vector_ranking_ids|async fn vector_ranking_ids (& self , agent : & AgentId , query : & [f32] , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > + +[v2-03-cutover-debt] +crates/basemyai/src/storage/native_store/mod.rs|ensure_container_meta|direct-engine-mutation|put#1 + +[product-api.default] +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::container_metadata|async fn container_metadata (& self) -> Result < Vec < (String , String) > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::list_agents|async fn list_agents (& self) -> Result < Vec < String > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_encrypted|fn open_encrypted (path : impl AsRef < Path > , key : & str) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_key|fn open_with_key (path : impl AsRef < Path > , key : & basemyai_core :: EncryptionKey) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_passphrase_and_profile|fn open_with_passphrase_and_profile (path : impl AsRef < Path > , passphrase : & str , profile : basemyai_engine :: Argon2idProfile ,) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_passphrase|fn open_with_passphrase (path : impl AsRef < Path > , passphrase : & str) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::read_snapshot|async fn read_snapshot (& self) -> Result < Arc < basemyai_engine :: ReadSnapshot > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_key_full|async fn rotate_key_full (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_key|async fn rotate_key (& self , new_key : & str) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_passphrase_full_with_profile|async fn rotate_passphrase_full_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : basemyai_engine :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_passphrase_with_profile|async fn rotate_passphrase_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : basemyai_engine :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_with_key|async fn rotate_with_key (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::total_memory_count|async fn total_memory_count (& self) -> Result < u64 > +crates/basemyai/src/storage/native_store/porting.rs|NativeMemoryStore::export_rows_at|async fn export_rows_at (& self , agent : & AgentId , snapshot : & Arc < ReadSnapshot >) -> Result < NativeExportRows > +crates/basemyai/src/storage/native_store/porting.rs|NativeMemoryStore::export_rows|async fn export_rows (& self , agent : & AgentId) -> Result < NativeExportRows > +crates/basemyai/src/storage/native_store/snapshot_ops.rs|NativeMemoryStore::hydrate_at|async fn hydrate_at (& self , snapshot : & Arc < ReadSnapshot > , agent : & AgentId , ids : & [String] , now : i64 ,) -> Result < Vec < HydratedRecord > > +crates/basemyai/src/storage/native_store/snapshot_ops.rs|NativeMemoryStore::keyword_ranking_ids_at|async fn keyword_ranking_ids_at (& self , snapshot : & Arc < ReadSnapshot > , agent : & AgentId , match_expr : & str , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > +crates/basemyai/src/storage/native_store/snapshot_ops.rs|NativeMemoryStore::vector_ranking_ids_at|async fn vector_ranking_ids_at (& self , snapshot : & Arc < ReadSnapshot > , agent : & AgentId , query : & [f32] , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > + +[product-api.test-util] +crates/basemyai/src/context/mod.rs|Memory::compile_context_with_estimator|async fn compile_context_with_estimator (& self , request : ContextRequest < '_ > , estimator : & dyn TokenEstimator ,) -> Result < ContextBundle > +crates/basemyai/src/context/mod.rs|Memory::compile_context|async fn compile_context (& self , request : ContextRequest < '_ >) -> Result < ContextBundle > +crates/basemyai/src/memory/mod.rs|Memory::adaptive_forget|async fn adaptive_forget (& self , policy : crate :: maintenance :: AdaptiveForgettingPolicy ,) -> Result < crate :: maintenance :: ForgettingReport > +crates/basemyai/src/memory/mod.rs|Memory::agent|fn agent (& self) -> & AgentId +crates/basemyai/src/memory/mod.rs|Memory::expired_gc|async fn expired_gc (& self , page_size : usize) -> Result < crate :: maintenance :: ExpiredGcReport > +crates/basemyai/src/memory/mod.rs|Memory::forget|async fn forget (& self , id : & str) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::from_native_store|async fn from_native_store (store : Arc < NativeMemoryStore > , embedder : Box < dyn Embedder > , agent : AgentId ,) -> Result < Self > +crates/basemyai/src/memory/mod.rs|Memory::graph|fn graph (& self) -> crate :: Graph +crates/basemyai/src/memory/mod.rs|Memory::invalidate|async fn invalidate (& self , id : & str) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::observe|async fn observe (& self , turns : & [ConversationTurn]) -> Result < Vec < String > > +crates/basemyai/src/memory/mod.rs|Memory::open_in_memory|async fn open_in_memory (agent_id : & str) -> Result < Self > +crates/basemyai/src/memory/mod.rs|Memory::open_native|async fn open_native (path : impl AsRef < std :: path :: Path > , key : & basemyai_core :: EncryptionKey , embedder : Box < dyn Embedder > , agent : AgentId ,) -> Result < Self > +crates/basemyai/src/memory/mod.rs|Memory::purge_agent|async fn purge_agent (& self) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::recall_by_layer|async fn recall_by_layer (& self , query : & str , layer : MemoryLayer , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_hybrid_with_options_at|async fn recall_hybrid_with_options_at (& self , snapshot : & Arc < basemyai_engine :: ReadSnapshot > , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_hybrid_with_options|async fn recall_hybrid_with_options (& self , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_hybrid|async fn recall_hybrid (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_keyword_with_options_at|async fn recall_keyword_with_options_at (& self , snapshot : & Arc < basemyai_engine :: ReadSnapshot > , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_keyword_with_options|async fn recall_keyword_with_options (& self , query : & str , k : usize , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_keyword|async fn recall_keyword (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_with_metric_options|async fn recall_with_metric_options (& self , query : & str , k : usize , metric : Metric , options : RecallOptions ,) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_with_metric|async fn recall_with_metric (& self , query : & str , k : usize , metric : Metric) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall_with_options|async fn recall_with_options (& self , query : & str , k : usize , options : RecallOptions) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::recall|async fn recall (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::remember_batch_with|async fn remember_batch_with (& self , texts : & [String] , layer : MemoryLayer , validity : Validity ,) -> Result < Vec < String > > +crates/basemyai/src/memory/mod.rs|Memory::remember_batch|async fn remember_batch (& self , texts : & [String] , layer : MemoryLayer) -> Result < Vec < String > > +crates/basemyai/src/memory/mod.rs|Memory::remember_with_importance|async fn remember_with_importance (& self , text : & str , layer : MemoryLayer , validity : Validity , importance : f64 ,) -> Result < String > +crates/basemyai/src/memory/mod.rs|Memory::remember_with|async fn remember_with (& self , text : & str , layer : MemoryLayer , validity : Validity) -> Result < String > +crates/basemyai/src/memory/mod.rs|Memory::remember|async fn remember (& self , text : & str , layer : MemoryLayer) -> Result < String > +crates/basemyai/src/memory/mod.rs|Memory::rotate_key_full|async fn rotate_key_full (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::rotate_key|async fn rotate_key (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::rotate_passphrase_full_with_profile|async fn rotate_passphrase_full_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : crate :: storage :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::rotate_passphrase_with_profile|async fn rotate_passphrase_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : crate :: storage :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::search_graph|async fn search_graph (& self , query : & str , k : usize) -> Result < Vec < Record > > +crates/basemyai/src/memory/mod.rs|Memory::set_importance|async fn set_importance (& self , id : & str , importance : f64) -> Result < () > +crates/basemyai/src/memory/mod.rs|Memory::stats|async fn stats (& self) -> Result < AgentStats > +crates/basemyai/src/memory/mod.rs|Memory::watch|fn watch (& self , agent_id : & str , layer : Option < MemoryLayer >) -> MemorySubscription +crates/basemyai/src/memory/porting.rs|Memory::export_jsonl_at|async fn export_jsonl_at (& self , snapshot : & std :: sync :: Arc < basemyai_engine :: ReadSnapshot >) -> Result < String > +crates/basemyai/src/memory/porting.rs|Memory::export_jsonl|async fn export_jsonl (& self) -> Result < String > +crates/basemyai/src/memory/porting.rs|Memory::import_jsonl_with_options|async fn import_jsonl_with_options (& self , jsonl : & str , trusted : bool) -> Result < ImportReport > +crates/basemyai/src/memory/porting.rs|Memory::import_jsonl|async fn import_jsonl (& self , jsonl : & str) -> Result < ImportReport > +crates/basemyai/src/storage/mod.rs|MemoryStore::agent_stats|async fn agent_stats (& self , agent : & AgentId , now : i64) -> Result < AgentStats > +crates/basemyai/src/storage/mod.rs|MemoryStore::exact_fact_exists|async fn exact_fact_exists (& self , agent : & AgentId , content : & str , at : i64) -> Result < bool > +crates/basemyai/src/storage/mod.rs|MemoryStore::forget_many|async fn forget_many (& self , agent : & AgentId , ids : & [String] , options : ForgetBatchOptions) -> Result < u64 > +crates/basemyai/src/storage/mod.rs|MemoryStore::forget|async fn forget (& self , agent : & AgentId , id : & str) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::graph_traverse|async fn graph_traverse (& self , agent : & AgentId , start : & str , max_depth : u32 , now : i64) -> Result < Vec < Reached > > +crates/basemyai/src/storage/mod.rs|MemoryStore::graph_upsert_edge|async fn graph_upsert_edge (& self , agent : & AgentId , src : & str , relation : & str , dst : & str , weight : f64 , now : i64 , source : basemyai_engine :: GraphSource ,) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::graph_upsert_entity|async fn graph_upsert_entity (& self , agent : & AgentId , id : & str , kind : & str , label : & str , validity : Validity , source : basemyai_engine :: GraphSource ,) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::hydrate|async fn hydrate (& self , agent : & AgentId , ids : & [String] , now : i64) -> Result < Vec < HydratedRecord > > +crates/basemyai/src/storage/mod.rs|MemoryStore::invalidate|async fn invalidate (& self , agent : & AgentId , id : & str , now : i64) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::keyword_ranking_ids|async fn keyword_ranking_ids (& self , agent : & AgentId , match_expr : & str , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > +crates/basemyai/src/storage/mod.rs|MemoryStore::layer_of|async fn layer_of (& self , agent : & AgentId , id : & str) -> Result < Option < MemoryLayer > > +crates/basemyai/src/storage/mod.rs|MemoryStore::list_memories|async fn list_memories (& self , agent : & AgentId , layer : Option < MemoryLayer > , limit : usize , include_invalid : bool , now : i64 ,) -> Result < Vec < ListedRecord > > +crates/basemyai/src/storage/mod.rs|MemoryStore::purge_agent|async fn purge_agent (& self , agent : & AgentId) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::put_memory_batch|async fn put_memory_batch (& self , agent : & AgentId , items : & [NewMemory < '_ >]) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::put_memory|async fn put_memory (& self , id : & str , agent : & AgentId , layer : MemoryLayer , text : & str , validity : Validity , vector : & [f32] , source : & str , importance : f64 ,) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::recall_graph_filtered|async fn recall_graph_filtered (& self , agent : & AgentId , query : & [f32] , k : usize , now : i64 , include_procedural : bool , include_imported : bool ,) -> Result < Vec < Record > > +crates/basemyai/src/storage/mod.rs|MemoryStore::recall_vector|async fn recall_vector (& self , agent : & AgentId , query : & [f32] , k : usize , layer : Option < MemoryLayer > , metric : Metric , now : i64 , include_procedural : bool ,) -> Result < Vec < Record > > +crates/basemyai/src/storage/mod.rs|MemoryStore::recent_episodes|async fn recent_episodes (& self , agent : & AgentId , limit : usize , now : i64) -> Result < Vec < String > > +crates/basemyai/src/storage/mod.rs|MemoryStore::scan_expired|async fn scan_expired (& self , agent : & AgentId , now : i64 , after_id : Option < & str > , limit : usize ,) -> Result < Vec < ExpiredCandidate > > +crates/basemyai/src/storage/mod.rs|MemoryStore::scan_for_forgetting|async fn scan_for_forgetting (& self , agent : & AgentId , now : i64 , after_id : Option < & str > , limit : usize ,) -> Result < Vec < ForgetCandidate > > +crates/basemyai/src/storage/mod.rs|MemoryStore::set_importance|async fn set_importance (& self , agent : & AgentId , id : & str , importance : f64) -> Result < () > +crates/basemyai/src/storage/mod.rs|MemoryStore::vector_ranking_ids|async fn vector_ranking_ids (& self , agent : & AgentId , query : & [f32] , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::container_metadata|async fn container_metadata (& self) -> Result < Vec < (String , String) > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::list_agents|async fn list_agents (& self) -> Result < Vec < String > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_encrypted|fn open_encrypted (path : impl AsRef < Path > , key : & str) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_ephemeral_encrypted|fn open_ephemeral_encrypted (key : & str) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_ephemeral|fn open_ephemeral () -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_engine_options|fn open_with_engine_options (path : impl AsRef < Path > , options : basemyai_engine :: EngineOptions) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_key|fn open_with_key (path : impl AsRef < Path > , key : & basemyai_core :: EncryptionKey) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_passphrase_and_profile|fn open_with_passphrase_and_profile (path : impl AsRef < Path > , passphrase : & str , profile : basemyai_engine :: Argon2idProfile ,) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open_with_passphrase|fn open_with_passphrase (path : impl AsRef < Path > , passphrase : & str) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::open|fn open (path : impl AsRef < Path >) -> Result < Self > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::read_snapshot|async fn read_snapshot (& self) -> Result < Arc < basemyai_engine :: ReadSnapshot > > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_key_full|async fn rotate_key_full (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_key|async fn rotate_key (& self , new_key : & str) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_passphrase_full_with_profile|async fn rotate_passphrase_full_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : basemyai_engine :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_passphrase_with_profile|async fn rotate_passphrase_with_profile (& self , new_passphrase : basemyai_core :: EncryptionKey , profile : basemyai_engine :: Argon2idProfile ,) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::rotate_with_key|async fn rotate_with_key (& self , new_key : basemyai_core :: EncryptionKey) -> Result < () > +crates/basemyai/src/storage/native_store/mod.rs|NativeMemoryStore::total_memory_count|async fn total_memory_count (& self) -> Result < u64 > +crates/basemyai/src/storage/native_store/porting.rs|NativeMemoryStore::export_rows_at|async fn export_rows_at (& self , agent : & AgentId , snapshot : & Arc < ReadSnapshot >) -> Result < NativeExportRows > +crates/basemyai/src/storage/native_store/porting.rs|NativeMemoryStore::export_rows|async fn export_rows (& self , agent : & AgentId) -> Result < NativeExportRows > +crates/basemyai/src/storage/native_store/snapshot_ops.rs|NativeMemoryStore::hydrate_at|async fn hydrate_at (& self , snapshot : & Arc < ReadSnapshot > , agent : & AgentId , ids : & [String] , now : i64 ,) -> Result < Vec < HydratedRecord > > +crates/basemyai/src/storage/native_store/snapshot_ops.rs|NativeMemoryStore::keyword_ranking_ids_at|async fn keyword_ranking_ids_at (& self , snapshot : & Arc < ReadSnapshot > , agent : & AgentId , match_expr : & str , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > +crates/basemyai/src/storage/native_store/snapshot_ops.rs|NativeMemoryStore::vector_ranking_ids_at|async fn vector_ranking_ids_at (& self , snapshot : & Arc < ReadSnapshot > , agent : & AgentId , query : & [f32] , k : usize , now : i64 , include_procedural : bool ,) -> Result < Vec < String > > + [freeze] crates/basemyai-core/src/storage/engine.rs|StorageEngine::capabilities|fn capabilities (& self) -> EngineCapabilities crates/basemyai-core/src/storage/native.rs|NativeEngine::inner_mut|fn inner_mut (& mut self) -> & mut Engine