diff --git a/Cargo.lock b/Cargo.lock index 37b4fe8b2..b23475786 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1396,6 +1396,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "feature-storage" +version = "0.1.0" +dependencies = [ + "rusqlite", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "ff" version = "0.13.1" @@ -4634,15 +4644,20 @@ dependencies = [ "agen", "async-trait", "client", + "feature-storage", "fs4", "futures", "job", + "libc", "manifest", + "memory", "protocol", "rusqlite", "serde", "serde_json", + "server-api", "session-store", + "subjektiv", "tempfile", "thiserror 2.0.18", "tokio", @@ -4719,6 +4734,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subjektiv" +version = "0.1.0" +dependencies = [ + "chrono", + "feature-storage", + "manifest", + "memory", + "rusqlite", + "serde", + "serde_json", + "server-api", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "subtle" version = "2.6.1" @@ -6583,6 +6615,7 @@ dependencies = [ "server-api", "session-analytics", "session-store", + "standalone", "tempfile", "ticket", "tokio", @@ -6627,6 +6660,7 @@ dependencies = [ "axum", "chrono", "config-source", + "feature-storage", "flow", "futures", "job", @@ -6646,6 +6680,7 @@ dependencies = [ "session-store", "sha2 0.11.0", "ssh-key", + "subjektiv", "tempfile", "thiserror 2.0.18", "ticket", diff --git a/Cargo.toml b/Cargo.toml index a43ed2096..a573ccb12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ members = [ "crates/workdir", "crates/tui", "crates/memory", + "crates/feature-storage", + "crates/subjektiv", "crates/ticket", "crates/merge-request", "crates/project-record", @@ -66,6 +68,8 @@ default-members = [ "crates/workdir", "crates/tui", "crates/memory", + "crates/feature-storage", + "crates/subjektiv", "crates/ticket", "crates/merge-request", "crates/project-record", @@ -93,6 +97,8 @@ manifest = { path = "crates/manifest" } mcp = { path = "crates/mcp" } lint-common = { path = "crates/lint-common" } memory = { path = "crates/memory" } +feature-storage = { path = "crates/feature-storage" } +subjektiv = { path = "crates/subjektiv" } merge-request = { path = "crates/merge-request" } ticket = { path = "crates/ticket" } project-record = { path = "crates/project-record" } diff --git a/crates/feature-storage/Cargo.toml b/crates/feature-storage/Cargo.toml new file mode 100644 index 000000000..2702517ea --- /dev/null +++ b/crates/feature-storage/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "feature-storage" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +rusqlite.workspace = true +thiserror.workspace = true +uuid = { workspace = true, features = ["v7"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/feature-storage/README.md b/crates/feature-storage/README.md new file mode 100644 index 000000000..9defd6a75 --- /dev/null +++ b/crates/feature-storage/README.md @@ -0,0 +1,28 @@ +# feature-storage + +Neutral Host-owned SQLite lifecycle manager for trusted Features. Owns placement, +WAL/connection settings, transaction serialization, migrations, snapshots, +restore/delete fencing, and shutdown. Features receive a managed +`FeatureDatabase` after registration. Worker/model input never selects a database +path or receives SQL access. + +Use `FeatureStorage::new(trusted_root)`, `register(FeatureRegistration)`, and +`scope(host_issued_scope_id).open(®istration)`. `ScopedFeatureStorage` and +`FeatureDatabase::scope_id()` are neutral storage isolation APIs: a local Host's +persisted random scope does not create a Workspace record or imply Backend +membership. Existing placement `//features/.sqlite` and +snapshot `workspace=` keys remain unchanged for physical compatibility. + +`WorkspaceFeatureStorage`, `workspace()`, `workspace_id()`, and +`for_server_database()` are Backend compatibility aliases/helpers. They do not +introduce a Server dependency. The Host is responsible for state-root privacy, +symlink/path validation, and external execution leases before constructing this +trusted manager. + +`configure_connection(&rusqlite::Connection)` is reusable by other Host stores +(such as JobStore). It sets the five-second busy timeout, foreign keys ON, WAL, +and synchronous FULL before migrations/transactions. The Feature manager uses +this same helper; WAL lock-contention retries stay within the busy timeout. + +Dependencies: `rusqlite` (existing workspace backup support), `thiserror`, `uuid`; +tests use `tempfile`. diff --git a/crates/feature-storage/src/lib.rs b/crates/feature-storage/src/lib.rs new file mode 100644 index 000000000..877a20dce --- /dev/null +++ b/crates/feature-storage/src/lib.rs @@ -0,0 +1,1433 @@ +//! Host-owned SQLite storage for trusted Features. +//! +//! The Host owns database placement, connection configuration, migrations, and +//! operational lifecycle. A Feature owns its schema and only receives a scoped +//! [`FeatureDatabase`] after trusted registration; Worker/model inputs never +//! select a path or receive a SQL surface. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use rusqlite::backup::Backup; +use rusqlite::{Connection, ErrorCode, OpenFlags, Transaction, TransactionBehavior, params}; +use uuid::Uuid; + +const BUSY_TIMEOUT: Duration = Duration::from_secs(5); +const MIGRATION_TABLE: &str = "__yoi_feature_schema_migrations"; +const SNAPSHOT_FORMAT: &str = "yoi-feature-storage-v1"; +const SNAPSHOT_MANIFEST: &str = "manifest.txt"; +const MAX_IDENTIFIER_BYTES: usize = 128; + +pub type Result = std::result::Result; +pub type FeatureMigrationApply = for<'connection> fn(&Transaction<'connection>) -> Result<()>; + +#[derive(Debug, thiserror::Error)] +pub enum FeatureStorageError { + #[error("invalid {kind} storage identifier `{value}`")] + InvalidIdentifier { kind: &'static str, value: String }, + #[error("Feature `{0}` is not registered with this Host storage manager")] + FeatureNotRegistered(String), + #[error("Feature `{0}` is already registered with this Host storage manager")] + FeatureAlreadyRegistered(String), + #[error("invalid migration plan for Feature `{feature}`: {detail}")] + InvalidMigrationPlan { feature: String, detail: String }, + #[error("Feature registration belongs to a different Host storage manager")] + ForeignRegistration, + #[error("Feature storage is shutting down")] + ShuttingDown, + #[error("Feature storage for scope `{0}` is undergoing maintenance")] + ScopeMaintenance(String), + #[error("Feature database is closed")] + DatabaseClosed, + #[error( + "Feature `{feature}` database schema version {actual} is newer than supported version {supported}" + )] + UnsupportedSchema { + feature: String, + actual: u32, + supported: u32, + }, + #[error("Feature `{feature}` migration history is incompatible: {detail}")] + MigrationHistory { feature: String, detail: String }, + #[error("Feature `{feature}` migration {version} (`{name}`) failed: {detail}")] + MigrationFailed { + feature: String, + version: u32, + name: String, + detail: String, + }, + #[error("Feature storage snapshot is invalid: {0}")] + InvalidSnapshot(String), + #[error("Feature storage destination already exists: {0}")] + DestinationExists(String), + #[error("Feature storage operation failed: {0}")] + Operation(String), + #[error("feature storage I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("feature storage SQLite error: {0}")] + Sqlite(#[from] rusqlite::Error), +} + +impl FeatureStorageError { + /// Lets a Feature reject a domain operation while retaining transaction rollback. + pub fn operation(message: impl Into) -> Self { + Self::Operation(message.into()) + } +} + +#[derive(Clone, Copy)] +pub struct FeatureMigration { + version: u32, + name: &'static str, + apply: FeatureMigrationApply, +} + +impl FeatureMigration { + pub const fn new(version: u32, name: &'static str, apply: FeatureMigrationApply) -> Self { + Self { + version, + name, + apply, + } + } + + pub fn version(&self) -> u32 { + self.version + } + + pub fn name(&self) -> &'static str { + self.name + } +} + +#[derive(Clone, Copy)] +pub struct FeatureRegistration { + feature_id: &'static str, + migrations: &'static [FeatureMigration], +} + +impl FeatureRegistration { + pub const fn new(feature_id: &'static str, migrations: &'static [FeatureMigration]) -> Self { + Self { + feature_id, + migrations, + } + } + + pub fn feature_id(&self) -> &'static str { + self.feature_id + } +} + +#[derive(Clone, Debug)] +pub struct RegisteredFeature { + manager_id: Uuid, + feature_id: String, +} + +impl RegisteredFeature { + pub fn feature_id(&self) -> &str { + &self.feature_id + } +} + +#[derive(Clone)] +pub struct FeatureStorage { + inner: Arc, +} + +struct FeatureStorageInner { + id: Uuid, + root: PathBuf, + state: Mutex, +} + +struct FeatureStorageState { + running: bool, + registrations: HashMap, + connections: HashMap<(String, String), Weak>, + maintenance: HashSet, + deletion_fences: HashSet, +} + +impl FeatureStorage { + /// Creates a lazy manager. No directories or databases are created until a + /// registered Feature is opened, restored, or explicitly backed up. + pub fn new(root: impl Into) -> Self { + Self { + inner: Arc::new(FeatureStorageInner { + id: Uuid::now_v7(), + root: root.into(), + state: Mutex::new(FeatureStorageState { + running: true, + registrations: HashMap::new(), + connections: HashMap::new(), + maintenance: HashSet::new(), + deletion_fences: HashSet::new(), + }), + }), + } + } + + pub fn for_server_database(database_path: impl AsRef) -> Result { + let database_path = database_path.as_ref(); + let parent = database_path.parent().ok_or_else(|| { + FeatureStorageError::Operation(format!( + "Server database path `{}` has no parent", + database_path.display() + )) + })?; + Ok(Self::new(parent.join("feature-storage"))) + } + + /// Binds trusted storage to a Host-issued opaque scope. This is a storage + /// isolation key, not a Workspace record or model authorization credential. + pub fn scope(&self, scope_id: &str) -> Result { + Ok(ScopedFeatureStorage { + manager: self.clone(), + scope_id: validate_identifier("Storage scope", scope_id)?.to_string(), + }) + } + + /// Backend compatibility alias; does not create or require a Workspace. + pub fn workspace(&self, scope_id: &str) -> Result { + self.scope(scope_id) + } + + pub fn register(&self, registration: FeatureRegistration) -> Result { + let feature_id = validate_identifier("Feature", registration.feature_id)?.to_string(); + validate_migrations(&feature_id, registration.migrations)?; + let mut state = self.lock_state()?; + ensure_running(&state)?; + if state.registrations.contains_key(&feature_id) { + return Err(FeatureStorageError::FeatureAlreadyRegistered(feature_id)); + } + state + .registrations + .insert(feature_id.clone(), registration.migrations); + Ok(RegisteredFeature { + manager_id: self.inner.id, + feature_id, + }) + } + + /// Closes every managed connection after any operation currently holding a + /// connection lock completes. New opens and operations then fail. + pub fn shutdown(&self) -> Result<()> { + let connections = { + let mut state = self.lock_state()?; + if !state.running { + return Ok(()); + } + state.running = false; + let connections = state + .connections + .values() + .filter_map(Weak::upgrade) + .collect::>(); + for connection in &connections { + connection.fence(); + } + state.connections.clear(); + connections + }; + for connection in connections { + connection.close()?; + } + Ok(()) + } + + fn open(&self, scope_id: &str, registration: &RegisteredFeature) -> Result { + if registration.manager_id != self.inner.id { + return Err(FeatureStorageError::ForeignRegistration); + } + let key = (scope_id.to_string(), registration.feature_id.clone()); + let mut state = self.lock_state()?; + ensure_running(&state)?; + if state.maintenance.contains(scope_id) || state.deletion_fences.contains(scope_id) { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.to_string())); + } + let migrations = state + .registrations + .get(®istration.feature_id) + .copied() + .ok_or_else(|| { + FeatureStorageError::FeatureNotRegistered(registration.feature_id.clone()) + })?; + if let Some(connection) = state.connections.get(&key).and_then(Weak::upgrade) { + return Ok(FeatureDatabase { inner: connection }); + } + + let feature_dir = self.feature_dir(scope_id); + fs::create_dir_all(&feature_dir)?; + let path = feature_dir.join(format!("{}.sqlite", registration.feature_id)); + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + )?; + configure_connection(&connection)?; + migrate(&connection, ®istration.feature_id, migrations)?; + let connection = Arc::new(FeatureDatabaseInner { + scope_id: scope_id.to_string(), + feature_id: registration.feature_id.clone(), + accepting_operations: AtomicBool::new(true), + connection: Mutex::new(Some(connection)), + }); + state.connections.insert(key, Arc::downgrade(&connection)); + Ok(FeatureDatabase { inner: connection }) + } + + fn backup_scope(&self, scope_id: &str, destination: &Path) -> Result<()> { + // Keep manager lifecycle and deletion/restore mutually exclusive for the + // duration. Feature transactions use SQLite's online backup snapshot rules. + let state = self.lock_state()?; + ensure_running(&state)?; + if state.maintenance.contains(scope_id) || state.deletion_fences.contains(scope_id) { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.to_string())); + } + if destination.exists() { + return Err(FeatureStorageError::DestinationExists( + destination.display().to_string(), + )); + } + let feature_ids = list_feature_databases(&self.feature_dir(scope_id))?; + let parent = destination.parent().ok_or_else(|| { + FeatureStorageError::Operation(format!( + "snapshot destination `{}` has no parent", + destination.display() + )) + })?; + fs::create_dir_all(parent)?; + let temporary = temporary_sibling(destination, "backup")?; + let result = (|| { + let temporary_features = temporary.join("features"); + fs::create_dir_all(&temporary_features)?; + for feature_id in &feature_ids { + backup_database( + &self + .feature_dir(scope_id) + .join(format!("{feature_id}.sqlite")), + &temporary_features.join(format!("{feature_id}.sqlite")), + )?; + } + fs::write( + temporary.join(SNAPSHOT_MANIFEST), + snapshot_manifest(scope_id, &feature_ids), + )?; + fs::rename(&temporary, destination)?; + Ok(()) + })(); + drop(state); + if result.is_err() { + let _ = fs::remove_dir_all(&temporary); + } + result + } + + fn restore_scope(&self, scope_id: &str, snapshot: &Path) -> Result<()> { + let feature_ids = read_snapshot_manifest(snapshot, scope_id)?; + self.begin_maintenance(scope_id, true)?; + let scope_dir = self.scope_dir(scope_id); + let temporary = self + .inner + .root + .join(format!(".restore-{scope_id}-{}", Uuid::now_v7())); + let result = (|| { + if scope_dir.exists() { + return Err(FeatureStorageError::DestinationExists( + scope_dir.display().to_string(), + )); + } + let temporary_features = temporary.join("features"); + fs::create_dir_all(&temporary_features)?; + for feature_id in &feature_ids { + backup_database( + &snapshot + .join("features") + .join(format!("{feature_id}.sqlite")), + &temporary_features.join(format!("{feature_id}.sqlite")), + )?; + } + fs::create_dir_all(&self.inner.root)?; + fs::rename(&temporary, &scope_dir)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&temporary); + } + self.end_maintenance(scope_id)?; + result + } + + fn delete_scope(&self, scope_id: &str) -> Result<()> { + let connections = { + let mut state = self.lock_state()?; + ensure_running(&state)?; + if state.maintenance.contains(scope_id) { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.to_string())); + } + // The fence intentionally survives physical removal. Workspace deletion + // is finalized in the Server DB immediately afterwards; keeping this + // admission fence prevents a cached API/background task from recreating + // Feature files in that gap. A retry is idempotently admitted. + state.deletion_fences.insert(scope_id.to_string()); + let connections = state + .connections + .iter() + .filter(|((candidate, _), _)| candidate == scope_id) + .filter_map(|(_, connection)| connection.upgrade()) + .collect::>(); + for connection in &connections { + connection.fence(); + } + state + .connections + .retain(|(candidate, _), _| candidate != scope_id); + connections + }; + for connection in connections { + connection.close()?; + } + match fs::remove_dir_all(self.scope_dir(scope_id)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + + fn begin_maintenance( + &self, + scope_id: &str, + require_no_connections: bool, + ) -> Result>> { + let mut state = self.lock_state()?; + ensure_running(&state)?; + if state.deletion_fences.contains(scope_id) + || !state.maintenance.insert(scope_id.to_string()) + { + return Err(FeatureStorageError::ScopeMaintenance(scope_id.to_string())); + } + let connections = state + .connections + .iter() + .filter(|((candidate, _), _)| candidate == scope_id) + .filter_map(|(_, connection)| connection.upgrade()) + .collect::>(); + if require_no_connections && !connections.is_empty() { + state.maintenance.remove(scope_id); + return Err(FeatureStorageError::Operation(format!( + "cannot restore storage scope `{scope_id}` while Feature databases are open" + ))); + } + state + .connections + .retain(|(candidate, _), _| candidate != scope_id); + Ok(connections) + } + + fn end_maintenance(&self, scope_id: &str) -> Result<()> { + let mut state = self.lock_state()?; + state.maintenance.remove(scope_id); + Ok(()) + } + + fn scope_dir(&self, scope_id: &str) -> PathBuf { + self.inner.root.join(scope_id) + } + + fn feature_dir(&self, scope_id: &str) -> PathBuf { + self.scope_dir(scope_id).join("features") + } + + fn lock_state(&self) -> Result> { + self.inner.state.lock().map_err(|_| { + FeatureStorageError::Operation("Feature storage state lock was poisoned".to_string()) + }) + } +} + +impl Drop for FeatureStorageInner { + fn drop(&mut self) { + if let Ok(mut state) = self.state.lock() { + state.running = false; + for connection in state.connections.values().filter_map(Weak::upgrade) { + let _ = connection.close(); + } + state.connections.clear(); + } + } +} + +#[derive(Clone)] +pub struct ScopedFeatureStorage { + manager: FeatureStorage, + scope_id: String, +} + +/// Compatibility name for Backend callers. New Hosts use [`ScopedFeatureStorage`]. +pub type WorkspaceFeatureStorage = ScopedFeatureStorage; + +impl ScopedFeatureStorage { + pub fn scope_id(&self) -> &str { + &self.scope_id + } + + /// Backend compatibility alias for the storage-scope identifier. + pub fn workspace_id(&self) -> &str { + &self.scope_id + } + + pub fn register(&self, registration: FeatureRegistration) -> Result { + self.manager.register(registration) + } + + pub fn open(&self, registration: &RegisteredFeature) -> Result { + self.manager.open(&self.scope_id, registration) + } + + /// Creates a consistent online SQLite snapshot of every Feature database, + /// including data for Features that are currently disabled/unregistered. + pub fn backup(&self, destination: impl AsRef) -> Result<()> { + self.manager + .backup_scope(&self.scope_id, destination.as_ref()) + } + + /// Restores a snapshot only into an absent scope storage directory and + /// only while no Feature database for this scope is open. + pub fn restore(&self, snapshot: impl AsRef) -> Result<()> { + self.manager + .restore_scope(&self.scope_id, snapshot.as_ref()) + } + + /// Closes this scope's Feature connections and removes only its scoped + /// storage tree. This is the operation used by Workspace deletion. + pub fn delete(&self) -> Result<()> { + self.manager.delete_scope(&self.scope_id) + } + + pub fn shutdown(&self) -> Result<()> { + self.manager.shutdown() + } +} + +#[derive(Clone)] +pub struct FeatureDatabase { + inner: Arc, +} + +struct FeatureDatabaseInner { + scope_id: String, + feature_id: String, + accepting_operations: AtomicBool, + connection: Mutex>, +} + +impl FeatureDatabase { + pub fn scope_id(&self) -> &str { + &self.inner.scope_id + } + + /// Backend compatibility alias for the storage-scope identifier. + pub fn workspace_id(&self) -> &str { + &self.inner.scope_id + } + + pub fn feature_id(&self) -> &str { + &self.inner.feature_id + } + + /// Executes a Feature-owned read/query while serializing use of the managed + /// connection. The connection never leaves trusted Host Rust code. + pub fn with_connection( + &self, + operation: impl FnOnce(&Connection) -> Result, + ) -> Result { + self.try_with_connection(operation) + } + + /// Variant of [`Self::with_connection`] that lets a typed Feature repository + /// retain its own domain errors without encoding them as storage strings. + pub fn try_with_connection( + &self, + operation: impl FnOnce(&Connection) -> std::result::Result, + ) -> std::result::Result + where + E: From, + { + self.inner.ensure_accepting().map_err(E::from)?; + let connection = self + .inner + .connection + .lock() + .map_err(|_| { + FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) + }) + .map_err(E::from)?; + self.inner.ensure_accepting().map_err(E::from)?; + let connection = connection + .as_ref() + .ok_or(FeatureStorageError::DatabaseClosed) + .map_err(E::from)?; + operation(connection) + } + + /// Executes a Feature-owned multi-table operation atomically. Returning an + /// error rolls the transaction back; successful return commits it. + pub fn transaction( + &self, + operation: impl FnOnce(&Transaction<'_>) -> Result, + ) -> Result { + self.try_transaction(operation) + } + + /// Variant of [`Self::transaction`] that preserves a Feature repository's + /// typed domain errors while still rolling every failure back atomically. + pub fn try_transaction( + &self, + operation: impl FnOnce(&Transaction<'_>) -> std::result::Result, + ) -> std::result::Result + where + E: From, + { + self.inner.ensure_accepting().map_err(E::from)?; + let mut connection = self + .inner + .connection + .lock() + .map_err(|_| { + FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) + }) + .map_err(E::from)?; + self.inner.ensure_accepting().map_err(E::from)?; + let connection = connection + .as_mut() + .ok_or(FeatureStorageError::DatabaseClosed) + .map_err(E::from)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(FeatureStorageError::from) + .map_err(E::from)?; + let value = operation(&transaction)?; + transaction + .commit() + .map_err(FeatureStorageError::from) + .map_err(E::from)?; + Ok(value) + } + + pub fn schema_version(&self) -> Result { + self.with_connection(|connection| { + connection + .query_row( + &format!("SELECT COALESCE(MAX(version), 0) FROM {MIGRATION_TABLE}"), + [], + |row| row.get(0), + ) + .map_err(Into::into) + }) + } +} + +impl FeatureDatabaseInner { + fn ensure_accepting(&self) -> Result<()> { + if self.accepting_operations.load(Ordering::Acquire) { + Ok(()) + } else { + Err(FeatureStorageError::DatabaseClosed) + } + } + + fn fence(&self) { + self.accepting_operations.store(false, Ordering::Release); + } + + fn close(&self) -> Result<()> { + self.fence(); + let mut connection = self.connection.lock().map_err(|_| { + FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) + })?; + connection.take(); + Ok(()) + } +} + +fn validate_identifier<'a>(kind: &'static str, value: &'a str) -> Result<&'a str> { + let valid = !value.is_empty() + && value.len() <= MAX_IDENTIFIER_BYTES + && value.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => true, + b'-' | b'_' => index > 0, + _ => false, + }); + if !valid { + return Err(FeatureStorageError::InvalidIdentifier { + kind, + value: value.to_string(), + }); + } + Ok(value) +} + +fn validate_migrations(feature: &str, migrations: &[FeatureMigration]) -> Result<()> { + for (index, migration) in migrations.iter().enumerate() { + let expected = (index + 1) as u32; + if migration.version != expected { + return Err(FeatureStorageError::InvalidMigrationPlan { + feature: feature.to_string(), + detail: format!( + "versions must be contiguous from 1; expected {expected}, found {}", + migration.version + ), + }); + } + if migration.name.trim().is_empty() { + return Err(FeatureStorageError::InvalidMigrationPlan { + feature: feature.to_string(), + detail: format!("migration {expected} has an empty name"), + }); + } + } + Ok(()) +} + +fn ensure_running(state: &FeatureStorageState) -> Result<()> { + if state.running { + Ok(()) + } else { + Err(FeatureStorageError::ShuttingDown) + } +} + +/// Applies the common durable Host SQLite connection policy: five-second busy +/// timeout, foreign keys, WAL, and FULL synchronous commits. Call immediately +/// after opening a file-backed database, before transactions or migrations. +/// WAL initialization retries lock contention within the same bounded timeout. +/// This does not grant path authority or select a database schema. +pub fn configure_connection(connection: &Connection) -> Result<()> { + connection.busy_timeout(BUSY_TIMEOUT)?; + connection.execute_batch( + r#" +PRAGMA foreign_keys = ON; +PRAGMA synchronous = FULL; +PRAGMA busy_timeout = 5000; +"#, + )?; + // Changing journal mode takes a database-wide lock and, unlike ordinary + // statements, may report SQLITE_BUSY immediately while another opener is + // making the same transition. Bound retries by the same common timeout. + let attempts = (BUSY_TIMEOUT.as_millis() / 10) as usize; + for attempt in 0..=attempts { + match connection.query_row("PRAGMA journal_mode = WAL", [], |row| { + row.get::<_, String>(0) + }) { + Ok(mode) if mode.eq_ignore_ascii_case("wal") => return Ok(()), + Ok(mode) => { + return Err(FeatureStorageError::Operation(format!( + "SQLite refused WAL journal mode and selected `{mode}`" + ))); + } + Err(error) if sqlite_is_busy(&error) && attempt < attempts => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error.into()), + } + } + unreachable!("bounded WAL retry loop always returns") +} + +fn sqlite_is_busy(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(error, _) + if matches!(error.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked) + ) +} + +fn migrate( + connection: &Connection, + feature_id: &str, + migrations: &[FeatureMigration], +) -> Result<()> { + let transaction = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?; + transaction.execute_batch(&format!( + r#" +CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE} ( + version INTEGER PRIMARY KEY CHECK (version > 0), + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +"# + ))?; + let mut statement = transaction.prepare(&format!( + "SELECT version, name FROM {MIGRATION_TABLE} ORDER BY version" + ))?; + let applied = statement + .query_map([], |row| { + Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + drop(statement); + + let supported = migrations.last().map_or(0, |migration| migration.version); + if let Some((actual, _)) = applied.last() + && *actual > supported + { + return Err(FeatureStorageError::UnsupportedSchema { + feature: feature_id.to_string(), + actual: *actual, + supported, + }); + } + for (index, (version, name)) in applied.iter().enumerate() { + let Some(expected) = migrations.get(index) else { + return Err(FeatureStorageError::UnsupportedSchema { + feature: feature_id.to_string(), + actual: *version, + supported, + }); + }; + if *version != expected.version || name != expected.name { + return Err(FeatureStorageError::MigrationHistory { + feature: feature_id.to_string(), + detail: format!( + "stored migration {version} (`{name}`) does not match expected {} (`{}`)", + expected.version, expected.name + ), + }); + } + } + + for migration in migrations.iter().skip(applied.len()) { + if let Err(error) = (migration.apply)(&transaction) { + return Err(FeatureStorageError::MigrationFailed { + feature: feature_id.to_string(), + version: migration.version, + name: migration.name.to_string(), + detail: error.to_string(), + }); + } + transaction.execute( + &format!("INSERT INTO {MIGRATION_TABLE} (version, name) VALUES (?1, ?2)"), + params![migration.version, migration.name], + )?; + } + transaction.commit()?; + Ok(()) +} + +fn backup_database(source: &Path, destination: &Path) -> Result<()> { + if !source.is_file() { + return Err(FeatureStorageError::InvalidSnapshot(format!( + "Feature database `{}` is missing", + source.display() + ))); + } + let source = Connection::open_with_flags(source, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + source.busy_timeout(BUSY_TIMEOUT)?; + verify_database(&source)?; + let mut destination = Connection::open(destination)?; + Backup::new(&source, &mut destination)?.run_to_completion( + 128, + Duration::from_millis(2), + None, + )?; + verify_database(&destination)?; + Ok(()) +} + +fn verify_database(connection: &Connection) -> Result<()> { + let integrity: String = connection.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; + if integrity != "ok" { + return Err(FeatureStorageError::InvalidSnapshot(format!( + "SQLite integrity_check failed: {integrity}" + ))); + } + let mut foreign_keys = connection.prepare("PRAGMA foreign_key_check")?; + if foreign_keys.query([])?.next()?.is_some() { + return Err(FeatureStorageError::InvalidSnapshot( + "SQLite foreign_key_check reported a violation".to_string(), + )); + } + Ok(()) +} + +fn list_feature_databases(feature_dir: &Path) -> Result> { + if !feature_dir.exists() { + return Ok(Vec::new()); + } + let mut feature_ids = Vec::new(); + for entry in fs::read_dir(feature_dir)? { + let entry = entry?; + let file_type = entry.file_type()?; + if !file_type.is_file() { + continue; + } + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("sqlite") { + continue; + } + let feature_id = path + .file_stem() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + FeatureStorageError::InvalidSnapshot(format!( + "Feature database filename `{}` is not UTF-8", + path.display() + )) + })?; + validate_identifier("Feature", feature_id)?; + feature_ids.push(feature_id.to_string()); + } + feature_ids.sort(); + Ok(feature_ids) +} + +fn snapshot_manifest(scope_id: &str, feature_ids: &[String]) -> String { + let mut manifest = format!("format={SNAPSHOT_FORMAT}\nworkspace={scope_id}\n"); + for feature_id in feature_ids { + manifest.push_str("feature="); + manifest.push_str(feature_id); + manifest.push('\n'); + } + manifest +} + +fn read_snapshot_manifest(snapshot: &Path, expected_workspace: &str) -> Result> { + let manifest = fs::read_to_string(snapshot.join(SNAPSHOT_MANIFEST)).map_err(|error| { + FeatureStorageError::InvalidSnapshot(format!("cannot read manifest: {error}")) + })?; + let mut format = None; + let mut workspace = None; + let mut feature_ids = Vec::new(); + for line in manifest.lines() { + if let Some(value) = line.strip_prefix("format=") { + format = Some(value); + } else if let Some(value) = line.strip_prefix("workspace=") { + workspace = Some(value); + } else if let Some(value) = line.strip_prefix("feature=") { + validate_identifier("Feature", value)?; + feature_ids.push(value.to_string()); + } else { + return Err(FeatureStorageError::InvalidSnapshot(format!( + "unknown manifest entry `{line}`" + ))); + } + } + if format != Some(SNAPSHOT_FORMAT) { + return Err(FeatureStorageError::InvalidSnapshot( + "unsupported snapshot format".to_string(), + )); + } + if workspace != Some(expected_workspace) { + return Err(FeatureStorageError::InvalidSnapshot(format!( + "snapshot storage scope `{}` does not match `{expected_workspace}`", + workspace.unwrap_or("") + ))); + } + feature_ids.sort(); + if feature_ids.windows(2).any(|ids| ids[0] == ids[1]) { + return Err(FeatureStorageError::InvalidSnapshot( + "duplicate Feature entry".to_string(), + )); + } + for feature_id in &feature_ids { + let database = snapshot + .join("features") + .join(format!("{feature_id}.sqlite")); + if !database.is_file() { + return Err(FeatureStorageError::InvalidSnapshot(format!( + "Feature database `{}` is missing", + database.display() + ))); + } + } + Ok(feature_ids) +} + +fn temporary_sibling(destination: &Path, operation: &str) -> Result { + let parent = destination.parent().ok_or_else(|| { + FeatureStorageError::Operation(format!( + "destination `{}` has no parent", + destination.display() + )) + })?; + let name = destination + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + FeatureStorageError::Operation(format!( + "destination `{}` has no UTF-8 filename", + destination.display() + )) + })?; + Ok(parent.join(format!(".{name}.{operation}-{}", Uuid::now_v7()))) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier}; + use std::thread; + + use rusqlite::OptionalExtension; + + use super::*; + + #[derive(Debug)] + enum TestDomainError { + Storage, + Rejected, + } + + impl From for TestDomainError { + fn from(_error: FeatureStorageError) -> Self { + Self::Storage + } + } + + fn create_items(transaction: &Transaction<'_>) -> Result<()> { + transaction.execute_batch( + "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE);", + )?; + Ok(()) + } + + fn add_audit(transaction: &Transaction<'_>) -> Result<()> { + transaction.execute_batch( + "CREATE TABLE audit (id INTEGER PRIMARY KEY, item_id INTEGER NOT NULL, note TEXT NOT NULL);", + )?; + Ok(()) + } + + const MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create items", create_items), + FeatureMigration::new(2, "add audit", add_audit), + ]; + + fn failing_migration(transaction: &Transaction<'_>) -> Result<()> { + transaction.execute_batch("CREATE TABLE partial (id INTEGER PRIMARY KEY);")?; + Err(FeatureStorageError::operation( + "intentional migration failure", + )) + } + + const FAILING_MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create items", create_items), + FeatureMigration::new(2, "fail", failing_migration), + ]; + + fn registration(feature_id: &'static str) -> FeatureRegistration { + FeatureRegistration::new(feature_id, MIGRATIONS) + } + + #[test] + fn reusable_connection_policy_and_manager_require_durable_commits() { + let temp = tempfile::tempdir().unwrap(); + let verify = |connection: &Connection| { + let integer = |pragma: &str| { + connection + .query_row(pragma, [], |row| row.get::<_, u32>(0)) + .unwrap() + }; + assert_eq!(integer("PRAGMA busy_timeout"), 5_000); + assert_eq!(integer("PRAGMA foreign_keys"), 1); + assert_eq!(integer("PRAGMA synchronous"), 2); // FULL + assert_eq!( + connection + .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0)) + .unwrap(), + "wal" + ); + }; + let connection = Connection::open(temp.path().join("job.sqlite")).unwrap(); + configure_connection(&connection).unwrap(); + verify(&connection); + let manager = FeatureStorage::new(temp.path().join("features")); + let registration = manager.register(registration("test-feature")).unwrap(); + let database = manager + .scope("random-local-scope") + .unwrap() + .open(®istration) + .unwrap(); + database + .with_connection(|connection| { + verify(connection); + Ok(()) + }) + .unwrap(); + } + + fn insert(database: &FeatureDatabase, value: &str) -> Result<()> { + database.transaction(|transaction| { + transaction.execute("INSERT INTO items (value) VALUES (?1)", [value])?; + Ok(()) + }) + } + + fn values(database: &FeatureDatabase) -> Result> { + database.with_connection(|connection| { + let mut statement = connection.prepare("SELECT value FROM items ORDER BY id")?; + statement + .query_map([], |row| row.get(0))? + .collect::, _>>() + .map_err(Into::into) + }) + } + + #[test] + fn open_migrate_transaction_rollback_and_reopen_persist() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("subject-memory")).unwrap(); + let database = workspace.open(&feature).unwrap(); + assert_eq!(database.schema_version().unwrap(), 2); + insert(&database, "committed").unwrap(); + let error = database + .transaction::<()>(|transaction| { + transaction.execute("INSERT INTO items (value) VALUES ('rolled-back')", [])?; + Err(FeatureStorageError::operation("reject candidate")) + }) + .unwrap_err(); + assert!(error.to_string().contains("reject candidate")); + assert_eq!(values(&database).unwrap(), ["committed"]); + + manager.shutdown().unwrap(); + assert!(matches!( + values(&database).unwrap_err(), + FeatureStorageError::DatabaseClosed + )); + + let reopened = FeatureStorage::new(temp.path().join("storage")); + let workspace = reopened.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("subject-memory")).unwrap(); + let database = workspace.open(&feature).unwrap(); + assert_eq!(values(&database).unwrap(), ["committed"]); + assert_eq!(database.schema_version().unwrap(), 2); + } + + #[test] + fn typed_domain_error_rolls_back_transaction() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + let database = workspace.open(&feature).unwrap(); + + let result = database.try_transaction::<(), TestDomainError>(|transaction| { + transaction + .execute("INSERT INTO items (value) VALUES ('must-roll-back')", []) + .map_err(FeatureStorageError::from)?; + Err(TestDomainError::Rejected) + }); + assert!(matches!(result, Err(TestDomainError::Rejected))); + assert!(values(&database).unwrap().is_empty()); + } + + #[test] + fn workspace_and_feature_databases_are_isolated() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let feature_a = manager.register(registration("feature-a")).unwrap(); + let feature_b = manager.register(registration("feature-b")).unwrap(); + let workspace_a = manager.workspace("workspace-a").unwrap(); + let workspace_b = manager.workspace("workspace-b").unwrap(); + let a_a = workspace_a.open(&feature_a).unwrap(); + let a_b = workspace_a.open(&feature_b).unwrap(); + let b_a = workspace_b.open(&feature_a).unwrap(); + insert(&a_a, "workspace-a/feature-a").unwrap(); + insert(&a_b, "workspace-a/feature-b").unwrap(); + insert(&b_a, "workspace-b/feature-a").unwrap(); + assert_eq!(values(&a_a).unwrap(), ["workspace-a/feature-a"]); + assert_eq!(values(&a_b).unwrap(), ["workspace-a/feature-b"]); + assert_eq!(values(&b_a).unwrap(), ["workspace-b/feature-a"]); + assert_eq!(a_a.schema_version().unwrap(), 2); + assert_eq!(a_b.schema_version().unwrap(), 2); + assert_eq!(b_a.schema_version().unwrap(), 2); + } + + #[test] + fn concurrent_open_migrates_once_and_reuses_connection() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let threads = (0..2) + .map(|_| { + let workspace = workspace.clone(); + let feature = feature.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + workspace.open(&feature).unwrap() + }) + }) + .collect::>(); + barrier.wait(); + let mut databases = Vec::new(); + for thread in threads { + databases.push(thread.join().unwrap()); + } + assert!(Arc::ptr_eq(&databases[0].inner, &databases[1].inner)); + assert_eq!(databases[0].schema_version().unwrap(), 2); + } + + #[test] + fn independent_managers_serialize_migration_and_writes() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let first_manager = FeatureStorage::new(&root); + let second_manager = FeatureStorage::new(&root); + let barrier = Arc::new(Barrier::new(3)); + let open = |manager: FeatureStorage, barrier: Arc| { + thread::spawn(move || { + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + barrier.wait(); + let database = workspace.open(&feature).unwrap(); + (manager, database) + }) + }; + let first = open(first_manager, barrier.clone()); + let second = open(second_manager, barrier.clone()); + barrier.wait(); + let (first_manager, first_database) = first.join().unwrap(); + let (second_manager, second_database) = second.join().unwrap(); + assert_eq!(first_database.schema_version().unwrap(), 2); + assert_eq!(second_database.schema_version().unwrap(), 2); + + let writers = (0..16) + .map(|index| { + let database = if index % 2 == 0 { + first_database.clone() + } else { + second_database.clone() + }; + thread::spawn(move || insert(&database, &format!("manager-value-{index}"))) + }) + .collect::>(); + for writer in writers { + writer.join().unwrap().unwrap(); + } + assert_eq!(values(&first_database).unwrap().len(), 16); + drop((first_manager, second_manager)); + } + + #[test] + fn concurrent_writes_are_serialized_without_lost_updates() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + let database = workspace.open(&feature).unwrap(); + let threads = (0..8) + .map(|index| { + let database = database.clone(); + thread::spawn(move || insert(&database, &format!("value-{index}"))) + }) + .collect::>(); + for thread in threads { + thread.join().unwrap().unwrap(); + } + assert_eq!(values(&database).unwrap().len(), 8); + } + + #[test] + fn failed_migration_rolls_back_schema_and_version() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace + .register(FeatureRegistration::new("broken", FAILING_MIGRATIONS)) + .unwrap(); + let error = workspace.open(&feature).err().unwrap(); + assert!(matches!(error, FeatureStorageError::MigrationFailed { .. })); + let path = temp + .path() + .join("storage/workspace-a/features/broken.sqlite"); + let connection = Connection::open(path).unwrap(); + let migration_table_exists: bool = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + [MIGRATION_TABLE], + |_| Ok(true), + ) + .optional() + .unwrap() + .unwrap_or(false); + assert!(!migration_table_exists); + let partial_exists: bool = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'partial'", + [], + |_| Ok(true), + ) + .optional() + .unwrap() + .unwrap_or(false); + assert!(!partial_exists); + } + + #[test] + fn rejects_invalid_identifiers_and_newer_schema() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + assert!(manager.workspace("../escape").is_err()); + assert!( + manager + .register(FeatureRegistration::new("bad/name", MIGRATIONS)) + .is_err() + ); + + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + let database = workspace.open(&feature).unwrap(); + database + .with_connection(|connection| { + connection.execute( + &format!("INSERT INTO {MIGRATION_TABLE} (version, name) VALUES (99, 'future')"), + [], + )?; + Ok(()) + }) + .unwrap(); + drop(database); + manager.shutdown().unwrap(); + + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + assert!(matches!( + workspace.open(&feature).err().unwrap(), + FeatureStorageError::UnsupportedSchema { actual: 99, .. } + )); + } + + #[test] + fn registration_is_lazy_and_disable_preserves_data() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + assert!(!root.exists(), "registration must not create storage"); + let database = workspace.open(&feature).unwrap(); + insert(&database, "retained").unwrap(); + manager.shutdown().unwrap(); + + let disabled = FeatureStorage::new(&root); + assert!(root.join("workspace-a/features/feature-a.sqlite").is_file()); + disabled.shutdown().unwrap(); + + let enabled = FeatureStorage::new(&root); + let workspace = enabled.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + assert_eq!( + values(&workspace.open(&feature).unwrap()).unwrap(), + ["retained"] + ); + } + + #[test] + fn backup_restore_is_consistent_and_includes_disabled_features() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let snapshot = temp.path().join("snapshot"); + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + let database = workspace.open(&feature).unwrap(); + insert(&database, "snapshot-value").unwrap(); + workspace.backup(&snapshot).unwrap(); + insert(&database, "after-snapshot").unwrap(); + workspace.delete().unwrap(); + assert!(matches!( + workspace.open(&feature).err().unwrap(), + FeatureStorageError::ScopeMaintenance(_) + )); + drop(database); + drop(workspace); + drop(manager); + + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + workspace.restore(&snapshot).unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + assert_eq!( + values(&workspace.open(&feature).unwrap()).unwrap(), + ["snapshot-value"] + ); + } + + #[test] + fn workspace_delete_closes_handles_and_is_target_limited() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let feature = manager.register(registration("feature-a")).unwrap(); + let workspace_a = manager.workspace("workspace-a").unwrap(); + let workspace_b = manager.workspace("workspace-b").unwrap(); + let database_a = workspace_a.open(&feature).unwrap(); + let database_b = workspace_b.open(&feature).unwrap(); + insert(&database_a, "a").unwrap(); + insert(&database_b, "b").unwrap(); + workspace_a.delete().unwrap(); + assert!(matches!( + values(&database_a).unwrap_err(), + FeatureStorageError::DatabaseClosed + )); + assert_eq!(values(&database_b).unwrap(), ["b"]); + assert!(!temp.path().join("storage/workspace-a").exists()); + assert!(temp.path().join("storage/workspace-b").exists()); + } + + #[test] + fn shutdown_waits_for_operation_and_rejects_later_use() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let workspace = manager.workspace("workspace-a").unwrap(); + let feature = workspace.register(registration("feature-a")).unwrap(); + let database = workspace.open(&feature).unwrap(); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let operation = { + let database = database.clone(); + let entered = entered.clone(); + let release = release.clone(); + thread::spawn(move || { + database.with_connection(|_| { + entered.wait(); + release.wait(); + Ok(()) + }) + }) + }; + entered.wait(); + let shutdown = { + let manager = manager.clone(); + thread::spawn(move || manager.shutdown()) + }; + while database.inner.accepting_operations.load(Ordering::Acquire) { + thread::yield_now(); + } + assert!(!shutdown.is_finished()); + assert!(matches!( + values(&database).unwrap_err(), + FeatureStorageError::DatabaseClosed + )); + release.wait(); + operation.join().unwrap().unwrap(); + shutdown.join().unwrap().unwrap(); + assert!(matches!( + values(&database).unwrap_err(), + FeatureStorageError::DatabaseClosed + )); + assert!(matches!( + workspace.open(&feature).err().unwrap(), + FeatureStorageError::ShuttingDown + )); + } +} diff --git a/crates/manifest/src/builtin_profile.rs b/crates/manifest/src/builtin_profile.rs index 4f44ca57a..17d63848b 100644 --- a/crates/manifest/src/builtin_profile.rs +++ b/crates/manifest/src/builtin_profile.rs @@ -59,6 +59,26 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ description: "Standalone Yoi coding profile.", imports: DEFAULT_IMPORT, }, + BuiltinProfileResource { + selector: Some("builtin:standalone-subjektiv"), + path: "profiles/standalone-subjektiv.dcdl", + source: include_str!("../../../resources/profiles/standalone-subjektiv.dcdl"), + description: "Standalone with an explicitly connected local Subject.", + imports: &[BuiltinProfileImport { + specifier: "./standalone.dcdl", + resolved_path: "profiles/standalone.dcdl", + }], + }, + BuiltinProfileResource { + selector: Some("builtin:standalone-subjektiv-consolidation"), + path: "profiles/standalone-subjektiv-consolidation.dcdl", + source: include_str!("../../../resources/profiles/standalone-subjektiv-consolidation.dcdl"), + description: "Delegated local Subject Job policy.", + imports: &[BuiltinProfileImport { + specifier: "./job.dcdl", + resolved_path: "profiles/job.dcdl", + }], + }, BuiltinProfileResource { selector: Some("builtin:coder"), path: "profiles/coder.dcdl", diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 4fc3ad04e..cf4c711ad 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -189,7 +189,9 @@ pub fn validate_profile_execution_target( if feature.memory.profile.enabled || feature.memory.profile.staging_tools { requirements.insert(WorkspaceAuthorityRequirement::Memory); } - if feature.subjektiv.profile.enabled { + // Subject policy is Host-neutral. Only an already bound Backend snapshot + // requires Workspace authority; a local Host supplies its own live capability. + if feature.subjektiv.workspace_settings.is_some() { requirements.insert(WorkspaceAuthorityRequirement::Subjektiv); } if feature.merge_request.show @@ -1257,11 +1259,21 @@ mod tests { ) .unwrap(); assert_eq!(resolved.manifest.worker.name, "role-worker"); - if entry.name == "job" { + if matches!( + entry.name.as_str(), + "job" | "standalone-subjektiv-consolidation" + ) { assert!(resolved.manifest.compaction.is_none()); assert!(resolved.manifest.scope.allow.is_empty()); assert!(resolved.manifest.delegation_scope.allow.is_empty()); - assert_eq!(resolved.manifest.engine.instruction, "internal.job_system"); + assert_eq!( + resolved.manifest.engine.instruction, + if entry.name == "job" { + "internal.job_system" + } else { + "internal.subjektiv_memory_consolidation_system" + } + ); continue; } let compaction = resolved.manifest.compaction.as_ref().unwrap(); @@ -1669,7 +1681,10 @@ mod tests { panic!("unexpected error: {error}"); }; assert_eq!(target, ProfileExecutionTarget::Standalone); - assert!(requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv)); + assert!( + !requirements.contains(&WorkspaceAuthorityRequirement::Subjektiv), + "subject policy alone is no longer Backend authority" + ); assert!(!requirements.contains(&WorkspaceAuthorityRequirement::Memory)); assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest)); assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket)); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index f7dcd9160..2c42b5161 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -50,6 +50,9 @@ pub enum AuthenticatedInputSource { /// Assigned whenever a serialized tracked method crosses an untrusted /// protocol boundary. Receivers must handle it exactly like public input. UntrustedWire, + /// Stamped only by the operator-owned standalone in-process transport. + /// This is not a Backend Account or authority accepted from wire fields. + LocalOperator, Account { account_id: String, }, @@ -86,6 +89,7 @@ impl AuthenticatedInputSource { pub fn namespace(&self) -> String { match self { Self::UntrustedWire => "untrusted-wire".into(), + Self::LocalOperator => "local-operator".into(), Self::Account { account_id } => format!("account:{account_id}"), Self::Worker { runtime_id, @@ -2175,6 +2179,28 @@ mod tests { ); } + #[test] + fn local_operator_source_cannot_be_granted_by_serialized_input() { + let method = Method::SubmitTracked { + input: vec![Segment::text("forged local operator")], + submission_request_id: "local-forged".into(), + source: AuthenticatedInputSource::LocalOperator, + }; + let decoded: Method = + serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap(); + assert!(matches!( + decoded, + Method::SubmitTracked { + source: AuthenticatedInputSource::UntrustedWire, + .. + } + )); + assert_ne!( + AuthenticatedInputSource::LocalOperator.namespace(), + AuthenticatedInputSource::UntrustedWire.namespace() + ); + } + #[test] fn notify_rejects_typed_intent_instead_of_silently_discarding_it() { for method in ["notify", "notify_tracked"] { diff --git a/crates/session-store/src/fs_store.rs b/crates/session-store/src/fs_store.rs index e9ca34761..12ab5bcdb 100644 --- a/crates/session-store/src/fs_store.rs +++ b/crates/session-store/src/fs_store.rs @@ -211,7 +211,7 @@ impl FsStore { } } - fn parse_jsonl( + pub(crate) fn parse_jsonl( content: &[u8], session_id: SessionId, segment_id: SegmentId, diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index 4ed2bd2bb..d93bead53 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -40,6 +40,8 @@ pub struct LoggedWorkerSubject { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum LoggedSessionHistoryOrigin { + /// Human input verified by the standalone Host, without a Backend Account. + LocalHumanInput, HumanInput { account_id: String, }, diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs index 1e1406d16..1a8392c7e 100644 --- a/crates/session-store/src/public_index.rs +++ b/crates/session-store/src/public_index.rs @@ -208,6 +208,172 @@ pub fn read_session_public_index( let segment_ids = store .list_segments_read_only(session_id) .map_err(map_store_error)?; + project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| { + store + .read_all_read_only_bounded(session_id, segment_id, remaining) + .map_err(map_store_error) + }) +} + +/// Strict committed public projection for the standalone FsStore layout. +/// Uses the same lineage, commit filtering and entry projection as retained +/// WorkerSessionStore observation. No writes, migrations, trace/artifact reads, +/// access-time updates or directory creation occur here. +pub fn read_fs_session_public_index( + sessions_root: &Path, + session_id: SessionId, + limits: SessionPublicIndexLimits, +) -> Result { + read_fs_session_evidence_index(sessions_root, session_id, limits).map(|value| value.index) +} + +/// Producer-compatible numeric hints for stable public evidence references. +/// Both committed annotated-history and filtered public-snapshot capture are +/// valid producers. No hidden history content is exposed by this metadata. +pub struct FsSessionEvidenceIndex { + pub index: SessionPublicIndex, + pub ranges: HashMap<(String, String), Vec<[u64; 2]>>, + pub has_open_run: bool, +} +pub fn read_fs_session_evidence_index( + sessions_root: &Path, + session_id: SessionId, + limits: SessionPublicIndexLimits, +) -> Result { + if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + let session_dir = sessions_root.join(session_id.to_string()); + // IDs are typed and every storage component is observationally validated; + // a known Session ID never authorizes following arbitrary local symlinks. + let mut prefix = std::path::PathBuf::new(); + for part in session_dir.components() { + prefix.push(part); + let metadata = + std::fs::symlink_metadata(&prefix).map_err(|_| SessionPublicIndexReadError::Missing)?; + if metadata.file_type().is_symlink() { + return Err(SessionPublicIndexReadError::Storage); + } + } + let mut segment_ids = Vec::new(); + for entry in + std::fs::read_dir(&session_dir).map_err(|_| SessionPublicIndexReadError::Missing)? + { + let entry = entry.map_err(|_| SessionPublicIndexReadError::Storage)?; + let path = entry.path(); + if path.extension().and_then(|v| v.to_str()) != Some("jsonl") { + continue; + } + let Some(id) = path + .file_stem() + .and_then(|v| v.to_str()) + .and_then(|v| v.parse::().ok()) + else { + continue; + }; + if !entry + .file_type() + .map_err(|_| SessionPublicIndexReadError::Storage)? + .is_file() + { + return Err(SessionPublicIndexReadError::Storage); + } + segment_ids.push(id); + if segment_ids.len() > limits.max_segments { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + } + segment_ids.sort(); + if segment_ids.is_empty() { + return Err(SessionPublicIndexReadError::Missing); + } + let mut ranges = HashMap::<(String, String), Vec<[u64; 2]>>::new(); + let mut has_open_run = false; + let index = + project_public_index_records(session_id, segment_ids, limits, |segment_id, remaining| { + let path = session_dir.join(format!("{segment_id}.jsonl")); + let bytes = crate::read_without_atime_bounded(&path, remaining) + .map_err(|_| SessionPublicIndexReadError::Storage)?; + if bytes.len() as u64 > remaining { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + let entries = crate::FsStore::parse_jsonl(&bytes, session_id, segment_id) + .map_err(map_store_error)?; + let mut open = false; + for entry in &entries { + match entry { + LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } => open = true, + LogEntry::RunCompleted { .. } + | LogEntry::RunYielded { .. } + | LogEntry::RunCancelled { .. } + | LogEntry::RunErrored { .. } + | LogEntry::PausedTurnAbandoned { .. } => open = false, + _ => {} + } + } + has_open_run |= open; + let committed = committed_conversation_records(&entries); + let state = crate::segment_log::collect_state(&committed); + for (position, entry) in state.annotated_history.iter().enumerate() { + ranges + .entry(( + segment_id.to_string(), + format!("E{}", entry.metadata.entry_id.0), + )) + .or_default() + .push([position as u64, position as u64]); + } + let snapshot = crate::public_snapshot::project_session_snapshot_for_segment( + session_id, + Some(segment_id), + &committed, + ); + let mut position = 0_u64; + for entry in snapshot.entries { + if matches!( + entry.data, + SessionSnapshotEntryData::UserInput { .. } + | SessionSnapshotEntryData::Message { .. } + | SessionSnapshotEntryData::ToolCall { .. } + | SessionSnapshotEntryData::ToolResult { .. } + ) { + ranges + .entry((segment_id.to_string(), format!("E{}", entry.entry_id))) + .or_default() + .push([position, position]); + position += 1; + } + } + Ok((entries, bytes.len() as u64)) + })?; + // Intersect with the strict public projection, never turn knowledge of a + // hidden/reasoning history identity into a public evidence reference. + let public = index + .segments + .iter() + .flat_map(|seg| { + seg.entries + .iter() + .map(|entry| (seg.segment_id.clone(), entry.entry_ref.clone())) + }) + .collect::>(); + ranges.retain(|key, _| public.contains(key)); + Ok(FsSessionEvidenceIndex { + index, + ranges, + has_open_run, + }) +} + +fn project_public_index_records( + session_id: SessionId, + segment_ids: Vec, + limits: SessionPublicIndexLimits, + mut read_segment: impl FnMut( + SegmentId, + u64, + ) -> Result<(Vec, u64), SessionPublicIndexReadError>, +) -> Result { if segment_ids.len() > limits.max_segments { return Err(SessionPublicIndexReadError::ResourceLimit); } @@ -224,9 +390,7 @@ pub fn read_session_public_index( .max_bytes .checked_sub(scanned_bytes) .ok_or(SessionPublicIndexReadError::ResourceLimit)?; - let (entries, bytes) = store - .read_all_read_only_bounded(session_id, segment_id, remaining) - .map_err(map_store_error)?; + let (entries, bytes) = read_segment(segment_id, remaining)?; scanned_bytes = scanned_bytes .checked_add(bytes) .ok_or(SessionPublicIndexReadError::ResourceLimit)?; diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 3f7f8e53c..6c305793a 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -1654,7 +1654,8 @@ fn legacy_entry_id( fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance { match origin { - LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput, + LoggedSessionHistoryOrigin::HumanInput { .. } + | LoggedSessionHistoryOrigin::LocalHumanInput => SessionEntryProvenance::HumanInput, LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput, LoggedSessionHistoryOrigin::FlowInstruction { .. } => { SessionEntryProvenance::FlowInstruction diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml index a8b078496..bf05d8375 100644 --- a/crates/standalone/Cargo.toml +++ b/crates/standalone/Cargo.toml @@ -9,6 +9,12 @@ license.workspace = true agen.workspace = true client.workspace = true fs4.workspace = true +feature-storage.workspace = true +subjektiv.workspace = true +memory.workspace = true +server-api.workspace = true +libc.workspace = true +async-trait.workspace = true manifest.workspace = true job.workspace = true rusqlite.workspace = true diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index 17c77ac35..09cd555e5 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -44,12 +44,15 @@ pub struct StandaloneHost { record: StandaloneWorkerRecord, lease: Option, jobs: crate::jobs::StandaloneJobs, + subject: Option, } #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum StandaloneStartupError { #[error("the standalone state store could not be opened or validated")] StateStore, + #[error("local Subject connection rejected: {0}")] + Subject(String), #[error("the standalone Worker is already active")] WorkerActive, #[error("the standalone Worker lease cannot be observed safely; recovery is rejected")] @@ -107,16 +110,53 @@ impl StandaloneHost { launch: ResolvedStandaloneLaunch, model_client: Option>, ) -> Result { + if launch.subject_id.is_some() + && (!launch.profile.manifest.feature.subjektiv.profile.enabled + || launch + .profile + .manifest + .feature + .subjektiv + .profile + .consolidation_tools + || launch + .profile + .manifest + .feature + .subjektiv + .workspace_settings + .is_some()) + { + return Err(StandaloneStartupError::Subject( + "incompatible local Subject policy".into(), + )); + } let store = StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?; let allocation = store .allocate(&launch.cwd, StaleLeasePolicy::Reject) .map_err(classify_store_startup_error)?; let worker_id = allocation.worker_id(); - let jobs = match crate::jobs::StandaloneJobs::open( - &store.jobs_path(worker_id), - launch.cwd.clone(), - ) { + let mut subject = match launch + .subject_id + .as_deref() + .map(|id| { + crate::subjektiv::StandaloneSubjects::open_existing(&launch.state_dir)? + .connect(id, worker_id) + }) + .transpose() + { + Ok(subject) => subject, + Err(error) => { + let _ = store.abandon_allocation(allocation); + return Err(StandaloneStartupError::Subject(error.to_string())); + } + }; + let jobs_path = subject + .as_ref() + .map(|s| s.jobs_path.clone()) + .unwrap_or_else(|| store.jobs_path(worker_id)); + let jobs = match crate::jobs::StandaloneJobs::open(&jobs_path, launch.cwd.clone()) { Ok(jobs) => jobs, Err(_) => { let _ = store.abandon_allocation(allocation); @@ -124,6 +164,12 @@ impl StandaloneHost { } }; + if let Some(client) = &model_client { + jobs.bind_model_client(client.clone_boxed()); + } + if let Some(subject) = &subject { + subject.host.bind_jobs(jobs.clone())?; + } // WorkerId is the stable identity. The current Worker store remains // name-keyed, so keep its derived storage key separate from the // user-facing profile name. @@ -156,9 +202,15 @@ impl StandaloneHost { }, WorkerControllerTransport::InProcess, ); + if let Some(subject) = &subject { + bootstrap = bootstrap.with_subjektiv_host(subject.host.clone()); + } if let Some(model_client) = model_client { bootstrap = bootstrap.with_model_client(model_client); } + if let Some(subject) = &mut subject { + subject.arm(); + } let started = match bootstrap.start().await { Ok(started) => started, Err(error) => { @@ -174,12 +226,13 @@ impl StandaloneHost { return Err(error); } }; - let record = match store.commit_created( + let record = match store.commit_created_connected( &allocation, manifest, storage_key, active.session_id, active.segment_id, + subject.as_ref().map(|s| s.binding.clone()), ) { Ok(record) => record, Err(_) => { @@ -195,6 +248,7 @@ impl StandaloneHost { record, allocation.into_lease(), jobs, + subject, )) } @@ -230,11 +284,50 @@ impl StandaloneHost { let lease = store .acquire_lease(worker_id, StaleLeasePolicy::Recover) .map_err(classify_store_startup_error)?; - let jobs = crate::jobs::StandaloneJobs::open( - &store.jobs_path(worker_id), - record.cwd.canonical_path.clone(), - ) - .map_err(|_| StandaloneStartupError::StateStore)?; + let mut subject = record + .subject + .as_ref() + .map(|binding| { + if !record.manifest.feature.subjektiv.profile.enabled + || record + .manifest + .feature + .subjektiv + .profile + .consolidation_tools + || record + .manifest + .feature + .subjektiv + .workspace_settings + .is_some() + { + return Err(crate::subjektiv::SubjectError::InvalidScope( + "persisted Subject policy changed".into(), + )); + } + let catalog = crate::subjektiv::StandaloneSubjects::open_existing(store.root())?; + if catalog.scope_id() != binding.scope_id { + return Err(crate::subjektiv::SubjectError::InvalidScope( + "persisted local storage scope changed".into(), + )); + } + catalog.connect(&binding.subject_id, worker_id) + }) + .transpose() + .map_err(|e| StandaloneStartupError::Subject(e.to_string()))?; + let jobs_path = subject + .as_ref() + .map(|s| s.jobs_path.clone()) + .unwrap_or_else(|| store.jobs_path(worker_id)); + let jobs = crate::jobs::StandaloneJobs::open(&jobs_path, record.cwd.canonical_path.clone()) + .map_err(|_| StandaloneStartupError::StateStore)?; + if let Some(client) = &model_client { + jobs.bind_model_client(client.clone_boxed()); + } + if let Some(subject) = &subject { + subject.host.bind_jobs(jobs.clone())?; + } let (backing_store, worker_store) = backing_store(&store, worker_id)?; let storage_key = record.storage_key.clone(); let mut manifest = record.manifest.clone(); @@ -259,6 +352,9 @@ impl StandaloneHost { }, WorkerControllerTransport::InProcess, ); + if let Some(subject) = &subject { + bootstrap = bootstrap.with_subjektiv_host(subject.host.clone()); + } if let Some(model_client) = model_client { bootstrap = bootstrap.with_model_client(model_client); } @@ -266,6 +362,9 @@ impl StandaloneHost { .prepare_restored(&storage_key) .await .map_err(classify_startup_error)?; + if let Some(subject) = &mut subject { + subject.arm(); + } let started = prepared.start().await.map_err(classify_startup_error)?; let active = match active_pointer(&worker_store, &storage_key) { Ok(active) => active, @@ -290,6 +389,7 @@ impl StandaloneHost { record, lease, jobs, + subject, )) } @@ -300,7 +400,11 @@ impl StandaloneHost { record: StandaloneWorkerRecord, lease: StandaloneWorkerLease, jobs: crate::jobs::StandaloneJobs, + subject: Option, ) -> Self { + if let Some(connection) = &subject { + connection.host.recover_jobs(); + } Self { handle: started.handle, shutdown: Some(started.shutdown), @@ -310,6 +414,7 @@ impl StandaloneHost { record, lease: Some(lease), jobs, + subject, } } @@ -388,6 +493,12 @@ impl StandaloneHost { self.retain_lease(); return Err(StandaloneShutdownError::StateStore); } + if let Some(subject) = &mut self.subject { + if subject.close().is_err() { + self.retain_lease(); + return Err(StandaloneShutdownError::StateStore); + } + } if let Some(lease) = self.lease.take() { lease .release() @@ -397,6 +508,9 @@ impl StandaloneHost { } fn retain_lease(&mut self) { + if let Some(subject) = &mut self.subject { + subject.retain(); + } if let Some(lease) = self.lease.take() { lease.retain(); } @@ -413,6 +527,51 @@ impl Drop for StandaloneHost { } } +// The in-process channel is owned by the local operator's Host, not an +// arbitrary socket or Backend account. decode_method discards wire provenance; +// only this boundary can stamp accepted local human input. +fn authorize_local_input(method: protocol::Method) -> protocol::Method { + use protocol::{AuthenticatedInputSource, Method}; + match method { + Method::Submit { + submission_request_id, + input, + } + | Method::SubmitTracked { + submission_request_id, + input, + .. + } => Method::SubmitTracked { + submission_request_id, + input, + source: AuthenticatedInputSource::LocalOperator, + }, + Method::SubmitIfIdle { + submission_request_id, + input, + .. + } => Method::SubmitIfIdle { + submission_request_id, + input, + source: AuthenticatedInputSource::LocalOperator, + }, + Method::Notify { + notification_request_id, + message, + } + | Method::NotifyTracked { + notification_request_id, + message, + .. + } => Method::NotifyTracked { + notification_request_id, + message, + source: AuthenticatedInputSource::LocalOperator, + }, + method => method, + } +} + async fn run_protocol_session( handle: worker::WorkerHandle, streams: WorkerProtocolSessionStreams, @@ -438,6 +597,7 @@ async fn run_protocol_session( let Ok(method) = decode_method(&message) else { return; }; + let method = authorize_local_input(method); if let Some(event) = dispatch_worker_protocol_method(&handle, method).await && !send_protocol_event(&peer, event).await { @@ -578,6 +738,9 @@ fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError WorkerBootstrapError::Worker(WorkerError::Provider(_)) => { StandaloneStartupError::ModelProvider } + WorkerBootstrapError::Worker(WorkerError::SubjektivSessionAttribution { + message, .. + }) => StandaloneStartupError::Subject(message), WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration, WorkerBootstrapError::Controller { source, .. } if source.kind() == std::io::ErrorKind::Other => @@ -587,3 +750,49 @@ fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller, } } + +#[cfg(test)] +mod local_input_tests { + use super::*; + use protocol::{AuthenticatedInputSource, Method}; + + #[test] + fn local_transport_stamps_operator_without_backend_account() { + let source = AuthenticatedInputSource::Account { + account_id: "forged-account".into(), + }; + for method in [ + Method::submit_text("plain", "hello"), + Method::SubmitTracked { + submission_request_id: "tracked".into(), + input: vec![], + source: source.clone(), + }, + Method::SubmitIfIdle { + submission_request_id: "idle".into(), + input: vec![], + source: source.clone(), + }, + Method::Notify { + notification_request_id: "plain-notify".into(), + message: "hello".into(), + }, + Method::NotifyTracked { + notification_request_id: "tracked-notify".into(), + message: "hello".into(), + source, + }, + ] { + let decoded: Method = + serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap(); + match authorize_local_input(decoded) { + Method::SubmitTracked { source, .. } + | Method::SubmitIfIdle { source, .. } + | Method::NotifyTracked { source, .. } => { + assert_eq!(source, AuthenticatedInputSource::LocalOperator) + } + other => panic!("unexpected local input {other:?}"), + } + } + } +} diff --git a/crates/standalone/src/job_store.rs b/crates/standalone/src/job_store.rs index a28979f00..c0dd3da43 100644 --- a/crates/standalone/src/job_store.rs +++ b/crates/standalone/src/job_store.rs @@ -6,7 +6,6 @@ //! authority. The service owns the connection mutex and live-resource cleanup. use std::path::Path; -use std::time::Duration; use job::{JobRequest, JobResultSubmission}; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; @@ -14,7 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -const SCHEMA_VERSION: i64 = 1; +const SCHEMA_VERSION: i64 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -57,6 +56,8 @@ impl JobAttemptState { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct JobSnapshot { pub request: JobRequest, + /// Durable Host-issued domain delegation, not model input or Profile identity. + pub domain_grant: Option, pub state: JobState, pub attempt: JobAttempt, pub result: Option, @@ -112,6 +113,8 @@ pub struct JobAttempt { pub enum JobStoreError { #[error("Job database: {0}")] Sqlite(#[from] rusqlite::Error), + #[error(transparent)] + Storage(#[from] feature_storage::FeatureStorageError), #[error("Job JSON: {0}")] Json(#[from] serde_json::Error), #[error(transparent)] @@ -151,16 +154,30 @@ impl JobStore { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path)?; + } let mut conn = Connection::open(path)?; - conn.busy_timeout(Duration::from_secs(5))?; - conn.pragma_update(None, "foreign_keys", "ON")?; - conn.pragma_update(None, "journal_mode", "WAL")?; - conn.pragma_update(None, "synchronous", "FULL")?; + feature_storage::configure_connection(&conn)?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let version: i64 = tx.pragma_query_value(None, "user_version", |row| row.get(0))?; match version { 0 => { tx.execute_batch(SCHEMA)?; + tx.execute_batch(GRANT_SCHEMA)?; + tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; + } + 1 => { + tx.execute_batch(GRANT_SCHEMA)?; tx.pragma_update(None, "user_version", SCHEMA_VERSION)?; } SCHEMA_VERSION => {} @@ -186,7 +203,17 @@ impl JobStore { } pub(crate) fn reserve(&mut self, request: JobRequest) -> Result { + self.reserve_granted(request, None) + } + pub(crate) fn reserve_granted( + &mut self, + request: JobRequest, + grant: Option, + ) -> Result { request.validate()?; + if let Some(value) = &grant { + job::result_digest(value, job::ABSOLUTE_MAX_RESULT_BYTES)?; + } let fingerprint = request.fingerprint()?; let request_json = serde_json::to_string(&request)?; let tx = self @@ -198,7 +225,10 @@ impl JobStore { [&request.job_id], |row| row.get(0), )?; - if stored != fingerprint || snapshot.request != request { + if stored != fingerprint + || snapshot.request != request + || snapshot.domain_grant != grant + { return Err(JobStoreError::IntentConflict(request.job_id)); } tx.commit()?; @@ -219,6 +249,12 @@ impl JobStore { ], )?; insert_attempt(&tx, &request, 1)?; + if let Some(grant) = grant { + tx.execute( + "INSERT INTO job_domain_grants(job_id, grant_json) VALUES (?1, ?2)", + params![request.job_id, serde_json::to_string(&grant)?], + )?; + } let snapshot = require_snapshot(&tx, &request.job_id)?; tx.commit()?; Ok(snapshot) @@ -255,6 +291,16 @@ impl JobStore { require_snapshot(&self.conn, job_id) } + pub(crate) fn unacknowledged(&self) -> Result, JobStoreError> { + let mut statement = self.conn.prepare("SELECT job_id FROM job_intents WHERE state = 'completed' AND acknowledged = 0 ORDER BY rowid")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + ids.into_iter() + .map(|id| require_snapshot(&self.conn, &id)) + .collect() + } + /// Only unstarted current attempts are eligible for service startup/resume. pub(crate) fn pending(&self) -> Result, JobStoreError> { let mut statement = self.conn.prepare( @@ -570,6 +616,15 @@ fn read_snapshot(conn: &Connection, job_id: &str) -> Result, }; Ok(JobSnapshot { request: serde_json::from_str(&request)?, + domain_grant: conn + .query_row( + "SELECT grant_json FROM job_domain_grants WHERE job_id = ?1", + [job_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|s| serde_json::from_str(&s)) + .transpose()?, state, acknowledged, attempt: JobAttempt { @@ -585,6 +640,17 @@ fn read_snapshot(conn: &Connection, job_id: &str) -> Result, .transpose() } +const GRANT_SCHEMA: &str = " +CREATE TABLE job_domain_grants ( + job_id TEXT PRIMARY KEY NOT NULL REFERENCES job_intents(job_id), + grant_json TEXT NOT NULL +); +CREATE TRIGGER job_grant_immutable BEFORE UPDATE ON job_domain_grants +BEGIN SELECT RAISE(ABORT, 'immutable Job domain grant'); END; +CREATE TRIGGER job_grant_retained BEFORE DELETE ON job_domain_grants +BEGIN SELECT RAISE(ABORT, 'retained Job domain grant'); END; +"; + const SCHEMA: &str = " CREATE TABLE job_intents ( job_id TEXT PRIMARY KEY NOT NULL, diff --git a/crates/standalone/src/jobs.rs b/crates/standalone/src/jobs.rs index 95a198efe..0753e1937 100644 --- a/crates/standalone/src/jobs.rs +++ b/crates/standalone/src/jobs.rs @@ -27,6 +27,17 @@ pub enum JobServiceError { Cleanup(String), } +pub(crate) trait JobAttemptFence: Send + Sync { + fn ensure_live(&self) -> Result<(), String>; +} +pub(crate) trait JobDomainProvider: Send + Sync { + fn grant( + &self, + snapshot: &JobSnapshot, + fence: Arc, + ) -> Result; +} + struct Execution { cancel: watch::Sender, complete: watch::Receiver>>, @@ -48,6 +59,8 @@ pub struct StandaloneJobs { executions: Arc>, cwd: PathBuf, capacity: u16, + domain: Arc>>>, + model_client: Arc>>>, } impl StandaloneJobs { @@ -60,9 +73,43 @@ impl StandaloneJobs { })), cwd, capacity: job::DEFAULT_MAX_CONCURRENT_JOBS, + domain: Arc::default(), + model_client: Arc::default(), }) } + pub(crate) fn bind_model_client(&self, client: Box) { + *self + .model_client + .lock() + .expect("Job model transport poisoned") = Some(client); + } + pub(crate) fn bind_domain( + &self, + provider: Arc, + ) -> Result<(), JobServiceError> { + let mut domain = self.domain.lock().expect("Job domain poisoned"); + if domain.is_some() { + return Err(JobServiceError::Configuration( + "Job domain already bound".into(), + )); + } + *domain = Some(provider); + Ok(()) + } + + pub(crate) fn request_granted( + &self, + request: JobRequest, + grant: serde_json::Value, + ) -> Result { + let executions = self.executions.lock().expect("Job executions poisoned"); + if executions.closed { + return Err(JobServiceError::Closed); + } + self.with_store(|store| store.reserve_granted(request, Some(grant))) + } + fn with_store( &self, operation: impl FnOnce(&mut JobStore) -> Result, @@ -91,6 +138,10 @@ impl StandaloneJobs { Ok(self.with_store(|store| store.pending())?) } + pub fn unacknowledged_results(&self) -> Result, JobServiceError> { + self.with_store(|store| store.unacknowledged()) + } + /// Explicit new attempt, bounded by immutable intent limits. Unknown /// outcomes cannot be retried without domain reconciliation/new intent. pub fn retry(&self, job_id: &str) -> Result { @@ -154,9 +205,40 @@ impl StandaloneJobs { job_id: &str, client: Option>, ) -> Result<(), JobServiceError> { + let client = client.or_else(|| { + self.model_client + .lock() + .expect("Job model transport poisoned") + .as_ref() + .map(|c| c.clone_boxed()) + }); let snapshot = self.get(job_id)?; - let prepared = worker::job::prepare_job(&snapshot.request, &self.cwd, client) - .map_err(|error| JobServiceError::Configuration(error.to_string()))?; + let timeout = Duration::from_secs(u64::from(snapshot.request.limits.timeout_seconds)); + let deadline = tokio::time::Instant::now() + timeout; + let fence: Arc = Arc::new(BoundAttemptFence { + store: self.store.clone(), + job_id: job_id.into(), + attempt_id: snapshot.attempt.attempt_id.clone(), + input_revision: snapshot.request.input_revision.clone(), + deadline, + }); + let grant = match ( + &snapshot.domain_grant, + self.domain.lock().expect("Job domain poisoned").as_ref(), + ) { + (Some(_), Some(provider)) => provider + .grant(&snapshot, fence) + .map_err(JobServiceError::Configuration)?, + (Some(_), None) => { + return Err(JobServiceError::Configuration( + "persisted Job requires an unavailable explicit domain capability".into(), + )); + } + (None, _) => worker::job::JobFeatureGrant::default(), + }; + let prepared = + worker::job::prepare_job_with_grant(&snapshot.request, &self.cwd, client, grant) + .map_err(|error| JobServiceError::Configuration(error.to_string()))?; let mut executions = self.executions.lock().expect("Job executions poisoned"); if executions.closed { return Err(JobServiceError::Closed); @@ -209,13 +291,12 @@ impl StandaloneJobs { let store = self.store.clone(); let id = job_id.to_string(); let attempt_id = snapshot.attempt.attempt_id.clone(); - let timeout = Duration::from_secs(u64::from(snapshot.request.limits.timeout_seconds)); let sink = Arc::new(BoundResultSink { store: store.clone(), job_id: id.clone(), attempt_id: attempt_id.clone(), input_revision: snapshot.request.input_revision.clone(), - deadline: tokio::time::Instant::now() + timeout, + deadline, }); let (cancel, mut cancellation) = watch::channel(false); let (complete_tx, complete) = watch::channel(None); @@ -341,6 +422,11 @@ impl StandaloneJobs { // Close SQLite before the Host releases its lease. Cloned handles may // outlive the Host but cannot mutate or retain the live connection. self.store.lock().expect("Job store poisoned").take(); + self.domain.lock().expect("Job domain poisoned").take(); + self.model_client + .lock() + .expect("Job model transport poisoned") + .take(); error.map_or(Ok(()), Err) } } @@ -391,3 +477,32 @@ impl worker::job::JobResultSink for BoundResultSink { .map_err(|error| error.to_string()) } } + +struct BoundAttemptFence { + store: Arc>>, + job_id: String, + attempt_id: String, + input_revision: String, + deadline: tokio::time::Instant, +} +impl JobAttemptFence for BoundAttemptFence { + fn ensure_live(&self) -> Result<(), String> { + if tokio::time::Instant::now() >= self.deadline { + return Err("Job domain deadline expired".into()); + } + let slot = self.store.lock().map_err(|_| "Job store poisoned")?; + let snapshot = slot + .as_ref() + .ok_or("Job Host closed")? + .get(&self.job_id) + .map_err(|e| e.to_string())?; + if snapshot.state != JobState::Pending + || snapshot.attempt.state != JobAttemptState::Dispatched + || snapshot.attempt.attempt_id != self.attempt_id + || snapshot.request.input_revision != self.input_revision + { + return Err("Job domain grant is not bound to the current live attempt".into()); + } + Ok(()) + } +} diff --git a/crates/standalone/src/jobs/tests.rs b/crates/standalone/src/jobs/tests.rs index 825cd360c..213afa6cf 100644 --- a/crates/standalone/src/jobs/tests.rs +++ b/crates/standalone/src/jobs/tests.rs @@ -1,4 +1,10 @@ use super::*; +use agen::llm_client::{ClientError, Request, event::Event}; +use async_trait::async_trait; +use futures::Stream; +use serde_json::{Value, json}; +use std::collections::VecDeque; +use std::pin::Pin; use worker::job::JobResultSink; fn request(id: &str) -> JobRequest { @@ -32,6 +38,345 @@ fn result(id: &str, attempt: &str) -> JobResultSubmission { } } +#[derive(Clone, Default)] +struct DomainTestClient { + scripts: Arc>>>, + requests: Arc>>, + entered: Arc, + stall: bool, + failure_release: Option>, +} +impl DomainTestClient { + fn success() -> Self { + Self { + scripts: Arc::new(Mutex::new(VecDeque::from([ + vec![ + Event::tool_use_start(0, "result-1", "SubmitJobResult"), + Event::tool_input_delta(0, r#"{"result":{"valid":true}}"#), + Event::tool_use_stop(0), + ], + vec![ + Event::text_block_start(0), + Event::text_delta(0, "done"), + Event::text_block_stop(0, None), + ], + ]))), + ..Default::default() + } + } +} +#[async_trait] +impl LlmClient for DomainTestClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> { + self.requests.lock().unwrap().push(request); + self.entered.notify_one(); + if let Some(release) = &self.failure_release { + release.notified().await; + return Err(ClientError::Config( + "known scripted provider failure".into(), + )); + } + if self.stall { + return Ok(Box::pin(futures::stream::pending())); + } + let events = self + .scripts + .lock() + .unwrap() + .pop_front() + .expect("unexpected model turn"); + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } +} + +type CapturedDomainAttempt = (JobSnapshot, Arc); +#[derive(Default)] +struct RecordingDomainProvider(Mutex>); +fn domain_grant(id: &str) -> Value { + json!({"consumer":"test-domain", "job_id":id, "input_revision":"r1"}) +} +fn domain_request(id: &str) -> JobRequest { + let mut intent = request(id); + intent.profile = "builtin:standalone-subjektiv-consolidation".into(); + intent +} +impl JobDomainProvider for RecordingDomainProvider { + fn grant( + &self, + snapshot: &JobSnapshot, + fence: Arc, + ) -> Result { + if snapshot.domain_grant != Some(domain_grant(&snapshot.request.job_id)) { + return Err("foreign or mismatched immutable domain grant".into()); + } + self.0.lock().unwrap().push((snapshot.clone(), fence)); + Ok(worker::job::JobFeatureGrant { + satisfied_requirements: vec!["feature.subjektiv"], + ..Default::default() + }) + } +} +async fn await_model(client: &DomainTestClient) { + tokio::time::timeout(Duration::from_secs(2), client.entered.notified()) + .await + .unwrap(); +} +async fn await_job(jobs: &StandaloneJobs, id: &str) -> JobSnapshot { + tokio::time::timeout(Duration::from_secs(3), jobs.wait(id)) + .await + .unwrap() + .unwrap() +} + +#[tokio::test] +async fn pending_domain_grants_survive_service_reopen_and_remain_immutable() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("jobs.sqlite3"); + let jobs = StandaloneJobs::open(&path, temp.path().to_path_buf()).unwrap(); + let intent = domain_request("granted"); + let grant = domain_grant("granted"); + let reserved = jobs.request_granted(intent.clone(), grant.clone()).unwrap(); + let root = jobs.request(request("root")).unwrap(); + assert_eq!(reserved.domain_grant, Some(grant.clone())); + assert!(root.domain_grant.is_none()); + assert_eq!( + jobs.request_granted(intent.clone(), grant.clone()).unwrap(), + reserved + ); + assert!(matches!( + jobs.request(intent.clone()), + Err(JobServiceError::Store(JobStoreError::IntentConflict(_))) + )); + assert!(matches!( + jobs.request_granted(intent.clone(), domain_grant("foreign")), + Err(JobServiceError::Store(JobStoreError::IntentConflict(_))) + )); + assert!(matches!( + jobs.request_granted(request("root"), grant.clone()), + Err(JobServiceError::Store(JobStoreError::IntentConflict(_))) + )); + assert_eq!(jobs.pending().unwrap().len(), 2); + jobs.shutdown().await.unwrap(); + + let reopened = StandaloneJobs::open(&path, temp.path().to_path_buf()).unwrap(); + let pending = reopened.pending().unwrap(); + assert_eq!(pending.len(), 2); + assert!(pending.contains(&reserved)); + assert!(pending.contains(&root)); + assert_eq!(reopened.get("granted").unwrap(), reserved); + assert_eq!( + reopened + .request_granted(intent.clone(), grant.clone()) + .unwrap(), + reserved + ); + assert!(matches!( + reopened.request_granted(intent, json!({"different":true})), + Err(JobServiceError::Store(JobStoreError::IntentConflict(_))) + )); + let provider = Arc::new(RecordingDomainProvider::default()); + reopened.bind_domain(provider.clone()).unwrap(); + reopened + .start_with_model_client("granted", DomainTestClient::success()) + .await + .unwrap(); + let completed = await_job(&reopened, "granted").await; + assert_eq!(completed.state, JobState::Completed); + assert_eq!(completed.domain_grant, Some(grant)); + assert_eq!(completed.result, Some(json!({"valid":true}))); + let captured = provider.0.lock().unwrap()[0].0.clone(); + assert_eq!( + captured, reserved, + "provider receives the persisted immutable intent and grant" + ); + reopened.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn persisted_grant_requires_provider_and_missing_grant_cannot_be_derived_from_profile() { + let temp = tempfile::tempdir().unwrap(); + let jobs = + StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); + let granted = jobs + .request_granted(domain_request("granted"), domain_grant("granted")) + .unwrap(); + let client = DomainTestClient::success(); + let error = jobs + .start_with_model_client("granted", client.clone()) + .await + .unwrap_err(); + assert!(matches!(error, JobServiceError::Configuration(_))); + assert!( + error + .to_string() + .contains("unavailable explicit domain capability"), + "{error}" + ); + assert_eq!(jobs.get("granted").unwrap(), granted); + assert!(client.requests.lock().unwrap().is_empty()); + + let provider = Arc::new(RecordingDomainProvider::default()); + jobs.bind_domain(provider.clone()).unwrap(); + assert!(matches!( + jobs.bind_domain(provider.clone()), + Err(JobServiceError::Configuration(_)) + )); + let ungranted = jobs.request(domain_request("ungranted")).unwrap(); + let error = jobs + .start_with_model_client("ungranted", client.clone()) + .await + .unwrap_err(); + assert!(matches!(error, JobServiceError::Configuration(_))); + assert!(error.to_string().contains("feature.subjektiv"), "{error}"); + assert_eq!(jobs.get("ungranted").unwrap(), ungranted); + assert!( + provider.0.lock().unwrap().is_empty(), + "bound provider is not a profile-derived grant" + ); + assert!(client.requests.lock().unwrap().is_empty()); + jobs.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn foreign_job_revision_and_consumer_grants_are_refused_before_model_dispatch() { + let temp = tempfile::tempdir().unwrap(); + let jobs = + StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); + let provider = Arc::new(RecordingDomainProvider::default()); + jobs.bind_domain(provider.clone()).unwrap(); + for (id, grant) in [ + ("foreign-job", domain_grant("another-job")), + ( + "foreign-revision", + json!({"consumer":"test-domain","job_id":"foreign-revision","input_revision":"r0"}), + ), + ( + "foreign-consumer", + json!({"consumer":"another-domain","job_id":"foreign-consumer","input_revision":"r1"}), + ), + ] { + let reserved = jobs.request_granted(domain_request(id), grant).unwrap(); + let client = DomainTestClient::success(); + let error = jobs + .start_with_model_client(id, client.clone()) + .await + .unwrap_err(); + assert!(matches!(error, JobServiceError::Configuration(_))); + assert!( + error.to_string().contains("foreign or mismatched"), + "{error}" + ); + assert_eq!(jobs.get(id).unwrap(), reserved); + assert!(client.requests.lock().unwrap().is_empty()); + } + assert!(provider.0.lock().unwrap().is_empty()); + jobs.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn domain_fence_is_live_only_for_current_dispatched_attempt_and_revoked_on_cancel() { + let temp = tempfile::tempdir().unwrap(); + let jobs = + StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); + let provider = Arc::new(RecordingDomainProvider::default()); + jobs.bind_domain(provider.clone()).unwrap(); + let reserved = jobs + .request_granted(domain_request("fenced"), domain_grant("fenced")) + .unwrap(); + let failure_release = Arc::new(tokio::sync::Notify::new()); + let first = DomainTestClient { + failure_release: Some(failure_release.clone()), + ..Default::default() + }; + jobs.start_with_model_client("fenced", first.clone()) + .await + .unwrap(); + await_model(&first).await; + let old_fence = provider.0.lock().unwrap()[0].1.clone(); + old_fence.ensure_live().unwrap(); + failure_release.notify_one(); + let failed = await_job(&jobs, "fenced").await; + assert_eq!(failed.state, JobState::Failed); + assert!(failed.result.is_none()); + assert!(old_fence.ensure_live().is_err()); + // Only known failure permits a retry; cancellation remains terminal. + let retried = jobs.retry("fenced").unwrap(); + assert_ne!(retried.attempt.attempt_id, reserved.attempt.attempt_id); + assert_eq!(retried.domain_grant, reserved.domain_grant); + assert!(old_fence.ensure_live().is_err()); + let second = DomainTestClient { + stall: true, + ..Default::default() + }; + jobs.start_with_model_client("fenced", second.clone()) + .await + .unwrap(); + await_model(&second).await; + let current_fence = provider.0.lock().unwrap()[1].1.clone(); + current_fence.ensure_live().unwrap(); + assert!( + old_fence.ensure_live().is_err(), + "a new attempt must not revive a stale capability" + ); + let cancelled = jobs.cancel("fenced").unwrap(); + assert_eq!(cancelled.state, JobState::Cancelled); + assert!( + current_fence.ensure_live().is_err(), + "durable cancellation revokes current domain operations immediately" + ); + assert!(old_fence.ensure_live().is_err()); + let cleaned = await_job(&jobs, "fenced").await; + assert_eq!(cleaned.state, JobState::Cancelled); + assert!(cleaned.result.is_none()); + assert!( + jobs.retry("fenced").is_err(), + "cancelled intent is not a known retryable failure" + ); + jobs.shutdown().await.unwrap(); + assert!(old_fence.ensure_live().is_err()); + assert!(current_fence.ensure_live().is_err()); +} + +#[tokio::test] +async fn bound_domain_does_not_change_root_generic_result_only_execution() { + let temp = tempfile::tempdir().unwrap(); + let jobs = + StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); + let provider = Arc::new(RecordingDomainProvider::default()); + jobs.bind_domain(provider.clone()).unwrap(); + let root = jobs.request(request("root")).unwrap(); + assert!(root.domain_grant.is_none()); + let client = DomainTestClient::success(); + jobs.start_with_model_client("root", client.clone()) + .await + .unwrap(); + let completed = await_job(&jobs, "root").await; + assert_eq!(completed.state, JobState::Completed); + assert_eq!(completed.result, Some(json!({"valid":true}))); + assert!(completed.domain_grant.is_none()); + assert!(provider.0.lock().unwrap().is_empty()); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + for sent in requests.iter() { + assert_eq!( + sent.tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + ["SubmitJobResult"] + ); + assert_eq!(sent.config.max_tokens, Some(worker::job::MAX_JOB_TOKENS)); + } + drop(requests); + jobs.shutdown().await.unwrap(); +} + #[tokio::test] async fn host_result_capability_cannot_cross_job_attempt_revision_or_deadline() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/standalone/src/launch.rs b/crates/standalone/src/launch.rs index e65b69bf4..2220a7c30 100644 --- a/crates/standalone/src/launch.rs +++ b/crates/standalone/src/launch.rs @@ -15,6 +15,7 @@ pub struct StandaloneLaunchConfig { pub state_dir: PathBuf, pub profile: ProfileSelector, pub worker_name: String, + pub subject_id: Option, } pub struct ResolvedStandaloneLaunch { @@ -22,6 +23,7 @@ pub struct ResolvedStandaloneLaunch { pub state_dir: PathBuf, pub profile: ResolvedProfile, pub prompt_catalog: PromptCatalogSource, + pub subject_id: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] @@ -32,6 +34,10 @@ pub enum StandaloneLaunchError { PathProfileUnsupported, #[error("the standalone profile could not be resolved")] ProfileResolutionFailed, + #[error( + "local Subject selection requires an enabled, non-consolidator subjektiv Profile and canonical Subject id" + )] + InvalidSubjectPolicy, } impl StandaloneLaunchConfig { @@ -46,9 +52,15 @@ impl StandaloneLaunchConfig { state_dir: state_dir.into(), profile, worker_name: worker_name.into(), + subject_id: None, } } + pub fn with_subject(mut self, subject_id: String) -> Self { + self.subject_id = Some(subject_id); + self + } + /// Resolve only built-in/XDG profile authority and bind standalone scope /// to the canonical process cwd. Repository-local profile discovery is /// deliberately not part of this path. @@ -74,12 +86,32 @@ impl StandaloneLaunchConfig { .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; validate_profile_execution_target(&profile.manifest, ProfileExecutionTarget::Standalone) .map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?; + if let Some(id) = &self.subject_id { + if !crate::subjektiv::valid_subject_id(id) + || !profile.manifest.feature.subjektiv.profile.enabled + || profile + .manifest + .feature + .subjektiv + .profile + .consolidation_tools + || profile + .manifest + .feature + .subjektiv + .workspace_settings + .is_some() + { + return Err(StandaloneLaunchError::InvalidSubjectPolicy); + } + } Ok(ResolvedStandaloneLaunch { cwd, state_dir: self.state_dir, profile, prompt_catalog: PromptCatalogSource::builtins_only(), + subject_id: self.subject_id, }) } } diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 43da6c0e7..2d8c6fa5d 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -9,6 +9,8 @@ mod job_store; pub mod jobs; pub mod launch; pub mod store; +pub mod subjektiv; +mod subjektiv_sessions; pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError}; pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError}; diff --git a/crates/standalone/src/store.rs b/crates/standalone/src/store.rs index 870da83f4..6d119a6af 100644 --- a/crates/standalone/src/store.rs +++ b/crates/standalone/src/store.rs @@ -87,6 +87,9 @@ pub struct StandaloneWorkerRecord { pub storage_key: String, pub cwd: StandaloneCwdIdentity, pub manifest: WorkerManifest, + /// Original explicit local Subject/storage binding; never inferred from policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, pub active_session_id: SessionId, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_segment_id: Option, @@ -127,6 +130,16 @@ impl StandaloneWorkerStore { Ok(Self { root }) } + /// Observational constructor: never creates a missing root or repairs state. + pub fn open_existing(root: impl Into) -> Result { + let root = root.into(); + let metadata = fs::symlink_metadata(&root).map_err(StandaloneStoreError::Io)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(StandaloneStoreError::NotDirectory); + } + Ok(Self { root }) + } + #[must_use] pub fn root(&self) -> &Path { &self.root @@ -158,6 +171,25 @@ impl StandaloneWorkerStore { storage_key: String, active_session_id: SessionId, active_segment_id: Option, + ) -> Result { + self.commit_created_connected( + allocation, + manifest, + storage_key, + active_session_id, + active_segment_id, + None, + ) + } + + pub(crate) fn commit_created_connected( + &self, + allocation: &StandaloneWorkerAllocation, + manifest: WorkerManifest, + storage_key: String, + active_session_id: SessionId, + active_segment_id: Option, + subject: Option, ) -> Result { let now = now_unix_ms()?; let record = StandaloneWorkerRecord { @@ -168,6 +200,7 @@ impl StandaloneWorkerStore { storage_key, cwd: allocation.cwd.clone(), manifest, + subject, active_session_id, active_segment_id, status: StandaloneWorkerStatus::Active, @@ -793,6 +826,7 @@ permission = "write" inode: None, }, manifest, + subject: None, active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(), active_segment_id: None, status: StandaloneWorkerStatus::Stopped, diff --git a/crates/standalone/src/subjektiv.rs b/crates/standalone/src/subjektiv.rs new file mode 100644 index 000000000..132ad588a --- /dev/null +++ b/crates/standalone/src/subjektiv.rs @@ -0,0 +1,1115 @@ +//! Operator-managed local Subject storage and explicit Host capabilities. +//! Profile policy, identifiers and repository files are never execution grants. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; + +use feature_storage::FeatureStorage; +use fs4::fs_std::FileExt; +use serde::{Deserialize, Serialize}; +use server_api::*; +use subjektiv::{SubjectRecord, SubjectRole, SubjectSessionAttribution, SubjektivStore}; +use worker::subjektiv::{ + SubjektivHost, SubjektivHostContext, SubjektivHostError, SubjektivHostSettings, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StandaloneSubjectBinding { + pub scope_id: String, + pub subject_id: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SubjectError { + #[error("local Subject storage: {0}")] + Io(#[from] std::io::Error), + #[error(transparent)] + Domain(#[from] subjektiv::SubjektivError), + #[error(transparent)] + Storage(#[from] feature_storage::FeatureStorageError), + #[error("local Subject storage metadata: {0}")] + Json(#[from] serde_json::Error), + #[error("local Subject storage permission or identity is invalid: {0}")] + InvalidScope(String), + #[error("Subject `{0}` is already active; its kernel lease cannot be replaced")] + Active(String), +} + +/// Explicit operator create/list surface, not a model-accessible database handle. +pub struct StandaloneSubjects { + state_root: PathBuf, + root: PathBuf, + scope_id: String, + memory_language: String, + manager: FeatureStorage, + store: SubjektivStore, +} + +impl StandaloneSubjects { + pub fn open(state_dir: impl AsRef) -> Result { + Self::open_inner(state_dir.as_ref(), true) + } + + pub(crate) fn open_existing(state_dir: &Path) -> Result { + Self::open_inner(state_dir, false) + } + + fn open_inner(state_dir: &Path, create: bool) -> Result { + if create { + private_dir(state_dir, true)?; + } + validate_directory(state_dir, true)?; + let state_root = fs::canonicalize(state_dir)?; + let root = state_root.join("subjektiv"); + if create { + private_dir(&root, true)?; + } + validate_directory(&root, true)?; + let init_lock = private_file(&root.join("scope.lock"), create)?; + init_lock.lock_exclusive()?; + let scope_path = root.join("scope.json"); + let scope_id: String = match fs::symlink_metadata(&scope_path) { + Ok(_) => { + validate_file(&scope_path)?; + serde_json::from_slice(&fs::read(&scope_path)?)? + } + Err(e) if create && e.kind() == std::io::ErrorKind::NotFound => { + let id = uuid::Uuid::now_v7().to_string(); + let mut file = private_file(&scope_path, true)?; + file.write_all(&serde_json::to_vec(&id)?)?; + file.sync_all()?; + File::open(&root)?.sync_all()?; + id + } + Err(e) => return Err(e.into()), + }; + if uuid::Uuid::parse_str(&scope_id).is_err() { + return Err(SubjectError::InvalidScope( + "storage scope id is invalid".into(), + )); + } + let language_path = root.join("language.json"); + let memory_language: String = match fs::symlink_metadata(&language_path) { + Ok(_) => { + validate_file(&language_path)?; + serde_json::from_slice(&fs::read(&language_path)?)? + } + Err(error) if create && error.kind() == std::io::ErrorKind::NotFound => { + let language = "English".to_owned(); + let mut file = private_file(&language_path, true)?; + file.write_all(&serde_json::to_vec(&language)?)?; + file.sync_all()?; + File::open(&root)?.sync_all()?; + language + } + Err(error) => return Err(error.into()), + }; + if !manifest::is_normalized_workspace_memory_language(&memory_language) { + return Err(SubjectError::InvalidScope( + "invalid local Memory language setting".into(), + )); + } + let db_root = root.join("features"); + if create { + private_dir(&db_root, true)?; + } + validate_directory(&db_root, true)?; + // Validate every existing node before SQLite opens it. Private parents + // prevent another user from replacing a checked node during the open. + validate_tree(&db_root)?; + let manager = FeatureStorage::new(&db_root); + let scope = manager.scope(&scope_id)?; + let registration = SubjektivStore::register(&scope)?; + let store = SubjektivStore::open(&scope, ®istration)?; + protect_tree(&db_root)?; + Ok(Self { + state_root, + root, + scope_id, + memory_language, + manager, + store, + }) + } + + pub fn create( + &self, + role: &str, + behavior_md: Option<&str>, + ) -> Result { + Ok(self.store.create_subject_with_behavior( + SubjectRole::new(role)?, + behavior_md.unwrap_or_default().to_owned(), + )?) + } + + pub fn list(&self) -> Result, SubjectError> { + let mut items = Vec::new(); + let mut after: Option<(String, String)> = None; + loop { + let page = self.store.list_subjects_after( + 100, + after + .as_ref() + .map(|(created, id)| (created.as_str(), id.as_str())), + )?; + if page.has_more { + after = page + .items + .last() + .map(|item| (item.created_at.clone(), item.id.clone())); + } + items.extend(page.items); + if !page.has_more { + return Ok(items); + } + } + } + + pub(crate) fn connect( + self, + subject_id: &str, + worker_id: protocol::WorkerId, + ) -> Result { + let subject = self.store.subject(subject_id)?.ok_or_else(|| { + SubjectError::InvalidScope( + "selected Subject does not exist in this storage scope".into(), + ) + })?; + if subject.state != subjektiv::SubjectState::Active { + return Err(SubjectError::InvalidScope( + "selected Subject is retired".into(), + )); + } + // Canonical common-issued UUID is required before using it as a path. + if !valid_subject_id(subject_id) || subject.id != subject_id { + return Err(SubjectError::InvalidScope("invalid Subject id".into())); + } + let leases = self.root.join("leases"); + private_dir(&leases, true)?; + let lease = private_file(&leases.join(format!("{subject_id}.lock")), true)?; + if !lease.try_lock_exclusive()? { + return Err(SubjectError::Active(subject_id.into())); + } + let jobs_root = self.root.join("jobs"); + private_dir(&jobs_root, true)?; + let jobs_dir = jobs_root.join(subject_id); + private_dir(&jobs_dir, true)?; + validate_tree(&jobs_dir)?; + let binding = StandaloneSubjectBinding { + scope_id: self.scope_id.clone(), + subject_id: subject_id.to_string(), + }; + let host = Arc::new(LocalSubjectHost { + language: self.memory_language.clone(), + catalog: self, + binding: binding.clone(), + worker_id, + worker_key: format!("standalone-{worker_id}"), + live: AtomicBool::new(true), + jobs: Mutex::new(None), + }); + Ok(SubjectConnection { + host, + binding, + lease: Some(lease), + jobs_path: jobs_dir.join("jobs.sqlite3"), + armed: false, + }) + } +} + +pub(crate) struct SubjectConnection { + pub host: Arc, + pub binding: StandaloneSubjectBinding, + lease: Option, + pub jobs_path: PathBuf, + armed: bool, +} +impl SubjectConnection { + pub fn arm(&mut self) { + self.armed = true; + } + pub fn retain(&mut self) { + if let Some(file) = self.lease.take() { + std::mem::forget(file); + } + } + pub fn close(&mut self) -> Result<(), SubjectError> { + self.host.jobs.lock().expect("Subject Jobs poisoned").take(); + self.host.live.store(false, Ordering::Release); + self.host.catalog.manager.shutdown()?; + if let Some(file) = &self.lease { + file.unlock()?; + } + self.lease.take(); + self.armed = false; + Ok(()) + } +} + +impl Drop for SubjectConnection { + fn drop(&mut self) { + if self.armed { + self.retain(); + } + } +} + +pub(crate) struct LocalSubjectHost { + catalog: StandaloneSubjects, + binding: StandaloneSubjectBinding, + worker_id: protocol::WorkerId, + worker_key: String, + language: String, + live: AtomicBool, + jobs: Mutex>, +} + +impl LocalSubjectHost { + fn ensure_live(&self) -> Result<(), SubjektivHostError> { + if !self.live.load(Ordering::Acquire) { + return Err(SubjektivHostError::Unavailable( + "local Subject Host is closed".into(), + )); + } + Ok(()) + } + fn body(&self, context: &SubjektivHostContext) -> Result<(), SubjektivHostError> { + self.ensure_live()?; + if context.worker_id != self.worker_key && context.worker_id != self.worker_id.to_string() { + return Err(SubjektivHostError::InvalidArgument( + "Worker is not bound to this Subject capability".into(), + )); + } + if context + .session_id + .parse::() + .is_err() + { + return Err(SubjektivHostError::InvalidArgument( + "invalid committed Session identity".into(), + )); + } + Ok(()) + } + fn attribution( + &self, + context: &SubjektivHostContext, + create: bool, + ) -> Result { + self.body(context)?; + let store = &self.catalog.store; + if let Some(record) = store + .session_attribution(&context.session_id) + .map_err(domain_error)? + { + if record.subject_id != self.binding.subject_id + || record.runtime_id != self.binding.scope_id + || record.worker_id != self.worker_id.to_string() + { + return Err(SubjektivHostError::InvalidArgument( + "Session does not belong to this local Subject/Worker scope".into(), + )); + } + return Ok(record); + } + if !create { + return Err(SubjektivHostError::InvalidArgument( + "Session attribution is unavailable".into(), + )); + } + // The capability is bound before controller startup. The Worker supplies + // the Session it owns; models cannot call this Host API or select one. + let record = SubjectSessionAttribution::new( + &self.binding.subject_id, + &self.binding.scope_id, + self.worker_id.to_string(), + &context.session_id, + ) + .map_err(domain_error)?; + store + .record_session_attribution(record) + .map_err(domain_error) + } +} + +fn domain_error(error: subjektiv::SubjektivError) -> SubjektivHostError { + operation_error(subjektiv::api::subjektiv_store_error(error)) +} +fn operation_error(error: subjektiv::api::OperationError) -> SubjektivHostError { + use subjektiv::api::OperationError::*; + match error { + Conflict(message) => SubjektivHostError::Conflict { + code: if message.contains("stale_cursor") { + "stale_cursor" + } else if message.contains("candidate_decision_conflict") { + "candidate_decision_conflict" + } else { + "revision_conflict" + } + .into(), + message, + }, + Store(message) => SubjektivHostError::Unavailable(message), + other => SubjektivHostError::InvalidArgument(other.to_string()), + } +} + +fn private_dir(path: &Path, strict: bool) -> Result<(), SubjectError> { + if !path.exists() { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + match builder.create(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + } + #[cfg(not(unix))] + fs::create_dir_all(path)?; + } + validate_directory(path, strict) +} +fn validate_directory(path: &Path, strict: bool) -> Result<(), SubjectError> { + reject_symlink_components(path)?; + let meta = fs::symlink_metadata(path)?; + if !meta.is_dir() { + return Err(SubjectError::InvalidScope(format!( + "{} is not a directory", + path.display() + ))); + } + validate_owner_mode(path, &meta, if strict { 0o077 } else { 0o022 }) +} +fn validate_file(path: &Path) -> Result<(), SubjectError> { + let meta = fs::symlink_metadata(path)?; + if !meta.is_file() { + return Err(SubjectError::InvalidScope(format!( + "{} is not a regular private file", + path.display() + ))); + } + validate_owner_mode(path, &meta, 0o077) +} +fn validate_owner_mode(path: &Path, meta: &fs::Metadata, mask: u32) -> Result<(), SubjectError> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if meta.uid() != unsafe { libc::geteuid() } || meta.mode() & mask != 0 { + return Err(SubjectError::InvalidScope(format!( + "{} must be owned by the operator and private/non-writable by others", + path.display() + ))); + } + } + #[cfg(not(unix))] + { + let _ = (path, meta, mask); + return Err(SubjectError::InvalidScope( + "private local Subject storage is not supported on this platform".into(), + )); + } + Ok(()) +} +fn reject_symlink_components(path: &Path) -> Result<(), SubjectError> { + let mut part = PathBuf::new(); + for component in path.components() { + part.push(component); + if fs::symlink_metadata(&part)?.file_type().is_symlink() { + return Err(SubjectError::InvalidScope( + "symlinked Subject storage is not authority".into(), + )); + } + } + Ok(()) +} +fn private_file(path: &Path, create: bool) -> Result { + reject_symlink_components( + path.parent() + .ok_or_else(|| SubjectError::InvalidScope("missing parent".into()))?, + )?; + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create(create) + .truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path)?; + validate_file(path)?; + Ok(file) +} +fn validate_tree(root: &Path) -> Result<(), SubjectError> { + for entry in fs::read_dir(root)? { + let entry = entry?; + let meta = fs::symlink_metadata(entry.path())?; + if meta.is_dir() { + validate_directory(&entry.path(), true)?; + validate_tree(&entry.path())?; + } else { + validate_file(&entry.path())?; + } + } + Ok(()) +} +fn protect_tree(root: &Path) -> Result<(), SubjectError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for entry in fs::read_dir(root)? { + let entry = entry?; + let meta = fs::symlink_metadata(entry.path())?; + if meta.file_type().is_symlink() { + return Err(SubjectError::InvalidScope("symlinked database".into())); + } + fs::set_permissions( + entry.path(), + fs::Permissions::from_mode(if meta.is_dir() { 0o700 } else { 0o600 }), + )?; + if meta.is_dir() { + protect_tree(&entry.path())?; + } + } + } + Ok(()) +} + +impl StandaloneSubjects { + pub(crate) fn scope_id(&self) -> &str { + &self.scope_id + } +} +impl LocalSubjectHost { + pub(crate) fn bind_jobs( + self: &Arc, + jobs: crate::jobs::StandaloneJobs, + ) -> Result<(), crate::StandaloneStartupError> { + jobs.bind_domain(Arc::new(LocalDomainProvider(Arc::downgrade(self)))) + .map_err(|e| crate::StandaloneStartupError::Subject(e.to_string()))?; + *self.jobs.lock().expect("Subject Job connection poisoned") = Some(jobs); + Ok(()) + } + fn jobs(&self) -> Result { + self.ensure_live()?; + self.jobs + .lock() + .expect("Subject Job connection poisoned") + .clone() + .ok_or_else(|| SubjektivHostError::Unavailable("Subject Job Host unavailable".into())) + } + pub(crate) fn recover_jobs(&self) { + let Ok(jobs) = self.jobs() else { + return; + }; + if let Ok(results) = jobs.unacknowledged_results() { + for result in results { + if result + .domain_grant + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .is_some_and(|g| g.binding == self.binding) + { + // Acceptance already verified the immutable attempt/receipts. + // Delivery acknowledgement never replays the successful model. + let _ = jobs.acknowledge(&result.request.job_id); + } + } + } + if let Ok(pending) = jobs.pending() { + for job in pending { + if job + .domain_grant + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .is_some_and(|g| g.binding == self.binding) + { + schedule_job(jobs.clone(), job.request.job_id); + } + } + } + } + fn validate_evidence( + &self, + session: &str, + evidence: &mut [memory::extract::StagingEvidence], + sources: &mut [memory::schema::SourceEvidenceRef], + ) -> Result<(), SubjektivHostError> { + if evidence.is_empty() + || sources.is_empty() + || evidence.len() > subjektiv::MAX_STAGING_ANCHORS + || sources.len() > subjektiv::MAX_STAGING_ANCHORS + { + return Err(SubjektivHostError::InvalidArgument( + "staging requires bounded committed public evidence".into(), + )); + } + let index = crate::subjektiv_sessions::authorized_index( + &self.catalog.state_root, + &self.binding.scope_id, + &self.binding.subject_id, + &self.catalog.store, + session, + )?; + let metadata = session_store::public_index::read_fs_session_evidence_index( + &self + .catalog + .state_root + .join(self.worker_id.to_string()) + .join("sessions"), + session + .parse() + .map_err(|_| SubjektivHostError::InvalidArgument("invalid Session id".into()))?, + session_store::SessionPublicIndexLimits { + max_bytes: 8 * 1024 * 1024, + max_entries: 20_000, + max_segments: 64, + }, + ) + .map_err(|e| { + SubjektivHostError::Unavailable(format!("committed evidence metadata unavailable: {e}")) + })?; + if metadata.index.generation != index.generation { + return Err(SubjektivHostError::Unavailable("committed Session changed while resolving evidence; retry from the committed capture".into())); + } + for anchor in evidence { + let source = sources + .iter_mut() + .find(|source| source.evidence_id.as_deref() == Some(&anchor.id)) + .ok_or_else(|| { + SubjektivHostError::InvalidArgument("evidence pointer is missing".into()) + })?; + if source.session_id.as_deref().is_some_and(|id| id != session) { + return Err(SubjektivHostError::InvalidArgument( + "foreign Session evidence".into(), + )); + } + let entry = index.segments.iter().filter(|seg|source.segment_id.as_deref() == Some(seg.segment_id.as_str())) + .flat_map(|seg|&seg.entries).find(|entry|entry.entry_ref == anchor.id) + .ok_or_else(|| if metadata.has_open_run { + SubjektivHostError::Conflict { code:"pending_commit".into(),message:"explicit evidence remains inside an open logical Run; retry after the append-only Run commit".into() } + } else { SubjektivHostError::InvalidArgument("evidence is not an accessible committed public entry".into()) })?; + if source.entry_range != anchor.entry_range + || anchor.entry_range.is_none_or(|[a, b]| a > b) + { + return Err(SubjektivHostError::InvalidArgument( + "invalid committed evidence range".into(), + )); + } + if !metadata + .ranges + .get(&(entry.segment_id.clone(), anchor.id.clone())) + .is_some_and(|ranges| { + anchor + .entry_range + .is_some_and(|range| ranges.contains(&range)) + }) + { + return Err(SubjektivHostError::InvalidArgument("evidence numeric range does not match either common committed producer projection".into())); + } + let kind = match entry.provenance { + protocol::SessionEntryProvenance::HumanInput => { + memory::schema::EvidenceOriginKind::HumanInput + } + protocol::SessionEntryProvenance::WorkerInput => { + memory::schema::EvidenceOriginKind::WorkerInput + } + protocol::SessionEntryProvenance::FlowInstruction => { + memory::schema::EvidenceOriginKind::FlowInstruction + } + protocol::SessionEntryProvenance::BackendInstruction => { + memory::schema::EvidenceOriginKind::BackendInstruction + } + protocol::SessionEntryProvenance::ModelOutput => { + memory::schema::EvidenceOriginKind::ModelOutput + } + protocol::SessionEntryProvenance::ToolOutput => { + memory::schema::EvidenceOriginKind::ToolOutput + } + protocol::SessionEntryProvenance::DerivedSummary => { + memory::schema::EvidenceOriginKind::DerivedSummary + } + protocol::SessionEntryProvenance::LegacyUnknown => { + memory::schema::EvidenceOriginKind::LegacyUnknown + } + }; + let origin = memory::schema::EvidenceOrigin { + kind, + account_id: None, + workspace_id: None, + // Local storage scope is not a Runtime/control-plane identity. + runtime_id: None, + worker_id: Some(self.worker_id.to_string()), + flow_selector: None, + flow_definition_id: None, + flow_definition_revision: None, + }; + anchor.origin = Some(origin.clone()); + source.origin = Some(origin); + anchor.excerpt = Some(entry.compact_text.clone()); + anchor.summary = Some(entry.compact_text.clone()); + source.session_id = Some(session.into()); + source.summary = Some(entry.compact_text.clone()); + } + if sources + .iter() + .any(|source| source.session_id.as_deref() != Some(session)) + { + return Err(SubjektivHostError::InvalidArgument( + "unresolved evidence source".into(), + )); + } + Ok(()) + } +} +impl SubjektivHost for LocalSubjectHost { + fn settings(&self) -> SubjektivHostSettings { + SubjektivHostSettings { + language: self.language.clone(), + } + } + fn memory( + &self, + context: &SubjektivHostContext, + mut operation: SubjektivMemoryBackendOperation, + ) -> Result { + self.body(context)?; + let attribution = + if let SubjektivMemoryBackendOperation::StageExplicit(input) = &mut operation { + if input.session_id != context.session_id { + return Err(SubjektivHostError::InvalidArgument( + "explicit staging must use the connected committed Session".into(), + )); + } + let attribution = self.attribution(context, false)?; + self.validate_evidence( + &context.session_id, + &mut input.evidence, + &mut input.source_refs, + )?; + Some(attribution) + } else { + None + }; + let authority = subjektiv::api::HostOperationContext::validated_body( + &self.catalog.store, + &self.binding.subject_id, + attribution, + ) + .map_err(operation_error)?; + subjektiv::api::execute(&self.catalog.store, &authority, operation).map_err(operation_error) + } + fn session( + &self, + context: &SubjektivHostContext, + operation: SubjektivSessionBackendOperation, + ) -> Result { + self.body(context)?; + crate::subjektiv_sessions::execute( + &self.catalog.state_root, + &self.binding.scope_id, + &self.binding.subject_id, + &self.catalog.store, + operation, + ) + } + fn stage_candidate( + &self, + context: &SubjektivHostContext, + mut operation: memory::backend::MemoryStageCandidateOperation, + ) -> Result { + let attribution = self.attribution(context, false)?; + self.validate_evidence( + &context.session_id, + &mut operation.evidence, + &mut operation.source_refs, + )?; + if operation.candidate.kind == memory::extract::CandidateKind::Preference + && operation.evidence.iter().any(|e| { + e.origin + .as_ref() + .is_none_or(|o| o.kind != memory::schema::EvidenceOriginKind::HumanInput) + }) + { + return Err(SubjektivHostError::InvalidArgument( + "preference requires HumanInput evidence".into(), + )); + } + let record = memory::extract::StagingRecord::from_candidate( + uuid::Uuid::now_v7().to_string(), + operation.extract_run_id, + operation.source, + operation.candidate, + operation.evidence, + operation.source_refs, + ); + let (staged, _) = self + .catalog + .store + .stage_candidate_with_attribution( + subjektiv::SubjectStagingRecord::attach(&self.binding.subject_id, record), + attribution, + ) + .map_err(domain_error)?; + Ok(SubjektivStageCandidateResponse { + staging_id: staged.id, + }) + } + fn record_session( + &self, + context: &SubjektivHostContext, + create_if_missing: bool, + ) -> Result { + let attribution = self.attribution(context, create_if_missing)?; + Ok(SubjektivRecordSessionResponse { + subject_id: attribution.subject_id, + session_id: attribution.session_id, + }) + } + fn request_consolidation( + &self, + context: &SubjektivHostContext, + operation: memory::backend::MemoryConsolidateStagingOperation, + ) -> Result { + self.body(context)?; + let jobs = self.jobs()?; + if let Some(pending) = jobs + .pending() + .map_err(|e| SubjektivHostError::Unavailable(e.to_string()))? + .into_iter() + .find(|job| { + job.domain_grant + .as_ref() + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .is_some_and(|g| g.binding == self.binding) + }) + { + schedule_job(jobs, pending.request.job_id); + let (count, bytes) = self + .catalog + .store + .pending_staging_backlog(&self.binding.subject_id) + .map_err(domain_error)?; + return Ok(memory::backend::MemoryConsolidationOutput { status:"resumed_pending".into(),summary:"Resumed the existing immutable reserved Subject Job; unknown attempts are never replayed".into(),candidate_count:count,total_bytes:bytes }); + } + let (count, bytes) = self + .catalog + .store + .pending_staging_backlog(&self.binding.subject_id) + .map_err(domain_error)?; + let surface = self + .catalog + .store + .resident_surface(&self.binding.subject_id) + .map_err(domain_error)? + .availability; + if !subjektiv::job::consolidation_required(count, bytes, operation.force, surface) { + return Ok(memory::backend::MemoryConsolidationOutput { + status: "skipped_below_threshold".into(), + summary: "Subject backlog/surface is below the common consolidation threshold" + .into(), + candidate_count: count, + total_bytes: bytes, + }); + } + let ids = subjektiv::job::bounded_candidate_batch( + self.catalog + .store + .pending_staging_candidates(&self.binding.subject_id, 100) + .map_err(domain_error)? + .into_iter() + .map(|c| c.id), + job::DEFAULT_MAX_RESULT_BYTES, + ) + .map_err(operation_error)?; + let subject = self + .catalog + .store + .subject(&self.binding.subject_id) + .map_err(domain_error)? + .ok_or_else(|| SubjektivHostError::Unavailable("Subject missing".into()))?; + let grant = LocalConsolidationGrant { + binding: self.binding.clone(), + candidate_ids: ids, + }; + let request=job::JobRequest { + job_id:format!("subjektiv-consolidation:{}",uuid::Uuid::now_v7()),purpose:"subjektiv_consolidation".into(), + input_revision:subject.store_revision.to_string(),input_ref:format!("subjektiv://{}/consolidation",self.binding.subject_id), + input:serde_json::json!({"subject_id":self.binding.subject_id,"candidate_ids":grant.candidate_ids}), + instruction:"Resolve ONLY candidate_ids with MemoryStagingList/Read, recall and MemoryApplyCandidate. Already resolved IDs are durable receipts; do not repeat them. Leave new candidates for a later Job. When the entire immutable batch has a disposition, call SubmitJobResult with result: {subject_id, candidate_ids}. The Host awaits clean-context surface generation and verifies actual receipts/outcome before accepting success; never supply surface claims.".into(), + profile:"builtin:standalone-subjektiv-consolidation".into(),serialization_key:Some(format!("subjektiv:{}",self.binding.subject_id)), + limits:job::JobLimits { timeout_seconds:600,..Default::default() }, + }; + let jobs = self.jobs()?; + let snapshot = jobs + .request_granted( + request, + serde_json::to_value(grant) + .map_err(|e| SubjektivHostError::Unavailable(e.to_string()))?, + ) + .map_err(|e| SubjektivHostError::Unavailable(e.to_string()))?; + schedule_job(jobs, snapshot.request.job_id); + Ok(memory::backend::MemoryConsolidationOutput { status:"started".into(),summary:"Requested a durable generic standalone consolidation Job; processing stops on Host shutdown".into(),candidate_count:count,total_bytes:bytes }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalConsolidationGrant { + binding: StandaloneSubjectBinding, + candidate_ids: Vec, +} +struct LocalDomainProvider(std::sync::Weak); +impl crate::jobs::JobDomainProvider for LocalDomainProvider { + fn grant( + &self, + snapshot: &crate::jobs::JobSnapshot, + fence: Arc, + ) -> Result { + let host = self.0.upgrade().ok_or("Subject Host unavailable")?; + host.ensure_live().map_err(|e| e.to_string())?; + let grant: LocalConsolidationGrant = serde_json::from_value( + snapshot + .domain_grant + .clone() + .ok_or("missing explicit Subject delegation")?, + ) + .map_err(|e| e.to_string())?; + if grant.binding != host.binding + || snapshot.request.purpose != "subjektiv_consolidation" + || snapshot.request.input + != serde_json::json!({"subject_id":host.binding.subject_id,"candidate_ids":grant.candidate_ids}) + { + return Err("persisted Job grant/input does not match local Subject capability".into()); + } + let adapter = Arc::new(LocalConsolidationHost { + host, + grant, + job_id: snapshot.request.job_id.clone(), + attempt_id: snapshot.attempt.attempt_id.clone(), + fence, + }); + let connection = worker::subjektiv::SubjektivHostConnection::new( + adapter.clone(), + SubjektivHostContext { + worker_id: format!("job:{}", snapshot.attempt.attempt_id), + session_id: format!("job:{}", snapshot.attempt.attempt_id), + }, + ) + .map_err(|e| e.to_string())?; + let feature = worker::feature::builtin::memory::SubjektivConsolidationToolsFeature::new( + connection.clone(), + ) + .map_err(|e| e.to_string())?; + Ok(worker::job::JobFeatureGrant { + features: worker::feature::FeatureRegistryBuilder::new().with_module(feature), + satisfied_requirements: vec!["feature.subjektiv"], + finalizer: Some(Arc::new(LocalConsolidationFinalizer { + adapter, + connection, + })), + }) + } +} +struct LocalConsolidationHost { + host: Arc, + grant: LocalConsolidationGrant, + job_id: String, + attempt_id: String, + fence: Arc, +} +impl LocalConsolidationHost { + fn live(&self) -> Result<(), SubjektivHostError> { + self.host.ensure_live()?; + self.fence + .ensure_live() + .map_err(SubjektivHostError::Unavailable) + } + fn denied(&self) -> Result { + Err(SubjektivHostError::InvalidArgument( + "consolidation Job has no Subject body/extraction authority".into(), + )) + } +} +impl SubjektivHost for LocalConsolidationHost { + fn settings(&self) -> SubjektivHostSettings { + self.host.settings() + } + fn consolidation_granted(&self) -> bool { + true + } + fn memory( + &self, + _context: &SubjektivHostContext, + operation: SubjektivMemoryBackendOperation, + ) -> Result { + self.live()?; + let authority = subjektiv::api::HostOperationContext::validated_consolidation( + &self.host.catalog.store, + &self.grant.binding.subject_id, + Some(self.grant.candidate_ids.clone()), + Some(subjektiv::api::JobAttemptBinding { + job_id: self.job_id.clone(), + attempt_id: self.attempt_id.clone(), + }), + ) + .map_err(operation_error)?; + subjektiv::api::execute(&self.host.catalog.store, &authority, operation) + .map_err(operation_error) + } + fn session( + &self, + _context: &SubjektivHostContext, + _operation: SubjektivSessionBackendOperation, + ) -> Result { + self.denied() + } + fn stage_candidate( + &self, + _: &SubjektivHostContext, + _: memory::backend::MemoryStageCandidateOperation, + ) -> Result { + self.denied() + } + fn record_session( + &self, + _: &SubjektivHostContext, + _: bool, + ) -> Result { + self.denied() + } + fn request_consolidation( + &self, + _: &SubjektivHostContext, + _: memory::backend::MemoryConsolidateStagingOperation, + ) -> Result { + self.denied() + } +} +struct LocalConsolidationFinalizer { + adapter: Arc, + connection: worker::subjektiv::SubjektivHostConnection, +} +#[async_trait::async_trait] +impl worker::job::JobResultFinalizer for LocalConsolidationFinalizer { + fn validate_profile(&self, manifest: &manifest::WorkerManifest) -> Result<(), String> { + let config = &manifest.feature.subjektiv; + if !config.profile.enabled + || !config.profile.consolidation_tools + || config.profile.extraction.enabled + || config.workspace_settings.is_some() + { + return Err("local consolidation requires enabled decision policy without extraction or Backend settings".into()); + } + Ok(()) + } + async fn finalize( + &self, + result: serde_json::Value, + manifest: manifest::WorkerManifest, + client: Box, + cancellation: worker::feature::background::BackgroundTaskCancellation, + ) -> Result { + use agen::tool::ToolError; + self.adapter.live().map_err(ToolError::from)?; + if result + != serde_json::json!({"subject_id":self.adapter.grant.binding.subject_id,"candidate_ids":self.adapter.grant.candidate_ids}) + { + return Err(ToolError::InvalidArgument("result must claim exactly the bound Subject/batch, without model surface/disposition assertions".into())); + } + for id in &self.adapter.grant.candidate_ids { + if self + .adapter + .host + .catalog + .store + .staging_resolution(&self.adapter.grant.binding.subject_id, id) + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))? + .is_none() + { + return Err(ToolError::InvalidArgument(format!( + "candidate {id} is unresolved" + ))); + } + } + let prompts = worker::PromptCatalog::builtins_only() + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let feature = worker::subjektiv::SubjektivSurfaceLifecycleFeature::for_host( + self.connection.clone(), + manifest, + client, + Arc::new(prompts.into()), + worker::WorkerWorkspaceContext::no_workspace(), + ) + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let surface = feature + .complete_for_job(cancellation, None) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + self.adapter.live().map_err(ToolError::from)?; + let mut value = result; + value + .as_object_mut() + .expect("checked object") + .insert("surface".into(), surface); + subjektiv::job::validate_result( + &self.adapter.host.catalog.store, + &self.adapter.grant.binding.subject_id, + &self.adapter.grant.candidate_ids, + &self.adapter.job_id, + &self.adapter.attempt_id, + &value, + ) + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + subjektiv::job::with_dispositions( + &self.adapter.host.catalog.store, + &self.adapter.grant.binding.subject_id, + &self.adapter.grant.candidate_ids, + &value, + ) + .map_err(|e| ToolError::ExecutionFailed(e.to_string())) + } +} + +fn schedule_job(jobs: crate::jobs::StandaloneJobs, id: String) { + // Scheduling/consumer acknowledgement has no Internal Worker ownership. + // The generic service fences and retains every execution until cleanup. + tokio::spawn(async move { + if let Err(error) = jobs.start(&id).await { + eprintln!("standalone Subject Job {id} remains pending: {error}"); + return; + } + match jobs.wait(&id).await { + Ok(result) if result.state == crate::jobs::JobState::Completed => { + let _ = jobs.acknowledge(&id); + } + Ok(_) => {} + Err(error) => eprintln!( + "standalone Subject Job {id} cleanup/acknowledgement is unconfirmed: {error}" + ), + } + }); +} + +#[cfg(test)] +mod tests; + +pub(crate) fn valid_subject_id(value: &str) -> bool { + value + .strip_prefix("subject-") + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_some_and(|id| format!("subject-{id}") == value) +} diff --git a/crates/standalone/src/subjektiv/tests.rs b/crates/standalone/src/subjektiv/tests.rs new file mode 100644 index 000000000..e04a894b3 --- /dev/null +++ b/crates/standalone/src/subjektiv/tests.rs @@ -0,0 +1,130 @@ +use super::*; + +#[test] +fn catalog_reopen_preserves_random_scope_subject_and_behavior() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&root).unwrap(); + let subject = catalog + .create( + "continuing-owner", + Some("Keep user behavior separate from Memory."), + ) + .unwrap(); + let scope = catalog.scope_id().to_owned(); + assert!(uuid::Uuid::parse_str(&scope).is_ok()); + assert!(uuid::Uuid::parse_str(subject.id.strip_prefix("subject-").unwrap()).is_ok()); + assert_ne!(scope, subject.id); + drop(catalog); + let reopened = StandaloneSubjects::open_existing(&root).unwrap(); + assert_eq!(reopened.scope_id(), scope); + assert_eq!(reopened.list().unwrap(), vec![subject]); +} + +#[test] +fn implicit_open_does_not_create_subject_storage() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("missing"); + assert!(StandaloneSubjects::open_existing(&root).is_err()); + assert!(!root.exists()); + assert!(StandaloneSubjects::open_existing(temp.path()).is_err()); + assert!(!temp.path().join("subjektiv").exists()); +} + +#[test] +fn subject_kernel_lease_is_not_worker_or_profile_scoped() { + let temp = tempfile::tempdir().unwrap(); + let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap(); + let a = catalog.create("a", None).unwrap(); + let b = catalog.create("b", None).unwrap(); + let mut first = catalog + .connect(&a.id, protocol::WorkerId::now_v7()) + .unwrap(); + assert!(matches!( + StandaloneSubjects::open_existing(&temp.path().join("state")) + .unwrap() + .connect(&a.id, protocol::WorkerId::now_v7()), + Err(SubjectError::Active(_)) + )); + let mut second = StandaloneSubjects::open_existing(&temp.path().join("state")) + .unwrap() + .connect(&b.id, protocol::WorkerId::now_v7()) + .unwrap(); + first.close().unwrap(); + let mut replacement = StandaloneSubjects::open_existing(&temp.path().join("state")) + .unwrap() + .connect(&a.id, protocol::WorkerId::now_v7()) + .unwrap(); + replacement.close().unwrap(); + second.close().unwrap(); +} + +#[test] +fn closed_capabilities_and_foreign_worker_context_are_rejected() { + let temp = tempfile::tempdir().unwrap(); + let catalog = StandaloneSubjects::open(temp.path().join("state")).unwrap(); + let subject = catalog.create("owner", None).unwrap(); + let id = protocol::WorkerId::now_v7(); + let mut connection = catalog.connect(&subject.id, id).unwrap(); + let port = connection.host.clone(); + let foreign = SubjektivHostContext { + worker_id: protocol::WorkerId::now_v7().to_string(), + session_id: session_store::SessionId::now_v7().to_string(), + }; + let read = SubjektivMemoryBackendOperation::ResidentContext( + memory::backend::MemoryResidentSummaryOperation {}, + ); + assert!(port.memory(&foreign, read.clone()).is_err()); + let owner = SubjektivHostContext { + worker_id: format!("standalone-{id}"), + session_id: session_store::SessionId::now_v7().to_string(), + }; + assert!(port.memory(&owner, read.clone()).is_ok()); + assert!( + port.record_session(&owner, false).is_err(), + "unattributed Session is not read authority" + ); + connection.close().unwrap(); + assert!(port.memory(&owner, read).is_err()); +} + +#[cfg(unix)] +#[test] +fn scope_rejects_symlinks_and_non_private_roots() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let temp = tempfile::tempdir().unwrap(); + let alias = temp.path().join("alias"); + let real = temp.path().join("real"); + fs::create_dir(&real).unwrap(); + fs::set_permissions(&real, fs::Permissions::from_mode(0o700)).unwrap(); + symlink(&real, &alias).unwrap(); + assert!(StandaloneSubjects::open(&alias).is_err()); + let public = temp.path().join("public"); + fs::create_dir(&public).unwrap(); + fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(StandaloneSubjects::open(&public).is_err()); + let catalog = StandaloneSubjects::open(&real).unwrap(); + drop(catalog); + let scope = real.join("subjektiv/scope.json"); + let original = fs::read(&scope).unwrap(); + fs::remove_file(&scope).unwrap(); + let foreign = temp.path().join("foreign.json"); + fs::write(&foreign, &original).unwrap(); + symlink(&foreign, &scope).unwrap(); + assert!(StandaloneSubjects::open_existing(&real).is_err()); + assert_eq!(fs::read(&foreign).unwrap(), original); +} + +#[test] +fn different_local_roots_do_not_authorize_shared_subject_ids() { + let temp = tempfile::tempdir().unwrap(); + let first = StandaloneSubjects::open(temp.path().join("first")).unwrap(); + let subject = first.create("owner", None).unwrap(); + let other = StandaloneSubjects::open(temp.path().join("other")).unwrap(); + assert_ne!(first.scope_id(), other.scope_id()); + assert!( + other + .connect(&subject.id, protocol::WorkerId::now_v7()) + .is_err() + ); +} diff --git a/crates/standalone/src/subjektiv_sessions.rs b/crates/standalone/src/subjektiv_sessions.rs new file mode 100644 index 000000000..0c510aa57 --- /dev/null +++ b/crates/standalone/src/subjektiv_sessions.rs @@ -0,0 +1,1696 @@ +//! Subject-scoped observation of standalone Sessions. This module owns no +//! Session body or projection: it uses the shared strict committed public index +//! reader for the FsStore layout, never a raw snapshot projector. + +use std::path::{Path, PathBuf}; + +use protocol::WorkerId; +use serde::{Deserialize, Serialize}; +use server_api::*; +use session_store::{ + SessionId, SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, + SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, + SessionPublicIndexReadError, SessionPublicIndexToolPart, +}; +use subjektiv::{SubjectSessionAttribution, SubjektivStore}; +use worker::subjektiv::SubjektivHostError; + +use crate::store::{StandaloneStoreError, StandaloneWorkerStore}; + +/// Adapter to the *shared* strict committed FsStore public projection. It must +/// be observational, enforce all supplied limits, and ignore unterminated tails, +/// system records, reasoning, and output without a persisted commit boundary. +pub(crate) type PublicIndexReader = fn( + &Path, + SessionId, + SessionPublicIndexLimits, +) -> Result; + +const MAX_SCANNED_SESSIONS: usize = 100; +const MAX_SCAN_BYTES: u64 = 8 * 1024 * 1024; +const MAX_SCAN_ENTRIES: usize = 20_000; +const MAX_SCAN_SEGMENTS: usize = 64; +const ITEM_BUDGET: usize = 28 * 1024; +const MAX_CURSOR_BYTES: usize = 24 * 1024; +const MAX_LINES: usize = 200; +const CURSOR_PREFIX: &str = "standalone-session-v1:"; +const END_OF_TIME: &str = "9999-12-31T23:59:59.999999999Z"; + +type Failure = SubjektivSessionErrorResponse; +type LocalResult = Result; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Cursor { + scope_id: String, + subject_id: String, + operation: String, + filters: serde_json::Value, + snapshot_at: String, + after: Option<(String, String)>, + /// Search resumes this Session before advancing the attribution keyset. + current: Option, + byte_offset: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Position { + session_id: String, + worker_id: String, + generation: String, + entry_offset: usize, +} + +struct Backend<'a> { + state_root: &'a Path, + scope_id: &'a str, + subject_id: &'a str, + store: &'a SubjektivStore, + reader: PublicIndexReader, +} + +struct Authorized { + attribution: SubjectSessionAttribution, + sessions_path: PathBuf, +} + +/// All scope arguments are the parent's trusted local binding, not values taken +/// from tool input. The projection is exclusively the shared FsStore reader. +pub(crate) fn execute( + state_root: &Path, + scope_id: &str, + subject_id: &str, + store: &SubjektivStore, + operation: SubjektivSessionBackendOperation, +) -> Result { + execute_with_reader( + state_root, + scope_id, + subject_id, + store, + operation, + session_store::public_index::read_fs_session_public_index, + ) +} + +fn execute_with_reader( + state_root: &Path, + scope_id: &str, + subject_id: &str, + store: &SubjektivStore, + operation: SubjektivSessionBackendOperation, + read_index: PublicIndexReader, +) -> Result { + let backend = Backend { + state_root, + scope_id, + subject_id, + store, + reader: read_index, + }; + let result = backend.check_scope().and_then(|()| match operation { + SubjektivSessionBackendOperation::List(input) => backend.list(input), + SubjektivSessionBackendOperation::Search(input) => backend.search(input), + SubjektivSessionBackendOperation::Read(input) => backend.read(input), + }); + Ok(match result { + Ok(result) => { + let response = SubjektivSessionBackendResponse::Ok { result }; + if fits(&response) { + response + } else { + error_response(failure( + SubjektivSessionDiagnosticCode::ResourceLimit, + "Session response exceeds the serialized output budget", + )) + } + } + Err(error) => error_response(error), + }) +} + +/// Evidence validation uses exactly the same attribution and Worker checks as +/// model reads. The parent can validate entry refs/ranges against this index; +/// no staging candidate is needed for a Session to be discoverable. +pub(crate) fn authorized_index( + state_root: &Path, + scope_id: &str, + subject_id: &str, + store: &SubjektivStore, + session_id: &str, +) -> Result { + authorized_index_with_reader( + state_root, + scope_id, + subject_id, + store, + session_id, + session_store::public_index::read_fs_session_public_index, + ) +} + +fn authorized_index_with_reader( + state_root: &Path, + scope_id: &str, + subject_id: &str, + store: &SubjektivStore, + session_id: &str, + read_index: PublicIndexReader, +) -> Result { + let backend = Backend { + state_root, + scope_id, + subject_id, + store, + reader: read_index, + }; + (|| { + backend.check_scope()?; + required_selector(session_id)?; + let attribution = backend.attribution(session_id)?; + let source = backend.authorize(attribution)?; + backend.index(&source, limits()) + })() + .map_err(|error: Failure| SubjektivHostError::Conflict { + code: serde_json::to_value(error.code) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "session_unavailable".into()), + message: error.message, + }) +} + +impl Backend<'_> { + fn check_scope(&self) -> LocalResult<()> { + if self.store.scope_id() != self.scope_id { + return Err(denied()); + } + if self + .store + .subject(self.subject_id) + .map_err(|_| subject_unavailable())? + .is_none() + { + return Err(subject_unavailable()); + } + Ok(()) + } + + fn attribution(&self, session_id: &str) -> LocalResult { + self.store + .session_attribution(session_id) + .map_err(|_| subject_unavailable())? + .filter(|a| a.subject_id == self.subject_id && a.runtime_id == self.scope_id) + .ok_or_else(denied) + } + + fn authorize(&self, attribution: SubjectSessionAttribution) -> LocalResult { + if attribution.subject_id != self.subject_id || attribution.runtime_id != self.scope_id { + return Err(denied()); + } + let id: WorkerId = attribution.worker_id.parse().map_err(|_| denied())?; + if !self.state_root.is_dir() { + return Err(failure( + SubjektivSessionDiagnosticCode::RetentionMissing, + "Attributed standalone Worker state is missing", + )); + } + let workers = StandaloneWorkerStore::open_existing(self.state_root).map_err(|_| { + failure( + SubjektivSessionDiagnosticCode::RuntimeUnavailable, + "Standalone Worker storage is inaccessible", + ) + })?; + let record = workers.load(id).map_err(|error| match error { + StandaloneStoreError::WorkerNotFound(_) => failure( + SubjektivSessionDiagnosticCode::RetentionMissing, + "Attributed standalone Worker record is missing", + ), + _ => failure( + SubjektivSessionDiagnosticCode::CorruptLog, + "Attributed standalone Worker record is unavailable or incomplete", + ), + })?; + if record.worker_id != id + || record.worker_id.to_string() != attribution.worker_id + || !record.subject.as_ref().is_some_and(|binding| { + binding.scope_id == self.scope_id && binding.subject_id == self.subject_id + }) + { + return Err(denied()); + } + Ok(Authorized { + attribution, + sessions_path: workers.sessions_dir(id), + }) + } + + fn index( + &self, + source: &Authorized, + limits: SessionPublicIndexLimits, + ) -> LocalResult { + let id: SessionId = source.attribution.session_id.parse().map_err(|_| { + failure( + SubjektivSessionDiagnosticCode::CorruptLog, + "Attributed Session identity is invalid", + ) + })?; + let index = (self.reader)(&source.sessions_path, id, limits).map_err(index_failure)?; + if index.session_id != source.attribution.session_id { + return Err(failure( + SubjektivSessionDiagnosticCode::CorruptLog, + "Committed public index does not match the attributed Session", + )); + } + if index.scanned_bytes > limits.max_bytes + || index.scanned_entries > limits.max_entries + || index.entry_count > limits.max_entries + || index.segments.len() > limits.max_segments + { + return Err(failure( + SubjektivSessionDiagnosticCode::ResourceLimit, + "Committed public index exceeds the scan budget", + )); + } + Ok(index) + } + + fn cursor( + &self, + operation: &str, + filters: serde_json::Value, + token: Option<&str>, + ) -> LocalResult { + if let Some(token) = token { + let cursor = decode_cursor(token)?; + if cursor.scope_id != self.scope_id + || cursor.subject_id != self.subject_id + || cursor.operation != operation + || cursor.filters != filters + || cursor.snapshot_at.is_empty() + || cursor.snapshot_at.len() > 128 + { + return Err(stale()); + } + Ok(cursor) + } else { + Ok(Cursor { + scope_id: self.scope_id.into(), + subject_id: self.subject_id.into(), + operation: operation.into(), + filters, + snapshot_at: END_OF_TIME.into(), + after: None, + current: None, + byte_offset: 0, + }) + } + } + + fn page( + &self, + cursor: &mut Cursor, + selector: Option<&str>, + ) -> LocalResult { + let page = self + .store + .subject_session_attribution_page( + self.subject_id, + selector, + &cursor.snapshot_at, + cursor + .after + .as_ref() + .map(|(at, id)| (at.as_str(), id.as_str())), + 1, + ) + .map_err(|_| subject_unavailable())?; + // Freeze discovery at the newest attribution on the first page, without + // needing a second clock or reading the complete Subject history. + if cursor.snapshot_at == END_OF_TIME { + if let Some(first) = page.items.first() { + cursor.snapshot_at = first.attributed_at.clone(); + } + } + Ok(page) + } + + fn list( + &self, + input: SubjektivSessionListRequest, + ) -> LocalResult { + let limit = limit(input.limit)?; + let selector = optional_selector(input.session_id.as_deref())?; + if let Some(id) = selector { + self.authorize(self.attribution(id)?)?; + } + let mut cursor = self.cursor( + "list", + serde_json::json!({"session_id": selector, "storage": input.storage}), + input.cursor.as_deref(), + )?; + if cursor.current.is_some() || cursor.byte_offset != 0 { + return Err(stale()); + } + let mut items = Vec::new(); + let mut has_more = false; + for _ in 0..MAX_SCANNED_SESSIONS { + let page = self.page(&mut cursor, selector)?; + has_more = page.has_more && selector.is_none(); + let Some(attribution) = page.items.into_iter().next() else { + break; + }; + let after = ( + attribution.attributed_at.clone(), + attribution.session_id.clone(), + ); + if attribution.runtime_id != self.scope_id { + cursor.after = Some(after); + if !has_more { + break; + } + continue; + } + // Discovery observes attribution and Worker authority only. Like + // Backend discovery, body availability remains unchecked until a + // bounded search/read; listing never scans every Session body. + let availability = self.authorize(attribution.clone()); + if availability + .as_ref() + .err() + .is_some_and(|e| e.code == SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized) + { + if selector.is_some() { + return Err(denied()); + } + cursor.after = Some(after); + if !has_more { + break; + } + continue; + } + // Standalone FsStore has no archive catalog. Archived filtering must + // not manufacture an archive or fall back to an unrelated root. + if input.storage == SubjektivSessionStorageFilter::Archived { + cursor.after = Some(after); + if !has_more { + break; + } + continue; + } + let item = SubjektivSessionListItem { + session_id: attribution.session_id, + attributed_at: attribution.attributed_at, + storage: SubjektivSessionStorage::Retained, + availability: if availability.is_ok() { + SubjektivSessionAvailability::Unchecked + } else { + SubjektivSessionAvailability::Unavailable + }, + reason: availability.err().map(|e| e.message), + }; + items.push(item); + if json_len(&items) > ITEM_BUDGET { + items.pop(); + if items.is_empty() { + return Err(resource_limit()); + } + has_more = true; + break; + } + cursor.after = Some(after); + if items.len() >= limit || !has_more { + break; + } + } + let next_cursor = has_more.then(|| encode_cursor(&cursor)).transpose()?; + Ok(SubjektivSessionBackendResult::List( + SubjektivSessionListResponse { + items, + next_cursor, + has_more, + }, + )) + } + + fn search( + &self, + input: SubjektivSessionSearchRequest, + ) -> LocalResult { + let limit = limit(input.limit)?; + let selector = optional_selector(input.session_id.as_deref())?; + let tool_name = optional_selector(input.tool_name.as_deref())?; + let query = input.query.as_deref().unwrap_or("").trim(); + if query.len() > 4096 || (query.is_empty() && selector.is_none()) { + return Err(failure( + SubjektivSessionDiagnosticCode::InvalidInput, + "Supply a bounded query, or select a Session to list its public entries", + )); + } + // Explicit foreign selectors are denied even if the query has no hits. + if let Some(id) = selector { + self.authorize(self.attribution(id)?)?; + } + let mut cursor = self.cursor( + "search", + serde_json::json!({"session_id": selector, "query": query, + "kind": input.kind, "tool_name": tool_name, "tool_part": input.tool_part}), + input.cursor.as_deref(), + )?; + if cursor.byte_offset != 0 { + return Err(stale()); + } + let mut items = Vec::new(); + let mut issues = Vec::new(); + let mut has_more = false; + let mut scan_limits = limits(); + 'sessions: for _ in 0..MAX_SCANNED_SESSIONS { + let page = self.page(&mut cursor, selector)?; + let more_attributions = page.has_more && selector.is_none(); + let Some(attribution) = page.items.into_iter().next() else { + if cursor.current.is_some() { + return Err(stale()); + } + break; + }; + let after = ( + attribution.attributed_at.clone(), + attribution.session_id.clone(), + ); + let source = self.authorize(attribution.clone()); + let index = source.and_then(|source| self.index(&source, scan_limits)); + let index = match index { + Ok(index) => index, + Err(error) => { + if cursor.current.is_some() { + return Err(error); + } + if error.code == SubjektivSessionDiagnosticCode::ResourceLimit + && (scan_limits.max_bytes < MAX_SCAN_BYTES + || scan_limits.max_entries < MAX_SCAN_ENTRIES + || scan_limits.max_segments < MAX_SCAN_SEGMENTS) + { + // This Session may fit a fresh call. Do not consume its + // attribution just because earlier Sessions used budget. + has_more = true; + break; + } + let exhausted_scan = matches!( + error.code, + SubjektivSessionDiagnosticCode::ResourceLimit + | SubjektivSessionDiagnosticCode::CorruptLog + | SubjektivSessionDiagnosticCode::RuntimeUnavailable + ); + if error.code != SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized { + issues.push(SubjektivSessionIssue { + session_id: attribution.session_id.clone(), + code: error.code, + message: error.message, + }); + } else if selector.is_some() { + return Err(denied()); + } + cursor.after = Some(after); + has_more = more_attributions; + if exhausted_scan || issues.len() >= 20 || !has_more { + break; + } + continue; + } + }; + scan_limits.max_bytes = scan_limits.max_bytes.saturating_sub(index.scanned_bytes); + scan_limits.max_entries = scan_limits + .max_entries + .saturating_sub(index.scanned_entries.max(index.entry_count)); + scan_limits.max_segments = scan_limits + .max_segments + .saturating_sub(index.segments.len()); + let mut offset = 0; + if let Some(position) = &cursor.current { + if position.session_id != attribution.session_id + || position.worker_id != attribution.worker_id + || position.generation != index.generation + || position.entry_offset > index.entry_count + { + return Err(stale()); + } + offset = position.entry_offset; + } + let total = index + .segments + .iter() + .map(|s| s.entries.len()) + .sum::(); + let mut entry_offset = offset; + for (segment, entry) in index + .segments + .iter() + .flat_map(|s| s.entries.iter().map(move |e| (s, e))) + .skip(offset) + { + if entry_matches(entry, query, input.kind, tool_name, input.tool_part) { + let (snippet, snippet_truncated) = snippet(&entry.full_text, query); + items.push(SubjektivSessionSearchItem { + session_id: index.session_id.clone(), + segment_id: segment.segment_id.clone(), + entry_ref: entry.entry_ref.clone(), + kind: entry_kind(entry.kind), + origin: entry.provenance.clone(), + tool_name: entry.tool_name.clone(), + tool_part: entry.tool_part.map(tool_part), + snippet, + snippet_truncated, + lineage: lineage(&segment.lineage), + }); + if json_len(&items) > ITEM_BUDGET { + items.pop(); + if items.is_empty() { + return Err(resource_limit()); + } + cursor.current = Some(Position { + session_id: attribution.session_id.clone(), + worker_id: attribution.worker_id.clone(), + generation: index.generation.clone(), + entry_offset, + }); + has_more = true; + break 'sessions; + } + } + entry_offset += 1; + if items.len() >= limit && entry_offset < total { + cursor.current = Some(Position { + session_id: attribution.session_id.clone(), + worker_id: attribution.worker_id.clone(), + generation: index.generation.clone(), + entry_offset, + }); + has_more = true; + break 'sessions; + } + } + cursor.current = None; + cursor.after = Some(after); + has_more = more_attributions; + if items.len() >= limit || !has_more { + break; + } + if scan_limits.max_bytes == 0 + || scan_limits.max_entries == 0 + || scan_limits.max_segments == 0 + { + break; + } + } + let next_cursor = has_more.then(|| encode_cursor(&cursor)).transpose()?; + let coverage = if issues.is_empty() { + SubjektivSessionCoverage::Complete + } else { + SubjektivSessionCoverage::Partial + }; + Ok(SubjektivSessionBackendResult::Search( + SubjektivSessionSearchResponse { + items, + issues, + coverage, + next_cursor, + has_more, + }, + )) + } + + fn read( + &self, + input: SubjektivSessionReadRequest, + ) -> LocalResult { + required_selector(&input.session_id)?; + required_selector(&input.segment_id)?; + required_selector(&input.entry_ref)?; + let mut cursor = self.cursor( + "read", + serde_json::json!({"session_id": input.session_id, + "segment_id": input.segment_id, "entry_ref": input.entry_ref, "mode": input.mode}), + input.cursor.as_deref(), + )?; + if cursor.after.is_some() { + return Err(stale()); + } + let attribution = self.attribution(&input.session_id)?; + let source = self.authorize(attribution)?; + let index = self.index(&source, limits())?; + if let Some(position) = &cursor.current { + if position.session_id != input.session_id + || position.worker_id != source.attribution.worker_id + || position.generation != index.generation + || position.entry_offset != 0 + { + return Err(stale()); + } + } else if input.cursor.is_some() { + return Err(stale()); + } + let segment = index + .segments + .iter() + .find(|s| s.segment_id == input.segment_id) + .ok_or_else(denied)?; + let entry = segment + .entries + .iter() + .find(|e| e.entry_ref == input.entry_ref) + .ok_or_else(denied)?; + let text = match input.mode { + SubjektivSessionReadMode::Compact => &entry.compact_text, + SubjektivSessionReadMode::Full => &entry.full_text, + }; + if cursor.byte_offset > text.len() || !text.is_char_boundary(cursor.byte_offset) { + return Err(stale()); + } + cursor.current = Some(Position { + session_id: input.session_id.clone(), + worker_id: source.attribution.worker_id, + generation: index.generation.clone(), + entry_offset: 0, + }); + let start = cursor.byte_offset; + let mut max_bytes = SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES; + loop { + let end = chunk_end(text, start, max_bytes, MAX_LINES); + let has_more = end < text.len(); + cursor.byte_offset = end; + let next_cursor = has_more.then(|| encode_cursor(&cursor)).transpose()?; + let result = SubjektivSessionBackendResult::Read(SubjektivSessionReadResponse { + session_id: input.session_id.clone(), + segment_id: input.segment_id.clone(), + entry_ref: input.entry_ref.clone(), + kind: entry_kind(entry.kind), + origin: entry.provenance.clone(), + lineage: lineage(&segment.lineage), + mode: input.mode, + content: text[start..end].into(), + truncated: has_more + || (input.mode == SubjektivSessionReadMode::Compact + && entry.compact_text_truncated), + has_more, + next_cursor, + }); + if fits(&SubjektivSessionBackendResponse::Ok { + result: result.clone(), + }) { + return Ok(result); + } + if max_bytes <= 4 { + return Err(resource_limit()); + } + max_bytes = (max_bytes / 2).max(4); + } + } +} + +fn limits() -> SessionPublicIndexLimits { + SessionPublicIndexLimits { + max_bytes: MAX_SCAN_BYTES, + max_segments: MAX_SCAN_SEGMENTS, + max_entries: MAX_SCAN_ENTRIES, + } +} + +fn failure(code: SubjektivSessionDiagnosticCode, message: &str) -> Failure { + Failure { + code, + message: message.into(), + } +} +fn denied() -> Failure { + failure( + SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session entry was not found or is not authorized", + ) +} +fn stale() -> Failure { + failure( + SubjektivSessionDiagnosticCode::StaleCursor, + "Session cursor is invalid or no longer matches its scope, filters, or committed generation", + ) +} +fn subject_unavailable() -> Failure { + failure( + SubjektivSessionDiagnosticCode::SubjectUnavailable, + "Bound Subject storage is unavailable", + ) +} +fn resource_limit() -> Failure { + failure( + SubjektivSessionDiagnosticCode::ResourceLimit, + "Session operation exceeds its bounded resource budget", + ) +} +fn error_response(error: Failure) -> SubjektivSessionBackendResponse { + SubjektivSessionBackendResponse::Error { error } +} +fn index_failure(error: SessionPublicIndexReadError) -> Failure { + let code = match error { + SessionPublicIndexReadError::MigrationRequired => { + SubjektivSessionDiagnosticCode::MigrationRequired + } + SessionPublicIndexReadError::Missing => SubjektivSessionDiagnosticCode::RetentionMissing, + SessionPublicIndexReadError::Corrupt => SubjektivSessionDiagnosticCode::CorruptLog, + SessionPublicIndexReadError::Storage => SubjektivSessionDiagnosticCode::RuntimeUnavailable, + SessionPublicIndexReadError::ResourceLimit => SubjektivSessionDiagnosticCode::ResourceLimit, + SessionPublicIndexReadError::StaleCursor => SubjektivSessionDiagnosticCode::StaleCursor, + }; + failure(code, &error.to_string()) +} +fn limit(value: Option) -> LocalResult { + let value = value.unwrap_or(20); + if (1..=100).contains(&value) { + Ok(value) + } else { + Err(failure( + SubjektivSessionDiagnosticCode::InvalidInput, + "limit must be between 1 and 100", + )) + } +} +fn required_selector(value: &str) -> LocalResult<()> { + if value.is_empty() + || value.len() > 256 + || value.trim() != value + || value.chars().any(char::is_control) + { + Err(failure( + SubjektivSessionDiagnosticCode::InvalidInput, + "Session selectors must be nonempty bounded identifiers", + )) + } else { + Ok(()) + } +} +fn optional_selector(value: Option<&str>) -> LocalResult> { + value.map(required_selector).transpose()?; + Ok(value) +} +fn json_len(value: &impl Serialize) -> usize { + serde_json::to_vec(value).map_or(usize::MAX, |v| v.len()) +} +fn fits(value: &SubjektivSessionBackendResponse) -> bool { + json_len(value) <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES +} + +// Hex is only an opaque transport encoding, not an authorization credential. +// Every decoded identity is rechecked against durable Host authority on use. +fn encode_cursor(cursor: &Cursor) -> LocalResult { + let bytes = serde_json::to_vec(cursor).map_err(|_| resource_limit())?; + if CURSOR_PREFIX.len() + bytes.len() * 2 > MAX_CURSOR_BYTES { + return Err(resource_limit()); + } + let mut token = String::with_capacity(CURSOR_PREFIX.len() + bytes.len() * 2); + token.push_str(CURSOR_PREFIX); + const HEX: &[u8] = b"0123456789abcdef"; + for byte in bytes { + token.push(HEX[(byte >> 4) as usize] as char); + token.push(HEX[(byte & 15) as usize] as char); + } + Ok(token) +} +fn decode_cursor(token: &str) -> LocalResult { + if token.len() > MAX_CURSOR_BYTES { + return Err(stale()); + } + let hex = token.strip_prefix(CURSOR_PREFIX).ok_or_else(stale)?; + if hex.len() % 2 != 0 { + return Err(stale()); + } + fn nibble(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + } + } + let bytes = hex + .as_bytes() + .chunks_exact(2) + .map(|pair| Some(nibble(pair[0])? * 16 + nibble(pair[1])?)) + .collect::>>() + .ok_or_else(stale)?; + serde_json::from_slice(&bytes).map_err(|_| stale()) +} +fn entry_kind(kind: SessionPublicIndexEntryKind) -> SubjektivSessionEntryKind { + match kind { + SessionPublicIndexEntryKind::User => SubjektivSessionEntryKind::User, + SessionPublicIndexEntryKind::Assistant => SubjektivSessionEntryKind::Assistant, + SessionPublicIndexEntryKind::Tool => SubjektivSessionEntryKind::Tool, + } +} +fn tool_part(part: SessionPublicIndexToolPart) -> SubjektivSessionToolPart { + match part { + SessionPublicIndexToolPart::Input => SubjektivSessionToolPart::Input, + SessionPublicIndexToolPart::Output => SubjektivSessionToolPart::Output, + } +} +fn lineage(value: &SessionPublicIndexLineage) -> SubjektivSessionLineage { + SubjektivSessionLineage { + kind: match value.origin_kind { + SessionPublicIndexOriginKind::Root => SubjektivSessionLineageKind::Root, + SessionPublicIndexOriginKind::Fork => SubjektivSessionLineageKind::Fork, + SessionPublicIndexOriginKind::Compact => SubjektivSessionLineageKind::Compact, + }, + parent_segment_id: value.parent_segment_id.clone(), + at_turn_index: value.parent_turn_index, + } +} +fn entry_matches( + entry: &SessionPublicIndexEntry, + query: &str, + kind: Option, + name: Option<&str>, + part: SubjektivSessionToolPart, +) -> bool { + kind.is_none_or(|k| k == entry_kind(entry.kind)) + && name.is_none_or(|name| entry.tool_name.as_deref() == Some(name)) + && (entry.tool_part.is_none() + || part == SubjektivSessionToolPart::Both + || entry.tool_part.map(tool_part) == Some(part)) + && (query.is_empty() + || entry + .full_text + .to_lowercase() + .contains(&query.to_lowercase())) +} +fn snippet(text: &str, query: &str) -> (String, bool) { + // Search matching is case-insensitive; snippet framing uses exact matches + // only to avoid byte offsets into case-folded Unicode text. + let mut start = if query.is_empty() { + 0 + } else { + text.find(query).unwrap_or(0).saturating_sub(128) + }; + while !text.is_char_boundary(start) { + start -= 1; + } + let end = chunk_end(text, start, 512, 8); + (text[start..end].into(), start > 0 || end < text.len()) +} +fn chunk_end(text: &str, start: usize, max_bytes: usize, max_lines: usize) -> usize { + let mut end = start.saturating_add(max_bytes).min(text.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + if let Some((offset, _)) = text[start..end].match_indices('\n').nth(max_lines - 1) { + end = start + offset + 1; + } + end +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::StaleLeasePolicy; + use crate::subjektiv::StandaloneSubjectBinding; + use feature_storage::FeatureStorage; + use session_store::SessionPublicIndexSegment; + + // Test-only committed public index fixtures exercise backend authorization + // and paging independently of storage layout. The production callback must + // be the shared strict reader (whose run-boundary tests live in session-store). + fn fixture_reader( + root: &Path, + id: SessionId, + bounds: SessionPublicIndexLimits, + ) -> Result { + let bytes = std::fs::read(root.join(format!("{id}.fixture"))).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + SessionPublicIndexReadError::Missing + } else { + SessionPublicIndexReadError::Storage + } + })?; + let index: SessionPublicIndex = + serde_json::from_slice(&bytes).map_err(|_| SessionPublicIndexReadError::Corrupt)?; + if index.scanned_bytes > bounds.max_bytes + || index.scanned_entries > bounds.max_entries + || index.segments.len() > bounds.max_segments + { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + Ok(index) + } + + struct Fixture { + temp: tempfile::TempDir, + store: SubjektivStore, + _manager: FeatureStorage, + subject: String, + worker: WorkerId, + session: SessionId, + segment: String, + index_path: PathBuf, + } + + impl Fixture { + fn new(texts: &[&str]) -> Self { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("features")); + let scope = manager.workspace("local-scope-a").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + let subject = store + .create_subject(subjektiv::SubjectRole::new("standalone_subject").unwrap()) + .unwrap() + .id; + let workers = StandaloneWorkerStore::open(temp.path().join("workers")).unwrap(); + let allocation = workers + .allocate(temp.path(), StaleLeasePolicy::Reject) + .unwrap(); + let session = SessionId::now_v7(); + let segment = SessionId::now_v7().to_string(); + let manifest = manifest::WorkerManifest::from_toml( + r#" +[worker] +name = "session-test" +[model] +scheme = "anthropic" +model_id = "claude-sonnet-4-20250514" +[engine] +[scope] +allow = [] +"#, + ) + .unwrap(); + workers + .commit_created_connected( + &allocation, + manifest, + "session-test".into(), + session, + None, + Some(StandaloneSubjectBinding { + scope_id: "local-scope-a".into(), + subject_id: subject.clone(), + }), + ) + .unwrap(); + let worker = allocation.worker_id(); + store + .record_session_attribution( + SubjectSessionAttribution::new( + &subject, + "local-scope-a", + worker.to_string(), + session.to_string(), + ) + .unwrap(), + ) + .unwrap(); + let index_path = workers + .sessions_dir(worker) + .join(format!("{session}.fixture")); + let index = SessionPublicIndex { + session_id: session.to_string(), + generation: "generation-1".into(), + scanned_bytes: 100, + scanned_entries: texts.len(), + entry_count: texts.len(), + segments: vec![SessionPublicIndexSegment { + segment_id: segment.clone(), + lineage: SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Root, + parent_segment_id: None, + parent_turn_index: None, + }, + entries: texts + .iter() + .enumerate() + .map(|(i, text)| SessionPublicIndexEntry { + segment_id: segment.clone(), + entry_ref: format!("E{i}"), + kind: SessionPublicIndexEntryKind::User, + provenance: protocol::SessionEntryProvenance::HumanInput, + timestamp: 1, + tool_part: None, + tool_name: None, + compact_text: text.chars().take(100).collect(), + compact_text_truncated: text.len() > 100, + full_text: (*text).into(), + }) + .collect(), + }], + }; + std::fs::write(&index_path, serde_json::to_vec(&index).unwrap()).unwrap(); + Self { + temp, + store, + _manager: manager, + subject, + worker, + session, + segment, + index_path, + } + } + fn execute( + &self, + operation: SubjektivSessionBackendOperation, + ) -> SubjektivSessionBackendResponse { + execute_with_reader( + &self.temp.path().join("workers"), + "local-scope-a", + &self.subject, + &self.store, + operation, + fixture_reader, + ) + .unwrap() + } + fn list(&self, cursor: Option, limit: usize) -> SubjektivSessionBackendResponse { + self.execute(SubjektivSessionBackendOperation::List( + SubjektivSessionListRequest { + session_id: None, + storage: SubjektivSessionStorageFilter::All, + cursor, + limit: Some(limit), + }, + )) + } + fn search(&self, cursor: Option, limit: usize) -> SubjektivSessionBackendResponse { + self.execute(SubjektivSessionBackendOperation::Search( + SubjektivSessionSearchRequest { + query: None, + session_id: Some(self.session.to_string()), + kind: None, + tool_name: None, + tool_part: SubjektivSessionToolPart::Both, + cursor, + limit: Some(limit), + }, + )) + } + fn read(&self, cursor: Option) -> SubjektivSessionBackendResponse { + self.execute(SubjektivSessionBackendOperation::Read( + SubjektivSessionReadRequest { + session_id: self.session.to_string(), + segment_id: self.segment.clone(), + entry_ref: "E0".into(), + mode: SubjektivSessionReadMode::Full, + cursor, + }, + )) + } + } + + fn expect_error( + response: SubjektivSessionBackendResponse, + code: SubjektivSessionDiagnosticCode, + ) { + match response { + SubjektivSessionBackendResponse::Error { error } => assert_eq!(error.code, code), + _ => panic!("expected {code:?}, got {response:?}"), + } + } + + #[test] + fn discovers_sessions_without_staging_candidates_and_empty_subjects() { + let f = Fixture::new(&["public experience"]); + match f.list(None, 20) { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(page), + } => { + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].session_id, f.session.to_string()); + assert_eq!( + page.items[0].availability, + SubjektivSessionAvailability::Unchecked + ); + assert!(!page.has_more); + } + response => panic!("{response:?}"), + } + let other = f + .store + .create_subject(subjektiv::SubjectRole::new("other_subject").unwrap()) + .unwrap(); + let response = execute_with_reader( + &f.temp.path().join("workers"), + "local-scope-a", + &other.id, + &f.store, + SubjektivSessionBackendOperation::List(SubjektivSessionListRequest { + session_id: None, + storage: SubjektivSessionStorageFilter::All, + limit: None, + cursor: None, + }), + fixture_reader, + ) + .unwrap(); + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(page), + } => { + assert!(page.items.is_empty()); + assert!(!page.has_more); + } + response => panic!("{response:?}"), + } + } + + #[test] + fn foreign_subject_and_storage_scope_reads_are_denied_even_for_known_ids() { + let f = Fixture::new(&["secret from first subject"]); + let other = f + .store + .create_subject(subjektiv::SubjectRole::new("other_subject").unwrap()) + .unwrap(); + let operation = SubjektivSessionBackendOperation::Read(SubjektivSessionReadRequest { + session_id: f.session.to_string(), + segment_id: f.segment.clone(), + entry_ref: "E0".into(), + mode: SubjektivSessionReadMode::Full, + cursor: None, + }); + for (scope, subject) in [ + ("local-scope-a", other.id.as_str()), + ("foreign-scope", f.subject.as_str()), + ] { + expect_error( + execute_with_reader( + &f.temp.path().join("workers"), + scope, + subject, + &f.store, + operation.clone(), + fixture_reader, + ) + .unwrap(), + SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + ); + } + let backend = Backend { + state_root: &f.temp.path().join("workers"), + scope_id: "local-scope-a", + subject_id: &f.subject, + store: &f.store, + reader: fixture_reader, + }; + let mut attribution = f + .store + .session_attribution(&f.session.to_string()) + .unwrap() + .unwrap(); + attribution.worker_id = WorkerId::now_v7().to_string(); + assert_eq!( + backend.authorize(attribution).err().unwrap().code, + SubjektivSessionDiagnosticCode::RetentionMissing + ); + } + + #[test] + fn mismatched_worker_subject_binding_is_denied_without_reading_body() { + let f = Fixture::new(&["must not leak"]); + let record_path = f + .temp + .path() + .join("workers") + .join(f.worker.to_string()) + .join("record.json"); + let mut record: serde_json::Value = + serde_json::from_slice(&std::fs::read(&record_path).unwrap()).unwrap(); + record["subject"]["subject_id"] = serde_json::json!("foreign-subject"); + std::fs::write(record_path, serde_json::to_vec(&record).unwrap()).unwrap(); + // Corrupt the body as well: authorization must still win over parsing. + std::fs::write(&f.index_path, b"bad index").unwrap(); + expect_error( + f.read(None), + SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + ); + expect_error( + f.search(None, 20), + SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + ); + expect_error( + f.execute(SubjektivSessionBackendOperation::List( + SubjektivSessionListRequest { + session_id: Some(f.session.to_string()), + storage: SubjektivSessionStorageFilter::All, + limit: None, + cursor: None, + }, + )), + SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + ); + } + + #[test] + fn search_continuations_have_no_duplicate_or_missing_refs_and_bind_filters() { + let f = Fixture::new(&["first", "second", "third"]); + let mut cursor = None; + let mut refs = Vec::new(); + loop { + let response = f.search(cursor, 1); + assert!(fits(&response)); + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(page), + } => { + refs.extend(page.items.into_iter().map(|item| item.entry_ref)); + if !page.has_more { + assert!(page.next_cursor.is_none()); + break; + } + cursor = page.next_cursor; + } + response => panic!("{response:?}"), + } + } + assert_eq!(refs, ["E0", "E1", "E2"]); + let token = match f.search(None, 1) { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(page), + } => page.next_cursor.unwrap(), + response => panic!("{response:?}"), + }; + let mut decoded = decode_cursor(&token).unwrap(); + decoded.scope_id = "foreign-scope".into(); + expect_error( + f.search(Some(encode_cursor(&decoded).unwrap()), 1), + SubjektivSessionDiagnosticCode::StaleCursor, + ); + expect_error( + f.execute(SubjektivSessionBackendOperation::Search( + SubjektivSessionSearchRequest { + query: Some("changed query".into()), + session_id: Some(f.session.to_string()), + kind: None, + tool_name: None, + tool_part: SubjektivSessionToolPart::Both, + cursor: Some(token), + limit: Some(1), + }, + )), + SubjektivSessionDiagnosticCode::StaleCursor, + ); + } + + #[test] + fn read_continuations_bound_utf8_lines_and_serialized_json_bytes() { + let text = format!("{}{}", "\0\"\\🦀".repeat(6000), "line\n".repeat(700)); + let f = Fixture::new(&[&text]); + let mut cursor = None; + let mut reconstructed = String::new(); + loop { + let response = f.read(cursor); + assert!(fits(&response)); + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read(page), + } => { + assert!(page.content.len() <= SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES); + assert!(page.content.matches('\n').count() <= MAX_LINES); + assert!(!page.content.is_empty()); + reconstructed.push_str(&page.content); + if !page.has_more { + assert!(page.next_cursor.is_none()); + break; + } + assert!(page.truncated); + cursor = page.next_cursor; + } + response => panic!("{response:?}"), + } + } + assert_eq!(reconstructed, text); + } + + #[test] + fn read_and_search_cursor_generation_changes_are_explicit() { + let text = "long".repeat(6000); + let f = Fixture::new(&[&text, "second"]); + let read_cursor = match f.read(None) { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read(page), + } => page.next_cursor.unwrap(), + response => panic!("{response:?}"), + }; + let search_cursor = match f.search(None, 1) { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(page), + } => page.next_cursor.unwrap(), + response => panic!("{response:?}"), + }; + let mut index: SessionPublicIndex = + serde_json::from_slice(&std::fs::read(&f.index_path).unwrap()).unwrap(); + index.generation = "new-generation".into(); + std::fs::write(&f.index_path, serde_json::to_vec(&index).unwrap()).unwrap(); + expect_error( + f.read(Some(read_cursor)), + SubjektivSessionDiagnosticCode::StaleCursor, + ); + expect_error( + f.search(Some(search_cursor), 1), + SubjektivSessionDiagnosticCode::StaleCursor, + ); + } + + #[test] + fn missing_and_corrupt_sessions_have_explicit_diagnostics_and_partial_search() { + let f = Fixture::new(&["public"]); + std::fs::remove_file(&f.index_path).unwrap(); + expect_error( + f.read(None), + SubjektivSessionDiagnosticCode::RetentionMissing, + ); + match f.search(None, 20) { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(page), + } => { + assert!(page.items.is_empty()); + assert_eq!(page.coverage, SubjektivSessionCoverage::Partial); + assert_eq!( + page.issues[0].code, + SubjektivSessionDiagnosticCode::RetentionMissing + ); + } + response => panic!("{response:?}"), + } + std::fs::write(&f.index_path, b"corrupt").unwrap(); + expect_error(f.read(None), SubjektivSessionDiagnosticCode::CorruptLog); + expect_error( + f.execute(SubjektivSessionBackendOperation::Read( + SubjektivSessionReadRequest { + session_id: "known-but-absent".into(), + segment_id: f.segment.clone(), + entry_ref: "E0".into(), + mode: SubjektivSessionReadMode::Full, + cursor: None, + }, + )), + SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + ); + } + + fn worker_tree(root: &Path) -> Vec<(PathBuf, Vec, std::time::SystemTime)> { + fn collect( + root: &Path, + path: &Path, + output: &mut Vec<(PathBuf, Vec, std::time::SystemTime)>, + ) { + let metadata = std::fs::metadata(path).unwrap(); + if metadata.is_dir() { + output.push(( + path.strip_prefix(root).unwrap().into(), + Vec::new(), + metadata.modified().unwrap(), + )); + for entry in std::fs::read_dir(path).unwrap() { + collect(root, &entry.unwrap().path(), output); + } + } else { + output.push(( + path.strip_prefix(root).unwrap().into(), + std::fs::read(path).unwrap(), + metadata.modified().unwrap(), + )); + } + } + let mut output = Vec::new(); + collect(root, root, &mut output); + output.sort_by(|a, b| a.0.cmp(&b.0)); + output + } + + #[test] + fn discovery_keyset_pagination_is_bounded_and_excludes_later_attributions() { + let f = Fixture::new(&["public"]); + let mut expected = std::collections::BTreeSet::from([f.session.to_string()]); + for _ in 0..137 { + let session = SessionId::now_v7().to_string(); + let mut attribution = SubjectSessionAttribution::new( + &f.subject, + "local-scope-a", + f.worker.to_string(), + &session, + ) + .unwrap(); + attribution.attributed_at = "2020-01-01T00:00:00.000Z".into(); + f.store.record_session_attribution(attribution).unwrap(); + expected.insert(session); + } + let before = worker_tree(&f.temp.path().join("workers")); + let mut cursor = None; + let mut found = Vec::new(); + loop { + let response = f.list(cursor, 100); + assert!(fits(&response)); + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(page), + } => { + assert!(page.items.len() <= 100); + found.extend(page.items.into_iter().map(|item| item.session_id)); + if !page.has_more { + assert!(page.next_cursor.is_none()); + break; + } + if cursor_is_first(&found) { + let mut later = SubjectSessionAttribution::new( + &f.subject, + "local-scope-a", + f.worker.to_string(), + SessionId::now_v7().to_string(), + ) + .unwrap(); + later.attributed_at = "2099-01-01T00:00:00.000Z".into(); + f.store.record_session_attribution(later).unwrap(); + } + cursor = page.next_cursor; + } + response => panic!("{response:?}"), + } + } + assert_eq!(found.len(), expected.len()); + assert_eq!( + found.into_iter().collect::>(), + expected + ); + assert_eq!(before, worker_tree(&f.temp.path().join("workers"))); + + fn cursor_is_first(found: &[String]) -> bool { + found.len() <= 100 + } + } + + #[test] + fn missing_state_root_observation_never_creates_a_directory() { + let f = Fixture::new(&["public"]); + let absent = f.temp.path().join("absent-state-root"); + expect_error( + execute( + &absent, + "local-scope-a", + &f.subject, + &f.store, + SubjektivSessionBackendOperation::Read(SubjektivSessionReadRequest { + session_id: f.session.to_string(), + segment_id: f.segment.clone(), + entry_ref: "E0".into(), + mode: SubjektivSessionReadMode::Full, + cursor: None, + }), + ) + .unwrap(), + SubjektivSessionDiagnosticCode::RetentionMissing, + ); + assert!(!absent.exists()); + } + + #[test] + fn real_fs_projection_hides_system_reasoning_and_unfinished_or_partial_records() { + use session_store::{ + LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, + LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, + Store, + }; + use std::io::Write; + + let f = Fixture::new(&[]); + let history = |id: &str, item: LoggedItem| LoggedHistoryEntry { + item, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(id.into()), + origin: LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None }, + derivation: None, + }, + }; + let message = |id: &str, role: LoggedRole, text: &str| { + history( + id, + LoggedItem::Message { + role, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + ) + }; + let sessions = f.index_path.parent().unwrap(); + let fs_store = session_store::FsStore::new(sessions).unwrap(); + let segment: SessionId = f.segment.parse().unwrap(); + fs_store + .create_segment( + f.session, + segment, + &[ + LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id: f.session, + system_prompt: Some("hidden-system-prompt".into()), + config: Default::default(), + forked_from: None, + compacted_from: None, + history: vec![ + message("public", LoggedRole::User, "visible committed input"), + message("system", LoggedRole::System, "hidden-system-message"), + history( + "reasoning", + LoggedItem::Reasoning { + text: "hidden-reasoning".into(), + summary: vec!["hidden-reasoning-summary".into()], + encrypted_content: None, + signature: None, + }, + ), + ], + }, + LogEntry::Invoke { + ts: 2, + trigger: protocol::InvokeKind::UserSend, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message( + "unfinished", + LoggedRole::Assistant, + "hidden-unfinished-output", + ), + }, + ], + ) + .unwrap(); + let path = sessions + .join(f.session.to_string()) + .join(format!("{segment}.jsonl")); + let mut log = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + // A valid JSON record without its final newline is not committed. + log.write_all( + &serde_json::to_vec(&LogEntry::RunCompleted { + ts: 4, + interrupted: false, + result: agen::EngineResult::Finished, + active_run_turn_count: None, + }) + .unwrap(), + ) + .unwrap(); + drop(log); + let operation = SubjektivSessionBackendOperation::Search(SubjektivSessionSearchRequest { + query: None, + session_id: Some(f.session.to_string()), + kind: None, + tool_name: None, + tool_part: SubjektivSessionToolPart::Both, + limit: None, + cursor: None, + }); + let before = worker_tree(&f.temp.path().join("workers")); + let response = execute( + &f.temp.path().join("workers"), + "local-scope-a", + &f.subject, + &f.store, + operation.clone(), + ) + .unwrap(); + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("visible committed input")); + assert!(!json.contains("hidden-")); + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(page), + } => { + assert_eq!(page.items.len(), 1); + assert!(page.issues.is_empty()); + } + response => panic!("{response:?}"), + } + let read = execute( + &f.temp.path().join("workers"), + "local-scope-a", + &f.subject, + &f.store, + SubjektivSessionBackendOperation::Read(SubjektivSessionReadRequest { + session_id: f.session.to_string(), + segment_id: f.segment.clone(), + entry_ref: "Epublic".into(), + mode: SubjektivSessionReadMode::Full, + cursor: None, + }), + ) + .unwrap(); + assert!(matches!( + read, + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read(_) + } + )); + assert!(fits(&f.list(None, 100))); + assert_eq!(before, worker_tree(&f.temp.path().join("workers"))); + + // Commit the completion: the same shared projection now exposes only + // the public assistant output, never hidden seed content. + let mut log = std::fs::OpenOptions::new().append(true).open(path).unwrap(); + log.write_all(b"\n").unwrap(); + drop(log); + let response = execute( + &f.temp.path().join("workers"), + "local-scope-a", + &f.subject, + &f.store, + operation, + ) + .unwrap(); + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("hidden-unfinished-output")); + assert!(!json.contains("hidden-system")); + assert!(!json.contains("hidden-reasoning")); + } + + #[test] + fn evidence_validation_uses_same_subject_authority() { + let f = Fixture::new(&["evidence"]); + let index = authorized_index_with_reader( + &f.temp.path().join("workers"), + "local-scope-a", + &f.subject, + &f.store, + &f.session.to_string(), + fixture_reader, + ) + .unwrap(); + assert_eq!(index.segments[0].entries[0].entry_ref, "E0"); + assert!( + authorized_index_with_reader( + &f.temp.path().join("workers"), + "local-scope-a", + "foreign-subject", + &f.store, + &f.session.to_string(), + fixture_reader + ) + .is_err() + ); + } +} diff --git a/crates/standalone/tests/subjektiv.rs b/crates/standalone/tests/subjektiv.rs new file mode 100644 index 000000000..b7464bc43 --- /dev/null +++ b/crates/standalone/tests/subjektiv.rs @@ -0,0 +1,1430 @@ +//! T-709: real standalone Hosts and test-owned processes, with scripted models. +//! These tests do not create a Workspace or contact a model provider. + +use std::collections::{BTreeMap, VecDeque}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use agen::llm_client::{ + LlmClient, + error::ClientError, + event::{Event as LlmEvent, StopReason}, + types::{Item, Request}, +}; +use async_trait::async_trait; +use feature_storage::FeatureStorage; +use futures::{Stream, stream}; +use manifest::{ProfileRegistrySource, ProfileSelector}; +use protocol::{Event, Method}; +use serde_json::{Value, json}; +use standalone::subjektiv::StandaloneSubjects; +use standalone::{ + ResolvedStandaloneLaunch, StandaloneHost, StandaloneLaunchConfig, StandaloneLaunchError, + StandaloneStartupError, StandaloneWorkerStore, +}; +use subjektiv::{SubjectStagingRecord, SubjektivStore}; + +#[derive(Clone)] +struct ScriptedClient { + responses: Arc>>>, + requests: Arc>>, + consolidate: bool, + extract: bool, + commit_guard: Option<(PathBuf, String)>, + fail_after_apply: bool, + job_ids: Arc>>, + job_started: Arc, +} + +impl ScriptedClient { + fn new(responses: Vec>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses.into())), + requests: Arc::new(Mutex::new(Vec::new())), + consolidate: false, + extract: false, + commit_guard: None, + fail_after_apply: false, + job_ids: Arc::new(Mutex::new(Vec::new())), + job_started: Arc::new(tokio::sync::Notify::new()), + } + } + + fn consolidating(responses: Vec>, fail_after_apply: bool) -> Self { + Self { + consolidate: true, + fail_after_apply, + ..Self::new(responses) + } + } + + async fn first_job(&self) -> String { + tokio::time::timeout(Duration::from_secs(30), self.job_started.notified()) + .await + .expect("lifecycle must auto-start a generic Job"); + self.job_ids.lock().unwrap()[0].clone() + } + + fn job_response(&self, request: &Request) -> Result, ClientError> { + assert!( + request + .system_prompt + .as_deref() + .unwrap() + .contains("# Memory staging consolidater"), + "generic Job must render the instruction of its selected consolidation Profile" + ); + for forbidden in [ + "Read", + "Bash", + "SubWorkerSpawn", + "SubjektivMemoryRemember", + "SubjektivSessionRead", + ] { + assert!( + !request.tools.iter().any(|tool| tool.name == forbidden), + "Job received {forbidden}" + ); + } + let envelope = request + .items + .iter() + .filter_map(Item::as_text) + .find(|text| text.contains("input_json: ")) + .expect("immutable Job envelope"); + let input: Value = serde_json::from_str( + envelope + .lines() + .find_map(|line| line.strip_prefix("input_json: ")) + .unwrap(), + ) + .unwrap(); + let id = envelope + .lines() + .find_map(|line| line.strip_prefix("job_id: ")) + .unwrap() + .to_owned(); + let mut ids = self.job_ids.lock().unwrap(); + if !ids.contains(&id) { + ids.push(id); + self.job_started.notify_one(); + } + drop(ids); + let result = |call: &str| { + request.items.iter().find_map(|item| match item { + Item::ToolResult { + call_id, + content, + is_error, + summary, + .. + } if call_id == call => { + assert!(!is_error, "{call}: {summary}; {content:?}"); + Some(content.as_deref().expect("structured domain result")) + } + _ => None, + }) + }; + if let Some(Item::ToolResult { + is_error, + summary, + content, + .. + }) = request.items.iter().find( + |item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "job-result"), + ) { + assert!( + !is_error, + "structured result rejected: {summary}; {content:?}" + ); + assert_eq!(summary, "Host Job result accepted"); + // SubmitJobResult intentionally returns summary-only. Durable + // structured acceptance is asserted against host.jobs().wait(). + return Ok(prose()); + } + if let Some(applied) = result("apply") { + let response: server_api::SubjektivMemoryBackendResponse = + serde_json::from_str(applied).unwrap(); + let server_api::SubjektivMemoryBackendResponse::CandidateDecided(decision) = response + else { + panic!("candidate apply rejected: {applied}"); + }; + assert_eq!( + decision.action, + server_api::SubjektivMemoryCandidateResolutionAction::Applied + ); + assert_eq!(decision.affected_memory.len(), 1); + if self.fail_after_apply { + return Err(ClientError::Config( + "simulated model transport loss after durable candidate apply".into(), + )); + } + return Ok(tool( + "job-result", + "SubmitJobResult", + json!({"result":input}), + )); + } + if let Some(candidate) = result("candidate") { + let response: server_api::SubjektivMemoryBackendResponse = + serde_json::from_str(candidate).unwrap(); + let server_api::SubjektivMemoryBackendResponse::Candidate(candidate) = response else { + panic!("candidate read rejected: {candidate}"); + }; + assert_eq!(input["candidate_ids"], json!([candidate.candidate_id])); + assert!( + !candidate.evidence.is_empty(), + "Job must receive committed provenance" + ); + return Ok(tool( + "apply", + "MemoryApplyCandidate", + json!({ + "request_id":format!("apply:{}", candidate.candidate_id), + "candidate_id":candidate.candidate_id, "reason":"Adopt the committed lesson", + "decision":{"kind":"apply", "target":{"kind":"create"}, "memory":{ + "kind":candidate.kind, "state":"active", "claim":candidate.claim, + "body_md":candidate.claim, "why_useful":candidate.why_useful, + "change_reason":"Adopt committed standalone evidence" + }} + }), + )); + } + if let Some(list) = result("candidates") { + let response: server_api::SubjektivMemoryBackendResponse = + serde_json::from_str(list).unwrap(); + let server_api::SubjektivMemoryBackendResponse::Candidates(list) = response else { + panic!("candidate list rejected: {list}"); + }; + assert_eq!( + input["candidate_ids"], + json!( + list.items + .iter() + .map(|c| &c.candidate_id) + .collect::>() + ) + ); + if let Some(candidate) = list.items.first() { + return Ok(tool( + "candidate", + "MemoryStagingRead", + json!({"candidate_id":candidate.candidate_id}), + )); + } + return Ok(tool( + "job-result", + "SubmitJobResult", + json!({"result":input}), + )); + } + Ok(tool("candidates", "MemoryStagingList", json!({}))) + } + + fn surface_response(&self, request: &Request) -> Vec { + assert_eq!( + request + .tools + .iter() + .map(|t| t.name.as_str()) + .collect::>(), + ["SubmitMemorySurface"] + ); + assert!( + !request.items.iter().any( + |item| matches!(item, Item::ToolCall { name, .. } if name == "MemoryApplyCandidate") + ), + "surface editing must use a clean context, not consolidation history" + ); + if let Some(Item::ToolResult { + is_error, + summary, + content, + .. + }) = request + .items + .iter() + .find(|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "surface")) + { + assert!(!is_error, "surface rejected: {summary}; {content:?}"); + assert_eq!(summary, "Accepted 1 grounded Memory surface point(s)."); + return prose(); + } + let input: Value = request + .items + .iter() + .filter_map(Item::as_text) + .filter_map(|text| serde_json::from_str::(text).ok()) + .find(|value| value.get("materials").is_some()) + .expect("bounded confirmed surface materials"); + let materials = input["materials"].as_array().unwrap(); + assert_eq!( + materials.len(), + 1, + "only the confirmed lesson belongs in this surface" + ); + let material = &materials[0]; + tool( + "surface", + "SubmitMemorySurface", + json!({"points":[{ + "body_md":format!("- {}", material["body_md"].as_str().unwrap()), + "memory_refs":[{"memory_id":material["memory_id"], "revision":material["revision"]}] + }]}), + ) + } + + fn extract_response(&self, request: &Request) -> Vec { + assert!(self.extract, "unexpected extract request"); + for forbidden in [ + "Bash", + "SubWorkerSpawn", + "MemoryApplyCandidate", + "SubmitJobResult", + "SubjektivMemoryRemember", + ] { + assert!( + !request.tools.iter().any(|tool| tool.name == forbidden), + "extract received {forbidden}" + ); + } + let result = |id: &str| { + request.items.iter().find_map(|item| match item { + Item::ToolResult { + call_id, + is_error, + summary, + .. + } if call_id == id => { + assert!(!is_error, "extract {id}: {summary}"); + Some(summary) + } + _ => None, + }) + }; + if result("extract-finish").is_some() { + return prose(); + } + if result("extract-stage").is_some() { + return tool( + "extract-finish", + "FinishMemoryExtraction", + json!({"staged_count":1}), + ); + } + let input = request + .items + .iter() + .filter_map(Item::as_text) + .find(|text| text.contains("# Initial session entry index")) + .expect("committed extract view"); + let entry = input + .lines() + .filter(|line| line.contains("A useful lesson is worth retaining across Workers.")) + .find_map(|line| { + line.strip_prefix("- [").and_then(|line| { + let (entry, rest) = line.split_once(' ')?; + (rest.starts_with("user ") || rest.starts_with("user]")).then_some(entry) + }) + }) + .expect("user input reference"); + tool( + "extract-stage", + "StageMemoryCandidate", + json!({ + "kind":"lesson", "claim":"Extract a committed lesson through the normal lifecycle", + "why_useful":"Normal extraction must retain Host-validated provenance", "entry_refs":[entry] + }), + ) + } + + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} + +#[async_trait] +impl LlmClient for ScriptedClient { + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + // Lifecycle-triggered consolidation uses a separate Internal Worker. It + // must not consume the dialog script or silently become a live request. + let job = request + .tools + .iter() + .any(|tool| tool.name == "SubmitJobResult"); + self.requests.lock().unwrap().push(request.clone()); + if let Some((state, subject)) = &self.commit_guard { + if request + .items + .iter() + .any(|item| matches!(item,Item::ToolResult {call_id,..} if call_id=="remember")) + { + inspect_store(state, |store| { + assert!( + store + .pending_staging_candidates(subject, 100) + .unwrap() + .is_empty(), + "the open Run must not yet publish an explicit candidate" + ) + }); + } + } + let response = if job { + if !self.consolidate { + return Err(ClientError::Config("unscripted background Job".into())); + } + self.job_response(&request)? + } else if request + .tools + .iter() + .any(|tool| tool.name == "StageMemoryCandidate") + { + self.extract_response(&request) + } else if request + .tools + .iter() + .any(|tool| tool.name == "SubmitMemorySurface") + { + assert!(self.consolidate, "unexpected surface model invocation"); + self.surface_response(&request) + } else { + self.responses + .lock() + .unwrap() + .pop_front() + .expect("unexpected model request: dialog script exhausted") + }; + Ok(Box::pin(stream::iter(response.into_iter().map(Ok)))) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } +} + +fn prose() -> Vec { + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "scripted answer"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ] +} + +fn tool(id: &str, name: &str, input: Value) -> Vec { + vec![ + LlmEvent::tool_use_start(0, id, name), + LlmEvent::tool_input_delta(0, input.to_string()), + LlmEvent::tool_use_stop(0), + ] +} + +fn launch(cwd: &Path, state: &Path, subject: &str, name: &str) -> ResolvedStandaloneLaunch { + StandaloneLaunchConfig::new( + cwd, + state, + ProfileSelector::source_named(ProfileRegistrySource::Builtin, "standalone-subjektiv"), + name, + ) + .with_subject(subject.to_owned()) + .resolve() + .expect("resolve selected Subject launch") +} + +async fn run(host: &StandaloneHost, text: &str) -> BTreeMap { + let mut client = host.connect(); + client + .send(&Method::submit_text( + protocol::new_submission_request_id(), + text, + )) + .await + .expect("submit dialog input"); + let mut event_trace = Vec::new(); + let outcome = tokio::time::timeout(Duration::from_secs(30), async { + let mut results = BTreeMap::new(); + let mut answered = false; + loop { + let event = client + .next_event() + .await + .expect("protocol stream") + .expect("event"); + if event_trace.len() < 100 { + event_trace.push(format!("{event:?}")); + } + match event { + Event::ToolResult { + id, + output, + summary, + is_error, + .. + } => { + assert!(!is_error, "{id}: {summary}; {output:?}"); + let value = serde_json::from_str(&output.expect("structured tool output")) + .expect("tool output JSON"); + results.insert(id, value); + } + Event::Error { code, message } => { + panic!("dialog {text:?} failed: {code:?}: {message}") + } + Event::TextDelta { text } if text.contains("scripted answer") => answered = true, + Event::RunEnd { .. } => { + assert!( + answered, + "a failed/interrupted Run is not successful execution" + ); + return results; + } + _ => {} + } + } + }) + .await; + outcome.unwrap_or_else(|error| { + panic!("dialog {text:?} did not complete: {error}; events: {event_trace:#?}") + }) +} + +// The common typed store is only an assertion oracle here. Mutations under +// test always go through operator admin or the Host/model capability. +fn inspect_store(state: &Path, inspect: impl FnOnce(&SubjektivStore) -> T) -> T { + let root = state.join("subjektiv"); + let scope_id: String = + serde_json::from_slice(&std::fs::read(root.join("scope.json")).unwrap()).unwrap(); + let manager = FeatureStorage::new(root.join("features")); + let scope = manager.scope(&scope_id).unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + let result = inspect(&store); + drop(store); + manager.shutdown().unwrap(); + result +} + +#[test] +fn operator_subject_catalog_survives_close_and_reopen() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&state).unwrap(); + assert!(catalog.list().unwrap().is_empty()); + let first = catalog + .create("coding", Some("Keep precise evidence.")) + .unwrap(); + let second = catalog.create("coding", None).unwrap(); + assert_ne!( + first.id, second.id, + "role/Profile/cwd must not derive Subject identity" + ); + uuid::Uuid::parse_str( + first + .id + .strip_prefix("subject-") + .expect("common Subject issuer"), + ) + .unwrap(); + uuid::Uuid::parse_str( + second + .id + .strip_prefix("subject-") + .expect("common Subject issuer"), + ) + .unwrap(); + drop(catalog); + let reopened = StandaloneSubjects::open(&state).unwrap(); + let items = reopened.list().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items.iter().find(|s| s.id == first.id).unwrap(), &first); + assert_eq!(items.iter().find(|s| s.id == second.id).unwrap(), &second); +} + +#[tokio::test] +async fn enabled_profile_without_subject_is_inert_and_disabled_selection_is_rejected() { + let temp = tempfile::tempdir().unwrap(); + for profile in ["standalone-subjektiv", "standalone"] { + let state = temp.path().join(profile); + let launch = StandaloneLaunchConfig::new( + temp.path(), + &state, + ProfileSelector::source_named(ProfileRegistrySource::Builtin, profile), + "unattached", + ) + .resolve() + .unwrap(); + let model = ScriptedClient::new(vec![prose()]); + let host = StandaloneHost::start_with_model_client(launch, model.clone()) + .await + .unwrap(); + assert!(host.record().subject.is_none()); + run( + &host, + "An unattached policy must not manufacture a Subject.", + ) + .await; + let request = &model.requests()[0]; + assert!(request.tools.iter().any(|t| t.name == "Read")); + assert!( + !request + .tools + .iter() + .any(|t| t.name.starts_with("Subjektiv")) + ); + assert!(host.jobs().pending().unwrap().is_empty()); + host.shutdown().await.unwrap(); + assert!( + !state.join("subjektiv").exists(), + "policy alone must have no Subject storage side effect" + ); + } + let state = temp.path().join("rejected"); + let selected = uuid::Uuid::now_v7().to_string(); + let error = StandaloneLaunchConfig::new( + temp.path(), + &state, + ProfileSelector::source_named(ProfileRegistrySource::Builtin, "standalone"), + "disabled", + ) + .with_subject(selected) + .resolve() + .err() + .expect("disabled selection rejected"); + assert_eq!(error, StandaloneLaunchError::InvalidSubjectPolicy); + assert!(!state.exists(), "admission rejection precedes persistence"); +} + +#[tokio::test] +async fn explicit_remember_is_committed_staging_and_sessions_follow_subject_across_workers() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&state).unwrap(); + let subject = catalog + .create("coding", Some("Keep committed references.")) + .unwrap(); + let foreign = catalog.create("other", None).unwrap(); + drop(catalog); + let claim = "Use immutable committed references for test evidence"; + let mut model = ScriptedClient::new(vec![ + tool( + "remember", + "SubjektivMemoryRemember", + json!({ + "kind":"lesson", "claim":claim, "why_useful":"Avoid uncommitted evidence" + }), + ), + tool("query", "SubjektivMemoryQuery", json!({"query":claim})), + prose(), + ]); + model.commit_guard = Some((state.clone(), subject.id.clone())); + let host = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "first-body"), + model.clone(), + ) + .await + .unwrap(); + let binding = host.record().subject.clone().unwrap(); + let worker_id = host.worker_id(); + let session_id = host.record().active_session_id; + assert_eq!(binding.subject_id, subject.id); + assert!( + host.record() + .manifest + .feature + .subjektiv + .workspace_settings + .is_none() + ); + let results = run( + &host, + "Remember this lesson using committed model-origin evidence.", + ) + .await; + assert_eq!(results["remember"]["status"], "pending_commit"); + assert_eq!( + results["query"]["items"], + json!([]), + "remember never writes confirmed Memory" + ); + let request = &model.requests()[0]; + for name in [ + "SubjektivMemoryRemember", + "SubjektivMemoryQuery", + "SubjektivSessionSearch", + "SubjektivSessionRead", + ] { + assert!( + request.tools.iter().any(|t| t.name == name), + "missing {name}" + ); + } + assert!( + !request + .tools + .iter() + .any(|t| t.name == "MemoryApplyCandidate"), + "body must not receive consolidation authority" + ); + host.shutdown().await.unwrap(); + + let staged: SubjectStagingRecord = inspect_store(&state, |store| { + assert!(store.list_memories(&subject.id).unwrap().is_empty()); + let candidates = store.pending_staging_candidates(&subject.id, 100).unwrap(); + assert_eq!( + candidates.len(), + 1, + "committed remember must become staging, not disappear" + ); + let attribution = store + .session_attribution(&session_id.to_string()) + .unwrap() + .unwrap(); + assert_eq!(attribution.subject_id, subject.id); + assert_eq!(attribution.runtime_id, binding.scope_id); + assert_eq!(attribution.worker_id, worker_id.to_string()); + candidates.into_iter().next().unwrap() + }); + assert_eq!(staged.claim, claim); + assert_eq!(staged.subject_id, subject.id); + assert!(!staged.evidence.is_empty()); + assert_eq!(staged.evidence.len(), staged.source_refs.len()); + let workers = StandaloneWorkerStore::open(&state).unwrap(); + let index = session_store::public_index::read_fs_session_public_index( + &workers.sessions_dir(worker_id), + session_id, + Default::default(), + ) + .unwrap(); + for evidence in &staged.evidence { + let source = staged + .source_refs + .iter() + .find(|s| s.evidence_id.as_deref() == Some(&evidence.id)) + .unwrap(); + assert_eq!( + source.session_id.as_deref(), + Some(session_id.to_string().as_str()) + ); + let entry = index + .segments + .iter() + .filter(|segment| source.segment_id.as_deref() == Some(segment.segment_id.as_str())) + .flat_map(|segment| &segment.entries) + .find(|entry| entry.entry_ref == evidence.id) + .expect("exact committed public reference"); + assert!(entry.compact_text.contains(claim)); + let [start, end] = evidence.entry_range.expect("committed range"); + assert!( + start <= end, + "committed source ranges use inclusive endpoints" + ); + assert_eq!(source.entry_range, evidence.entry_range); + let origin = evidence.origin.as_ref().unwrap(); + assert_eq!(origin.kind, memory::schema::EvidenceOriginKind::ModelOutput); + assert!( + origin.runtime_id.is_none(), + "local scope is not a Runtime identity" + ); + assert_eq!( + origin.worker_id.as_deref(), + Some(worker_id.to_string().as_str()) + ); + assert!(origin.workspace_id.is_none()); + assert!(origin.account_id.is_none()); + assert_eq!(source.origin.as_ref(), Some(origin)); + } + let source = &staged.source_refs[0]; + let read_input = json!({ + "session_id":session_id.to_string(), "segment_id":source.segment_id, + "entry_ref":source.evidence_id, + }); + let restored_model = ScriptedClient::new(vec![ + tool("recall", "SubjektivSessionRead", read_input.clone()), + tool("query-restored", "SubjektivMemoryQuery", json!({})), + prose(), + ]); + let restored = + StandaloneHost::restore_with_model_client(state.clone(), worker_id, restored_model.clone()) + .await + .unwrap(); + assert_eq!(restored.record().subject.as_ref(), Some(&binding)); + assert_eq!(restored.record().active_session_id, session_id); + let results = run(&restored, "Resume and recall the committed evidence.").await; + assert_eq!(results["recall"]["status"], "ok"); + assert!(results["recall"].to_string().contains(claim)); + assert_eq!(results["query-restored"]["items"], json!([])); + assert!(format!("{:?}", restored_model.requests()[0].items).contains("Remember this lesson")); + restored.shutdown().await.unwrap(); + + let second_model = ScriptedClient::new(vec![ + tool("prior-session", "SubjektivSessionRead", read_input.clone()), + tool("sessions", "SubjektivSessionList", json!({})), + prose(), + ]); + let second = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "second-body"), + second_model, + ) + .await + .unwrap(); + assert_ne!(second.worker_id(), worker_id); + assert_eq!(second.record().subject.as_ref(), Some(&binding)); + let second_session = second.record().active_session_id; + let results = run( + &second, + "Recall from another Worker, without a new Memory candidate.", + ) + .await; + assert_eq!(results["prior-session"]["status"], "ok"); + assert!(results["prior-session"].to_string().contains(claim)); + let sessions = results["sessions"].to_string(); + assert!(sessions.contains(&session_id.to_string())); + assert!( + sessions.contains(&second_session.to_string()), + "Sessions with no candidates must be attributed too" + ); + second.shutdown().await.unwrap(); + inspect_store(&state, |store| { + assert_eq!( + serde_json::to_value(store.pending_staging_candidates(&subject.id, 100).unwrap()) + .unwrap(), + json!([staged]) + ); + assert_eq!( + store + .session_attribution(&second_session.to_string()) + .unwrap() + .unwrap() + .subject_id, + subject.id + ); + }); + + let other = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &foreign.id, "foreign-body"), + ScriptedClient::new(vec![ + tool("foreign", "SubjektivSessionRead", read_input), + prose(), + ]), + ) + .await + .unwrap(); + let results = run( + &other, + "A known foreign Session reference is not authority.", + ) + .await; + assert_eq!(results["foreign"]["status"], "error"); + assert!(!results["foreign"].to_string().contains(claim)); + other.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn restore_keeps_saved_subject_and_rejects_replaced_storage_scope() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&state).unwrap(); + let subject = catalog.create("original", None).unwrap(); + let other = catalog.create("not-a-resume-selection", None).unwrap(); + drop(catalog); + let host = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "saved-binding"), + ScriptedClient::new(Vec::new()), + ) + .await + .unwrap(); + let id = host.worker_id(); + let binding = host.record().subject.clone().unwrap(); + host.shutdown().await.unwrap(); + + // Replace only the operator-managed storage root with a genuine, separately + // issued scope. The saved Worker/cwd/session remains otherwise untouched. + let foreign_state = temp.path().join("foreign-state"); + let catalog = StandaloneSubjects::open(&foreign_state).unwrap(); + catalog.create("foreign", None).unwrap(); + drop(catalog); + let original_root = temp.path().join("saved-subjektiv"); + std::fs::rename(state.join("subjektiv"), &original_root).unwrap(); + std::fs::rename(foreign_state.join("subjektiv"), state.join("subjektiv")).unwrap(); + let error = StandaloneHost::restore_with_model_client( + state.clone(), + id, + ScriptedClient::new(Vec::new()), + ) + .await + .err() + .expect("foreign scope rejected"); + assert!( + matches!(error, StandaloneStartupError::Subject(ref message) if message.contains("storage scope changed")), + "{error:?}" + ); + assert_eq!( + StandaloneWorkerStore::open(&state) + .unwrap() + .load(id) + .unwrap() + .subject + .as_ref(), + Some(&binding) + ); + std::fs::rename(state.join("subjektiv"), foreign_state.join("subjektiv")).unwrap(); + std::fs::rename(&original_root, state.join("subjektiv")).unwrap(); + let restored = + StandaloneHost::restore_with_model_client(state, id, ScriptedClient::new(Vec::new())) + .await + .unwrap(); + assert_eq!(restored.record().subject.as_ref(), Some(&binding)); + assert_ne!( + restored.record().subject.as_ref().unwrap().subject_id, + other.id + ); + restored.shutdown().await.unwrap(); +} + +// A failing test never leaves its test-owned process running. This guard can +// only address the Child returned by spawn, never a parent or dogfood Worker. +struct OwnedChild(Child); +impl Drop for OwnedChild { + fn drop(&mut self) { + if self.0.try_wait().ok().flatten().is_none() { + let _ = self.0.kill(); + } + let _ = self.0.wait(); + } +} + +fn child_command(mode: &str, cwd: &Path, state: &Path, subject: &str, ready: &Path) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", "subject_process_child", "--nocapture"]) + .env("YOI_SUBJEKTIV_TEST_CHILD_MODE", mode) + .env("YOI_SUBJEKTIV_TEST_CWD", cwd) + .env("YOI_SUBJEKTIV_TEST_STATE", state) + .env("YOI_SUBJEKTIV_TEST_SUBJECT", subject) + .env("YOI_SUBJEKTIV_TEST_READY", ready) + .stdin(Stdio::piped()); + command +} + +fn wait_child(child: &mut OwnedChild) -> std::process::ExitStatus { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "test-owned child did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[tokio::test] +async fn subject_process_child() { + // The ordinary harness also discovers this subprocess entry point. Only a + // parent semantic test supplies the private fixture paths and mode. + let Ok(mode) = std::env::var("YOI_SUBJEKTIV_TEST_CHILD_MODE") else { + return; + }; + let cwd = PathBuf::from(std::env::var_os("YOI_SUBJEKTIV_TEST_CWD").unwrap()); + let state = PathBuf::from(std::env::var_os("YOI_SUBJEKTIV_TEST_STATE").unwrap()); + let subject = std::env::var("YOI_SUBJEKTIV_TEST_SUBJECT").unwrap(); + let result = StandaloneHost::start_with_model_client( + launch(&cwd, &state, &subject, "test-process"), + ScriptedClient::new(Vec::new()), + ) + .await; + match mode.as_str() { + "contender" => { + let error = result + .err() + .expect("another process owns the Subject kernel lease"); + assert!( + matches!(error, StandaloneStartupError::Subject(ref message) if message.contains("already active")), + "{error:?}" + ); + } + "abnormal-owner" => { + let host = result.expect("child owns Subject"); + let ready = PathBuf::from(std::env::var_os("YOI_SUBJEKTIV_TEST_READY").unwrap()); + let mut file = std::fs::File::create(&ready).unwrap(); + file.write_all(host.worker_id().to_string().as_bytes()) + .unwrap(); + file.sync_all().unwrap(); + let mut byte = [0]; + std::io::stdin().read_exact(&mut byte).unwrap(); + assert_eq!(byte, [b'x']); + // Deliberately omit Host shutdown and all Rust destructors. The + // parent requests this exit only after proving cross-process lock. + std::process::exit(86); + } + other => panic!("unknown child mode {other}"), + } +} + +#[tokio::test] +async fn kernel_subject_lease_excludes_other_processes_and_recovers_after_abnormal_exit() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let parent_cwd = temp.path().join("parent-project"); + let child_cwd = temp.path().join("child-project"); + std::fs::create_dir(&parent_cwd).unwrap(); + std::fs::create_dir(&child_cwd).unwrap(); + let catalog = StandaloneSubjects::open(&state).unwrap(); + let subject = catalog.create("singleton", None).unwrap(); + let independent = catalog.create("independent", None).unwrap(); + drop(catalog); + let ready = temp.path().join("ready"); + let parent = StandaloneHost::start_with_model_client( + launch(&parent_cwd, &state, &subject.id, "parent-owner"), + ScriptedClient::new(Vec::new()), + ) + .await + .unwrap(); + let mut contender = OwnedChild( + child_command("contender", &child_cwd, &state, &subject.id, &ready) + .spawn() + .unwrap(), + ); + assert!( + wait_child(&mut contender).success(), + "a different cwd must not evade the Subject lock" + ); + let independent_host = StandaloneHost::start_with_model_client( + launch(&child_cwd, &state, &independent.id, "independent-owner"), + ScriptedClient::new(Vec::new()), + ) + .await + .unwrap(); + independent_host.shutdown().await.unwrap(); + parent.shutdown().await.unwrap(); + + let mut owner = OwnedChild( + child_command("abnormal-owner", &child_cwd, &state, &subject.id, &ready) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(30); + let id = loop { + if ready.exists() { + let text = std::fs::read_to_string(&ready).unwrap(); + if let Ok(id) = text.parse::() { + break id; + } + } + assert!( + owner.0.try_wait().unwrap().is_none(), + "owner exited before ready" + ); + assert!(Instant::now() < deadline, "owner did not become ready"); + std::thread::sleep(Duration::from_millis(10)); + }; + let mut contender = OwnedChild( + child_command("contender", &parent_cwd, &state, &subject.id, &ready) + .spawn() + .unwrap(), + ); + assert!(wait_child(&mut contender).success()); + owner.0.stdin.as_mut().unwrap().write_all(b"x").unwrap(); + assert_eq!( + wait_child(&mut owner).code(), + Some(86), + "exercise process death, not graceful shutdown" + ); + let recovered = + StandaloneHost::restore_with_model_client(state, id, ScriptedClient::new(Vec::new())) + .await + .unwrap(); + assert_eq!(recovered.worker_id(), id); + assert_eq!( + recovered.record().subject.as_ref().unwrap().subject_id, + subject.id + ); + recovered.shutdown().await.unwrap(); +} + +fn remember_script(claim: &str) -> Vec> { + vec![ + tool( + "remember", + "SubjektivMemoryRemember", + json!({ + "kind":"lesson", "claim":claim, "why_useful":"Retain standalone evidence" + }), + ), + prose(), + ] +} + +#[tokio::test] +async fn automatic_generic_consolidation_applies_candidate_publishes_surface_and_follows_subject() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&state).unwrap(); + let subject = catalog.create("consolidation", None).unwrap(); + drop(catalog); + let claim = "Validate the immutable result before reporting success"; + let model = ScriptedClient::consolidating(remember_script(claim), false); + let host = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "consolidation-body"), + model.clone(), + ) + .await + .unwrap(); + let worker_id = host.worker_id(); + let results = run( + &host, + "Remember this lesson and let the Host consolidate its committed candidate.", + ) + .await; + assert_eq!(results["remember"]["status"], "pending_commit"); + let job_id = model.first_job().await; + let completed = tokio::time::timeout(Duration::from_secs(30), host.jobs().wait(&job_id)) + .await + .expect("Job cleanup completes") + .unwrap(); + assert_eq!( + completed.state, + standalone::jobs::JobState::Completed, + "{completed:?}" + ); + assert_eq!( + completed.request.profile, + "builtin:standalone-subjektiv-consolidation" + ); + assert_eq!(completed.request.purpose, "subjektiv_consolidation"); + assert!( + completed.domain_grant.is_some(), + "policy is not a domain capability" + ); + let ids = completed.request.input["candidate_ids"].as_array().unwrap(); + assert_eq!(ids.len(), 1, "Job must snapshot the committed candidate"); + let accepted = completed + .result + .clone() + .expect("structured result is durable success authority"); + assert_eq!(accepted["subject_id"], subject.id); + assert_eq!(accepted["candidate_ids"], json!(ids)); + assert_eq!( + accepted["candidate_dispositions"], + json!([{"candidate_id":ids[0], "action":"applied"}]) + ); + assert_eq!(accepted["surface"]["availability"], "ready"); + assert!(accepted["surface"]["generation_id"].is_string()); + assert!(accepted["surface"]["snapshot_id"].is_string()); + // The Subject consumer may already have acknowledged successful cleanup. + // Acknowledgement timing is not success authority; the immutable accepted + // result above remains durable regardless of that scheduling race. + let requests = model.requests(); + assert!(requests.iter().any(|r| { + r.tools + .iter() + .any(|tool| tool.name == "MemoryApplyCandidate") + })); + assert!(requests.iter().any(|r| { + r.tools + .iter() + .any(|tool| tool.name == "SubmitMemorySurface") + })); + assert!( + state + .join("subjektiv/jobs") + .join(&subject.id) + .join("jobs.sqlite3") + .exists() + ); + assert!( + !state + .join(worker_id.to_string()) + .join("jobs.sqlite3") + .exists(), + "selected Subject owns the shared generic Job ledger" + ); + host.shutdown().await.unwrap(); + let memory = inspect_store(&state, |store| { + assert!( + store + .pending_staging_candidates(&subject.id, 100) + .unwrap() + .is_empty() + ); + let memory = store.list_memories(&subject.id).unwrap().pop().unwrap(); + assert_eq!(memory.claim, claim); + assert_eq!(memory.revision, 1); + assert_eq!(memory.source_candidate_ids, vec![ids[0].as_str().unwrap()]); + let surface = store.resident_surface(&subject.id).unwrap(); + assert_eq!(surface.availability, subjektiv::SurfaceAvailability::Ready); + let snapshot = surface.snapshot.unwrap(); + assert_eq!( + snapshot.id, + accepted["surface"]["snapshot_id"].as_str().unwrap() + ); + assert!(snapshot.body_md.contains(claim)); + assert_eq!( + snapshot.memory_refs, + vec![subjektiv::MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 1 + }] + ); + assert_eq!( + snapshot.built_from_store_revision, + accepted["surface"]["store_revision"].as_u64().unwrap() + ); + memory + }); + let next_model = ScriptedClient::new(vec![ + tool("confirmed", "SubjektivMemoryQuery", json!({"query":claim})), + prose(), + ]); + let next = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "next-body"), + next_model.clone(), + ) + .await + .unwrap(); + assert_ne!(next.worker_id(), worker_id); + let retained = next.jobs().get(&job_id).unwrap(); + assert_eq!(retained.request, completed.request); + assert_eq!(retained.state, completed.state); + assert_eq!( + retained.result, completed.result, + "result follows the Subject, not the old Worker" + ); + let results = run(&next, "Recall confirmed Memory in the next Worker.").await; + assert_eq!(results["confirmed"]["items"].as_array().unwrap().len(), 1); + assert!(results["confirmed"].to_string().contains(&memory.id)); + assert!( + next_model.requests()[0] + .system_prompt + .as_deref() + .unwrap() + .contains(claim), + "fresh resident surface must be injected at new Worker creation" + ); + assert_eq!( + next.jobs().acknowledge(&job_id).unwrap().result, + Some(accepted) + ); + next.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn transport_loss_after_candidate_apply_preserves_memory_and_later_job_only_rebuilds_surface() +{ + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&state).unwrap(); + let subject = catalog.create("partial-apply", None).unwrap(); + drop(catalog); + let claim = "Candidate receipts survive transport failure"; + let losing = ScriptedClient::consolidating(remember_script(claim), true); + let host = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "losing-body"), + losing.clone(), + ) + .await + .unwrap(); + run( + &host, + "Stage a lesson whose Job loses its transport after applying it.", + ) + .await; + let first_id = losing.first_job().await; + let failed = tokio::time::timeout(Duration::from_secs(30), host.jobs().wait(&first_id)) + .await + .unwrap() + .unwrap(); + assert_eq!( + failed.state, + standalone::jobs::JobState::Failed, + "{failed:?}" + ); + assert!( + failed.result.is_none(), + "partial apply is not an accepted Job result" + ); + host.shutdown().await.unwrap(); + let original = inspect_store(&state, |store| { + assert!( + store + .pending_staging_candidates(&subject.id, 100) + .unwrap() + .is_empty() + ); + let memory = store.list_memories(&subject.id).unwrap(); + assert_eq!(memory.len(), 1); + assert_eq!(memory[0].claim, claim); + assert_eq!(memory[0].revision, 1); + assert_ne!( + store.resident_surface(&subject.id).unwrap().availability, + subjektiv::SurfaceAvailability::Ready + ); + serde_json::to_value(&memory[0]).unwrap() + }); + let rebuilding = ScriptedClient::consolidating(vec![prose()], false); + let next = StandaloneHost::start_with_model_client( + launch(temp.path(), &state, &subject.id, "rebuild-body"), + rebuilding.clone(), + ) + .await + .unwrap(); + assert_eq!(next.jobs().get(&first_id).unwrap(), failed); + run( + &next, + "A later explicit Host interaction rebuilds the missing surface.", + ) + .await; + let second_id = rebuilding.first_job().await; + assert_ne!( + first_id, second_id, + "do not silently replay the failed immutable Job" + ); + let completed = tokio::time::timeout(Duration::from_secs(30), next.jobs().wait(&second_id)) + .await + .unwrap() + .unwrap(); + assert_eq!( + completed.state, + standalone::jobs::JobState::Completed, + "{completed:?}" + ); + assert_eq!( + completed.request.input["candidate_ids"], + json!([]), + "resolved candidates cannot be applied again" + ); + assert_eq!( + completed.result.as_ref().unwrap()["surface"]["availability"], + "ready" + ); + assert!( + !rebuilding + .requests() + .iter() + .any(|request| request.items.iter().any( + |item| matches!(item, Item::ToolCall { name, .. } if name == "MemoryApplyCandidate") + )) + ); + assert_eq!( + next.jobs().get(&first_id).unwrap(), + failed, + "later success does not rewrite the original failed outcome" + ); + next.shutdown().await.unwrap(); + inspect_store(&state, |store| { + let memories = store.list_memories(&subject.id).unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!( + serde_json::to_value(&memories[0]).unwrap(), + original, + "confirmed Memory is immutable across surface repair" + ); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + subjektiv::SurfaceAvailability::Ready + ); + }); +} + +#[tokio::test] +async fn normal_extract_stages_committed_sources_and_consolidates_through_generic_job() { + let temp = tempfile::tempdir().unwrap(); + let state = temp.path().join("state"); + let catalog = StandaloneSubjects::open(&state).unwrap(); + let subject = catalog.create("extract", None).unwrap(); + drop(catalog); + let mut model = ScriptedClient::consolidating(vec![prose()], false); + model.extract = true; + let mut selected = launch(temp.path(), &state, &subject.id, "extract-body"); + selected + .profile + .manifest + .feature + .subjektiv + .profile + .extraction + .threshold = Some(1); + // An explicit model override selects a different provider by contract. + // Keep this mechanism test entirely on the injected scripted transport. + selected + .profile + .manifest + .feature + .subjektiv + .profile + .extraction + .model = None; + let host = StandaloneHost::start_with_model_client(selected, model.clone()) + .await + .unwrap(); + let session = host.record().active_session_id; + run(&host, "A useful lesson is worth retaining across Workers.").await; + let job_id = model.first_job().await; + let finished = tokio::time::timeout(Duration::from_secs(30), host.jobs().wait(&job_id)) + .await + .unwrap() + .unwrap(); + assert_eq!( + finished.state, + standalone::jobs::JobState::Completed, + "{finished:?}" + ); + assert_eq!( + finished.result.as_ref().unwrap()["candidate_dispositions"][0]["action"], + "applied" + ); + let worker = host.worker_id(); + host.shutdown().await.unwrap(); + inspect_store(&state, |store| { + let memory = store.list_memories(&subject.id).unwrap().pop().unwrap(); + assert_eq!( + memory.claim, + "Extract a committed lesson through the normal lifecycle" + ); + let candidate = store + .staging_candidate(&subject.id, &memory.source_candidate_ids[0]) + .unwrap() + .unwrap(); + assert_eq!( + candidate.source_refs[0].session_id, + Some(session.to_string()) + ); + assert!( + candidate + .evidence + .iter() + .all(|e| e.origin.as_ref().unwrap().kind + == memory::schema::EvidenceOriginKind::HumanInput), + "{candidate:#?}" + ); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + subjektiv::SurfaceAvailability::Ready + ); + }); + assert!( + model + .requests() + .iter() + .any(|r| r.tools.iter().any(|t| t.name == "StageMemoryCandidate")), + "extract must use the injected scripted transport" + ); + let log_root = state + .join(worker.to_string()) + .join("sessions") + .join(session.to_string()); + let text = std::fs::read_dir(log_root) + .unwrap() + .map(|entry| std::fs::read_to_string(entry.unwrap().path()).unwrap()) + .collect::(); + assert!( + text.contains("subjektiv.extract.v1"), + "normal extraction must durably advance the existing append-only pointer" + ); +} diff --git a/crates/subjektiv/Cargo.toml b/crates/subjektiv/Cargo.toml new file mode 100644 index 000000000..1b4fd76a8 --- /dev/null +++ b/crates/subjektiv/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "subjektiv" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +feature-storage = { path = "../feature-storage" } +chrono = { version = "0.4", default-features = false, features = ["clock"] } +memory.workspace = true +manifest.workspace = true +rusqlite.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +server-api.workspace = true +thiserror.workspace = true +uuid = { workspace = true, features = ["v7"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/subjektiv/README.md b/crates/subjektiv/README.md new file mode 100644 index 000000000..8f5394522 --- /dev/null +++ b/crates/subjektiv/README.md @@ -0,0 +1,76 @@ +# subjektiv + +Shared Subject-scoped Memory domain and model-facing operations. This crate has +no Workspace Server dependency. `server-api` is the existing transport-neutral +request/response contract crate, not an authentication or Server implementation. + +## Storage scope + +The trusted Host creates `feature_storage::FeatureStorage`, selects +`FeatureStorage::scope(scope_id)`, registers `REGISTRATION` (or uses +`SubjektivStore::register`), and opens `SubjektivStore::open`. A trusted managed +`FeatureDatabase` can also be opened with `SubjektivStore::from_database`. + +`scope_id` identifies the physical storage sharing boundary. Backend uses its +existing Workspace ID. Standalone uses a persisted random local storage-scope +identity under its operator-managed state directory, **not a fake Workspace**. +Subject IDs are independently issued random domain identities. An ID is a lookup +key, never an authorization credential. + +Existing SQL schema and migrations are retained. In particular, +`store_scope.workspace_id` still stores the physical storage scope, under its +historical column name. Existing Backend placement and snapshot manifest keys +are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue +to be checked against the physical scope; local provenance need not invent a +Workspace value. `workspace_id()` is a Backend compatibility alias for +`scope_id()`. + +## Host operation boundary + +`api::execute(store, &HostOperationContext, operation)` is the common dispatcher +used by Backend and local Hosts. It owns query/cursor rules, bounded body and +nested provenance projections, immutable revision reads, proposal validation, +explicit staging/receipt rules, resident context, candidate decisions and +surface generation/publication/failure projections. + +The Host context is not serializable or model input. Trusted Rust Hosts construct +it only after validating their external execution authority: + +- `HostOperationContext::validated_body` binds one Subject and optionally an + existing immutable `SubjectSessionAttribution`. Explicit staging requires the + request Session to match that attribution. +- `HostOperationContext::validated_consolidation` binds one Subject, an optional + immutable candidate batch, and an optional `JobAttemptBinding`. Attenuated Jobs + must supply `Some(batch)`, including an empty batch; `None` is legacy non-Job + Host consolidation authority. Body-only operations are unavailable to + consolidation contexts, and candidate reads/decisions cannot escape the batch. + +These constructors validate store/domain bindings, not external authentication. +Backend retains authenticated Runtime/Worker identity, current singleton and +live Job attempt checks, candidate grant checks, and public Session validation. +Local Hosts retain exclusive Subject execution leases, generic Job admission, +Worker/Session ownership, and committed public Session evidence validation. +Before explicit staging, the Host resolves evidence using its committed public +Session projection; the common API does not inspect Session files, hidden +reasoning, system prompts, or arbitrary paths. Lower-level repository/projection +functions are trusted Rust APIs, not model capabilities. + +## Common consolidation Job rules + +`job::bounded_candidate_batch(ids, max_result_bytes)` selects the exact T-704 +ordered-prefix budget (100 candidates maximum, 2,048-byte envelope, twice each +JSON-escaped ID size plus 128 bytes per disposition). +`job::consolidation_required(count, bytes, force, availability)` owns the shared +trigger thresholds and empty-ready/surface-retry rules. + +`job::validate_result(store, subject_id, batch, job_id, attempt_id, value)` checks +the strict result shape, exact admitted Subject/batch, durable dispositions, and +the persisted terminal surface outcome from that attempt. After validation, +`job::with_dispositions(store, subject_id, batch, value)` adds the deterministic +compact disposition projection. Hosts retain Job result binding/admission; these +helpers never run or replay a Job. The store's `prepare_job_surface_generation`, +`require_job_surface_generation`, and `validate_job_surface_outcome` are public +trusted-domain methods for both Backend and local Hosts. + +Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`, +`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`. diff --git a/crates/subjektiv/src/api.rs b/crates/subjektiv/src/api.rs new file mode 100644 index 000000000..cd6dc64b5 --- /dev/null +++ b/crates/subjektiv/src/api.rs @@ -0,0 +1,1316 @@ +//! Shared model-facing Memory operations and bounded projections. +//! Hosts retain authentication, execution leases, Job admission and committed +//! Session evidence validation; this module never derives ambient authority. + +mod context; +mod operation; +pub use context::{HostOperationContext, JobAttemptBinding}; +pub use operation::execute; + +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +pub const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20; +pub const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; +pub const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; +pub const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; +pub const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024; +pub const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = crate::MAX_STAGING_ANCHORS; +pub const SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE: usize = 2; +pub const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64; +pub const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivQueryCursor { + subject_id: String, + store_revision: u64, + query: Option, + kinds: Vec, + states: Vec, + offset: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivRevisionCursor { + subject_id: String, + memory_id: String, + max_revision: u64, + offset: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivEvidenceCursor { + subject_id: String, + memory_id: String, + revision: u64, + /// Candidate/derivation reference offset within the immutable revision. + offset: usize, + /// Nested offsets keep compatible historical candidates with more anchors + /// resumable without admitting new over-limit records. + #[serde(default)] + evidence_offset: usize, + #[serde(default)] + source_ref_offset: usize, +} + +pub fn resident_summary_output( + resident: crate::ResidentSurface, +) -> memory::backend::MemoryResidentSummaryOutput { + let (availability, content) = match resident.availability { + crate::SurfaceAvailability::Ready => { + let snapshot = resident.snapshot.expect("ready surface has a snapshot"); + ( + memory::backend::MemoryResidentSummaryAvailability::Ready, + (!snapshot.body_md.is_empty()).then_some(snapshot.body_md), + ) + } + crate::SurfaceAvailability::Ungenerated => ( + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + None, + ), + crate::SurfaceAvailability::Stale => ( + memory::backend::MemoryResidentSummaryAvailability::Stale, + None, + ), + crate::SurfaceAvailability::Failed => ( + memory::backend::MemoryResidentSummaryAvailability::Failed, + None, + ), + }; + memory::backend::MemoryResidentSummaryOutput { + availability, + content, + } +} + +pub fn subjektiv_surface_prepare_response( + generation: crate::SurfaceGeneration, +) -> server_api::SubjektivSurfacePrepareResponse { + server_api::SubjektivSurfacePrepareResponse { + generation_id: generation.id, + current_snapshot_id: None, + store_revision: generation.store_revision, + active_memory_count: generation.active_memory_count, + materials: generation + .materials + .into_iter() + .map(|material| server_api::SubjektivSurfaceMaterial { + memory_id: material.memory_id, + revision: material.revision, + kind: material.kind, + body_md: material.body_md, + why_useful: material.why_useful, + staleness: material.staleness, + }) + .collect(), + body_token_budget: crate::SURFACE_BODY_TOKEN_BUDGET, + input_token_budget: crate::SURFACE_INPUT_TOKEN_BUDGET, + per_kind_limit: crate::SURFACE_PER_KIND_LIMIT, + total_material_limit: crate::SURFACE_TOTAL_MATERIAL_LIMIT, + } +} + +pub fn subjektiv_candidate_summary( + candidate: crate::SubjectStagingRecord, +) -> server_api::SubjektivMemoryCandidateSummary { + server_api::SubjektivMemoryCandidateSummary { + candidate_id: candidate.id, + kind: candidate.kind, + claim: candidate.claim, + revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), + created_at: candidate.created_at, + } +} + +pub fn subjektiv_candidate( + candidate: crate::SubjectStagingRecord, +) -> server_api::SubjektivMemoryCandidate { + server_api::SubjektivMemoryCandidate { + candidate_id: candidate.id, + kind: candidate.kind, + claim: candidate.claim, + why_useful: candidate.why_useful, + staleness: candidate.staleness, + source: candidate.source, + evidence: candidate.evidence, + source_refs: candidate.source_refs, + revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), + created_at: candidate.created_at, + } +} + +pub fn subjektiv_proposal_metadata( + proposal: crate::RevisionProposal, +) -> server_api::SubjektivMemoryRevisionProposalMetadata { + server_api::SubjektivMemoryRevisionProposalMetadata { + memory_id: proposal.memory_id, + expected_revision: proposal.expected_revision, + intent: match proposal.intent { + crate::RevisionProposalIntent::Revise => { + server_api::SubjektivMemoryRevisionIntent::Revise + } + crate::RevisionProposalIntent::Resolve => { + server_api::SubjektivMemoryRevisionIntent::Resolve + } + crate::RevisionProposalIntent::Retract => { + server_api::SubjektivMemoryRevisionIntent::Retract + } + crate::RevisionProposalIntent::Reopen => { + server_api::SubjektivMemoryRevisionIntent::Reopen + } + }, + change_reason: proposal.change_reason, + } +} + +pub fn subjektiv_candidate_decision( + input: server_api::SubjektivMemoryCandidateDecisionRequest, +) -> OperationResult { + let decision = match input.decision { + server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => { + let target = match target { + server_api::SubjektivMemoryApplyTarget::Create => { + crate::MemoryRevisionTarget::Create + } + server_api::SubjektivMemoryApplyTarget::Revise { + memory_id, + expected_revision, + } => crate::MemoryRevisionTarget::Revise { + memory_id, + expected_revision, + }, + }; + crate::CandidateDecision::Apply { + target, + draft: crate::MemoryDraft { + kind: memory.kind, + state: domain_memory_state(memory.state), + claim: memory.claim, + body_md: memory.body_md, + why_useful: memory.why_useful, + staleness: memory.staleness, + source_candidate_ids: Vec::new(), + derived_from: memory + .derived_from + .into_iter() + .map(|reference| crate::MemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + change_reason: memory.change_reason, + }, + } + } + server_api::SubjektivMemoryCandidateDecision::Close { + action, + affected_memory, + } => crate::CandidateDecision::Close { + action: match action { + server_api::SubjektivMemoryCandidateCloseAction::Discarded => { + crate::StagingResolutionAction::Discarded + } + server_api::SubjektivMemoryCandidateCloseAction::Invalid => { + crate::StagingResolutionAction::Invalid + } + server_api::SubjektivMemoryCandidateCloseAction::Duplicate => { + crate::StagingResolutionAction::Duplicate + } + server_api::SubjektivMemoryCandidateCloseAction::AlreadyCovered => { + crate::StagingResolutionAction::AlreadyCovered + } + }, + affected_memory: affected_memory + .into_iter() + .map(|reference| crate::MemoryRevisionRef { + memory_id: reference.memory_id, + revision: reference.revision, + }) + .collect(), + }, + }; + Ok(crate::CandidateDecisionRequest { + request_id: input.request_id, + candidate_id: input.candidate_id, + reason: input.reason, + decision, + }) +} + +pub fn subjektiv_candidate_decision_response( + receipt: crate::CandidateDecisionReceipt, +) -> server_api::SubjektivMemoryCandidateDecisionResponse { + let operation = match receipt.operation { + Some(crate::MemoryDecisionOperation::Create) => { + server_api::SubjektivMemoryAffectedOperation::Create + } + Some(crate::MemoryDecisionOperation::Revise) => { + server_api::SubjektivMemoryAffectedOperation::Revise + } + Some(crate::MemoryDecisionOperation::Reference) | None => { + server_api::SubjektivMemoryAffectedOperation::Reference + } + }; + server_api::SubjektivMemoryCandidateDecisionResponse { + request_id: receipt.request_id, + candidate_id: receipt.candidate_id, + action: match receipt.resolution.action { + crate::StagingResolutionAction::Applied => { + server_api::SubjektivMemoryCandidateResolutionAction::Applied + } + crate::StagingResolutionAction::Discarded => { + server_api::SubjektivMemoryCandidateResolutionAction::Discarded + } + crate::StagingResolutionAction::Invalid => { + server_api::SubjektivMemoryCandidateResolutionAction::Invalid + } + crate::StagingResolutionAction::Duplicate => { + server_api::SubjektivMemoryCandidateResolutionAction::Duplicate + } + crate::StagingResolutionAction::AlreadyCovered => { + server_api::SubjektivMemoryCandidateResolutionAction::AlreadyCovered + } + }, + reason: receipt.resolution.reason, + affected_memory: receipt + .resolution + .affected_memory + .into_iter() + .map(|reference| server_api::SubjektivMemoryAffectedRef { + memory_id: reference.memory_id, + revision: reference.revision, + operation, + }) + .collect(), + memory: receipt + .memory + .map(|memory| server_api::SubjektivMemoryRevisionRef { + memory_id: memory.id, + revision: memory.revision, + }), + store_revision: receipt.store_revision, + surface_dirty: receipt.surface_dirty, + } +} + +pub fn domain_memory_state(state: server_api::SubjektivMemoryState) -> crate::MemoryState { + match state { + server_api::SubjektivMemoryState::Active => crate::MemoryState::Active, + server_api::SubjektivMemoryState::Resolved => crate::MemoryState::Resolved, + server_api::SubjektivMemoryState::Retracted => crate::MemoryState::Retracted, + } +} + +pub fn subjektiv_memory_query( + store: &crate::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryQueryRequest, +) -> OperationResult { + let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; + let query = input + .query + .map(|value| value.trim().to_lowercase()) + .filter(|value| !value.is_empty()); + let kinds = canonical_subjektiv_kinds(input.kinds)?; + let states = canonical_subjektiv_states(input.states)?; + let subject = store + .subject(subject_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| OperationError::SubjectNotFound(subject_id.to_string()))?; + let mut offset = 0; + if let Some(cursor) = input.cursor { + let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?; + if cursor.subject_id != subject_id + || cursor.query != query + || cursor.kinds != kinds + || cursor.states != states + { + return Err(OperationError::InvalidInput( + "subjektiv query cursor does not match subject or filters".into(), + ) + .into()); + } + if cursor.store_revision != subject.store_revision { + return Err(OperationError::Conflict( + "stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(), + ) + .into()); + } + offset = cursor.offset; + } + + let kind_filter = kinds.iter().cloned().collect::>(); + let state_filter = states.iter().cloned().collect::>(); + let records = store + .list_memories(subject_id) + .map_err(subjektiv_store_error)? + .into_iter() + .filter(|record| { + kind_filter.contains(record.kind.as_str()) + && state_filter.contains(subjektiv_memory_state_name(record.state)) + && query.as_ref().is_none_or(|query| { + record.claim.to_lowercase().contains(query) + || record.body_md.to_lowercase().contains(query) + }) + }) + .collect::>(); + if offset > records.len() { + return Err(OperationError::InvalidInput( + "subjektiv query cursor offset is invalid".into(), + ) + .into()); + } + let end = offset.saturating_add(limit).min(records.len()); + let has_more = end < records.len(); + let items = records[offset..end] + .iter() + .map(|record| server_api::SubjektivMemoryQueryItem { + id: record.id.clone(), + revision: record.revision, + kind: record.kind.clone(), + state: api_memory_state(record.state), + claim: record.claim.clone(), + excerpt: bounded_memory_excerpt(&record.body_md, 240), + updated_at: record.updated_at.clone(), + }) + .collect(); + let next_cursor = has_more + .then(|| { + encode_subjektiv_cursor( + "query", + &SubjektivQueryCursor { + subject_id: subject_id.to_string(), + store_revision: subject.store_revision, + query, + kinds, + states, + offset: end, + }, + ) + }) + .transpose()?; + Ok(server_api::SubjektivMemoryQueryResponse { + items, + next_cursor, + has_more, + }) +} + +pub fn subjektiv_memory_read( + store: &crate::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryReadRequest, +) -> OperationResult { + let current = store + .scoped_memory(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)?; + let record = match input.revision { + Some(0) => { + return Err( + OperationError::InvalidInput("revision must be a positive integer".into()).into(), + ); + } + Some(revision) => store + .scoped_memory_revision(subject_id, &input.memory_id, revision) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + OperationError::InvalidInput(format!( + "memory_revision_not_found: Memory `{}` has no revision {revision}", + input.memory_id + )) + })?, + None => current.clone(), + }; + let body_limit = input.limit.unwrap_or(SUBJEKTIV_BODY_DEFAULT_LINES); + if body_limit == 0 || body_limit > SUBJEKTIV_BODY_MAX_LINES { + return Err(OperationError::InvalidInput(format!( + "body line limit must be within 1..={SUBJEKTIV_BODY_MAX_LINES}" + )) + .into()); + } + let body_offset = input.offset.unwrap_or(0); + let body_byte_offset = input.byte_offset.unwrap_or(0); + if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() { + return Err(OperationError::InvalidInput( + "body continuation requires the exact revision returned by the first read".into(), + ) + .into()); + } + let lines = memory_body_lines(&record.body_md); + if body_offset > lines.len() { + return Err(OperationError::InvalidInput( + "body line offset exceeds document length".into(), + ) + .into()); + } + if body_offset == lines.len() && body_byte_offset > 0 { + return Err(OperationError::InvalidInput( + "body byte offset exceeds document length".into(), + ) + .into()); + } + if let Some(line) = lines.get(body_offset) + && (body_byte_offset > line.len() || !line.is_char_boundary(body_byte_offset)) + { + return Err(OperationError::InvalidInput( + "body byte offset must be a UTF-8 boundary within the selected line".into(), + ) + .into()); + } + let (body_md, body_next) = bounded_memory_body_page( + &lines, + body_offset, + body_byte_offset, + body_limit, + SUBJEKTIV_BODY_MAX_BYTES, + ); + let body_truncated = body_next.is_some(); + let (body_next_offset, body_next_byte_offset) = body_next + .map(|(line, byte)| (Some(line), Some(byte))) + .unwrap_or((None, None)); + + let (evidence_offset, nested_evidence_offset, nested_source_ref_offset) = + if let Some(cursor) = input.evidence_cursor { + let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; + if cursor.subject_id != subject_id + || cursor.memory_id != record.id + || cursor.revision != record.revision + { + return Err(OperationError::InvalidInput( + "subjektiv evidence cursor does not match subject, Memory, or revision".into(), + ) + .into()); + } + ( + cursor.offset, + cursor.evidence_offset, + cursor.source_ref_offset, + ) + } else { + (0, 0, 0) + }; + let total_evidence = record + .source_candidate_ids + .len() + .saturating_add(record.derived_from.len()); + if evidence_offset > total_evidence { + return Err(OperationError::InvalidInput( + "subjektiv evidence cursor offset is invalid".into(), + ) + .into()); + } + + let mut source_candidate_ids = Vec::new(); + let mut source_candidates = Vec::new(); + let mut derived_from = Vec::new(); + let mut next_evidence_offset = evidence_offset; + let mut next_nested_evidence_offset = 0; + let mut next_nested_source_ref_offset = 0; + if let Some(candidate_id) = record.source_candidate_ids.get(evidence_offset) { + let candidate = store + .staging_candidate(subject_id, candidate_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + OperationError::Store(format!( + "subjektiv candidate provenance `{candidate_id}` is missing" + )) + })?; + let evidence_total = candidate.evidence.len(); + let source_refs_total = candidate.source_refs.len(); + if nested_evidence_offset > evidence_total || nested_source_ref_offset > source_refs_total { + return Err(OperationError::InvalidInput( + "subjektiv nested evidence cursor offset is invalid".into(), + ) + .into()); + } + let evidence_end = nested_evidence_offset + .saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE) + .min(evidence_total); + let source_ref_end = nested_source_ref_offset + .saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE) + .min(source_refs_total); + source_candidate_ids.push(candidate_id.clone()); + source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate { + candidate_id: candidate.id, + evidence: candidate + .evidence + .into_iter() + .skip(nested_evidence_offset) + .take(evidence_end - nested_evidence_offset) + .map(bounded_subjektiv_staging_evidence) + .collect(), + evidence_total, + evidence_truncated: nested_evidence_offset > 0 || evidence_end < evidence_total, + source_refs: candidate + .source_refs + .into_iter() + .skip(nested_source_ref_offset) + .take(source_ref_end - nested_source_ref_offset) + .map(bounded_subjektiv_source_ref) + .collect(), + source_refs_total, + source_refs_truncated: nested_source_ref_offset > 0 + || source_ref_end < source_refs_total, + }); + if evidence_end < evidence_total || source_ref_end < source_refs_total { + next_nested_evidence_offset = evidence_end; + next_nested_source_ref_offset = source_ref_end; + } else { + next_evidence_offset += 1; + } + } else if evidence_offset < total_evidence { + if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { + return Err(OperationError::InvalidInput( + "subjektiv derivation cursor cannot contain nested offsets".into(), + ) + .into()); + } + let derived_index = evidence_offset - record.source_candidate_ids.len(); + let reference = &record.derived_from[derived_index]; + derived_from.push(server_api::SubjektivMemoryRevisionRef { + memory_id: reference.memory_id.clone(), + revision: reference.revision, + }); + next_evidence_offset += 1; + } else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { + return Err(OperationError::InvalidInput( + "subjektiv evidence cursor points beyond the final reference".into(), + ) + .into()); + } + bound_subjektiv_nested_evidence(&source_candidates)?; + let evidence_has_more = next_evidence_offset < total_evidence + || next_nested_evidence_offset != 0 + || next_nested_source_ref_offset != 0; + let evidence_next_cursor = evidence_has_more + .then(|| { + encode_subjektiv_cursor( + "evidence", + &SubjektivEvidenceCursor { + subject_id: subject_id.to_string(), + memory_id: record.id.clone(), + revision: record.revision, + offset: next_evidence_offset, + evidence_offset: next_nested_evidence_offset, + source_ref_offset: next_nested_source_ref_offset, + }, + ) + }) + .transpose()?; + + let mut response = server_api::SubjektivMemoryReadResponse { + memory_id: record.id, + revision: record.revision, + current_revision: current.revision, + kind: record.kind, + state: api_memory_state(record.state), + claim: record.claim, + body_md, + why_useful: record.why_useful, + staleness: record.staleness, + change_reason: record.change_reason, + created_at: record.created_at, + updated_at: record.updated_at, + body_offset, + body_byte_offset, + body_next_offset, + body_next_byte_offset, + body_truncated, + source_candidate_ids, + source_candidates, + derived_from, + evidence_next_cursor, + evidence_has_more, + }; + fit_subjektiv_read_body_to_output_budget( + &mut response, + &lines, + body_offset, + body_byte_offset, + body_limit, + )?; + Ok(response) +} + +pub fn subjektiv_memory_list_revisions( + store: &crate::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryListRevisionsRequest, +) -> OperationResult { + let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; + let current = store + .scoped_memory(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)?; + let (max_revision, offset) = if let Some(cursor) = input.cursor { + let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?; + if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id { + return Err(OperationError::InvalidInput( + "subjektiv revision cursor does not match subject or Memory".into(), + ) + .into()); + } + (cursor.max_revision, cursor.offset) + } else { + (current.revision, 0) + }; + let records = store + .list_memory_revisions(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)? + .into_iter() + .filter(|record| record.revision <= max_revision) + .collect::>(); + if offset > records.len() { + return Err(OperationError::InvalidInput( + "subjektiv revision cursor offset is invalid".into(), + ) + .into()); + } + let end = offset.saturating_add(limit).min(records.len()); + let has_more = end < records.len(); + let items = records[offset..end] + .iter() + .map(|record| server_api::SubjektivMemoryRevisionItem { + revision: record.revision, + kind: record.kind.clone(), + state: api_memory_state(record.state), + claim: record.claim.clone(), + change_reason: record.change_reason.clone(), + updated_at: record.updated_at.clone(), + }) + .collect(); + let next_cursor = has_more + .then(|| { + encode_subjektiv_cursor( + "revisions", + &SubjektivRevisionCursor { + subject_id: subject_id.to_string(), + memory_id: input.memory_id.clone(), + max_revision, + offset: end, + }, + ) + }) + .transpose()?; + Ok(server_api::SubjektivMemoryListRevisionsResponse { + memory_id: input.memory_id, + current_revision: current.revision, + items, + next_cursor, + has_more, + }) +} + +pub fn subjektiv_memory_validate_proposal( + store: &crate::SubjektivStore, + subject_id: &str, + input: server_api::SubjektivMemoryValidateProposalRequest, +) -> OperationResult { + if input.expected_revision == 0 { + return Err( + OperationError::InvalidInput("expected_revision must be positive".into()).into(), + ); + } + let current = store + .scoped_memory(subject_id, &input.memory_id) + .map_err(subjektiv_store_error)?; + if current.revision != input.expected_revision { + return Err(OperationError::Conflict(format!( + "revision_conflict: Memory `{}` expected {}, current {}", + input.memory_id, input.expected_revision, current.revision + )) + .into()); + } + validate_subjektiv_proposal_transition(current.state, input.intent)?; + Ok(server_api::SubjektivMemoryProposalValidationResponse { + memory_id: input.memory_id, + current_revision: current.revision, + kind: current.kind, + state: api_memory_state(current.state), + intent: input.intent, + }) +} + +pub fn validate_subjektiv_receipt_id(receipt_id: &str) -> OperationResult<()> { + let receipt_id = receipt_id.trim(); + if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) { + return Err( + OperationError::InvalidInput("explicit Memory receipt_id is invalid".into()).into(), + ); + } + Ok(()) +} + +pub fn subjektiv_memory_stage_explicit( + store: &crate::SubjektivStore, + mut input: server_api::SubjektivMemoryStageExplicitRequest, + attribution: crate::SubjectSessionAttribution, +) -> OperationResult { + validate_subjektiv_receipt_id(&input.receipt_id)?; + let receipt_id = input.receipt_id.trim(); + if input.evidence.is_empty() || input.source_refs.is_empty() { + return Err(OperationError::InvalidInput( + "explicit Memory staging requires host-resolved committed evidence".into(), + ) + .into()); + } + if input.evidence.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + || input.source_refs.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT + { + return Err(OperationError::InvalidInput(format!( + "explicit Memory evidence/source_refs are limited to {SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT} items each" + )) + .into()); + } + if matches!(input.kind, memory::extract::CandidateKind::Preference) + && input.evidence.iter().any(|evidence| { + !matches!( + evidence.origin.as_ref().map(|origin| &origin.kind), + Some(memory::schema::EvidenceOriginKind::HumanInput) + ) + }) + { + return Err(OperationError::InvalidInput( + "preference candidates require exclusively HumanInput evidence".into(), + ) + .into()); + } + let mut range_start = u64::MAX; + let mut range_end = 0; + let mut segment_id = None; + for source_ref in &mut input.source_refs { + if source_ref + .session_id + .as_deref() + .is_some_and(|value| value != attribution.session_id) + { + return Err(OperationError::InvalidInput( + "explicit candidate source belongs to a different Session".into(), + ) + .into()); + } + source_ref.session_id = Some(attribution.session_id.clone()); + let source_segment = source_ref.segment_id.as_deref().ok_or_else(|| { + OperationError::InvalidInput("explicit candidate source is missing segment_id".into()) + })?; + if segment_id + .as_deref() + .is_some_and(|value| value != source_segment) + { + return Err(OperationError::InvalidInput( + "explicit candidate evidence spans multiple Session segments".into(), + ) + .into()); + } + segment_id = Some(source_segment.to_string()); + let range = source_ref.entry_range.ok_or_else(|| { + OperationError::InvalidInput("explicit candidate source is missing entry_range".into()) + })?; + range_start = range_start.min(range[0]); + range_end = range_end.max(range[1]); + } + let source = memory::schema::SourceRef { + segment_id: segment_id.ok_or_else(|| { + OperationError::InvalidInput("explicit candidate source is missing segment_id".into()) + })?, + range: [range_start, range_end], + }; + let proposal = input + .proposal + .as_ref() + .map(|proposal| { + crate::RevisionProposal::new( + domain_proposal_intent(proposal.intent), + proposal.memory_id.clone(), + proposal.expected_revision, + proposal.change_reason.clone(), + ) + }) + .transpose() + .map_err(subjektiv_store_error)?; + if let Some(proposal) = &proposal { + let current = store + .scoped_memory(&attribution.subject_id, &proposal.memory_id) + .map_err(subjektiv_store_error)?; + if current.kind != input.kind { + return Err(OperationError::InvalidInput( + "revision proposal kind must be derived from its target Memory".into(), + ) + .into()); + } + } + let candidate = memory::extract::ExtractedCandidate { + kind: input.kind, + claim: input.claim, + why_useful: input.why_useful, + staleness: input.staleness, + evidence_ids: input.evidence.iter().map(|item| item.id.clone()).collect(), + }; + let staging = memory::extract::StagingRecord::from_candidate( + receipt_id, + format!("explicit:{receipt_id}"), + source, + candidate, + input.evidence, + input.source_refs, + ); + let mut staging = crate::SubjectStagingRecord::attach(&attribution.subject_id, staging); + if let Some(proposal) = proposal.clone() { + staging + .attach_revision_proposal(proposal) + .map_err(subjektiv_store_error)?; + } + let (staged, _) = store + .stage_candidate_with_attribution(staging, attribution) + .map_err(subjektiv_store_error)?; + Ok(server_api::SubjektivMemoryStageExplicitResponse { + candidate_id: staged.id, + receipt_id: receipt_id.to_string(), + status: server_api::SubjektivMemoryReceiptStatus::Staged, + target: proposal + .as_ref() + .map(|proposal| server_api::SubjektivMemoryProposalTarget { + memory_id: proposal.memory_id.clone(), + expected_revision: proposal.expected_revision, + }), + intent: proposal + .as_ref() + .map(|proposal| api_proposal_intent(proposal.intent)), + }) +} + +pub fn validate_subjektiv_proposal_transition( + state: crate::MemoryState, + intent: server_api::SubjektivMemoryRevisionIntent, +) -> OperationResult<()> { + use crate::MemoryState::{Active, Resolved}; + use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise}; + let valid = match intent { + Revise => matches!(state, Active | Resolved), + Resolve => state == Active, + Retract => matches!(state, Active | Resolved), + Reopen => state == Resolved, + }; + if valid { + Ok(()) + } else { + Err(OperationError::InvalidInput(format!( + "invalid_state_transition: {state:?} cannot accept {intent:?}" + )) + .into()) + } +} + +pub fn canonical_subjektiv_kinds( + kinds: Option>, +) -> OperationResult> { + let kinds = kinds.unwrap_or_else(|| { + use memory::extract::CandidateKind::*; + vec![ + Preference, + WorkingAssumption, + Constraint, + Decision, + OpenQuestion, + Lesson, + ] + }); + if kinds.is_empty() { + return Err(OperationError::InvalidInput("kinds must not be an empty array".into()).into()); + } + let mut names = kinds + .iter() + .map(|kind| kind.as_str().to_string()) + .collect::>(); + names.sort(); + names.dedup(); + Ok(names) +} + +pub fn canonical_subjektiv_states( + states: Option>, +) -> OperationResult> { + let states = states.unwrap_or_else(|| vec![server_api::SubjektivMemoryState::Active]); + if states.is_empty() { + return Err( + OperationError::InvalidInput("states must not be an empty array".into()).into(), + ); + } + let mut names = states + .into_iter() + .map(|state| match state { + server_api::SubjektivMemoryState::Active => "active".to_string(), + server_api::SubjektivMemoryState::Resolved => "resolved".to_string(), + server_api::SubjektivMemoryState::Retracted => "retracted".to_string(), + }) + .collect::>(); + names.sort(); + names.dedup(); + Ok(names) +} + +pub fn bounded_subjektiv_limit(limit: Option, default: usize) -> OperationResult { + let limit = limit.unwrap_or(default); + if limit == 0 || limit > SUBJEKTIV_QUERY_MAX_LIMIT { + return Err(OperationError::InvalidInput(format!( + "limit must be within 1..={SUBJEKTIV_QUERY_MAX_LIMIT}" + )) + .into()); + } + Ok(limit) +} + +pub fn api_memory_state(state: crate::MemoryState) -> server_api::SubjektivMemoryState { + match state { + crate::MemoryState::Active => server_api::SubjektivMemoryState::Active, + crate::MemoryState::Resolved => server_api::SubjektivMemoryState::Resolved, + crate::MemoryState::Retracted => server_api::SubjektivMemoryState::Retracted, + } +} + +pub fn subjektiv_memory_state_name(state: crate::MemoryState) -> &'static str { + match state { + crate::MemoryState::Active => "active", + crate::MemoryState::Resolved => "resolved", + crate::MemoryState::Retracted => "retracted", + } +} + +pub fn domain_proposal_intent( + intent: server_api::SubjektivMemoryRevisionIntent, +) -> crate::RevisionProposalIntent { + match intent { + server_api::SubjektivMemoryRevisionIntent::Revise => crate::RevisionProposalIntent::Revise, + server_api::SubjektivMemoryRevisionIntent::Resolve => { + crate::RevisionProposalIntent::Resolve + } + server_api::SubjektivMemoryRevisionIntent::Retract => { + crate::RevisionProposalIntent::Retract + } + server_api::SubjektivMemoryRevisionIntent::Reopen => crate::RevisionProposalIntent::Reopen, + } +} + +pub fn api_proposal_intent( + intent: crate::RevisionProposalIntent, +) -> server_api::SubjektivMemoryRevisionIntent { + match intent { + crate::RevisionProposalIntent::Revise => server_api::SubjektivMemoryRevisionIntent::Revise, + crate::RevisionProposalIntent::Resolve => { + server_api::SubjektivMemoryRevisionIntent::Resolve + } + crate::RevisionProposalIntent::Retract => { + server_api::SubjektivMemoryRevisionIntent::Retract + } + crate::RevisionProposalIntent::Reopen => server_api::SubjektivMemoryRevisionIntent::Reopen, + } +} + +pub fn fit_subjektiv_read_body_to_output_budget( + response: &mut server_api::SubjektivMemoryReadResponse, + lines: &[&str], + line_offset: usize, + byte_offset: usize, + line_limit: usize, +) -> OperationResult<()> { + let serialized_len = |response: &server_api::SubjektivMemoryReadResponse| { + serde_json::to_vec_pretty(response) + .map(|value| value.len()) + .map_err(|error| { + OperationError::Store(format!("encode subjektiv Memory read: {error}")) + }) + }; + if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { + return Ok(()); + } + + let initial_body_len = response.body_md.len(); + let mut lower = 0usize; + let mut upper = initial_body_len; + while lower < upper { + let candidate_limit = lower + (upper - lower).div_ceil(2); + set_subjektiv_read_body_page( + response, + lines, + line_offset, + byte_offset, + line_limit, + candidate_limit, + ); + if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { + lower = candidate_limit; + } else { + upper = candidate_limit - 1; + } + } + set_subjektiv_read_body_page(response, lines, line_offset, byte_offset, line_limit, lower); + let final_len = serialized_len(response)?; + if final_len > server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { + return Err(OperationError::Store(format!( + "subjektiv Memory metadata/provenance requires {final_len} bytes and exceeds the {} byte read-output budget", + server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES + )) + .into()); + } + if initial_body_len > 0 && response.body_md.is_empty() { + return Err(OperationError::Store( + "subjektiv Memory metadata/provenance leaves no room for resumable body content".into(), + ) + .into()); + } + Ok(()) +} + +pub fn set_subjektiv_read_body_page( + response: &mut server_api::SubjektivMemoryReadResponse, + lines: &[&str], + line_offset: usize, + byte_offset: usize, + line_limit: usize, + byte_limit: usize, +) { + let (body_md, body_next) = + bounded_memory_body_page(lines, line_offset, byte_offset, line_limit, byte_limit); + response.body_md = body_md; + response.body_truncated = body_next.is_some(); + (response.body_next_offset, response.body_next_byte_offset) = body_next + .map(|(line, byte)| (Some(line), Some(byte))) + .unwrap_or((None, None)); +} + +pub fn bounded_memory_body_page( + lines: &[&str], + line_offset: usize, + byte_offset: usize, + line_limit: usize, + byte_limit: usize, +) -> (String, Option<(usize, usize)>) { + let line_end = line_offset.saturating_add(line_limit).min(lines.len()); + let mut line_index = line_offset; + let mut within_line = byte_offset; + let mut body = String::new(); + while line_index < line_end { + let line = lines[line_index]; + let remainder = &line[within_line..]; + let available = byte_limit.saturating_sub(body.len()); + if remainder.len() <= available { + body.push_str(remainder); + line_index += 1; + within_line = 0; + continue; + } + let mut take = available.min(remainder.len()); + while take > 0 && !remainder.is_char_boundary(take) { + take -= 1; + } + if take == 0 { + return (body, Some((line_index, within_line))); + } + body.push_str(&remainder[..take]); + within_line += take; + return (body, Some((line_index, within_line))); + } + if line_index < lines.len() { + (body, Some((line_index, 0))) + } else { + (body, None) + } +} + +pub fn memory_body_lines(body: &str) -> Vec<&str> { + if body.is_empty() { + Vec::new() + } else { + body.split_inclusive('\n').collect() + } +} + +pub fn bound_subjektiv_nested_evidence( + candidates: &[server_api::SubjektivMemoryEvidenceCandidate], +) -> OperationResult<()> { + let serialized = serde_json::to_vec(candidates).map_err(|error| { + OperationError::Store(format!("encode subjektiv evidence page: {error}")) + })?; + if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES { + Ok(()) + } else { + Err(OperationError::Store( + "subjektiv evidence page exceeds its enforced response byte budget".into(), + ) + .into()) + } +} + +pub fn bounded_subjektiv_staging_evidence( + mut evidence: memory::extract::StagingEvidence, +) -> memory::extract::StagingEvidence { + evidence.kind = memory::schema::EvidenceKind::new(bounded_utf8_bytes( + evidence.kind.as_str().to_string(), + SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES, + )); + evidence.origin = evidence.origin.map(bounded_subjektiv_evidence_origin); + evidence.excerpt = evidence + .excerpt + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + evidence.summary = evidence + .summary + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + evidence +} + +pub fn bounded_subjektiv_source_ref( + mut source: memory::schema::SourceEvidenceRef, +) -> memory::schema::SourceEvidenceRef { + source.origin = source.origin.map(bounded_subjektiv_evidence_origin); + source.evidence_kind = source.evidence_kind.map(|kind| { + memory::schema::EvidenceKind::new(bounded_utf8_bytes( + kind.as_str().to_string(), + SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES, + )) + }); + source.label = source + .label + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + source.summary = source + .summary + .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + source +} + +pub fn bounded_subjektiv_evidence_origin( + mut origin: memory::schema::EvidenceOrigin, +) -> memory::schema::EvidenceOrigin { + for field in [ + &mut origin.account_id, + &mut origin.workspace_id, + &mut origin.runtime_id, + &mut origin.worker_id, + &mut origin.flow_selector, + &mut origin.flow_definition_id, + ] { + if let Some(value) = field.take() { + *field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); + } + } + origin +} + +pub fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String { + let value = value + .chars() + .map(|character| { + if character.is_control() || matches!(character, '"' | '\\') { + ' ' + } else { + character + } + }) + .collect::(); + if value.len() <= max_bytes { + return value; + } + let marker = "…"; + let mut end = max_bytes.saturating_sub(marker.len()); + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + format!("{}{marker}", &value[..end]) +} + +pub fn bounded_memory_excerpt(body: &str, max_chars: usize) -> String { + let normalized = body.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let excerpt = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{excerpt}…") + } else { + excerpt + } +} + +pub fn encode_subjektiv_cursor(kind: &str, value: &T) -> OperationResult { + let raw = serde_json::to_vec(value) + .map_err(|error| OperationError::Store(format!("encode subjektiv cursor: {error}")))?; + let encoded = raw + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ok(format!("subjektiv.{kind}.{encoded}")) +} + +pub fn decode_subjektiv_cursor( + kind: &str, + cursor: &str, +) -> OperationResult { + let prefix = format!("subjektiv.{kind}."); + let encoded = cursor.strip_prefix(&prefix).ok_or_else(|| { + OperationError::InvalidInput(format!("invalid subjektiv {kind} cursor type")) + })?; + if encoded.is_empty() + || encoded.len() > 16_384 + || encoded.len() % 2 != 0 + || !encoded.is_ascii() + || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err( + OperationError::InvalidInput(format!("invalid subjektiv {kind} cursor")).into(), + ); + } + let mut raw = Vec::with_capacity(encoded.len() / 2); + for index in (0..encoded.len()).step_by(2) { + raw.push( + u8::from_str_radix(&encoded[index..index + 2], 16).map_err(|_| { + OperationError::InvalidInput(format!("invalid subjektiv {kind} cursor encoding")) + })?, + ); + } + serde_json::from_slice(&raw).map_err(|_| { + OperationError::InvalidInput(format!("invalid subjektiv {kind} cursor payload")).into() + }) +} + +/// Transport-independent error classification. Backend maps these to its existing +/// HTTP errors; local Hosts preserve the same conflict/permission distinctions. +#[derive(Debug, thiserror::Error)] +pub enum OperationError { + #[error("{0}")] + InvalidInput(String), + #[error("{0}")] + Conflict(String), + #[error("{0}")] + PermissionDenied(String), + #[error("subject not found: {0}")] + SubjectNotFound(String), + #[error("{0}")] + Store(String), +} + +pub type OperationResult = std::result::Result; + +pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError { + match error { + crate::SubjektivError::SubjectScopeMismatch { .. } => { + OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) + } + crate::SubjektivError::RevisionConflict { .. } + | crate::SubjektivError::SubjectBehaviorConflict { .. } + | crate::SubjektivError::SurfaceGenerationConflict(_) => { + OperationError::Conflict(format!("revision_conflict: {error}")) + } + crate::SubjektivError::CandidateResolved(_) + | crate::SubjektivError::DecisionRequestConflict(_) => { + OperationError::Conflict(format!("candidate_decision_conflict: {error}")) + } + crate::SubjektivError::SubjectNotFound(id) => OperationError::SubjectNotFound(id), + crate::SubjektivError::MemoryNotFound(id) => { + OperationError::InvalidInput(format!("memory_not_found: Memory `{id}` was not found")) + } + crate::SubjektivError::Storage(_) => OperationError::Store(error.to_string()), + _ => OperationError::InvalidInput(error.to_string()), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/api/context.rs b/crates/subjektiv/src/api/context.rs new file mode 100644 index 000000000..6e1a95633 --- /dev/null +++ b/crates/subjektiv/src/api/context.rs @@ -0,0 +1,155 @@ +//! Trusted Host operation context, never deserialized from model input. +use super::{OperationError, OperationResult, subjektiv_store_error}; +use crate::{SubjectSessionAttribution, SubjektivStore}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobAttemptBinding { + pub job_id: String, + pub attempt_id: String, +} + +#[derive(Debug, Clone)] +enum Authority { + Body { + attribution: Option, + }, + Consolidation { + candidate_ids: Option>, + job: Option, + }, +} + +/// A capability issued by trusted Rust Host code after authentication/lease, +/// delegation, and committed Session evidence checks. Not a request DTO. +/// Constructors check domain binding, not external identity or execution leases. +/// Backend must retain its authenticated Worker and active Job grant checks; +/// standalone must hold the selected Subject lease and attenuate Job batches. +#[derive(Debug, Clone)] +pub struct HostOperationContext { + scope_id: String, + subject_id: String, + authority: Authority, +} + +impl HostOperationContext { + /// The Host has validated body execution authority. Attribution, when supplied, + /// must already be immutable history in this store. Before StageExplicit the + /// Host must resolve/validate all evidence against committed public Session + /// entries; neither this constructor nor the operation reads Session files. + pub fn validated_body( + store: &SubjektivStore, + subject_id: &str, + attribution: Option, + ) -> OperationResult { + Self::require_subject(store, subject_id)?; + if let Some(record) = &attribution { + if record.subject_id != subject_id + || store + .session_attribution(&record.session_id) + .map_err(subjektiv_store_error)? + .as_ref() + != Some(record) + { + return Err(OperationError::PermissionDenied( + "Session attribution does not match the Host subject".into(), + )); + } + } + Ok(Self { + scope_id: store.scope_id().into(), + subject_id: subject_id.into(), + authority: Authority::Body { attribution }, + }) + } + + /// The Host has validated consolidation authority and (for a Job) its active + /// attempt. `Some(candidate_ids)` is the immutable admitted batch; no candidate + /// outside it is accessible. `None` retains Backend's non-Job consolidation + /// authority, and must not be issued to an attenuated Job. + pub fn validated_consolidation( + store: &SubjektivStore, + subject_id: &str, + candidate_ids: Option>, + job: Option, + ) -> OperationResult { + Self::require_subject(store, subject_id)?; + if let Some(ids) = &candidate_ids { + for id in ids { + if store + .staging_candidate(subject_id, id) + .map_err(subjektiv_store_error)? + .is_none() + { + return Err(OperationError::InvalidInput(format!( + "candidate_not_found: {id}" + ))); + } + } + } + if job.as_ref().is_some_and(|binding| { + binding.job_id.trim().is_empty() || binding.attempt_id.trim().is_empty() + }) { + return Err(OperationError::InvalidInput( + "Job attempt binding must not be empty".into(), + )); + } + Ok(Self { + scope_id: store.scope_id().into(), + subject_id: subject_id.into(), + authority: Authority::Consolidation { candidate_ids, job }, + }) + } + + fn require_subject(store: &SubjektivStore, id: &str) -> OperationResult<()> { + store + .subject(id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| OperationError::SubjectNotFound(id.into()))?; + Ok(()) + } + + pub fn subject_id(&self) -> &str { + &self.subject_id + } + + pub(super) fn require_store(&self, store: &SubjektivStore) -> OperationResult<()> { + if self.scope_id != store.scope_id() { + return Err(OperationError::PermissionDenied( + "Host operation context belongs to another storage scope".into(), + )); + } + Ok(()) + } + + pub(super) fn body(&self) -> OperationResult> { + match &self.authority { + Authority::Body { attribution } => Ok(attribution.as_ref()), + _ => Err(OperationError::PermissionDenied( + "operation requires Subject body authority".into(), + )), + } + } + + pub(super) fn consolidation( + &self, + ) -> OperationResult<(Option<&[String]>, Option<&JobAttemptBinding>)> { + match &self.authority { + Authority::Consolidation { candidate_ids, job } => { + Ok((candidate_ids.as_deref(), job.as_ref())) + } + _ => Err(OperationError::PermissionDenied( + "operation requires consolidation authority".into(), + )), + } + } + + pub(super) fn candidate(&self, id: &str) -> OperationResult<()> { + let (ids, _) = self.consolidation()?; + if ids.is_some_and(|ids| !ids.iter().any(|allowed| allowed == id)) { + return Err(OperationError::PermissionDenied( + "candidate is outside the admitted Job batch".into(), + )); + } + Ok(()) + } +} diff --git a/crates/subjektiv/src/api/operation.rs b/crates/subjektiv/src/api/operation.rs new file mode 100644 index 000000000..253408ad7 --- /dev/null +++ b/crates/subjektiv/src/api/operation.rs @@ -0,0 +1,255 @@ +use super::*; +use server_api::{ + SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response, +}; + +/// Executes the same domain operations and bounded projections for every Host. +/// The caller authenticates and validates evidence before issuing `context`. +pub fn execute( + store: &crate::SubjektivStore, + context: &HostOperationContext, + operation: Op, +) -> OperationResult { + context.require_store(store)?; + let subject_id = context.subject_id(); + Ok(match operation { + Op::ResidentSummary(_) => { + context.body()?; + Response::ResidentSummary(resident_summary_output( + store + .resident_surface(subject_id) + .map_err(subjektiv_store_error)?, + )) + } + Op::ResidentContext(_) => { + context.body()?; + let resident = store + .resident_context(subject_id) + .map_err(subjektiv_store_error)?; + Response::ResidentContext(server_api::SubjektivResidentContextOutput { + behavior_md: resident.subject.behavior_md, + behavior_revision: resident.subject.behavior_revision, + memory_surface: resident_summary_output(resident.surface), + }) + } + Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?), + Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?), + Op::ListRevisions(input) => { + Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?) + } + Op::ValidateProposal(input) => { + context.body()?; + Response::ProposalValidated(subjektiv_memory_validate_proposal( + store, subject_id, input, + )?) + } + Op::ReceiptStatus(input) => { + context.body()?; + validate_subjektiv_receipt_id(&input.receipt_id)?; + let candidate = store + .staging_candidate(subject_id, &input.receipt_id) + .map_err(subjektiv_store_error)?; + Response::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusResponse { + receipt_id: input.receipt_id, + status: if candidate.is_some() { + server_api::SubjektivMemoryReceiptStatus::Staged + } else { + server_api::SubjektivMemoryReceiptStatus::Missing + }, + candidate_id: candidate.map(|record| record.id), + }) + } + Op::StageExplicit(input) => { + let attribution = context.body()?.ok_or_else(|| { + OperationError::PermissionDenied( + "explicit staging requires validated Session attribution".into(), + ) + })?; + if input.session_id != attribution.session_id { + return Err(OperationError::PermissionDenied( + "explicit staging Session does not match Host attribution".into(), + )); + } + Response::Staged(subjektiv_memory_stage_explicit( + store, + input, + attribution.clone(), + )?) + } + Op::ListCandidates(input) => { + let (ids, _) = context.consolidation()?; + let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; + let mut candidates = if let Some(ids) = ids { + let mut pending = Vec::new(); + for id in ids { + if store + .staging_resolution(subject_id, id) + .map_err(subjektiv_store_error)? + .is_none() + { + if let Some(record) = store + .staging_candidate(subject_id, id) + .map_err(subjektiv_store_error)? + { + pending.push(record); + } + } + } + pending + } else { + store + .pending_staging_candidates(subject_id, limit.saturating_add(1)) + .map_err(subjektiv_store_error)? + }; + let has_more = candidates.len() > limit; + candidates.truncate(limit); + Response::Candidates(server_api::SubjektivMemoryCandidateListResponse { + items: candidates + .into_iter() + .map(subjektiv_candidate_summary) + .collect(), + has_more, + }) + } + Op::ReadCandidate(input) => { + context.candidate(&input.candidate_id)?; + let candidate = store + .staging_candidate(subject_id, &input.candidate_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| { + OperationError::InvalidInput(format!( + "candidate_not_found: pending candidate `{}` was not found", + input.candidate_id + )) + })?; + if store + .staging_resolution(subject_id, &input.candidate_id) + .map_err(subjektiv_store_error)? + .is_some() + { + return Err(OperationError::Conflict(format!( + "candidate_resolved: candidate `{}` is already resolved", + input.candidate_id + ))); + } + Response::Candidate(subjektiv_candidate(candidate)) + } + Op::DecideCandidate(input) => { + context.candidate(&input.candidate_id)?; + let receipt = store + .decide_candidate(subject_id, subjektiv_candidate_decision(input)?) + .map_err(subjektiv_store_error)?; + Response::CandidateDecided(subjektiv_candidate_decision_response(receipt)) + } + Op::PrepareSurface(_) => { + let (ids, job) = context.consolidation()?; + if let Some(ids) = ids { + for id in ids { + if store + .staging_resolution(subject_id, id) + .map_err(subjektiv_store_error)? + .is_none() + { + return Err(OperationError::InvalidInput(format!( + "surface generation requires a durable disposition for batch candidate `{id}`" + ))); + } + } + } + let generation = store + .prepare_job_surface_generation( + subject_id, + job.map(|b| (b.job_id.as_str(), b.attempt_id.as_str())), + ) + .map_err(subjektiv_store_error)?; + let resident = store + .resident_surface(subject_id) + .map_err(subjektiv_store_error)?; + let mut response = subjektiv_surface_prepare_response(generation); + if resident.availability == crate::SurfaceAvailability::Ready { + response.current_snapshot_id = resident + .snapshot + .filter(|s| s.built_from_store_revision == response.store_revision) + .map(|s| s.id); + } + Response::SurfacePrepared(response) + } + Op::PublishSurface(input) => { + let (_, job) = context.consolidation()?; + if let Some(job) = job { + store + .require_job_surface_generation( + subject_id, + &input.generation_id, + &job.job_id, + &job.attempt_id, + ) + .map_err(subjektiv_store_error)?; + } + let points = input + .points + .into_iter() + .map(|point| crate::SurfacePoint { + body_md: point.body_md, + memory_refs: point + .memory_refs + .into_iter() + .map(|r| crate::MemoryRevisionRef { + memory_id: r.memory_id, + revision: r.revision, + }) + .collect(), + }) + .collect(); + let snapshot = store + .publish_surface_generation(subject_id, &input.generation_id, points) + .map_err(subjektiv_store_error)?; + Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse { + snapshot_id: snapshot.id, + built_from_store_revision: snapshot.built_from_store_revision, + empty: snapshot.body_md.is_empty(), + }) + } + Op::FailSurface(input) => { + let (_, job) = context.consolidation()?; + if let Some(job) = job { + store + .require_job_surface_generation( + subject_id, + &input.generation_id, + &job.job_id, + &job.attempt_id, + ) + .map_err(subjektiv_store_error)?; + } + let revision = store + .fail_surface_generation(subject_id, &input.generation_id, &input.reason_code) + .map_err(subjektiv_store_error)?; + let status = if let Some(job) = job { + store + .validate_job_surface_outcome( + subject_id, + &input.generation_id, + revision, + "failed", + None, + Some(&input.reason_code), + &job.job_id, + &job.attempt_id, + ) + .map_err(|e| { + OperationError::Conflict(format!( + "surface failure was not recorded for this attempt: {e}" + )) + })?; + "failed_confirmed" + } else { + "failed" + }; + Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse { + store_revision: revision, + status: status.into(), + }) + } + }) +} diff --git a/crates/subjektiv/src/api/tests.rs b/crates/subjektiv/src/api/tests.rs new file mode 100644 index 000000000..eab46e930 --- /dev/null +++ b/crates/subjektiv/src/api/tests.rs @@ -0,0 +1,343 @@ +use super::*; +use crate::{MemoryDraft, SubjectRole, SubjectSessionAttribution, SubjektivStore}; +use memory::extract::CandidateKind; +use server_api::{ + SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response, +}; + +fn open(root: &std::path::Path, scope: &str) -> (feature_storage::FeatureStorage, SubjektivStore) { + let manager = feature_storage::FeatureStorage::new(root); + let scope = manager.scope(scope).unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + (manager, store) +} + +fn query(cursor: Option) -> Op { + Op::Query(server_api::SubjektivMemoryQueryRequest { + query: None, + kinds: None, + states: None, + limit: Some(1), + cursor, + }) +} + +fn read(id: &str, revision: Option, offset: Option, byte_offset: Option) -> Op { + Op::Read(server_api::SubjektivMemoryReadRequest { + memory_id: id.into(), + revision, + offset, + byte_offset, + limit: Some(1), + evidence_cursor: None, + }) +} + +#[test] +fn neutral_scope_reopen_and_context_attenuation() { + let temp = tempfile::tempdir().unwrap(); + let random_scope = uuid::Uuid::now_v7().to_string(); + let (manager, store) = open(temp.path(), &random_scope); + assert_eq!(store.scope_id(), random_scope); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap(); + let consolidation = + HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None) + .unwrap(); + assert!( + execute( + &store, + &body, + Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None }) + ) + .is_err() + ); + assert!( + execute( + &store, + &consolidation, + Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {}) + ) + .is_err() + ); + let attribution = + SubjectSessionAttribution::new(&subject.id, "local-host", "worker", "session").unwrap(); + assert!( + HostOperationContext::validated_body(&store, &subject.id, Some(attribution.clone())) + .is_err() + ); + store + .record_session_attribution(attribution.clone()) + .unwrap(); + HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap(); + let (_, foreign) = open(&temp.path().join("foreign"), "foreign-scope"); + assert!(matches!( + execute(&foreign, &body, query(None)), + Err(OperationError::PermissionDenied(_)) + )); + manager.shutdown().unwrap(); + drop(store); + let (_manager, reopened) = open(temp.path(), &random_scope); + assert_eq!(reopened.subject(&subject.id).unwrap().unwrap(), subject); + assert!(reopened.session_attribution("session").unwrap().is_some()); +} + +#[test] +fn shared_query_cursor_and_utf8_fixed_revision_projection() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, store) = open(temp.path(), "local-random-scope"); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap(); + let original = "日本語\\\"".repeat(10_000); + let first = store + .create_memory( + &subject.id, + MemoryDraft::active( + CandidateKind::Lesson, + "first", + &original, + "reason", + "initial", + ), + ) + .unwrap(); + store + .create_memory( + &subject.id, + MemoryDraft::active( + CandidateKind::Decision, + "second", + "second body", + "reason", + "initial", + ), + ) + .unwrap(); + let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else { + panic!() + }; + assert!(page.has_more); + assert_eq!(page.items.len(), 1); + let cursor = page.next_cursor.unwrap(); + store + .create_memory( + &subject.id, + MemoryDraft::active( + CandidateKind::Constraint, + "third", + "third body", + "reason", + "initial", + ), + ) + .unwrap(); + assert!(matches!( + execute(&store, &context, query(Some(cursor))), + Err(OperationError::Conflict(_)) + )); + assert!(execute(&store, &context, read(&first.id, None, Some(1), None)).is_err()); + let mut offset = None; + let mut byte_offset = None; + let mut reconstructed = String::new(); + loop { + let Response::Read(page) = execute( + &store, + &context, + read(&first.id, Some(1), offset, byte_offset), + ) + .unwrap() else { + panic!() + }; + assert!( + serde_json::to_vec_pretty(&page).unwrap().len() + <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES + ); + reconstructed.push_str(&page.body_md); + if !page.body_truncated { + break; + } + assert!(!page.body_md.is_empty()); + offset = page.body_next_offset; + byte_offset = page.body_next_byte_offset; + } + assert_eq!(reconstructed, original); +} + +#[test] +fn shared_surface_empty_snapshot_is_ready_not_missing() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, store) = open(temp.path(), "local-random-scope"); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let consolidation = HostOperationContext::validated_consolidation( + &store, + &subject.id, + Some(vec![]), + Some(JobAttemptBinding { + job_id: "job".into(), + attempt_id: "attempt".into(), + }), + ) + .unwrap(); + let prepared: server_api::SubjektivSurfacePrepareRequest = serde_json::from_str("{}").unwrap(); + let Response::SurfacePrepared(prepared) = + execute(&store, &consolidation, Op::PrepareSurface(prepared)).unwrap() + else { + panic!() + }; + let Response::SurfacePublished(published) = execute( + &store, + &consolidation, + Op::PublishSurface(server_api::SubjektivSurfacePublishRequest { + generation_id: prepared.generation_id, + points: vec![], + }), + ) + .unwrap() else { + panic!() + }; + assert!(published.empty); + let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap(); + let Response::ResidentSummary(resident) = execute( + &store, + &body, + Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {}), + ) + .unwrap() else { + panic!() + }; + assert_eq!( + resident.availability, + memory::backend::MemoryResidentSummaryAvailability::Ready + ); + assert_eq!(resident.content, None); +} + +#[test] +fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, store) = open(temp.path(), "random-local-scope"); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let attribution = + SubjectSessionAttribution::new(&subject.id, "host", "worker", "session").unwrap(); + store + .record_session_attribution(attribution.clone()) + .unwrap(); + let context = + HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap(); + // Host-resolved public Session evidence with no invented Workspace identity. + let input = server_api::SubjektivMemoryStageExplicitRequest { + receipt_id: "receipt-1".into(), + session_id: "session".into(), + kind: CandidateKind::Lesson, + claim: "Preserve correction history".into(), + why_useful: "Allows auditing".into(), + staleness: None, + proposal: None, + evidence: vec![memory::extract::StagingEvidence { + id: "e1".into(), + entry_range: Some([1, 1]), + kind: memory::schema::EvidenceKind::new("model_output"), + origin: None, + excerpt: Some("Use immutable revisions".into()), + summary: None, + }], + source_refs: vec![memory::schema::SourceEvidenceRef { + session_id: Some("session".into()), + segment_id: Some("segment".into()), + entry_range: Some([1, 1]), + evidence_id: Some("e1".into()), + ..Default::default() + }], + }; + let mut foreign_session = input.clone(); + foreign_session.session_id = "foreign".into(); + assert!(matches!( + execute(&store, &context, Op::StageExplicit(foreign_session)), + Err(OperationError::PermissionDenied(_)) + )); + execute(&store, &context, Op::StageExplicit(input.clone())).unwrap(); + execute(&store, &context, Op::StageExplicit(input)).unwrap(); + let Response::ReceiptStatus(receipt) = execute( + &store, + &context, + Op::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusRequest { + receipt_id: "receipt-1".into(), + }), + ) + .unwrap() else { + panic!() + }; + assert_eq!( + receipt.status, + server_api::SubjektivMemoryReceiptStatus::Staged + ); + let candidate_id = receipt.candidate_id.unwrap(); + let no_batch = + HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None) + .unwrap(); + assert!(matches!( + execute( + &store, + &no_batch, + Op::ReadCandidate(server_api::SubjektivMemoryCandidateReadRequest { + candidate_id: candidate_id.clone() + }) + ), + Err(OperationError::PermissionDenied(_)) + )); + let batch = HostOperationContext::validated_consolidation( + &store, + &subject.id, + Some(vec![candidate_id.clone()]), + None, + ) + .unwrap(); + assert!( + execute( + &store, + &batch, + Op::PrepareSurface(serde_json::from_str("{}").unwrap()) + ) + .is_err() + ); + let decision = server_api::SubjektivMemoryCandidateDecisionRequest { + request_id: "decision-1".into(), + candidate_id, + reason: "adopt lesson".into(), + decision: server_api::SubjektivMemoryCandidateDecision::Apply { + target: server_api::SubjektivMemoryApplyTarget::Create, + memory: server_api::SubjektivMemoryDraft { + kind: CandidateKind::Lesson, + state: server_api::SubjektivMemoryState::Active, + claim: "Preserve correction history".into(), + body_md: "Use immutable revisions".into(), + why_useful: "Allows auditing".into(), + staleness: None, + derived_from: vec![], + change_reason: "adopt".into(), + }, + }, + }; + let first = execute(&store, &batch, Op::DecideCandidate(decision.clone())).unwrap(); + let retry = execute(&store, &batch, Op::DecideCandidate(decision)).unwrap(); + assert_eq!( + serde_json::to_value(first).unwrap(), + serde_json::to_value(retry).unwrap() + ); + assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1); + execute( + &store, + &batch, + Op::PrepareSurface(serde_json::from_str("{}").unwrap()), + ) + .unwrap(); +} diff --git a/crates/subjektiv/src/job.rs b/crates/subjektiv/src/job.rs new file mode 100644 index 000000000..6b65b48ab --- /dev/null +++ b/crates/subjektiv/src/job.rs @@ -0,0 +1,153 @@ +//! Common consolidation Job policy and verified result projections. +//! +//! Hosts authenticate/admit Jobs and bind their immutable batch and attempt. +//! These helpers do not dispatch, replay, or grant Job execution authority. +use serde::Deserialize; + +use crate::api::{OperationError, OperationResult}; +use crate::{SubjektivStore, SurfaceAvailability}; + +pub const CANDIDATE_BATCH_LIMIT: usize = 100; +pub const CONSOLIDATION_THRESHOLD_FILES: usize = 5; +pub const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000; + +/// Selects an ordered prefix using the T-704 complete-result budget. Reserve +/// 2,048 bytes for the Subject/Host surface envelope, then twice the JSON-escaped +/// ID size plus 128 bytes per candidate for its compact verified disposition. +pub fn bounded_candidate_batch( + ids: impl IntoIterator, + max_result_bytes: u32, +) -> OperationResult> { + let mut estimated_bytes = 2_048_usize; + let mut selected = Vec::new(); + for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) { + let id_bytes = serde_json::to_vec(&id) + .map_err(|e| OperationError::InvalidInput(e.to_string()))? + .len(); + let additional = 2 * id_bytes + 128; + if estimated_bytes + additional > max_result_bytes as usize { + break; + } + estimated_bytes += additional; + selected.push(id); + } + Ok(selected) +} + +/// Empty ready Subjects need no work, even when forced. Missing/stale/failed +/// surfaces bypass candidate thresholds; confirmed Memory is never discarded. +pub fn consolidation_required( + candidate_count: usize, + total_bytes: u64, + force: bool, + surface_availability: SurfaceAvailability, +) -> bool { + let surface_needs_generation = surface_availability != SurfaceAvailability::Ready; + if candidate_count == 0 { + return surface_needs_generation; + } + force + || surface_needs_generation + || candidate_count >= CONSOLIDATION_THRESHOLD_FILES + || total_bytes >= CONSOLIDATION_THRESHOLD_BYTES +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ConsolidationResult { + subject_id: String, + candidate_ids: Vec, + surface: SurfaceResult, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SurfaceResult { + availability: String, + generation_id: String, + store_revision: u64, + #[serde(default)] + snapshot_id: Option, + #[serde(default)] + reason_code: Option, +} + +/// Verifies the exact Subject/batch, every immutable disposition, and the actual +/// terminal surface outcome begun by this Job attempt. Model claims alone never +/// establish completion. Same admission semantics as the Backend T-704 adapter. +pub fn validate_result( + store: &SubjektivStore, + subject_id: &str, + batch: &[String], + job_id: &str, + attempt_id: &str, + value: &serde_json::Value, +) -> OperationResult<()> { + let result: ConsolidationResult = serde_json::from_value(value.clone()) + .map_err(|e| OperationError::InvalidInput(format!("invalid consolidation result: {e}")))?; + if result.subject_id != subject_id || result.candidate_ids != batch { + return Err(OperationError::InvalidInput( + "consolidation result does not match the immutable Subject/batch grant".into(), + )); + } + for candidate_id in batch { + if store + .staging_resolution(subject_id, candidate_id) + .map_err(|e| OperationError::Store(e.to_string()))? + .is_none() + { + return Err(OperationError::InvalidInput(format!( + "consolidation candidate `{candidate_id}` is unresolved" + ))); + } + } + store + .validate_job_surface_outcome( + subject_id, + &result.surface.generation_id, + result.surface.store_revision, + &result.surface.availability, + result.surface.snapshot_id.as_deref(), + result.surface.reason_code.as_deref(), + job_id, + attempt_id, + ) + .map_err(|e| { + OperationError::InvalidInput(format!("consolidation surface result mismatch: {e}")) + }) +} + +/// Enriches a previously verified result with compact durable dispositions. Full +/// reasons, affected revisions and provenance remain in the receipt store. This +/// deterministic projection neither applies candidates nor creates a new ledger. +pub fn with_dispositions( + store: &SubjektivStore, + subject_id: &str, + batch: &[String], + value: &serde_json::Value, +) -> OperationResult { + let mut dispositions = Vec::new(); + for id in batch { + let resolution = store + .staging_resolution(subject_id, id) + .map_err(|e| OperationError::Store(e.to_string()))? + .ok_or_else(|| { + OperationError::InvalidInput(format!("candidate `{id}` has no durable disposition")) + })?; + dispositions.push(serde_json::json!({"candidate_id": id, "action": resolution.action})); + } + let mut enriched = value.clone(); + enriched + .as_object_mut() + .ok_or_else(|| { + OperationError::InvalidInput("consolidation result must be an object".into()) + })? + .insert( + "candidate_dispositions".into(), + serde_json::Value::Array(dispositions), + ); + Ok(enriched) +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/job/tests.rs b/crates/subjektiv/src/job/tests.rs new file mode 100644 index 000000000..a2e447686 --- /dev/null +++ b/crates/subjektiv/src/job/tests.rs @@ -0,0 +1,183 @@ +use super::*; +use crate::{ + CandidateDecision, CandidateDecisionRequest, StagingResolutionAction, SubjectRole, + SubjectStagingRecord, +}; +use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord}; +use memory::schema::SourceRef; + +#[test] +fn escaped_ids_use_exact_t704_complete_result_budget() { + const MAX_RESULT_BYTES: u32 = 16 * 1024; + let ids: Vec<_> = (0..100) + .map(|i| format!("{i:03}{}", "\\\"".repeat(126))) + .collect(); + let batch = bounded_candidate_batch(ids.clone(), MAX_RESULT_BYTES).unwrap(); + assert!(!batch.is_empty()); + assert!(batch.len() < ids.len()); + assert_eq!(batch, ids[..batch.len()]); + let dispositions: Vec<_> = batch + .iter() + .map(|id| serde_json::json!({"candidate_id": id, "action": "already_covered"})) + .collect(); + let result = serde_json::json!({ + "subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions, + "surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) } + }); + assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize); + assert!( + bounded_candidate_batch(["one".into()], 2048) + .unwrap() + .is_empty() + ); + assert_eq!( + bounded_candidate_batch((0..200).map(|i| i.to_string()), u32::MAX) + .unwrap() + .len(), + CANDIDATE_BATCH_LIMIT + ); +} + +#[test] +fn trigger_policy_preserves_empty_ready_and_surface_retry_rules() { + for availability in [ + SurfaceAvailability::Ungenerated, + SurfaceAvailability::Stale, + SurfaceAvailability::Failed, + ] { + assert!(consolidation_required(0, 0, false, availability)); + assert!(consolidation_required(1, 1, false, availability)); + } + assert!(!consolidation_required( + 0, + 0, + true, + SurfaceAvailability::Ready + )); + assert!(!consolidation_required( + 4, + 49_999, + false, + SurfaceAvailability::Ready + )); + assert!(consolidation_required( + 5, + 0, + false, + SurfaceAvailability::Ready + )); + assert!(consolidation_required( + 1, + 50_000, + false, + SurfaceAvailability::Ready + )); + assert!(consolidation_required( + 1, + 1, + true, + SurfaceAvailability::Ready + )); +} + +#[test] +fn result_checks_durable_dispositions_and_exact_job_surface_outcome() { + let temp = tempfile::tempdir().unwrap(); + let manager = feature_storage::FeatureStorage::new(temp.path()); + let scope = manager.scope("local-random-scope").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + let subject = store + .create_subject(SubjectRole::new("companion").unwrap()) + .unwrap(); + let candidate = SubjectStagingRecord::attach( + &subject.id, + StagingRecord::from_candidate( + "candidate", + "dedup", + SourceRef { + segment_id: "segment".into(), + range: [1, 1], + }, + ExtractedCandidate { + kind: CandidateKind::Lesson, + claim: "claim".into(), + why_useful: "reason".into(), + staleness: None, + evidence_ids: vec![], + }, + vec![], + vec![], + ), + ); + store.stage_candidate(candidate).unwrap(); + let batch = vec!["candidate".to_string()]; + let generation = store + .prepare_job_surface_generation(&subject.id, Some(("job", "attempt"))) + .unwrap(); + let snapshot = store + .publish_surface_generation(&subject.id, &generation.id, vec![]) + .unwrap(); + let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, + "surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}}); + assert!( + validate_result(&store, &subject.id, &batch, "job", "attempt", &ready) + .unwrap_err() + .to_string() + .contains("unresolved") + ); + assert!(with_dispositions(&store, &subject.id, &batch, &ready).is_err()); + store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision".into(), + candidate_id: "candidate".into(), + reason: "invalid".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::Invalid, + affected_memory: vec![], + }, + }, + ) + .unwrap(); + validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).unwrap(); + let enriched = with_dispositions(&store, &subject.id, &batch, &ready).unwrap(); + assert_eq!( + enriched["candidate_dispositions"], + serde_json::json!([{"candidate_id": "candidate", "action": "invalid"}]) + ); + assert_eq!( + enriched, + with_dispositions(&store, &subject.id, &batch, &ready).unwrap() + ); + assert!(validate_result(&store, &subject.id, &batch, "job", "other-attempt", &ready).is_err()); + assert!(validate_result(&store, &subject.id, &[], "job", "attempt", &ready).is_err()); + // A current ready snapshot survives a redundant editor failure. Advance + // Memory first so this attempt genuinely needs a new surface and records failure. + store + .create_memory( + &subject.id, + crate::MemoryDraft::active( + CandidateKind::Lesson, + "new lesson", + "new body", + "reason", + "change", + ), + ) + .unwrap(); + let failure = store + .prepare_job_surface_generation(&subject.id, Some(("job", "attempt"))) + .unwrap(); + let revision = store + .fail_surface_generation(&subject.id, &failure.id, "model_failure") + .unwrap(); + let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch, + "surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}}); + validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap(); + assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err()); + let mut false_reason = failed; + false_reason["surface"]["reason_code"] = serde_json::json!("invented"); + assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &false_reason).is_err()); +} diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs new file mode 100644 index 000000000..9865a770d --- /dev/null +++ b/crates/subjektiv/src/lib.rs @@ -0,0 +1,6182 @@ +//! Subject-scoped, revisioned Memory storage for the `subjektiv` Feature. +//! +//! This module owns subjektiv's domain schema and typed repository. Physical +//! SQLite placement, connection configuration, migration execution, backup, +//! restore, and teardown remain owned by [`feature_storage`]. A store is +//! already bound to one Host storage scope; a subject identifier is therefore a lookup +//! key, not an authorization credential. + +pub mod api; +pub mod job; + +use std::collections::HashSet; + +use chrono::{SecondsFormat, Utc}; +use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord}; +use memory::schema::{SourceEvidenceRef, SourceRef}; +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use serde::{Deserialize, Deserializer, Serialize}; +use uuid::Uuid; + +use feature_storage::{ + FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature, + ScopedFeatureStorage, +}; + +pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; +pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; +pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; +pub const MAX_STAGING_ANCHORS: usize = 10; +/// Maximum UTF-8 size of the user-managed Subject behavior document. +pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES; +/// Deterministic surface-generation policy. Input estimates use the repository's +/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material +/// payload is capped below the whole-input budget to reserve room for the fixed +/// prompt and JSON framing. +pub const SURFACE_BODY_TOKEN_BUDGET: usize = 1_024; +pub const SURFACE_INPUT_TOKEN_BUDGET: usize = 12_000; +pub const SURFACE_MATERIAL_TOKEN_BUDGET: usize = 10_000; +pub const SURFACE_PER_KIND_LIMIT: usize = 8; +pub const SURFACE_TOTAL_MATERIAL_LIMIT: usize = 24; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum SubjektivError { + #[error(transparent)] + Storage(#[from] FeatureStorageError), + #[error("invalid subjektiv record: {0}")] + InvalidRecord(String), + #[error("subject `{0}` was not found in this Workspace")] + SubjectNotFound(String), + #[error("subject `{0}` is retired")] + SubjectRetired(String), + #[error("the selected Worker Profile does not enable subjektiv")] + ProfileDisabled, + #[error("staging candidate `{0}` was not found for this subject")] + CandidateNotFound(String), + #[error("staging candidate `{0}` already exists with different content")] + CandidateConflict(String), + #[error("staging candidate `{0}` is already resolved")] + CandidateResolved(String), + #[error("candidate decision request `{0}` was already used with different input")] + DecisionRequestConflict(String), + #[error( + "session `{session_id}` is already attributed to subject `{existing_subject_id}` and cannot be attributed to `{requested_subject_id}`" + )] + SessionSubjectConflict { + session_id: String, + existing_subject_id: String, + requested_subject_id: String, + }, + #[error("session `{0}` already has different historical attribution")] + SessionAttributionConflict(String), + #[error("memory `{0}` was not found for this subject")] + MemoryNotFound(String), + #[error("memory `{memory_id}` revision conflict: expected {expected}, current {actual}")] + RevisionConflict { + memory_id: String, + expected: u64, + actual: u64, + }, + #[error( + "subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}" + )] + SubjectBehaviorConflict { + subject_id: String, + expected: u64, + actual: u64, + }, + #[error("surface generation conflict: {0}")] + SurfaceGenerationConflict(String), + #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] + InvalidStateTransition { + memory_id: String, + from: MemoryState, + to: MemoryState, + }, + #[error("reference `{reference}` does not belong to subject `{subject_id}`")] + SubjectScopeMismatch { + subject_id: String, + reference: String, + }, + #[error("subjektiv JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +impl From for SubjektivError { + fn from(error: rusqlite::Error) -> Self { + Self::Storage(FeatureStorageError::from(error)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct SubjectRole(String); + +impl SubjectRole { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_label("subject role", &value)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for SubjectRole { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + SubjectRole::new(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubjectState { + Active, + Retired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectRecord { + pub schema_version: u32, + pub id: String, + pub role: SubjectRole, + /// User-managed, host-injected behavior document. This is not Memory and + /// Memory lifecycle operations must preserve it byte-for-byte. + #[serde(default)] + pub behavior_md: String, + /// Monotonic CAS generation for behavior updates, independent of Memory's + /// `store_revision`. + #[serde(default)] + pub behavior_revision: u64, + pub state: SubjectState, + /// Monotonic generation incremented exactly once for every committed Memory + /// creation or revision for this subject. + pub store_revision: u64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectPage { + pub items: Vec, + pub has_more: bool, +} + +/// Backend-authorized launch material for one active subject. The singleton key +/// is derived by subjektiv and then acquired through Yoi's generic singleton path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedSubjectWorkerAttachment { + pub singleton_key: String, +} + +pub fn subject_worker_singleton_key(subject_id: &str) -> Result { + validate_label("subject id", subject_id)?; + if subject_id.trim() != subject_id { + return Err(SubjektivError::InvalidRecord( + "subject id must not contain surrounding whitespace".to_string(), + )); + } + Ok(format!("{SUBJECT_WORKER_SINGLETON_PREFIX}{subject_id}")) +} + +/// Immutable host-recorded history that attributes one durable Session to the +/// subject and Worker that produced it. This is archival provenance only: it is +/// deliberately not a current-Worker link or execution ownership record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectSessionAttribution { + pub schema_version: u32, + pub subject_id: String, + pub runtime_id: String, + pub worker_id: String, + pub session_id: String, + pub attributed_at: String, +} + +impl SubjectSessionAttribution { + pub fn new( + subject_id: impl Into, + runtime_id: impl Into, + worker_id: impl Into, + session_id: impl Into, + ) -> Result { + let record = Self { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + subject_id: subject_id.into(), + runtime_id: runtime_id.into(), + worker_id: worker_id.into(), + session_id: session_id.into(), + attributed_at: now(), + }; + validate_session_attribution(&record)?; + Ok(record) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectSessionAttributionPage { + pub items: Vec, + pub has_more: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RevisionProposalIntent { + Revise, + Resolve, + Retract, + Reopen, +} + +/// Host-owned metadata describing how a staged candidate is expected to change +/// one existing Memory. It is validated against the current scoped Memory when +/// the candidate is staged; it is not model-authored and does not itself apply +/// the revision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RevisionProposal { + pub intent: RevisionProposalIntent, + pub memory_id: String, + pub expected_revision: u64, + pub change_reason: String, +} + +impl RevisionProposal { + pub fn new( + intent: RevisionProposalIntent, + memory_id: impl Into, + expected_revision: u64, + change_reason: impl Into, + ) -> Result { + let proposal = Self { + intent, + memory_id: memory_id.into(), + expected_revision, + change_reason: change_reason.into(), + }; + validate_revision_proposal_metadata(&proposal)?; + Ok(proposal) + } +} + +impl<'de> Deserialize<'de> for RevisionProposal { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct WireProposal { + intent: RevisionProposalIntent, + memory_id: String, + expected_revision: u64, + change_reason: String, + } + + let proposal = WireProposal::deserialize(deserializer)?; + RevisionProposal::new( + proposal.intent, + proposal.memory_id, + proposal.expected_revision, + proposal.change_reason, + ) + .map_err(serde::de::Error::custom) + } +} + +/// The existing extraction schema with only host-owned subject scope, +/// persistence time, and optional revision proposal metadata added. Model input +/// still contains none of those fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SubjectStagingRecord { + pub schema_version: u32, + pub id: String, + pub subject_id: String, + pub extract_run_id: String, + pub source: SourceRef, + pub kind: CandidateKind, + pub claim: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision_proposal: Option, + pub created_at: String, +} + +impl SubjectStagingRecord { + /// Attaches host-resolved subject scope and time to an existing v2 staging + /// record without changing its taxonomy, evidence, or source anchors. + pub fn attach(subject_id: impl Into, record: StagingRecord) -> Self { + Self::attach_at(subject_id, record, now()) + } + + fn attach_at(subject_id: impl Into, record: StagingRecord, created_at: String) -> Self { + Self { + schema_version: record.schema_version, + id: record.id, + subject_id: subject_id.into(), + extract_run_id: record.extract_run_id, + source: record.source, + kind: record.kind, + claim: record.claim, + why_useful: record.why_useful, + staleness: record.staleness, + evidence: record.evidence, + source_refs: record.source_refs, + revision_proposal: None, + created_at, + } + } + + /// Attaches host-owned revision proposal metadata to this candidate. + pub fn with_revision_proposal(mut self, proposal: RevisionProposal) -> Result { + validate_revision_proposal_metadata(&proposal)?; + self.revision_proposal = Some(proposal); + Ok(self) + } + + /// Mutably attaches host-owned revision proposal metadata to this candidate. + pub fn attach_revision_proposal(&mut self, proposal: RevisionProposal) -> Result<()> { + validate_revision_proposal_metadata(&proposal)?; + self.revision_proposal = Some(proposal); + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryState { + /// Current and eligible for ordinary recall. This does not assert truth and + /// grants no authority. + Active, + /// No longer current because its question, condition, or applicability has + /// ended. A later revision may reopen it when circumstances change. + Resolved, + /// Invalidated and no longer eligible for recall. Retraction is terminal; + /// corrected follow-up experience is a separate Memory linked by derivation. + Retracted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryRevisionRef { + pub memory_id: String, + pub revision: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryRecord { + pub schema_version: u32, + pub id: String, + pub subject_id: String, + pub revision: u64, + pub kind: CandidateKind, + pub state: MemoryState, + pub claim: String, + pub body_md: String, + pub why_useful: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staleness: Option, + #[serde(default)] + pub source_candidate_ids: Vec, + #[serde(default)] + pub derived_from: Vec, + pub change_reason: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryDraft { + pub kind: CandidateKind, + pub state: MemoryState, + pub claim: String, + pub body_md: String, + pub why_useful: String, + pub staleness: Option, + pub source_candidate_ids: Vec, + pub derived_from: Vec, + pub change_reason: String, +} + +impl MemoryDraft { + pub fn active( + kind: CandidateKind, + claim: impl Into, + body_md: impl Into, + why_useful: impl Into, + change_reason: impl Into, + ) -> Self { + Self { + kind, + state: MemoryState::Active, + claim: claim.into(), + body_md: body_md.into(), + why_useful: why_useful.into(), + staleness: None, + source_candidate_ids: Vec::new(), + derived_from: Vec::new(), + change_reason: change_reason.into(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StagingResolutionAction { + Applied, + Discarded, + Invalid, + Duplicate, + AlreadyCovered, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StagingResolution { + pub schema_version: u32, + pub id: String, + pub subject_id: String, + pub candidate_id: String, + pub action: StagingResolutionAction, + pub reason: String, + #[serde(default)] + pub affected_memory: Vec, + /// Immutable copy of the candidate as it was staged. The database also + /// retains the exact serialized bytes independently of this projection. + pub candidate: SubjectStagingRecord, + pub resolved_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceSnapshot { + pub schema_version: u32, + pub id: String, + pub subject_id: String, + pub body_md: String, + #[serde(default)] + pub memory_refs: Vec, + pub built_from_store_revision: u64, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceMaterial { + pub memory_id: String, + pub revision: u64, + pub kind: CandidateKind, + pub body_md: String, + pub why_useful: String, + pub staleness: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceGeneration { + pub id: String, + pub subject_id: String, + pub store_revision: u64, + pub active_memory_count: usize, + pub materials: Vec, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfacePoint { + pub body_md: String, + pub memory_refs: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SurfaceAvailability { + Ungenerated, + Stale, + Failed, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResidentSurface { + pub availability: SurfaceAvailability, + pub snapshot: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectResidentContext { + pub subject: SubjectRecord, + pub surface: ResidentSurface, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MemoryRevisionTarget { + Create, + Revise { + memory_id: String, + expected_revision: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryDecisionOperation { + Create, + Revise, + Reference, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CandidateDecision { + Apply { + target: MemoryRevisionTarget, + draft: MemoryDraft, + }, + Close { + action: StagingResolutionAction, + #[serde(default)] + affected_memory: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateDecisionRequest { + pub request_id: String, + pub candidate_id: String, + pub reason: String, + pub decision: CandidateDecision, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateDecisionReceipt { + pub request_id: String, + pub candidate_id: String, + pub resolution: StagingResolution, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub operation: Option, + pub store_revision: u64, + pub surface_dirty: bool, +} + +fn create_schema(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE store_scope ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + workspace_id TEXT NOT NULL UNIQUE +); + +CREATE TABLE subjects ( + subject_id TEXT PRIMARY KEY, + role TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'retired')), + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE staging_records ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_records ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + current_revision INTEGER NOT NULL CHECK (current_revision > 0), + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, current_revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) + DEFERRABLE INITIALLY DEFERRED +); + +CREATE TABLE memory_revisions ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL CHECK (revision > 0), + kind TEXT NOT NULL CHECK ( + kind IN ( + 'preference', 'working_assumption', 'constraint', + 'decision', 'open_question', 'lesson' + ) + ), + state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), + record_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id, revision), + FOREIGN KEY (subject_id, memory_id) + REFERENCES memory_records(subject_id, memory_id) + ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED +); + +CREATE TABLE memory_revision_candidates ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + candidate_id TEXT NOT NULL, + PRIMARY KEY (subject_id, memory_id, revision, candidate_id), + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE memory_revision_derivations ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + source_memory_id TEXT NOT NULL, + source_revision INTEGER NOT NULL, + PRIMARY KEY ( + subject_id, memory_id, revision, source_memory_id, source_revision + ), + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, source_memory_id, source_revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolutions ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + resolution_id TEXT NOT NULL, + action TEXT NOT NULL CHECK ( + action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered') + ), + reason TEXT NOT NULL, + affected_refs_json TEXT NOT NULL, + staging_raw_json TEXT NOT NULL, + resolution_json TEXT NOT NULL, + resolved_at TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + UNIQUE (resolution_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolution_targets ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (subject_id, candidate_id, memory_id, revision), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshots ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + built_from_store_revision INTEGER NOT NULL CHECK (built_from_store_revision >= 0), + snapshot_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, snapshot_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshot_refs ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (subject_id, snapshot_id, memory_id), + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE memory_revision_seals ( + subject_id TEXT NOT NULL, + memory_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (subject_id, memory_id, revision), + FOREIGN KEY (subject_id, memory_id, revision) + REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT +); + +CREATE TABLE staging_resolution_seals ( + subject_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + PRIMARY KEY (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TABLE surface_snapshot_seals ( + subject_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + PRIMARY KEY (subject_id, snapshot_id), + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + +CREATE TRIGGER memory_revision_candidates_no_late_insert +BEFORE INSERT ON memory_revision_candidates +WHEN EXISTS ( + SELECT 1 FROM memory_revision_seals + WHERE subject_id = NEW.subject_id + AND memory_id = NEW.memory_id + AND revision = NEW.revision +) BEGIN + SELECT RAISE(ABORT, 'subjektiv revision evidence is sealed'); +END; +CREATE TRIGGER memory_revision_derivations_no_late_insert +BEFORE INSERT ON memory_revision_derivations +WHEN EXISTS ( + SELECT 1 FROM memory_revision_seals + WHERE subject_id = NEW.subject_id + AND memory_id = NEW.memory_id + AND revision = NEW.revision +) BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are sealed'); +END; +CREATE TRIGGER staging_resolution_targets_no_late_insert +BEFORE INSERT ON staging_resolution_targets +WHEN EXISTS ( + SELECT 1 FROM staging_resolution_seals + WHERE subject_id = NEW.subject_id + AND candidate_id = NEW.candidate_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed'); +END; +CREATE TRIGGER surface_snapshot_refs_no_late_insert +BEFORE INSERT ON surface_snapshot_refs +WHEN EXISTS ( + SELECT 1 FROM surface_snapshot_seals + WHERE subject_id = NEW.subject_id + AND snapshot_id = NEW.snapshot_id +) BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are sealed'); +END; + +CREATE TRIGGER memory_revision_seals_no_update +BEFORE UPDATE ON memory_revision_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv revision seals are immutable'); +END; +CREATE TRIGGER memory_revision_seals_no_delete +BEFORE DELETE ON memory_revision_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv revision seals are retained'); +END; +CREATE TRIGGER staging_resolution_seals_no_update +BEFORE UPDATE ON staging_resolution_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable'); +END; +CREATE TRIGGER staging_resolution_seals_no_delete +BEFORE DELETE ON staging_resolution_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution seals are retained'); +END; +CREATE TRIGGER surface_snapshot_seals_no_update +BEFORE UPDATE ON surface_snapshot_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable'); +END; +CREATE TRIGGER surface_snapshot_seals_no_delete +BEFORE DELETE ON surface_snapshot_seals BEGIN + SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained'); +END; + +CREATE TRIGGER memory_revisions_no_update +BEFORE UPDATE ON memory_revisions BEGIN + SELECT RAISE(ABORT, 'subjektiv memory revisions are immutable'); +END; +CREATE TRIGGER memory_revisions_no_delete +BEFORE DELETE ON memory_revisions BEGIN + SELECT RAISE(ABORT, 'subjektiv memory revisions are retained'); +END; +CREATE TRIGGER memory_revision_candidates_no_update +BEFORE UPDATE ON memory_revision_candidates BEGIN + SELECT RAISE(ABORT, 'subjektiv revision evidence is immutable'); +END; +CREATE TRIGGER memory_revision_candidates_no_delete +BEFORE DELETE ON memory_revision_candidates BEGIN + SELECT RAISE(ABORT, 'subjektiv revision evidence is retained'); +END; +CREATE TRIGGER memory_revision_derivations_no_update +BEFORE UPDATE ON memory_revision_derivations BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are immutable'); +END; +CREATE TRIGGER memory_revision_derivations_no_delete +BEFORE DELETE ON memory_revision_derivations BEGIN + SELECT RAISE(ABORT, 'subjektiv derivations are retained'); +END; +CREATE TRIGGER staging_records_no_update +BEFORE UPDATE ON staging_records BEGIN + SELECT RAISE(ABORT, 'subjektiv staging records are immutable'); +END; +CREATE TRIGGER staging_records_no_delete +BEFORE DELETE ON staging_records BEGIN + SELECT RAISE(ABORT, 'subjektiv staging records are retained'); +END; +CREATE TRIGGER staging_resolutions_no_update +BEFORE UPDATE ON staging_resolutions BEGIN + SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable'); +END; +CREATE TRIGGER staging_resolutions_no_delete +BEFORE DELETE ON staging_resolutions BEGIN + SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained'); +END; +CREATE TRIGGER staging_resolution_targets_no_update +BEFORE UPDATE ON staging_resolution_targets BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable'); +END; +CREATE TRIGGER staging_resolution_targets_no_delete +BEFORE DELETE ON staging_resolution_targets BEGIN + SELECT RAISE(ABORT, 'subjektiv resolution targets are retained'); +END; +CREATE TRIGGER surface_snapshots_no_update +BEFORE UPDATE ON surface_snapshots BEGIN + SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable'); +END; +CREATE TRIGGER surface_snapshots_no_delete +BEFORE DELETE ON surface_snapshots BEGIN + SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained'); +END; +CREATE TRIGGER surface_snapshot_refs_no_update +BEFORE UPDATE ON surface_snapshot_refs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are immutable'); +END; +CREATE TRIGGER surface_snapshot_refs_no_delete +BEFORE DELETE ON surface_snapshot_refs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface references are retained'); +END; +"#, + )?; + Ok(()) +} + +fn add_subject_session_attribution(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE subject_session_attributions ( + session_id TEXT PRIMARY KEY, + subject_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + record_json TEXT NOT NULL, + attributed_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX subject_session_attributions_by_subject +ON subject_session_attributions(subject_id, attributed_at, session_id); + +CREATE TRIGGER subject_session_attributions_no_update +BEFORE UPDATE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is immutable'); +END; +CREATE TRIGGER subject_session_attributions_no_delete +BEFORE DELETE ON subject_session_attributions BEGIN + SELECT RAISE(ABORT, 'subjektiv session attribution is retained'); +END; +"#, + )?; + Ok(()) +} + +fn add_candidate_decision_receipts(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE candidate_decision_receipts ( + subject_id TEXT NOT NULL, + request_id TEXT NOT NULL, + candidate_id TEXT NOT NULL, + request_json TEXT NOT NULL, + receipt_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, request_id), + UNIQUE (subject_id, candidate_id), + FOREIGN KEY (subject_id, candidate_id) + REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT +); + +CREATE TRIGGER candidate_decision_receipts_no_update +BEFORE UPDATE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable'); +END; +CREATE TRIGGER candidate_decision_receipts_no_delete +BEFORE DELETE ON candidate_decision_receipts BEGIN + SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained'); +END; +"#, + )?; + Ok(()) +} + +fn add_surface_generation_state(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +CREATE TABLE surface_generation_state ( + subject_id TEXT PRIMARY KEY, + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')), + snapshot_id TEXT, + reason_code TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, + FOREIGN KEY (subject_id, snapshot_id) + REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT +); + +INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at +) +SELECT subjects.subject_id, + subjects.store_revision, + 'dirty', + NULL, + 'legacy_snapshot_requires_regeneration', + subjects.updated_at +FROM subjects +WHERE EXISTS ( + SELECT 1 FROM surface_snapshots any_snapshot + WHERE any_snapshot.subject_id = subjects.subject_id +); + +CREATE TABLE surface_generation_runs ( + subject_id TEXT NOT NULL, + generation_id TEXT NOT NULL, + store_revision INTEGER NOT NULL CHECK (store_revision >= 0), + active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0), + materials_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (subject_id, generation_id), + FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT +); + +CREATE INDEX surface_generation_runs_by_revision +ON surface_generation_runs(subject_id, store_revision, created_at, generation_id); + +CREATE TRIGGER surface_generation_runs_no_update +BEFORE UPDATE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable'); +END; +CREATE TRIGGER surface_generation_runs_no_delete +BEFORE DELETE ON surface_generation_runs BEGIN + SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained'); +END; +"#, + )?; + Ok(()) +} + +fn add_subject_behavior(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch( + r#" +ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; +ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 + CHECK (behavior_revision >= 0); +"#, + )?; + Ok(()) +} + +fn add_surface_job_provenance(transaction: &Transaction<'_>) -> feature_storage::Result<()> { + transaction.execute_batch("ALTER TABLE surface_generation_runs ADD COLUMN job_id TEXT; ALTER TABLE surface_generation_runs ADD COLUMN attempt_id TEXT;")?; + Ok(()) +} + +static MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), + FeatureMigration::new( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), + FeatureMigration::new( + 3, + "add idempotent atomic candidate decision receipts", + add_candidate_decision_receipts, + ), + FeatureMigration::new( + 4, + "add bounded surface generation state and runs", + add_surface_generation_state, + ), + FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior), + FeatureMigration::new( + 6, + "bind surface generation provenance to Job attempts", + add_surface_job_provenance, + ), +]; + +pub const REGISTRATION: FeatureRegistration = + FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS); + +#[derive(Clone)] +pub struct SubjektivStore { + database: FeatureDatabase, +} + +impl SubjektivStore { + /// Registers the trusted schema with the neutral Host storage manager. + /// One registration can be reused across all scopes served by that manager. + pub fn register(storage: &ScopedFeatureStorage) -> Result { + storage.register(REGISTRATION).map_err(Into::into) + } + + pub fn open(storage: &ScopedFeatureStorage, registration: &RegisteredFeature) -> Result { + if registration.feature_id() != SUBJEKTIV_FEATURE_ID { + return Err(SubjektivError::InvalidRecord(format!( + "registration is for Feature `{}` rather than `{SUBJEKTIV_FEATURE_ID}`", + registration.feature_id() + ))); + } + Self::from_database(storage.open(registration)?) + } + + /// Opens a trusted managed database. `store_scope.workspace_id` retains its + /// historical SQL name: it stores the physical Host storage-scope identity. + /// Backend uses its Workspace ID; local Hosts use a persisted random scope, + /// without creating a Workspace. Subject IDs are separate domain identities. + pub fn from_database(database: FeatureDatabase) -> Result { + if database.feature_id() != SUBJEKTIV_FEATURE_ID { + return Err(SubjektivError::InvalidRecord( + "not a subjektiv database".into(), + )); + } + let workspace_id = database.scope_id().to_string(); + database.try_transaction::<_, SubjektivError>(|transaction| { + let stored = transaction + .query_row( + "SELECT workspace_id FROM store_scope WHERE singleton = 1", + [], + |row| row.get::<_, String>(0), + ) + .optional()?; + match stored { + Some(stored) if stored != workspace_id => { + Err(SubjektivError::SubjectScopeMismatch { + subject_id: "".to_string(), + reference: format!( + "database is bound to Workspace `{stored}`, not `{workspace_id}`" + ), + }) + } + Some(_) => Ok(()), + None => { + transaction.execute( + "INSERT INTO store_scope (singleton, workspace_id) VALUES (1, ?1)", + [&workspace_id], + )?; + Ok(()) + } + } + })?; + Ok(Self { database }) + } + + pub fn scope_id(&self) -> &str { + self.database.scope_id() + } + + /// Backend compatibility alias; see [`Self::from_database`] for scope semantics. + pub fn workspace_id(&self) -> &str { + self.scope_id() + } + + /// Issues a random host-owned subject id. It is never derived from Worker, + /// Profile, Session, or Memory content. + pub fn create_subject(&self, role: SubjectRole) -> Result { + self.create_subject_with_behavior(role, String::new()) + } + + /// Creates a Subject with its optional user-managed behavior document. + pub fn create_subject_with_behavior( + &self, + role: SubjectRole, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; + let timestamp = now(); + let record = SubjectRecord { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: issued_id("subject"), + role, + behavior_md, + behavior_revision: 0, + state: SubjectState::Active, + store_revision: 0, + created_at: timestamp.clone(), + updated_at: timestamp, + }; + let raw = serde_json::to_string(&record)?; + self.database.try_transaction(|transaction| { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, behavior_md, behavior_revision, state, + store_revision, record_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)", + params![ + record.id, + record.role.as_str(), + record.behavior_md, + raw, + record.created_at, + record.updated_at + ], + )?; + Ok(record) + }) + } + + pub fn subject(&self, subject_id: &str) -> Result> { + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT record_json FROM subjects WHERE subject_id = ?1", + [subject_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_subject(&raw)).transpose() + }) + } + + /// Replaces or clears the user-managed behavior document using its own CAS + /// generation. Memory revisions do not participate in this generation. + pub fn update_subject_behavior( + &self, + subject_id: &str, + expected_behavior_revision: u64, + behavior_md: String, + ) -> Result { + validate_subject_behavior(&behavior_md)?; + self.database.try_transaction(|transaction| { + let mut subject = require_subject(transaction, subject_id)?; + if subject.behavior_revision != expected_behavior_revision { + return Err(SubjektivError::SubjectBehaviorConflict { + subject_id: subject_id.to_string(), + expected: expected_behavior_revision, + actual: subject.behavior_revision, + }); + } + if subject.behavior_md == behavior_md { + return Ok(subject); + } + subject.behavior_md = behavior_md; + subject.behavior_revision = + subject.behavior_revision.checked_add(1).ok_or_else(|| { + SubjektivError::InvalidRecord("subject behavior revision overflow".to_string()) + })?; + subject.updated_at = now(); + let raw = serde_json::to_string(&subject)?; + transaction.execute( + "UPDATE subjects + SET behavior_md = ?2, behavior_revision = ?3, + record_json = ?4, updated_at = ?5 + WHERE subject_id = ?1", + params![ + subject.id, + subject.behavior_md, + to_i64(subject.behavior_revision)?, + raw, + subject.updated_at + ], + )?; + Ok(subject) + }) + } + + /// Validates an explicit product launch connection against this Workspace's + /// subject store and the already-resolved Profile policy. Subject knowledge + /// alone is insufficient: only an active local record produces host launch material. + pub fn authorize_worker_attachment( + &self, + subject_id: &str, + profile: &manifest::WorkerManifest, + ) -> Result { + if !profile.feature.subjektiv.profile.enabled { + return Err(SubjektivError::ProfileDisabled); + } + let subject = self + .subject(subject_id)? + .ok_or_else(|| SubjektivError::SubjectNotFound(subject_id.to_string()))?; + if subject.state != SubjectState::Active { + return Err(SubjektivError::SubjectRetired(subject_id.to_string())); + } + Ok(AuthorizedSubjectWorkerAttachment { + singleton_key: subject_worker_singleton_key(&subject.id)?, + }) + } + + /// Lists the first deterministic bounded page of subjects. Ordering uses + /// immutable creation time with the host-issued subject id as a total-order + /// tie breaker. + pub fn list_subjects(&self, limit: usize) -> Result { + self.list_subjects_after(limit, None) + } + + /// Lists a deterministic bounded keyset page after an exact `(created_at, + /// subject_id)` cursor. The cursor fields are wrapped by the public API in + /// an opaque Workspace-bound token. + pub fn list_subjects_after( + &self, + limit: usize, + after: Option<(&str, &str)>, + ) -> Result { + if !(1..=100).contains(&limit) { + return Err(SubjektivError::InvalidRecord( + "subject list limit must be between 1 and 100".to_string(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + let sql = if after.is_some() { + "SELECT record_json FROM subjects + WHERE created_at > ?1 OR (created_at = ?1 AND subject_id > ?2) + ORDER BY created_at ASC, subject_id ASC + LIMIT ?3" + } else { + "SELECT record_json FROM subjects + ORDER BY created_at ASC, subject_id ASC + LIMIT ?1" + }; + let mut statement = connection.prepare(sql)?; + let mut items = Vec::new(); + if let Some((created_at, subject_id)) = after { + let rows = statement.query_map( + params![created_at, subject_id, to_i64(query_limit as u64)?], + |row| row.get::<_, String>(0), + )?; + for row in rows { + items.push(parse_subject(&row?)?); + } + } else { + let rows = statement + .query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?; + for row in rows { + items.push(parse_subject(&row?)?); + } + } + let has_more = items.len() > limit; + if has_more { + items.truncate(limit); + } + Ok(SubjectPage { items, has_more }) + }) + } + + /// Subject retirement is distinct from stopping any current Worker. It does + /// not edit or delete Memory and cannot be reversed by this baseline schema. + pub fn retire_subject(&self, subject_id: &str) -> Result { + self.database.try_transaction(|transaction| { + let mut subject = require_subject(transaction, subject_id)?; + if subject.state == SubjectState::Retired { + return Ok(subject); + } + subject.state = SubjectState::Retired; + subject.updated_at = now(); + let raw = serde_json::to_string(&subject)?; + transaction.execute( + "UPDATE subjects + SET state = 'retired', record_json = ?2, updated_at = ?3 + WHERE subject_id = ?1", + params![subject_id, raw, subject.updated_at], + )?; + Ok(subject) + }) + } + + pub fn stage_candidate(&self, record: SubjectStagingRecord) -> Result { + validate_staging_record(self.workspace_id(), &record)?; + validate_staging_admission(&record)?; + let raw = serde_json::to_string(&record)?; + self.database.try_transaction(|transaction| { + require_active_subject(transaction, &record.subject_id)?; + write_staging_candidate(transaction, self.workspace_id(), record, raw) + }) + } + + /// Atomically verifies immutable Host attribution already completed at the + /// Session lifecycle boundary and stages its extracted candidate. Candidate + /// staging never creates or repairs Session attribution. + pub fn stage_candidate_with_attribution( + &self, + mut record: SubjectStagingRecord, + attribution: SubjectSessionAttribution, + ) -> Result<(SubjectStagingRecord, SubjectSessionAttribution)> { + validate_session_attribution(&attribution)?; + if record.subject_id != attribution.subject_id { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: record.subject_id, + reference: format!( + "session {} is attributed to subject {}", + attribution.session_id, attribution.subject_id + ), + }); + } + attach_session_to_candidate(&mut record, &attribution.session_id)?; + validate_staging_record(self.workspace_id(), &record)?; + validate_staging_admission(&record)?; + let candidate_raw = serde_json::to_string(&record)?; + + self.database.try_transaction(|transaction| { + require_active_subject(transaction, &record.subject_id)?; + let existing_raw = transaction + .query_row( + "SELECT record_json FROM subject_session_attributions WHERE session_id = ?1", + [&attribution.session_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| { + SubjektivError::InvalidRecord(format!( + "Session {} has not completed Host attribution", + attribution.session_id + )) + })?; + let existing = parse_session_attribution(&existing_raw)?; + if existing.subject_id != attribution.subject_id { + return Err(SubjektivError::SessionSubjectConflict { + session_id: attribution.session_id, + existing_subject_id: existing.subject_id, + requested_subject_id: attribution.subject_id, + }); + } + if !equivalent_session_attribution(&existing, &attribution) { + return Err(SubjektivError::SessionAttributionConflict( + attribution.session_id, + )); + } + let candidate = + write_staging_candidate(transaction, self.workspace_id(), record, candidate_raw)?; + Ok((candidate, existing)) + }) + } + + pub fn record_session_attribution( + &self, + attribution: SubjectSessionAttribution, + ) -> Result { + validate_session_attribution(&attribution)?; + let raw = serde_json::to_string(&attribution)?; + self.database.try_transaction(|transaction| { + require_active_subject(transaction, &attribution.subject_id)?; + write_session_attribution(transaction, attribution, raw) + }) + } + + pub fn session_attribution( + &self, + session_id: &str, + ) -> Result> { + validate_label("session id", session_id)?; + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT record_json FROM subject_session_attributions + WHERE session_id = ?1", + [session_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_session_attribution(&raw)).transpose() + }) + } + + pub fn subject_session_attributions( + &self, + subject_id: &str, + ) -> Result> { + validate_label("subject id", subject_id)?; + self.database.try_with_connection(|connection| { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 + ORDER BY attributed_at ASC, session_id ASC", + )?; + let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_session_attribution(&row?)?); + } + Ok(records) + }) + } + + /// Reads one bounded immutable attribution page without materializing the + /// subject's complete history. `snapshot_at` fixes the initial discovery + /// boundary; later Host attributions require a fresh traversal. + pub fn subject_session_attribution_page( + &self, + subject_id: &str, + session_id: Option<&str>, + snapshot_at: &str, + after: Option<(&str, &str)>, + limit: usize, + ) -> Result { + validate_label("subject id", subject_id)?; + validate_label("attribution snapshot", snapshot_at)?; + if let Some(session_id) = session_id { + validate_label("session id", session_id)?; + } + if let Some((attributed_at, session_id)) = after { + validate_label("attribution cursor timestamp", attributed_at)?; + validate_label("attribution cursor session id", session_id)?; + } + if !(1..=100).contains(&limit) { + return Err(SubjektivError::InvalidRecord( + "subject Session attribution page limit must be between 1 and 100".to_string(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + let raw = match (session_id, after) { + (Some(session_id), _) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3 + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?4", + )?; + let rows = statement.query_map( + params![subject_id, session_id, snapshot_at, query_limit as i64], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + (None, Some((after_at, after_session_id))) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND attributed_at <= ?2 + AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4)) + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?5", + )?; + let rows = statement.query_map( + params![ + subject_id, + snapshot_at, + after_at, + after_session_id, + query_limit as i64 + ], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + (None, None) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND attributed_at <= ?2 + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?3", + )?; + let rows = statement.query_map( + params![subject_id, snapshot_at, query_limit as i64], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + }; + let mut items = raw + .into_iter() + .map(|raw| parse_session_attribution(&raw)) + .collect::>>()?; + let has_more = items.len() > limit; + if has_more { + items.truncate(limit); + } + Ok(SubjectSessionAttributionPage { items, has_more }) + }) + } + + pub fn staging_candidate( + &self, + subject_id: &str, + candidate_id: &str, + ) -> Result> { + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT record_json FROM staging_records + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, candidate_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_staging(self.workspace_id(), &raw)) + .transpose() + }) + } + + pub fn pending_staging_candidates( + &self, + subject_id: &str, + limit: usize, + ) -> Result> { + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let mut statement = connection.prepare( + "SELECT s.record_json + FROM staging_records s + LEFT JOIN staging_resolutions r + ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id + WHERE s.subject_id = ?1 AND r.candidate_id IS NULL + ORDER BY s.created_at ASC, s.candidate_id ASC + LIMIT ?2", + )?; + let rows = statement.query_map(params![subject_id, to_i64(limit as u64)?], |row| { + row.get::<_, String>(0) + })?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_staging(self.workspace_id(), &row?)?); + } + Ok(records) + }) + } + + pub fn pending_staging_backlog(&self, subject_id: &str) -> Result<(usize, u64)> { + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let (count, bytes) = connection.query_row( + "SELECT COUNT(*), COALESCE(SUM(LENGTH(s.record_json)), 0) + FROM staging_records s + LEFT JOIN staging_resolutions r + ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id + WHERE s.subject_id = ?1 AND r.candidate_id IS NULL", + [subject_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + )?; + Ok(( + usize::try_from(count).map_err(|_| { + SubjektivError::InvalidRecord("pending candidate count exceeds usize".into()) + })?, + u64::try_from(bytes).map_err(|_| { + SubjektivError::InvalidRecord("pending candidate bytes are negative".into()) + })?, + )) + }) + } + + pub fn create_memory(&self, subject_id: &str, draft: MemoryDraft) -> Result { + validate_direct_memory_draft(&draft)?; + if draft.state != MemoryState::Active { + return Err(SubjektivError::InvalidRecord( + "a new Memory must start in active state".to_string(), + )); + } + let memory_id = issued_id("memory"); + self.database.try_transaction(|transaction| { + write_memory_revision(transaction, subject_id, &memory_id, None, draft) + }) + } + + pub fn revise_memory( + &self, + subject_id: &str, + memory_id: &str, + expected_revision: u64, + draft: MemoryDraft, + ) -> Result { + validate_direct_memory_draft(&draft)?; + self.database.try_transaction(|transaction| { + write_memory_revision( + transaction, + subject_id, + memory_id, + Some(expected_revision), + draft, + ) + }) + } + + /// Atomically writes a Memory revision and one candidate's immutable + /// resolution. Any conflict or invalid reference rolls both changes back. + pub fn apply_candidate( + &self, + subject_id: &str, + candidate_id: &str, + target: MemoryRevisionTarget, + draft: MemoryDraft, + reason: impl Into, + ) -> Result<(MemoryRecord, StagingResolution)> { + let candidate_ids = vec![candidate_id.to_string()]; + let (memory, mut resolutions) = + self.apply_candidates(subject_id, &candidate_ids, target, draft, reason)?; + Ok((memory, resolutions.remove(0))) + } + + /// Atomically applies one or more candidates to exactly one new Memory + /// revision. The repository derives `source_candidate_ids` from this list so + /// every applied resolution has a reciprocal immutable provenance edge. + pub fn apply_candidates( + &self, + subject_id: &str, + candidate_ids: &[String], + target: MemoryRevisionTarget, + draft: MemoryDraft, + reason: impl Into, + ) -> Result<(MemoryRecord, Vec)> { + if candidate_ids.is_empty() { + return Err(SubjektivError::InvalidRecord( + "candidate application requires at least one candidate".into(), + )); + } + reject_duplicates("applied candidate", candidate_ids)?; + for candidate_id in candidate_ids { + validate_label("candidate id", candidate_id)?; + } + let reason = reason.into(); + validate_nonempty("resolution reason", &reason)?; + self.database.try_transaction(|transaction| { + apply_candidates_in_transaction( + transaction, + subject_id, + candidate_ids, + &target, + draft, + &reason, + ) + }) + } + + pub fn decide_candidate( + &self, + subject_id: &str, + request: CandidateDecisionRequest, + ) -> Result { + validate_label("candidate decision request id", &request.request_id)?; + validate_label("candidate id", &request.candidate_id)?; + validate_nonempty("resolution reason", &request.reason)?; + let request_raw = serde_json::to_string(&request)?; + self.database.try_transaction(|transaction| { + if let Some((stored_request, stored_receipt)) = transaction + .query_row( + "SELECT request_json, receipt_json + FROM candidate_decision_receipts + WHERE subject_id = ?1 AND request_id = ?2", + params![subject_id, request.request_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()? + { + if stored_request == request_raw { + return serde_json::from_str(&stored_receipt).map_err(Into::into); + } + return Err(SubjektivError::DecisionRequestConflict( + request.request_id.clone(), + )); + } + if transaction + .query_row( + "SELECT 1 FROM candidate_decision_receipts + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, request.candidate_id], + |_| Ok(()), + ) + .optional()? + .is_some() + { + return Err(SubjektivError::CandidateResolved( + request.candidate_id.clone(), + )); + } + + let (memory, operation, resolution) = match request.decision.clone() { + CandidateDecision::Apply { target, draft } => { + let operation = match &target { + MemoryRevisionTarget::Create => MemoryDecisionOperation::Create, + MemoryRevisionTarget::Revise { .. } => MemoryDecisionOperation::Revise, + }; + let candidate_ids = [request.candidate_id.clone()]; + let (memory, mut resolutions) = apply_candidates_in_transaction( + transaction, + subject_id, + &candidate_ids, + &target, + draft, + &request.reason, + )?; + (Some(memory), Some(operation), resolutions.remove(0)) + } + CandidateDecision::Close { + action, + affected_memory, + } => { + if action == StagingResolutionAction::Applied { + return Err(SubjektivError::InvalidRecord( + "applied candidates require an apply decision".into(), + )); + } + let resolution = insert_resolution( + transaction, + subject_id, + &request.candidate_id, + action, + &request.reason, + &affected_memory, + )?; + let operation = + (!affected_memory.is_empty()).then_some(MemoryDecisionOperation::Reference); + (None, operation, resolution) + } + }; + let store_revision = require_subject(transaction, subject_id)?.store_revision; + let receipt = CandidateDecisionReceipt { + request_id: request.request_id.clone(), + candidate_id: request.candidate_id.clone(), + surface_dirty: memory.is_some(), + memory, + operation, + resolution, + store_revision, + }; + let receipt_raw = serde_json::to_string(&receipt)?; + transaction.execute( + "INSERT INTO candidate_decision_receipts ( + subject_id, request_id, candidate_id, request_json, + receipt_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + subject_id, + request.request_id, + request.candidate_id, + request_raw, + receipt_raw, + now() + ], + )?; + Ok(receipt) + }) + } + + pub fn resolve_candidate( + &self, + subject_id: &str, + candidate_id: &str, + action: StagingResolutionAction, + reason: impl Into, + affected_memory: Vec, + ) -> Result { + let reason = reason.into(); + validate_nonempty("resolution reason", &reason)?; + if action == StagingResolutionAction::Applied { + return Err(SubjektivError::InvalidRecord( + "applied candidates must use apply_candidate(s) so Memory and provenance commit atomically" + .to_string(), + )); + } + self.database.try_transaction(|transaction| { + insert_resolution( + transaction, + subject_id, + candidate_id, + action, + &reason, + &affected_memory, + ) + }) + } + + pub fn staging_resolution( + &self, + subject_id: &str, + candidate_id: &str, + ) -> Result> { + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT resolution_json FROM staging_resolutions + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, candidate_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_resolution(&raw)).transpose() + }) + } + + pub fn memory(&self, subject_id: &str, memory_id: &str) -> Result> { + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 AND memory_id = ?2", + params![subject_id, memory_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_memory(&raw)).transpose() + }) + } + + pub fn memory_revision( + &self, + subject_id: &str, + memory_id: &str, + revision: u64, + ) -> Result> { + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT record_json FROM memory_revisions + WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", + params![subject_id, memory_id, to_i64(revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_memory(&raw)).transpose() + }) + } + + /// Reads one current Memory while distinguishing an absent target from a + /// Memory id owned by another subject. + pub fn scoped_memory(&self, subject_id: &str, memory_id: &str) -> Result { + validate_label("subject id", subject_id)?; + validate_label("Memory id", memory_id)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + scoped_memory_in_connection(connection, subject_id, memory_id) + }) + } + + /// Reads one immutable revision after verifying that the Memory belongs to + /// the requested subject. A missing revision of a valid scoped Memory is + /// returned as `None`. + pub fn scoped_memory_revision( + &self, + subject_id: &str, + memory_id: &str, + revision: u64, + ) -> Result> { + validate_label("subject id", subject_id)?; + validate_label("Memory id", memory_id)?; + if revision == 0 { + return Err(SubjektivError::InvalidRecord( + "Memory revision must be greater than zero".into(), + )); + } + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + scoped_memory_in_connection(connection, subject_id, memory_id)?; + let raw = connection + .query_row( + "SELECT record_json FROM memory_revisions + WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", + params![subject_id, memory_id, to_i64(revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_memory(&raw)).transpose() + }) + } + + /// Lists current Memory records for one existing subject in deterministic + /// most-recently-updated order. + pub fn list_memories(&self, subject_id: &str) -> Result> { + validate_label("subject id", subject_id)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let mut statement = connection.prepare( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 + ORDER BY updated_at DESC, memory_id ASC", + )?; + let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_memory(&row?)?); + } + Ok(records) + }) + } + + /// Lists every immutable revision of one scoped Memory, newest first. + pub fn list_memory_revisions( + &self, + subject_id: &str, + memory_id: &str, + ) -> Result> { + validate_label("subject id", subject_id)?; + validate_label("Memory id", memory_id)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + scoped_memory_in_connection(connection, subject_id, memory_id)?; + let mut statement = connection.prepare( + "SELECT record_json FROM memory_revisions + WHERE subject_id = ?1 AND memory_id = ?2 + ORDER BY revision DESC", + )?; + let rows = statement.query_map(params![subject_id, memory_id], |row| { + row.get::<_, String>(0) + })?; + let mut records = Vec::new(); + for row in rows { + records.push(parse_memory(&row?)?); + } + Ok(records) + }) + } + + /// Captures one deterministic, bounded generation input from only current + /// active Memory revisions. Kind order is fixed and selection is round-robin + /// across kinds; within each kind, updated_at desc then memory id asc. + pub fn prepare_surface_generation(&self, subject_id: &str) -> Result { + self.prepare_job_surface_generation(subject_id, None) + } + + pub fn prepare_job_surface_generation( + &self, + subject_id: &str, + job_attempt: Option<(&str, &str)>, + ) -> Result { + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + let (active_memory_count, materials) = + select_surface_materials(transaction, subject_id)?; + let generation = SurfaceGeneration { + id: issued_id("surface-generation"), + subject_id: subject_id.to_string(), + store_revision: subject.store_revision, + active_memory_count, + materials, + created_at: now(), + }; + transaction.execute( + "INSERT INTO surface_generation_runs ( + subject_id, generation_id, store_revision, active_memory_count, + materials_json, created_at, job_id, attempt_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + subject_id, + generation.id, + to_i64(generation.store_revision)?, + to_i64(generation.active_memory_count as u64)?, + serde_json::to_string(&generation.materials)?, + generation.created_at, + job_attempt.map(|(job_id, _)| job_id), + job_attempt.map(|(_, attempt_id)| attempt_id), + ], + )?; + Ok(generation) + }) + } + + /// Validate a Job's structured surface claim against the existing generation + /// and publication/failure records. This does not create another receipt or + /// roll back Memory. The generation must have begun inside this attempt. + #[allow(clippy::too_many_arguments)] + pub fn validate_job_surface_outcome( + &self, + subject_id: &str, + generation_id: &str, + store_revision: u64, + availability: &str, + snapshot_id: Option<&str>, + reason_code: Option<&str>, + job_id: &str, + attempt_id: &str, + ) -> Result<()> { + self.database.try_with_connection(|connection| { + let subject = require_subject_in_connection(connection, subject_id)?; + let generation_revision = require_job_generation(connection, subject_id, generation_id, job_id, attempt_id)?; + if generation_revision != store_revision || subject.store_revision != store_revision { + return Err(SubjektivError::InvalidRecord("surface generation is stale".into())); + } + let state = connection.query_row( + "SELECT store_revision, status, snapshot_id, reason_code FROM surface_generation_state WHERE subject_id = ?1", + [subject_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?)), + ).optional()?.ok_or_else(|| SubjektivError::InvalidRecord("surface generation has no terminal outcome".into()))?; + if state.0 != to_i64(store_revision)? || state.1 != availability + || !matches!(availability, "ready" | "failed") + || state.2.as_deref() != snapshot_id || state.3.as_deref() != reason_code { + return Err(SubjektivError::InvalidRecord("surface claim differs from the persisted outcome".into())); + } + Ok(()) + }) + } + + pub fn require_job_surface_generation( + &self, + subject_id: &str, + generation_id: &str, + job_id: &str, + attempt_id: &str, + ) -> Result<()> { + self.database.try_with_connection(|connection| { + require_job_generation(connection, subject_id, generation_id, job_id, attempt_id)?; + Ok(()) + }) + } + + /// Validates structured editor output against the exact material snapshot and + /// atomically publishes it only while the subject store generation is current. + pub fn publish_surface_generation( + &self, + subject_id: &str, + generation_id: &str, + points: Vec, + ) -> Result { + validate_label("surface generation id", generation_id)?; + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + let (store_revision, active_memory_count, materials_json) = transaction + .query_row( + "SELECT store_revision, active_memory_count, materials_json + FROM surface_generation_runs + WHERE subject_id = ?1 AND generation_id = ?2", + params![subject_id, generation_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()? + .ok_or_else(|| { + SubjektivError::SurfaceGenerationConflict(format!( + "unknown generation `{generation_id}` for subject `{subject_id}`" + )) + })?; + let store_revision = u64::try_from(store_revision).map_err(|_| { + SubjektivError::InvalidRecord("surface generation revision is negative".into()) + })?; + if subject.store_revision != store_revision { + return Err(SubjektivError::SurfaceGenerationConflict(format!( + "generation {store_revision} is stale; current store revision is {}", + subject.store_revision + ))); + } + let materials: Vec = serde_json::from_str(&materials_json)?; + if active_memory_count > 0 && materials.is_empty() { + return Err(SubjektivError::InvalidRecord( + "active Memory exists but no record fits the surface input budget".into(), + )); + } + let (body_md, memory_refs) = validate_surface_points(&materials, &points)?; + if let Some(existing) = + ready_surface_snapshot_for_revision(transaction, subject_id, store_revision)? + { + if existing.body_md == body_md && existing.memory_refs == memory_refs { + return Ok(existing); + } + return Err(SubjektivError::SurfaceGenerationConflict(format!( + "store revision {store_revision} already has a different published surface" + ))); + } + insert_surface_snapshot( + transaction, + subject_id, + body_md, + memory_refs, + store_revision, + ) + }) + } + + /// Records a bounded generator failure without changing confirmed Memory. A + /// late failure cannot replace a ready surface or dirty a newer generation. + pub fn fail_surface_generation( + &self, + subject_id: &str, + generation_id: &str, + reason_code: &str, + ) -> Result { + validate_label("surface generation id", generation_id)?; + validate_label("surface failure reason", reason_code)?; + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + let generation_revision = transaction + .query_row( + "SELECT store_revision FROM surface_generation_runs + WHERE subject_id = ?1 AND generation_id = ?2", + params![subject_id, generation_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .ok_or_else(|| { + SubjektivError::SurfaceGenerationConflict(format!( + "unknown generation `{generation_id}` for subject `{subject_id}`" + )) + })?; + let generation_revision = u64::try_from(generation_revision).map_err(|_| { + SubjektivError::InvalidRecord("surface generation revision is negative".into()) + })?; + if generation_revision == subject.store_revision { + let ready = transaction + .query_row( + "SELECT status FROM surface_generation_state WHERE subject_id = ?1", + [subject_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .is_some_and(|status| status == "ready"); + if !ready { + transaction.execute( + "INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at + ) VALUES (?1, ?2, 'failed', NULL, ?3, ?4) + ON CONFLICT(subject_id) DO UPDATE SET + store_revision = excluded.store_revision, + status = 'failed', snapshot_id = NULL, + reason_code = excluded.reason_code, + updated_at = excluded.updated_at", + params![subject_id, to_i64(generation_revision)?, reason_code, now()], + )?; + } + } + Ok(subject.store_revision) + }) + } + + /// Returns only a current ready snapshot for injection. Other states are + /// explicit so callers never report missing/stale/failed generation as empty + /// confirmed Memory. + pub fn resident_surface(&self, subject_id: &str) -> Result { + self.database.try_with_connection(|connection| { + let subject = require_subject_in_connection(connection, subject_id)?; + resident_surface_in_connection(connection, &subject) + }) + } + + /// Atomically reads user-managed behavior and the independently-fresh + /// generated Memory surface from one SQLite connection snapshot. + pub fn resident_context(&self, subject_id: &str) -> Result { + self.database.try_transaction(|transaction| { + let subject = require_subject(transaction, subject_id)?; + let surface = resident_surface_in_connection(transaction, &subject)?; + Ok(SubjectResidentContext { subject, surface }) + }) + } + + pub fn create_surface_snapshot( + &self, + subject_id: &str, + body_md: impl Into, + memory_refs: Vec, + built_from_store_revision: u64, + ) -> Result { + reject_duplicate_refs("surface snapshot", &memory_refs)?; + reject_duplicate_memory_ids("surface snapshot", &memory_refs)?; + let body_md = body_md.into(); + self.database.try_transaction(|transaction| { + let subject = require_active_subject(transaction, subject_id)?; + if subject.store_revision != built_from_store_revision { + return Err(SubjektivError::InvalidRecord(format!( + "surface snapshot generation {built_from_store_revision} does not match current subject store revision {}", + subject.store_revision + ))); + } + validate_memory_refs(transaction, subject_id, &memory_refs)?; + if estimated_tokens(&body_md) > SURFACE_BODY_TOKEN_BUDGET { + return Err(SubjektivError::InvalidRecord(format!( + "surface snapshot body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" + ))); + } + if let Some(existing) = surface_snapshot_for_revision( + transaction, + subject_id, + built_from_store_revision, + )? { + if existing.body_md == body_md && existing.memory_refs == memory_refs { + return Ok(existing); + } + return Err(SubjektivError::SurfaceGenerationConflict(format!( + "store revision {built_from_store_revision} already has a different published surface" + ))); + } + insert_surface_snapshot( + transaction, + subject_id, + body_md, + memory_refs, + built_from_store_revision, + ) + }) + } + + pub fn surface_snapshot( + &self, + subject_id: &str, + snapshot_id: &str, + ) -> Result> { + self.database.try_with_connection(|connection| { + let raw = connection + .query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND snapshot_id = ?2", + params![subject_id, snapshot_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_surface_snapshot(&raw)).transpose() + }) + } +} + +fn select_surface_materials( + transaction: &Transaction<'_>, + subject_id: &str, +) -> Result<(usize, Vec)> { + let mut statement = transaction.prepare( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 AND state = 'active' + ORDER BY updated_at DESC, memory_id ASC", + )?; + let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; + let mut buckets: [Vec; 6] = std::array::from_fn(|_| Vec::new()); + let mut active_memory_count = 0usize; + for row in rows { + let memory = parse_memory(&row?)?; + active_memory_count = active_memory_count.saturating_add(1); + let bucket = surface_kind_index(&memory.kind); + if buckets[bucket].len() < SURFACE_PER_KIND_LIMIT { + buckets[bucket].push(SurfaceMaterial { + memory_id: memory.id, + revision: memory.revision, + kind: memory.kind, + body_md: memory.body_md, + why_useful: memory.why_useful, + staleness: memory.staleness, + }); + } + } + + let populated_kind_count = buckets.iter().filter(|bucket| !bucket.is_empty()).count(); + let per_kind_token_budget = SURFACE_MATERIAL_TOKEN_BUDGET / populated_kind_count.max(1); + let mut ordered = Vec::new(); + for offset in 0..SURFACE_PER_KIND_LIMIT { + for (bucket_index, bucket) in buckets.iter().enumerate() { + if let Some(material) = bucket.get(offset) { + ordered.push((bucket_index, material.clone())); + } + } + } + + let mut accepted = vec![false; ordered.len()]; + let mut selected_count = 0usize; + let mut deferred = Vec::new(); + for (index, (bucket_index, material)) in ordered.iter().enumerate() { + if selected_count == SURFACE_TOTAL_MATERIAL_LIMIT { + break; + } + let kind_materials = ordered + .iter() + .zip(&accepted) + .filter(|((selected_bucket, _), accepted)| { + **accepted && selected_bucket == bucket_index + }) + .map(|((_, material), _)| material) + .chain(std::iter::once(material)); + let all_materials = ordered + .iter() + .zip(&accepted) + .filter(|(_, accepted)| **accepted) + .map(|((_, material), _)| material) + .chain(std::iter::once(material)); + if serialized_material_tokens(kind_materials)? <= per_kind_token_budget + && serialized_material_tokens(all_materials)? <= SURFACE_MATERIAL_TOKEN_BUDGET + { + accepted[index] = true; + selected_count += 1; + } else { + deferred.push(index); + } + } + + // Category shares are fairness reservations, not hard ceilings. Reclaim + // unused shares in the same canonical order after every populated category + // had its first opportunity. + for index in deferred { + if selected_count == SURFACE_TOTAL_MATERIAL_LIMIT { + break; + } + let material = &ordered[index].1; + let all_materials = ordered + .iter() + .zip(&accepted) + .filter(|(_, accepted)| **accepted) + .map(|((_, material), _)| material) + .chain(std::iter::once(material)); + if serialized_material_tokens(all_materials)? <= SURFACE_MATERIAL_TOKEN_BUDGET { + accepted[index] = true; + selected_count += 1; + } + } + + let selected = ordered + .into_iter() + .zip(accepted) + .filter_map(|((_, material), accepted)| accepted.then_some(material)) + .collect(); + Ok((active_memory_count, selected)) +} + +fn serialized_material_tokens<'a>( + materials: impl IntoIterator, +) -> Result { + let materials = materials.into_iter().collect::>(); + Ok(estimated_tokens_for_bytes( + serde_json::to_vec(&materials)?.len(), + )) +} + +fn surface_kind_index(kind: &CandidateKind) -> usize { + match kind { + CandidateKind::Preference => 0, + CandidateKind::Constraint => 1, + CandidateKind::Decision => 2, + CandidateKind::WorkingAssumption => 3, + CandidateKind::OpenQuestion => 4, + CandidateKind::Lesson => 5, + } +} + +fn validate_surface_points( + materials: &[SurfaceMaterial], + points: &[SurfacePoint], +) -> Result<(String, Vec)> { + if materials.is_empty() { + if points.is_empty() { + return Ok((String::new(), Vec::new())); + } + return Err(SubjektivError::InvalidRecord( + "an empty material set requires an empty surface".into(), + )); + } + if points.is_empty() { + return Err(SubjektivError::InvalidRecord( + "non-empty surface materials require at least one grounded point".into(), + )); + } + if points.len() > SURFACE_TOTAL_MATERIAL_LIMIT { + return Err(SubjektivError::InvalidRecord(format!( + "surface output exceeds {SURFACE_TOTAL_MATERIAL_LIMIT} points" + ))); + } + let allowed = materials + .iter() + .map(|material| (material.memory_id.as_str(), material.revision)) + .collect::>(); + let mut bodies = Vec::with_capacity(points.len()); + let mut seen = HashSet::new(); + let mut refs = Vec::new(); + for point in points { + let body = point.body_md.trim(); + validate_nonempty("surface point body", body)?; + if point.memory_refs.is_empty() { + return Err(SubjektivError::InvalidRecord( + "every surface point requires at least one Memory reference".into(), + )); + } + reject_duplicate_refs("surface point", &point.memory_refs)?; + for reference in &point.memory_refs { + if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: "surface generation materials".into(), + reference: format!("{}@{}", reference.memory_id, reference.revision), + }); + } + if seen.insert((reference.memory_id.clone(), reference.revision)) { + refs.push(reference.clone()); + } + } + bodies.push(body.to_string()); + } + let body_md = bodies.join("\n\n"); + if estimated_tokens(&body_md) > SURFACE_BODY_TOKEN_BUDGET { + return Err(SubjektivError::InvalidRecord(format!( + "surface body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" + ))); + } + Ok((body_md, refs)) +} + +fn ready_surface_snapshot_for_revision( + transaction: &Transaction<'_>, + subject_id: &str, + store_revision: u64, +) -> Result> { + let raw = transaction + .query_row( + "SELECT snapshots.snapshot_json + FROM surface_generation_state state + JOIN surface_snapshots snapshots + ON snapshots.subject_id = state.subject_id + AND snapshots.snapshot_id = state.snapshot_id + WHERE state.subject_id = ?1 + AND state.store_revision = ?2 + AND state.status = 'ready'", + params![subject_id, to_i64(store_revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_surface_snapshot(&raw)).transpose() +} + +fn surface_snapshot_for_revision( + transaction: &Transaction<'_>, + subject_id: &str, + store_revision: u64, +) -> Result> { + let raw = transaction + .query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND built_from_store_revision = ?2 + ORDER BY created_at ASC, snapshot_id ASC LIMIT 1", + params![subject_id, to_i64(store_revision)?], + |row| row.get::<_, String>(0), + ) + .optional()?; + raw.map(|raw| parse_surface_snapshot(&raw)).transpose() +} + +fn insert_surface_snapshot( + transaction: &Transaction<'_>, + subject_id: &str, + body_md: String, + memory_refs: Vec, + built_from_store_revision: u64, +) -> Result { + validate_memory_refs(transaction, subject_id, &memory_refs)?; + let snapshot = SurfaceSnapshot { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: issued_id("surface"), + subject_id: subject_id.to_string(), + body_md, + memory_refs, + built_from_store_revision, + created_at: now(), + }; + let raw = serde_json::to_string(&snapshot)?; + transaction.execute( + "INSERT INTO surface_snapshots ( + subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject_id, + snapshot.id, + to_i64(snapshot.built_from_store_revision)?, + raw, + snapshot.created_at + ], + )?; + for reference in &snapshot.memory_refs { + transaction.execute( + "INSERT INTO surface_snapshot_refs ( + subject_id, snapshot_id, memory_id, revision + ) VALUES (?1, ?2, ?3, ?4)", + params![ + subject_id, + snapshot.id, + reference.memory_id, + to_i64(reference.revision)? + ], + )?; + } + transaction.execute( + "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) VALUES (?1, ?2)", + params![subject_id, snapshot.id], + )?; + transaction.execute( + "INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at + ) VALUES (?1, ?2, 'ready', ?3, NULL, ?4) + ON CONFLICT(subject_id) DO UPDATE SET + store_revision = excluded.store_revision, + status = 'ready', snapshot_id = excluded.snapshot_id, + reason_code = NULL, updated_at = excluded.updated_at", + params![ + subject_id, + to_i64(built_from_store_revision)?, + snapshot.id, + snapshot.created_at + ], + )?; + Ok(snapshot) +} + +fn estimated_tokens(value: &str) -> usize { + estimated_tokens_for_bytes(value.len()) +} + +fn estimated_tokens_for_bytes(bytes: usize) -> usize { + bytes.saturating_add(3) / 4 +} + +fn apply_candidates_in_transaction( + transaction: &Transaction<'_>, + subject_id: &str, + candidate_ids: &[String], + target: &MemoryRevisionTarget, + mut draft: MemoryDraft, + reason: &str, +) -> Result<(MemoryRecord, Vec)> { + let mut candidates = Vec::with_capacity(candidate_ids.len()); + for candidate_id in candidate_ids { + let raw = require_unresolved_candidate(transaction, subject_id, candidate_id)?; + candidates.push(parse_staging_without_workspace(&raw)?); + } + for candidate in &candidates { + validate_candidate_application(transaction, subject_id, candidate, target, &draft)?; + } + draft.source_candidate_ids = candidate_ids.to_vec(); + let new_memory_id = issued_id("memory"); + let memory = match target { + MemoryRevisionTarget::Create => { + if draft.state != MemoryState::Active { + return Err(SubjektivError::InvalidRecord( + "a new Memory must start in active state".into(), + )); + } + write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)? + } + MemoryRevisionTarget::Revise { + memory_id, + expected_revision, + } => write_memory_revision( + transaction, + subject_id, + memory_id, + Some(*expected_revision), + draft, + )?, + }; + let reference = MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: memory.revision, + }; + let resolutions = candidate_ids + .iter() + .map(|candidate_id| { + insert_resolution( + transaction, + subject_id, + candidate_id, + StagingResolutionAction::Applied, + reason, + std::slice::from_ref(&reference), + ) + }) + .collect::>>()?; + Ok((memory, resolutions)) +} + +fn write_memory_revision( + transaction: &Transaction<'_>, + subject_id: &str, + memory_id: &str, + expected_revision: Option, + draft: MemoryDraft, +) -> Result { + validate_memory_draft(&draft)?; + let mut subject = require_active_subject(transaction, subject_id)?; + let current_raw = transaction + .query_row( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 AND memory_id = ?2", + params![subject_id, memory_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let current = current_raw.map(|raw| parse_memory(&raw)).transpose()?; + + let (revision, created_at) = match (current.as_ref(), expected_revision) { + (None, None) => (1, now()), + (None, Some(_)) => return Err(SubjektivError::MemoryNotFound(memory_id.to_string())), + (Some(_), None) => { + return Err(SubjektivError::InvalidRecord(format!( + "memory `{memory_id}` already exists" + ))); + } + (Some(current), Some(expected)) if current.revision != expected => { + return Err(SubjektivError::RevisionConflict { + memory_id: memory_id.to_string(), + expected, + actual: current.revision, + }); + } + (Some(current), Some(_)) => { + validate_state_transition(memory_id, current.state, draft.state)?; + (current.revision + 1, current.created_at.clone()) + } + }; + + reject_duplicates("source candidate", &draft.source_candidate_ids)?; + reject_duplicate_refs("derived Memory", &draft.derived_from)?; + validate_candidate_refs(transaction, subject_id, &draft.source_candidate_ids)?; + validate_memory_refs(transaction, subject_id, &draft.derived_from)?; + + let timestamp = now(); + let record = MemoryRecord { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: memory_id.to_string(), + subject_id: subject_id.to_string(), + revision, + kind: draft.kind, + state: draft.state, + claim: draft.claim, + body_md: draft.body_md, + why_useful: draft.why_useful, + staleness: draft.staleness, + source_candidate_ids: draft.source_candidate_ids, + derived_from: draft.derived_from, + change_reason: draft.change_reason, + created_at, + updated_at: timestamp, + }; + let raw = serde_json::to_string(&record)?; + + if current.is_none() { + transaction.execute( + "INSERT INTO memory_records ( + subject_id, memory_id, current_revision, kind, state, + record_json, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + subject_id, + memory_id, + to_i64(revision)?, + candidate_kind_name(&record.kind), + memory_state_name(record.state), + raw, + record.created_at, + record.updated_at + ], + )?; + } + transaction.execute( + "INSERT INTO memory_revisions ( + subject_id, memory_id, revision, kind, state, record_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + subject_id, + memory_id, + to_i64(revision)?, + candidate_kind_name(&record.kind), + memory_state_name(record.state), + raw, + record.updated_at + ], + )?; + if current.is_some() { + transaction.execute( + "UPDATE memory_records + SET current_revision = ?3, kind = ?4, state = ?5, + record_json = ?6, updated_at = ?7 + WHERE subject_id = ?1 AND memory_id = ?2", + params![ + subject_id, + memory_id, + to_i64(revision)?, + candidate_kind_name(&record.kind), + memory_state_name(record.state), + raw, + record.updated_at + ], + )?; + } + for candidate_id in &record.source_candidate_ids { + transaction.execute( + "INSERT INTO memory_revision_candidates ( + subject_id, memory_id, revision, candidate_id + ) VALUES (?1, ?2, ?3, ?4)", + params![subject_id, memory_id, to_i64(revision)?, candidate_id], + )?; + } + for source in &record.derived_from { + transaction.execute( + "INSERT INTO memory_revision_derivations ( + subject_id, memory_id, revision, source_memory_id, source_revision + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject_id, + memory_id, + to_i64(revision)?, + source.memory_id, + to_i64(source.revision)? + ], + )?; + } + transaction.execute( + "INSERT INTO memory_revision_seals (subject_id, memory_id, revision) + VALUES (?1, ?2, ?3)", + params![subject_id, memory_id, to_i64(revision)?], + )?; + + subject.store_revision = subject + .store_revision + .checked_add(1) + .ok_or_else(|| SubjektivError::InvalidRecord("subject store revision overflow".into()))?; + subject.updated_at = record.updated_at.clone(); + let subject_raw = serde_json::to_string(&subject)?; + transaction.execute( + "UPDATE subjects + SET store_revision = ?2, record_json = ?3, updated_at = ?4 + WHERE subject_id = ?1", + params![ + subject_id, + to_i64(subject.store_revision)?, + subject_raw, + subject.updated_at + ], + )?; + transaction.execute( + "INSERT INTO surface_generation_state ( + subject_id, store_revision, status, snapshot_id, reason_code, updated_at + ) VALUES (?1, ?2, 'dirty', NULL, NULL, ?3) + ON CONFLICT(subject_id) DO UPDATE SET + store_revision = excluded.store_revision, + status = 'dirty', snapshot_id = NULL, reason_code = NULL, + updated_at = excluded.updated_at", + params![ + subject_id, + to_i64(subject.store_revision)?, + subject.updated_at + ], + )?; + Ok(record) +} + +fn write_staging_candidate( + transaction: &Transaction<'_>, + workspace_id: &str, + record: SubjectStagingRecord, + raw: String, +) -> Result { + let existing = transaction + .query_row( + "SELECT record_json FROM staging_records + WHERE subject_id = ?1 AND candidate_id = ?2", + params![record.subject_id, record.id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(existing) = existing { + if equivalent_staging_payload(&existing, &raw)? { + return parse_staging(workspace_id, &existing); + } + return Err(SubjektivError::CandidateConflict(record.id.clone())); + } + validate_staged_revision_proposal(transaction, &record)?; + transaction.execute( + "INSERT INTO staging_records ( + subject_id, candidate_id, kind, record_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + record.subject_id, + record.id, + candidate_kind_name(&record.kind), + raw, + record.created_at + ], + )?; + Ok(record) +} + +fn write_session_attribution( + transaction: &Transaction<'_>, + record: SubjectSessionAttribution, + raw: String, +) -> Result { + let existing = transaction + .query_row( + "SELECT record_json FROM subject_session_attributions WHERE session_id = ?1", + [&record.session_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(existing) = existing { + let existing = parse_session_attribution(&existing)?; + if existing.subject_id != record.subject_id { + return Err(SubjektivError::SessionSubjectConflict { + session_id: record.session_id, + existing_subject_id: existing.subject_id, + requested_subject_id: record.subject_id, + }); + } + if equivalent_session_attribution(&existing, &record) { + return Ok(existing); + } + return Err(SubjektivError::SessionAttributionConflict( + record.session_id, + )); + } + transaction.execute( + "INSERT INTO subject_session_attributions ( + session_id, subject_id, runtime_id, worker_id, record_json, attributed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + record.session_id, + record.subject_id, + record.runtime_id, + record.worker_id, + raw, + record.attributed_at + ], + )?; + Ok(record) +} + +fn insert_resolution( + transaction: &Transaction<'_>, + subject_id: &str, + candidate_id: &str, + action: StagingResolutionAction, + reason: &str, + affected_memory: &[MemoryRevisionRef], +) -> Result { + require_active_subject(transaction, subject_id)?; + reject_duplicate_refs("staging resolution", affected_memory)?; + validate_memory_refs(transaction, subject_id, affected_memory)?; + if action == StagingResolutionAction::Applied { + if affected_memory.is_empty() { + return Err(SubjektivError::InvalidRecord( + "an applied candidate requires an affected Memory revision".into(), + )); + } + for reference in affected_memory { + let reciprocal = transaction + .query_row( + "SELECT 1 FROM memory_revision_candidates + WHERE subject_id = ?1 AND memory_id = ?2 + AND revision = ?3 AND candidate_id = ?4", + params![ + subject_id, + reference.memory_id, + to_i64(reference.revision)?, + candidate_id + ], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !reciprocal { + return Err(SubjektivError::InvalidRecord(format!( + "applied candidate `{candidate_id}` has no reciprocal provenance edge to `{}@{}`", + reference.memory_id, reference.revision + ))); + } + } + } + let candidate_raw = require_unresolved_candidate(transaction, subject_id, candidate_id)?; + let candidate = parse_staging_without_workspace(&candidate_raw)?; + if candidate.subject_id != subject_id { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: candidate_id.to_string(), + }); + } + let resolution = StagingResolution { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: issued_id("resolution"), + subject_id: subject_id.to_string(), + candidate_id: candidate_id.to_string(), + action, + reason: reason.to_string(), + affected_memory: affected_memory.to_vec(), + candidate, + resolved_at: now(), + }; + let affected_raw = serde_json::to_string(affected_memory)?; + let resolution_raw = serde_json::to_string(&resolution)?; + transaction.execute( + "INSERT INTO staging_resolutions ( + subject_id, candidate_id, resolution_id, action, reason, + affected_refs_json, staging_raw_json, resolution_json, resolved_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + subject_id, + candidate_id, + resolution.id, + resolution_action_name(action), + reason, + affected_raw, + candidate_raw, + resolution_raw, + resolution.resolved_at + ], + )?; + for reference in affected_memory { + transaction.execute( + "INSERT INTO staging_resolution_targets ( + subject_id, candidate_id, memory_id, revision + ) VALUES (?1, ?2, ?3, ?4)", + params![ + subject_id, + candidate_id, + reference.memory_id, + to_i64(reference.revision)? + ], + )?; + } + transaction.execute( + "INSERT INTO staging_resolution_seals (subject_id, candidate_id) + VALUES (?1, ?2)", + params![subject_id, candidate_id], + )?; + Ok(resolution) +} + +fn require_unresolved_candidate( + transaction: &Transaction<'_>, + subject_id: &str, + candidate_id: &str, +) -> Result { + let raw = transaction + .query_row( + "SELECT record_json FROM staging_records + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, candidate_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let raw = match raw { + Some(raw) => raw, + None => { + let foreign = transaction + .query_row( + "SELECT subject_id FROM staging_records WHERE candidate_id = ?1 LIMIT 1", + [candidate_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if foreign.is_some() { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: candidate_id.to_string(), + }); + } + return Err(SubjektivError::CandidateNotFound(candidate_id.to_string())); + } + }; + let resolved = transaction + .query_row( + "SELECT 1 FROM staging_resolutions + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, candidate_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if resolved { + return Err(SubjektivError::CandidateResolved(candidate_id.to_string())); + } + Ok(raw) +} + +fn require_subject(transaction: &Transaction<'_>, subject_id: &str) -> Result { + require_subject_in_connection(transaction, subject_id) +} + +fn require_subject_in_connection( + connection: &rusqlite::Connection, + subject_id: &str, +) -> Result { + let raw = connection + .query_row( + "SELECT record_json FROM subjects WHERE subject_id = ?1", + [subject_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .ok_or_else(|| SubjektivError::SubjectNotFound(subject_id.to_string()))?; + parse_subject(&raw) +} + +fn require_active_subject( + transaction: &Transaction<'_>, + subject_id: &str, +) -> Result { + let subject = require_subject(transaction, subject_id)?; + if subject.state == SubjectState::Retired { + return Err(SubjektivError::SubjectRetired(subject_id.to_string())); + } + Ok(subject) +} + +fn validate_candidate_refs( + transaction: &Transaction<'_>, + subject_id: &str, + candidate_ids: &[String], +) -> Result<()> { + for candidate_id in candidate_ids { + let exists = transaction + .query_row( + "SELECT 1 FROM staging_records + WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject_id, candidate_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !exists { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: candidate_id.clone(), + }); + } + } + Ok(()) +} + +fn validate_memory_refs( + transaction: &Transaction<'_>, + subject_id: &str, + references: &[MemoryRevisionRef], +) -> Result<()> { + for reference in references { + if reference.revision == 0 { + return Err(SubjektivError::InvalidRecord(format!( + "Memory `{}` revision must be greater than zero", + reference.memory_id + ))); + } + let exists = transaction + .query_row( + "SELECT 1 FROM memory_revisions + WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", + params![subject_id, reference.memory_id, to_i64(reference.revision)?], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !exists { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: format!("{}@{}", reference.memory_id, reference.revision), + }); + } + } + Ok(()) +} + +fn validate_session_attribution(record: &SubjectSessionAttribution) -> Result<()> { + validate_schema_version(record.schema_version, "subject Session attribution")?; + validate_label("subject id", &record.subject_id)?; + validate_label("runtime id", &record.runtime_id)?; + validate_label("worker id", &record.worker_id)?; + validate_label("session id", &record.session_id)?; + validate_label("Session attribution timestamp", &record.attributed_at)?; + chrono::DateTime::parse_from_rfc3339(&record.attributed_at).map_err(|error| { + SubjektivError::InvalidRecord(format!( + "Session attribution timestamp is not RFC 3339: {error}" + )) + })?; + Ok(()) +} + +fn attach_session_to_candidate(record: &mut SubjectStagingRecord, session_id: &str) -> Result<()> { + if record.source_refs.is_empty() { + record.source_refs.push(SourceEvidenceRef { + session_id: Some(session_id.to_string()), + segment_id: Some(record.source.segment_id.clone()), + entry_range: Some(record.source.range), + ..SourceEvidenceRef::default() + }); + return Ok(()); + } + for source in &mut record.source_refs { + match source.session_id.as_deref() { + Some(existing) if existing != session_id => { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: record.subject_id.clone(), + reference: format!( + "candidate source Session `{existing}` does not match attributed Session `{session_id}`" + ), + }); + } + Some(_) => {} + None => source.session_id = Some(session_id.to_string()), + } + } + Ok(()) +} + +fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> Result<()> { + if record.schema_version != STAGING_SCHEMA_VERSION { + return Err(SubjektivError::InvalidRecord(format!( + "staging schema version {} is unsupported", + record.schema_version + ))); + } + validate_label("candidate id", &record.id)?; + validate_label("subject id", &record.subject_id)?; + validate_label("extract run id", &record.extract_run_id)?; + validate_label("source segment id", &record.source.segment_id)?; + validate_nonempty("candidate claim", &record.claim)?; + validate_nonempty("candidate why_useful", &record.why_useful)?; + validate_range("candidate source", record.source.range)?; + if matches!(record.kind, CandidateKind::Preference) + && record.evidence.iter().any(|evidence| { + !matches!( + evidence.origin.as_ref().map(|origin| &origin.kind), + Some(memory::schema::EvidenceOriginKind::HumanInput) + ) + }) + { + return Err(SubjektivError::InvalidRecord( + "preference candidates require exclusively HumanInput evidence".to_string(), + )); + } + + let mut evidence_ids = HashSet::new(); + for evidence in &record.evidence { + validate_label("evidence id", &evidence.id)?; + if !evidence_ids.insert(evidence.id.as_str()) { + return Err(SubjektivError::InvalidRecord(format!( + "duplicate evidence id `{}`", + evidence.id + ))); + } + validate_nonempty("evidence kind", evidence.kind.as_str())?; + if let Some(range) = evidence.entry_range { + validate_range("evidence entry", range)?; + } + } + for source in &record.source_refs { + if let Some(session_id) = source.session_id.as_deref() { + validate_label("source session id", session_id)?; + } + if let Some(segment_id) = source.segment_id.as_deref() { + validate_label("source segment id", segment_id)?; + } + if let Some(range) = source.entry_range { + validate_range("source evidence entry", range)?; + } + if let Some(evidence_id) = source.evidence_id.as_deref() { + validate_label("source evidence id", evidence_id)?; + if !evidence_ids.contains(evidence_id) { + return Err(SubjektivError::InvalidRecord(format!( + "source evidence id `{evidence_id}` is not present in evidence" + ))); + } + } + if let Some(kind) = source.evidence_kind.as_ref() { + validate_nonempty("source evidence kind", kind.as_str())?; + } + } + for origin in record + .evidence + .iter() + .filter_map(|evidence| evidence.origin.as_ref()) + .chain( + record + .source_refs + .iter() + .filter_map(|source| source.origin.as_ref()), + ) + { + if let Some(origin_workspace) = origin.workspace_id.as_deref() + && origin_workspace != workspace_id + { + return Err(SubjektivError::SubjectScopeMismatch { + subject_id: record.subject_id.clone(), + reference: format!("evidence Workspace {origin_workspace}"), + }); + } + } + if let Some(proposal) = &record.revision_proposal { + validate_revision_proposal_metadata(proposal)?; + } + Ok(()) +} + +/// Applies the current write-admission contract separately from structural +/// validation. Persisted rows from older compatible implementations remain +/// readable even when a later release tightens admission limits. +fn validate_staging_admission(record: &SubjectStagingRecord) -> Result<()> { + if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS + { + return Err(SubjektivError::InvalidRecord(format!( + "staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each" + ))); + } + Ok(()) +} + +fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> { + validate_label("revision proposal Memory id", &proposal.memory_id)?; + if proposal.expected_revision == 0 { + return Err(SubjektivError::InvalidRecord( + "revision proposal expected_revision must be greater than zero".into(), + )); + } + validate_nonempty("revision proposal change_reason", &proposal.change_reason) +} + +fn validate_candidate_application( + transaction: &Transaction<'_>, + subject_id: &str, + candidate: &SubjectStagingRecord, + target: &MemoryRevisionTarget, + draft: &MemoryDraft, +) -> Result<()> { + let Some(proposal) = &candidate.revision_proposal else { + return Ok(()); + }; + let MemoryRevisionTarget::Revise { + memory_id, + expected_revision, + } = target + else { + return Err(SubjektivError::InvalidRecord( + "a revision proposal cannot create a different Memory".into(), + )); + }; + if memory_id != &proposal.memory_id || expected_revision != &proposal.expected_revision { + return Err(SubjektivError::InvalidRecord( + "candidate application target must exactly match revision proposal metadata".into(), + )); + } + let current = scoped_memory_in_connection(transaction, subject_id, memory_id)?; + if current.revision != *expected_revision { + return Err(SubjektivError::RevisionConflict { + memory_id: memory_id.clone(), + expected: *expected_revision, + actual: current.revision, + }); + } + let expected_state = match proposal.intent { + RevisionProposalIntent::Revise => current.state, + RevisionProposalIntent::Resolve => MemoryState::Resolved, + RevisionProposalIntent::Retract => MemoryState::Retracted, + RevisionProposalIntent::Reopen => MemoryState::Active, + }; + let intent_allowed = match proposal.intent { + RevisionProposalIntent::Revise => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Resolve => current.state == MemoryState::Active, + RevisionProposalIntent::Retract => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, + }; + if !intent_allowed || draft.state != expected_state { + return Err(SubjektivError::InvalidStateTransition { + memory_id: memory_id.clone(), + from: current.state, + to: draft.state, + }); + } + if draft.kind != current.kind || draft.kind != candidate.kind { + return Err(SubjektivError::InvalidRecord( + "revision proposal application must preserve the target Memory kind".into(), + )); + } + if draft.change_reason != proposal.change_reason { + return Err(SubjektivError::InvalidRecord( + "revision proposal application must preserve change_reason".into(), + )); + } + Ok(()) +} + +fn validate_staged_revision_proposal( + transaction: &Transaction<'_>, + record: &SubjectStagingRecord, +) -> Result<()> { + let Some(proposal) = &record.revision_proposal else { + return Ok(()); + }; + let current = + scoped_memory_in_connection(transaction, &record.subject_id, &proposal.memory_id)?; + if current.revision != proposal.expected_revision { + return Err(SubjektivError::RevisionConflict { + memory_id: proposal.memory_id.clone(), + expected: proposal.expected_revision, + actual: current.revision, + }); + } + let to = match proposal.intent { + RevisionProposalIntent::Revise => current.state, + RevisionProposalIntent::Resolve => MemoryState::Resolved, + RevisionProposalIntent::Retract => MemoryState::Retracted, + RevisionProposalIntent::Reopen => MemoryState::Active, + }; + let allowed = match proposal.intent { + RevisionProposalIntent::Revise => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Resolve => current.state == MemoryState::Active, + RevisionProposalIntent::Retract => { + matches!(current.state, MemoryState::Active | MemoryState::Resolved) + } + RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, + }; + if allowed { + Ok(()) + } else { + Err(SubjektivError::InvalidStateTransition { + memory_id: proposal.memory_id.clone(), + from: current.state, + to, + }) + } +} + +fn scoped_memory_in_connection( + connection: &rusqlite::Connection, + subject_id: &str, + memory_id: &str, +) -> Result { + let raw = connection + .query_row( + "SELECT record_json FROM memory_records + WHERE subject_id = ?1 AND memory_id = ?2", + params![subject_id, memory_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if let Some(raw) = raw { + return parse_memory(&raw); + } + let foreign_owner = connection + .query_row( + "SELECT subject_id FROM memory_records WHERE memory_id = ?1 LIMIT 1", + [memory_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + if foreign_owner.is_some() { + Err(SubjektivError::SubjectScopeMismatch { + subject_id: subject_id.to_string(), + reference: memory_id.to_string(), + }) + } else { + Err(SubjektivError::MemoryNotFound(memory_id.to_string())) + } +} + +fn validate_memory_draft(draft: &MemoryDraft) -> Result<()> { + validate_nonempty("Memory claim", &draft.claim)?; + validate_nonempty("Memory why_useful", &draft.why_useful)?; + validate_nonempty("Memory change_reason", &draft.change_reason)?; + for candidate_id in &draft.source_candidate_ids { + validate_label("source candidate id", candidate_id)?; + } + for reference in &draft.derived_from { + validate_label("derived Memory id", &reference.memory_id)?; + } + Ok(()) +} + +fn validate_direct_memory_draft(draft: &MemoryDraft) -> Result<()> { + if draft.source_candidate_ids.is_empty() { + Ok(()) + } else { + Err(SubjektivError::InvalidRecord( + "candidate provenance must be committed through apply_candidate(s)".into(), + )) + } +} + +fn validate_state_transition(memory_id: &str, from: MemoryState, to: MemoryState) -> Result<()> { + let allowed = from != MemoryState::Retracted; + if allowed { + Ok(()) + } else { + Err(SubjektivError::InvalidStateTransition { + memory_id: memory_id.to_string(), + from, + to, + }) + } +} + +fn parse_subject(raw: &str) -> Result { + let record: SubjectRecord = serde_json::from_str(raw)?; + validate_schema_version(record.schema_version, "subject")?; + Ok(record) +} + +fn equivalent_session_attribution( + left: &SubjectSessionAttribution, + right: &SubjectSessionAttribution, +) -> bool { + left.schema_version == right.schema_version + && left.subject_id == right.subject_id + && left.runtime_id == right.runtime_id + && left.worker_id == right.worker_id + && left.session_id == right.session_id +} + +fn equivalent_staging_payload(left: &str, right: &str) -> Result { + let mut left = serde_json::from_str::(left)?; + let mut right = serde_json::from_str::(right)?; + let left_object = left.as_object_mut().ok_or_else(|| { + SubjektivError::InvalidRecord("stored staging record is not a JSON object".into()) + })?; + let right_object = right.as_object_mut().ok_or_else(|| { + SubjektivError::InvalidRecord("new staging record is not a JSON object".into()) + })?; + // A retry may reconstruct the host envelope after an ambiguous response. + // Identity and extracted content must match, while the first persisted + // host-issued staging timestamp remains authoritative. + left_object.remove("created_at"); + right_object.remove("created_at"); + Ok(left == right) +} + +fn parse_session_attribution(raw: &str) -> Result { + let record: SubjectSessionAttribution = serde_json::from_str(raw)?; + validate_session_attribution(&record)?; + Ok(record) +} + +fn parse_staging(workspace_id: &str, raw: &str) -> Result { + let record = parse_staging_without_workspace(raw)?; + validate_staging_record(workspace_id, &record)?; + Ok(record) +} + +fn parse_staging_without_workspace(raw: &str) -> Result { + let record: SubjectStagingRecord = serde_json::from_str(raw)?; + if record.schema_version != STAGING_SCHEMA_VERSION { + return Err(SubjektivError::InvalidRecord(format!( + "staging schema version {} is unsupported", + record.schema_version + ))); + } + Ok(record) +} + +fn parse_memory(raw: &str) -> Result { + let record: MemoryRecord = serde_json::from_str(raw)?; + validate_schema_version(record.schema_version, "Memory")?; + if record.revision == 0 { + return Err(SubjektivError::InvalidRecord( + "Memory revision must be greater than zero".to_string(), + )); + } + Ok(record) +} + +fn parse_resolution(raw: &str) -> Result { + let record: StagingResolution = serde_json::from_str(raw)?; + validate_schema_version(record.schema_version, "staging resolution")?; + Ok(record) +} + +fn parse_surface_snapshot(raw: &str) -> Result { + let record: SurfaceSnapshot = serde_json::from_str(raw)?; + validate_schema_version(record.schema_version, "surface snapshot")?; + Ok(record) +} + +fn validate_schema_version(version: u32, kind: &str) -> Result<()> { + if version == SUBJEKTIV_SCHEMA_VERSION { + Ok(()) + } else { + Err(SubjektivError::InvalidRecord(format!( + "{kind} schema version {version} is unsupported" + ))) + } +} + +fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> { + if range[0] <= range[1] { + Ok(()) + } else { + Err(SubjektivError::InvalidRecord(format!( + "{kind} range is reversed" + ))) + } +} + +fn resident_surface_in_connection( + connection: &Connection, + subject: &SubjectRecord, +) -> Result { + let state = connection + .query_row( + "SELECT store_revision, status, snapshot_id + FROM surface_generation_state WHERE subject_id = ?1", + [&subject.id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional()?; + let Some((revision, status, snapshot_id)) = state else { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Ungenerated, + snapshot: None, + }); + }; + let revision = u64::try_from(revision) + .map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?; + if revision != subject.store_revision || status == "dirty" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Stale, + snapshot: None, + }); + } + if status == "failed" { + return Ok(ResidentSurface { + availability: SurfaceAvailability::Failed, + snapshot: None, + }); + } + let snapshot_id = snapshot_id.ok_or_else(|| { + SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) + })?; + let raw = connection.query_row( + "SELECT snapshot_json FROM surface_snapshots + WHERE subject_id = ?1 AND snapshot_id = ?2", + params![subject.id, snapshot_id], + |row| row.get::<_, String>(0), + )?; + let snapshot = parse_surface_snapshot(&raw)?; + if snapshot.built_from_store_revision != subject.store_revision { + return Err(SubjektivError::InvalidRecord( + "ready surface snapshot generation does not match subject".into(), + )); + } + Ok(ResidentSurface { + availability: SurfaceAvailability::Ready, + snapshot: Some(snapshot), + }) +} + +pub fn validate_subject_behavior(value: &str) -> Result<()> { + if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES { + return Err(SubjektivError::InvalidRecord(format!( + "subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes" + ))); + } + if !value.is_empty() && value.trim().is_empty() { + return Err(SubjektivError::InvalidRecord( + "subject behavior must be empty or contain non-whitespace text".to_string(), + )); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(SubjektivError::InvalidRecord( + "subject behavior contains an unsupported control character".to_string(), + )); + } + Ok(()) +} + +fn validate_label(kind: &str, value: &str) -> Result<()> { + validate_nonempty(kind, value)?; + if value.len() > 256 || value.chars().any(char::is_control) { + return Err(SubjektivError::InvalidRecord(format!( + "{kind} must be at most 256 bytes and contain no control characters" + ))); + } + Ok(()) +} + +fn validate_nonempty(kind: &str, value: &str) -> Result<()> { + if value.trim().is_empty() { + Err(SubjektivError::InvalidRecord(format!( + "{kind} must not be empty" + ))) + } else { + Ok(()) + } +} + +fn reject_duplicates(kind: &str, values: &[String]) -> Result<()> { + let mut seen = HashSet::new(); + if let Some(duplicate) = values.iter().find(|value| !seen.insert(value.as_str())) { + return Err(SubjektivError::InvalidRecord(format!( + "duplicate {kind} `{duplicate}`" + ))); + } + Ok(()) +} + +fn reject_duplicate_refs(kind: &str, values: &[MemoryRevisionRef]) -> Result<()> { + let mut seen = HashSet::new(); + if let Some(duplicate) = values + .iter() + .find(|value| !seen.insert((value.memory_id.as_str(), value.revision))) + { + return Err(SubjektivError::InvalidRecord(format!( + "duplicate {kind} `{}@{}`", + duplicate.memory_id, duplicate.revision + ))); + } + Ok(()) +} + +fn reject_duplicate_memory_ids(kind: &str, values: &[MemoryRevisionRef]) -> Result<()> { + let mut seen = HashSet::new(); + if let Some(duplicate) = values + .iter() + .find(|value| !seen.insert(value.memory_id.as_str())) + { + return Err(SubjektivError::InvalidRecord(format!( + "duplicate {kind} Memory `{}`", + duplicate.memory_id + ))); + } + Ok(()) +} + +fn require_job_generation( + connection: &Connection, + subject_id: &str, + generation_id: &str, + job_id: &str, + attempt_id: &str, +) -> Result { + let revision: Option = connection.query_row( + "SELECT store_revision FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2 AND job_id = ?3 AND attempt_id = ?4", + params![subject_id, generation_id, job_id, attempt_id], |row| row.get(0), + ).optional()?; + revision + .and_then(|value| u64::try_from(value).ok()) + .ok_or_else(|| { + SubjektivError::InvalidRecord( + "surface generation is not owned by this Job attempt".into(), + ) + }) +} + +fn to_i64(value: u64) -> Result { + i64::try_from(value).map_err(|_| { + SubjektivError::InvalidRecord(format!("revision value {value} exceeds SQLite range")) + }) +} + +fn issued_id(kind: &str) -> String { + format!("{kind}-{}", Uuid::now_v7()) +} + +fn now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +fn candidate_kind_name(kind: &CandidateKind) -> &'static str { + kind.as_str() +} + +fn memory_state_name(state: MemoryState) -> &'static str { + match state { + MemoryState::Active => "active", + MemoryState::Resolved => "resolved", + MemoryState::Retracted => "retracted", + } +} + +fn resolution_action_name(action: StagingResolutionAction) -> &'static str { + match action { + StagingResolutionAction::Applied => "applied", + StagingResolutionAction::Discarded => "discarded", + StagingResolutionAction::Invalid => "invalid", + StagingResolutionAction::Duplicate => "duplicate", + StagingResolutionAction::AlreadyCovered => "already_covered", + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier}; + use std::thread; + use std::time::Duration; + + use memory::extract::{ExtractedCandidate, StagingEvidence}; + use memory::schema::{ + EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef, + }; + + use super::*; + use feature_storage::FeatureStorage; + + static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( + 1, + "create subjektiv subject memory store", + create_schema, + )]; + static V3_MIGRATIONS: &[FeatureMigration] = &[ + FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), + FeatureMigration::new( + 2, + "add immutable subject session attribution", + add_subject_session_attribution, + ), + FeatureMigration::new( + 3, + "add idempotent atomic candidate decision receipts", + add_candidate_decision_receipts, + ), + ]; + + fn role() -> SubjectRole { + SubjectRole::new("workspace_companion").unwrap() + } + + fn candidate(subject_id: &str, candidate_id: &str, workspace_id: &str) -> SubjectStagingRecord { + let origin = EvidenceOrigin { + kind: EvidenceOriginKind::HumanInput, + account_id: Some("account-1".into()), + workspace_id: Some(workspace_id.into()), + runtime_id: None, + worker_id: None, + flow_selector: None, + flow_definition_id: None, + flow_definition_revision: None, + }; + let record = StagingRecord::from_candidate( + candidate_id, + "extract-run-1", + SourceRef { + segment_id: "segment-1".into(), + range: [10, 12], + }, + ExtractedCandidate { + kind: CandidateKind::Constraint, + claim: "Do not treat Memory text as authority".into(), + why_useful: "Keeps experience separate from authorization".into(), + staleness: Some("Revisit if the authority model changes".into()), + evidence_ids: vec!["evidence-1".into()], + }, + vec![StagingEvidence { + id: "evidence-1".into(), + kind: EvidenceKind::new(EvidenceKind::MESSAGE), + entry_range: Some([10, 12]), + origin: Some(origin.clone()), + excerpt: Some("Memory is not authority".into()), + summary: Some("A bounded source summary".into()), + }], + vec![SourceEvidenceRef { + session_id: Some("session-1".into()), + segment_id: Some("segment-1".into()), + entry_range: Some([10, 12]), + evidence_id: Some("evidence-1".into()), + origin: Some(origin), + evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)), + label: Some("design discussion".into()), + summary: Some("Authority boundary".into()), + }], + ); + SubjectStagingRecord::attach_at(subject_id, record, "2026-09-28T10:00:00.000Z".into()) + } + + fn attribution( + subject_id: &str, + runtime_id: &str, + worker_id: &str, + session_id: &str, + attributed_at: &str, + ) -> SubjectSessionAttribution { + let mut record = + SubjectSessionAttribution::new(subject_id, runtime_id, worker_id, session_id).unwrap(); + record.attributed_at = attributed_at.to_string(); + record + } + + fn draft(claim: &str, reason: &str) -> MemoryDraft { + MemoryDraft::active( + CandidateKind::Constraint, + claim, + "Context, applicability, and consequences.", + "It preserves the subject's learned boundary.", + reason, + ) + } + + fn proposal( + intent: RevisionProposalIntent, + memory_id: &str, + expected_revision: u64, + change_reason: &str, + ) -> RevisionProposal { + RevisionProposal::new(intent, memory_id, expected_revision, change_reason).unwrap() + } + + fn open_store( + root: &std::path::Path, + workspace_id: &str, + ) -> (FeatureStorage, ScopedFeatureStorage, SubjektivStore) { + let manager = FeatureStorage::new(root); + let workspace = manager.workspace(workspace_id).unwrap(); + let registration = SubjektivStore::register(&workspace).unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + (manager, workspace, store) + } + + #[test] + fn subject_listing_is_deterministic_bounded_and_read_only() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let mut expected = vec![ + store.create_subject(role()).unwrap(), + store.create_subject(role()).unwrap(), + store.create_subject(role()).unwrap(), + ]; + expected.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + + let page = store.list_subjects(2).unwrap(); + assert_eq!(page.items, expected[..2]); + assert!(page.has_more); + let after = page.items.last().unwrap(); + let next = store + .list_subjects_after(2, Some((&after.created_at, &after.id))) + .unwrap(); + assert_eq!(next.items, expected[2..]); + assert!(!next.has_more); + assert!(store.list_subjects(0).is_err()); + assert!(store.list_subjects(101).is_err()); + + assert!(store.subject("missing-subject").unwrap().is_none()); + let complete = store.list_subjects(3).unwrap(); + assert_eq!(complete.items, expected); + assert!(!complete.has_more); + } + + #[test] + fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store + .create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string()) + .unwrap(); + assert_eq!(subject.behavior_revision, 0); + assert_eq!(subject.store_revision, 0); + + let updated = store + .update_subject_behavior( + &subject.id, + 0, + "Prefer explicit evidence.\nAsk when uncertain.".to_string(), + ) + .unwrap(); + assert_eq!(updated.behavior_revision, 1); + assert_eq!(updated.store_revision, 0); + + store + .create_memory( + &subject.id, + draft( + "A confirmed observation", + "exercise Memory lifecycle isolation", + ), + ) + .unwrap(); + let after_memory = store.subject(&subject.id).unwrap().unwrap(); + assert_eq!(after_memory.behavior_md, updated.behavior_md); + assert_eq!(after_memory.behavior_revision, 1); + assert_eq!(after_memory.store_revision, 1); + + let (_reopened_manager, _reopened_workspace, reopened) = + open_store(temp.path(), "workspace-a"); + let persisted = reopened.subject(&subject.id).unwrap().unwrap(); + assert_eq!(persisted.behavior_md, updated.behavior_md); + assert_eq!(persisted.behavior_revision, 1); + assert!(matches!( + store.update_subject_behavior(&subject.id, 0, String::new()), + Err(SubjektivError::SubjectBehaviorConflict { + expected: 0, + actual: 1, + .. + }) + )); + + let unchanged = store + .update_subject_behavior(&subject.id, 1, updated.behavior_md.clone()) + .unwrap(); + assert_eq!(unchanged.behavior_revision, 1); + let cleared = store + .update_subject_behavior(&subject.id, 1, String::new()) + .unwrap(); + assert_eq!(cleared.behavior_revision, 2); + assert!(cleared.behavior_md.is_empty()); + assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err()); + assert!(validate_subject_behavior(" ").is_err()); + } + + #[test] + fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let session = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T09:00:00.000Z", + ); + + store.record_session_attribution(session.clone()).unwrap(); + let (first_candidate, stored_session) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + session.clone(), + ) + .unwrap(); + assert_eq!(stored_session, session); + assert_eq!( + first_candidate.source_refs[0].session_id.as_deref(), + Some("session-1") + ); + + let create_request = CandidateDecisionRequest { + request_id: "decision-create-1".into(), + candidate_id: first_candidate.id.clone(), + reason: "adopt the attributed candidate".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Create, + draft: draft( + "Keep the first confirmed rule", + "initial attributed decision", + ), + }, + }; + let created = store + .decide_candidate(&subject.id, create_request.clone()) + .unwrap(); + let first_revision = created.memory.unwrap(); + assert_eq!(first_revision.revision, 1); + assert_eq!( + first_revision.source_candidate_ids, + [first_candidate.id.clone()] + ); + assert_eq!(created.resolution.affected_memory.len(), 1); + assert_eq!(created.resolution.affected_memory[0].revision, 1); + + // Exact decision retries return the original receipt rather than creating + // a second revision or resolution. + let replayed = store.decide_candidate(&subject.id, create_request).unwrap(); + assert_eq!(replayed.store_revision, created.store_revision); + assert_eq!(replayed.memory.as_ref().unwrap().revision, 1); + + let (second_candidate, _) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-2", "workspace-a"), + session, + ) + .unwrap(); + let revised = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-revise-2".into(), + candidate_id: second_candidate.id.clone(), + reason: "apply the attributed correction".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: first_revision.id.clone(), + expected_revision: 1, + }, + draft: draft("Keep the corrected confirmed rule", "attributed correction"), + }, + }, + ) + .unwrap(); + let second_revision = revised.memory.unwrap(); + assert_eq!(second_revision.revision, 2); + assert_eq!(second_revision.source_candidate_ids, [second_candidate.id]); + + let historical = store + .scoped_memory_revision(&subject.id, &first_revision.id, 1) + .unwrap() + .unwrap(); + assert_eq!(historical.source_candidate_ids, [first_candidate.id]); + assert_eq!( + store + .session_attribution("session-1") + .unwrap() + .unwrap() + .subject_id, + subject.id + ); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.materials.len(), 1); + assert_eq!(generation.materials[0].memory_id, second_revision.id); + assert_eq!(generation.materials[0].revision, 2); + let expected_ref = MemoryRevisionRef { + memory_id: second_revision.id.clone(), + revision: second_revision.revision, + }; + let published = store + .publish_surface_generation( + &subject.id, + &generation.id, + vec![SurfacePoint { + body_md: "- Keep the corrected confirmed rule".into(), + memory_refs: vec![expected_ref.clone()], + }], + ) + .unwrap(); + assert_eq!(published.memory_refs, [expected_ref.clone()]); + assert_eq!(published.built_from_store_revision, revised.store_revision); + + let resident = store.resident_surface(&subject.id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Ready); + assert_eq!(resident.snapshot.unwrap().memory_refs, [expected_ref]); + } + + #[test] + fn versioned_json_roundtrips_and_rejects_invalid_shapes() { + let staging = candidate("subject-1", "candidate-1", "workspace-a"); + let raw = serde_json::to_string_pretty(&staging).unwrap(); + let decoded: SubjectStagingRecord = serde_json::from_str(&raw).unwrap(); + assert_eq!(decoded.schema_version, STAGING_SCHEMA_VERSION); + assert_eq!(decoded.subject_id, "subject-1"); + assert_eq!( + decoded.source_refs[0].session_id.as_deref(), + Some("session-1") + ); + assert!(decoded.revision_proposal.is_none()); + assert!(!raw.contains("revision_proposal")); + assert!(raw.contains("human_input")); + + let proposed = staging + .clone() + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + "memory-1", + 3, + "Incorporate the new bounded evidence", + )) + .unwrap(); + let proposed_raw = serde_json::to_string(&proposed).unwrap(); + let proposed_decoded: SubjectStagingRecord = serde_json::from_str(&proposed_raw).unwrap(); + assert_eq!( + proposed_decoded.revision_proposal, + Some(proposal( + RevisionProposalIntent::Revise, + "memory-1", + 3, + "Incorporate the new bounded evidence", + )) + ); + assert!( + RevisionProposal::new( + RevisionProposalIntent::Revise, + "memory-1", + 0, + "invalid zero revision" + ) + .is_err() + ); + assert!( + RevisionProposal::new(RevisionProposalIntent::Revise, "memory-1", 1, " ").is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "intent": "revise", + "memory_id": "memory-1", + "expected_revision": 0, + "change_reason": "invalid" + })) + .is_err() + ); + + let mut value = serde_json::to_value(&staging).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("invented".into(), serde_json::json!(true)); + assert!(serde_json::from_value::(value).is_err()); + + let memory = MemoryRecord { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: "memory-1".into(), + subject_id: "subject-1".into(), + revision: 1, + kind: CandidateKind::Constraint, + state: MemoryState::Active, + claim: "Memory is not authority".into(), + body_md: "Applies to every tool call.".into(), + why_useful: "Avoids privilege confusion".into(), + staleness: None, + source_candidate_ids: vec!["candidate-1".into()], + derived_from: vec![], + change_reason: "Accepted the staged constraint".into(), + created_at: "2026-09-28T10:00:00.000Z".into(), + updated_at: "2026-09-28T10:00:00.000Z".into(), + }; + let raw = serde_json::to_string(&memory).unwrap(); + let decoded: MemoryRecord = serde_json::from_str(&raw).unwrap(); + assert_eq!(decoded.revision, 1); + assert!(serde_json::from_str::(&raw.replace("active", "unknown")).is_err()); + } + + #[test] + fn v1_store_migrates_before_attributed_staging() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let subject_id = { + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let registration = workspace + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + V1_MIGRATIONS, + )) + .unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + let timestamp = now(); + let subject_id = issued_id("subject"); + let mut raw = serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp.clone(), + "updated_at": timestamp.clone(), + }); + let raw = serde_json::to_string(&raw.take()).unwrap(); + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![subject_id, role().as_str(), raw, timestamp, timestamp], + )?; + Ok(()) + }) + .unwrap(); + subject_id + }; + + let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); + assert!(migrated_subject.behavior_md.is_empty()); + assert_eq!(migrated_subject.behavior_revision, 0); + let lifecycle_attribution = attribution( + &subject_id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ); + store + .record_session_attribution(lifecycle_attribution.clone()) + .unwrap(); + let (staged, stored_attribution) = store + .stage_candidate_with_attribution( + candidate(&subject_id, "candidate-after-migration", "workspace-a"), + lifecycle_attribution, + ) + .unwrap(); + + assert_eq!(staged.subject_id, subject_id); + assert_eq!(stored_attribution.session_id, "session-1"); + assert_eq!( + store.session_attribution("session-1").unwrap().unwrap(), + stored_attribution + ); + } + + #[test] + fn legacy_surface_snapshot_migrates_as_stale_until_regenerated() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let (subject_id, snapshot_id) = { + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + let registration = workspace + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + V3_MIGRATIONS, + )) + .unwrap(); + let store = SubjektivStore::open(&workspace, ®istration).unwrap(); + let subject_id = issued_id("subject"); + let timestamp = now(); + let subject_json = serde_json::to_string(&serde_json::json!({ + "schema_version": SUBJEKTIV_SCHEMA_VERSION, + "id": subject_id, + "role": role(), + "state": SubjectState::Active, + "store_revision": 0, + "created_at": timestamp, + "updated_at": timestamp, + })) + .unwrap(); + let snapshot = SurfaceSnapshot { + schema_version: SUBJEKTIV_SCHEMA_VERSION, + id: "legacy-surface".into(), + subject_id: subject_id.clone(), + body_md: "Legacy summary without generation-policy evidence.".into(), + memory_refs: Vec::new(), + built_from_store_revision: 0, + created_at: now(), + }; + store + .database + .try_transaction(|transaction| -> Result<()> { + transaction.execute( + "INSERT INTO subjects ( + subject_id, role, state, store_revision, + record_json, created_at, updated_at + ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", + params![ + subject_id, + role().as_str(), + subject_json, + timestamp, + timestamp + ], + )?; + transaction.execute( + "INSERT INTO surface_snapshots ( + subject_id, snapshot_id, built_from_store_revision, + snapshot_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + subject_id, + snapshot.id, + to_i64(snapshot.built_from_store_revision)?, + serde_json::to_string(&snapshot)?, + snapshot.created_at + ], + )?; + transaction.execute( + "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) + VALUES (?1, ?2)", + params![subject_id, snapshot.id], + )?; + Ok(()) + }) + .unwrap(); + (subject_id, snapshot.id) + }; + + let (_manager, _workspace, store) = open_store(&root, "workspace-a"); + let resident = store.resident_surface(&subject_id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Stale); + assert!(resident.snapshot.is_none()); + assert!( + store + .surface_snapshot(&subject_id, &snapshot_id) + .unwrap() + .is_some(), + "migration retains the legacy snapshot as history without injecting it" + ); + + let generation = store.prepare_surface_generation(&subject_id).unwrap(); + let regenerated = store + .publish_surface_generation(&subject_id, &generation.id, Vec::new()) + .unwrap(); + assert_ne!(regenerated.id, snapshot_id); + let ready = store.resident_surface(&subject_id).unwrap(); + assert_eq!(ready.availability, SurfaceAvailability::Ready); + assert_eq!(ready.snapshot.unwrap().id, regenerated.id); + assert!( + store + .surface_snapshot(&subject_id, &snapshot_id) + .unwrap() + .is_some(), + "regeneration appends instead of mutating legacy history" + ); + let retried = store + .publish_surface_generation(&subject_id, &generation.id, Vec::new()) + .unwrap(); + assert_eq!(retried.id, regenerated.id); + } + + #[test] + fn attributed_staging_requires_lifecycle_record_and_retry_is_idempotent() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let first_attribution = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ); + assert!(matches!( + store.stage_candidate_with_attribution( + candidate(&subject.id, "candidate-unattributed", "workspace-a"), + first_attribution.clone(), + ), + Err(SubjektivError::InvalidRecord(message)) + if message.contains("has not completed Host attribution") + )); + assert!(store.session_attribution("session-1").unwrap().is_none()); + assert!( + store + .staging_candidate(&subject.id, "candidate-unattributed") + .unwrap() + .is_none() + ); + store + .record_session_attribution(first_attribution.clone()) + .unwrap(); + let (first_candidate, first_attribution) = store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + first_attribution, + ) + .unwrap(); + + let mut retried_candidate = first_candidate.clone(); + retried_candidate.created_at = "2026-09-28T11:00:00.000Z".into(); + let retried_attribution = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T11:00:00.000Z", + ); + let (retried_candidate, retried_attribution) = store + .stage_candidate_with_attribution(retried_candidate, retried_attribution) + .unwrap(); + assert_eq!(retried_candidate.created_at, first_candidate.created_at); + assert_eq!(retried_attribution, first_attribution); + + let mut conflicting_candidate = candidate(&subject.id, "candidate-1", "workspace-a"); + conflicting_candidate.claim = "different content".into(); + conflicting_candidate.source_refs[0].session_id = Some("session-2".into()); + let conflicting_attribution = attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-2", + "2026-09-28T12:00:00.000Z", + ); + store + .record_session_attribution(conflicting_attribution.clone()) + .unwrap(); + let result = + store.stage_candidate_with_attribution(conflicting_candidate, conflicting_attribution); + assert!(matches!( + result, + Err(SubjektivError::CandidateConflict(candidate_id)) + if candidate_id == "candidate-1" + )); + assert!(store.session_attribution("session-2").unwrap().is_some()); + } + + #[test] + fn attribution_pages_are_bounded_descending_and_snapshot_fixed() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + for (session, at) in [ + ("session-1", "2026-09-28T10:00:00.000Z"), + ("session-2", "2026-09-28T11:00:00.000Z"), + ("session-3", "2026-09-28T11:00:00.000Z"), + ] { + store + .record_session_attribution(attribution( + &subject.id, + "runtime-1", + "worker-1", + session, + at, + )) + .unwrap(); + } + + let first = store + .subject_session_attribution_page( + &subject.id, + None, + "2026-09-28T11:30:00.000Z", + None, + 2, + ) + .unwrap(); + assert_eq!( + first + .items + .iter() + .map(|item| item.session_id.as_str()) + .collect::>(), + vec!["session-2", "session-3"] + ); + assert!(first.has_more); + + store + .record_session_attribution(attribution( + &subject.id, + "runtime-2", + "worker-2", + "session-later", + "2026-09-28T12:00:00.000Z", + )) + .unwrap(); + let last = first.items.last().unwrap(); + let second = store + .subject_session_attribution_page( + &subject.id, + None, + "2026-09-28T11:30:00.000Z", + Some((&last.attributed_at, &last.session_id)), + 2, + ) + .unwrap(); + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].session_id, "session-1"); + assert!(!second.has_more); + assert!( + second + .items + .iter() + .all(|item| item.session_id != "session-later") + ); + + let exact = store + .subject_session_attribution_page( + &subject.id, + Some("session-1"), + "2026-09-28T11:30:00.000Z", + None, + 20, + ) + .unwrap(); + assert_eq!(exact.items.len(), 1); + assert!(!exact.has_more); + } + + #[test] + fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other_subject = store.create_subject(role()).unwrap(); + for lifecycle_attribution in [ + attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ), + attribution( + &subject.id, + "runtime-2", + "worker-2", + "session-2", + "2026-09-28T11:00:00.000Z", + ), + ] { + store + .record_session_attribution(lifecycle_attribution) + .unwrap(); + } + + store + .stage_candidate_with_attribution( + candidate(&subject.id, "candidate-1", "workspace-a"), + attribution( + &subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T10:00:00.000Z", + ), + ) + .unwrap(); + let mut replacement_candidate = candidate(&subject.id, "candidate-2", "workspace-a"); + replacement_candidate.source_refs[0].session_id = None; + let (replacement_candidate, _) = store + .stage_candidate_with_attribution( + replacement_candidate, + attribution( + &subject.id, + "runtime-2", + "worker-2", + "session-2", + "2026-09-28T11:00:00.000Z", + ), + ) + .unwrap(); + assert_eq!( + replacement_candidate.source_refs[0].session_id.as_deref(), + Some("session-2") + ); + + let history = store.subject_session_attributions(&subject.id).unwrap(); + assert_eq!(history.len(), 2); + assert_eq!(history[0].worker_id, "worker-1"); + assert_eq!(history[1].worker_id, "worker-2"); + assert!(history.iter().all(|entry| entry.subject_id == subject.id)); + + let update = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "UPDATE subject_session_attributions SET worker_id = 'worker-other' + WHERE session_id = 'session-1'", + [], + )?; + Ok(()) + }); + assert!(update.is_err()); + let delete = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "DELETE FROM subject_session_attributions WHERE session_id = 'session-1'", + [], + )?; + Ok(()) + }); + assert!(delete.is_err()); + + let mut foreign_candidate = + candidate(&other_subject.id, "candidate-foreign", "workspace-a"); + foreign_candidate.source_refs[0].session_id = Some("session-1".into()); + let result = store.stage_candidate_with_attribution( + foreign_candidate, + attribution( + &other_subject.id, + "runtime-1", + "worker-1", + "session-1", + "2026-09-28T12:00:00.000Z", + ), + ); + assert!(matches!( + result, + Err(SubjektivError::SessionSubjectConflict { + session_id, + existing_subject_id, + requested_subject_id, + }) if session_id == "session-1" + && existing_subject_id == subject.id + && requested_subject_id == other_subject.id + )); + assert!( + store + .staging_candidate(&other_subject.id, "candidate-foreign") + .unwrap() + .is_none() + ); + } + + #[test] + fn malformed_nested_provenance_is_rejected_without_staging() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + + let mut non_human_preference = + candidate(&subject.id, "candidate-preference", "workspace-a"); + non_human_preference.kind = CandidateKind::Preference; + non_human_preference.evidence[0] + .origin + .as_mut() + .unwrap() + .kind = EvidenceOriginKind::ModelOutput; + assert!(matches!( + store.stage_candidate(non_human_preference), + Err(SubjektivError::InvalidRecord(message)) + if message.contains("HumanInput") + )); + + let mut reversed = candidate(&subject.id, "candidate-reversed", "workspace-a"); + reversed.evidence[0].entry_range = Some([12, 10]); + assert!(matches!( + store.stage_candidate(reversed), + Err(SubjektivError::InvalidRecord(_)) + )); + + let mut duplicate = candidate(&subject.id, "candidate-duplicate", "workspace-a"); + duplicate.evidence.push(duplicate.evidence[0].clone()); + assert!(matches!( + store.stage_candidate(duplicate), + Err(SubjektivError::InvalidRecord(_)) + )); + + let mut dangling = candidate(&subject.id, "candidate-dangling", "workspace-a"); + dangling.source_refs[0].evidence_id = Some("missing-evidence".into()); + assert!(matches!( + store.stage_candidate(dangling), + Err(SubjektivError::InvalidRecord(_)) + )); + assert!( + store + .staging_candidate(&subject.id, "candidate-dangling") + .unwrap() + .is_none() + ); + } + + #[test] + fn revision_proposal_roundtrips_through_staging_and_resolution() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Existing memory", "initial observation")) + .unwrap(); + let proposal = proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "Refine the wording from new evidence", + ); + let staged = store + .stage_candidate( + candidate(&subject.id, "candidate-proposed", "workspace-a") + .with_revision_proposal(proposal.clone()) + .unwrap(), + ) + .unwrap(); + assert_eq!(staged.revision_proposal, Some(proposal.clone())); + assert_eq!( + store + .staging_candidate(&subject.id, &staged.id) + .unwrap() + .unwrap() + .revision_proposal, + Some(proposal.clone()) + ); + + let resolution = store + .resolve_candidate( + &subject.id, + &staged.id, + StagingResolutionAction::Discarded, + "The current wording remains preferable", + vec![], + ) + .unwrap(); + assert_eq!( + resolution.candidate.revision_proposal, + Some(proposal.clone()) + ); + assert_eq!( + store + .staging_resolution(&subject.id, &staged.id) + .unwrap() + .unwrap() + .candidate + .revision_proposal, + Some(proposal) + ); + } + + #[test] + fn atomic_candidate_decision_is_idempotent_and_preserves_proposal_metadata() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Original", "initial observation")) + .unwrap(); + let change_reason = "The remembered condition has ended"; + let staged = store + .stage_candidate( + candidate(&subject.id, "candidate-resolve-atomic", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Resolve, + &memory.id, + memory.revision, + change_reason, + )) + .unwrap(), + ) + .unwrap(); + let mut revised = draft("Resolved", change_reason); + revised.state = MemoryState::Resolved; + let request = CandidateDecisionRequest { + request_id: "decision-resolve-1".into(), + candidate_id: staged.id.clone(), + reason: "Accepted exact resolution proposal".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: memory.id.clone(), + expected_revision: memory.revision, + }, + draft: revised, + }, + }; + + let first = store + .decide_candidate(&subject.id, request.clone()) + .unwrap(); + let retried = store.decide_candidate(&subject.id, request).unwrap(); + + assert_eq!(first.memory.as_ref().unwrap().revision, 2); + assert_eq!(retried.memory.as_ref().unwrap().revision, 2); + assert_eq!(first.resolution.id, retried.resolution.id); + assert_eq!(first.store_revision, 2); + assert!(first.surface_dirty); + assert_eq!( + first + .resolution + .candidate + .revision_proposal + .unwrap() + .change_reason, + change_reason + ); + assert_eq!( + store + .list_memory_revisions(&subject.id, &memory.id) + .unwrap() + .len(), + 2 + ); + } + + #[test] + fn candidate_decision_revalidates_stale_proposals_and_intent_transitions() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Lifecycle", "initial")) + .unwrap(); + let stale = store + .stage_candidate( + candidate(&subject.id, "candidate-stale-apply", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "stale correction", + )) + .unwrap(), + ) + .unwrap(); + store + .revise_memory(&subject.id, &memory.id, 1, draft("Advanced", "advance")) + .unwrap(); + let stale_result = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-stale".into(), + candidate_id: stale.id.clone(), + reason: "must not auto-rebase".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: memory.id.clone(), + expected_revision: 1, + }, + draft: draft("Stale", "stale correction"), + }, + }, + ); + assert!(matches!( + stale_result, + Err(SubjektivError::RevisionConflict { + expected: 1, + actual: 2, + .. + }) + )); + assert!( + store + .staging_resolution(&subject.id, &stale.id) + .unwrap() + .is_none() + ); + + let apply_intent = |store: &SubjektivStore, + candidate_id: &str, + intent: RevisionProposalIntent, + expected_revision: u64, + state: MemoryState, + reason: &str| { + let staged = store + .stage_candidate( + candidate(&subject.id, candidate_id, "workspace-a") + .with_revision_proposal(proposal( + intent, + &memory.id, + expected_revision, + reason, + )) + .unwrap(), + ) + .unwrap(); + let mut next = draft(candidate_id, reason); + next.state = state; + store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: format!("decision-{candidate_id}"), + candidate_id: staged.id, + reason: format!("apply {candidate_id}"), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Revise { + memory_id: memory.id.clone(), + expected_revision, + }, + draft: next, + }, + }, + ) + .unwrap() + .memory + .unwrap() + }; + let resolved = apply_intent( + &store, + "resolve", + RevisionProposalIntent::Resolve, + 2, + MemoryState::Resolved, + "resolve reason", + ); + let reopened = apply_intent( + &store, + "reopen", + RevisionProposalIntent::Reopen, + resolved.revision, + MemoryState::Active, + "reopen reason", + ); + let retracted = apply_intent( + &store, + "retract", + RevisionProposalIntent::Retract, + reopened.revision, + MemoryState::Retracted, + "retract reason", + ); + assert_eq!(retracted.state, MemoryState::Retracted); + assert!(matches!( + store.stage_candidate( + candidate(&subject.id, "post-retract", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Reopen, + &memory.id, + retracted.revision, + "cannot revive", + )) + .unwrap() + ), + Err(SubjektivError::InvalidStateTransition { .. }) + )); + } + + #[test] + fn candidate_decisions_reject_conflicts_and_roll_back_partial_writes() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other = store.create_subject(role()).unwrap(); + let staged = store + .stage_candidate(candidate(&subject.id, "candidate-rollback", "workspace-a")) + .unwrap(); + let foreign = store + .stage_candidate(candidate(&other.id, "candidate-foreign", "workspace-a")) + .unwrap(); + let mut invalid = draft("Would be written", "must roll back"); + invalid.derived_from.push(MemoryRevisionRef { + memory_id: "missing-memory".into(), + revision: 1, + }); + let failed = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-rollback".into(), + candidate_id: staged.id.clone(), + reason: "invalid derivation".into(), + decision: CandidateDecision::Apply { + target: MemoryRevisionTarget::Create, + draft: invalid, + }, + }, + ); + assert!(matches!( + failed, + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + assert!( + store + .staging_resolution(&subject.id, &staged.id) + .unwrap() + .is_none() + ); + assert!(store.list_memories(&subject.id).unwrap().is_empty()); + + let cross_subject = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-foreign".into(), + candidate_id: foreign.id, + reason: "must remain scoped".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::Invalid, + affected_memory: vec![], + }, + }, + ); + assert!(matches!( + cross_subject, + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + + let closed = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-close".into(), + candidate_id: staged.id.clone(), + reason: "already represented elsewhere".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::AlreadyCovered, + affected_memory: vec![], + }, + }, + ) + .unwrap(); + assert!(!closed.surface_dirty); + assert!(closed.memory.is_none()); + for (suffix, action) in [ + ("discarded", StagingResolutionAction::Discarded), + ("invalid", StagingResolutionAction::Invalid), + ("duplicate", StagingResolutionAction::Duplicate), + ] { + let candidate_id = format!("candidate-{suffix}"); + store + .stage_candidate(candidate(&subject.id, &candidate_id, "workspace-a")) + .unwrap(); + let receipt = store + .decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: format!("decision-{suffix}"), + candidate_id, + reason: format!("specific {suffix} reason"), + decision: CandidateDecision::Close { + action, + affected_memory: vec![], + }, + }, + ) + .unwrap(); + assert_eq!(receipt.resolution.action, action); + assert!(!receipt.surface_dirty); + } + let conflicting_retry = store.decide_candidate( + &subject.id, + CandidateDecisionRequest { + request_id: "decision-close".into(), + candidate_id: staged.id, + reason: "different disposition".into(), + decision: CandidateDecision::Close { + action: StagingResolutionAction::Discarded, + affected_memory: vec![], + }, + }, + ); + assert!(matches!( + conflicting_retry, + Err(SubjektivError::DecisionRequestConflict(_)) + )); + } + + #[test] + fn revision_proposals_enforce_scope_revision_and_state_transitions() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other_subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory( + &subject.id, + draft("Transition target", "initial observation"), + ) + .unwrap(); + + let foreign = candidate(&other_subject.id, "candidate-foreign-target", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "Must remain subject scoped", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(foreign), + Err(SubjektivError::SubjectScopeMismatch { + subject_id, + reference, + }) if subject_id == other_subject.id && reference == memory.id + )); + + let stale = candidate(&subject.id, "candidate-stale", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Resolve, + &memory.id, + 2, + "Resolve the active memory", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(stale), + Err(SubjektivError::RevisionConflict { + memory_id, + expected: 2, + actual: 1, + }) if memory_id == memory.id + )); + + let resolve_proposal = candidate(&subject.id, "candidate-resolve", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Resolve, + &memory.id, + 1, + "The active condition has ended", + )) + .unwrap(); + assert!(store.stage_candidate(resolve_proposal).is_ok()); + let invalid_reopen = candidate(&subject.id, "candidate-invalid-reopen", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Reopen, + &memory.id, + 1, + "An active Memory cannot be reopened", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(invalid_reopen), + Err(SubjektivError::InvalidStateTransition { + from: MemoryState::Active, + to: MemoryState::Active, + .. + }) + )); + + let mut resolve = draft("Resolved target", "the condition ended"); + resolve.state = MemoryState::Resolved; + let resolved = store + .revise_memory(&subject.id, &memory.id, 1, resolve) + .unwrap(); + let mut attributed_stale = + candidate(&subject.id, "candidate-attributed-stale", "workspace-a"); + attributed_stale.source_refs[0].session_id = None; + attributed_stale + .attach_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "This attribution must roll back with the stale proposal", + )) + .unwrap(); + let stale_attribution = attribution( + &subject.id, + "runtime-proposal", + "worker-proposal", + "session-proposal-stale", + "2026-09-28T12:00:00.000Z", + ); + store + .record_session_attribution(stale_attribution.clone()) + .unwrap(); + assert!(matches!( + store.stage_candidate_with_attribution(attributed_stale, stale_attribution,), + Err(SubjektivError::RevisionConflict { + expected: 1, + actual: 2, + .. + }) + )); + assert!( + store + .session_attribution("session-proposal-stale") + .unwrap() + .is_some() + ); + let reopen = candidate(&subject.id, "candidate-reopen", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Reopen, + &memory.id, + resolved.revision, + "The condition applies again", + )) + .unwrap(); + assert_eq!( + store + .stage_candidate(reopen) + .unwrap() + .revision_proposal + .unwrap() + .intent, + RevisionProposalIntent::Reopen + ); + let retract_proposal = candidate(&subject.id, "candidate-retract", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Retract, + &memory.id, + resolved.revision, + "The supporting evidence was invalidated", + )) + .unwrap(); + assert!(store.stage_candidate(retract_proposal).is_ok()); + + let mut retract = draft("Retracted target", "the evidence was invalidated"); + retract.state = MemoryState::Retracted; + let retracted = store + .revise_memory(&subject.id, &memory.id, resolved.revision, retract) + .unwrap(); + let terminal = candidate(&subject.id, "candidate-after-retract", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + retracted.revision, + "Retraction must remain terminal", + )) + .unwrap(); + assert!(matches!( + store.stage_candidate(terminal), + Err(SubjektivError::InvalidStateTransition { + memory_id, + from: MemoryState::Retracted, + to: MemoryState::Retracted, + }) if memory_id == memory.id + )); + } + + #[test] + fn exact_proposal_retry_remains_idempotent_after_target_advances() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Retry target", "initial observation")) + .unwrap(); + let staged = store + .stage_candidate( + candidate(&subject.id, "candidate-retry-proposal", "workspace-a") + .with_revision_proposal(proposal( + RevisionProposalIntent::Revise, + &memory.id, + 1, + "Refine the current revision", + )) + .unwrap(), + ) + .unwrap(); + store + .revise_memory( + &subject.id, + &memory.id, + 1, + draft("Advanced target", "another accepted refinement"), + ) + .unwrap(); + + let mut retry = staged.clone(); + retry.created_at = "2026-09-28T11:00:00.000Z".into(); + let retried = store.stage_candidate(retry).unwrap(); + assert_eq!(retried.created_at, staged.created_at); + assert_eq!(retried.revision_proposal, staged.revision_proposal); + } + + #[test] + fn bounded_memory_reads_are_scoped_ordered_and_immutable() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other_subject = store.create_subject(role()).unwrap(); + assert!(matches!( + store.list_memories("subject-missing"), + Err(SubjektivError::SubjectNotFound(subject_id)) if subject_id == "subject-missing" + )); + + let mut first_draft = draft("First memory", "first reason"); + first_draft.body_md = "Immutable first body".into(); + let first = store.create_memory(&subject.id, first_draft).unwrap(); + thread::sleep(Duration::from_millis(2)); + let second = store + .create_memory(&subject.id, draft("Second memory", "second reason")) + .unwrap(); + let memories = store.list_memories(&subject.id).unwrap(); + assert_eq!( + memories.iter().map(|record| &record.id).collect::>(), + vec![&second.id, &first.id] + ); + + thread::sleep(Duration::from_millis(2)); + let mut revision = draft("First memory revised", "second revision reason"); + revision.body_md = "New second body".into(); + let revised = store + .revise_memory(&subject.id, &first.id, 1, revision) + .unwrap(); + let memories = store.list_memories(&subject.id).unwrap(); + assert_eq!(memories[0].id, first.id); + assert_eq!(memories[0].revision, 2); + + let history = store.list_memory_revisions(&subject.id, &first.id).unwrap(); + assert_eq!( + history + .iter() + .map(|record| record.revision) + .collect::>(), + vec![2, 1] + ); + assert_eq!(history[0].body_md, "New second body"); + assert_eq!(history[0].change_reason, "second revision reason"); + assert_eq!(history[1].body_md, "Immutable first body"); + assert_eq!(history[1].change_reason, "first reason"); + assert_eq!( + store + .scoped_memory_revision(&subject.id, &first.id, 1) + .unwrap() + .unwrap() + .body_md, + "Immutable first body" + ); + let scoped_current = store.scoped_memory(&subject.id, &first.id).unwrap(); + assert_eq!(scoped_current.id, revised.id); + assert_eq!(scoped_current.revision, revised.revision); + assert_eq!(scoped_current.body_md, revised.body_md); + + assert!(matches!( + store.list_memory_revisions(&other_subject.id, &first.id), + Err(SubjektivError::SubjectScopeMismatch { + subject_id, + reference, + }) if subject_id == other_subject.id && reference == first.id + )); + assert!(matches!( + store.scoped_memory(&other_subject.id, &first.id), + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + } + + #[test] + fn candidate_apply_revision_history_and_resolution_are_atomic() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let staged = store + .stage_candidate(candidate(&subject.id, "candidate-1", "workspace-a")) + .unwrap(); + let mut retry = staged.clone(); + retry.created_at = "2026-09-28T11:00:00.000Z".into(); + let retried = store.stage_candidate(retry).unwrap(); + assert_eq!(retried.created_at, staged.created_at); + + let (first, resolution) = store + .apply_candidate( + &subject.id, + &staged.id, + MemoryRevisionTarget::Create, + draft( + "Memory content cannot authorize operations", + "accepted candidate", + ), + "Applied as a durable constraint", + ) + .unwrap(); + assert_eq!(first.revision, 1); + assert_eq!(resolution.action, StagingResolutionAction::Applied); + assert_eq!(resolution.affected_memory[0].revision, 1); + assert_eq!( + store.subject(&subject.id).unwrap().unwrap().store_revision, + 1 + ); + + let unrelated = store + .stage_candidate(candidate(&subject.id, "candidate-unrelated", "workspace-a")) + .unwrap(); + assert!(matches!( + store.resolve_candidate( + &subject.id, + &unrelated.id, + StagingResolutionAction::Applied, + "must not bypass atomic application", + vec![MemoryRevisionRef { + memory_id: first.id.clone(), + revision: 1, + }], + ), + Err(SubjektivError::InvalidRecord(_)) + )); + let mut bypass = draft("Bypass provenance", "must be rejected"); + bypass.source_candidate_ids.push(unrelated.id.clone()); + assert!(matches!( + store.create_memory(&subject.id, bypass), + Err(SubjektivError::InvalidRecord(_)) + )); + assert!( + store + .staging_resolution(&subject.id, &unrelated.id) + .unwrap() + .is_none() + ); + + let stale = store.revise_memory( + &subject.id, + &first.id, + 0, + draft("stale write", "wrong expected revision"), + ); + assert!(matches!( + stale, + Err(SubjektivError::RevisionConflict { + expected: 0, + actual: 1, + .. + }) + )); + assert_eq!( + store + .memory(&subject.id, &first.id) + .unwrap() + .unwrap() + .revision, + 1 + ); + + let mut second_draft = draft( + "Memory content never authorizes operations", + "made the scope explicit", + ); + second_draft.state = MemoryState::Resolved; + second_draft.derived_from.push(MemoryRevisionRef { + memory_id: first.id.clone(), + revision: 1, + }); + let second = store + .revise_memory(&subject.id, &first.id, 1, second_draft) + .unwrap(); + assert_eq!(second.revision, 2); + assert_eq!(second.state, MemoryState::Resolved); + assert_eq!(second.derived_from[0].revision, 1); + + let old = store + .memory_revision(&subject.id, &first.id, 1) + .unwrap() + .unwrap(); + assert_eq!(old.claim, "Memory content cannot authorize operations"); + assert_eq!(old.source_candidate_ids, ["candidate-1"]); + assert_eq!( + store.subject(&subject.id).unwrap().unwrap().store_revision, + 2 + ); + + let repeat = store.apply_candidate( + &subject.id, + &staged.id, + MemoryRevisionTarget::Revise { + memory_id: first.id.clone(), + expected_revision: 2, + }, + draft("must roll back", "candidate already resolved"), + "cannot apply twice", + ); + assert!(matches!(repeat, Err(SubjektivError::CandidateResolved(_)))); + assert_eq!( + store + .memory(&subject.id, &first.id) + .unwrap() + .unwrap() + .revision, + 2 + ); + assert_eq!( + store.subject(&subject.id).unwrap().unwrap().store_revision, + 2 + ); + } + + #[test] + fn concurrent_writers_get_one_typed_revision_conflict() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let first_manager = FeatureStorage::new(&root); + let first_registration = first_manager.register(REGISTRATION).unwrap(); + let first_workspace = first_manager.workspace("workspace-a").unwrap(); + let first_store = SubjektivStore::open(&first_workspace, &first_registration).unwrap(); + let subject = first_store.create_subject(role()).unwrap(); + let memory = first_store + .create_memory(&subject.id, draft("Revision basis", "initial")) + .unwrap(); + + let second_manager = FeatureStorage::new(&root); + let second_registration = second_manager.register(REGISTRATION).unwrap(); + let second_workspace = second_manager.workspace("workspace-a").unwrap(); + let second_store = SubjektivStore::open(&second_workspace, &second_registration).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let revise = |store: SubjektivStore, claim: &'static str, barrier: Arc| { + let subject_id = subject.id.clone(); + let memory_id = memory.id.clone(); + thread::spawn(move || { + barrier.wait(); + store.revise_memory( + &subject_id, + &memory_id, + 1, + draft(claim, "concurrent refinement"), + ) + }) + }; + let first = revise(first_store.clone(), "first writer", barrier.clone()); + let second = revise(second_store, "second writer", barrier.clone()); + barrier.wait(); + let results = [first.join().unwrap(), second.join().unwrap()]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(SubjektivError::RevisionConflict { .. }))) + .count(), + 1 + ); + assert_eq!( + first_store + .subject(&subject.id) + .unwrap() + .unwrap() + .store_revision, + 2 + ); + } + + #[test] + fn scope_and_fixed_revision_references_are_enforced() { + let temp = tempfile::tempdir().unwrap(); + let manager = FeatureStorage::new(temp.path().join("storage")); + let registration = manager.register(REGISTRATION).unwrap(); + let workspace_a = manager.workspace("workspace-a").unwrap(); + let workspace_b = manager.workspace("workspace-b").unwrap(); + let store_a = SubjektivStore::open(&workspace_a, ®istration).unwrap(); + let store_b = SubjektivStore::open(&workspace_b, ®istration).unwrap(); + let subject_a = store_a.create_subject(role()).unwrap(); + let other_subject_a = store_a.create_subject(role()).unwrap(); + let subject_b = store_b.create_subject(role()).unwrap(); + let memory_a = store_a + .create_memory( + &subject_a.id, + draft("Workspace A experience", "direct host record"), + ) + .unwrap(); + + assert!(matches!( + store_b.create_memory(&subject_a.id, draft("foreign subject", "must be rejected")), + Err(SubjektivError::SubjectNotFound(_)) + )); + let mut foreign_derived = draft("Workspace B experience", "invalid derivation"); + foreign_derived.derived_from.push(MemoryRevisionRef { + memory_id: memory_a.id.clone(), + revision: 1, + }); + assert!(matches!( + store_b.create_memory(&subject_b.id, foreign_derived), + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + assert_eq!( + store_b + .subject(&subject_b.id) + .unwrap() + .unwrap() + .store_revision, + 0 + ); + + let mut other_subject_derived = draft("Same Workspace, other subject", "invalid scope"); + other_subject_derived.derived_from.push(MemoryRevisionRef { + memory_id: memory_a.id.clone(), + revision: 1, + }); + assert!(matches!( + store_a.create_memory(&other_subject_a.id, other_subject_derived), + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + let mut wrong_revision = draft("Missing fixed revision", "invalid revision"); + wrong_revision.derived_from.push(MemoryRevisionRef { + memory_id: memory_a.id.clone(), + revision: 2, + }); + assert!(matches!( + store_a.create_memory(&subject_a.id, wrong_revision), + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + assert_eq!( + store_a + .subject(&other_subject_a.id) + .unwrap() + .unwrap() + .store_revision, + 0 + ); + + let foreign_origin = candidate(&subject_a.id, "candidate-foreign", "workspace-b"); + assert!(matches!( + store_a.stage_candidate(foreign_origin), + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + } + + #[test] + fn memory_states_snapshots_and_retirement_have_explicit_boundaries() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let first = store + .create_memory(&subject.id, draft("A constraint", "initial observation")) + .unwrap(); + let snapshot = store + .create_surface_snapshot( + &subject.id, + "Bounded resident summary", + vec![MemoryRevisionRef { + memory_id: first.id.clone(), + revision: 1, + }], + 1, + ) + .unwrap(); + assert_eq!( + store + .surface_snapshot(&subject.id, &snapshot.id) + .unwrap() + .unwrap() + .memory_refs[0] + .revision, + 1 + ); + assert!( + store + .create_surface_snapshot(&subject.id, "stale", vec![], 0) + .is_err() + ); + + let mut retract = draft("Retracted constraint", "evidence invalidated it"); + retract.state = MemoryState::Retracted; + let retracted = store + .revise_memory(&subject.id, &first.id, 1, retract) + .unwrap(); + assert_eq!(retracted.state, MemoryState::Retracted); + assert!(matches!( + store.revise_memory( + &subject.id, + &first.id, + 2, + draft("cannot reactivate", "retraction is terminal") + ), + Err(SubjektivError::InvalidStateTransition { .. }) + )); + let mut retract_again = draft("still retracted", "no post-retraction revisions"); + retract_again.state = MemoryState::Retracted; + assert!(matches!( + store.revise_memory(&subject.id, &first.id, 2, retract_again), + Err(SubjektivError::InvalidStateTransition { .. }) + )); + + let retired = store.retire_subject(&subject.id).unwrap(); + assert_eq!(retired.state, SubjectState::Retired); + assert!(matches!( + store.create_memory(&subject.id, draft("after retirement", "invalid")), + Err(SubjektivError::SubjectRetired(_)) + )); + assert!(store.memory(&subject.id, &first.id).unwrap().is_some()); + } + + #[test] + fn history_rows_reject_update_and_delete() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let staged = store + .stage_candidate(candidate(&subject.id, "candidate-1", "workspace-a")) + .unwrap(); + let (memory, _) = store + .apply_candidate( + &subject.id, + &staged.id, + MemoryRevisionTarget::Create, + draft("Immutable history", "accept"), + "applied", + ) + .unwrap(); + let extra_candidate = store + .stage_candidate(candidate(&subject.id, "candidate-extra", "workspace-a")) + .unwrap(); + let other_memory = store + .create_memory(&subject.id, draft("Other memory", "snapshot seal target")) + .unwrap(); + let snapshot = store + .create_surface_snapshot( + &subject.id, + "sealed snapshot", + vec![MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 1, + }], + 2, + ) + .unwrap(); + + let update = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "UPDATE memory_revisions SET record_json = '{}' \ + WHERE subject_id = ?1 AND memory_id = ?2 AND revision = 1", + params![subject.id, memory.id], + )?; + Ok(()) + }); + assert!(update.is_err()); + let delete = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "DELETE FROM staging_records WHERE subject_id = ?1 AND candidate_id = ?2", + params![subject.id, staged.id], + )?; + Ok(()) + }); + assert!(delete.is_err()); + + let late_evidence = store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "INSERT INTO memory_revision_candidates ( + subject_id, memory_id, revision, candidate_id + ) VALUES (?1, ?2, 1, ?3)", + params![subject.id, memory.id, extra_candidate.id], + )?; + Ok(()) + }); + assert!(late_evidence.is_err()); + let late_derivation = + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "INSERT INTO memory_revision_derivations ( + subject_id, memory_id, revision, source_memory_id, source_revision + ) VALUES (?1, ?2, 1, ?3, 1)", + params![subject.id, memory.id, other_memory.id], + )?; + Ok(()) + }); + assert!(late_derivation.is_err()); + let late_resolution_target = + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "INSERT INTO staging_resolution_targets ( + subject_id, candidate_id, memory_id, revision + ) VALUES (?1, ?2, ?3, 1)", + params![subject.id, staged.id, other_memory.id], + )?; + Ok(()) + }); + assert!(late_resolution_target.is_err()); + let late_snapshot_ref = + store + .database + .try_with_connection::<_, SubjektivError>(|connection| { + connection.execute( + "INSERT INTO surface_snapshot_refs ( + subject_id, snapshot_id, memory_id, revision + ) VALUES (?1, ?2, ?3, 1)", + params![subject.id, snapshot.id, other_memory.id], + )?; + Ok(()) + }); + assert!(late_snapshot_ref.is_err()); + + assert!( + store + .memory_revision(&subject.id, &memory.id, 1) + .unwrap() + .is_some() + ); + assert!( + store + .staging_candidate(&subject.id, &staged.id) + .unwrap() + .is_some() + ); + } + + #[test] + fn surface_material_selection_is_scoped_active_bounded_and_deterministic() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let other = store.create_subject(role()).unwrap(); + store + .create_memory(&other.id, draft("foreign", "foreign subject")) + .unwrap(); + + let kinds = [ + CandidateKind::Preference, + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ]; + let mut first_id = None; + for kind in &kinds { + for index in 0..9 { + let mut value = draft(&format!("{kind:?}-{index}"), "selection fixture"); + value.kind = kind.clone(); + let memory = store.create_memory(&subject.id, value).unwrap(); + first_id.get_or_insert(memory.id); + } + } + let resolved_id = first_id.unwrap(); + let current = store.memory(&subject.id, &resolved_id).unwrap().unwrap(); + let mut resolved = draft("resolved", "no longer active"); + resolved.kind = current.kind; + resolved.state = MemoryState::Resolved; + store + .revise_memory(&subject.id, &resolved_id, current.revision, resolved) + .unwrap(); + + let first = store.prepare_surface_generation(&subject.id).unwrap(); + let second = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(first.materials, second.materials); + assert_eq!(first.active_memory_count, 53); + assert_eq!(first.materials.len(), SURFACE_TOTAL_MATERIAL_LIMIT); + assert!( + first + .materials + .iter() + .all(|item| item.memory_id != resolved_id) + ); + assert!(first.materials.iter().all(|item| item.body_md != "foreign")); + for kind in &kinds { + assert!( + first + .materials + .iter() + .filter(|item| &item.kind == kind) + .count() + <= SURFACE_PER_KIND_LIMIT + ); + } + let serialized = serde_json::to_vec(&first.materials).unwrap(); + assert!(estimated_tokens_for_bytes(serialized.len()) <= SURFACE_MATERIAL_TOKEN_BUDGET); + } + + #[test] + fn oversized_kind_cannot_starve_other_surface_categories() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + + let mut oversized = draft("oversized preference", "category fairness fixture"); + oversized.kind = CandidateKind::Preference; + oversized.body_md = "x".repeat((SURFACE_MATERIAL_TOKEN_BUDGET - 100) * 4); + store.create_memory(&subject.id, oversized).unwrap(); + + for kind in [ + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ] { + let mut value = draft(&format!("{kind:?}"), "category fairness fixture"); + value.kind = kind; + store.create_memory(&subject.id, value).unwrap(); + } + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + for kind in [ + CandidateKind::Constraint, + CandidateKind::Decision, + CandidateKind::WorkingAssumption, + CandidateKind::OpenQuestion, + CandidateKind::Lesson, + ] { + assert!( + generation.materials.iter().any(|item| item.kind == kind), + "one large category must not exclude {kind:?}" + ); + } + assert!( + estimated_tokens_for_bytes(serde_json::to_vec(&generation.materials).unwrap().len()) + <= SURFACE_MATERIAL_TOKEN_BUDGET + ); + } + + #[test] + fn surface_material_selection_reclaims_unused_category_budget() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + + let mut small = draft("small preference", "soft category budget fixture"); + small.kind = CandidateKind::Preference; + store.create_memory(&subject.id, small).unwrap(); + let mut large = draft("large constraint", "soft category budget fixture"); + large.kind = CandidateKind::Constraint; + large.body_md = "x".repeat(22_000); + store.create_memory(&subject.id, large).unwrap(); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.materials.len(), 2); + assert_eq!(generation.materials[0].kind, CandidateKind::Preference); + assert_eq!(generation.materials[1].kind, CandidateKind::Constraint); + assert!( + estimated_tokens_for_bytes(serde_json::to_vec(&generation.materials).unwrap().len()) + <= SURFACE_MATERIAL_TOKEN_BUDGET + ); + } + + #[test] + fn surface_states_validation_races_and_idempotency_fail_closed() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Ungenerated + ); + + let empty_generation = store.prepare_surface_generation(&subject.id).unwrap(); + let empty = store + .publish_surface_generation(&subject.id, &empty_generation.id, Vec::new()) + .unwrap(); + assert!(empty.body_md.is_empty()); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Ready + ); + + let memory = store + .create_memory(&subject.id, draft("Current constraint", "confirmed")) + .unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Stale + ); + let stale_generation = store.prepare_surface_generation(&subject.id).unwrap(); + let invalid = store.publish_surface_generation( + &subject.id, + &stale_generation.id, + vec![SurfacePoint { + body_md: "Invented".into(), + memory_refs: vec![MemoryRevisionRef { + memory_id: "foreign-memory".into(), + revision: 1, + }], + }], + ); + assert!(matches!( + invalid, + Err(SubjektivError::SubjectScopeMismatch { .. }) + )); + store + .fail_surface_generation(&subject.id, &stale_generation.id, "invalid_output") + .unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Failed + ); + assert_eq!( + store + .memory(&subject.id, &memory.id) + .unwrap() + .unwrap() + .revision, + 1, + "surface failure must not roll back confirmed Memory" + ); + + let raced = store.prepare_surface_generation(&subject.id).unwrap(); + store + .revise_memory( + &subject.id, + &memory.id, + 1, + draft("Corrected constraint", "correction"), + ) + .unwrap(); + assert!(matches!( + store.publish_surface_generation( + &subject.id, + &raced.id, + vec![SurfacePoint { + body_md: "Old wording".into(), + memory_refs: vec![MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 1, + }], + }], + ), + Err(SubjektivError::SurfaceGenerationConflict(_)) + )); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Stale + ); + + let current = store.prepare_surface_generation(&subject.id).unwrap(); + let current_ref = MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 2, + }; + let points = vec![SurfacePoint { + body_md: "Keep the corrected constraint, including its condition.".into(), + memory_refs: vec![current_ref.clone()], + }]; + let published = store + .publish_surface_generation(&subject.id, ¤t.id, points.clone()) + .unwrap(); + let retry = store + .publish_surface_generation(&subject.id, ¤t.id, points) + .unwrap(); + assert_eq!(published.id, retry.id); + let resident = store.resident_surface(&subject.id).unwrap(); + assert_eq!(resident.availability, SurfaceAvailability::Ready); + assert_eq!(resident.snapshot.unwrap().memory_refs, vec![current_ref]); + } + + #[test] + fn active_memory_that_cannot_fit_is_a_failure_not_a_normal_empty_surface() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let mut oversized = draft("oversized", "fixture"); + oversized.body_md = "x".repeat((SURFACE_MATERIAL_TOKEN_BUDGET + 1) * 4); + store.create_memory(&subject.id, oversized).unwrap(); + + let generation = store.prepare_surface_generation(&subject.id).unwrap(); + assert_eq!(generation.active_memory_count, 1); + assert!(generation.materials.is_empty()); + assert!( + store + .publish_surface_generation(&subject.id, &generation.id, Vec::new()) + .is_err() + ); + store + .fail_surface_generation(&subject.id, &generation.id, "input_budget_exhausted") + .unwrap(); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Failed + ); + } + + #[test] + fn concurrent_surface_generations_cannot_overwrite_one_revision() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let memory = store + .create_memory(&subject.id, draft("Concurrent constraint", "fixture")) + .unwrap(); + let first = store.prepare_surface_generation(&subject.id).unwrap(); + let second = store.prepare_surface_generation(&subject.id).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let mut joins = Vec::new(); + for (generation, body) in [(first, "first"), (second, "second")] { + let store = store.clone(); + let subject_id = subject.id.clone(); + let memory_id = memory.id.clone(); + let barrier = Arc::clone(&barrier); + joins.push(thread::spawn(move || { + barrier.wait(); + store.publish_surface_generation( + &subject_id, + &generation.id, + vec![SurfacePoint { + body_md: body.into(), + memory_refs: vec![MemoryRevisionRef { + memory_id, + revision: 1, + }], + }], + ) + })); + } + let results = joins + .into_iter() + .map(|join| join.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!( + result, + Err(SubjektivError::SurfaceGenerationConflict(_)) + )) + .count(), + 1 + ); + assert_eq!( + store.resident_surface(&subject.id).unwrap().availability, + SurfaceAvailability::Ready + ); + } + + #[test] + fn reopen_and_backup_restore_preserve_exact_history() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("storage"); + let snapshot_path = temp.path().join("snapshot"); + let (manager, _workspace, store) = open_store(&root, "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + let candidate = store + .stage_candidate(candidate(&subject.id, "candidate-1", "workspace-a")) + .unwrap(); + let (memory, _) = store + .apply_candidate( + &subject.id, + &candidate.id, + MemoryRevisionTarget::Create, + draft("Persist this revision", "accepted before restart"), + "applied", + ) + .unwrap(); + drop(store); + manager.shutdown().unwrap(); + drop(manager); + + let (manager, workspace, reopened) = open_store(&root, "workspace-a"); + assert_eq!( + reopened + .memory_revision(&subject.id, &memory.id, 1) + .unwrap() + .unwrap() + .claim, + "Persist this revision" + ); + assert_eq!( + reopened + .staging_resolution(&subject.id, &candidate.id) + .unwrap() + .unwrap() + .candidate + .source_refs[0] + .session_id + .as_deref(), + Some("session-1") + ); + let surface = reopened + .create_surface_snapshot( + &subject.id, + "Persisted resident surface", + vec![MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 1, + }], + 1, + ) + .unwrap(); + workspace.backup(&snapshot_path).unwrap(); + let mut post_backup = draft("Post-backup revision", "not in snapshot"); + post_backup.derived_from.push(MemoryRevisionRef { + memory_id: memory.id.clone(), + revision: 1, + }); + let second = reopened + .revise_memory(&subject.id, &memory.id, 1, post_backup) + .unwrap(); + assert_eq!(second.revision, 2); + workspace.delete().unwrap(); + drop(reopened); + drop(workspace); + drop(manager); + + let manager = FeatureStorage::new(&root); + let workspace = manager.workspace("workspace-a").unwrap(); + workspace.restore(&snapshot_path).unwrap(); + let registration = SubjektivStore::register(&workspace).unwrap(); + let restored = SubjektivStore::open(&workspace, ®istration).unwrap(); + assert_eq!( + restored + .subject(&subject.id) + .unwrap() + .unwrap() + .store_revision, + 1 + ); + assert_eq!( + restored + .memory(&subject.id, &memory.id) + .unwrap() + .unwrap() + .claim, + "Persist this revision" + ); + assert!( + restored + .memory_revision(&subject.id, &memory.id, 2) + .unwrap() + .is_none() + ); + assert!( + restored + .staging_resolution(&subject.id, &candidate.id) + .unwrap() + .is_some() + ); + assert_eq!( + restored + .surface_snapshot(&subject.id, &surface.id) + .unwrap() + .unwrap() + .memory_refs, + vec![MemoryRevisionRef { + memory_id: memory.id, + revision: 1, + }] + ); + } +} diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 6821d1c19..bd8f176fa 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -957,6 +957,7 @@ mod tests { language: "English".into(), }), subjektiv_attached: false, + backend_job: None, }) .unwrap(); runtime.stop_worker(&worker.worker_ref, None).unwrap(); diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 03083575d..04a762904 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -457,20 +457,21 @@ pub(crate) async fn run_standalone( state_dir: PathBuf, worker_name: Option, profile: Option, + subject_id: Option, ) -> Result<(), Box> { let worker_name = worker_name.unwrap_or_else(|| "local".to_string()); let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| { manifest::ProfileSelector::parse_cli(&profile) }); let history_root = workspace_root.clone(); - let launch = StandaloneLaunchConfig { - state_dir, - cwd: workspace_root, - profile, - worker_name: worker_name.clone(), - } - .resolve() - .map_err(|error| { + let launch = + StandaloneLaunchConfig::new(workspace_root, state_dir, profile, worker_name.clone()); + let launch = if let Some(subject_id) = subject_id { + launch.with_subject(subject_id) + } else { + launch + }; + let launch = launch.resolve().map_err(|error| { io::Error::new( io::ErrorKind::InvalidInput, format!("Standalone launch configuration failed: {error}"), diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 359063b6e..d72dffc1d 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -65,6 +65,7 @@ pub enum LaunchMode { Spawn { worker_name: Option, profile: Option, + subject_id: Option, }, /// Restore one client-owned standalone Worker. The current cwd is the default scope; /// `include_all` opts into all standalone Workers under the same client data root. @@ -202,7 +203,8 @@ async fn launch_mode( LaunchMode::Spawn { worker_name, profile, - } => match standalone_spawn::select(&workspace_root, worker_name, profile) { + subject_id, + } => match standalone_spawn::select(&workspace_root, worker_name, profile, subject_id) { Ok(Some(selection)) => match target.spawn_worker() { Ok(spawn) => { console::run_standalone( @@ -210,6 +212,7 @@ async fn launch_mode( spawn.state_dir, Some(selection.worker_name), Some(selection.profile), + selection.subject_id, ) .await } diff --git a/crates/tui/src/standalone_spawn.rs b/crates/tui/src/standalone_spawn.rs index 722d1a1aa..091bbc598 100644 --- a/crates/tui/src/standalone_spawn.rs +++ b/crates/tui/src/standalone_spawn.rs @@ -12,13 +12,14 @@ use thiserror::Error; use crate::inline_terminal::{InlineTerminal, with_inline_terminal}; -const VIEWPORT_HEIGHT: u16 = 6; +const VIEWPORT_HEIGHT: u16 = 7; const FALLBACK_WORKER_NAME: &str = "worker"; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct StandaloneSpawnSelection { pub worker_name: String, pub profile: String, + pub subject_id: Option, } #[derive(Debug, Error)] @@ -55,6 +56,9 @@ enum SpawnAction { struct SpawnForm { worker_name: String, cursor: usize, + subject_id: String, + subject_cursor: usize, + editing_subject: bool, profile_choices: Vec, selected_profile: usize, status: Option<(String, StatusKind)>, @@ -75,12 +79,26 @@ impl SpawnForm { Self { worker_name, cursor, + subject_id: String::new(), + subject_cursor: 0, + editing_subject: false, profile_choices, selected_profile, status: None, } } + fn with_subject(mut self, subject_id: Option) -> Self { + self.subject_id = subject_id.unwrap_or_default(); + self.subject_cursor = self.subject_id.chars().count(); + self + } + + fn subject_selection(&self) -> Option { + let id = self.subject_id.trim(); + (!id.is_empty()).then(|| id.to_owned()) + } + fn selected_profile(&self) -> &ProfileChoice { &self.profile_choices[self.selected_profile] } @@ -98,6 +116,47 @@ impl SpawnForm { } self.status = None; + if key.code == KeyCode::F(2) { + self.editing_subject = !self.editing_subject; + return SpawnAction::None; + } + if self.editing_subject { + match key.code { + KeyCode::Left => self.subject_cursor = self.subject_cursor.saturating_sub(1), + KeyCode::Right => { + self.subject_cursor = + (self.subject_cursor + 1).min(self.subject_id.chars().count()) + } + KeyCode::Home => self.subject_cursor = 0, + KeyCode::End => self.subject_cursor = self.subject_id.chars().count(), + KeyCode::Backspace => { + if self.subject_cursor > 0 { + self.subject_id + .remove(byte_index(&self.subject_id, self.subject_cursor - 1)); + self.subject_cursor -= 1; + } + } + KeyCode::Delete => { + if self.subject_cursor < self.subject_id.chars().count() { + self.subject_id + .remove(byte_index(&self.subject_id, self.subject_cursor)); + } + } + KeyCode::Char(ch) if is_safe_worker_char(ch) => { + self.subject_id + .insert(byte_index(&self.subject_id, self.subject_cursor), ch); + self.subject_cursor += 1; + } + KeyCode::Char(_) => {} + // Submit/cancel and profile cycling remain available while editing the Subject. + _ => return self.apply_form_key(key), + } + return SpawnAction::None; + } + self.apply_form_key(key) + } + + fn apply_form_key(&mut self, key: KeyEvent) -> SpawnAction { match key.code { KeyCode::Esc => SpawnAction::Cancel, KeyCode::Enter => { @@ -167,12 +226,14 @@ pub(crate) fn select( workspace_root: &Path, worker_name: Option, profile: Option, + subject_id: Option, ) -> Result, StandaloneSpawnError> { let default_worker_name = default_worker_name(workspace_root); if let Some(profile) = profile { return Ok(Some(StandaloneSpawnSelection { worker_name: worker_name.unwrap_or(default_worker_name), profile, + subject_id, })); } @@ -185,7 +246,7 @@ pub(crate) fn select( with_inline_terminal(VIEWPORT_HEIGHT, |terminal| { run_picker( terminal, - SpawnForm::new(worker_name, default_worker_name, choices), + SpawnForm::new(worker_name, default_worker_name, choices).with_subject(subject_id), ) }) } @@ -214,6 +275,7 @@ fn run_picker( let selection = StandaloneSpawnSelection { worker_name: form.worker_name.trim().to_owned(), profile: form.selected_profile().selector.clone(), + subject_id: form.subject_selection(), }; form.status = Some(("starting worker...".to_owned(), StatusKind::Progress)); terminal.draw(|frame| draw_form(frame, &form))?; @@ -261,6 +323,7 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) { Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), Constraint::Min(0), ]) .split(frame.area()); @@ -306,12 +369,32 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) { chunks[2], ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::raw(" "), + Span::styled("subject: ", Style::default().fg(Color::DarkGray)), + Span::styled( + if form.subject_id.is_empty() { + "(none)" + } else { + &form.subject_id + }, + Style::default().fg(Color::Cyan), + ), + Span::styled( + " (F2 edit name/Subject ID)", + Style::default().fg(Color::DarkGray), + ), + ])), + chunks[3], + ); + frame.render_widget( Paragraph::new(Line::from(Span::styled( " enter spawn · left/right edit · esc cancel", Style::default().fg(Color::DarkGray), ))), - chunks[3], + chunks[4], ); let (message, color) = form @@ -331,16 +414,20 @@ fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) { Span::raw(" "), Span::styled(message, Style::default().fg(color)), ])), - chunks[4], + chunks[5], ); - let prefix_width = " name: ".chars().count() as u16; - let x = chunks[1] + let (row, prefix, cursor) = if form.editing_subject { + (3, " subject: ", form.subject_cursor) + } else { + (1, " name: ", form.cursor) + }; + let x = chunks[row] .x - .saturating_add(prefix_width) - .saturating_add(form.cursor as u16) - .min(chunks[1].right().saturating_sub(1)); - frame.set_cursor_position((x, chunks[1].y)); + .saturating_add(prefix.chars().count() as u16) + .saturating_add(cursor as u16) + .min(chunks[row].right().saturating_sub(1)); + frame.set_cursor_position((x, chunks[row].y)); } fn default_worker_name(workspace_root: &Path) -> String { @@ -394,9 +481,43 @@ mod tests { fn default_form_preserves_old_spawn_layout_defaults() { let form = SpawnForm::new(None, "yoi".to_owned(), choices()); assert_eq!(form.worker_name, "yoi"); + assert_eq!(form.subject_selection(), None); assert_eq!(form.selected_profile().selector, "builtin:standalone"); } + #[test] + fn optional_subject_field_edits_independently_and_can_be_cleared() { + let mut form = SpawnForm::new(None, "worker".to_owned(), choices()); + form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + for ch in "subject-1".chars() { + form.apply_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert_eq!(form.subject_selection().as_deref(), Some("subject-1")); + assert_eq!(form.worker_name, "worker"); + form.apply_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(form.selected_profile().selector, "builtin:coder"); + assert_eq!( + form.apply_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + SpawnAction::Submit + ); + for _ in 0..9 { + form.apply_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + } + assert_eq!(form.subject_selection(), None); + form.apply_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); + form.apply_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!(form.worker_name, "worker2"); + assert_eq!(form.subject_selection(), None); + } + + #[test] + fn explicit_subject_prefills_the_optional_field() { + let form = SpawnForm::new(None, "worker".to_owned(), choices()) + .with_subject(Some("subject-id".to_owned())); + assert_eq!(form.subject_selection().as_deref(), Some("subject-id")); + assert_eq!(form.subject_cursor, 10); + } + #[test] fn tab_and_arrows_cycle_profiles() { let mut form = SpawnForm::new(None, "yoi".to_owned(), choices()); @@ -453,6 +574,7 @@ mod tests { assert!(rendered.contains("spawn worker")); assert!(rendered.contains("name: yoi")); + assert!(rendered.contains("subject: (none)")); assert!(rendered.contains("profile: builtin:standalone (default) — Standalone")); assert!(rendered.contains("enter spawn · left/right edit · esc cancel")); } @@ -504,10 +626,12 @@ mod tests { Path::new("/home/hare/Project/yoi"), None, Some("builtin:coder".to_owned()), + Some("subject-id".to_owned()), ) .unwrap() .unwrap(); assert_eq!(selection.worker_name, "yoi"); assert_eq!(selection.profile, "builtin:coder"); + assert_eq!(selection.subject_id.as_deref(), Some("subject-id")); } } diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index 587ed11c9..d027aa8c9 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -52,6 +52,7 @@ pub struct WorkerBootstrap { transport: WorkerControllerTransport, model_client: Option>, workdir_session: Option, + subjektiv_host: Option>, } /// A constructed Worker whose host-owned live bindings can still be installed @@ -106,6 +107,7 @@ where transport, model_client: None, workdir_session: None, + subjektiv_host: None, } } @@ -127,6 +129,16 @@ where self } + /// Bind an explicit, process-owned subject capability. Profile policy alone + /// is inert; the connection is never persisted into the Worker manifest. + pub fn with_subjektiv_host( + mut self, + host: std::sync::Arc, + ) -> Self { + self.subjektiv_host = Some(host); + self + } + /// Construct the Worker without exposing a controller handle. Runtime hosts /// use this seam to bind Workdir, observation, Flow, and other live services /// before [`PreparedWorker::start`] performs Feature installation. @@ -147,6 +159,24 @@ where if let Some(workdir_session) = self.workdir_session { worker.bind_single_workdir_session(Some(workdir_session)); } + let explicit_subjektiv_host = self.subjektiv_host.is_some(); + worker.bind_subjektiv_host(self.subjektiv_host); + if explicit_subjektiv_host + && worker.manifest().feature.subjektiv.profile.enabled + && !worker + .manifest() + .feature + .subjektiv + .profile + .consolidation_tools + { + worker + .finalize_subjektiv_session_attribution( + true, + crate::SubjektivSessionAttributionLifecycle::NewSession, + ) + .map_err(WorkerBootstrapError::Worker)?; + } Ok(PreparedWorker::new(worker, self.layout, self.transport)) } @@ -170,6 +200,24 @@ where if let Some(workdir_session) = self.workdir_session { worker.bind_single_workdir_session(Some(workdir_session)); } + let explicit_subjektiv_host = self.subjektiv_host.is_some(); + worker.bind_subjektiv_host(self.subjektiv_host); + if explicit_subjektiv_host + && worker.manifest().feature.subjektiv.profile.enabled + && !worker + .manifest() + .feature + .subjektiv + .profile + .consolidation_tools + { + worker + .finalize_subjektiv_session_attribution( + true, + crate::SubjektivSessionAttributionLifecycle::RestoredSession, + ) + .map_err(WorkerBootstrapError::Worker)?; + } Ok(PreparedWorker::new(worker, self.layout, self.transport)) } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 509c2a85e..38e36e541 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1377,6 +1377,7 @@ where None => durable_parent_notifications, }; let backend_job = worker.backend_job().cloned(); + let subjektiv_host = worker.subjektiv_host_connection()?; // Bounded Jobs complete their lifecycle synchronously, never by post-run background work. let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none(); let prompts = worker.prompts().clone(); @@ -1443,8 +1444,8 @@ where workspace_client.clone(), ); if binding.subjektiv_consolidation { - let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job( - workspace_client, spawner_manifest.clone(), worker.llm_client_handle(), + let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_host( + subjektiv_host.clone().ok_or_else(|| std::io::Error::other("Backend Job requires subjektiv Host capability"))?, spawner_manifest.clone(), worker.llm_client_handle(), prompts.clone(), spawner_workspace_context.clone(), )?; result_feature = result_feature.with_surface(surface); @@ -1453,7 +1454,9 @@ where } let memory_profile = &worker.manifest().feature.memory.profile; let subjektiv_profile = &worker.manifest().feature.subjektiv.profile; - validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?; + if subjektiv_host.is_some() { + validate_memory_lifecycle_targets(memory_profile, subjektiv_profile)?; + } let memory_install_plan = crate::feature::builtin::memory::MemoryFeatureInstallPlan::prepare( worker.manifest(), worker.workspace_client_handle(), @@ -1476,7 +1479,7 @@ where let subjektiv_consolidation_plan = crate::feature::builtin::memory::SubjektivConsolidationFeatureInstallPlan::prepare( worker.manifest(), - worker.workspace_client_handle(), + subjektiv_host.clone(), worker.prompts().load_full(), )?; if let Some(plan) = subjektiv_consolidation_plan { @@ -1485,22 +1488,20 @@ where } feature_registry.add_module(plan.module); } - let ordinary_subjektiv_features_enabled = - crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest()); + let ordinary_subjektiv_features_enabled = subjektiv_host.is_some() + && crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(worker.manifest()); if let Some(resident_summary_source) = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( worker.manifest(), - worker.workspace_client_handle(), + subjektiv_host.clone(), worker.prompts(), )? { - let workspace_id = worker - .workspace_client() - .workspace_id() - .expect("validated subject resident source has Workspace identity") - .to_string(); + let workspace_id = subjektiv_host + .as_ref() + .and_then(|host| host.host.workspace_id()); if let Some(refresh_feature) = - crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_host( + crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_connection( lifecycle_enabled, Arc::clone(&resident_summary_source), worker.prompts(), @@ -1516,7 +1517,7 @@ where crate::feature::builtin::subjektiv_memory::SubjektivMemoryFeature::from_resolved_config( &worker.manifest().feature.subjektiv, worker.committed_session_capture_handle(), - worker.workspace_client_handle(), + subjektiv_host.clone(), )? { feature_registry.add_module(subjektiv_memory); @@ -1525,7 +1526,7 @@ where && let Some(subjektiv_sessions) = crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( &worker.manifest().feature.subjektiv, - worker.workspace_client_handle(), + subjektiv_host.clone(), )? { feature_registry.add_module(subjektiv_sessions); @@ -1548,7 +1549,7 @@ where } if backend_job.is_none() && let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest( lifecycle_enabled, - worker.workspace_client_handle(), + subjektiv_host.clone(), spawner_manifest.clone(), worker.llm_client_handle(), prompts.clone(), @@ -1563,7 +1564,7 @@ where worker.manifest().feature.subjektiv.clone(), worker.committed_session_capture_handle(), worker.session_extension_handle(), - worker.workspace_client_handle(), + subjektiv_host.clone(), spawner_manifest.clone(), worker.llm_client_handle(), prompts.clone(), diff --git a/crates/worker/src/feature/builtin/memory.rs b/crates/worker/src/feature/builtin/memory.rs index d25581cf5..96fe698cd 100644 --- a/crates/worker/src/feature/builtin/memory.rs +++ b/crates/worker/src/feature/builtin/memory.rs @@ -4,8 +4,10 @@ //! authority. In that case model-visible Memory tools must go through the //! workspace backend instead of resolving `.yoi/memory` from a Worker workdir. +use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; +#[cfg(test)] use std::time::Duration; use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; @@ -158,6 +160,7 @@ impl dyn WorkspaceClient + '_ { } } +#[cfg(test)] const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); async fn execute_subjektiv_memory_operation( @@ -327,9 +330,7 @@ pub fn workspace_http_memory_tools(client: Arc) -> Vec, -) -> Vec { +fn subjektiv_consolidation_tools(host: SubjektivHostConnection) -> Vec { use SubjectConsolidationOperation as Operation; [ ( @@ -371,14 +372,14 @@ pub fn workspace_http_subjektiv_consolidation_tools( ] .into_iter() .map(|(name, description, schema, operation)| { - let client = Arc::clone(&client); + let host = host.clone(); Arc::new(move || { ( ToolMeta::new(name) .description(description) .input_schema(schema.clone()), Arc::new(SubjectConsolidationTool { - client: Arc::clone(&client), + host: host.clone(), operation, }) as Arc, ) @@ -398,7 +399,7 @@ enum SubjectConsolidationOperation { } struct SubjectConsolidationTool { - client: Arc, + host: SubjektivHostConnection, operation: SubjectConsolidationOperation, } @@ -434,59 +435,17 @@ impl Tool for SubjectConsolidationTool { )?) } }; - let workspace_id = self.client.workspace_id().ok_or_else(|| { - ToolError::ExecutionFailed( - "subjektiv consolidation requires Workspace authority".into(), - ) - })?; - let response = self - .client - .execute(WorkspaceRequest::json( - WorkspaceRequestMethod::Post, - format!("/api/w/{workspace_id}/subjektiv/memory"), - serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation }) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, - )) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if !response.is_success() { - let parsed = - serde_json::from_str::(&response.body).ok(); - if let Some((code, message)) = parsed.as_ref().and_then(|error| { - error - .diagnostics - .iter() - .find(|diagnostic| { - matches!( - diagnostic.code.as_str(), - "revision_conflict" - | "candidate_decision_conflict" - | "subject_scope_mismatch" - ) - }) - .map(|diagnostic| (diagnostic.code.clone(), error.message.clone())) - }) { + let response = match self.host.memory(operation) { + Ok(response) => response, + Err(SubjektivHostError::Conflict { code, message }) => { return Ok(ToolOutput { summary: format!("Candidate decision requires reread: {code}."), - content: Some( - serde_json::json!({ - "status": "error", - "error": { "code": code, "message": message } - }) - .to_string(), - ), + content: Some(serde_json::json!({"status":"error", "error":{"code":code,"message":message}}).to_string()), attachments: Vec::new(), }); } - let detail = parsed.map(|error| error.message).unwrap_or(response.body); - return Err(if matches!(response.status, 400 | 404 | 409 | 422) { - ToolError::InvalidArgument(detail) - } else { - ToolError::ExecutionFailed(detail) - }); - } - let response: server_api::SubjektivMemoryBackendResponse = - serde_json::from_str(&response.body) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + Err(error) => return Err(error.into()), + }; let summary = match &response { server_api::SubjektivMemoryBackendResponse::Query(value) => { format!("Found {} subject Memory item(s).", value.items.len()) @@ -686,7 +645,7 @@ struct SubjectBehaviorRefreshState { } struct WorkspaceSubjektivResidentSummarySource { - client: Arc, + host: SubjektivHostConnection, prompts: Arc>, behavior_state: Mutex, } @@ -730,16 +689,12 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource { impl WorkspaceSubjektivResidentSummarySource { async fn fetch( &self, - ) -> Result { - match execute_subjektiv_memory_operation_with( - self.client.as_ref(), + ) -> Result { + match self.host.memory( server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()), - |client, request| { - client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT) - }, )? { server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output), - other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!( + other => Err(SubjektivHostError::Unavailable(format!( "unexpected subject resident context response: {other:?}" ))), } @@ -862,10 +817,11 @@ const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-befor pub(crate) struct SubjektivResidentRestoreRefreshFeature { source: Arc, prompts: Arc>, - workspace_id: String, + workspace_id: Option, } impl SubjektivResidentRestoreRefreshFeature { + #[cfg(test)] pub(crate) fn for_host( lifecycle_enabled: bool, source: Arc, @@ -875,6 +831,20 @@ impl SubjektivResidentRestoreRefreshFeature { lifecycle_enabled.then(|| Self::new(source, prompts, workspace_id)) } + pub(crate) fn for_connection( + lifecycle_enabled: bool, + source: Arc, + prompts: Arc>, + workspace_id: Option, + ) -> Option { + lifecycle_enabled.then_some(Self { + source, + prompts, + workspace_id, + }) + } + + #[cfg(test)] pub(crate) fn new( source: Arc, prompts: Arc>, @@ -883,7 +853,7 @@ impl SubjektivResidentRestoreRefreshFeature { Self { source, prompts, - workspace_id: workspace_id.into(), + workspace_id: Some(workspace_id.into()), } } } @@ -981,7 +951,7 @@ impl Hook for SubjektivBehaviorRefreshHook { struct SubjektivResidentRestoreRefreshHook { source: Arc, prompts: Arc>, - workspace_id: String, + workspace_id: Option, } #[async_trait] @@ -1000,7 +970,7 @@ impl Hook for SubjektivResidentRestoreRefreshHook { context.system_items().append_resident_summary_refresh( body, session_store::PromptRenderProvenance { - workspace_id: Some(self.workspace_id.clone()), + workspace_id: self.workspace_id.clone(), config_revision: projection.config_revision, source_digest: projection.source_digest.clone(), projection_digest: projection.catalog_digest.clone(), @@ -1114,51 +1084,25 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile( } pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool { - manifest.feature.subjektiv.execution_enabled() + manifest.feature.subjektiv.profile.enabled && !manifest.feature.subjektiv.profile.consolidation_tools } pub(crate) fn ordinary_subjektiv_resident_summary_source( manifest: &manifest::WorkerManifest, - client: Arc, + host: Option, prompts: Arc>, ) -> std::io::Result>> { - if !ordinary_subjektiv_features_enabled(manifest) { + if !ordinary_subjektiv_features_enabled(manifest) || host.is_none() { return Ok(None); } manifest .feature .subjektiv .validate_execution() - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !client.is_available() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv resident Memory requires Backend Workspace API authority", - )); - } - let workspace_id = client.workspace_id().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv resident Memory requires Backend Workspace API authority", - ) - })?; - let settings = manifest - .feature - .subjektiv - .workspace_settings() - .expect("validated enabled subjektiv config has Workspace settings"); - if settings.workspace_id != workspace_id { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "subjektiv Workspace settings belong to {} instead of {}", - settings.workspace_id, workspace_id - ), - )); - } + .map_err(std::io::Error::other)?; Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource { - client, + host: host.expect("checked explicit connection"), prompts, behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()), }))) @@ -1172,57 +1116,51 @@ pub(crate) struct SubjektivConsolidationFeatureInstallPlan { impl SubjektivConsolidationFeatureInstallPlan { pub(crate) fn prepare( manifest: &manifest::WorkerManifest, - client: Arc, + host: Option, prompts: Arc, ) -> std::io::Result> { - if !manifest.feature.subjektiv.profile.consolidation_tools { + let config = &manifest.feature.subjektiv; + if !config.profile.enabled || !config.profile.consolidation_tools || host.is_none() { return Ok(None); } - let config = &manifest.feature.subjektiv; - config - .validate_execution() - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !config.execution_enabled() || config.profile.extraction.enabled { + config.validate_execution().map_err(std::io::Error::other)?; + let host = host.expect("checked explicit connection"); + if config.profile.extraction.enabled || !host.host.consolidation_granted() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "subjektiv consolidation profile requires trusted subject settings without automatic extraction", - )); - } - let workspace_id = client.workspace_id().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv consolidation tools require Backend Workspace API authority", - ) - })?; - let settings = config - .workspace_settings() - .expect("validated enabled subjektiv config has Workspace settings"); - if settings.workspace_id != workspace_id { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!( - "subjektiv settings belong to {} instead of {}", - settings.workspace_id, workspace_id - ), + "subjektiv consolidation requires explicit Host grant without automatic extraction", )); } let system_prompt_override = prompts - .subjektiv_memory_consolidation_system(&settings.language) + .subjektiv_memory_consolidation_system(&host.host.settings().language) .map_err(|error| std::io::Error::other(error.to_string()))?; Ok(Some(Self { - module: SubjektivConsolidationToolsFeature { - tools: workspace_http_subjektiv_consolidation_tools(client), - }, + module: SubjektivConsolidationToolsFeature::new(host)?, system_prompt_override, })) } } #[derive(Clone)] -pub(crate) struct SubjektivConsolidationToolsFeature { +pub struct SubjektivConsolidationToolsFeature { tools: Vec, } +impl SubjektivConsolidationToolsFeature { + /// Reusable installation for a generic Job with an explicit attenuated Host. + pub fn new(host: SubjektivHostConnection) -> std::io::Result { + if !host.host.consolidation_granted() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Host did not grant consolidation", + )); + } + Ok(Self { + tools: subjektiv_consolidation_tools(host), + }) + } +} + impl FeatureModule for SubjektivConsolidationToolsFeature { fn descriptor(&self) -> FeatureDescriptor { let mut descriptor = FeatureDescriptor::builtin( @@ -1293,6 +1231,29 @@ mod tests { use agen::tool::ToolDefinition; use std::sync::Mutex; + fn backend_resident_source( + manifest: &manifest::WorkerManifest, + client: Arc, + prompts: Arc>, + ) -> std::io::Result>> { + let host = crate::subjektiv::BackendSubjektivHost::from_resolved_config( + &manifest.feature.subjektiv, + client, + )?; + let host = host + .map(|host| { + SubjektivHostConnection::new( + host, + crate::subjektiv::SubjektivHostContext { + worker_id: manifest.worker.name.clone(), + session_id: "session-1".into(), + }, + ) + }) + .transpose()?; + ordinary_subjektiv_resident_summary_source(manifest, host, prompts) + } + fn test_prompts() -> Arc> { Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())) } @@ -1601,7 +1562,7 @@ mod tests { let plan = SubjektivConsolidationFeatureInstallPlan::prepare( &manifest, - test_client(), + Some(crate::subjektiv::test_connection(test_client())), crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), ) .unwrap() @@ -1696,13 +1657,9 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, Some("current subject surface"), )); - let ready = ordinary_subjektiv_resident_summary_source( - &manifest, - ready_client.clone(), - test_prompts(), - ) - .unwrap() - .unwrap(); + let ready = backend_resident_source(&manifest, ready_client.clone(), test_prompts()) + .unwrap() + .unwrap(); let loaded = ready.load().await; let SystemPromptContribution::Ready(loaded) = loaded else { panic!("subject resident context must be present"); @@ -1722,13 +1679,9 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ready, None, )); - let ready_empty = ordinary_subjektiv_resident_summary_source( - &manifest, - ready_empty_client, - test_prompts(), - ) - .unwrap() - .unwrap(); + let ready_empty = backend_resident_source(&manifest, ready_empty_client, test_prompts()) + .unwrap() + .unwrap(); let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else { panic!("empty surface must retain Subject behavior"); }; @@ -1740,7 +1693,7 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Stale, memory::backend::MemoryResidentSummaryAvailability::Failed, ] { - let source = ordinary_subjektiv_resident_summary_source( + let source = backend_resident_source( &manifest, Arc::new(SubjectResidentClient::new( availability, @@ -1765,10 +1718,9 @@ permission = "write" memory::backend::MemoryResidentSummaryAvailability::Ungenerated, None, )); - let source = - ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) - .unwrap() - .unwrap(); + let source = backend_resident_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); let initial = source.load().await; assert!(matches!(initial, SystemPromptContribution::Ready(_))); @@ -1838,10 +1790,9 @@ permission = "write" async fn subject_resident_source_fails_closed_without_trusted_subject_scope() { let manifest = subject_manifest("workspace"); let client = Arc::new(SubjectResidentClient::denied()); - let source = - ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts()) - .unwrap() - .unwrap(); + let source = backend_resident_source(&manifest, client.clone(), test_prompts()) + .unwrap() + .unwrap(); let SystemPromptContribution::Ready(failure) = source.load().await else { panic!("fetch failure must be explicit in resident context"); @@ -1874,7 +1825,7 @@ permission = "write" ) .unwrap(); assert!( - ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts()) + backend_resident_source(&disabled, test_client(), test_prompts()) .unwrap() .is_none() ); @@ -1886,30 +1837,22 @@ permission = "write" Some("policy-only surface must not load"), )); assert!( - ordinary_subjektiv_resident_summary_source( - &policy_only, - unattached_client.clone(), - test_prompts() - ) - .unwrap() - .is_none() + backend_resident_source(&policy_only, unattached_client.clone(), test_prompts()) + .unwrap() + .is_none() ); assert!(unattached_client.paths.lock().unwrap().is_empty()); let foreign = subject_manifest("other-workspace"); - assert!( - ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts()) - .is_err() - ); + assert!(backend_resident_source(&foreign, test_client(), test_prompts()).is_err()); } #[test] fn subject_restore_refresh_is_suppressed_for_internal_hosts() { let manifest = subject_manifest("workspace"); - let source = - ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) - .unwrap() - .unwrap(); + let source = backend_resident_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let prompts = Arc::new(ArcSwap::from( crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(), )); @@ -1923,10 +1866,9 @@ permission = "write" #[test] fn subject_restore_refresh_feature_declares_only_restore_lifecycle() { let manifest = subject_manifest("workspace"); - let source = - ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts()) - .unwrap() - .unwrap(); + let source = backend_resident_source(&manifest, test_client(), test_prompts()) + .unwrap() + .unwrap(); let feature = SubjektivResidentRestoreRefreshFeature::new( source, Arc::new(ArcSwap::from( @@ -2034,7 +1976,7 @@ permission = "write" (403, "subject_scope_mismatch"), ] { let tool = SubjectConsolidationTool { - client: repository_error_client(status, code), + host: crate::subjektiv::test_connection(repository_error_client(status, code)), operation: SubjectConsolidationOperation::DecideCandidate, }; let output = tool @@ -2051,7 +1993,7 @@ permission = "write" #[test] fn subjektiv_consolidation_has_atomic_decision_without_host_scope_fields() { - let tools = workspace_http_subjektiv_consolidation_tools(test_client()); + let tools = subjektiv_consolidation_tools(crate::subjektiv::test_connection(test_client())); let names = tool_names(tools.clone()); assert_eq!( names, diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 5635bfe35..13d68eba9 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -101,6 +101,7 @@ struct MemoryLifecycleTask { capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, workspace_client: Arc, + subjektiv_host: Option, manifest: WorkerManifest, client: Box, prompts: Arc>, @@ -175,6 +176,7 @@ impl MemoryLifecycleFeature { capture, extensions, workspace_client, + subjektiv_host: None, manifest, client, prompts, @@ -192,32 +194,34 @@ impl SubjektivLifecycleFeature { config: manifest::ResolvedSubjektivFeatureConfig, capture: CommittedSessionCaptureHandle, extensions: SessionExtensionHandle, - workspace_client: Arc, + host: Option, manifest: WorkerManifest, client: Box, prompts: Arc>, workspace_context: WorkerWorkspaceContext, event_tx: Option>, ) -> std::io::Result> { - if !lifecycle_enabled || !config.execution_enabled() || !config.profile.extraction.enabled { + if !lifecycle_enabled + || !config.profile.enabled + || host.is_none() + || !config.profile.extraction.enabled + { return Ok(None); } config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv extraction requires Backend Workspace API authority", - )); - } Ok(Some(Self { task: MemoryLifecycleTask { config: LifecycleConfig::from_subjektiv(config), target: ExtractionTarget::Subjektiv, capture, extensions, - workspace_client, + workspace_client: crate::unavailable_workspace_client( + None, + "subjektiv Host has no legacy Memory authority", + ), + subjektiv_host: host, manifest, client, prompts, @@ -477,10 +481,11 @@ impl MemoryLifecycleTask { ), ExtractionTarget::Subjektiv => MemoryStagingOutputState::new_subjektiv( view.clone(), - Arc::clone(&self.workspace_client), + self.subjektiv_host + .clone() + .expect("subjektiv task has Host connection"), source, audit.run_id.to_string(), - capture.session_id.clone(), ), }; let client = if let Some(model) = self.config.extraction.model.as_ref() { @@ -499,6 +504,11 @@ impl MemoryLifecycleTask { .config .workspace_settings() .map(|snapshot| snapshot.language) + .or_else(|| { + self.subjektiv_host + .as_ref() + .map(|host| host.host.settings().language) + }) else { self.record_preparation_failure( &audit, @@ -737,14 +747,25 @@ impl MemoryLifecycleTask { .with_workspace_settings(self.config.workspace_settings.as_ref()); let operation = memory::backend::MemoryConsolidateStagingOperation { force: false }; let request = if subjektiv { - self.workspace_client - .request_subjektiv_memory_staging_consolidation(operation) - .await + let host = self + .subjektiv_host + .as_ref() + .expect("subjektiv task has Host connection"); + host.host + .request_consolidation(&host.context, operation) + .map_err(|error| { + super::memory::WorkspaceMemoryBackendError::Backend(error.to_string()) + }) } else { self.workspace_client .request_memory_staging_consolidation(operation) .await }; + let audit = if subjektiv { + audit.without_legacy_backend() + } else { + audit + }; match request { Ok(output) => { tracing::debug!( @@ -1319,6 +1340,7 @@ permission = "write" capture: capture_handle, extensions, workspace_client, + subjektiv_host: None, manifest: test_manifest(), client, prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), @@ -1379,7 +1401,7 @@ permission = "write" manifest::ResolvedSubjektivFeatureConfig::default(), capture, extensions, - crate::worker::marker_workspace_client(None, "disabled"), + None, test_manifest(), Box::new(ScriptClient::new(Vec::new())), Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), @@ -1407,7 +1429,7 @@ permission = "write" config, capture, extensions, - crate::worker::marker_workspace_client(None, "extraction-disabled"), + None, test_manifest(), Box::new(ScriptClient::new(Vec::new())), Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), @@ -1444,7 +1466,7 @@ permission = "write" config, capture, extensions, - crate::worker::marker_workspace_client(None, "child-disabled"), + None, test_manifest(), Box::new(ScriptClient::new(Vec::new())), Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), @@ -1593,6 +1615,9 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; + task.subjektiv_host = Some(crate::subjektiv::test_connection( + task.workspace_client.clone(), + )); task.config.consolidation_request_enabled = false; run_background_task(task).await; @@ -1647,6 +1672,9 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; + task.subjektiv_host = Some(crate::subjektiv::test_connection( + task.workspace_client.clone(), + )); task.config.consolidation_request_enabled = false; run_background_task(task).await; @@ -1689,6 +1717,9 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; + task.subjektiv_host = Some(crate::subjektiv::test_connection( + task.workspace_client.clone(), + )); task.config.consolidation_request_enabled = false; run_background_task(task).await; @@ -1806,6 +1837,9 @@ permission = "write" workspace_client.clone(), ); task.target = ExtractionTarget::Subjektiv; + task.subjektiv_host = Some(crate::subjektiv::test_connection( + task.workspace_client.clone(), + )); task.config.consolidation_request_enabled = false; let registry = start_background_task(task); tokio::time::timeout(Duration::from_secs(5), async { diff --git a/crates/worker/src/feature/builtin/memory_staging_output.rs b/crates/worker/src/feature/builtin/memory_staging_output.rs index e51939a3a..3896063b0 100644 --- a/crates/worker/src/feature/builtin/memory_staging_output.rs +++ b/crates/worker/src/feature/builtin/memory_staging_output.rs @@ -41,7 +41,9 @@ pub(crate) struct MemoryStagingOutputState { #[derive(Clone)] enum MemoryStagingDestination { WorkspaceMemory, - Subjektiv { session_id: String }, + Subjektiv { + host: crate::subjektiv::SubjektivHostConnection, + }, } impl MemoryStagingOutputState { @@ -64,17 +66,19 @@ impl MemoryStagingOutputState { pub(crate) fn new_subjektiv( view: SessionCapture, - workspace_client: Arc, + host: crate::subjektiv::SubjektivHostConnection, source: SourceRef, extract_run_id: String, - session_id: String, ) -> Self { Self { view: Arc::new(view), - workspace_client, + workspace_client: crate::unavailable_workspace_client( + None, + "subjektiv Host has no legacy Memory authority", + ), source, extract_run_id, - destination: MemoryStagingDestination::Subjektiv { session_id }, + destination: MemoryStagingDestination::Subjektiv { host }, staged: Arc::new(Mutex::new(Vec::new())), finished: Arc::new(Mutex::new(None)), } @@ -263,35 +267,11 @@ impl Tool for StageMemoryCandidateTool { } } } - MemoryStagingDestination::Subjektiv { session_id } => { - let response = self - .state - .workspace_client - .execute_server_operation( - crate::worker::WorkspaceServerOperation::SubjektivStageCandidate( - server_api::SubjektivStageCandidateRequest { - session_id: session_id.clone(), - operation, - }, - ), - ) - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "write subjektiv staging failed: {error}" - )) - })?; - if !response.is_success() { - return Err(ToolError::ExecutionFailed(format!( - "write subjektiv staging returned HTTP {}: {}", - response.status, response.body - ))); - } - let output: server_api::SubjektivStageCandidateResponse = - serde_json::from_str(&response.body).map_err(|error| { - ToolError::ExecutionFailed(format!( - "decode subjektiv staging response: {error}" - )) - })?; + MemoryStagingDestination::Subjektiv { host } => { + let output = host + .host + .stage_candidate(&host.context, operation) + .map_err(ToolError::from)?; vec![output.staging_id] } }; @@ -607,13 +587,16 @@ mod tests { let client = Arc::new(RecordingSubjektivClient::default()); let state = MemoryStagingOutputState::new_subjektiv( SessionCapture::new("segment-1", vec![Item::user_message("durable decision")]), - client.clone(), + { + let mut host = crate::subjektiv::test_connection(client.clone()); + host.context.session_id = "session-committed".into(); + host + }, SourceRef { segment_id: "segment-1".to_string(), range: [0, 0], }, "run-1".to_string(), - "session-committed".to_string(), ); let tool = StageMemoryCandidateTool { state: state.clone(), diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index 657d9b96e..6f012aedc 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -14,9 +14,7 @@ use crate::feature::background::{ BackgroundTaskCancellation, BackgroundTaskContext, BackgroundTaskSpec, BackgroundTaskTrigger, FeatureBackgroundTask, }; -use crate::feature::builtin::memory::{ - WorkspaceMemoryBackendError, is_builtin_subjektiv_consolidation_profile, -}; +use crate::feature::builtin::memory::is_builtin_subjektiv_consolidation_profile; use crate::feature::builtin::memory_surface_output::{ MemorySurfaceOutputFeature, MemorySurfaceOutputState, submit_llm_tool_definition, }; @@ -29,7 +27,10 @@ use crate::internal_worker::{ InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, run_internal_worker_with_cancel_sender, }; -use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext, WorkspaceClient}; +use crate::subjektiv::{SubjektivHostConnection, SubjektivHostError}; +#[cfg(test)] +use crate::worker::WorkspaceClient; +use crate::worker::{WorkerFilesystemAuthority, WorkerWorkspaceContext}; use manifest::WorkerManifest; const TASK_NAME: &str = "subjektiv-memory-surface"; @@ -39,13 +40,13 @@ const EDITOR_MAX_TURNS: u32 = 3; const EDITOR_QUESTION: &str = "この主体が次の作業を始める際、毎回思い出しておくべきことは何か。継続的な制約、判断の前提、未解決事項、再発を避けたい教訓を、指定予算内でまとめる。"; #[derive(Clone)] -pub(crate) struct SubjektivSurfaceLifecycleFeature { +pub struct SubjektivSurfaceLifecycleFeature { task: SubjektivSurfaceLifecycleTask, } #[derive(Clone)] struct SubjektivSurfaceLifecycleTask { - workspace_client: Arc, + host: SubjektivHostConnection, manifest: WorkerManifest, client: Box, prompts: Arc>, @@ -94,18 +95,20 @@ impl LlmClient for InputBudgetLlmClient { impl SubjektivSurfaceLifecycleFeature { pub(crate) fn from_manifest( lifecycle_enabled: bool, - workspace_client: Arc, + host: Option, manifest: WorkerManifest, client: Box, prompts: Arc>, workspace_context: WorkerWorkspaceContext, ) -> std::io::Result> { - let dedicated = is_builtin_subjektiv_consolidation_profile(&manifest); - if !lifecycle_enabled || !dedicated { + if !lifecycle_enabled + || !is_builtin_subjektiv_consolidation_profile(&manifest) + || host.is_none() + { return Ok(None); } - Self::for_job( - workspace_client, + Self::for_host( + host.expect("checked connection"), manifest, client, prompts, @@ -114,8 +117,9 @@ impl SubjektivSurfaceLifecycleFeature { .map(Some) } - pub(crate) fn for_job( - workspace_client: Arc, + /// Install the reusable surface Feature for an explicitly granted Host. + pub fn for_host( + host: SubjektivHostConnection, manifest: WorkerManifest, client: Box, prompts: Arc>, @@ -125,22 +129,16 @@ impl SubjektivSurfaceLifecycleFeature { .feature .subjektiv .validate_execution() - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !manifest.feature.subjektiv.execution_enabled() { + .map_err(std::io::Error::other)?; + if !manifest.feature.subjektiv.profile.enabled || !host.host.consolidation_granted() { return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv surface generation requires trusted subject attachment", - )); - } - if !workspace_client.is_available() || workspace_client.workspace_id().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv surface generation requires Backend Workspace API authority", + std::io::ErrorKind::PermissionDenied, + "surface generation requires explicit Host consolidation grant", )); } Ok(Self { task: SubjektivSurfaceLifecycleTask { - workspace_client, + host, manifest, client, prompts, @@ -150,7 +148,7 @@ impl SubjektivSurfaceLifecycleFeature { } /// The Job tool awaits this clean-context lifecycle before submitting its result. - pub(crate) async fn complete_for_job( + pub async fn complete_for_job( &self, cancellation: BackgroundTaskCancellation, reusable_surface: Option<&serde_json::Value>, @@ -234,7 +232,7 @@ impl SubjektivSurfaceLifecycleTask { )); } let generation = self - .workspace_client + .host .prepare_subjektiv_memory_surface() .await .map_err(surface_hook_error)?; @@ -299,7 +297,7 @@ impl SubjektivSurfaceLifecycleTask { )); } match self - .workspace_client + .host .publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest { generation_id: generation.generation_id.clone(), points, @@ -312,9 +310,8 @@ impl SubjektivSurfaceLifecycleTask { "store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id, })); } - Err(WorkspaceMemoryBackendError::Http { status, .. }) - if status == reqwest::StatusCode::CONFLICT - && attempt + 1 < MAX_GENERATION_ATTEMPTS => + Err(SubjektivHostError::Conflict { .. }) + if attempt + 1 < MAX_GENERATION_ATTEMPTS => { continue; } @@ -334,18 +331,7 @@ impl SubjektivSurfaceLifecycleTask { generation: &server_api::SubjektivSurfacePrepareResponse, cancellation: BackgroundTaskCancellation, ) -> Result, SurfaceEditorFailure> { - let language = self - .manifest - .feature - .subjektiv - .workspace_settings() - .map(|settings| settings.language) - .ok_or_else(|| { - SurfaceEditorFailure::editor(HookError::new( - HookErrorCategory::Internal, - "surface editor requires bound subjektiv Workspace settings", - )) - })?; + let language = self.host.host.settings().language; let system_prompt = self .prompts .load_full() @@ -424,7 +410,7 @@ impl SubjektivSurfaceLifecycleTask { job_confirmation_required: bool, ) -> Result { let failure = self - .workspace_client + .host .fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest { generation_id: generation.generation_id.clone(), reason_code: reason_code.to_string(), @@ -586,7 +572,7 @@ fn estimated_editor_request_tokens(request: &Request) -> Result HookError { +fn surface_hook_error(error: SubjektivHostError) -> HookError { HookError::new(HookErrorCategory::Internal, error.to_string()) } @@ -889,7 +875,7 @@ permission = "write" calls: Arc, ) -> SubjektivSurfaceLifecycleTask { SubjektivSurfaceLifecycleTask { - workspace_client, + host: crate::subjektiv::test_connection(workspace_client), manifest: test_manifest(), client: Box::new(SurfaceEditorClient { calls }), prompts: Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index 73331e4d3..a0b752a01 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -13,7 +13,7 @@ use memory::extract::CandidateKind; use schemars::JsonSchema; use serde::Deserialize; use server_api::{ - SubjektivMemoryBackendOperation, SubjektivMemoryBackendRequest, SubjektivMemoryBackendResponse, + SubjektivMemoryBackendOperation, SubjektivMemoryBackendResponse, SubjektivMemoryListRevisionsRequest, SubjektivMemoryQueryRequest, SubjektivMemoryReadRequest, SubjektivMemoryReceiptStatus, SubjektivMemoryReceiptStatusRequest, SubjektivMemoryRevisionIntent, SubjektivMemoryRevisionProposal, @@ -33,7 +33,11 @@ use crate::hook::{ PreRequestContext, RunCommitted, RunCommittedContext, }; use crate::session_capture::{SessionCapture, SessionEntryEvidence}; -use crate::worker::{WorkspaceClient, WorkspaceServerOperation}; +use crate::subjektiv::SubjektivHostConnection; +#[cfg(test)] +use crate::worker::WorkspaceClient; +#[cfg(test)] +use server_api::SubjektivMemoryBackendRequest; const QUERY_TOOL: &str = "SubjektivMemoryQuery"; const READ_TOOL: &str = "SubjektivMemoryRead"; @@ -58,7 +62,7 @@ pub(crate) struct SubjektivMemoryFeature { #[derive(Clone)] struct SubjektivMemoryState { - client: Arc, + host: SubjektivHostConnection, capture: CommittedSessionCaptureHandle, } @@ -76,22 +80,17 @@ impl SubjektivMemoryFeature { pub(crate) fn from_resolved_config( config: &manifest::ResolvedSubjektivFeatureConfig, capture: CommittedSessionCaptureHandle, - client: Arc, + host: Option, ) -> std::io::Result> { - if !config.execution_enabled() { + if !config.profile.enabled || host.is_none() { return Ok(None); } config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !client.is_available() || client.workspace_id().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv Memory tools require Backend Workspace API authority", - )); - } + let host = host.expect("checked explicit connection"); Ok(Some(Self { - state: SubjektivMemoryState { client, capture }, + state: SubjektivMemoryState { host, capture }, })) } } @@ -483,48 +482,7 @@ impl SubjektivMemoryState { &self, operation: SubjektivMemoryBackendOperation, ) -> Result { - let response = self - .client - .execute_server_operation(WorkspaceServerOperation::SubjektivMemory( - SubjektivMemoryBackendRequest { operation }, - )) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - if !response.is_success() { - let parsed = - serde_json::from_str::(&response.body).ok(); - if let Some((code, message)) = parsed.as_ref().and_then(|error| { - error - .diagnostics - .iter() - .find(|diagnostic| { - matches!( - diagnostic.code.as_str(), - "revision_conflict" | "stale_cursor" - ) - }) - .map(|diagnostic| (diagnostic.code.as_str(), error.message.as_str())) - }) { - return Err(ToolError::StructuredConflict { - code: code.to_string(), - message: message.to_string(), - }); - } - let detail = parsed - .map(|error| error.message) - .unwrap_or_else(|| response.body.clone()); - let message = format!( - "subjektiv Memory backend returned HTTP {}: {detail}", - response.status - ); - return if matches!(response.status, 400 | 404 | 409 | 422) { - Err(ToolError::InvalidArgument(message)) - } else { - Err(ToolError::ExecutionFailed(message)) - }; - } - serde_json::from_str(&response.body).map_err(|error| { - ToolError::ExecutionFailed(format!("decode subjektiv response: {error}")) - }) + self.host.memory(operation).map_err(Into::into) } fn backend_receipt_status( @@ -628,7 +586,7 @@ struct PendingRetryHook { #[async_trait] impl Hook for PendingRetryHook { async fn call(&self, _input: &PreRequestContext) -> Result { - replay_pending_committed_memory(&self.state)?; + replay_pending_committed_memory_with_policy(&self.state, true)?; Ok(HookPreRequestAction::Continue) } } @@ -649,6 +607,12 @@ impl Hook for PendingRewriteHook { } fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), HookError> { + replay_pending_committed_memory_with_policy(state, false) +} +fn replay_pending_committed_memory_with_policy( + state: &SubjektivMemoryState, + allow_pending: bool, +) -> Result<(), HookError> { let capture = state .capture .capture() @@ -691,7 +655,11 @@ fn replay_pending_committed_memory(state: &SubjektivMemoryState) -> Result<(), H vec![call.evidence], item.proposal, ) { - failures.push(format!("{receipt_id}: {error}")); + if !allow_pending + || !matches!(&error,ToolError::StructuredConflict {code,..} if code=="pending_commit") + { + failures.push(format!("{receipt_id}: {error}")); + } } } if failures.is_empty() { @@ -1109,7 +1077,7 @@ mod tests { }); SubjektivMemoryFeature { state: SubjektivMemoryState { - client: Arc::new(TestWorkspaceClient), + host: crate::subjektiv::test_connection(Arc::new(TestWorkspaceClient)), capture, }, } @@ -1245,7 +1213,7 @@ mod tests { "\\\u{0000}".repeat(server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES); let tool = SubjektivReadTool { state: SubjektivMemoryState { - client: Arc::new(ReadWorkspaceClient(response)), + host: crate::subjektiv::test_connection(Arc::new(ReadWorkspaceClient(response))), capture: feature().state.capture, }, operation: ReadOperation::Read, @@ -1265,7 +1233,9 @@ mod tests { let oversized_tool = SubjektivReadTool { state: SubjektivMemoryState { - client: Arc::new(ReadWorkspaceClient(oversized_response)), + host: crate::subjektiv::test_connection(Arc::new(ReadWorkspaceClient( + oversized_response, + ))), capture: feature().state.capture, }, operation: ReadOperation::Read, @@ -1285,7 +1255,9 @@ mod tests { for code in ["revision_conflict", "stale_cursor"] { let tool = SubjektivReadTool { state: SubjektivMemoryState { - client: Arc::new(ConflictWorkspaceClient(code)), + host: crate::subjektiv::test_connection(Arc::new(ConflictWorkspaceClient( + code, + ))), capture: feature().state.capture, }, operation: ReadOperation::Query, @@ -1386,7 +1358,7 @@ mod tests { }); PendingCommitHook { state: SubjektivMemoryState { - client: client.clone(), + host: crate::subjektiv::test_connection(client.clone()), capture, }, } @@ -1439,7 +1411,7 @@ mod tests { }); let hook = PendingCommitHook { state: SubjektivMemoryState { - client: client.clone(), + host: crate::subjektiv::test_connection(client.clone()), capture, }, }; @@ -1515,7 +1487,7 @@ mod tests { }) }); let state = SubjektivMemoryState { - client: client.clone(), + host: crate::subjektiv::test_connection(client.clone()), capture, }; assert_eq!( diff --git a/crates/worker/src/feature/builtin/subjektiv_session.rs b/crates/worker/src/feature/builtin/subjektiv_session.rs index b7ee46256..3d257d680 100644 --- a/crates/worker/src/feature/builtin/subjektiv_session.rs +++ b/crates/worker/src/feature/builtin/subjektiv_session.rs @@ -11,16 +11,20 @@ use async_trait::async_trait; use schemars::JsonSchema; use server_api::{ SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivSessionBackendOperation, - SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult, - SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest, - SubjektivSessionReadRequest, SubjektivSessionSearchRequest, + SubjektivSessionBackendResponse, SubjektivSessionBackendResult, SubjektivSessionDiagnosticCode, + SubjektivSessionErrorResponse, SubjektivSessionListRequest, SubjektivSessionReadRequest, + SubjektivSessionSearchRequest, }; use crate::feature::{ FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, ToolDeclaration, }; -use crate::worker::{WorkspaceClient, WorkspaceServerOperation}; +use crate::subjektiv::SubjektivHostConnection; +#[cfg(test)] +use crate::worker::WorkspaceClient; +#[cfg(test)] +use server_api::SubjektivSessionBackendRequest; const LIST_TOOL: &str = "SubjektivSessionList"; const SEARCH_TOOL: &str = "SubjektivSessionSearch"; @@ -37,28 +41,23 @@ pub(crate) struct SubjektivSessionFeature { #[derive(Clone)] struct SubjektivSessionState { - client: Arc, + host: SubjektivHostConnection, } impl SubjektivSessionFeature { pub(crate) fn from_resolved_config( config: &manifest::ResolvedSubjektivFeatureConfig, - client: Arc, + host: Option, ) -> std::io::Result> { - if !config.execution_enabled() { + if !config.profile.enabled || host.is_none() { return Ok(None); } config .validate_execution() .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - if !client.is_available() || client.workspace_id().is_none() { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "subjektiv Session tools require Backend Workspace API authority", - )); - } + let host = host.expect("checked explicit connection"); Ok(Some(Self { - state: SubjektivSessionState { client }, + state: SubjektivSessionState { host }, })) } } @@ -154,25 +153,7 @@ impl SubjektivSessionState { &self, operation: SubjektivSessionBackendOperation, ) -> Result { - let response = self - .client - .execute_server_operation(WorkspaceServerOperation::SubjektivSession( - SubjektivSessionBackendRequest { operation }, - )) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; - - if let Ok(response) = - serde_json::from_str::(&response.body) - { - return Ok(response); - } - let detail = serde_json::from_str::(&response.body) - .map(|error| error.message) - .unwrap_or(response.body); - Err(ToolError::ExecutionFailed(format!( - "subjektiv Session backend returned HTTP {}: {detail}", - response.status - ))) + self.host.session(operation).map_err(Into::into) } } @@ -293,7 +274,9 @@ mod tests { } fn state(client: Arc) -> SubjektivSessionState { - SubjektivSessionState { client } + SubjektivSessionState { + host: crate::subjektiv::test_connection(client), + } } fn feature(client: Arc) -> SubjektivSessionFeature { @@ -363,7 +346,9 @@ mod tests { let config = manifest::ResolvedSubjektivFeatureConfig::default(); let result = SubjektivSessionFeature::from_resolved_config( &config, - Arc::new(SessionToolClient::new(list_response())), + Some(crate::subjektiv::test_connection(Arc::new( + SessionToolClient::new(list_response()), + ))), ) .unwrap(); assert!(result.is_none()); diff --git a/crates/worker/src/job.rs b/crates/worker/src/job.rs index afae9a29f..1788f54dc 100644 --- a/crates/worker/src/job.rs +++ b/crates/worker/src/job.rs @@ -8,7 +8,7 @@ //! cannot be preempted by dropping its tool future and must itself be bounded. mod result; -pub use result::{JobResultFeature, JobResultSink, SUBMIT_JOB_RESULT_TOOL}; +pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL}; use std::borrow::Cow; use std::path::Path; @@ -72,6 +72,28 @@ pub struct PreparedInternalJob { manifest: WorkerManifest, client: Box, system_prompt: String, + features: FeatureRegistryBuilder, + finalizer: Option>, +} + +/// Explicit Host-supplied domain modules and the Profile requirements they satisfy. +/// Profile names never issue this grant. The Host must bind all module operations to +/// the immutable request/attempt and fence them on cancellation, timeout and shutdown. +#[derive(Default)] +pub struct JobFeatureGrant { + pub features: FeatureRegistryBuilder, + pub satisfied_requirements: Vec<&'static str>, + pub finalizer: Option>, +} + +pub fn prepare_job_with_grant( + request: &::job::JobRequest, + cwd: &Path, + client: Option>, + grant: JobFeatureGrant, +) -> Result { + let registry = ProfileDiscovery::user_settings().discover()?; + prepare_job_from_registry_with_grant(request, cwd, client, ®istry, grant) } /// Resolve exactly the requested registry Profile using the existing user settings registry. @@ -91,6 +113,16 @@ fn prepare_job_from_registry( cwd: &Path, client: Option>, registry: &manifest::ProfileRegistry, +) -> Result { + prepare_job_from_registry_with_grant(request, cwd, client, registry, JobFeatureGrant::default()) +} + +fn prepare_job_from_registry_with_grant( + request: &::job::JobRequest, + cwd: &Path, + client: Option>, + registry: &manifest::ProfileRegistry, + grant: JobFeatureGrant, ) -> Result { request.validate()?; let resolved = ProfileResolver::new() @@ -101,7 +133,16 @@ fn prepare_job_from_registry( ProfileResolveOptions::with_worker_name("internal-job"), )?; let mut manifest = resolved.manifest; - validate_result_only_profile(&manifest)?; + validate_profile_with_grant(&manifest, &grant.satisfied_requirements)?; + if let Some(finalizer) = &grant.finalizer { + finalizer + .validate_profile(&manifest) + .map_err(JobExecutionError::UnsupportedProfile)?; + } + let mut tool_names = vec![SUBMIT_JOB_RESULT_TOOL.to_string()]; + for descriptor in grant.features.descriptors() { + tool_names.extend(descriptor.tools.iter().map(|tool| tool.name.clone())); + } let scope = Scope::empty(); let prompts = PromptCatalog::builtins_only()?; let template = SystemPromptTemplate::parse( @@ -113,7 +154,7 @@ fn prepare_job_from_registry( cwd: Cow::Borrowed("(no filesystem)"), language: &manifest.engine.language, scope: &scope, - tool_names: vec![SUBMIT_JOB_RESULT_TOOL.into()], + tool_names, feature_instructions: &[], agents_md: None, resident_summary: None, @@ -148,6 +189,8 @@ fn prepare_job_from_registry( manifest, client, system_prompt, + features: grant.features, + finalizer: grant.finalizer, }) } @@ -165,7 +208,11 @@ impl PreparedInternalJob { where F: FnOnce(tokio::sync::mpsc::Sender<()>), { - let feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?; + let mut feature = JobResultFeature::new(&self.request, attempt_id.clone(), submit)?; + if let Some(finalizer) = self.finalizer { + feature = + feature.with_finalizer(finalizer, self.manifest.clone(), self.client.clone_boxed()); + } let accepted = feature.accepted(); let engine_policy = self.manifest.engine.clone(); let spec = InternalWorkerSpec { @@ -182,7 +229,7 @@ impl PreparedInternalJob { system_prompt: self.system_prompt, input: self.request.worker_input(&attempt_id)?, cache_key: None, - features: FeatureRegistryBuilder::new().with_module(feature.clone()), + features: self.features.with_module(feature.clone()), required_tools: &[SUBMIT_JOB_RESULT_TOOL], authority: InternalWorkerAuthority { workspace: WorkerWorkspaceContext::no_workspace(), @@ -218,7 +265,11 @@ impl PreparedInternalJob { } /// Validate requirements, not Profile names. Nothing configured as active is silently stripped. -fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExecutionError> { + +fn validate_profile_with_grant( + manifest: &WorkerManifest, + satisfied: &[&str], +) -> Result<(), JobExecutionError> { let manifest::FeatureConfig { task, memory, @@ -306,7 +357,7 @@ fn validate_result_only_profile(manifest: &WorkerManifest) -> Result<(), JobExec manifest.session.record_event_trace, ), ] { - if enabled { + if enabled && !satisfied.contains(&name) { unavailable.push(name); } } diff --git a/crates/worker/src/job/result.rs b/crates/worker/src/job/result.rs index edb10712b..ee3fedb30 100644 --- a/crates/worker/src/job/result.rs +++ b/crates/worker/src/job/result.rs @@ -23,6 +23,29 @@ pub trait JobResultSink: Send + Sync { fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String>; } +/// Consumer-owned asynchronous completion before durable result acceptance. +/// The generic runner owns no domain algorithms. This runs in the result tool, +/// never an orphaned background task, with the selected policy/model and a +/// cancellation token that must propagate into any child resource cleanup. +#[async_trait] +pub trait JobResultFinalizer: Send + Sync { + fn validate_profile(&self, manifest: &manifest::WorkerManifest) -> Result<(), String>; + async fn finalize( + &self, + result: Value, + manifest: manifest::WorkerManifest, + client: Box, + cancellation: crate::feature::background::BackgroundTaskCancellation, + ) -> Result; +} + +#[derive(Clone)] +struct FinalizerContext { + finalizer: Arc, + manifest: manifest::WorkerManifest, + client: Box, +} + #[derive(Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] struct SubmitJobResultInput { @@ -32,6 +55,7 @@ struct SubmitJobResultInput { #[derive(Default)] struct SubmissionState { sealed: Option, + prepared: Option, unconfirmed: Option, } @@ -46,6 +70,9 @@ pub struct JobResultFeature { state: Arc>, accepted: Arc>>, cancelled: Arc>>, + serial: Arc>, + cancellation: crate::feature::background::BackgroundTaskCancellation, + finalizer: Option, } impl JobResultFeature { @@ -65,9 +92,26 @@ impl JobResultFeature { state: Arc::default(), accepted: Arc::default(), cancelled: Arc::default(), + serial: Arc::default(), + cancellation: Default::default(), + finalizer: None, }) } + pub fn with_finalizer( + mut self, + finalizer: Arc, + manifest: manifest::WorkerManifest, + client: Box, + ) -> Self { + self.finalizer = Some(FinalizerContext { + finalizer, + manifest, + client, + }); + self + } + pub(super) fn accepted(&self) -> Arc>> { self.accepted.clone() } @@ -107,6 +151,44 @@ impl Tool for JobResultFeature { .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; ::job::result_digest(&input.result, self.max_result_bytes) .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + let _serial = self.serial.lock().await; + if self.cancellation.is_cancelled() { + return Err(ToolError::Cancelled(ToolOutput { + summary: "Job result submission cancelled".into(), + content: None, + attachments: vec![], + })); + } + let cached = { + let state = self.state.lock().expect("Job submission state poisoned"); + if state + .sealed + .as_ref() + .is_some_and(|sealed| sealed != &input.result) + { + return Err(ToolError::InvalidArgument( + "Job result is sealed; retry only the identical result".into(), + )); + } + state.prepared.clone() + }; + let prepared = if let Some(value) = cached { + value + } else if let Some(finalizer) = &self.finalizer { + finalizer + .finalizer + .finalize( + input.result.clone(), + finalizer.manifest.clone(), + finalizer.client.clone_boxed(), + self.cancellation.clone(), + ) + .await? + } else { + input.result.clone() + }; + ::job::result_digest(&prepared, self.max_result_bytes) + .map_err(|e| ToolError::InvalidArgument(e.to_string()))?; let mut state = self.state.lock().expect("Job submission state poisoned"); if self .cancelled @@ -129,12 +211,13 @@ impl Tool for JobResultFeature { } else { // Seal before dispatch: a sink error can follow durable Host acceptance. state.sealed = Some(input.result.clone()); + state.prepared = Some(prepared.clone()); } let submission = ::job::JobResultSubmission { job_id: self.job_id.clone(), attempt_id: self.attempt_id.clone(), input_revision: self.input_revision.clone(), - result: input.result, + result: prepared, }; let mut accepted = self.accepted.lock().expect("Job result state poisoned"); if accepted.is_none() { @@ -156,6 +239,7 @@ impl Tool for JobResultFeature { } async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> { + self.cancellation.cancel(); self.cancelled .lock() .expect("Job cancellation state poisoned") diff --git a/crates/worker/src/job/tests.rs b/crates/worker/src/job/tests.rs index 2135e1b80..3c0c66ec6 100644 --- a/crates/worker/src/job/tests.rs +++ b/crates/worker/src/job/tests.rs @@ -1,14 +1,21 @@ //! Scripted in-process model executions, not provider/process tests. use super::*; +use crate::feature::background::BackgroundTaskCancellation; +use crate::feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, + ToolDeclaration, +}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::{ClientError, Request}; use agen::tool::{Tool, ToolExecutionContext}; +use agen::tool::{ToolError, ToolOutput}; use async_trait::async_trait; use futures::Stream; use serde_json::{Value, json}; use std::collections::VecDeque; use std::pin::Pin; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; #[derive(Clone)] struct ScriptedClient { @@ -592,6 +599,458 @@ fn unknown_prompt_and_model_are_not_substituted_even_with_injected_client() { } } +// A domain capability is issued by the Host, never by the selected Profile. +#[derive(Clone, Default)] +struct GrantedTool(Arc); +#[async_trait] +impl Tool for GrantedTool { + async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(ToolOutput { + summary: "granted".into(), + content: None, + attachments: vec![], + }) + } +} +impl FeatureModule for GrantedTool { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("test-job-domain", "Test domain").with_tool( + ToolDeclaration::new("GrantedDomainTool", "Explicit Host capability"), + ) + } + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + let tool = self.clone(); + context.tools().register(ToolContribution::new( + "GrantedDomainTool", + Arc::new(move || { + ( + agen::tool::ToolMeta::new("GrantedDomainTool"), + Arc::new(tool.clone()) as Arc, + ) + }), + )) + } +} + +#[tokio::test] +async fn domain_grant_is_explicit_not_profile_derived_and_preserves_selected_policy() { + let root = tempfile::tempdir().unwrap(); + let mut profile = artifact("internal.flow_verifier_system", 100000, 1000); + profile["feature"] = json!({"task":{"enabled":true}}); + let registry = registry(root.path(), profile.clone(), profile); + let marker = GrantedTool::default(); + for selector in ["user:a", "user:b", "default"] { + assert!(matches!( + prepare_job_from_registry( + &request(selector), + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry, + ), + Err(JobExecutionError::UnsupportedProfile(_)) + )); + let client = ScriptedClient::new(vec![ + call("GrantedDomainTool", json!({})), + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})), + prose(), + ]); + let job = prepare_job_from_registry_with_grant( + &request(selector), + root.path(), + Some(Box::new(client.clone())), + ®istry, + JobFeatureGrant { + features: FeatureRegistryBuilder::new().with_module(marker.clone()), + satisfied_requirements: vec!["feature.task"], + finalizer: None, + }, + ) + .unwrap(); + assert_eq!( + job.manifest.model.model_id.as_deref(), + Some("scripted-model") + ); + assert_eq!( + job.manifest.engine.instruction, + "internal.flow_verifier_system" + ); + assert_eq!(job.manifest.engine.max_turns.unwrap().get(), MAX_JOB_TURNS); + assert_eq!(job.manifest.engine.max_tokens, Some(MAX_JOB_TOKENS)); + assert!(job.manifest.scope.allow.is_empty()); + assert!(job.manifest.delegation_scope.allow.is_empty()); + job.run("attempt".into(), Arc::new(RecordingSink::default()), |_| {}) + .await + .unwrap(); + for sent in client.requests.lock().unwrap().iter() { + let mut names = sent + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + names.sort(); + assert_eq!(names, ["GrantedDomainTool", SUBMIT_JOB_RESULT_TOOL]); + assert_eq!(sent.config.max_tokens, Some(MAX_JOB_TOKENS)); + } + } + assert_eq!(marker.0.load(Ordering::SeqCst), 3); +} + +#[test] +fn domain_grant_does_not_satisfy_unrelated_profile_requirements() { + let root = tempfile::tempdir().unwrap(); + for (key, value, needle) in [ + ( + "feature", + json!({"task":{"enabled":true},"web":{"enabled":true}}), + "feature.web", + ), + ("scope", json!("workspace_read"), "scope"), + ( + "delegation_scope", + json!("workspace_write"), + "delegation_scope", + ), + ] { + let mut profile = artifact("default", 512, 3); + profile[key] = value; + let registry = registry(root.path(), profile.clone(), profile); + let error = prepare_job_from_registry_with_grant( + &request("user:a"), + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry, + JobFeatureGrant { + features: FeatureRegistryBuilder::new().with_module(GrantedTool::default()), + satisfied_requirements: vec!["feature.task"], + finalizer: None, + }, + ) + .err() + .unwrap(); + assert!( + matches!(error, JobExecutionError::UnsupportedProfile(_)), + "{error}" + ); + assert!(error.to_string().contains(needle), "{error}"); + } +} + +#[derive(Default)] +struct ControlledFinalizer { + calls: AtomicUsize, + active: Arc, + manifests: Mutex>, + entered: tokio::sync::Notify, + release: tokio::sync::Notify, + cancellation_seen: tokio::sync::Notify, + cleanup: tokio::sync::Notify, + cancelled: AtomicBool, + gated: bool, + // Reusing a captured request with the supplied client proves transport identity. + probe: Option>>>, + reject_profile: bool, +} +struct FinalizerResource(Arc); +impl Drop for FinalizerResource { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} +#[async_trait] +impl JobResultFinalizer for ControlledFinalizer { + fn validate_profile(&self, _: &manifest::WorkerManifest) -> Result<(), String> { + if self.reject_profile { + Err("test finalizer refuses selected policy".into()) + } else { + Ok(()) + } + } + async fn finalize( + &self, + result: Value, + manifest: manifest::WorkerManifest, + client: Box, + cancellation: BackgroundTaskCancellation, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.active.fetch_add(1, Ordering::SeqCst); + let _resource = FinalizerResource(self.active.clone()); + self.manifests.lock().unwrap().push(manifest); + if let Some(requests) = &self.probe { + let request = requests.lock().unwrap()[0].clone(); + let events = client + .stream(request) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let _: Vec<_> = futures::StreamExt::collect(events).await; + } + self.entered.notify_one(); + if self.gated { + tokio::select! { + _ = self.release.notified() => {}, + _ = cancellation.cancelled() => { + self.cancelled.store(true, Ordering::SeqCst); + self.cancellation_seen.notify_one(); + self.cleanup.notified().await; + return Err(ToolError::Cancelled(ToolOutput { + summary: "finalizer cleaned up".into(), content: None, attachments: vec![], + })); + } + } + } + Ok(json!({"prepared":result,"completion":self.calls.load(Ordering::SeqCst)})) + } +} +async fn signalled(notify: &tokio::sync::Notify) { + tokio::time::timeout(std::time::Duration::from_secs(2), notify.notified()) + .await + .unwrap(); +} + +#[tokio::test] +async fn result_finalization_is_awaited_with_selected_model_policy_and_no_orphan_resource() { + let root = tempfile::tempdir().unwrap(); + let registry = registry( + root.path(), + artifact("default", 777, 4), + artifact("default", 512, 3), + ); + let client = ScriptedClient::new(vec![ + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"draft":true}})), + prose(), + prose(), + ]); + let finalizer = Arc::new(ControlledFinalizer { + gated: true, + probe: Some(client.requests.clone()), + ..Default::default() + }); + let job = prepare_job_from_registry_with_grant( + &request("user:a"), + root.path(), + Some(Box::new(client.clone())), + ®istry, + JobFeatureGrant { + finalizer: Some(finalizer.clone()), + ..Default::default() + }, + ) + .unwrap(); + let expected = serde_json::to_value(&job.manifest).unwrap(); + let sink = Arc::new(RecordingSink::default()); + let recording = sink.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let run = tokio::spawn(async move { + job.run("attempt".into(), recording, move |sender| { + let _ = tx.send(sender); + }) + .await + }); + let sender = rx.await.unwrap(); + signalled(&finalizer.entered).await; + assert!(!run.is_finished()); + assert!( + sink.0.lock().unwrap().is_empty(), + "sink cannot accept an unfinished result" + ); + assert_eq!(finalizer.active.load(Ordering::SeqCst), 1); + assert_eq!( + serde_json::to_value(&finalizer.manifests.lock().unwrap()[0]).unwrap(), + expected + ); + assert_eq!( + client.requests.lock().unwrap().len(), + 2, + "finalizer uses the injected selected transport" + ); + finalizer.release.notify_one(); + let accepted = tokio::time::timeout(std::time::Duration::from_secs(2), run) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!( + accepted.submission.result, + json!({"prepared":{"draft":true},"completion":1}) + ); + assert_eq!(sink.0.lock().unwrap().as_slice(), &[accepted.submission]); + assert_eq!(finalizer.active.load(Ordering::SeqCst), 0); + assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1); + assert!(sender.is_closed()); +} + +#[tokio::test] +async fn cancellation_reaches_finalizer_and_runner_waits_for_cleanup_without_acceptance() { + let root = tempfile::tempdir().unwrap(); + let registry = registry( + root.path(), + artifact("default", 512, 3), + artifact("default", 512, 3), + ); + let finalizer = Arc::new(ControlledFinalizer { + gated: true, + ..Default::default() + }); + let job = prepare_job_from_registry_with_grant( + &request("user:a"), + root.path(), + Some(Box::new(ScriptedClient::new(vec![ + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})), + prose(), + ]))), + ®istry, + JobFeatureGrant { + finalizer: Some(finalizer.clone()), + ..Default::default() + }, + ) + .unwrap(); + let sink = Arc::new(RecordingSink::default()); + let recording = sink.clone(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let run = tokio::spawn(async move { + job.run("attempt".into(), recording, move |sender| { + let _ = tx.send(sender); + }) + .await + }); + let sender = rx.await.unwrap(); + signalled(&finalizer.entered).await; + assert!(sink.0.lock().unwrap().is_empty()); + sender.send(()).await.unwrap(); + signalled(&finalizer.cancellation_seen).await; + assert!(finalizer.cancelled.load(Ordering::SeqCst)); + assert!( + !run.is_finished(), + "cancellation must await consumer resource cleanup" + ); + assert_eq!(finalizer.active.load(Ordering::SeqCst), 1); + assert!(sink.0.lock().unwrap().is_empty()); + finalizer.cleanup.notify_one(); + let outcome = tokio::time::timeout(std::time::Duration::from_secs(2), run) + .await + .unwrap() + .unwrap(); + assert!( + matches!(outcome, Err(JobExecutionError::Cancelled)), + "{outcome:?}" + ); + assert!(sink.0.lock().unwrap().is_empty()); + assert_eq!(finalizer.active.load(Ordering::SeqCst), 0); + assert!(sender.is_closed()); +} + +#[test] +fn finalizer_can_refuse_selected_profile_before_execution() { + let root = tempfile::tempdir().unwrap(); + let registry = registry( + root.path(), + artifact("default", 512, 3), + artifact("default", 512, 3), + ); + let finalizer = Arc::new(ControlledFinalizer { + reject_profile: true, + ..Default::default() + }); + let client = ScriptedClient::new(vec![]); + let error = prepare_job_from_registry_with_grant( + &request("user:a"), + root.path(), + Some(Box::new(client.clone())), + ®istry, + JobFeatureGrant { + finalizer: Some(finalizer.clone()), + ..Default::default() + }, + ) + .err() + .unwrap(); + assert!(matches!(error, JobExecutionError::UnsupportedProfile(_))); + assert!( + error + .to_string() + .contains("test finalizer refuses selected policy") + ); + assert!(client.requests.lock().unwrap().is_empty()); + assert_eq!(finalizer.calls.load(Ordering::SeqCst), 0); +} + +#[derive(Default)] +struct UncertainThenAcceptedSink(Mutex>); +impl JobResultSink for UncertainThenAcceptedSink { + fn submit(&self, submission: ::job::JobResultSubmission) -> Result<(), String> { + let mut submissions = self.0.lock().unwrap(); + submissions.push(submission); + if submissions.len() == 1 { + Err("durable acceptance is uncertain".into()) + } else { + Ok(()) + } + } +} + +#[tokio::test] +async fn exact_uncertain_sink_retry_reuses_prepared_outcome_without_rerunning_finalizer() { + let root = tempfile::tempdir().unwrap(); + let job = prepared(root.path(), &request("user:a"), ScriptedClient::new(vec![])); + let finalizer = Arc::new(ControlledFinalizer::default()); + let sink = Arc::new(UncertainThenAcceptedSink::default()); + let feature = JobResultFeature::new(&request("user:a"), "attempt".into(), sink.clone()) + .unwrap() + .with_finalizer(finalizer.clone(), job.manifest, job.client); + assert!( + feature + .execute( + r#"{"result":{"draft":true}}"#, + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert!(feature.unconfirmed().is_some()); + assert!(feature.accepted().lock().unwrap().is_none()); + assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1); + assert!( + feature + .execute( + r#"{"result":{"draft":false}}"#, + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert_eq!(sink.0.lock().unwrap().len(), 1); + feature + .execute( + r#"{"result":{"draft":true}}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + feature + .execute( + r#"{"result":{"draft":true}}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + assert!(feature.unconfirmed().is_none()); + let submissions = sink.0.lock().unwrap(); + assert_eq!(submissions.len(), 2); + assert_eq!(submissions[0], submissions[1]); + assert_eq!( + submissions[0].result, + json!({"prepared":{"draft":true},"completion":1}) + ); + assert_eq!( + feature.accepted().lock().unwrap().as_ref(), + Some(&submissions[1]) + ); + assert_eq!(finalizer.calls.load(Ordering::SeqCst), 1); + assert_eq!(finalizer.active.load(Ordering::SeqCst), 0); +} + struct UnknownSink(Mutex>); impl JobResultSink for UnknownSink { fn submit(&self, value: ::job::JobResultSubmission) -> Result<(), String> { diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 07daef4e2..069938d1f 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -25,6 +25,7 @@ pub mod shared_state; mod shutdown_after_idle; pub mod skill; pub mod spawn; +pub mod subjektiv; pub mod wip; mod internal_worker; diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index ad7a25adf..570c2b7c7 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -712,7 +712,9 @@ impl SessionCapture { fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance { match origin { - WorkerHistoryProvenance::HumanInput { .. } => SessionEntryProvenance::HumanInput, + WorkerHistoryProvenance::HumanInput { .. } | WorkerHistoryProvenance::LocalHumanInput => { + SessionEntryProvenance::HumanInput + } WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput, WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction, WorkerHistoryProvenance::BackendInstruction { .. } => { diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs new file mode 100644 index 000000000..3a3c31d5a --- /dev/null +++ b/crates/worker/src/subjektiv.rs @@ -0,0 +1,700 @@ +//! Explicit Host capability for subject-scoped Memory and committed Sessions. +//! +//! A manifest is policy, not a connection. Hosts bind Subject/storage authority +//! in their implementation; the Worker supplies its own Worker/Session context. +//! Neither Subject identity nor filesystem/Workspace authority is model input. + +pub use crate::feature::builtin::memory::SubjektivConsolidationToolsFeature; +pub use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature; + +use std::sync::Arc; +use std::time::Duration; + +use agen::tool::ToolError; +use memory::backend::{ + MemoryConsolidateStagingOperation, MemoryConsolidationOutput, MemoryStageCandidateOperation, +}; +use server_api::*; + +use crate::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceServerOperation}; + +/// Host-issued execution context, never deserialized from model arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubjektivHostContext { + pub worker_id: String, + pub session_id: String, +} + +/// Explicit local configuration. This is not a trusted Workspace snapshot and +/// is not persisted in the reusable Worker manifest. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubjektivHostSettings { + pub language: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum SubjektivHostError { + #[error("{message}")] + Conflict { code: String, message: String }, + #[error("{0}")] + InvalidArgument(String), + #[error("{0}")] + Unavailable(String), +} + +impl From for ToolError { + fn from(error: SubjektivHostError) -> Self { + match error { + SubjektivHostError::Conflict { code, message } => { + Self::StructuredConflict { code, message } + } + SubjektivHostError::InvalidArgument(message) => Self::InvalidArgument(message), + SubjektivHostError::Unavailable(message) => Self::ExecutionFailed(message), + } + } +} + +/// Object-safe capability implemented by Backend and standalone Hosts. +/// Implementations must enforce their bound Subject, Worker/Session authority, +/// committed evidence and (for Jobs) immutable batch attenuation. Knowing an +/// identifier is not a grant. No method grants ambient filesystem access. +pub trait SubjektivHost: Send + Sync + 'static { + fn settings(&self) -> SubjektivHostSettings; + /// Separate explicit grant for decision/surface execution; Profile policy + /// alone must never authorize consolidation tools. + fn consolidation_granted(&self) -> bool { + false + } + /// Optional real Backend identity, used only for prompt provenance. + fn workspace_id(&self) -> Option { + None + } + fn memory( + &self, + context: &SubjektivHostContext, + operation: SubjektivMemoryBackendOperation, + ) -> Result; + fn session( + &self, + context: &SubjektivHostContext, + operation: SubjektivSessionBackendOperation, + ) -> Result; + fn stage_candidate( + &self, + context: &SubjektivHostContext, + operation: MemoryStageCandidateOperation, + ) -> Result; + fn record_session( + &self, + context: &SubjektivHostContext, + create_if_missing: bool, + ) -> Result; + fn request_consolidation( + &self, + context: &SubjektivHostContext, + operation: MemoryConsolidateStagingOperation, + ) -> Result; +} + +/// Backend adapter. Existing authenticated Workspace operations and snapshot +/// validation remain the authority; no local Workspace is synthesized. +pub struct BackendSubjektivHost { + client: Arc, + settings: SubjektivHostSettings, + workspace_id: String, + consolidation_granted: bool, +} + +impl BackendSubjektivHost { + pub fn from_resolved_config( + config: &manifest::ResolvedSubjektivFeatureConfig, + client: Arc, + ) -> std::io::Result>> { + config.validate_execution().map_err(std::io::Error::other)?; + if !config.execution_enabled() { + return Ok(None); + } + let settings = config + .workspace_settings() + .expect("execution requires trusted settings"); + if !client.is_available() || client.workspace_id() != Some(settings.workspace_id.as_str()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv requires available Backend authority matching trusted Workspace settings", + )); + } + Ok(Some(Arc::new(Self { + client, + workspace_id: settings.workspace_id, + settings: SubjektivHostSettings { + language: settings.language, + }, + consolidation_granted: config.profile.consolidation_tools, + }))) + } + + fn execute( + &self, + operation: WorkspaceServerOperation, + ) -> Result { + let response = self + .client + .execute_server_operation(operation) + .map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?; + decode(response) + } +} + +fn decode( + response: crate::WorkspaceResponse, +) -> Result { + if !response.is_success() { + let parsed = serde_json::from_str::(&response.body).ok(); + let message = parsed + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or(response.body); + if let Some(code) = parsed.as_ref().and_then(|error| { + error + .diagnostics + .iter() + .find(|diagnostic| { + matches!( + diagnostic.code.as_str(), + "revision_conflict" + | "stale_cursor" + | "candidate_decision_conflict" + | "subject_scope_mismatch" + ) + }) + .map(|diagnostic| diagnostic.code.clone()) + }) { + return Err(SubjektivHostError::Conflict { code, message }); + } + return Err(if response.status == 409 { + SubjektivHostError::Conflict { + code: "conflict".into(), + message, + } + } else if matches!(response.status, 400 | 404 | 422) { + SubjektivHostError::InvalidArgument(message) + } else { + SubjektivHostError::Unavailable(message) + }); + } + serde_json::from_str(&response.body).map_err(|error| { + SubjektivHostError::Unavailable(format!("decode subjektiv response: {error}")) + }) +} + +impl SubjektivHost for BackendSubjektivHost { + fn settings(&self) -> SubjektivHostSettings { + SubjektivHostSettings { + language: self.settings.language.clone(), + } + } + fn workspace_id(&self) -> Option { + Some(self.workspace_id.clone()) + } + fn consolidation_granted(&self) -> bool { + self.consolidation_granted + } + fn memory( + &self, + _context: &SubjektivHostContext, + operation: SubjektivMemoryBackendOperation, + ) -> Result { + // Preserve the bounded resident-context request timeout. + if matches!( + operation, + SubjektivMemoryBackendOperation::ResidentContext(_) + ) { + let request = WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("/api/w/{}/subjektiv/memory", self.workspace_id), + serde_json::to_string(&SubjektivMemoryBackendRequest { operation }) + .map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?, + ); + return decode( + self.client + .execute_with_timeout(request, Duration::from_secs(5)) + .map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?, + ); + } + self.execute(WorkspaceServerOperation::SubjektivMemory( + SubjektivMemoryBackendRequest { operation }, + )) + } + fn session( + &self, + _context: &SubjektivHostContext, + operation: SubjektivSessionBackendOperation, + ) -> Result { + let response = self + .client + .execute_server_operation(WorkspaceServerOperation::SubjektivSession( + SubjektivSessionBackendRequest { operation }, + )) + .map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?; + // Session domain diagnostics are typed even on non-2xx responses. + if let Ok(value) = serde_json::from_str(&response.body) { + return Ok(value); + } + decode(response) + } + fn stage_candidate( + &self, + context: &SubjektivHostContext, + operation: MemoryStageCandidateOperation, + ) -> Result { + self.execute(WorkspaceServerOperation::SubjektivStageCandidate( + SubjektivStageCandidateRequest { + session_id: context.session_id.clone(), + operation, + }, + )) + } + fn record_session( + &self, + context: &SubjektivHostContext, + create_if_missing: bool, + ) -> Result { + self.execute(WorkspaceServerOperation::SubjektivRecordSession( + SubjektivRecordSessionRequest { + session_id: context.session_id.clone(), + create_if_missing, + }, + )) + } + fn request_consolidation( + &self, + _context: &SubjektivHostContext, + operation: MemoryConsolidateStagingOperation, + ) -> Result { + let response = self + .client + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + format!("/api/w/{}/subjektiv/consolidation", self.workspace_id), + serde_json::to_string(&MemoryConsolidateStagingRequest { + force: operation.force, + }) + .map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?, + )) + .map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?; + let response: MemoryConsolidationResponse = decode(response)?; + Ok(MemoryConsolidationOutput { + status: response.status, + summary: response.summary, + candidate_count: response.candidate_count, + total_bytes: response.total_bytes, + }) + } +} + +/// Live connection with Worker-supplied authority. Not serializable and never +/// inherited by an ordinary SubWorker. +#[derive(Clone)] +pub struct SubjektivHostConnection { + pub host: Arc, + pub context: SubjektivHostContext, +} + +impl SubjektivHostConnection { + pub fn new( + host: Arc, + context: SubjektivHostContext, + ) -> std::io::Result { + let language = host.settings().language; + if context.worker_id.trim().is_empty() + || context.session_id.trim().is_empty() + || context.worker_id.chars().any(char::is_control) + || context.session_id.chars().any(char::is_control) + || !manifest::is_normalized_workspace_memory_language(&language) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid subjektiv Host context/settings", + )); + } + Ok(Self { host, context }) + } + pub fn memory( + &self, + operation: SubjektivMemoryBackendOperation, + ) -> Result { + if let SubjektivMemoryBackendOperation::StageExplicit(input) = &operation + && input.session_id != self.context.session_id + { + return Err(SubjektivHostError::InvalidArgument( + "explicit staging targets a foreign Session".into(), + )); + } + if matches!( + operation, + SubjektivMemoryBackendOperation::DecideCandidate(_) + | SubjektivMemoryBackendOperation::PrepareSurface(_) + | SubjektivMemoryBackendOperation::PublishSurface(_) + | SubjektivMemoryBackendOperation::FailSurface(_) + ) && !self.host.consolidation_granted() + { + return Err(SubjektivHostError::InvalidArgument( + "Host did not grant consolidation".into(), + )); + } + self.host.memory(&self.context, operation) + } + pub fn session( + &self, + operation: SubjektivSessionBackendOperation, + ) -> Result { + self.host.session(&self.context, operation) + } + pub async fn prepare_subjektiv_memory_surface( + &self, + ) -> Result { + match self.memory(SubjektivMemoryBackendOperation::PrepareSurface( + SubjektivSurfacePrepareRequest {}, + ))? { + SubjektivMemoryBackendResponse::SurfacePrepared(value) => Ok(value), + _ => Err(SubjektivHostError::Unavailable( + "unexpected surface preparation response".into(), + )), + } + } + pub async fn publish_subjektiv_memory_surface( + &self, + input: SubjektivSurfacePublishRequest, + ) -> Result { + match self.memory(SubjektivMemoryBackendOperation::PublishSurface(input))? { + SubjektivMemoryBackendResponse::SurfacePublished(value) => Ok(value), + _ => Err(SubjektivHostError::Unavailable( + "unexpected surface publication response".into(), + )), + } + } + pub async fn fail_subjektiv_memory_surface( + &self, + input: SubjektivSurfaceFailureRequest, + ) -> Result { + match self.memory(SubjektivMemoryBackendOperation::FailSurface(input))? { + SubjektivMemoryBackendResponse::SurfaceFailed(value) => Ok(value), + _ => Err(SubjektivHostError::Unavailable( + "unexpected surface failure response".into(), + )), + } + } +} + +#[cfg(test)] +pub(crate) fn test_connection(client: Arc) -> SubjektivHostConnection { + let mut config = manifest::ResolvedSubjektivFeatureConfig::default(); + config.profile.enabled = true; + config.profile.consolidation_tools = true; + config + .bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot { + workspace_id: client.workspace_id().unwrap().to_string(), + settings_revision: 1, + language: "English".into(), + }) + .unwrap(); + SubjektivHostConnection::new( + BackendSubjektivHost::from_resolved_config(&config, client) + .unwrap() + .unwrap(), + SubjektivHostContext { + worker_id: "worker-test".into(), + session_id: "session-1".into(), + }, + ) + .unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Method, WorkerBootstrap, WorkerBootstrapLayout, WorkerControllerTransport, + WorkerFilesystemAuthority, WorkerManifest, WorkerWorkspaceContext, + }; + use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; + use agen::llm_client::{ClientError, LlmClient, Request}; + use async_trait::async_trait; + use futures_util::Stream; + use session_store::{CombinedStore, FsStore, FsWorkerStore, WorkerMetadataStore}; + use std::pin::Pin; + use std::sync::Mutex; + + #[derive(Default)] + struct LocalHost { + contexts: Mutex>, + attributed: Mutex>, + } + impl SubjektivHost for LocalHost { + fn settings(&self) -> SubjektivHostSettings { + SubjektivHostSettings { + language: "English".into(), + } + } + fn memory( + &self, + context: &SubjektivHostContext, + operation: SubjektivMemoryBackendOperation, + ) -> Result { + self.contexts.lock().unwrap().push(context.clone()); + match operation { + SubjektivMemoryBackendOperation::ResidentContext(_) => { + Ok(SubjektivMemoryBackendResponse::ResidentContext( + SubjektivResidentContextOutput { + behavior_md: "Explicit local subject behavior.".into(), + behavior_revision: 1, + memory_surface: memory::backend::MemoryResidentSummaryOutput { + availability: + memory::backend::MemoryResidentSummaryAvailability::Ungenerated, + content: None, + }, + }, + )) + } + _ => Err(SubjektivHostError::Unavailable( + "not granted by test Host".into(), + )), + } + } + fn session( + &self, + _: &SubjektivHostContext, + _: SubjektivSessionBackendOperation, + ) -> Result { + Err(SubjektivHostError::Unavailable("not used".into())) + } + fn stage_candidate( + &self, + _: &SubjektivHostContext, + _: MemoryStageCandidateOperation, + ) -> Result { + Err(SubjektivHostError::Unavailable("not used".into())) + } + fn record_session( + &self, + context: &SubjektivHostContext, + create_if_missing: bool, + ) -> Result { + self.attributed + .lock() + .unwrap() + .push((context.clone(), create_if_missing)); + Ok(SubjektivRecordSessionResponse { + subject_id: "test-owned-local-subject".into(), + session_id: context.session_id.clone(), + }) + } + fn request_consolidation( + &self, + _: &SubjektivHostContext, + _: MemoryConsolidateStagingOperation, + ) -> Result { + Err(SubjektivHostError::Unavailable("no candidates".into())) + } + } + + #[derive(Clone)] + struct ScriptedClient(tokio::sync::mpsc::UnboundedSender); + #[async_trait] + impl LlmClient for ScriptedClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + self.0.send(request).unwrap(); + Ok(Box::pin(futures_util::stream::iter(vec![ + Ok(LlmEvent::text_block_start(0)), + Ok(LlmEvent::text_delta(0, "Done.")), + Ok(LlmEvent::text_block_stop(0, None)), + Ok(LlmEvent::Status(StatusEvent { + status: ResponseStatus::Completed, + })), + ]))) + } + } + + async fn launch_case(enabled: bool, attach: bool) { + let temp = tempfile::tempdir().unwrap(); + let _runtime_sandbox = + crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path()); + let name = format!("subjektiv-host-{}", uuid::Uuid::now_v7()); + let mut manifest = WorkerManifest::from_toml(&format!( + r#" +[worker] +name = "{name}" +[model] +scheme = "anthropic" +model_id = "scripted-model" +[engine] +[scope] +allow = [] +[feature.task] +enabled = true +"# + )) + .unwrap(); + manifest.feature.subjektiv.profile.enabled = enabled; + manifest.feature.subjektiv.profile.extraction.enabled = true; + manifest.feature.subjektiv.profile.extraction.threshold = Some(u64::MAX); + assert!(manifest.feature.subjektiv.workspace_settings.is_none()); + let store = CombinedStore::new( + FsStore::new(temp.path().join("sessions")).unwrap(), + FsWorkerStore::new(temp.path().join("workers")).unwrap(), + ); + let host = Arc::new(LocalHost::default()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let bootstrap = WorkerBootstrap::new( + manifest, + store.clone(), + crate::PromptCatalogSource::builtins_only(), + WorkerWorkspaceContext::local_filesystem(None), + WorkerFilesystemAuthority::None, + WorkerBootstrapLayout::Direct { + runtime_base: temp.path().join("runtime"), + bash_output_dir: temp.path().join("bash-output"), + }, + WorkerControllerTransport::InProcess, + ) + .with_model_client(ScriptedClient(tx)); + let bootstrap = if attach { + bootstrap.with_subjektiv_host(host.clone()) + } else { + bootstrap + }; + let prepared = bootstrap.prepare().await.unwrap(); + let expected = SubjektivHostContext { + worker_id: name.clone(), + session_id: prepared.worker().session_id().to_string(), + }; + assert!( + prepared + .worker() + .manifest() + .feature + .subjektiv + .workspace_settings + .is_none() + ); + assert!(prepared.worker().workspace_id().is_none()); + assert!( + prepared + .worker() + .workspace_client() + .workspace_id() + .is_none() + ); + let controller = prepared.start().await.unwrap(); + controller + .handle + .send(Method::submit_text( + protocol::new_submission_request_id(), + "Hello", + )) + .await + .unwrap(); + let request = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + let tools = request + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert!( + tools.contains(&"TaskCreate"), + "ordinary Features must survive" + ); + for name in [ + "SubjektivMemoryQuery", + "SubjektivMemoryRead", + "SubjektivMemoryRemember", + "SubjektivSessionList", + "SubjektivSessionRead", + ] { + assert_eq!(tools.contains(&name), enabled && attach, "{name}"); + } + assert!(!tools.contains(&"MemoryApplyCandidate")); + let rendered = format!( + "{}\n{}", + request.system_prompt.as_deref().unwrap_or_default(), + request + .items + .iter() + .filter_map(|item| item.as_text()) + .collect::>() + .join("\n") + ); + assert_eq!( + rendered.contains("Explicit local subject behavior."), + enabled && attach + ); + if enabled && attach { + assert_eq!( + *host.attributed.lock().unwrap(), + vec![(expected.clone(), true)] + ); + assert!( + host.contexts + .lock() + .unwrap() + .iter() + .all(|context| context == &expected) + ); + let metadata = store.read_by_name(&name).unwrap().unwrap(); + assert!(matches!( + metadata.subjektiv_session_attribution, + Some(session_store::SubjektivSessionAttributionState::Confirmed { .. }) + )); + } else { + assert!(host.contexts.lock().unwrap().is_empty()); + assert!(host.attributed.lock().unwrap().is_empty()); + } + controller + .handle + .send(Method::Shutdown { + command: protocol::WorkerCommandEnvelope::new(1), + }) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), controller.shutdown) + .await + .unwrap() + .unwrap(); + controller.controller_task.await.unwrap(); + } + + #[tokio::test] + async fn explicit_local_host_installs_ordinary_features_without_workspace_snapshot() { + launch_case(true, true).await; + } + #[tokio::test] + async fn enabled_policy_without_host_is_inert() { + launch_case(true, false).await; + } + #[tokio::test] + async fn disabled_policy_with_host_has_no_subjektiv_side_effects() { + launch_case(false, true).await; + } + #[test] + fn consolidation_installation_requires_separate_explicit_host_grant() { + let connection = SubjektivHostConnection::new( + Arc::new(LocalHost::default()), + SubjektivHostContext { + worker_id: "test-worker".into(), + session_id: "test-session".into(), + }, + ) + .unwrap(); + assert!(SubjektivConsolidationToolsFeature::new(connection).is_err()); + } +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index e3dbde07d..a4bae5f46 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -357,6 +357,9 @@ pub(crate) fn authenticated_input_provenance( ) -> WorkerHistoryProvenance { match source { protocol::AuthenticatedInputSource::UntrustedWire => WorkerHistoryProvenance::LegacyUnknown, + protocol::AuthenticatedInputSource::LocalOperator => { + WorkerHistoryProvenance::LocalHumanInput + } protocol::AuthenticatedInputSource::Account { account_id } => { WorkerHistoryProvenance::HumanInput { account_id: account_id.clone(), @@ -2752,6 +2755,7 @@ pub struct Worker { inject_resident_summary: bool, /// Deferred resident prompt source installed by an enabled Feature. feature_resident_summary_source: Option>, + subjektiv_host: Option>, /// One-shot generic Feature lifecycle notification required after restore. restore_feature_lifecycle_pending: bool, /// Complete system prompt replacement installed by an enabled Feature. @@ -3014,6 +3018,7 @@ impl Worker { prompts, inject_resident_summary: true, feature_resident_summary_source: None, + subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), @@ -3233,6 +3238,49 @@ impl Worker { self.workspace_context.client_handle() } + /// Bind a live capability before Feature installation. This is deliberately + /// separate from Workspace authority and is not inherited by SubWorkers. + pub fn bind_subjektiv_host(&mut self, host: Option>) { + self.subjektiv_host = host; + } + + pub fn subjektiv_host_connection( + &self, + ) -> std::io::Result> { + use crate::subjektiv::{ + BackendSubjektivHost, SubjektivHostConnection, SubjektivHostContext, + }; + let config = &self.manifest.feature.subjektiv; + config.validate_execution().map_err(std::io::Error::other)?; + if !config.profile.enabled { + return Ok(None); + } + let host = if let Some(host) = &self.subjektiv_host { + if config.workspace_settings.is_some() + || self.workspace_id().is_some() + || self.workspace_client().workspace_id().is_some() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "explicit local subjektiv Host cannot replace trusted Backend Workspace authority", + )); + } + Some(host.clone()) + } else { + BackendSubjektivHost::from_resolved_config(config, self.workspace_client_handle())? + }; + host.map(|host| { + SubjektivHostConnection::new( + host, + SubjektivHostContext { + worker_id: self.manifest.worker.name.clone(), + session_id: self.session_id().to_string(), + }, + ) + }) + .transpose() + } + /// Bind the host-owned Worker-session observation projection. The provider /// is responsible for workspace authorization and per-capture revalidation. pub fn bind_worker_observation_provider( @@ -7324,6 +7372,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), @@ -7433,64 +7482,62 @@ where create_if_missing, initial_diagnostic.to_string(), ))?; - let response = match self.workspace_client().execute_server_operation( - WorkspaceServerOperation::SubjektivRecordSession( - server_api::SubjektivRecordSessionRequest { - session_id: session_id.to_string(), - create_if_missing, - }, - ), - ) { - Ok(response) => response, - Err(error) => { - let message = bounded_attribution_diagnostic(format!( - "Session attribution transport outcome is unknown: {error}" - )); - self.persist_subjektiv_session_attribution( - incomplete_subjektiv_attribution_state( - session_id, - create_if_missing, - message.clone(), - ), - )?; - return Err(WorkerError::SubjektivSessionAttribution { - state: incomplete_subjektiv_attribution_label(create_if_missing), - message, - }); - } - }; - if !response.is_success() { - let message = bounded_attribution_diagnostic(format!( - "Session attribution was rejected with HTTP {}: {}", - response.status, response.body - )); - let state = if create_if_missing { - SubjektivSessionAttributionState::Failed { - session_id, - diagnostic: message.clone(), - } - } else { - SubjektivSessionAttributionState::LegacyUnknown { - session_id, - diagnostic: message.clone(), - } - }; - self.persist_subjektiv_session_attribution(state)?; - return Err(WorkerError::SubjektivSessionAttribution { - state: if create_if_missing { - "failed" - } else { - "legacy_unknown" - }, - message, - }); - } - let output: server_api::SubjektivRecordSessionResponse = - match serde_json::from_str(&response.body) { + let output = if self.subjektiv_host.is_some() { + let connection = self + .subjektiv_host_connection() + .map_err(|error| WorkerError::SubjektivSessionAttribution { + state: "failed", + message: error.to_string(), + })? + .ok_or_else(|| WorkerError::SubjektivSessionAttribution { + state: "failed", + message: "Session attribution requires enabled explicit Host capability".into(), + })?; + match connection + .host + .record_session(&connection.context, create_if_missing) + { Ok(output) => output, + Err(error) => { + let message = bounded_attribution_diagnostic(error.to_string()); + let rejected = + !matches!(error, crate::subjektiv::SubjektivHostError::Unavailable(_)); + let state = if create_if_missing && rejected { + SubjektivSessionAttributionState::Failed { + session_id, + diagnostic: message.clone(), + } + } else { + incomplete_subjektiv_attribution_state( + session_id, + create_if_missing, + message.clone(), + ) + }; + self.persist_subjektiv_session_attribution(state)?; + return Err(WorkerError::SubjektivSessionAttribution { + state: if create_if_missing && rejected { + "failed" + } else { + incomplete_subjektiv_attribution_label(create_if_missing) + }, + message, + }); + } + } + } else { + let response = match self.workspace_client().execute_server_operation( + WorkspaceServerOperation::SubjektivRecordSession( + server_api::SubjektivRecordSessionRequest { + session_id: session_id.to_string(), + create_if_missing, + }, + ), + ) { + Ok(response) => response, Err(error) => { let message = bounded_attribution_diagnostic(format!( - "Session attribution response outcome is unknown: {error}" + "Session attribution transport outcome is unknown: {error}" )); self.persist_subjektiv_session_attribution( incomplete_subjektiv_attribution_state( @@ -7505,6 +7552,54 @@ where }); } }; + if !response.is_success() { + let message = bounded_attribution_diagnostic(format!( + "Session attribution was rejected with HTTP {}: {}", + response.status, response.body + )); + let state = if create_if_missing { + SubjektivSessionAttributionState::Failed { + session_id, + diagnostic: message.clone(), + } + } else { + SubjektivSessionAttributionState::LegacyUnknown { + session_id, + diagnostic: message.clone(), + } + }; + self.persist_subjektiv_session_attribution(state)?; + return Err(WorkerError::SubjektivSessionAttribution { + state: if create_if_missing { + "failed" + } else { + "legacy_unknown" + }, + message, + }); + } + let output: server_api::SubjektivRecordSessionResponse = + match serde_json::from_str(&response.body) { + Ok(output) => output, + Err(error) => { + let message = bounded_attribution_diagnostic(format!( + "Session attribution response outcome is unknown: {error}" + )); + self.persist_subjektiv_session_attribution( + incomplete_subjektiv_attribution_state( + session_id, + create_if_missing, + message.clone(), + ), + )?; + return Err(WorkerError::SubjektivSessionAttribution { + state: incomplete_subjektiv_attribution_label(create_if_missing), + message, + }); + } + }; + output + }; if output.session_id != session_id.to_string() || output.subject_id.trim().is_empty() || output.subject_id.chars().any(char::is_control) @@ -7616,6 +7711,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), @@ -7744,6 +7840,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + subjektiv_host: None, restore_feature_lifecycle_pending: false, feature_system_prompt_override: None, user_segments: Vec::new(), @@ -8130,6 +8227,7 @@ where prompts: common.prompts, inject_resident_summary: true, feature_resident_summary_source: None, + subjektiv_host: None, restore_feature_lifecycle_pending: true, feature_system_prompt_override: None, user_segments: state.user_segments, @@ -9852,7 +9950,7 @@ mod build_summary_prompt_tests { }); let source = crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( manifest, - client, + Some(crate::subjektiv::test_connection(client)), Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), ) .unwrap() @@ -14731,12 +14829,14 @@ permission = "write" let resident_source = || { crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source( &manifest, - Arc::new(RestoreSubjectResidentClient { - availability: memory::backend::MemoryResidentSummaryAvailability::Ready, - content: Some("literal /prepare()".into()), - load_count: Arc::default(), - behavior_revision: Some(behavior_revision.clone()), - }), + Some(crate::subjektiv::test_connection(Arc::new( + RestoreSubjectResidentClient { + availability: memory::backend::MemoryResidentSummaryAvailability::Ready, + content: Some("literal /prepare()".into()), + load_count: Arc::default(), + behavior_revision: Some(behavior_revision.clone()), + }, + ))), Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap())), ) .unwrap() diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index d89f9e161..25e7fc27b 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -16,6 +16,8 @@ axum = { workspace = true, features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } futures.workspace = true config-source.workspace = true +feature-storage.workspace = true +subjektiv.workspace = true flow = { path = "../flow" } manifest.workspace = true job.workspace = true diff --git a/crates/workspace-server/src/feature_storage.rs b/crates/workspace-server/src/feature_storage.rs index d523233fd..b4386fd07 100644 --- a/crates/workspace-server/src/feature_storage.rs +++ b/crates/workspace-server/src/feature_storage.rs @@ -1,1385 +1,2 @@ -//! Server-owned SQLite storage for trusted Workspace Features. -//! -//! The server owns database placement, connection configuration, migrations, and -//! operational lifecycle. A Feature owns its schema and only receives a scoped -//! [`FeatureDatabase`] after trusted registration; Worker/model inputs never -//! select a path or receive a SQL surface. - -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, Weak}; -use std::time::Duration; - -use rusqlite::backup::Backup; -use rusqlite::{Connection, ErrorCode, OpenFlags, Transaction, TransactionBehavior, params}; -use uuid::Uuid; - -const BUSY_TIMEOUT: Duration = Duration::from_secs(5); -const MIGRATION_TABLE: &str = "__yoi_feature_schema_migrations"; -const SNAPSHOT_FORMAT: &str = "yoi-feature-storage-v1"; -const SNAPSHOT_MANIFEST: &str = "manifest.txt"; -const MAX_IDENTIFIER_BYTES: usize = 128; - -pub type Result = std::result::Result; -pub type FeatureMigrationApply = for<'connection> fn(&Transaction<'connection>) -> Result<()>; - -#[derive(Debug, thiserror::Error)] -pub enum FeatureStorageError { - #[error("invalid {kind} storage identifier `{value}`")] - InvalidIdentifier { kind: &'static str, value: String }, - #[error("Feature `{0}` is not registered with this Server storage manager")] - FeatureNotRegistered(String), - #[error("Feature `{0}` is already registered with this Server storage manager")] - FeatureAlreadyRegistered(String), - #[error("invalid migration plan for Feature `{feature}`: {detail}")] - InvalidMigrationPlan { feature: String, detail: String }, - #[error("Feature registration belongs to a different Server storage manager")] - ForeignRegistration, - #[error("Feature storage is shutting down")] - ShuttingDown, - #[error("Feature storage for Workspace `{0}` is undergoing maintenance")] - WorkspaceMaintenance(String), - #[error("Feature database is closed")] - DatabaseClosed, - #[error( - "Feature `{feature}` database schema version {actual} is newer than supported version {supported}" - )] - UnsupportedSchema { - feature: String, - actual: u32, - supported: u32, - }, - #[error("Feature `{feature}` migration history is incompatible: {detail}")] - MigrationHistory { feature: String, detail: String }, - #[error("Feature `{feature}` migration {version} (`{name}`) failed: {detail}")] - MigrationFailed { - feature: String, - version: u32, - name: String, - detail: String, - }, - #[error("Feature storage snapshot is invalid: {0}")] - InvalidSnapshot(String), - #[error("Feature storage destination already exists: {0}")] - DestinationExists(String), - #[error("Feature storage operation failed: {0}")] - Operation(String), - #[error("feature storage I/O error: {0}")] - Io(#[from] std::io::Error), - #[error("feature storage SQLite error: {0}")] - Sqlite(#[from] rusqlite::Error), -} - -impl FeatureStorageError { - /// Lets a Feature reject a domain operation while retaining transaction rollback. - pub fn operation(message: impl Into) -> Self { - Self::Operation(message.into()) - } -} - -#[derive(Clone, Copy)] -pub struct FeatureMigration { - version: u32, - name: &'static str, - apply: FeatureMigrationApply, -} - -impl FeatureMigration { - pub const fn new(version: u32, name: &'static str, apply: FeatureMigrationApply) -> Self { - Self { - version, - name, - apply, - } - } - - pub fn version(&self) -> u32 { - self.version - } - - pub fn name(&self) -> &'static str { - self.name - } -} - -#[derive(Clone, Copy)] -pub struct FeatureRegistration { - feature_id: &'static str, - migrations: &'static [FeatureMigration], -} - -impl FeatureRegistration { - pub const fn new(feature_id: &'static str, migrations: &'static [FeatureMigration]) -> Self { - Self { - feature_id, - migrations, - } - } - - pub fn feature_id(&self) -> &'static str { - self.feature_id - } -} - -#[derive(Clone, Debug)] -pub struct RegisteredFeature { - manager_id: Uuid, - feature_id: String, -} - -impl RegisteredFeature { - pub fn feature_id(&self) -> &str { - &self.feature_id - } -} - -#[derive(Clone)] -pub struct FeatureStorage { - inner: Arc, -} - -struct FeatureStorageInner { - id: Uuid, - root: PathBuf, - state: Mutex, -} - -struct FeatureStorageState { - running: bool, - registrations: HashMap, - connections: HashMap<(String, String), Weak>, - maintenance: HashSet, - deletion_fences: HashSet, -} - -impl FeatureStorage { - /// Creates a lazy manager. No directories or databases are created until a - /// registered Feature is opened, restored, or explicitly backed up. - pub fn new(root: impl Into) -> Self { - Self { - inner: Arc::new(FeatureStorageInner { - id: Uuid::now_v7(), - root: root.into(), - state: Mutex::new(FeatureStorageState { - running: true, - registrations: HashMap::new(), - connections: HashMap::new(), - maintenance: HashSet::new(), - deletion_fences: HashSet::new(), - }), - }), - } - } - - pub fn for_server_database(database_path: impl AsRef) -> Result { - let database_path = database_path.as_ref(); - let parent = database_path.parent().ok_or_else(|| { - FeatureStorageError::Operation(format!( - "Server database path `{}` has no parent", - database_path.display() - )) - })?; - Ok(Self::new(parent.join("feature-storage"))) - } - - pub fn workspace(&self, workspace_id: &str) -> Result { - Ok(WorkspaceFeatureStorage { - manager: self.clone(), - workspace_id: validate_identifier("Workspace", workspace_id)?.to_string(), - }) - } - - pub fn register(&self, registration: FeatureRegistration) -> Result { - let feature_id = validate_identifier("Feature", registration.feature_id)?.to_string(); - validate_migrations(&feature_id, registration.migrations)?; - let mut state = self.lock_state()?; - ensure_running(&state)?; - if state.registrations.contains_key(&feature_id) { - return Err(FeatureStorageError::FeatureAlreadyRegistered(feature_id)); - } - state - .registrations - .insert(feature_id.clone(), registration.migrations); - Ok(RegisteredFeature { - manager_id: self.inner.id, - feature_id, - }) - } - - /// Closes every managed connection after any operation currently holding a - /// connection lock completes. New opens and operations then fail. - pub fn shutdown(&self) -> Result<()> { - let connections = { - let mut state = self.lock_state()?; - if !state.running { - return Ok(()); - } - state.running = false; - let connections = state - .connections - .values() - .filter_map(Weak::upgrade) - .collect::>(); - for connection in &connections { - connection.fence(); - } - state.connections.clear(); - connections - }; - for connection in connections { - connection.close()?; - } - Ok(()) - } - - fn open( - &self, - workspace_id: &str, - registration: &RegisteredFeature, - ) -> Result { - if registration.manager_id != self.inner.id { - return Err(FeatureStorageError::ForeignRegistration); - } - let key = (workspace_id.to_string(), registration.feature_id.clone()); - let mut state = self.lock_state()?; - ensure_running(&state)?; - if state.maintenance.contains(workspace_id) || state.deletion_fences.contains(workspace_id) - { - return Err(FeatureStorageError::WorkspaceMaintenance( - workspace_id.to_string(), - )); - } - let migrations = state - .registrations - .get(®istration.feature_id) - .copied() - .ok_or_else(|| { - FeatureStorageError::FeatureNotRegistered(registration.feature_id.clone()) - })?; - if let Some(connection) = state.connections.get(&key).and_then(Weak::upgrade) { - return Ok(FeatureDatabase { inner: connection }); - } - - let feature_dir = self.feature_dir(workspace_id); - fs::create_dir_all(&feature_dir)?; - let path = feature_dir.join(format!("{}.sqlite", registration.feature_id)); - let connection = Connection::open_with_flags( - path, - OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, - )?; - configure_connection(&connection)?; - migrate(&connection, ®istration.feature_id, migrations)?; - let connection = Arc::new(FeatureDatabaseInner { - workspace_id: workspace_id.to_string(), - feature_id: registration.feature_id.clone(), - accepting_operations: AtomicBool::new(true), - connection: Mutex::new(Some(connection)), - }); - state.connections.insert(key, Arc::downgrade(&connection)); - Ok(FeatureDatabase { inner: connection }) - } - - fn backup_workspace(&self, workspace_id: &str, destination: &Path) -> Result<()> { - // Keep manager lifecycle and deletion/restore mutually exclusive for the - // duration. Feature transactions use SQLite's online backup snapshot rules. - let state = self.lock_state()?; - ensure_running(&state)?; - if state.maintenance.contains(workspace_id) || state.deletion_fences.contains(workspace_id) - { - return Err(FeatureStorageError::WorkspaceMaintenance( - workspace_id.to_string(), - )); - } - if destination.exists() { - return Err(FeatureStorageError::DestinationExists( - destination.display().to_string(), - )); - } - let feature_ids = list_feature_databases(&self.feature_dir(workspace_id))?; - let parent = destination.parent().ok_or_else(|| { - FeatureStorageError::Operation(format!( - "snapshot destination `{}` has no parent", - destination.display() - )) - })?; - fs::create_dir_all(parent)?; - let temporary = temporary_sibling(destination, "backup")?; - let result = (|| { - let temporary_features = temporary.join("features"); - fs::create_dir_all(&temporary_features)?; - for feature_id in &feature_ids { - backup_database( - &self - .feature_dir(workspace_id) - .join(format!("{feature_id}.sqlite")), - &temporary_features.join(format!("{feature_id}.sqlite")), - )?; - } - fs::write( - temporary.join(SNAPSHOT_MANIFEST), - snapshot_manifest(workspace_id, &feature_ids), - )?; - fs::rename(&temporary, destination)?; - Ok(()) - })(); - drop(state); - if result.is_err() { - let _ = fs::remove_dir_all(&temporary); - } - result - } - - fn restore_workspace(&self, workspace_id: &str, snapshot: &Path) -> Result<()> { - let feature_ids = read_snapshot_manifest(snapshot, workspace_id)?; - self.begin_maintenance(workspace_id, true)?; - let workspace_dir = self.workspace_dir(workspace_id); - let temporary = self - .inner - .root - .join(format!(".restore-{workspace_id}-{}", Uuid::now_v7())); - let result = (|| { - if workspace_dir.exists() { - return Err(FeatureStorageError::DestinationExists( - workspace_dir.display().to_string(), - )); - } - let temporary_features = temporary.join("features"); - fs::create_dir_all(&temporary_features)?; - for feature_id in &feature_ids { - backup_database( - &snapshot - .join("features") - .join(format!("{feature_id}.sqlite")), - &temporary_features.join(format!("{feature_id}.sqlite")), - )?; - } - fs::create_dir_all(&self.inner.root)?; - fs::rename(&temporary, &workspace_dir)?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_dir_all(&temporary); - } - self.end_maintenance(workspace_id)?; - result - } - - fn delete_workspace(&self, workspace_id: &str) -> Result<()> { - let connections = { - let mut state = self.lock_state()?; - ensure_running(&state)?; - if state.maintenance.contains(workspace_id) { - return Err(FeatureStorageError::WorkspaceMaintenance( - workspace_id.to_string(), - )); - } - // The fence intentionally survives physical removal. Workspace deletion - // is finalized in the Server DB immediately afterwards; keeping this - // admission fence prevents a cached API/background task from recreating - // Feature files in that gap. A retry is idempotently admitted. - state.deletion_fences.insert(workspace_id.to_string()); - let connections = state - .connections - .iter() - .filter(|((candidate, _), _)| candidate == workspace_id) - .filter_map(|(_, connection)| connection.upgrade()) - .collect::>(); - for connection in &connections { - connection.fence(); - } - state - .connections - .retain(|(candidate, _), _| candidate != workspace_id); - connections - }; - for connection in connections { - connection.close()?; - } - match fs::remove_dir_all(self.workspace_dir(workspace_id)) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } - } - - fn begin_maintenance( - &self, - workspace_id: &str, - require_no_connections: bool, - ) -> Result>> { - let mut state = self.lock_state()?; - ensure_running(&state)?; - if state.deletion_fences.contains(workspace_id) - || !state.maintenance.insert(workspace_id.to_string()) - { - return Err(FeatureStorageError::WorkspaceMaintenance( - workspace_id.to_string(), - )); - } - let connections = state - .connections - .iter() - .filter(|((candidate, _), _)| candidate == workspace_id) - .filter_map(|(_, connection)| connection.upgrade()) - .collect::>(); - if require_no_connections && !connections.is_empty() { - state.maintenance.remove(workspace_id); - return Err(FeatureStorageError::Operation(format!( - "cannot restore Workspace `{workspace_id}` while Feature databases are open" - ))); - } - state - .connections - .retain(|(candidate, _), _| candidate != workspace_id); - Ok(connections) - } - - fn end_maintenance(&self, workspace_id: &str) -> Result<()> { - let mut state = self.lock_state()?; - state.maintenance.remove(workspace_id); - Ok(()) - } - - fn workspace_dir(&self, workspace_id: &str) -> PathBuf { - self.inner.root.join(workspace_id) - } - - fn feature_dir(&self, workspace_id: &str) -> PathBuf { - self.workspace_dir(workspace_id).join("features") - } - - fn lock_state(&self) -> Result> { - self.inner.state.lock().map_err(|_| { - FeatureStorageError::Operation("Feature storage state lock was poisoned".to_string()) - }) - } -} - -impl Drop for FeatureStorageInner { - fn drop(&mut self) { - if let Ok(mut state) = self.state.lock() { - state.running = false; - for connection in state.connections.values().filter_map(Weak::upgrade) { - let _ = connection.close(); - } - state.connections.clear(); - } - } -} - -#[derive(Clone)] -pub struct WorkspaceFeatureStorage { - manager: FeatureStorage, - workspace_id: String, -} - -impl WorkspaceFeatureStorage { - pub fn workspace_id(&self) -> &str { - &self.workspace_id - } - - pub fn register(&self, registration: FeatureRegistration) -> Result { - self.manager.register(registration) - } - - pub fn open(&self, registration: &RegisteredFeature) -> Result { - self.manager.open(&self.workspace_id, registration) - } - - /// Creates a consistent online SQLite snapshot of every Feature database, - /// including data for Features that are currently disabled/unregistered. - pub fn backup(&self, destination: impl AsRef) -> Result<()> { - self.manager - .backup_workspace(&self.workspace_id, destination.as_ref()) - } - - /// Restores a snapshot only into an absent Workspace storage directory and - /// only while no Feature database for this Workspace is open. - pub fn restore(&self, snapshot: impl AsRef) -> Result<()> { - self.manager - .restore_workspace(&self.workspace_id, snapshot.as_ref()) - } - - /// Closes this Workspace's Feature connections and removes only its scoped - /// storage tree. This is the operation used by Workspace deletion. - pub fn delete(&self) -> Result<()> { - self.manager.delete_workspace(&self.workspace_id) - } - - pub fn shutdown(&self) -> Result<()> { - self.manager.shutdown() - } -} - -#[derive(Clone)] -pub struct FeatureDatabase { - inner: Arc, -} - -struct FeatureDatabaseInner { - workspace_id: String, - feature_id: String, - accepting_operations: AtomicBool, - connection: Mutex>, -} - -impl FeatureDatabase { - pub fn workspace_id(&self) -> &str { - &self.inner.workspace_id - } - - pub fn feature_id(&self) -> &str { - &self.inner.feature_id - } - - /// Executes a Feature-owned read/query while serializing use of the managed - /// connection. The connection never leaves trusted Server Rust code. - pub fn with_connection( - &self, - operation: impl FnOnce(&Connection) -> Result, - ) -> Result { - self.try_with_connection(operation) - } - - /// Variant of [`Self::with_connection`] that lets a typed Feature repository - /// retain its own domain errors without encoding them as storage strings. - pub fn try_with_connection( - &self, - operation: impl FnOnce(&Connection) -> std::result::Result, - ) -> std::result::Result - where - E: From, - { - self.inner.ensure_accepting().map_err(E::from)?; - let connection = self - .inner - .connection - .lock() - .map_err(|_| { - FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) - }) - .map_err(E::from)?; - self.inner.ensure_accepting().map_err(E::from)?; - let connection = connection - .as_ref() - .ok_or(FeatureStorageError::DatabaseClosed) - .map_err(E::from)?; - operation(connection) - } - - /// Executes a Feature-owned multi-table operation atomically. Returning an - /// error rolls the transaction back; successful return commits it. - pub fn transaction( - &self, - operation: impl FnOnce(&Transaction<'_>) -> Result, - ) -> Result { - self.try_transaction(operation) - } - - /// Variant of [`Self::transaction`] that preserves a Feature repository's - /// typed domain errors while still rolling every failure back atomically. - pub fn try_transaction( - &self, - operation: impl FnOnce(&Transaction<'_>) -> std::result::Result, - ) -> std::result::Result - where - E: From, - { - self.inner.ensure_accepting().map_err(E::from)?; - let mut connection = self - .inner - .connection - .lock() - .map_err(|_| { - FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) - }) - .map_err(E::from)?; - self.inner.ensure_accepting().map_err(E::from)?; - let connection = connection - .as_mut() - .ok_or(FeatureStorageError::DatabaseClosed) - .map_err(E::from)?; - let transaction = connection - .transaction_with_behavior(TransactionBehavior::Immediate) - .map_err(FeatureStorageError::from) - .map_err(E::from)?; - let value = operation(&transaction)?; - transaction - .commit() - .map_err(FeatureStorageError::from) - .map_err(E::from)?; - Ok(value) - } - - pub fn schema_version(&self) -> Result { - self.with_connection(|connection| { - connection - .query_row( - &format!("SELECT COALESCE(MAX(version), 0) FROM {MIGRATION_TABLE}"), - [], - |row| row.get(0), - ) - .map_err(Into::into) - }) - } -} - -impl FeatureDatabaseInner { - fn ensure_accepting(&self) -> Result<()> { - if self.accepting_operations.load(Ordering::Acquire) { - Ok(()) - } else { - Err(FeatureStorageError::DatabaseClosed) - } - } - - fn fence(&self) { - self.accepting_operations.store(false, Ordering::Release); - } - - fn close(&self) -> Result<()> { - self.fence(); - let mut connection = self.connection.lock().map_err(|_| { - FeatureStorageError::Operation("Feature database lock was poisoned".to_string()) - })?; - connection.take(); - Ok(()) - } -} - -fn validate_identifier<'a>(kind: &'static str, value: &'a str) -> Result<&'a str> { - let valid = !value.is_empty() - && value.len() <= MAX_IDENTIFIER_BYTES - && value.bytes().enumerate().all(|(index, byte)| match byte { - b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => true, - b'-' | b'_' => index > 0, - _ => false, - }); - if !valid { - return Err(FeatureStorageError::InvalidIdentifier { - kind, - value: value.to_string(), - }); - } - Ok(value) -} - -fn validate_migrations(feature: &str, migrations: &[FeatureMigration]) -> Result<()> { - for (index, migration) in migrations.iter().enumerate() { - let expected = (index + 1) as u32; - if migration.version != expected { - return Err(FeatureStorageError::InvalidMigrationPlan { - feature: feature.to_string(), - detail: format!( - "versions must be contiguous from 1; expected {expected}, found {}", - migration.version - ), - }); - } - if migration.name.trim().is_empty() { - return Err(FeatureStorageError::InvalidMigrationPlan { - feature: feature.to_string(), - detail: format!("migration {expected} has an empty name"), - }); - } - } - Ok(()) -} - -fn ensure_running(state: &FeatureStorageState) -> Result<()> { - if state.running { - Ok(()) - } else { - Err(FeatureStorageError::ShuttingDown) - } -} - -fn configure_connection(connection: &Connection) -> Result<()> { - connection.busy_timeout(BUSY_TIMEOUT)?; - connection.execute_batch( - r#" -PRAGMA foreign_keys = ON; -PRAGMA synchronous = NORMAL; -PRAGMA busy_timeout = 5000; -"#, - )?; - // Changing journal mode takes a database-wide lock and, unlike ordinary - // statements, may report SQLITE_BUSY immediately while another opener is - // making the same transition. Bound retries by the same common timeout. - let attempts = (BUSY_TIMEOUT.as_millis() / 10) as usize; - for attempt in 0..=attempts { - match connection.query_row("PRAGMA journal_mode = WAL", [], |row| { - row.get::<_, String>(0) - }) { - Ok(mode) if mode.eq_ignore_ascii_case("wal") => return Ok(()), - Ok(mode) => { - return Err(FeatureStorageError::Operation(format!( - "SQLite refused WAL journal mode and selected `{mode}`" - ))); - } - Err(error) if sqlite_is_busy(&error) && attempt < attempts => { - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => return Err(error.into()), - } - } - unreachable!("bounded WAL retry loop always returns") -} - -fn sqlite_is_busy(error: &rusqlite::Error) -> bool { - matches!( - error, - rusqlite::Error::SqliteFailure(error, _) - if matches!(error.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked) - ) -} - -fn migrate( - connection: &Connection, - feature_id: &str, - migrations: &[FeatureMigration], -) -> Result<()> { - let transaction = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?; - transaction.execute_batch(&format!( - r#" -CREATE TABLE IF NOT EXISTS {MIGRATION_TABLE} ( - version INTEGER PRIMARY KEY CHECK (version > 0), - name TEXT NOT NULL, - applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP -); -"# - ))?; - let mut statement = transaction.prepare(&format!( - "SELECT version, name FROM {MIGRATION_TABLE} ORDER BY version" - ))?; - let applied = statement - .query_map([], |row| { - Ok((row.get::<_, u32>(0)?, row.get::<_, String>(1)?)) - })? - .collect::, _>>()?; - drop(statement); - - let supported = migrations.last().map_or(0, |migration| migration.version); - if let Some((actual, _)) = applied.last() - && *actual > supported - { - return Err(FeatureStorageError::UnsupportedSchema { - feature: feature_id.to_string(), - actual: *actual, - supported, - }); - } - for (index, (version, name)) in applied.iter().enumerate() { - let Some(expected) = migrations.get(index) else { - return Err(FeatureStorageError::UnsupportedSchema { - feature: feature_id.to_string(), - actual: *version, - supported, - }); - }; - if *version != expected.version || name != expected.name { - return Err(FeatureStorageError::MigrationHistory { - feature: feature_id.to_string(), - detail: format!( - "stored migration {version} (`{name}`) does not match expected {} (`{}`)", - expected.version, expected.name - ), - }); - } - } - - for migration in migrations.iter().skip(applied.len()) { - if let Err(error) = (migration.apply)(&transaction) { - return Err(FeatureStorageError::MigrationFailed { - feature: feature_id.to_string(), - version: migration.version, - name: migration.name.to_string(), - detail: error.to_string(), - }); - } - transaction.execute( - &format!("INSERT INTO {MIGRATION_TABLE} (version, name) VALUES (?1, ?2)"), - params![migration.version, migration.name], - )?; - } - transaction.commit()?; - Ok(()) -} - -fn backup_database(source: &Path, destination: &Path) -> Result<()> { - if !source.is_file() { - return Err(FeatureStorageError::InvalidSnapshot(format!( - "Feature database `{}` is missing", - source.display() - ))); - } - let source = Connection::open_with_flags(source, OpenFlags::SQLITE_OPEN_READ_ONLY)?; - source.busy_timeout(BUSY_TIMEOUT)?; - verify_database(&source)?; - let mut destination = Connection::open(destination)?; - Backup::new(&source, &mut destination)?.run_to_completion( - 128, - Duration::from_millis(2), - None, - )?; - verify_database(&destination)?; - Ok(()) -} - -fn verify_database(connection: &Connection) -> Result<()> { - let integrity: String = connection.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; - if integrity != "ok" { - return Err(FeatureStorageError::InvalidSnapshot(format!( - "SQLite integrity_check failed: {integrity}" - ))); - } - let mut foreign_keys = connection.prepare("PRAGMA foreign_key_check")?; - if foreign_keys.query([])?.next()?.is_some() { - return Err(FeatureStorageError::InvalidSnapshot( - "SQLite foreign_key_check reported a violation".to_string(), - )); - } - Ok(()) -} - -fn list_feature_databases(feature_dir: &Path) -> Result> { - if !feature_dir.exists() { - return Ok(Vec::new()); - } - let mut feature_ids = Vec::new(); - for entry in fs::read_dir(feature_dir)? { - let entry = entry?; - let file_type = entry.file_type()?; - if !file_type.is_file() { - continue; - } - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("sqlite") { - continue; - } - let feature_id = path - .file_stem() - .and_then(|value| value.to_str()) - .ok_or_else(|| { - FeatureStorageError::InvalidSnapshot(format!( - "Feature database filename `{}` is not UTF-8", - path.display() - )) - })?; - validate_identifier("Feature", feature_id)?; - feature_ids.push(feature_id.to_string()); - } - feature_ids.sort(); - Ok(feature_ids) -} - -fn snapshot_manifest(workspace_id: &str, feature_ids: &[String]) -> String { - let mut manifest = format!("format={SNAPSHOT_FORMAT}\nworkspace={workspace_id}\n"); - for feature_id in feature_ids { - manifest.push_str("feature="); - manifest.push_str(feature_id); - manifest.push('\n'); - } - manifest -} - -fn read_snapshot_manifest(snapshot: &Path, expected_workspace: &str) -> Result> { - let manifest = fs::read_to_string(snapshot.join(SNAPSHOT_MANIFEST)).map_err(|error| { - FeatureStorageError::InvalidSnapshot(format!("cannot read manifest: {error}")) - })?; - let mut format = None; - let mut workspace = None; - let mut feature_ids = Vec::new(); - for line in manifest.lines() { - if let Some(value) = line.strip_prefix("format=") { - format = Some(value); - } else if let Some(value) = line.strip_prefix("workspace=") { - workspace = Some(value); - } else if let Some(value) = line.strip_prefix("feature=") { - validate_identifier("Feature", value)?; - feature_ids.push(value.to_string()); - } else { - return Err(FeatureStorageError::InvalidSnapshot(format!( - "unknown manifest entry `{line}`" - ))); - } - } - if format != Some(SNAPSHOT_FORMAT) { - return Err(FeatureStorageError::InvalidSnapshot( - "unsupported snapshot format".to_string(), - )); - } - if workspace != Some(expected_workspace) { - return Err(FeatureStorageError::InvalidSnapshot(format!( - "snapshot Workspace `{}` does not match `{expected_workspace}`", - workspace.unwrap_or("") - ))); - } - feature_ids.sort(); - if feature_ids.windows(2).any(|ids| ids[0] == ids[1]) { - return Err(FeatureStorageError::InvalidSnapshot( - "duplicate Feature entry".to_string(), - )); - } - for feature_id in &feature_ids { - let database = snapshot - .join("features") - .join(format!("{feature_id}.sqlite")); - if !database.is_file() { - return Err(FeatureStorageError::InvalidSnapshot(format!( - "Feature database `{}` is missing", - database.display() - ))); - } - } - Ok(feature_ids) -} - -fn temporary_sibling(destination: &Path, operation: &str) -> Result { - let parent = destination.parent().ok_or_else(|| { - FeatureStorageError::Operation(format!( - "destination `{}` has no parent", - destination.display() - )) - })?; - let name = destination - .file_name() - .and_then(|value| value.to_str()) - .ok_or_else(|| { - FeatureStorageError::Operation(format!( - "destination `{}` has no UTF-8 filename", - destination.display() - )) - })?; - Ok(parent.join(format!(".{name}.{operation}-{}", Uuid::now_v7()))) -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Barrier}; - use std::thread; - - use rusqlite::OptionalExtension; - - use super::*; - - #[derive(Debug)] - enum TestDomainError { - Storage, - Rejected, - } - - impl From for TestDomainError { - fn from(_error: FeatureStorageError) -> Self { - Self::Storage - } - } - - fn create_items(transaction: &Transaction<'_>) -> Result<()> { - transaction.execute_batch( - "CREATE TABLE items (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE);", - )?; - Ok(()) - } - - fn add_audit(transaction: &Transaction<'_>) -> Result<()> { - transaction.execute_batch( - "CREATE TABLE audit (id INTEGER PRIMARY KEY, item_id INTEGER NOT NULL, note TEXT NOT NULL);", - )?; - Ok(()) - } - - const MIGRATIONS: &[FeatureMigration] = &[ - FeatureMigration::new(1, "create items", create_items), - FeatureMigration::new(2, "add audit", add_audit), - ]; - - fn failing_migration(transaction: &Transaction<'_>) -> Result<()> { - transaction.execute_batch("CREATE TABLE partial (id INTEGER PRIMARY KEY);")?; - Err(FeatureStorageError::operation( - "intentional migration failure", - )) - } - - const FAILING_MIGRATIONS: &[FeatureMigration] = &[ - FeatureMigration::new(1, "create items", create_items), - FeatureMigration::new(2, "fail", failing_migration), - ]; - - fn registration(feature_id: &'static str) -> FeatureRegistration { - FeatureRegistration::new(feature_id, MIGRATIONS) - } - - fn insert(database: &FeatureDatabase, value: &str) -> Result<()> { - database.transaction(|transaction| { - transaction.execute("INSERT INTO items (value) VALUES (?1)", [value])?; - Ok(()) - }) - } - - fn values(database: &FeatureDatabase) -> Result> { - database.with_connection(|connection| { - let mut statement = connection.prepare("SELECT value FROM items ORDER BY id")?; - statement - .query_map([], |row| row.get(0))? - .collect::, _>>() - .map_err(Into::into) - }) - } - - #[test] - fn open_migrate_transaction_rollback_and_reopen_persist() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("subject-memory")).unwrap(); - let database = workspace.open(&feature).unwrap(); - assert_eq!(database.schema_version().unwrap(), 2); - insert(&database, "committed").unwrap(); - let error = database - .transaction::<()>(|transaction| { - transaction.execute("INSERT INTO items (value) VALUES ('rolled-back')", [])?; - Err(FeatureStorageError::operation("reject candidate")) - }) - .unwrap_err(); - assert!(error.to_string().contains("reject candidate")); - assert_eq!(values(&database).unwrap(), ["committed"]); - - manager.shutdown().unwrap(); - assert!(matches!( - values(&database).unwrap_err(), - FeatureStorageError::DatabaseClosed - )); - - let reopened = FeatureStorage::new(temp.path().join("storage")); - let workspace = reopened.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("subject-memory")).unwrap(); - let database = workspace.open(&feature).unwrap(); - assert_eq!(values(&database).unwrap(), ["committed"]); - assert_eq!(database.schema_version().unwrap(), 2); - } - - #[test] - fn typed_domain_error_rolls_back_transaction() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - let database = workspace.open(&feature).unwrap(); - - let result = database.try_transaction::<(), TestDomainError>(|transaction| { - transaction - .execute("INSERT INTO items (value) VALUES ('must-roll-back')", []) - .map_err(FeatureStorageError::from)?; - Err(TestDomainError::Rejected) - }); - assert!(matches!(result, Err(TestDomainError::Rejected))); - assert!(values(&database).unwrap().is_empty()); - } - - #[test] - fn workspace_and_feature_databases_are_isolated() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let feature_a = manager.register(registration("feature-a")).unwrap(); - let feature_b = manager.register(registration("feature-b")).unwrap(); - let workspace_a = manager.workspace("workspace-a").unwrap(); - let workspace_b = manager.workspace("workspace-b").unwrap(); - let a_a = workspace_a.open(&feature_a).unwrap(); - let a_b = workspace_a.open(&feature_b).unwrap(); - let b_a = workspace_b.open(&feature_a).unwrap(); - insert(&a_a, "workspace-a/feature-a").unwrap(); - insert(&a_b, "workspace-a/feature-b").unwrap(); - insert(&b_a, "workspace-b/feature-a").unwrap(); - assert_eq!(values(&a_a).unwrap(), ["workspace-a/feature-a"]); - assert_eq!(values(&a_b).unwrap(), ["workspace-a/feature-b"]); - assert_eq!(values(&b_a).unwrap(), ["workspace-b/feature-a"]); - assert_eq!(a_a.schema_version().unwrap(), 2); - assert_eq!(a_b.schema_version().unwrap(), 2); - assert_eq!(b_a.schema_version().unwrap(), 2); - } - - #[test] - fn concurrent_open_migrates_once_and_reuses_connection() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - let barrier = Arc::new(Barrier::new(3)); - let threads = (0..2) - .map(|_| { - let workspace = workspace.clone(); - let feature = feature.clone(); - let barrier = barrier.clone(); - thread::spawn(move || { - barrier.wait(); - workspace.open(&feature).unwrap() - }) - }) - .collect::>(); - barrier.wait(); - let mut databases = Vec::new(); - for thread in threads { - databases.push(thread.join().unwrap()); - } - assert!(Arc::ptr_eq(&databases[0].inner, &databases[1].inner)); - assert_eq!(databases[0].schema_version().unwrap(), 2); - } - - #[test] - fn independent_managers_serialize_migration_and_writes() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let first_manager = FeatureStorage::new(&root); - let second_manager = FeatureStorage::new(&root); - let barrier = Arc::new(Barrier::new(3)); - let open = |manager: FeatureStorage, barrier: Arc| { - thread::spawn(move || { - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - barrier.wait(); - let database = workspace.open(&feature).unwrap(); - (manager, database) - }) - }; - let first = open(first_manager, barrier.clone()); - let second = open(second_manager, barrier.clone()); - barrier.wait(); - let (first_manager, first_database) = first.join().unwrap(); - let (second_manager, second_database) = second.join().unwrap(); - assert_eq!(first_database.schema_version().unwrap(), 2); - assert_eq!(second_database.schema_version().unwrap(), 2); - - let writers = (0..16) - .map(|index| { - let database = if index % 2 == 0 { - first_database.clone() - } else { - second_database.clone() - }; - thread::spawn(move || insert(&database, &format!("manager-value-{index}"))) - }) - .collect::>(); - for writer in writers { - writer.join().unwrap().unwrap(); - } - assert_eq!(values(&first_database).unwrap().len(), 16); - drop((first_manager, second_manager)); - } - - #[test] - fn concurrent_writes_are_serialized_without_lost_updates() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - let database = workspace.open(&feature).unwrap(); - let threads = (0..8) - .map(|index| { - let database = database.clone(); - thread::spawn(move || insert(&database, &format!("value-{index}"))) - }) - .collect::>(); - for thread in threads { - thread.join().unwrap().unwrap(); - } - assert_eq!(values(&database).unwrap().len(), 8); - } - - #[test] - fn failed_migration_rolls_back_schema_and_version() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace - .register(FeatureRegistration::new("broken", FAILING_MIGRATIONS)) - .unwrap(); - let error = workspace.open(&feature).err().unwrap(); - assert!(matches!(error, FeatureStorageError::MigrationFailed { .. })); - let path = temp - .path() - .join("storage/workspace-a/features/broken.sqlite"); - let connection = Connection::open(path).unwrap(); - let migration_table_exists: bool = connection - .query_row( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", - [MIGRATION_TABLE], - |_| Ok(true), - ) - .optional() - .unwrap() - .unwrap_or(false); - assert!(!migration_table_exists); - let partial_exists: bool = connection - .query_row( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'partial'", - [], - |_| Ok(true), - ) - .optional() - .unwrap() - .unwrap_or(false); - assert!(!partial_exists); - } - - #[test] - fn rejects_invalid_identifiers_and_newer_schema() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - assert!(manager.workspace("../escape").is_err()); - assert!( - manager - .register(FeatureRegistration::new("bad/name", MIGRATIONS)) - .is_err() - ); - - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - let database = workspace.open(&feature).unwrap(); - database - .with_connection(|connection| { - connection.execute( - &format!("INSERT INTO {MIGRATION_TABLE} (version, name) VALUES (99, 'future')"), - [], - )?; - Ok(()) - }) - .unwrap(); - drop(database); - manager.shutdown().unwrap(); - - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - assert!(matches!( - workspace.open(&feature).err().unwrap(), - FeatureStorageError::UnsupportedSchema { actual: 99, .. } - )); - } - - #[test] - fn registration_is_lazy_and_disable_preserves_data() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - assert!(!root.exists(), "registration must not create storage"); - let database = workspace.open(&feature).unwrap(); - insert(&database, "retained").unwrap(); - manager.shutdown().unwrap(); - - let disabled = FeatureStorage::new(&root); - assert!(root.join("workspace-a/features/feature-a.sqlite").is_file()); - disabled.shutdown().unwrap(); - - let enabled = FeatureStorage::new(&root); - let workspace = enabled.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - assert_eq!( - values(&workspace.open(&feature).unwrap()).unwrap(), - ["retained"] - ); - } - - #[test] - fn backup_restore_is_consistent_and_includes_disabled_features() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let snapshot = temp.path().join("snapshot"); - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - let database = workspace.open(&feature).unwrap(); - insert(&database, "snapshot-value").unwrap(); - workspace.backup(&snapshot).unwrap(); - insert(&database, "after-snapshot").unwrap(); - workspace.delete().unwrap(); - assert!(matches!( - workspace.open(&feature).err().unwrap(), - FeatureStorageError::WorkspaceMaintenance(_) - )); - drop(database); - drop(workspace); - drop(manager); - - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - workspace.restore(&snapshot).unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - assert_eq!( - values(&workspace.open(&feature).unwrap()).unwrap(), - ["snapshot-value"] - ); - } - - #[test] - fn workspace_delete_closes_handles_and_is_target_limited() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let feature = manager.register(registration("feature-a")).unwrap(); - let workspace_a = manager.workspace("workspace-a").unwrap(); - let workspace_b = manager.workspace("workspace-b").unwrap(); - let database_a = workspace_a.open(&feature).unwrap(); - let database_b = workspace_b.open(&feature).unwrap(); - insert(&database_a, "a").unwrap(); - insert(&database_b, "b").unwrap(); - workspace_a.delete().unwrap(); - assert!(matches!( - values(&database_a).unwrap_err(), - FeatureStorageError::DatabaseClosed - )); - assert_eq!(values(&database_b).unwrap(), ["b"]); - assert!(!temp.path().join("storage/workspace-a").exists()); - assert!(temp.path().join("storage/workspace-b").exists()); - } - - #[test] - fn shutdown_waits_for_operation_and_rejects_later_use() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let workspace = manager.workspace("workspace-a").unwrap(); - let feature = workspace.register(registration("feature-a")).unwrap(); - let database = workspace.open(&feature).unwrap(); - let entered = Arc::new(Barrier::new(2)); - let release = Arc::new(Barrier::new(2)); - let operation = { - let database = database.clone(); - let entered = entered.clone(); - let release = release.clone(); - thread::spawn(move || { - database.with_connection(|_| { - entered.wait(); - release.wait(); - Ok(()) - }) - }) - }; - entered.wait(); - let shutdown = { - let manager = manager.clone(); - thread::spawn(move || manager.shutdown()) - }; - while database.inner.accepting_operations.load(Ordering::Acquire) { - thread::yield_now(); - } - assert!(!shutdown.is_finished()); - assert!(matches!( - values(&database).unwrap_err(), - FeatureStorageError::DatabaseClosed - )); - release.wait(); - operation.join().unwrap().unwrap(); - shutdown.join().unwrap().unwrap(); - assert!(matches!( - values(&database).unwrap_err(), - FeatureStorageError::DatabaseClosed - )); - assert!(matches!( - workspace.open(&feature).err().unwrap(), - FeatureStorageError::ShuttingDown - )); - } -} +//! Backend compatibility facade for the neutral Host storage manager. +pub use feature_storage::*; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 84fb046e1..7b13b898a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1,3 +1,4 @@ +use subjektiv::api::*; mod backend_job_profile; mod subjektiv_job; mod worker_operations; @@ -18110,36 +18111,6 @@ async fn scoped_memory_backend_operation( Ok(Json(response)) } -fn resident_summary_output( - resident: crate::subjektiv::ResidentSurface, -) -> memory::backend::MemoryResidentSummaryOutput { - let (availability, content) = match resident.availability { - crate::subjektiv::SurfaceAvailability::Ready => { - let snapshot = resident.snapshot.expect("ready surface has a snapshot"); - ( - memory::backend::MemoryResidentSummaryAvailability::Ready, - (!snapshot.body_md.is_empty()).then_some(snapshot.body_md), - ) - } - crate::subjektiv::SurfaceAvailability::Ungenerated => ( - memory::backend::MemoryResidentSummaryAvailability::Ungenerated, - None, - ), - crate::subjektiv::SurfaceAvailability::Stale => ( - memory::backend::MemoryResidentSummaryAvailability::Stale, - None, - ), - crate::subjektiv::SurfaceAvailability::Failed => ( - memory::backend::MemoryResidentSummaryAvailability::Failed, - None, - ), - }; - memory::backend::MemoryResidentSummaryOutput { - availability, - content, - } -} - const SUBJEKTIV_SINGLETON_PREFIX: &str = crate::subjektiv::SUBJECT_WORKER_SINGLETON_PREFIX; const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX; @@ -18993,7 +18964,7 @@ fn subjektiv_session_list( Err(error) => { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - bounded_subjektiv_session_message(&error.error.to_string()), + bounded_subjektiv_session_message(&error.to_string()), ); } } @@ -19197,7 +19168,7 @@ fn subjektiv_session_read( Err(error) => { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.error.to_string(), + error.to_string(), ); } } @@ -19650,7 +19621,7 @@ fn subjektiv_session_search( Err(error) => { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.error.to_string(), + error.to_string(), ); } } @@ -20014,52 +19985,6 @@ fn truncate_subjektiv_session_snippet(value: &str) -> (String, bool) { } } -const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20; -const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; -const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; -const SUBJEKTIV_BODY_MAX_LINES: usize = 1_000; -const SUBJEKTIV_BODY_MAX_BYTES: usize = 16 * 1024; -const SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT: usize = crate::subjektiv::MAX_STAGING_ANCHORS; -const SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE: usize = 2; -const SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES: usize = 64; -const SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES: usize = 40 * 1024; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct SubjektivQueryCursor { - subject_id: String, - store_revision: u64, - query: Option, - kinds: Vec, - states: Vec, - offset: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct SubjektivRevisionCursor { - subject_id: String, - memory_id: String, - max_revision: u64, - offset: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct SubjektivEvidenceCursor { - subject_id: String, - memory_id: String, - revision: u64, - /// Candidate/derivation reference offset within the immutable revision. - offset: usize, - /// Nested offsets keep compatible historical candidates with more anchors - /// resumable without admitting new over-limit records. - #[serde(default)] - evidence_offset: usize, - #[serde(default)] - source_ref_offset: usize, -} - async fn scoped_subjektiv_memory_backend( State(api): State, AxumPath(path): AxumPath, @@ -20070,1520 +19995,111 @@ async fn scoped_subjektiv_memory_backend( let (subject_id, source_worker, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; let store = open_subjektiv_store(&api)?; - let response = match request.operation { - server_api::SubjektivMemoryBackendOperation::ResidentSummary(_) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "resident surface read", - )?; - let resident = store - .resident_surface(&subject_id) - .map_err(subjektiv_store_error)?; - server_api::SubjektivMemoryBackendResponse::ResidentSummary(resident_summary_output( - resident, - )) + use server_api::SubjektivMemoryBackendOperation as Op; + // Authentication, live Worker/Job authority and committed Session provenance + // remain Backend-owned. Only the domain operation/projection is shared. + let (expected, label) = match &request.operation { + Op::ResidentSummary(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "resident surface read", + ), + Op::ResidentContext(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "resident subject context read", + ), + Op::ValidateProposal(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "revision proposal validation", + ), + Op::ReceiptStatus(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "explicit candidate receipt lookup", + ), + Op::StageExplicit(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "explicit candidate staging", + ), + Op::ListCandidates(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "candidate listing", + ), + Op::ReadCandidate(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "candidate read", + ), + Op::DecideCandidate(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "candidate decision", + ), + Op::PrepareSurface(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "surface generation preparation", + ), + Op::PublishSurface(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "surface publication", + ), + Op::FailSurface(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "surface generation failure", + ), + Op::Query(_) | Op::Read(_) | Op::ListRevisions(_) => (None, "recall"), + }; + if let Some(expected) = expected { + require_subjektiv_worker_authority(authority, expected, label)?; + } + let host_context = match authority { + SubjektivWorkerAuthority::Subject => { + let attribution = if let Op::StageExplicit(input) = &request.operation { + Some(require_existing_subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &input.session_id, + )?) + } else { + None + }; + HostOperationContext::validated_body(&store, &subject_id, attribution)? } - server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "resident subject context read", - )?; - let resident = store - .resident_context(&subject_id) - .map_err(subjektiv_store_error)?; - server_api::SubjektivMemoryBackendResponse::ResidentContext( - server_api::SubjektivResidentContextOutput { - behavior_md: resident.subject.behavior_md, - behavior_revision: resident.subject.behavior_revision, - memory_surface: resident_summary_output(resident.surface), - }, - ) - } - server_api::SubjektivMemoryBackendOperation::Query(input) => { - server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query( + SubjektivWorkerAuthority::Consolidation => { + match &request.operation { + Op::ReadCandidate(input) => subjektiv_job::require_candidate_grant( + &api, + &path.workspace_id, + &source_worker, + &input.candidate_id, + )?, + Op::DecideCandidate(input) => subjektiv_job::require_candidate_grant( + &api, + &path.workspace_id, + &source_worker, + &input.candidate_id, + )?, + _ => {} + } + let grant = subjektiv_job::active_grant(&api, &path.workspace_id, &source_worker)?; + let job = api + .store + .worker_registry_projection(&path.workspace_id, &source_worker)? + .and_then(|projection| projection.job) + .map(|binding| JobAttemptBinding { + job_id: binding.job_id, + attempt_id: binding.attempt_id, + }); + HostOperationContext::validated_consolidation( &store, &subject_id, - input, - )?) - } - server_api::SubjektivMemoryBackendOperation::Read(input) => { - server_api::SubjektivMemoryBackendResponse::Read(subjektiv_memory_read( - &store, - &subject_id, - input, - )?) - } - server_api::SubjektivMemoryBackendOperation::ListRevisions(input) => { - server_api::SubjektivMemoryBackendResponse::ListRevisions( - subjektiv_memory_list_revisions(&store, &subject_id, input)?, - ) - } - server_api::SubjektivMemoryBackendOperation::ValidateProposal(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "revision proposal validation", - )?; - server_api::SubjektivMemoryBackendResponse::ProposalValidated( - subjektiv_memory_validate_proposal(&store, &subject_id, input)?, - ) - } - server_api::SubjektivMemoryBackendOperation::ReceiptStatus(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "explicit candidate receipt lookup", - )?; - validate_subjektiv_receipt_id(&input.receipt_id)?; - let candidate = store - .staging_candidate(&subject_id, &input.receipt_id) - .map_err(subjektiv_store_error)?; - server_api::SubjektivMemoryBackendResponse::ReceiptStatus( - server_api::SubjektivMemoryReceiptStatusResponse { - receipt_id: input.receipt_id, - status: if candidate.is_some() { - server_api::SubjektivMemoryReceiptStatus::Staged - } else { - server_api::SubjektivMemoryReceiptStatus::Missing - }, - candidate_id: candidate.map(|record| record.id), - }, - ) - } - server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "explicit candidate staging", - )?; - // Re-derive the same subject and immutable Session attribution for the - // write. The request never carries subject, Worker, Runtime, origin, - // or arbitrary source JSON from the model. - let attribution = require_existing_subjektiv_session_attribution( - &api, - &path.workspace_id, - &context, - &input.session_id, - )?; - server_api::SubjektivMemoryBackendResponse::Staged(subjektiv_memory_stage_explicit( - &store, - input, - attribution, - )?) - } - server_api::SubjektivMemoryBackendOperation::ListCandidates(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Consolidation, - "candidate listing", - )?; - let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; - let mut candidates = if let Some(grant) = - subjektiv_job::active_grant(&api, &path.workspace_id, &source_worker)? - { - let mut pending = Vec::new(); - for id in &grant.candidate_ids { - if store - .staging_resolution(&subject_id, id) - .map_err(subjektiv_store_error)? - .is_none() - { - if let Some(candidate) = store - .staging_candidate(&subject_id, id) - .map_err(subjektiv_store_error)? - { - pending.push(candidate); - } - } - } - pending - } else { - store - .pending_staging_candidates(&subject_id, limit.saturating_add(1)) - .map_err(subjektiv_store_error)? - }; - let has_more = candidates.len() > limit; - candidates.truncate(limit); - server_api::SubjektivMemoryBackendResponse::Candidates( - server_api::SubjektivMemoryCandidateListResponse { - items: candidates - .into_iter() - .map(subjektiv_candidate_summary) - .collect(), - has_more, - }, - ) - } - server_api::SubjektivMemoryBackendOperation::ReadCandidate(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Consolidation, - "candidate read", - )?; - subjektiv_job::require_candidate_grant( - &api, - &path.workspace_id, - &source_worker, - &input.candidate_id, - )?; - let candidate = store - .staging_candidate(&subject_id, &input.candidate_id) - .map_err(subjektiv_store_error)? - .ok_or_else(|| { - Error::InvalidInput(format!( - "candidate_not_found: pending candidate `{}` was not found", - input.candidate_id - )) - })?; - if store - .staging_resolution(&subject_id, &input.candidate_id) - .map_err(subjektiv_store_error)? - .is_some() - { - return Err(Error::RepositoryConflict(format!( - "candidate_resolved: candidate `{}` is already resolved", - input.candidate_id - )) - .into()); - } - server_api::SubjektivMemoryBackendResponse::Candidate(subjektiv_candidate(candidate)) - } - server_api::SubjektivMemoryBackendOperation::DecideCandidate(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Consolidation, - "candidate decision", - )?; - subjektiv_job::require_candidate_grant( - &api, - &path.workspace_id, - &source_worker, - &input.candidate_id, - )?; - let receipt = store - .decide_candidate(&subject_id, subjektiv_candidate_decision(input)?) - .map_err(subjektiv_store_error)?; - server_api::SubjektivMemoryBackendResponse::CandidateDecided( - subjektiv_candidate_decision_response(receipt), - ) - } - server_api::SubjektivMemoryBackendOperation::PrepareSurface(_) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Consolidation, - "surface generation preparation", - )?; - if let Some(grant) = - subjektiv_job::active_grant(&api, &path.workspace_id, &source_worker)? - { - for id in &grant.candidate_ids { - if store - .staging_resolution(&subject_id, id) - .map_err(subjektiv_store_error)? - .is_none() - { - return Err(Error::InvalidInput(format!("surface generation requires a durable disposition for batch candidate `{id}`")).into()); - } - } - } - let binding = api - .store - .worker_registry_projection(&path.workspace_id, &source_worker)? - .and_then(|p| p.job); - let generation = store - .prepare_job_surface_generation( - &subject_id, - binding - .as_ref() - .map(|b| (b.job_id.as_str(), b.attempt_id.as_str())), - ) - .map_err(subjektiv_store_error)?; - let resident = store - .resident_surface(&subject_id) - .map_err(subjektiv_store_error)?; - let mut response = subjektiv_surface_prepare_response(generation); - if resident.availability == crate::subjektiv::SurfaceAvailability::Ready { - response.current_snapshot_id = resident - .snapshot - .filter(|s| s.built_from_store_revision == response.store_revision) - .map(|s| s.id); - } - server_api::SubjektivMemoryBackendResponse::SurfacePrepared(response) - } - server_api::SubjektivMemoryBackendOperation::PublishSurface(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Consolidation, - "surface publication", - )?; - if let Some(binding) = api - .store - .worker_registry_projection(&path.workspace_id, &source_worker)? - .and_then(|p| p.job) - { - store - .require_job_surface_generation( - &subject_id, - &input.generation_id, - &binding.job_id, - &binding.attempt_id, - ) - .map_err(subjektiv_store_error)?; - } - let points = input - .points - .into_iter() - .map(|point| crate::subjektiv::SurfacePoint { - body_md: point.body_md, - memory_refs: point - .memory_refs - .into_iter() - .map(|reference| crate::subjektiv::MemoryRevisionRef { - memory_id: reference.memory_id, - revision: reference.revision, - }) - .collect(), - }) - .collect(); - let snapshot = store - .publish_surface_generation(&subject_id, &input.generation_id, points) - .map_err(subjektiv_store_error)?; - server_api::SubjektivMemoryBackendResponse::SurfacePublished( - server_api::SubjektivSurfacePublishResponse { - snapshot_id: snapshot.id, - built_from_store_revision: snapshot.built_from_store_revision, - empty: snapshot.body_md.is_empty(), - }, - ) - } - server_api::SubjektivMemoryBackendOperation::FailSurface(input) => { - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Consolidation, - "surface generation failure", - )?; - if let Some(binding) = api - .store - .worker_registry_projection(&path.workspace_id, &source_worker)? - .and_then(|p| p.job) - { - store - .require_job_surface_generation( - &subject_id, - &input.generation_id, - &binding.job_id, - &binding.attempt_id, - ) - .map_err(subjektiv_store_error)?; - } - let revision = store - .fail_surface_generation(&subject_id, &input.generation_id, &input.reason_code) - .map_err(subjektiv_store_error)?; - let status = if let Some(binding) = api - .store - .worker_registry_projection(&path.workspace_id, &source_worker)? - .and_then(|p| p.job) - { - store - .validate_job_surface_outcome( - &subject_id, - &input.generation_id, - revision, - "failed", - None, - Some(&input.reason_code), - &binding.job_id, - &binding.attempt_id, - ) - .map_err(|e| { - Error::RepositoryConflict(format!( - "surface failure was not recorded for this attempt: {e}" - )) - })?; - "failed_confirmed" - } else { - "failed" - }; - server_api::SubjektivMemoryBackendResponse::SurfaceFailed( - server_api::SubjektivSurfaceFailureResponse { - store_revision: revision, - status: status.into(), - }, - ) + grant.map(|g| g.candidate_ids), + job, + )? } }; - Ok(Json(response)) -} - -fn subjektiv_surface_prepare_response( - generation: crate::subjektiv::SurfaceGeneration, -) -> server_api::SubjektivSurfacePrepareResponse { - server_api::SubjektivSurfacePrepareResponse { - generation_id: generation.id, - current_snapshot_id: None, - store_revision: generation.store_revision, - active_memory_count: generation.active_memory_count, - materials: generation - .materials - .into_iter() - .map(|material| server_api::SubjektivSurfaceMaterial { - memory_id: material.memory_id, - revision: material.revision, - kind: material.kind, - body_md: material.body_md, - why_useful: material.why_useful, - staleness: material.staleness, - }) - .collect(), - body_token_budget: crate::subjektiv::SURFACE_BODY_TOKEN_BUDGET, - input_token_budget: crate::subjektiv::SURFACE_INPUT_TOKEN_BUDGET, - per_kind_limit: crate::subjektiv::SURFACE_PER_KIND_LIMIT, - total_material_limit: crate::subjektiv::SURFACE_TOTAL_MATERIAL_LIMIT, - } -} - -fn subjektiv_candidate_summary( - candidate: crate::subjektiv::SubjectStagingRecord, -) -> server_api::SubjektivMemoryCandidateSummary { - server_api::SubjektivMemoryCandidateSummary { - candidate_id: candidate.id, - kind: candidate.kind, - claim: candidate.claim, - revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), - created_at: candidate.created_at, - } -} - -fn subjektiv_candidate( - candidate: crate::subjektiv::SubjectStagingRecord, -) -> server_api::SubjektivMemoryCandidate { - server_api::SubjektivMemoryCandidate { - candidate_id: candidate.id, - kind: candidate.kind, - claim: candidate.claim, - why_useful: candidate.why_useful, - staleness: candidate.staleness, - source: candidate.source, - evidence: candidate.evidence, - source_refs: candidate.source_refs, - revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata), - created_at: candidate.created_at, - } -} - -fn subjektiv_proposal_metadata( - proposal: crate::subjektiv::RevisionProposal, -) -> server_api::SubjektivMemoryRevisionProposalMetadata { - server_api::SubjektivMemoryRevisionProposalMetadata { - memory_id: proposal.memory_id, - expected_revision: proposal.expected_revision, - intent: match proposal.intent { - crate::subjektiv::RevisionProposalIntent::Revise => { - server_api::SubjektivMemoryRevisionIntent::Revise - } - crate::subjektiv::RevisionProposalIntent::Resolve => { - server_api::SubjektivMemoryRevisionIntent::Resolve - } - crate::subjektiv::RevisionProposalIntent::Retract => { - server_api::SubjektivMemoryRevisionIntent::Retract - } - crate::subjektiv::RevisionProposalIntent::Reopen => { - server_api::SubjektivMemoryRevisionIntent::Reopen - } - }, - change_reason: proposal.change_reason, - } -} - -fn subjektiv_candidate_decision( - input: server_api::SubjektivMemoryCandidateDecisionRequest, -) -> ApiResult { - let decision = match input.decision { - server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => { - let target = match target { - server_api::SubjektivMemoryApplyTarget::Create => { - crate::subjektiv::MemoryRevisionTarget::Create - } - server_api::SubjektivMemoryApplyTarget::Revise { - memory_id, - expected_revision, - } => crate::subjektiv::MemoryRevisionTarget::Revise { - memory_id, - expected_revision, - }, - }; - crate::subjektiv::CandidateDecision::Apply { - target, - draft: crate::subjektiv::MemoryDraft { - kind: memory.kind, - state: domain_memory_state(memory.state), - claim: memory.claim, - body_md: memory.body_md, - why_useful: memory.why_useful, - staleness: memory.staleness, - source_candidate_ids: Vec::new(), - derived_from: memory - .derived_from - .into_iter() - .map(|reference| crate::subjektiv::MemoryRevisionRef { - memory_id: reference.memory_id, - revision: reference.revision, - }) - .collect(), - change_reason: memory.change_reason, - }, - } - } - server_api::SubjektivMemoryCandidateDecision::Close { - action, - affected_memory, - } => crate::subjektiv::CandidateDecision::Close { - action: match action { - server_api::SubjektivMemoryCandidateCloseAction::Discarded => { - crate::subjektiv::StagingResolutionAction::Discarded - } - server_api::SubjektivMemoryCandidateCloseAction::Invalid => { - crate::subjektiv::StagingResolutionAction::Invalid - } - server_api::SubjektivMemoryCandidateCloseAction::Duplicate => { - crate::subjektiv::StagingResolutionAction::Duplicate - } - server_api::SubjektivMemoryCandidateCloseAction::AlreadyCovered => { - crate::subjektiv::StagingResolutionAction::AlreadyCovered - } - }, - affected_memory: affected_memory - .into_iter() - .map(|reference| crate::subjektiv::MemoryRevisionRef { - memory_id: reference.memory_id, - revision: reference.revision, - }) - .collect(), - }, - }; - Ok(crate::subjektiv::CandidateDecisionRequest { - request_id: input.request_id, - candidate_id: input.candidate_id, - reason: input.reason, - decision, - }) -} - -fn subjektiv_candidate_decision_response( - receipt: crate::subjektiv::CandidateDecisionReceipt, -) -> server_api::SubjektivMemoryCandidateDecisionResponse { - let operation = match receipt.operation { - Some(crate::subjektiv::MemoryDecisionOperation::Create) => { - server_api::SubjektivMemoryAffectedOperation::Create - } - Some(crate::subjektiv::MemoryDecisionOperation::Revise) => { - server_api::SubjektivMemoryAffectedOperation::Revise - } - Some(crate::subjektiv::MemoryDecisionOperation::Reference) | None => { - server_api::SubjektivMemoryAffectedOperation::Reference - } - }; - server_api::SubjektivMemoryCandidateDecisionResponse { - request_id: receipt.request_id, - candidate_id: receipt.candidate_id, - action: match receipt.resolution.action { - crate::subjektiv::StagingResolutionAction::Applied => { - server_api::SubjektivMemoryCandidateResolutionAction::Applied - } - crate::subjektiv::StagingResolutionAction::Discarded => { - server_api::SubjektivMemoryCandidateResolutionAction::Discarded - } - crate::subjektiv::StagingResolutionAction::Invalid => { - server_api::SubjektivMemoryCandidateResolutionAction::Invalid - } - crate::subjektiv::StagingResolutionAction::Duplicate => { - server_api::SubjektivMemoryCandidateResolutionAction::Duplicate - } - crate::subjektiv::StagingResolutionAction::AlreadyCovered => { - server_api::SubjektivMemoryCandidateResolutionAction::AlreadyCovered - } - }, - reason: receipt.resolution.reason, - affected_memory: receipt - .resolution - .affected_memory - .into_iter() - .map(|reference| server_api::SubjektivMemoryAffectedRef { - memory_id: reference.memory_id, - revision: reference.revision, - operation, - }) - .collect(), - memory: receipt - .memory - .map(|memory| server_api::SubjektivMemoryRevisionRef { - memory_id: memory.id, - revision: memory.revision, - }), - store_revision: receipt.store_revision, - surface_dirty: receipt.surface_dirty, - } -} - -fn domain_memory_state(state: server_api::SubjektivMemoryState) -> crate::subjektiv::MemoryState { - match state { - server_api::SubjektivMemoryState::Active => crate::subjektiv::MemoryState::Active, - server_api::SubjektivMemoryState::Resolved => crate::subjektiv::MemoryState::Resolved, - server_api::SubjektivMemoryState::Retracted => crate::subjektiv::MemoryState::Retracted, - } -} - -fn subjektiv_memory_query( - store: &crate::subjektiv::SubjektivStore, - subject_id: &str, - input: server_api::SubjektivMemoryQueryRequest, -) -> ApiResult { - let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; - let query = input - .query - .map(|value| value.trim().to_lowercase()) - .filter(|value| !value.is_empty()); - let kinds = canonical_subjektiv_kinds(input.kinds)?; - let states = canonical_subjektiv_states(input.states)?; - let subject = store - .subject(subject_id) - .map_err(subjektiv_store_error)? - .ok_or_else(|| Error::SubjektivSubjectNotFound(subject_id.to_string()))?; - let mut offset = 0; - if let Some(cursor) = input.cursor { - let cursor: SubjektivQueryCursor = decode_subjektiv_cursor("query", &cursor)?; - if cursor.subject_id != subject_id - || cursor.query != query - || cursor.kinds != kinds - || cursor.states != states - { - return Err(Error::InvalidInput( - "subjektiv query cursor does not match subject or filters".into(), - ) - .into()); - } - if cursor.store_revision != subject.store_revision { - return Err(Error::RepositoryConflict( - "stale_cursor: subjektiv query cursor is stale because Memory changed; restart the query".into(), - ) - .into()); - } - offset = cursor.offset; - } - - let kind_filter = kinds.iter().cloned().collect::>(); - let state_filter = states.iter().cloned().collect::>(); - let records = store - .list_memories(subject_id) - .map_err(subjektiv_store_error)? - .into_iter() - .filter(|record| { - kind_filter.contains(record.kind.as_str()) - && state_filter.contains(subjektiv_memory_state_name(record.state)) - && query.as_ref().is_none_or(|query| { - record.claim.to_lowercase().contains(query) - || record.body_md.to_lowercase().contains(query) - }) - }) - .collect::>(); - if offset > records.len() { - return Err(Error::InvalidInput("subjektiv query cursor offset is invalid".into()).into()); - } - let end = offset.saturating_add(limit).min(records.len()); - let has_more = end < records.len(); - let items = records[offset..end] - .iter() - .map(|record| server_api::SubjektivMemoryQueryItem { - id: record.id.clone(), - revision: record.revision, - kind: record.kind.clone(), - state: api_memory_state(record.state), - claim: record.claim.clone(), - excerpt: bounded_memory_excerpt(&record.body_md, 240), - updated_at: record.updated_at.clone(), - }) - .collect(); - let next_cursor = has_more - .then(|| { - encode_subjektiv_cursor( - "query", - &SubjektivQueryCursor { - subject_id: subject_id.to_string(), - store_revision: subject.store_revision, - query, - kinds, - states, - offset: end, - }, - ) - }) - .transpose()?; - Ok(server_api::SubjektivMemoryQueryResponse { - items, - next_cursor, - has_more, - }) -} - -fn subjektiv_memory_read( - store: &crate::subjektiv::SubjektivStore, - subject_id: &str, - input: server_api::SubjektivMemoryReadRequest, -) -> ApiResult { - let current = store - .scoped_memory(subject_id, &input.memory_id) - .map_err(subjektiv_store_error)?; - let record = match input.revision { - Some(0) => { - return Err(Error::InvalidInput("revision must be a positive integer".into()).into()); - } - Some(revision) => store - .scoped_memory_revision(subject_id, &input.memory_id, revision) - .map_err(subjektiv_store_error)? - .ok_or_else(|| { - Error::InvalidInput(format!( - "memory_revision_not_found: Memory `{}` has no revision {revision}", - input.memory_id - )) - })?, - None => current.clone(), - }; - let body_limit = input.limit.unwrap_or(SUBJEKTIV_BODY_DEFAULT_LINES); - if body_limit == 0 || body_limit > SUBJEKTIV_BODY_MAX_LINES { - return Err(Error::InvalidInput(format!( - "body line limit must be within 1..={SUBJEKTIV_BODY_MAX_LINES}" - )) - .into()); - } - let body_offset = input.offset.unwrap_or(0); - let body_byte_offset = input.byte_offset.unwrap_or(0); - if (body_offset > 0 || body_byte_offset > 0) && input.revision.is_none() { - return Err(Error::InvalidInput( - "body continuation requires the exact revision returned by the first read".into(), - ) - .into()); - } - let lines = memory_body_lines(&record.body_md); - if body_offset > lines.len() { - return Err(Error::InvalidInput("body line offset exceeds document length".into()).into()); - } - if body_offset == lines.len() && body_byte_offset > 0 { - return Err(Error::InvalidInput("body byte offset exceeds document length".into()).into()); - } - if let Some(line) = lines.get(body_offset) - && (body_byte_offset > line.len() || !line.is_char_boundary(body_byte_offset)) - { - return Err(Error::InvalidInput( - "body byte offset must be a UTF-8 boundary within the selected line".into(), - ) - .into()); - } - let (body_md, body_next) = bounded_memory_body_page( - &lines, - body_offset, - body_byte_offset, - body_limit, - SUBJEKTIV_BODY_MAX_BYTES, - ); - let body_truncated = body_next.is_some(); - let (body_next_offset, body_next_byte_offset) = body_next - .map(|(line, byte)| (Some(line), Some(byte))) - .unwrap_or((None, None)); - - let (evidence_offset, nested_evidence_offset, nested_source_ref_offset) = - if let Some(cursor) = input.evidence_cursor { - let cursor: SubjektivEvidenceCursor = decode_subjektiv_cursor("evidence", &cursor)?; - if cursor.subject_id != subject_id - || cursor.memory_id != record.id - || cursor.revision != record.revision - { - return Err(Error::InvalidInput( - "subjektiv evidence cursor does not match subject, Memory, or revision".into(), - ) - .into()); - } - ( - cursor.offset, - cursor.evidence_offset, - cursor.source_ref_offset, - ) - } else { - (0, 0, 0) - }; - let total_evidence = record - .source_candidate_ids - .len() - .saturating_add(record.derived_from.len()); - if evidence_offset > total_evidence { - return Err( - Error::InvalidInput("subjektiv evidence cursor offset is invalid".into()).into(), - ); - } - - let mut source_candidate_ids = Vec::new(); - let mut source_candidates = Vec::new(); - let mut derived_from = Vec::new(); - let mut next_evidence_offset = evidence_offset; - let mut next_nested_evidence_offset = 0; - let mut next_nested_source_ref_offset = 0; - if let Some(candidate_id) = record.source_candidate_ids.get(evidence_offset) { - let candidate = store - .staging_candidate(subject_id, candidate_id) - .map_err(subjektiv_store_error)? - .ok_or_else(|| { - Error::Store(format!( - "subjektiv candidate provenance `{candidate_id}` is missing" - )) - })?; - let evidence_total = candidate.evidence.len(); - let source_refs_total = candidate.source_refs.len(); - if nested_evidence_offset > evidence_total || nested_source_ref_offset > source_refs_total { - return Err(Error::InvalidInput( - "subjektiv nested evidence cursor offset is invalid".into(), - ) - .into()); - } - let evidence_end = nested_evidence_offset - .saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE) - .min(evidence_total); - let source_ref_end = nested_source_ref_offset - .saturating_add(SUBJEKTIV_EVIDENCE_ANCHOR_PAGE_SIZE) - .min(source_refs_total); - source_candidate_ids.push(candidate_id.clone()); - source_candidates.push(server_api::SubjektivMemoryEvidenceCandidate { - candidate_id: candidate.id, - evidence: candidate - .evidence - .into_iter() - .skip(nested_evidence_offset) - .take(evidence_end - nested_evidence_offset) - .map(bounded_subjektiv_staging_evidence) - .collect(), - evidence_total, - evidence_truncated: nested_evidence_offset > 0 || evidence_end < evidence_total, - source_refs: candidate - .source_refs - .into_iter() - .skip(nested_source_ref_offset) - .take(source_ref_end - nested_source_ref_offset) - .map(bounded_subjektiv_source_ref) - .collect(), - source_refs_total, - source_refs_truncated: nested_source_ref_offset > 0 - || source_ref_end < source_refs_total, - }); - if evidence_end < evidence_total || source_ref_end < source_refs_total { - next_nested_evidence_offset = evidence_end; - next_nested_source_ref_offset = source_ref_end; - } else { - next_evidence_offset += 1; - } - } else if evidence_offset < total_evidence { - if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { - return Err(Error::InvalidInput( - "subjektiv derivation cursor cannot contain nested offsets".into(), - ) - .into()); - } - let derived_index = evidence_offset - record.source_candidate_ids.len(); - let reference = &record.derived_from[derived_index]; - derived_from.push(server_api::SubjektivMemoryRevisionRef { - memory_id: reference.memory_id.clone(), - revision: reference.revision, - }); - next_evidence_offset += 1; - } else if nested_evidence_offset != 0 || nested_source_ref_offset != 0 { - return Err(Error::InvalidInput( - "subjektiv evidence cursor points beyond the final reference".into(), - ) - .into()); - } - bound_subjektiv_nested_evidence(&source_candidates)?; - let evidence_has_more = next_evidence_offset < total_evidence - || next_nested_evidence_offset != 0 - || next_nested_source_ref_offset != 0; - let evidence_next_cursor = evidence_has_more - .then(|| { - encode_subjektiv_cursor( - "evidence", - &SubjektivEvidenceCursor { - subject_id: subject_id.to_string(), - memory_id: record.id.clone(), - revision: record.revision, - offset: next_evidence_offset, - evidence_offset: next_nested_evidence_offset, - source_ref_offset: next_nested_source_ref_offset, - }, - ) - }) - .transpose()?; - - let mut response = server_api::SubjektivMemoryReadResponse { - memory_id: record.id, - revision: record.revision, - current_revision: current.revision, - kind: record.kind, - state: api_memory_state(record.state), - claim: record.claim, - body_md, - why_useful: record.why_useful, - staleness: record.staleness, - change_reason: record.change_reason, - created_at: record.created_at, - updated_at: record.updated_at, - body_offset, - body_byte_offset, - body_next_offset, - body_next_byte_offset, - body_truncated, - source_candidate_ids, - source_candidates, - derived_from, - evidence_next_cursor, - evidence_has_more, - }; - fit_subjektiv_read_body_to_output_budget( - &mut response, - &lines, - body_offset, - body_byte_offset, - body_limit, - )?; - Ok(response) -} - -fn subjektiv_memory_list_revisions( - store: &crate::subjektiv::SubjektivStore, - subject_id: &str, - input: server_api::SubjektivMemoryListRevisionsRequest, -) -> ApiResult { - let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?; - let current = store - .scoped_memory(subject_id, &input.memory_id) - .map_err(subjektiv_store_error)?; - let (max_revision, offset) = if let Some(cursor) = input.cursor { - let cursor: SubjektivRevisionCursor = decode_subjektiv_cursor("revisions", &cursor)?; - if cursor.subject_id != subject_id || cursor.memory_id != input.memory_id { - return Err(Error::InvalidInput( - "subjektiv revision cursor does not match subject or Memory".into(), - ) - .into()); - } - (cursor.max_revision, cursor.offset) - } else { - (current.revision, 0) - }; - let records = store - .list_memory_revisions(subject_id, &input.memory_id) - .map_err(subjektiv_store_error)? - .into_iter() - .filter(|record| record.revision <= max_revision) - .collect::>(); - if offset > records.len() { - return Err( - Error::InvalidInput("subjektiv revision cursor offset is invalid".into()).into(), - ); - } - let end = offset.saturating_add(limit).min(records.len()); - let has_more = end < records.len(); - let items = records[offset..end] - .iter() - .map(|record| server_api::SubjektivMemoryRevisionItem { - revision: record.revision, - kind: record.kind.clone(), - state: api_memory_state(record.state), - claim: record.claim.clone(), - change_reason: record.change_reason.clone(), - updated_at: record.updated_at.clone(), - }) - .collect(); - let next_cursor = has_more - .then(|| { - encode_subjektiv_cursor( - "revisions", - &SubjektivRevisionCursor { - subject_id: subject_id.to_string(), - memory_id: input.memory_id.clone(), - max_revision, - offset: end, - }, - ) - }) - .transpose()?; - Ok(server_api::SubjektivMemoryListRevisionsResponse { - memory_id: input.memory_id, - current_revision: current.revision, - items, - next_cursor, - has_more, - }) -} - -fn subjektiv_memory_validate_proposal( - store: &crate::subjektiv::SubjektivStore, - subject_id: &str, - input: server_api::SubjektivMemoryValidateProposalRequest, -) -> ApiResult { - if input.expected_revision == 0 { - return Err(Error::InvalidInput("expected_revision must be positive".into()).into()); - } - let current = store - .scoped_memory(subject_id, &input.memory_id) - .map_err(subjektiv_store_error)?; - if current.revision != input.expected_revision { - return Err(Error::RepositoryConflict(format!( - "revision_conflict: Memory `{}` expected {}, current {}", - input.memory_id, input.expected_revision, current.revision - )) - .into()); - } - validate_subjektiv_proposal_transition(current.state, input.intent)?; - Ok(server_api::SubjektivMemoryProposalValidationResponse { - memory_id: input.memory_id, - current_revision: current.revision, - kind: current.kind, - state: api_memory_state(current.state), - intent: input.intent, - }) -} - -fn validate_subjektiv_receipt_id(receipt_id: &str) -> ApiResult<()> { - let receipt_id = receipt_id.trim(); - if receipt_id.is_empty() || receipt_id.len() > 512 || receipt_id.chars().any(char::is_control) { - return Err(Error::InvalidInput("explicit Memory receipt_id is invalid".into()).into()); - } - Ok(()) -} - -fn subjektiv_memory_stage_explicit( - store: &crate::subjektiv::SubjektivStore, - mut input: server_api::SubjektivMemoryStageExplicitRequest, - attribution: crate::subjektiv::SubjectSessionAttribution, -) -> ApiResult { - validate_subjektiv_receipt_id(&input.receipt_id)?; - let receipt_id = input.receipt_id.trim(); - if input.evidence.is_empty() || input.source_refs.is_empty() { - return Err(Error::InvalidInput( - "explicit Memory staging requires host-resolved committed evidence".into(), - ) - .into()); - } - if input.evidence.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT - || input.source_refs.len() > SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT - { - return Err(Error::InvalidInput(format!( - "explicit Memory evidence/source_refs are limited to {SUBJEKTIV_CANDIDATE_ANCHOR_LIMIT} items each" - )) - .into()); - } - if matches!(input.kind, memory::extract::CandidateKind::Preference) - && input.evidence.iter().any(|evidence| { - !matches!( - evidence.origin.as_ref().map(|origin| &origin.kind), - Some(memory::schema::EvidenceOriginKind::HumanInput) - ) - }) - { - return Err(Error::InvalidInput( - "preference candidates require exclusively HumanInput evidence".into(), - ) - .into()); - } - let mut range_start = u64::MAX; - let mut range_end = 0; - let mut segment_id = None; - for source_ref in &mut input.source_refs { - if source_ref - .session_id - .as_deref() - .is_some_and(|value| value != attribution.session_id) - { - return Err(Error::InvalidInput( - "explicit candidate source belongs to a different Session".into(), - ) - .into()); - } - source_ref.session_id = Some(attribution.session_id.clone()); - let source_segment = source_ref.segment_id.as_deref().ok_or_else(|| { - Error::InvalidInput("explicit candidate source is missing segment_id".into()) - })?; - if segment_id - .as_deref() - .is_some_and(|value| value != source_segment) - { - return Err(Error::InvalidInput( - "explicit candidate evidence spans multiple Session segments".into(), - ) - .into()); - } - segment_id = Some(source_segment.to_string()); - let range = source_ref.entry_range.ok_or_else(|| { - Error::InvalidInput("explicit candidate source is missing entry_range".into()) - })?; - range_start = range_start.min(range[0]); - range_end = range_end.max(range[1]); - } - let source = memory::schema::SourceRef { - segment_id: segment_id.ok_or_else(|| { - Error::InvalidInput("explicit candidate source is missing segment_id".into()) - })?, - range: [range_start, range_end], - }; - let proposal = input - .proposal - .as_ref() - .map(|proposal| { - crate::subjektiv::RevisionProposal::new( - domain_proposal_intent(proposal.intent), - proposal.memory_id.clone(), - proposal.expected_revision, - proposal.change_reason.clone(), - ) - }) - .transpose() - .map_err(subjektiv_store_error)?; - if let Some(proposal) = &proposal { - let current = store - .scoped_memory(&attribution.subject_id, &proposal.memory_id) - .map_err(subjektiv_store_error)?; - if current.kind != input.kind { - return Err(Error::InvalidInput( - "revision proposal kind must be derived from its target Memory".into(), - ) - .into()); - } - } - let candidate = memory::extract::ExtractedCandidate { - kind: input.kind, - claim: input.claim, - why_useful: input.why_useful, - staleness: input.staleness, - evidence_ids: input.evidence.iter().map(|item| item.id.clone()).collect(), - }; - let staging = memory::extract::StagingRecord::from_candidate( - receipt_id, - format!("explicit:{receipt_id}"), - source, - candidate, - input.evidence, - input.source_refs, - ); - let mut staging = - crate::subjektiv::SubjectStagingRecord::attach(&attribution.subject_id, staging); - if let Some(proposal) = proposal.clone() { - staging - .attach_revision_proposal(proposal) - .map_err(subjektiv_store_error)?; - } - let (staged, _) = store - .stage_candidate_with_attribution(staging, attribution) - .map_err(subjektiv_store_error)?; - Ok(server_api::SubjektivMemoryStageExplicitResponse { - candidate_id: staged.id, - receipt_id: receipt_id.to_string(), - status: server_api::SubjektivMemoryReceiptStatus::Staged, - target: proposal - .as_ref() - .map(|proposal| server_api::SubjektivMemoryProposalTarget { - memory_id: proposal.memory_id.clone(), - expected_revision: proposal.expected_revision, - }), - intent: proposal - .as_ref() - .map(|proposal| api_proposal_intent(proposal.intent)), - }) -} - -fn validate_subjektiv_proposal_transition( - state: crate::subjektiv::MemoryState, - intent: server_api::SubjektivMemoryRevisionIntent, -) -> ApiResult<()> { - use crate::subjektiv::MemoryState::{Active, Resolved}; - use server_api::SubjektivMemoryRevisionIntent::{Reopen, Resolve, Retract, Revise}; - let valid = match intent { - Revise => matches!(state, Active | Resolved), - Resolve => state == Active, - Retract => matches!(state, Active | Resolved), - Reopen => state == Resolved, - }; - if valid { - Ok(()) - } else { - Err(Error::InvalidInput(format!( - "invalid_state_transition: {state:?} cannot accept {intent:?}" - )) - .into()) - } -} - -fn canonical_subjektiv_kinds( - kinds: Option>, -) -> ApiResult> { - let kinds = kinds.unwrap_or_else(|| { - use memory::extract::CandidateKind::*; - vec![ - Preference, - WorkingAssumption, - Constraint, - Decision, - OpenQuestion, - Lesson, - ] - }); - if kinds.is_empty() { - return Err(Error::InvalidInput("kinds must not be an empty array".into()).into()); - } - let mut names = kinds - .iter() - .map(|kind| kind.as_str().to_string()) - .collect::>(); - names.sort(); - names.dedup(); - Ok(names) -} - -fn canonical_subjektiv_states( - states: Option>, -) -> ApiResult> { - let states = states.unwrap_or_else(|| vec![server_api::SubjektivMemoryState::Active]); - if states.is_empty() { - return Err(Error::InvalidInput("states must not be an empty array".into()).into()); - } - let mut names = states - .into_iter() - .map(|state| match state { - server_api::SubjektivMemoryState::Active => "active".to_string(), - server_api::SubjektivMemoryState::Resolved => "resolved".to_string(), - server_api::SubjektivMemoryState::Retracted => "retracted".to_string(), - }) - .collect::>(); - names.sort(); - names.dedup(); - Ok(names) -} - -fn bounded_subjektiv_limit(limit: Option, default: usize) -> ApiResult { - let limit = limit.unwrap_or(default); - if limit == 0 || limit > SUBJEKTIV_QUERY_MAX_LIMIT { - return Err(Error::InvalidInput(format!( - "limit must be within 1..={SUBJEKTIV_QUERY_MAX_LIMIT}" - )) - .into()); - } - Ok(limit) -} - -fn api_memory_state(state: crate::subjektiv::MemoryState) -> server_api::SubjektivMemoryState { - match state { - crate::subjektiv::MemoryState::Active => server_api::SubjektivMemoryState::Active, - crate::subjektiv::MemoryState::Resolved => server_api::SubjektivMemoryState::Resolved, - crate::subjektiv::MemoryState::Retracted => server_api::SubjektivMemoryState::Retracted, - } -} - -fn subjektiv_memory_state_name(state: crate::subjektiv::MemoryState) -> &'static str { - match state { - crate::subjektiv::MemoryState::Active => "active", - crate::subjektiv::MemoryState::Resolved => "resolved", - crate::subjektiv::MemoryState::Retracted => "retracted", - } -} - -fn domain_proposal_intent( - intent: server_api::SubjektivMemoryRevisionIntent, -) -> crate::subjektiv::RevisionProposalIntent { - match intent { - server_api::SubjektivMemoryRevisionIntent::Revise => { - crate::subjektiv::RevisionProposalIntent::Revise - } - server_api::SubjektivMemoryRevisionIntent::Resolve => { - crate::subjektiv::RevisionProposalIntent::Resolve - } - server_api::SubjektivMemoryRevisionIntent::Retract => { - crate::subjektiv::RevisionProposalIntent::Retract - } - server_api::SubjektivMemoryRevisionIntent::Reopen => { - crate::subjektiv::RevisionProposalIntent::Reopen - } - } -} - -fn api_proposal_intent( - intent: crate::subjektiv::RevisionProposalIntent, -) -> server_api::SubjektivMemoryRevisionIntent { - match intent { - crate::subjektiv::RevisionProposalIntent::Revise => { - server_api::SubjektivMemoryRevisionIntent::Revise - } - crate::subjektiv::RevisionProposalIntent::Resolve => { - server_api::SubjektivMemoryRevisionIntent::Resolve - } - crate::subjektiv::RevisionProposalIntent::Retract => { - server_api::SubjektivMemoryRevisionIntent::Retract - } - crate::subjektiv::RevisionProposalIntent::Reopen => { - server_api::SubjektivMemoryRevisionIntent::Reopen - } - } -} - -fn fit_subjektiv_read_body_to_output_budget( - response: &mut server_api::SubjektivMemoryReadResponse, - lines: &[&str], - line_offset: usize, - byte_offset: usize, - line_limit: usize, -) -> ApiResult<()> { - let serialized_len = |response: &server_api::SubjektivMemoryReadResponse| { - serde_json::to_vec_pretty(response) - .map(|value| value.len()) - .map_err(|error| Error::Store(format!("encode subjektiv Memory read: {error}"))) - }; - if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { - return Ok(()); - } - - let initial_body_len = response.body_md.len(); - let mut lower = 0usize; - let mut upper = initial_body_len; - while lower < upper { - let candidate_limit = lower + (upper - lower).div_ceil(2); - set_subjektiv_read_body_page( - response, - lines, - line_offset, - byte_offset, - line_limit, - candidate_limit, - ); - if serialized_len(response)? <= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { - lower = candidate_limit; - } else { - upper = candidate_limit - 1; - } - } - set_subjektiv_read_body_page(response, lines, line_offset, byte_offset, line_limit, lower); - let final_len = serialized_len(response)?; - if final_len > server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES { - return Err(Error::Store(format!( - "subjektiv Memory metadata/provenance requires {final_len} bytes and exceeds the {} byte read-output budget", - server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES - )) - .into()); - } - if initial_body_len > 0 && response.body_md.is_empty() { - return Err(Error::Store( - "subjektiv Memory metadata/provenance leaves no room for resumable body content".into(), - ) - .into()); - } - Ok(()) -} - -fn set_subjektiv_read_body_page( - response: &mut server_api::SubjektivMemoryReadResponse, - lines: &[&str], - line_offset: usize, - byte_offset: usize, - line_limit: usize, - byte_limit: usize, -) { - let (body_md, body_next) = - bounded_memory_body_page(lines, line_offset, byte_offset, line_limit, byte_limit); - response.body_md = body_md; - response.body_truncated = body_next.is_some(); - (response.body_next_offset, response.body_next_byte_offset) = body_next - .map(|(line, byte)| (Some(line), Some(byte))) - .unwrap_or((None, None)); -} - -fn bounded_memory_body_page( - lines: &[&str], - line_offset: usize, - byte_offset: usize, - line_limit: usize, - byte_limit: usize, -) -> (String, Option<(usize, usize)>) { - let line_end = line_offset.saturating_add(line_limit).min(lines.len()); - let mut line_index = line_offset; - let mut within_line = byte_offset; - let mut body = String::new(); - while line_index < line_end { - let line = lines[line_index]; - let remainder = &line[within_line..]; - let available = byte_limit.saturating_sub(body.len()); - if remainder.len() <= available { - body.push_str(remainder); - line_index += 1; - within_line = 0; - continue; - } - let mut take = available.min(remainder.len()); - while take > 0 && !remainder.is_char_boundary(take) { - take -= 1; - } - if take == 0 { - return (body, Some((line_index, within_line))); - } - body.push_str(&remainder[..take]); - within_line += take; - return (body, Some((line_index, within_line))); - } - if line_index < lines.len() { - (body, Some((line_index, 0))) - } else { - (body, None) - } -} - -fn memory_body_lines(body: &str) -> Vec<&str> { - if body.is_empty() { - Vec::new() - } else { - body.split_inclusive('\n').collect() - } -} - -fn bound_subjektiv_nested_evidence( - candidates: &[server_api::SubjektivMemoryEvidenceCandidate], -) -> ApiResult<()> { - let serialized = serde_json::to_vec(candidates) - .map_err(|error| Error::Store(format!("encode subjektiv evidence page: {error}")))?; - if serialized.len() <= SUBJEKTIV_NESTED_EVIDENCE_MAX_BYTES { - Ok(()) - } else { - Err(Error::Store( - "subjektiv evidence page exceeds its enforced response byte budget".into(), - ) - .into()) - } -} - -fn bounded_subjektiv_staging_evidence( - mut evidence: memory::extract::StagingEvidence, -) -> memory::extract::StagingEvidence { - evidence.kind = memory::schema::EvidenceKind::new(bounded_utf8_bytes( - evidence.kind.as_str().to_string(), - SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES, - )); - evidence.origin = evidence.origin.map(bounded_subjektiv_evidence_origin); - evidence.excerpt = evidence - .excerpt - .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); - evidence.summary = evidence - .summary - .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); - evidence -} - -fn bounded_subjektiv_source_ref( - mut source: memory::schema::SourceEvidenceRef, -) -> memory::schema::SourceEvidenceRef { - source.origin = source.origin.map(bounded_subjektiv_evidence_origin); - source.evidence_kind = source.evidence_kind.map(|kind| { - memory::schema::EvidenceKind::new(bounded_utf8_bytes( - kind.as_str().to_string(), - SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES, - )) - }); - source.label = source - .label - .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); - source.summary = source - .summary - .map(|value| bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); - source -} - -fn bounded_subjektiv_evidence_origin( - mut origin: memory::schema::EvidenceOrigin, -) -> memory::schema::EvidenceOrigin { - for field in [ - &mut origin.account_id, - &mut origin.workspace_id, - &mut origin.runtime_id, - &mut origin.worker_id, - &mut origin.flow_selector, - &mut origin.flow_definition_id, - ] { - if let Some(value) = field.take() { - *field = Some(bounded_utf8_bytes(value, SUBJEKTIV_ANCHOR_TEXT_MAX_BYTES)); - } - } - origin -} - -fn bounded_utf8_bytes(value: String, max_bytes: usize) -> String { - let value = value - .chars() - .map(|character| { - if character.is_control() || matches!(character, '"' | '\\') { - ' ' - } else { - character - } - }) - .collect::(); - if value.len() <= max_bytes { - return value; - } - let marker = "…"; - let mut end = max_bytes.saturating_sub(marker.len()); - while end > 0 && !value.is_char_boundary(end) { - end -= 1; - } - format!("{}{marker}", &value[..end]) -} - -fn bounded_memory_excerpt(body: &str, max_chars: usize) -> String { - let normalized = body.split_whitespace().collect::>().join(" "); - let mut chars = normalized.chars(); - let excerpt = chars.by_ref().take(max_chars).collect::(); - if chars.next().is_some() { - format!("{excerpt}…") - } else { - excerpt - } -} - -fn encode_subjektiv_cursor(kind: &str, value: &T) -> ApiResult { - let raw = serde_json::to_vec(value) - .map_err(|error| Error::Store(format!("encode subjektiv cursor: {error}")))?; - let encoded = raw - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - Ok(format!("subjektiv.{kind}.{encoded}")) -} - -fn decode_subjektiv_cursor( - kind: &str, - cursor: &str, -) -> ApiResult { - let prefix = format!("subjektiv.{kind}."); - let encoded = cursor - .strip_prefix(&prefix) - .ok_or_else(|| Error::InvalidInput(format!("invalid subjektiv {kind} cursor type")))?; - if encoded.is_empty() - || encoded.len() > 16_384 - || encoded.len() % 2 != 0 - || !encoded.is_ascii() - || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err(Error::InvalidInput(format!("invalid subjektiv {kind} cursor")).into()); - } - let mut raw = Vec::with_capacity(encoded.len() / 2); - for index in (0..encoded.len()).step_by(2) { - raw.push( - u8::from_str_radix(&encoded[index..index + 2], 16).map_err(|_| { - Error::InvalidInput(format!("invalid subjektiv {kind} cursor encoding")) - })?, - ); - } - serde_json::from_slice(&raw) - .map_err(|_| Error::InvalidInput(format!("invalid subjektiv {kind} cursor payload")).into()) + Ok(Json(subjektiv::api::execute( + &store, + &host_context, + request.operation, + )?)) } fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { @@ -21632,24 +20148,18 @@ async fn scoped_subjektiv_memory_consolidation( )) } -const SUBJECT_CONSOLIDATION_THRESHOLD_FILES: usize = 5; -const SUBJECT_CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000; - fn subject_consolidation_required( candidate_count: usize, total_bytes: u64, force: bool, surface_availability: crate::subjektiv::SurfaceAvailability, ) -> bool { - let surface_needs_generation = - surface_availability != crate::subjektiv::SurfaceAvailability::Ready; - if candidate_count == 0 { - return surface_needs_generation; - } - force - || surface_needs_generation - || candidate_count >= SUBJECT_CONSOLIDATION_THRESHOLD_FILES - || total_bytes >= SUBJECT_CONSOLIDATION_THRESHOLD_BYTES + subjektiv::job::consolidation_required( + candidate_count, + total_bytes, + force, + surface_availability, + ) } #[cfg(test)] @@ -21680,7 +20190,7 @@ mod subject_surface_consolidation_policy_tests { SurfaceAvailability::Ready )); assert!(subject_consolidation_required( - SUBJECT_CONSOLIDATION_THRESHOLD_FILES, + subjektiv::job::CONSOLIDATION_THRESHOLD_FILES, 1, false, SurfaceAvailability::Ready @@ -41716,7 +40226,7 @@ mod tests { }, ) .unwrap_err(); - let revision_api = ApiError::from(revision_conflict.error).into_repository_api_error(); + let revision_api = ApiError::from(revision_conflict).into_repository_api_error(); assert_eq!(revision_api.diagnostics[0].code, "revision_conflict"); let default_page = subjektiv_memory_query( @@ -41793,8 +40303,11 @@ mod tests { }, ) .unwrap_err(); - assert!(matches!(&stale.error, Error::RepositoryConflict(_))); - let stale_api = ApiError::from(stale.error).into_repository_api_error(); + assert!(matches!( + &stale, + subjektiv::api::OperationError::Conflict(_) + )); + let stale_api = ApiError::from(stale).into_repository_api_error(); assert_eq!(stale_api.diagnostics[0].code, "stale_cursor"); let initial_read = subjektiv_memory_read( @@ -41839,7 +40352,10 @@ mod tests { }, ) .unwrap_err(); - assert!(matches!(unbound_continuation.error, Error::InvalidInput(_))); + assert!(matches!( + unbound_continuation, + subjektiv::api::OperationError::InvalidInput(_) + )); let read = subjektiv_memory_read( &store, @@ -42219,12 +40735,18 @@ mod tests { legacy_evidence, legacy_refs, ); - let legacy_candidate = store - .stage_legacy_candidate_for_test(crate::subjektiv::SubjectStagingRecord::attach( - &subject.id, - legacy_candidate, - )) - .unwrap(); + let legacy_candidate = + crate::subjektiv::SubjectStagingRecord::attach(&subject.id, legacy_candidate); + // Seed an old compatible row directly: production admission intentionally + // rejects this historical over-limit shape. No shared bypass API is exposed. + api.feature_storage.open(&api.subjektiv_registration).unwrap() + .transaction(|transaction| { + transaction.execute( + "INSERT INTO staging_records (subject_id, candidate_id, kind, record_json, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![legacy_candidate.subject_id, legacy_candidate.id, legacy_candidate.kind.as_str(), serde_json::to_string(&legacy_candidate).unwrap(), legacy_candidate.created_at], + )?; + Ok(()) + }).unwrap(); let (legacy_memory, _) = store .apply_candidate( &subject.id, @@ -60470,3 +58992,23 @@ Objective body. .unwrap(); } } + +impl From for Error { + fn from(error: subjektiv::api::OperationError) -> Self { + use subjektiv::api::OperationError as Common; + let error = match error { + Common::InvalidInput(message) => Error::InvalidInput(message), + Common::Conflict(message) => Error::RepositoryConflict(message), + Common::PermissionDenied(message) => Error::WorkspacePermissionDenied(message), + Common::SubjectNotFound(id) => Error::SubjektivSubjectNotFound(id), + Common::Store(message) => Error::Store(message), + }; + error + } +} + +impl From for ApiError { + fn from(error: subjektiv::api::OperationError) -> Self { + Error::from(error).into() + } +} diff --git a/crates/workspace-server/src/server/subjektiv_job.rs b/crates/workspace-server/src/server/subjektiv_job.rs index 66317f7ca..f2a93075a 100644 --- a/crates/workspace-server/src/server/subjektiv_job.rs +++ b/crates/workspace-server/src/server/subjektiv_job.rs @@ -151,23 +151,7 @@ fn bounded_candidate_batch( ids: impl IntoIterator, max_result_bytes: u32, ) -> Result> { - // Reserve the bounded subject and Host surface envelope. Each candidate ID - // appears twice (input claims and verified outcome); account for JSON escaping, - // not raw UTF-8 bytes. The fixed per-item allowance covers action and framing. - let mut estimated_bytes = 2_048_usize; - let mut selected = Vec::new(); - for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) { - let id_bytes = serde_json::to_vec(&id) - .map_err(|e| Error::InvalidInput(e.to_string()))? - .len(); - let additional = 2 * id_bytes + 128; - if estimated_bytes + additional > max_result_bytes as usize { - break; - } - estimated_bytes += additional; - selected.push(id); - } - Ok(selected) + subjektiv::job::bounded_candidate_batch(ids, max_result_bytes).map_err(Error::from) } pub(super) fn dispatch( @@ -234,25 +218,6 @@ pub(super) fn dispatch( }) } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ConsolidationResult { - subject_id: String, - candidate_ids: Vec, - surface: SurfaceResult, -} -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SurfaceResult { - availability: String, - generation_id: String, - store_revision: u64, - #[serde(default)] - snapshot_id: Option, - #[serde(default)] - reason_code: Option, -} - pub(super) fn validate_result( api: &WorkspaceApi, request: &BackendJobRequest, @@ -262,39 +227,18 @@ pub(super) fn validate_result( let Some(grant) = &request.grants.subjektiv_consolidation else { return Ok(()); }; - let result: ConsolidationResult = serde_json::from_value(value.clone()) - .map_err(|e| Error::InvalidInput(format!("invalid consolidation result: {e}")))?; - if result.subject_id != grant.subject_id || result.candidate_ids != grant.candidate_ids { - return Err(Error::InvalidInput( - "consolidation result does not match the immutable Subject/batch grant".into(), - )); - } let store = crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|e| Error::Store(e.to_string()))?; - for candidate_id in &grant.candidate_ids { - if store - .staging_resolution(&grant.subject_id, candidate_id) - .map_err(|e| Error::Store(e.to_string()))? - .is_none() - { - return Err(Error::InvalidInput(format!( - "consolidation candidate `{candidate_id}` is unresolved" - ))); - } - } - store - .validate_job_surface_outcome( - &grant.subject_id, - &result.surface.generation_id, - result.surface.store_revision, - &result.surface.availability, - result.surface.snapshot_id.as_deref(), - result.surface.reason_code.as_deref(), - &request.job_id, - &attempt.attempt_id, - ) - .map_err(|e| Error::InvalidInput(format!("consolidation surface result mismatch: {e}"))) + subjektiv::job::validate_result( + &store, + &grant.subject_id, + &grant.candidate_ids, + &request.job_id, + &attempt.attempt_id, + value, + ) + .map_err(Error::from) } /// Persist actual candidate dispositions alongside the separate surface outcome. @@ -313,28 +257,8 @@ pub(super) fn with_dispositions( let store = crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|e| Error::Store(e.to_string()))?; - let mut dispositions = Vec::new(); - for id in &grant.candidate_ids { - let resolution = store - .staging_resolution(&grant.subject_id, id) - .map_err(|e| Error::Store(e.to_string()))? - .ok_or_else(|| { - Error::InvalidInput(format!("candidate `{id}` has no durable disposition")) - })?; - // Full reasons, affected revisions and source edges remain in the existing - // immutable receipt store. Duplicating them makes a valid bounded batch's - // completion unbounded; this result is its verified compact projection. - dispositions.push(serde_json::json!({ "candidate_id": id, "action": resolution.action })); - } - let mut enriched = value.clone(); - enriched - .as_object_mut() - .ok_or_else(|| Error::InvalidInput("consolidation result must be an object".into()))? - .insert( - "candidate_dispositions".into(), - serde_json::Value::Array(dispositions), - ); - Ok(enriched) + subjektiv::job::with_dispositions(&store, &grant.subject_id, &grant.candidate_ids, value) + .map_err(Error::from) } #[cfg(test)] diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 83d4cfe63..5335ffbf3 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -1,6187 +1,2 @@ -//! Subject-scoped, revisioned Memory storage for the `subjektiv` Feature. -//! -//! This module owns subjektiv's domain schema and typed repository. Physical -//! SQLite placement, connection configuration, migration execution, backup, -//! restore, and teardown remain owned by [`crate::feature_storage`]. A store is -//! already bound to one Workspace; a subject identifier is therefore a lookup -//! key, not an authorization credential. - -use std::collections::HashSet; - -use chrono::{SecondsFormat, Utc}; -use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord}; -use memory::schema::{SourceEvidenceRef, SourceRef}; -use rusqlite::{Connection, OptionalExtension, Transaction, params}; -use serde::{Deserialize, Deserializer, Serialize}; -use uuid::Uuid; - -use crate::feature_storage::{ - FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature, - WorkspaceFeatureStorage, -}; - -pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1; -pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv"; -pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:"; -pub const MAX_STAGING_ANCHORS: usize = 10; -/// Maximum UTF-8 size of the user-managed Subject behavior document. -pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES; -/// Deterministic surface-generation policy. Input estimates use the repository's -/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material -/// payload is capped below the whole-input budget to reserve room for the fixed -/// prompt and JSON framing. -pub const SURFACE_BODY_TOKEN_BUDGET: usize = 1_024; -pub const SURFACE_INPUT_TOKEN_BUDGET: usize = 12_000; -pub const SURFACE_MATERIAL_TOKEN_BUDGET: usize = 10_000; -pub const SURFACE_PER_KIND_LIMIT: usize = 8; -pub const SURFACE_TOTAL_MATERIAL_LIMIT: usize = 24; - -pub type Result = std::result::Result; - -#[derive(Debug, thiserror::Error)] -pub enum SubjektivError { - #[error(transparent)] - Storage(#[from] FeatureStorageError), - #[error("invalid subjektiv record: {0}")] - InvalidRecord(String), - #[error("subject `{0}` was not found in this Workspace")] - SubjectNotFound(String), - #[error("subject `{0}` is retired")] - SubjectRetired(String), - #[error("the selected Worker Profile does not enable subjektiv")] - ProfileDisabled, - #[error("staging candidate `{0}` was not found for this subject")] - CandidateNotFound(String), - #[error("staging candidate `{0}` already exists with different content")] - CandidateConflict(String), - #[error("staging candidate `{0}` is already resolved")] - CandidateResolved(String), - #[error("candidate decision request `{0}` was already used with different input")] - DecisionRequestConflict(String), - #[error( - "session `{session_id}` is already attributed to subject `{existing_subject_id}` and cannot be attributed to `{requested_subject_id}`" - )] - SessionSubjectConflict { - session_id: String, - existing_subject_id: String, - requested_subject_id: String, - }, - #[error("session `{0}` already has different historical attribution")] - SessionAttributionConflict(String), - #[error("memory `{0}` was not found for this subject")] - MemoryNotFound(String), - #[error("memory `{memory_id}` revision conflict: expected {expected}, current {actual}")] - RevisionConflict { - memory_id: String, - expected: u64, - actual: u64, - }, - #[error( - "subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}" - )] - SubjectBehaviorConflict { - subject_id: String, - expected: u64, - actual: u64, - }, - #[error("surface generation conflict: {0}")] - SurfaceGenerationConflict(String), - #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] - InvalidStateTransition { - memory_id: String, - from: MemoryState, - to: MemoryState, - }, - #[error("reference `{reference}` does not belong to subject `{subject_id}`")] - SubjectScopeMismatch { - subject_id: String, - reference: String, - }, - #[error("subjektiv JSON error: {0}")] - Json(#[from] serde_json::Error), -} - -impl From for SubjektivError { - fn from(error: rusqlite::Error) -> Self { - Self::Storage(FeatureStorageError::from(error)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(transparent)] -pub struct SubjectRole(String); - -impl SubjectRole { - pub fn new(value: impl Into) -> Result { - let value = value.into(); - validate_label("subject role", &value)?; - Ok(Self(value)) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl<'de> Deserialize<'de> for SubjectRole { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - SubjectRole::new(value).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SubjectState { - Active, - Retired, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SubjectRecord { - pub schema_version: u32, - pub id: String, - pub role: SubjectRole, - /// User-managed, host-injected behavior document. This is not Memory and - /// Memory lifecycle operations must preserve it byte-for-byte. - #[serde(default)] - pub behavior_md: String, - /// Monotonic CAS generation for behavior updates, independent of Memory's - /// `store_revision`. - #[serde(default)] - pub behavior_revision: u64, - pub state: SubjectState, - /// Monotonic generation incremented exactly once for every committed Memory - /// creation or revision for this subject. - pub store_revision: u64, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubjectPage { - pub items: Vec, - pub has_more: bool, -} - -/// Backend-authorized launch material for one active subject. The singleton key -/// is derived by subjektiv and then acquired through Yoi's generic singleton path. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthorizedSubjectWorkerAttachment { - pub singleton_key: String, -} - -pub fn subject_worker_singleton_key(subject_id: &str) -> Result { - validate_label("subject id", subject_id)?; - if subject_id.trim() != subject_id { - return Err(SubjektivError::InvalidRecord( - "subject id must not contain surrounding whitespace".to_string(), - )); - } - Ok(format!("{SUBJECT_WORKER_SINGLETON_PREFIX}{subject_id}")) -} - -/// Immutable host-recorded history that attributes one durable Session to the -/// subject and Worker that produced it. This is archival provenance only: it is -/// deliberately not a current-Worker link or execution ownership record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SubjectSessionAttribution { - pub schema_version: u32, - pub subject_id: String, - pub runtime_id: String, - pub worker_id: String, - pub session_id: String, - pub attributed_at: String, -} - -impl SubjectSessionAttribution { - pub fn new( - subject_id: impl Into, - runtime_id: impl Into, - worker_id: impl Into, - session_id: impl Into, - ) -> Result { - let record = Self { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - subject_id: subject_id.into(), - runtime_id: runtime_id.into(), - worker_id: worker_id.into(), - session_id: session_id.into(), - attributed_at: now(), - }; - validate_session_attribution(&record)?; - Ok(record) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubjectSessionAttributionPage { - pub items: Vec, - pub has_more: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RevisionProposalIntent { - Revise, - Resolve, - Retract, - Reopen, -} - -/// Host-owned metadata describing how a staged candidate is expected to change -/// one existing Memory. It is validated against the current scoped Memory when -/// the candidate is staged; it is not model-authored and does not itself apply -/// the revision. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct RevisionProposal { - pub intent: RevisionProposalIntent, - pub memory_id: String, - pub expected_revision: u64, - pub change_reason: String, -} - -impl RevisionProposal { - pub fn new( - intent: RevisionProposalIntent, - memory_id: impl Into, - expected_revision: u64, - change_reason: impl Into, - ) -> Result { - let proposal = Self { - intent, - memory_id: memory_id.into(), - expected_revision, - change_reason: change_reason.into(), - }; - validate_revision_proposal_metadata(&proposal)?; - Ok(proposal) - } -} - -impl<'de> Deserialize<'de> for RevisionProposal { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct WireProposal { - intent: RevisionProposalIntent, - memory_id: String, - expected_revision: u64, - change_reason: String, - } - - let proposal = WireProposal::deserialize(deserializer)?; - RevisionProposal::new( - proposal.intent, - proposal.memory_id, - proposal.expected_revision, - proposal.change_reason, - ) - .map_err(serde::de::Error::custom) - } -} - -/// The existing extraction schema with only host-owned subject scope, -/// persistence time, and optional revision proposal metadata added. Model input -/// still contains none of those fields. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SubjectStagingRecord { - pub schema_version: u32, - pub id: String, - pub subject_id: String, - pub extract_run_id: String, - pub source: SourceRef, - pub kind: CandidateKind, - pub claim: String, - pub why_useful: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub staleness: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub evidence: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub source_refs: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub revision_proposal: Option, - pub created_at: String, -} - -impl SubjectStagingRecord { - /// Attaches host-resolved subject scope and time to an existing v2 staging - /// record without changing its taxonomy, evidence, or source anchors. - pub fn attach(subject_id: impl Into, record: StagingRecord) -> Self { - Self::attach_at(subject_id, record, now()) - } - - fn attach_at(subject_id: impl Into, record: StagingRecord, created_at: String) -> Self { - Self { - schema_version: record.schema_version, - id: record.id, - subject_id: subject_id.into(), - extract_run_id: record.extract_run_id, - source: record.source, - kind: record.kind, - claim: record.claim, - why_useful: record.why_useful, - staleness: record.staleness, - evidence: record.evidence, - source_refs: record.source_refs, - revision_proposal: None, - created_at, - } - } - - /// Attaches host-owned revision proposal metadata to this candidate. - pub fn with_revision_proposal(mut self, proposal: RevisionProposal) -> Result { - validate_revision_proposal_metadata(&proposal)?; - self.revision_proposal = Some(proposal); - Ok(self) - } - - /// Mutably attaches host-owned revision proposal metadata to this candidate. - pub fn attach_revision_proposal(&mut self, proposal: RevisionProposal) -> Result<()> { - validate_revision_proposal_metadata(&proposal)?; - self.revision_proposal = Some(proposal); - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MemoryState { - /// Current and eligible for ordinary recall. This does not assert truth and - /// grants no authority. - Active, - /// No longer current because its question, condition, or applicability has - /// ended. A later revision may reopen it when circumstances change. - Resolved, - /// Invalidated and no longer eligible for recall. Retraction is terminal; - /// corrected follow-up experience is a separate Memory linked by derivation. - Retracted, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MemoryRevisionRef { - pub memory_id: String, - pub revision: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MemoryRecord { - pub schema_version: u32, - pub id: String, - pub subject_id: String, - pub revision: u64, - pub kind: CandidateKind, - pub state: MemoryState, - pub claim: String, - pub body_md: String, - pub why_useful: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub staleness: Option, - #[serde(default)] - pub source_candidate_ids: Vec, - #[serde(default)] - pub derived_from: Vec, - pub change_reason: String, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MemoryDraft { - pub kind: CandidateKind, - pub state: MemoryState, - pub claim: String, - pub body_md: String, - pub why_useful: String, - pub staleness: Option, - pub source_candidate_ids: Vec, - pub derived_from: Vec, - pub change_reason: String, -} - -impl MemoryDraft { - pub fn active( - kind: CandidateKind, - claim: impl Into, - body_md: impl Into, - why_useful: impl Into, - change_reason: impl Into, - ) -> Self { - Self { - kind, - state: MemoryState::Active, - claim: claim.into(), - body_md: body_md.into(), - why_useful: why_useful.into(), - staleness: None, - source_candidate_ids: Vec::new(), - derived_from: Vec::new(), - change_reason: change_reason.into(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StagingResolutionAction { - Applied, - Discarded, - Invalid, - Duplicate, - AlreadyCovered, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StagingResolution { - pub schema_version: u32, - pub id: String, - pub subject_id: String, - pub candidate_id: String, - pub action: StagingResolutionAction, - pub reason: String, - #[serde(default)] - pub affected_memory: Vec, - /// Immutable copy of the candidate as it was staged. The database also - /// retains the exact serialized bytes independently of this projection. - pub candidate: SubjectStagingRecord, - pub resolved_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SurfaceSnapshot { - pub schema_version: u32, - pub id: String, - pub subject_id: String, - pub body_md: String, - #[serde(default)] - pub memory_refs: Vec, - pub built_from_store_revision: u64, - pub created_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SurfaceMaterial { - pub memory_id: String, - pub revision: u64, - pub kind: CandidateKind, - pub body_md: String, - pub why_useful: String, - pub staleness: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SurfaceGeneration { - pub id: String, - pub subject_id: String, - pub store_revision: u64, - pub active_memory_count: usize, - pub materials: Vec, - pub created_at: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SurfacePoint { - pub body_md: String, - pub memory_refs: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SurfaceAvailability { - Ungenerated, - Stale, - Failed, - Ready, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResidentSurface { - pub availability: SurfaceAvailability, - pub snapshot: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SubjectResidentContext { - pub subject: SubjectRecord, - pub surface: ResidentSurface, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum MemoryRevisionTarget { - Create, - Revise { - memory_id: String, - expected_revision: u64, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MemoryDecisionOperation { - Create, - Revise, - Reference, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum CandidateDecision { - Apply { - target: MemoryRevisionTarget, - draft: MemoryDraft, - }, - Close { - action: StagingResolutionAction, - #[serde(default)] - affected_memory: Vec, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CandidateDecisionRequest { - pub request_id: String, - pub candidate_id: String, - pub reason: String, - pub decision: CandidateDecision, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CandidateDecisionReceipt { - pub request_id: String, - pub candidate_id: String, - pub resolution: StagingResolution, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub operation: Option, - pub store_revision: u64, - pub surface_dirty: bool, -} - -fn create_schema(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE store_scope ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - workspace_id TEXT NOT NULL UNIQUE -); - -CREATE TABLE subjects ( - subject_id TEXT PRIMARY KEY, - role TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('active', 'retired')), - store_revision INTEGER NOT NULL CHECK (store_revision >= 0), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE staging_records ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - kind TEXT NOT NULL CHECK ( - kind IN ( - 'preference', 'working_assumption', 'constraint', - 'decision', 'open_question', 'lesson' - ) - ), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, candidate_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE TABLE memory_records ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - current_revision INTEGER NOT NULL CHECK (current_revision > 0), - kind TEXT NOT NULL CHECK ( - kind IN ( - 'preference', 'working_assumption', 'constraint', - 'decision', 'open_question', 'lesson' - ) - ), - state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (subject_id, memory_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, memory_id, current_revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) - DEFERRABLE INITIALLY DEFERRED -); - -CREATE TABLE memory_revisions ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL CHECK (revision > 0), - kind TEXT NOT NULL CHECK ( - kind IN ( - 'preference', 'working_assumption', 'constraint', - 'decision', 'open_question', 'lesson' - ) - ), - state TEXT NOT NULL CHECK (state IN ('active', 'resolved', 'retracted')), - record_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, memory_id, revision), - FOREIGN KEY (subject_id, memory_id) - REFERENCES memory_records(subject_id, memory_id) - ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED -); - -CREATE TABLE memory_revision_candidates ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - candidate_id TEXT NOT NULL, - PRIMARY KEY (subject_id, memory_id, revision, candidate_id), - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TABLE memory_revision_derivations ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - source_memory_id TEXT NOT NULL, - source_revision INTEGER NOT NULL, - PRIMARY KEY ( - subject_id, memory_id, revision, source_memory_id, source_revision - ), - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, source_memory_id, source_revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE staging_resolutions ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - resolution_id TEXT NOT NULL, - action TEXT NOT NULL CHECK ( - action IN ('applied', 'discarded', 'invalid', 'duplicate', 'already_covered') - ), - reason TEXT NOT NULL, - affected_refs_json TEXT NOT NULL, - staging_raw_json TEXT NOT NULL, - resolution_json TEXT NOT NULL, - resolved_at TEXT NOT NULL, - PRIMARY KEY (subject_id, candidate_id), - UNIQUE (resolution_id), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_records(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TABLE staging_resolution_targets ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - PRIMARY KEY (subject_id, candidate_id, memory_id, revision), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE surface_snapshots ( - subject_id TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - built_from_store_revision INTEGER NOT NULL CHECK (built_from_store_revision >= 0), - snapshot_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, snapshot_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE TABLE surface_snapshot_refs ( - subject_id TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - PRIMARY KEY (subject_id, snapshot_id, memory_id), - FOREIGN KEY (subject_id, snapshot_id) - REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE memory_revision_seals ( - subject_id TEXT NOT NULL, - memory_id TEXT NOT NULL, - revision INTEGER NOT NULL, - PRIMARY KEY (subject_id, memory_id, revision), - FOREIGN KEY (subject_id, memory_id, revision) - REFERENCES memory_revisions(subject_id, memory_id, revision) ON DELETE RESTRICT -); - -CREATE TABLE staging_resolution_seals ( - subject_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - PRIMARY KEY (subject_id, candidate_id), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TABLE surface_snapshot_seals ( - subject_id TEXT NOT NULL, - snapshot_id TEXT NOT NULL, - PRIMARY KEY (subject_id, snapshot_id), - FOREIGN KEY (subject_id, snapshot_id) - REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT -); - -CREATE TRIGGER memory_revision_candidates_no_late_insert -BEFORE INSERT ON memory_revision_candidates -WHEN EXISTS ( - SELECT 1 FROM memory_revision_seals - WHERE subject_id = NEW.subject_id - AND memory_id = NEW.memory_id - AND revision = NEW.revision -) BEGIN - SELECT RAISE(ABORT, 'subjektiv revision evidence is sealed'); -END; -CREATE TRIGGER memory_revision_derivations_no_late_insert -BEFORE INSERT ON memory_revision_derivations -WHEN EXISTS ( - SELECT 1 FROM memory_revision_seals - WHERE subject_id = NEW.subject_id - AND memory_id = NEW.memory_id - AND revision = NEW.revision -) BEGIN - SELECT RAISE(ABORT, 'subjektiv derivations are sealed'); -END; -CREATE TRIGGER staging_resolution_targets_no_late_insert -BEFORE INSERT ON staging_resolution_targets -WHEN EXISTS ( - SELECT 1 FROM staging_resolution_seals - WHERE subject_id = NEW.subject_id - AND candidate_id = NEW.candidate_id -) BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution targets are sealed'); -END; -CREATE TRIGGER surface_snapshot_refs_no_late_insert -BEFORE INSERT ON surface_snapshot_refs -WHEN EXISTS ( - SELECT 1 FROM surface_snapshot_seals - WHERE subject_id = NEW.subject_id - AND snapshot_id = NEW.snapshot_id -) BEGIN - SELECT RAISE(ABORT, 'subjektiv surface references are sealed'); -END; - -CREATE TRIGGER memory_revision_seals_no_update -BEFORE UPDATE ON memory_revision_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv revision seals are immutable'); -END; -CREATE TRIGGER memory_revision_seals_no_delete -BEFORE DELETE ON memory_revision_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv revision seals are retained'); -END; -CREATE TRIGGER staging_resolution_seals_no_update -BEFORE UPDATE ON staging_resolution_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution seals are immutable'); -END; -CREATE TRIGGER staging_resolution_seals_no_delete -BEFORE DELETE ON staging_resolution_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution seals are retained'); -END; -CREATE TRIGGER surface_snapshot_seals_no_update -BEFORE UPDATE ON surface_snapshot_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv snapshot seals are immutable'); -END; -CREATE TRIGGER surface_snapshot_seals_no_delete -BEFORE DELETE ON surface_snapshot_seals BEGIN - SELECT RAISE(ABORT, 'subjektiv snapshot seals are retained'); -END; - -CREATE TRIGGER memory_revisions_no_update -BEFORE UPDATE ON memory_revisions BEGIN - SELECT RAISE(ABORT, 'subjektiv memory revisions are immutable'); -END; -CREATE TRIGGER memory_revisions_no_delete -BEFORE DELETE ON memory_revisions BEGIN - SELECT RAISE(ABORT, 'subjektiv memory revisions are retained'); -END; -CREATE TRIGGER memory_revision_candidates_no_update -BEFORE UPDATE ON memory_revision_candidates BEGIN - SELECT RAISE(ABORT, 'subjektiv revision evidence is immutable'); -END; -CREATE TRIGGER memory_revision_candidates_no_delete -BEFORE DELETE ON memory_revision_candidates BEGIN - SELECT RAISE(ABORT, 'subjektiv revision evidence is retained'); -END; -CREATE TRIGGER memory_revision_derivations_no_update -BEFORE UPDATE ON memory_revision_derivations BEGIN - SELECT RAISE(ABORT, 'subjektiv derivations are immutable'); -END; -CREATE TRIGGER memory_revision_derivations_no_delete -BEFORE DELETE ON memory_revision_derivations BEGIN - SELECT RAISE(ABORT, 'subjektiv derivations are retained'); -END; -CREATE TRIGGER staging_records_no_update -BEFORE UPDATE ON staging_records BEGIN - SELECT RAISE(ABORT, 'subjektiv staging records are immutable'); -END; -CREATE TRIGGER staging_records_no_delete -BEFORE DELETE ON staging_records BEGIN - SELECT RAISE(ABORT, 'subjektiv staging records are retained'); -END; -CREATE TRIGGER staging_resolutions_no_update -BEFORE UPDATE ON staging_resolutions BEGIN - SELECT RAISE(ABORT, 'subjektiv staging resolutions are immutable'); -END; -CREATE TRIGGER staging_resolutions_no_delete -BEFORE DELETE ON staging_resolutions BEGIN - SELECT RAISE(ABORT, 'subjektiv staging resolutions are retained'); -END; -CREATE TRIGGER staging_resolution_targets_no_update -BEFORE UPDATE ON staging_resolution_targets BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution targets are immutable'); -END; -CREATE TRIGGER staging_resolution_targets_no_delete -BEFORE DELETE ON staging_resolution_targets BEGIN - SELECT RAISE(ABORT, 'subjektiv resolution targets are retained'); -END; -CREATE TRIGGER surface_snapshots_no_update -BEFORE UPDATE ON surface_snapshots BEGIN - SELECT RAISE(ABORT, 'subjektiv surface snapshots are immutable'); -END; -CREATE TRIGGER surface_snapshots_no_delete -BEFORE DELETE ON surface_snapshots BEGIN - SELECT RAISE(ABORT, 'subjektiv surface snapshots are retained'); -END; -CREATE TRIGGER surface_snapshot_refs_no_update -BEFORE UPDATE ON surface_snapshot_refs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface references are immutable'); -END; -CREATE TRIGGER surface_snapshot_refs_no_delete -BEFORE DELETE ON surface_snapshot_refs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface references are retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_subject_session_attribution( - transaction: &Transaction<'_>, -) -> crate::feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE subject_session_attributions ( - session_id TEXT PRIMARY KEY, - subject_id TEXT NOT NULL, - runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, - record_json TEXT NOT NULL, - attributed_at TEXT NOT NULL, - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE INDEX subject_session_attributions_by_subject -ON subject_session_attributions(subject_id, attributed_at, session_id); - -CREATE TRIGGER subject_session_attributions_no_update -BEFORE UPDATE ON subject_session_attributions BEGIN - SELECT RAISE(ABORT, 'subjektiv session attribution is immutable'); -END; -CREATE TRIGGER subject_session_attributions_no_delete -BEFORE DELETE ON subject_session_attributions BEGIN - SELECT RAISE(ABORT, 'subjektiv session attribution is retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_candidate_decision_receipts( - transaction: &Transaction<'_>, -) -> crate::feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE candidate_decision_receipts ( - subject_id TEXT NOT NULL, - request_id TEXT NOT NULL, - candidate_id TEXT NOT NULL, - request_json TEXT NOT NULL, - receipt_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, request_id), - UNIQUE (subject_id, candidate_id), - FOREIGN KEY (subject_id, candidate_id) - REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT -); - -CREATE TRIGGER candidate_decision_receipts_no_update -BEFORE UPDATE ON candidate_decision_receipts BEGIN - SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable'); -END; -CREATE TRIGGER candidate_decision_receipts_no_delete -BEFORE DELETE ON candidate_decision_receipts BEGIN - SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_surface_generation_state( - transaction: &Transaction<'_>, -) -> crate::feature_storage::Result<()> { - transaction.execute_batch( - r#" -CREATE TABLE surface_generation_state ( - subject_id TEXT PRIMARY KEY, - store_revision INTEGER NOT NULL CHECK (store_revision >= 0), - status TEXT NOT NULL CHECK (status IN ('dirty', 'failed', 'ready')), - snapshot_id TEXT, - reason_code TEXT, - updated_at TEXT NOT NULL, - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT, - FOREIGN KEY (subject_id, snapshot_id) - REFERENCES surface_snapshots(subject_id, snapshot_id) ON DELETE RESTRICT -); - -INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at -) -SELECT subjects.subject_id, - subjects.store_revision, - 'dirty', - NULL, - 'legacy_snapshot_requires_regeneration', - subjects.updated_at -FROM subjects -WHERE EXISTS ( - SELECT 1 FROM surface_snapshots any_snapshot - WHERE any_snapshot.subject_id = subjects.subject_id -); - -CREATE TABLE surface_generation_runs ( - subject_id TEXT NOT NULL, - generation_id TEXT NOT NULL, - store_revision INTEGER NOT NULL CHECK (store_revision >= 0), - active_memory_count INTEGER NOT NULL CHECK (active_memory_count >= 0), - materials_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (subject_id, generation_id), - FOREIGN KEY (subject_id) REFERENCES subjects(subject_id) ON DELETE RESTRICT -); - -CREATE INDEX surface_generation_runs_by_revision -ON surface_generation_runs(subject_id, store_revision, created_at, generation_id); - -CREATE TRIGGER surface_generation_runs_no_update -BEFORE UPDATE ON surface_generation_runs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface generation inputs are immutable'); -END; -CREATE TRIGGER surface_generation_runs_no_delete -BEFORE DELETE ON surface_generation_runs BEGIN - SELECT RAISE(ABORT, 'subjektiv surface generation inputs are retained'); -END; -"#, - )?; - Ok(()) -} - -fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { - transaction.execute_batch( - r#" -ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT ''; -ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0 - CHECK (behavior_revision >= 0); -"#, - )?; - Ok(()) -} - -fn add_surface_job_provenance(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> { - transaction.execute_batch("ALTER TABLE surface_generation_runs ADD COLUMN job_id TEXT; ALTER TABLE surface_generation_runs ADD COLUMN attempt_id TEXT;")?; - Ok(()) -} - -static MIGRATIONS: &[FeatureMigration] = &[ - FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), - FeatureMigration::new( - 2, - "add immutable subject session attribution", - add_subject_session_attribution, - ), - FeatureMigration::new( - 3, - "add idempotent atomic candidate decision receipts", - add_candidate_decision_receipts, - ), - FeatureMigration::new( - 4, - "add bounded surface generation state and runs", - add_surface_generation_state, - ), - FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior), - FeatureMigration::new( - 6, - "bind surface generation provenance to Job attempts", - add_surface_job_provenance, - ), -]; - -pub const REGISTRATION: FeatureRegistration = - FeatureRegistration::new(SUBJEKTIV_FEATURE_ID, MIGRATIONS); - -#[derive(Clone)] -pub struct SubjektivStore { - database: FeatureDatabase, -} - -impl SubjektivStore { - /// Registers the trusted subjektiv schema with the Server storage manager. - /// Registration is intentionally separate so one registration can be reused - /// by every Workspace served by that manager. - pub fn register(storage: &WorkspaceFeatureStorage) -> Result { - storage.register(REGISTRATION).map_err(Into::into) - } - - pub fn open( - storage: &WorkspaceFeatureStorage, - registration: &RegisteredFeature, - ) -> Result { - if registration.feature_id() != SUBJEKTIV_FEATURE_ID { - return Err(SubjektivError::InvalidRecord(format!( - "registration is for Feature `{}` rather than `{SUBJEKTIV_FEATURE_ID}`", - registration.feature_id() - ))); - } - let database = storage.open(registration)?; - let workspace_id = database.workspace_id().to_string(); - database.try_transaction::<_, SubjektivError>(|transaction| { - let stored = transaction - .query_row( - "SELECT workspace_id FROM store_scope WHERE singleton = 1", - [], - |row| row.get::<_, String>(0), - ) - .optional()?; - match stored { - Some(stored) if stored != workspace_id => { - Err(SubjektivError::SubjectScopeMismatch { - subject_id: "".to_string(), - reference: format!( - "database is bound to Workspace `{stored}`, not `{workspace_id}`" - ), - }) - } - Some(_) => Ok(()), - None => { - transaction.execute( - "INSERT INTO store_scope (singleton, workspace_id) VALUES (1, ?1)", - [&workspace_id], - )?; - Ok(()) - } - } - })?; - Ok(Self { database }) - } - - pub fn workspace_id(&self) -> &str { - self.database.workspace_id() - } - - /// Issues a random host-owned subject id. It is never derived from Worker, - /// Profile, Session, or Memory content. - pub fn create_subject(&self, role: SubjectRole) -> Result { - self.create_subject_with_behavior(role, String::new()) - } - - /// Creates a Subject with its optional user-managed behavior document. - pub fn create_subject_with_behavior( - &self, - role: SubjectRole, - behavior_md: String, - ) -> Result { - validate_subject_behavior(&behavior_md)?; - let timestamp = now(); - let record = SubjectRecord { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: issued_id("subject"), - role, - behavior_md, - behavior_revision: 0, - state: SubjectState::Active, - store_revision: 0, - created_at: timestamp.clone(), - updated_at: timestamp, - }; - let raw = serde_json::to_string(&record)?; - self.database.try_transaction(|transaction| { - transaction.execute( - "INSERT INTO subjects ( - subject_id, role, behavior_md, behavior_revision, state, - store_revision, record_json, created_at, updated_at - ) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)", - params![ - record.id, - record.role.as_str(), - record.behavior_md, - raw, - record.created_at, - record.updated_at - ], - )?; - Ok(record) - }) - } - - pub fn subject(&self, subject_id: &str) -> Result> { - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT record_json FROM subjects WHERE subject_id = ?1", - [subject_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_subject(&raw)).transpose() - }) - } - - /// Replaces or clears the user-managed behavior document using its own CAS - /// generation. Memory revisions do not participate in this generation. - pub fn update_subject_behavior( - &self, - subject_id: &str, - expected_behavior_revision: u64, - behavior_md: String, - ) -> Result { - validate_subject_behavior(&behavior_md)?; - self.database.try_transaction(|transaction| { - let mut subject = require_subject(transaction, subject_id)?; - if subject.behavior_revision != expected_behavior_revision { - return Err(SubjektivError::SubjectBehaviorConflict { - subject_id: subject_id.to_string(), - expected: expected_behavior_revision, - actual: subject.behavior_revision, - }); - } - if subject.behavior_md == behavior_md { - return Ok(subject); - } - subject.behavior_md = behavior_md; - subject.behavior_revision = - subject.behavior_revision.checked_add(1).ok_or_else(|| { - SubjektivError::InvalidRecord("subject behavior revision overflow".to_string()) - })?; - subject.updated_at = now(); - let raw = serde_json::to_string(&subject)?; - transaction.execute( - "UPDATE subjects - SET behavior_md = ?2, behavior_revision = ?3, - record_json = ?4, updated_at = ?5 - WHERE subject_id = ?1", - params![ - subject.id, - subject.behavior_md, - to_i64(subject.behavior_revision)?, - raw, - subject.updated_at - ], - )?; - Ok(subject) - }) - } - - /// Validates an explicit product launch connection against this Workspace's - /// subject store and the already-resolved Profile policy. Subject knowledge - /// alone is insufficient: only an active local record produces host launch material. - pub fn authorize_worker_attachment( - &self, - subject_id: &str, - profile: &manifest::WorkerManifest, - ) -> Result { - if !profile.feature.subjektiv.profile.enabled { - return Err(SubjektivError::ProfileDisabled); - } - let subject = self - .subject(subject_id)? - .ok_or_else(|| SubjektivError::SubjectNotFound(subject_id.to_string()))?; - if subject.state != SubjectState::Active { - return Err(SubjektivError::SubjectRetired(subject_id.to_string())); - } - Ok(AuthorizedSubjectWorkerAttachment { - singleton_key: subject_worker_singleton_key(&subject.id)?, - }) - } - - /// Lists the first deterministic bounded page of subjects. Ordering uses - /// immutable creation time with the host-issued subject id as a total-order - /// tie breaker. - pub fn list_subjects(&self, limit: usize) -> Result { - self.list_subjects_after(limit, None) - } - - /// Lists a deterministic bounded keyset page after an exact `(created_at, - /// subject_id)` cursor. The cursor fields are wrapped by the public API in - /// an opaque Workspace-bound token. - pub fn list_subjects_after( - &self, - limit: usize, - after: Option<(&str, &str)>, - ) -> Result { - if !(1..=100).contains(&limit) { - return Err(SubjektivError::InvalidRecord( - "subject list limit must be between 1 and 100".to_string(), - )); - } - let query_limit = limit.saturating_add(1); - self.database.try_with_connection(|connection| { - let sql = if after.is_some() { - "SELECT record_json FROM subjects - WHERE created_at > ?1 OR (created_at = ?1 AND subject_id > ?2) - ORDER BY created_at ASC, subject_id ASC - LIMIT ?3" - } else { - "SELECT record_json FROM subjects - ORDER BY created_at ASC, subject_id ASC - LIMIT ?1" - }; - let mut statement = connection.prepare(sql)?; - let mut items = Vec::new(); - if let Some((created_at, subject_id)) = after { - let rows = statement.query_map( - params![created_at, subject_id, to_i64(query_limit as u64)?], - |row| row.get::<_, String>(0), - )?; - for row in rows { - items.push(parse_subject(&row?)?); - } - } else { - let rows = statement - .query_map([to_i64(query_limit as u64)?], |row| row.get::<_, String>(0))?; - for row in rows { - items.push(parse_subject(&row?)?); - } - } - let has_more = items.len() > limit; - if has_more { - items.truncate(limit); - } - Ok(SubjectPage { items, has_more }) - }) - } - - /// Subject retirement is distinct from stopping any current Worker. It does - /// not edit or delete Memory and cannot be reversed by this baseline schema. - pub fn retire_subject(&self, subject_id: &str) -> Result { - self.database.try_transaction(|transaction| { - let mut subject = require_subject(transaction, subject_id)?; - if subject.state == SubjectState::Retired { - return Ok(subject); - } - subject.state = SubjectState::Retired; - subject.updated_at = now(); - let raw = serde_json::to_string(&subject)?; - transaction.execute( - "UPDATE subjects - SET state = 'retired', record_json = ?2, updated_at = ?3 - WHERE subject_id = ?1", - params![subject_id, raw, subject.updated_at], - )?; - Ok(subject) - }) - } - - pub fn stage_candidate(&self, record: SubjectStagingRecord) -> Result { - validate_staging_record(self.workspace_id(), &record)?; - validate_staging_admission(&record)?; - let raw = serde_json::to_string(&record)?; - self.database.try_transaction(|transaction| { - require_active_subject(transaction, &record.subject_id)?; - write_staging_candidate(transaction, self.workspace_id(), record, raw) - }) - } - - /// Atomically verifies immutable Host attribution already completed at the - /// Session lifecycle boundary and stages its extracted candidate. Candidate - /// staging never creates or repairs Session attribution. - pub fn stage_candidate_with_attribution( - &self, - mut record: SubjectStagingRecord, - attribution: SubjectSessionAttribution, - ) -> Result<(SubjectStagingRecord, SubjectSessionAttribution)> { - validate_session_attribution(&attribution)?; - if record.subject_id != attribution.subject_id { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: record.subject_id, - reference: format!( - "session {} is attributed to subject {}", - attribution.session_id, attribution.subject_id - ), - }); - } - attach_session_to_candidate(&mut record, &attribution.session_id)?; - validate_staging_record(self.workspace_id(), &record)?; - validate_staging_admission(&record)?; - let candidate_raw = serde_json::to_string(&record)?; - - self.database.try_transaction(|transaction| { - require_active_subject(transaction, &record.subject_id)?; - let existing_raw = transaction - .query_row( - "SELECT record_json FROM subject_session_attributions WHERE session_id = ?1", - [&attribution.session_id], - |row| row.get::<_, String>(0), - ) - .optional()? - .ok_or_else(|| { - SubjektivError::InvalidRecord(format!( - "Session {} has not completed Host attribution", - attribution.session_id - )) - })?; - let existing = parse_session_attribution(&existing_raw)?; - if existing.subject_id != attribution.subject_id { - return Err(SubjektivError::SessionSubjectConflict { - session_id: attribution.session_id, - existing_subject_id: existing.subject_id, - requested_subject_id: attribution.subject_id, - }); - } - if !equivalent_session_attribution(&existing, &attribution) { - return Err(SubjektivError::SessionAttributionConflict( - attribution.session_id, - )); - } - let candidate = - write_staging_candidate(transaction, self.workspace_id(), record, candidate_raw)?; - Ok((candidate, existing)) - }) - } - - /// Inserts a structurally valid record using the pre-admission-limit path so - /// upgrade tests can prove that compatible historical rows stay readable. - #[cfg(test)] - pub(crate) fn stage_legacy_candidate_for_test( - &self, - record: SubjectStagingRecord, - ) -> Result { - validate_staging_record(self.workspace_id(), &record)?; - let raw = serde_json::to_string(&record)?; - self.database.try_transaction(|transaction| { - require_active_subject(transaction, &record.subject_id)?; - write_staging_candidate(transaction, self.workspace_id(), record, raw) - }) - } - - pub fn record_session_attribution( - &self, - attribution: SubjectSessionAttribution, - ) -> Result { - validate_session_attribution(&attribution)?; - let raw = serde_json::to_string(&attribution)?; - self.database.try_transaction(|transaction| { - require_active_subject(transaction, &attribution.subject_id)?; - write_session_attribution(transaction, attribution, raw) - }) - } - - pub fn session_attribution( - &self, - session_id: &str, - ) -> Result> { - validate_label("session id", session_id)?; - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT record_json FROM subject_session_attributions - WHERE session_id = ?1", - [session_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_session_attribution(&raw)).transpose() - }) - } - - pub fn subject_session_attributions( - &self, - subject_id: &str, - ) -> Result> { - validate_label("subject id", subject_id)?; - self.database.try_with_connection(|connection| { - let mut statement = connection.prepare( - "SELECT record_json FROM subject_session_attributions - WHERE subject_id = ?1 - ORDER BY attributed_at ASC, session_id ASC", - )?; - let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; - let mut records = Vec::new(); - for row in rows { - records.push(parse_session_attribution(&row?)?); - } - Ok(records) - }) - } - - /// Reads one bounded immutable attribution page without materializing the - /// subject's complete history. `snapshot_at` fixes the initial discovery - /// boundary; later Host attributions require a fresh traversal. - pub fn subject_session_attribution_page( - &self, - subject_id: &str, - session_id: Option<&str>, - snapshot_at: &str, - after: Option<(&str, &str)>, - limit: usize, - ) -> Result { - validate_label("subject id", subject_id)?; - validate_label("attribution snapshot", snapshot_at)?; - if let Some(session_id) = session_id { - validate_label("session id", session_id)?; - } - if let Some((attributed_at, session_id)) = after { - validate_label("attribution cursor timestamp", attributed_at)?; - validate_label("attribution cursor session id", session_id)?; - } - if !(1..=100).contains(&limit) { - return Err(SubjektivError::InvalidRecord( - "subject Session attribution page limit must be between 1 and 100".to_string(), - )); - } - let query_limit = limit.saturating_add(1); - self.database.try_with_connection(|connection| { - let raw = match (session_id, after) { - (Some(session_id), _) => { - let mut statement = connection.prepare( - "SELECT record_json FROM subject_session_attributions - WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3 - ORDER BY attributed_at DESC, session_id ASC - LIMIT ?4", - )?; - let rows = statement.query_map( - params![subject_id, session_id, snapshot_at, query_limit as i64], - |row| row.get::<_, String>(0), - )?; - rows.collect::, _>>()? - } - (None, Some((after_at, after_session_id))) => { - let mut statement = connection.prepare( - "SELECT record_json FROM subject_session_attributions - WHERE subject_id = ?1 AND attributed_at <= ?2 - AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4)) - ORDER BY attributed_at DESC, session_id ASC - LIMIT ?5", - )?; - let rows = statement.query_map( - params![ - subject_id, - snapshot_at, - after_at, - after_session_id, - query_limit as i64 - ], - |row| row.get::<_, String>(0), - )?; - rows.collect::, _>>()? - } - (None, None) => { - let mut statement = connection.prepare( - "SELECT record_json FROM subject_session_attributions - WHERE subject_id = ?1 AND attributed_at <= ?2 - ORDER BY attributed_at DESC, session_id ASC - LIMIT ?3", - )?; - let rows = statement.query_map( - params![subject_id, snapshot_at, query_limit as i64], - |row| row.get::<_, String>(0), - )?; - rows.collect::, _>>()? - } - }; - let mut items = raw - .into_iter() - .map(|raw| parse_session_attribution(&raw)) - .collect::>>()?; - let has_more = items.len() > limit; - if has_more { - items.truncate(limit); - } - Ok(SubjectSessionAttributionPage { items, has_more }) - }) - } - - pub fn staging_candidate( - &self, - subject_id: &str, - candidate_id: &str, - ) -> Result> { - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT record_json FROM staging_records - WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject_id, candidate_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_staging(self.workspace_id(), &raw)) - .transpose() - }) - } - - pub fn pending_staging_candidates( - &self, - subject_id: &str, - limit: usize, - ) -> Result> { - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - let mut statement = connection.prepare( - "SELECT s.record_json - FROM staging_records s - LEFT JOIN staging_resolutions r - ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id - WHERE s.subject_id = ?1 AND r.candidate_id IS NULL - ORDER BY s.created_at ASC, s.candidate_id ASC - LIMIT ?2", - )?; - let rows = statement.query_map(params![subject_id, to_i64(limit as u64)?], |row| { - row.get::<_, String>(0) - })?; - let mut records = Vec::new(); - for row in rows { - records.push(parse_staging(self.workspace_id(), &row?)?); - } - Ok(records) - }) - } - - pub fn pending_staging_backlog(&self, subject_id: &str) -> Result<(usize, u64)> { - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - let (count, bytes) = connection.query_row( - "SELECT COUNT(*), COALESCE(SUM(LENGTH(s.record_json)), 0) - FROM staging_records s - LEFT JOIN staging_resolutions r - ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id - WHERE s.subject_id = ?1 AND r.candidate_id IS NULL", - [subject_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), - )?; - Ok(( - usize::try_from(count).map_err(|_| { - SubjektivError::InvalidRecord("pending candidate count exceeds usize".into()) - })?, - u64::try_from(bytes).map_err(|_| { - SubjektivError::InvalidRecord("pending candidate bytes are negative".into()) - })?, - )) - }) - } - - pub fn create_memory(&self, subject_id: &str, draft: MemoryDraft) -> Result { - validate_direct_memory_draft(&draft)?; - if draft.state != MemoryState::Active { - return Err(SubjektivError::InvalidRecord( - "a new Memory must start in active state".to_string(), - )); - } - let memory_id = issued_id("memory"); - self.database.try_transaction(|transaction| { - write_memory_revision(transaction, subject_id, &memory_id, None, draft) - }) - } - - pub fn revise_memory( - &self, - subject_id: &str, - memory_id: &str, - expected_revision: u64, - draft: MemoryDraft, - ) -> Result { - validate_direct_memory_draft(&draft)?; - self.database.try_transaction(|transaction| { - write_memory_revision( - transaction, - subject_id, - memory_id, - Some(expected_revision), - draft, - ) - }) - } - - /// Atomically writes a Memory revision and one candidate's immutable - /// resolution. Any conflict or invalid reference rolls both changes back. - pub fn apply_candidate( - &self, - subject_id: &str, - candidate_id: &str, - target: MemoryRevisionTarget, - draft: MemoryDraft, - reason: impl Into, - ) -> Result<(MemoryRecord, StagingResolution)> { - let candidate_ids = vec![candidate_id.to_string()]; - let (memory, mut resolutions) = - self.apply_candidates(subject_id, &candidate_ids, target, draft, reason)?; - Ok((memory, resolutions.remove(0))) - } - - /// Atomically applies one or more candidates to exactly one new Memory - /// revision. The repository derives `source_candidate_ids` from this list so - /// every applied resolution has a reciprocal immutable provenance edge. - pub fn apply_candidates( - &self, - subject_id: &str, - candidate_ids: &[String], - target: MemoryRevisionTarget, - draft: MemoryDraft, - reason: impl Into, - ) -> Result<(MemoryRecord, Vec)> { - if candidate_ids.is_empty() { - return Err(SubjektivError::InvalidRecord( - "candidate application requires at least one candidate".into(), - )); - } - reject_duplicates("applied candidate", candidate_ids)?; - for candidate_id in candidate_ids { - validate_label("candidate id", candidate_id)?; - } - let reason = reason.into(); - validate_nonempty("resolution reason", &reason)?; - self.database.try_transaction(|transaction| { - apply_candidates_in_transaction( - transaction, - subject_id, - candidate_ids, - &target, - draft, - &reason, - ) - }) - } - - pub fn decide_candidate( - &self, - subject_id: &str, - request: CandidateDecisionRequest, - ) -> Result { - validate_label("candidate decision request id", &request.request_id)?; - validate_label("candidate id", &request.candidate_id)?; - validate_nonempty("resolution reason", &request.reason)?; - let request_raw = serde_json::to_string(&request)?; - self.database.try_transaction(|transaction| { - if let Some((stored_request, stored_receipt)) = transaction - .query_row( - "SELECT request_json, receipt_json - FROM candidate_decision_receipts - WHERE subject_id = ?1 AND request_id = ?2", - params![subject_id, request.request_id], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()? - { - if stored_request == request_raw { - return serde_json::from_str(&stored_receipt).map_err(Into::into); - } - return Err(SubjektivError::DecisionRequestConflict( - request.request_id.clone(), - )); - } - if transaction - .query_row( - "SELECT 1 FROM candidate_decision_receipts - WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject_id, request.candidate_id], - |_| Ok(()), - ) - .optional()? - .is_some() - { - return Err(SubjektivError::CandidateResolved( - request.candidate_id.clone(), - )); - } - - let (memory, operation, resolution) = match request.decision.clone() { - CandidateDecision::Apply { target, draft } => { - let operation = match &target { - MemoryRevisionTarget::Create => MemoryDecisionOperation::Create, - MemoryRevisionTarget::Revise { .. } => MemoryDecisionOperation::Revise, - }; - let candidate_ids = [request.candidate_id.clone()]; - let (memory, mut resolutions) = apply_candidates_in_transaction( - transaction, - subject_id, - &candidate_ids, - &target, - draft, - &request.reason, - )?; - (Some(memory), Some(operation), resolutions.remove(0)) - } - CandidateDecision::Close { - action, - affected_memory, - } => { - if action == StagingResolutionAction::Applied { - return Err(SubjektivError::InvalidRecord( - "applied candidates require an apply decision".into(), - )); - } - let resolution = insert_resolution( - transaction, - subject_id, - &request.candidate_id, - action, - &request.reason, - &affected_memory, - )?; - let operation = - (!affected_memory.is_empty()).then_some(MemoryDecisionOperation::Reference); - (None, operation, resolution) - } - }; - let store_revision = require_subject(transaction, subject_id)?.store_revision; - let receipt = CandidateDecisionReceipt { - request_id: request.request_id.clone(), - candidate_id: request.candidate_id.clone(), - surface_dirty: memory.is_some(), - memory, - operation, - resolution, - store_revision, - }; - let receipt_raw = serde_json::to_string(&receipt)?; - transaction.execute( - "INSERT INTO candidate_decision_receipts ( - subject_id, request_id, candidate_id, request_json, - receipt_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - subject_id, - request.request_id, - request.candidate_id, - request_raw, - receipt_raw, - now() - ], - )?; - Ok(receipt) - }) - } - - pub fn resolve_candidate( - &self, - subject_id: &str, - candidate_id: &str, - action: StagingResolutionAction, - reason: impl Into, - affected_memory: Vec, - ) -> Result { - let reason = reason.into(); - validate_nonempty("resolution reason", &reason)?; - if action == StagingResolutionAction::Applied { - return Err(SubjektivError::InvalidRecord( - "applied candidates must use apply_candidate(s) so Memory and provenance commit atomically" - .to_string(), - )); - } - self.database.try_transaction(|transaction| { - insert_resolution( - transaction, - subject_id, - candidate_id, - action, - &reason, - &affected_memory, - ) - }) - } - - pub fn staging_resolution( - &self, - subject_id: &str, - candidate_id: &str, - ) -> Result> { - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT resolution_json FROM staging_resolutions - WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject_id, candidate_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_resolution(&raw)).transpose() - }) - } - - pub fn memory(&self, subject_id: &str, memory_id: &str) -> Result> { - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT record_json FROM memory_records - WHERE subject_id = ?1 AND memory_id = ?2", - params![subject_id, memory_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_memory(&raw)).transpose() - }) - } - - pub fn memory_revision( - &self, - subject_id: &str, - memory_id: &str, - revision: u64, - ) -> Result> { - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT record_json FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", - params![subject_id, memory_id, to_i64(revision)?], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_memory(&raw)).transpose() - }) - } - - /// Reads one current Memory while distinguishing an absent target from a - /// Memory id owned by another subject. - pub fn scoped_memory(&self, subject_id: &str, memory_id: &str) -> Result { - validate_label("subject id", subject_id)?; - validate_label("Memory id", memory_id)?; - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - scoped_memory_in_connection(connection, subject_id, memory_id) - }) - } - - /// Reads one immutable revision after verifying that the Memory belongs to - /// the requested subject. A missing revision of a valid scoped Memory is - /// returned as `None`. - pub fn scoped_memory_revision( - &self, - subject_id: &str, - memory_id: &str, - revision: u64, - ) -> Result> { - validate_label("subject id", subject_id)?; - validate_label("Memory id", memory_id)?; - if revision == 0 { - return Err(SubjektivError::InvalidRecord( - "Memory revision must be greater than zero".into(), - )); - } - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - scoped_memory_in_connection(connection, subject_id, memory_id)?; - let raw = connection - .query_row( - "SELECT record_json FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", - params![subject_id, memory_id, to_i64(revision)?], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_memory(&raw)).transpose() - }) - } - - /// Lists current Memory records for one existing subject in deterministic - /// most-recently-updated order. - pub fn list_memories(&self, subject_id: &str) -> Result> { - validate_label("subject id", subject_id)?; - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - let mut statement = connection.prepare( - "SELECT record_json FROM memory_records - WHERE subject_id = ?1 - ORDER BY updated_at DESC, memory_id ASC", - )?; - let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; - let mut records = Vec::new(); - for row in rows { - records.push(parse_memory(&row?)?); - } - Ok(records) - }) - } - - /// Lists every immutable revision of one scoped Memory, newest first. - pub fn list_memory_revisions( - &self, - subject_id: &str, - memory_id: &str, - ) -> Result> { - validate_label("subject id", subject_id)?; - validate_label("Memory id", memory_id)?; - self.database.try_with_connection(|connection| { - require_subject_in_connection(connection, subject_id)?; - scoped_memory_in_connection(connection, subject_id, memory_id)?; - let mut statement = connection.prepare( - "SELECT record_json FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 - ORDER BY revision DESC", - )?; - let rows = statement.query_map(params![subject_id, memory_id], |row| { - row.get::<_, String>(0) - })?; - let mut records = Vec::new(); - for row in rows { - records.push(parse_memory(&row?)?); - } - Ok(records) - }) - } - - /// Captures one deterministic, bounded generation input from only current - /// active Memory revisions. Kind order is fixed and selection is round-robin - /// across kinds; within each kind, updated_at desc then memory id asc. - pub fn prepare_surface_generation(&self, subject_id: &str) -> Result { - self.prepare_job_surface_generation(subject_id, None) - } - - pub(crate) fn prepare_job_surface_generation( - &self, - subject_id: &str, - job_attempt: Option<(&str, &str)>, - ) -> Result { - self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - let (active_memory_count, materials) = - select_surface_materials(transaction, subject_id)?; - let generation = SurfaceGeneration { - id: issued_id("surface-generation"), - subject_id: subject_id.to_string(), - store_revision: subject.store_revision, - active_memory_count, - materials, - created_at: now(), - }; - transaction.execute( - "INSERT INTO surface_generation_runs ( - subject_id, generation_id, store_revision, active_memory_count, - materials_json, created_at, job_id, attempt_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - subject_id, - generation.id, - to_i64(generation.store_revision)?, - to_i64(generation.active_memory_count as u64)?, - serde_json::to_string(&generation.materials)?, - generation.created_at, - job_attempt.map(|(job_id, _)| job_id), - job_attempt.map(|(_, attempt_id)| attempt_id), - ], - )?; - Ok(generation) - }) - } - - /// Validate a Job's structured surface claim against the existing generation - /// and publication/failure records. This does not create another receipt or - /// roll back Memory. The generation must have begun inside this attempt. - #[allow(clippy::too_many_arguments)] - pub(crate) fn validate_job_surface_outcome( - &self, - subject_id: &str, - generation_id: &str, - store_revision: u64, - availability: &str, - snapshot_id: Option<&str>, - reason_code: Option<&str>, - job_id: &str, - attempt_id: &str, - ) -> Result<()> { - self.database.try_with_connection(|connection| { - let subject = require_subject_in_connection(connection, subject_id)?; - let generation_revision = require_job_generation(connection, subject_id, generation_id, job_id, attempt_id)?; - if generation_revision != store_revision || subject.store_revision != store_revision { - return Err(SubjektivError::InvalidRecord("surface generation is stale".into())); - } - let state = connection.query_row( - "SELECT store_revision, status, snapshot_id, reason_code FROM surface_generation_state WHERE subject_id = ?1", - [subject_id], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?)), - ).optional()?.ok_or_else(|| SubjektivError::InvalidRecord("surface generation has no terminal outcome".into()))?; - if state.0 != to_i64(store_revision)? || state.1 != availability - || !matches!(availability, "ready" | "failed") - || state.2.as_deref() != snapshot_id || state.3.as_deref() != reason_code { - return Err(SubjektivError::InvalidRecord("surface claim differs from the persisted outcome".into())); - } - Ok(()) - }) - } - - pub(crate) fn require_job_surface_generation( - &self, - subject_id: &str, - generation_id: &str, - job_id: &str, - attempt_id: &str, - ) -> Result<()> { - self.database.try_with_connection(|connection| { - require_job_generation(connection, subject_id, generation_id, job_id, attempt_id)?; - Ok(()) - }) - } - - /// Validates structured editor output against the exact material snapshot and - /// atomically publishes it only while the subject store generation is current. - pub fn publish_surface_generation( - &self, - subject_id: &str, - generation_id: &str, - points: Vec, - ) -> Result { - validate_label("surface generation id", generation_id)?; - self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - let (store_revision, active_memory_count, materials_json) = transaction - .query_row( - "SELECT store_revision, active_memory_count, materials_json - FROM surface_generation_runs - WHERE subject_id = ?1 AND generation_id = ?2", - params![subject_id, generation_id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, String>(2)?, - )) - }, - ) - .optional()? - .ok_or_else(|| { - SubjektivError::SurfaceGenerationConflict(format!( - "unknown generation `{generation_id}` for subject `{subject_id}`" - )) - })?; - let store_revision = u64::try_from(store_revision).map_err(|_| { - SubjektivError::InvalidRecord("surface generation revision is negative".into()) - })?; - if subject.store_revision != store_revision { - return Err(SubjektivError::SurfaceGenerationConflict(format!( - "generation {store_revision} is stale; current store revision is {}", - subject.store_revision - ))); - } - let materials: Vec = serde_json::from_str(&materials_json)?; - if active_memory_count > 0 && materials.is_empty() { - return Err(SubjektivError::InvalidRecord( - "active Memory exists but no record fits the surface input budget".into(), - )); - } - let (body_md, memory_refs) = validate_surface_points(&materials, &points)?; - if let Some(existing) = - ready_surface_snapshot_for_revision(transaction, subject_id, store_revision)? - { - if existing.body_md == body_md && existing.memory_refs == memory_refs { - return Ok(existing); - } - return Err(SubjektivError::SurfaceGenerationConflict(format!( - "store revision {store_revision} already has a different published surface" - ))); - } - insert_surface_snapshot( - transaction, - subject_id, - body_md, - memory_refs, - store_revision, - ) - }) - } - - /// Records a bounded generator failure without changing confirmed Memory. A - /// late failure cannot replace a ready surface or dirty a newer generation. - pub fn fail_surface_generation( - &self, - subject_id: &str, - generation_id: &str, - reason_code: &str, - ) -> Result { - validate_label("surface generation id", generation_id)?; - validate_label("surface failure reason", reason_code)?; - self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - let generation_revision = transaction - .query_row( - "SELECT store_revision FROM surface_generation_runs - WHERE subject_id = ?1 AND generation_id = ?2", - params![subject_id, generation_id], - |row| row.get::<_, i64>(0), - ) - .optional()? - .ok_or_else(|| { - SubjektivError::SurfaceGenerationConflict(format!( - "unknown generation `{generation_id}` for subject `{subject_id}`" - )) - })?; - let generation_revision = u64::try_from(generation_revision).map_err(|_| { - SubjektivError::InvalidRecord("surface generation revision is negative".into()) - })?; - if generation_revision == subject.store_revision { - let ready = transaction - .query_row( - "SELECT status FROM surface_generation_state WHERE subject_id = ?1", - [subject_id], - |row| row.get::<_, String>(0), - ) - .optional()? - .is_some_and(|status| status == "ready"); - if !ready { - transaction.execute( - "INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at - ) VALUES (?1, ?2, 'failed', NULL, ?3, ?4) - ON CONFLICT(subject_id) DO UPDATE SET - store_revision = excluded.store_revision, - status = 'failed', snapshot_id = NULL, - reason_code = excluded.reason_code, - updated_at = excluded.updated_at", - params![subject_id, to_i64(generation_revision)?, reason_code, now()], - )?; - } - } - Ok(subject.store_revision) - }) - } - - /// Returns only a current ready snapshot for injection. Other states are - /// explicit so callers never report missing/stale/failed generation as empty - /// confirmed Memory. - pub fn resident_surface(&self, subject_id: &str) -> Result { - self.database.try_with_connection(|connection| { - let subject = require_subject_in_connection(connection, subject_id)?; - resident_surface_in_connection(connection, &subject) - }) - } - - /// Atomically reads user-managed behavior and the independently-fresh - /// generated Memory surface from one SQLite connection snapshot. - pub fn resident_context(&self, subject_id: &str) -> Result { - self.database.try_transaction(|transaction| { - let subject = require_subject(transaction, subject_id)?; - let surface = resident_surface_in_connection(transaction, &subject)?; - Ok(SubjectResidentContext { subject, surface }) - }) - } - - pub fn create_surface_snapshot( - &self, - subject_id: &str, - body_md: impl Into, - memory_refs: Vec, - built_from_store_revision: u64, - ) -> Result { - reject_duplicate_refs("surface snapshot", &memory_refs)?; - reject_duplicate_memory_ids("surface snapshot", &memory_refs)?; - let body_md = body_md.into(); - self.database.try_transaction(|transaction| { - let subject = require_active_subject(transaction, subject_id)?; - if subject.store_revision != built_from_store_revision { - return Err(SubjektivError::InvalidRecord(format!( - "surface snapshot generation {built_from_store_revision} does not match current subject store revision {}", - subject.store_revision - ))); - } - validate_memory_refs(transaction, subject_id, &memory_refs)?; - if estimated_tokens(&body_md) > SURFACE_BODY_TOKEN_BUDGET { - return Err(SubjektivError::InvalidRecord(format!( - "surface snapshot body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" - ))); - } - if let Some(existing) = surface_snapshot_for_revision( - transaction, - subject_id, - built_from_store_revision, - )? { - if existing.body_md == body_md && existing.memory_refs == memory_refs { - return Ok(existing); - } - return Err(SubjektivError::SurfaceGenerationConflict(format!( - "store revision {built_from_store_revision} already has a different published surface" - ))); - } - insert_surface_snapshot( - transaction, - subject_id, - body_md, - memory_refs, - built_from_store_revision, - ) - }) - } - - pub fn surface_snapshot( - &self, - subject_id: &str, - snapshot_id: &str, - ) -> Result> { - self.database.try_with_connection(|connection| { - let raw = connection - .query_row( - "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND snapshot_id = ?2", - params![subject_id, snapshot_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_surface_snapshot(&raw)).transpose() - }) - } -} - -fn select_surface_materials( - transaction: &Transaction<'_>, - subject_id: &str, -) -> Result<(usize, Vec)> { - let mut statement = transaction.prepare( - "SELECT record_json FROM memory_records - WHERE subject_id = ?1 AND state = 'active' - ORDER BY updated_at DESC, memory_id ASC", - )?; - let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?; - let mut buckets: [Vec; 6] = std::array::from_fn(|_| Vec::new()); - let mut active_memory_count = 0usize; - for row in rows { - let memory = parse_memory(&row?)?; - active_memory_count = active_memory_count.saturating_add(1); - let bucket = surface_kind_index(&memory.kind); - if buckets[bucket].len() < SURFACE_PER_KIND_LIMIT { - buckets[bucket].push(SurfaceMaterial { - memory_id: memory.id, - revision: memory.revision, - kind: memory.kind, - body_md: memory.body_md, - why_useful: memory.why_useful, - staleness: memory.staleness, - }); - } - } - - let populated_kind_count = buckets.iter().filter(|bucket| !bucket.is_empty()).count(); - let per_kind_token_budget = SURFACE_MATERIAL_TOKEN_BUDGET / populated_kind_count.max(1); - let mut ordered = Vec::new(); - for offset in 0..SURFACE_PER_KIND_LIMIT { - for (bucket_index, bucket) in buckets.iter().enumerate() { - if let Some(material) = bucket.get(offset) { - ordered.push((bucket_index, material.clone())); - } - } - } - - let mut accepted = vec![false; ordered.len()]; - let mut selected_count = 0usize; - let mut deferred = Vec::new(); - for (index, (bucket_index, material)) in ordered.iter().enumerate() { - if selected_count == SURFACE_TOTAL_MATERIAL_LIMIT { - break; - } - let kind_materials = ordered - .iter() - .zip(&accepted) - .filter(|((selected_bucket, _), accepted)| { - **accepted && selected_bucket == bucket_index - }) - .map(|((_, material), _)| material) - .chain(std::iter::once(material)); - let all_materials = ordered - .iter() - .zip(&accepted) - .filter(|(_, accepted)| **accepted) - .map(|((_, material), _)| material) - .chain(std::iter::once(material)); - if serialized_material_tokens(kind_materials)? <= per_kind_token_budget - && serialized_material_tokens(all_materials)? <= SURFACE_MATERIAL_TOKEN_BUDGET - { - accepted[index] = true; - selected_count += 1; - } else { - deferred.push(index); - } - } - - // Category shares are fairness reservations, not hard ceilings. Reclaim - // unused shares in the same canonical order after every populated category - // had its first opportunity. - for index in deferred { - if selected_count == SURFACE_TOTAL_MATERIAL_LIMIT { - break; - } - let material = &ordered[index].1; - let all_materials = ordered - .iter() - .zip(&accepted) - .filter(|(_, accepted)| **accepted) - .map(|((_, material), _)| material) - .chain(std::iter::once(material)); - if serialized_material_tokens(all_materials)? <= SURFACE_MATERIAL_TOKEN_BUDGET { - accepted[index] = true; - selected_count += 1; - } - } - - let selected = ordered - .into_iter() - .zip(accepted) - .filter_map(|((_, material), accepted)| accepted.then_some(material)) - .collect(); - Ok((active_memory_count, selected)) -} - -fn serialized_material_tokens<'a>( - materials: impl IntoIterator, -) -> Result { - let materials = materials.into_iter().collect::>(); - Ok(estimated_tokens_for_bytes( - serde_json::to_vec(&materials)?.len(), - )) -} - -fn surface_kind_index(kind: &CandidateKind) -> usize { - match kind { - CandidateKind::Preference => 0, - CandidateKind::Constraint => 1, - CandidateKind::Decision => 2, - CandidateKind::WorkingAssumption => 3, - CandidateKind::OpenQuestion => 4, - CandidateKind::Lesson => 5, - } -} - -fn validate_surface_points( - materials: &[SurfaceMaterial], - points: &[SurfacePoint], -) -> Result<(String, Vec)> { - if materials.is_empty() { - if points.is_empty() { - return Ok((String::new(), Vec::new())); - } - return Err(SubjektivError::InvalidRecord( - "an empty material set requires an empty surface".into(), - )); - } - if points.is_empty() { - return Err(SubjektivError::InvalidRecord( - "non-empty surface materials require at least one grounded point".into(), - )); - } - if points.len() > SURFACE_TOTAL_MATERIAL_LIMIT { - return Err(SubjektivError::InvalidRecord(format!( - "surface output exceeds {SURFACE_TOTAL_MATERIAL_LIMIT} points" - ))); - } - let allowed = materials - .iter() - .map(|material| (material.memory_id.as_str(), material.revision)) - .collect::>(); - let mut bodies = Vec::with_capacity(points.len()); - let mut seen = HashSet::new(); - let mut refs = Vec::new(); - for point in points { - let body = point.body_md.trim(); - validate_nonempty("surface point body", body)?; - if point.memory_refs.is_empty() { - return Err(SubjektivError::InvalidRecord( - "every surface point requires at least one Memory reference".into(), - )); - } - reject_duplicate_refs("surface point", &point.memory_refs)?; - for reference in &point.memory_refs { - if !allowed.contains(&(reference.memory_id.as_str(), reference.revision)) { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: "surface generation materials".into(), - reference: format!("{}@{}", reference.memory_id, reference.revision), - }); - } - if seen.insert((reference.memory_id.clone(), reference.revision)) { - refs.push(reference.clone()); - } - } - bodies.push(body.to_string()); - } - let body_md = bodies.join("\n\n"); - if estimated_tokens(&body_md) > SURFACE_BODY_TOKEN_BUDGET { - return Err(SubjektivError::InvalidRecord(format!( - "surface body exceeds {SURFACE_BODY_TOKEN_BUDGET} token estimate" - ))); - } - Ok((body_md, refs)) -} - -fn ready_surface_snapshot_for_revision( - transaction: &Transaction<'_>, - subject_id: &str, - store_revision: u64, -) -> Result> { - let raw = transaction - .query_row( - "SELECT snapshots.snapshot_json - FROM surface_generation_state state - JOIN surface_snapshots snapshots - ON snapshots.subject_id = state.subject_id - AND snapshots.snapshot_id = state.snapshot_id - WHERE state.subject_id = ?1 - AND state.store_revision = ?2 - AND state.status = 'ready'", - params![subject_id, to_i64(store_revision)?], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_surface_snapshot(&raw)).transpose() -} - -fn surface_snapshot_for_revision( - transaction: &Transaction<'_>, - subject_id: &str, - store_revision: u64, -) -> Result> { - let raw = transaction - .query_row( - "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND built_from_store_revision = ?2 - ORDER BY created_at ASC, snapshot_id ASC LIMIT 1", - params![subject_id, to_i64(store_revision)?], - |row| row.get::<_, String>(0), - ) - .optional()?; - raw.map(|raw| parse_surface_snapshot(&raw)).transpose() -} - -fn insert_surface_snapshot( - transaction: &Transaction<'_>, - subject_id: &str, - body_md: String, - memory_refs: Vec, - built_from_store_revision: u64, -) -> Result { - validate_memory_refs(transaction, subject_id, &memory_refs)?; - let snapshot = SurfaceSnapshot { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: issued_id("surface"), - subject_id: subject_id.to_string(), - body_md, - memory_refs, - built_from_store_revision, - created_at: now(), - }; - let raw = serde_json::to_string(&snapshot)?; - transaction.execute( - "INSERT INTO surface_snapshots ( - subject_id, snapshot_id, built_from_store_revision, snapshot_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - subject_id, - snapshot.id, - to_i64(snapshot.built_from_store_revision)?, - raw, - snapshot.created_at - ], - )?; - for reference in &snapshot.memory_refs { - transaction.execute( - "INSERT INTO surface_snapshot_refs ( - subject_id, snapshot_id, memory_id, revision - ) VALUES (?1, ?2, ?3, ?4)", - params![ - subject_id, - snapshot.id, - reference.memory_id, - to_i64(reference.revision)? - ], - )?; - } - transaction.execute( - "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) VALUES (?1, ?2)", - params![subject_id, snapshot.id], - )?; - transaction.execute( - "INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at - ) VALUES (?1, ?2, 'ready', ?3, NULL, ?4) - ON CONFLICT(subject_id) DO UPDATE SET - store_revision = excluded.store_revision, - status = 'ready', snapshot_id = excluded.snapshot_id, - reason_code = NULL, updated_at = excluded.updated_at", - params![ - subject_id, - to_i64(built_from_store_revision)?, - snapshot.id, - snapshot.created_at - ], - )?; - Ok(snapshot) -} - -fn estimated_tokens(value: &str) -> usize { - estimated_tokens_for_bytes(value.len()) -} - -fn estimated_tokens_for_bytes(bytes: usize) -> usize { - bytes.saturating_add(3) / 4 -} - -fn apply_candidates_in_transaction( - transaction: &Transaction<'_>, - subject_id: &str, - candidate_ids: &[String], - target: &MemoryRevisionTarget, - mut draft: MemoryDraft, - reason: &str, -) -> Result<(MemoryRecord, Vec)> { - let mut candidates = Vec::with_capacity(candidate_ids.len()); - for candidate_id in candidate_ids { - let raw = require_unresolved_candidate(transaction, subject_id, candidate_id)?; - candidates.push(parse_staging_without_workspace(&raw)?); - } - for candidate in &candidates { - validate_candidate_application(transaction, subject_id, candidate, target, &draft)?; - } - draft.source_candidate_ids = candidate_ids.to_vec(); - let new_memory_id = issued_id("memory"); - let memory = match target { - MemoryRevisionTarget::Create => { - if draft.state != MemoryState::Active { - return Err(SubjektivError::InvalidRecord( - "a new Memory must start in active state".into(), - )); - } - write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)? - } - MemoryRevisionTarget::Revise { - memory_id, - expected_revision, - } => write_memory_revision( - transaction, - subject_id, - memory_id, - Some(*expected_revision), - draft, - )?, - }; - let reference = MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: memory.revision, - }; - let resolutions = candidate_ids - .iter() - .map(|candidate_id| { - insert_resolution( - transaction, - subject_id, - candidate_id, - StagingResolutionAction::Applied, - reason, - std::slice::from_ref(&reference), - ) - }) - .collect::>>()?; - Ok((memory, resolutions)) -} - -fn write_memory_revision( - transaction: &Transaction<'_>, - subject_id: &str, - memory_id: &str, - expected_revision: Option, - draft: MemoryDraft, -) -> Result { - validate_memory_draft(&draft)?; - let mut subject = require_active_subject(transaction, subject_id)?; - let current_raw = transaction - .query_row( - "SELECT record_json FROM memory_records - WHERE subject_id = ?1 AND memory_id = ?2", - params![subject_id, memory_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - let current = current_raw.map(|raw| parse_memory(&raw)).transpose()?; - - let (revision, created_at) = match (current.as_ref(), expected_revision) { - (None, None) => (1, now()), - (None, Some(_)) => return Err(SubjektivError::MemoryNotFound(memory_id.to_string())), - (Some(_), None) => { - return Err(SubjektivError::InvalidRecord(format!( - "memory `{memory_id}` already exists" - ))); - } - (Some(current), Some(expected)) if current.revision != expected => { - return Err(SubjektivError::RevisionConflict { - memory_id: memory_id.to_string(), - expected, - actual: current.revision, - }); - } - (Some(current), Some(_)) => { - validate_state_transition(memory_id, current.state, draft.state)?; - (current.revision + 1, current.created_at.clone()) - } - }; - - reject_duplicates("source candidate", &draft.source_candidate_ids)?; - reject_duplicate_refs("derived Memory", &draft.derived_from)?; - validate_candidate_refs(transaction, subject_id, &draft.source_candidate_ids)?; - validate_memory_refs(transaction, subject_id, &draft.derived_from)?; - - let timestamp = now(); - let record = MemoryRecord { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: memory_id.to_string(), - subject_id: subject_id.to_string(), - revision, - kind: draft.kind, - state: draft.state, - claim: draft.claim, - body_md: draft.body_md, - why_useful: draft.why_useful, - staleness: draft.staleness, - source_candidate_ids: draft.source_candidate_ids, - derived_from: draft.derived_from, - change_reason: draft.change_reason, - created_at, - updated_at: timestamp, - }; - let raw = serde_json::to_string(&record)?; - - if current.is_none() { - transaction.execute( - "INSERT INTO memory_records ( - subject_id, memory_id, current_revision, kind, state, - record_json, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![ - subject_id, - memory_id, - to_i64(revision)?, - candidate_kind_name(&record.kind), - memory_state_name(record.state), - raw, - record.created_at, - record.updated_at - ], - )?; - } - transaction.execute( - "INSERT INTO memory_revisions ( - subject_id, memory_id, revision, kind, state, record_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - subject_id, - memory_id, - to_i64(revision)?, - candidate_kind_name(&record.kind), - memory_state_name(record.state), - raw, - record.updated_at - ], - )?; - if current.is_some() { - transaction.execute( - "UPDATE memory_records - SET current_revision = ?3, kind = ?4, state = ?5, - record_json = ?6, updated_at = ?7 - WHERE subject_id = ?1 AND memory_id = ?2", - params![ - subject_id, - memory_id, - to_i64(revision)?, - candidate_kind_name(&record.kind), - memory_state_name(record.state), - raw, - record.updated_at - ], - )?; - } - for candidate_id in &record.source_candidate_ids { - transaction.execute( - "INSERT INTO memory_revision_candidates ( - subject_id, memory_id, revision, candidate_id - ) VALUES (?1, ?2, ?3, ?4)", - params![subject_id, memory_id, to_i64(revision)?, candidate_id], - )?; - } - for source in &record.derived_from { - transaction.execute( - "INSERT INTO memory_revision_derivations ( - subject_id, memory_id, revision, source_memory_id, source_revision - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - subject_id, - memory_id, - to_i64(revision)?, - source.memory_id, - to_i64(source.revision)? - ], - )?; - } - transaction.execute( - "INSERT INTO memory_revision_seals (subject_id, memory_id, revision) - VALUES (?1, ?2, ?3)", - params![subject_id, memory_id, to_i64(revision)?], - )?; - - subject.store_revision = subject - .store_revision - .checked_add(1) - .ok_or_else(|| SubjektivError::InvalidRecord("subject store revision overflow".into()))?; - subject.updated_at = record.updated_at.clone(); - let subject_raw = serde_json::to_string(&subject)?; - transaction.execute( - "UPDATE subjects - SET store_revision = ?2, record_json = ?3, updated_at = ?4 - WHERE subject_id = ?1", - params![ - subject_id, - to_i64(subject.store_revision)?, - subject_raw, - subject.updated_at - ], - )?; - transaction.execute( - "INSERT INTO surface_generation_state ( - subject_id, store_revision, status, snapshot_id, reason_code, updated_at - ) VALUES (?1, ?2, 'dirty', NULL, NULL, ?3) - ON CONFLICT(subject_id) DO UPDATE SET - store_revision = excluded.store_revision, - status = 'dirty', snapshot_id = NULL, reason_code = NULL, - updated_at = excluded.updated_at", - params![ - subject_id, - to_i64(subject.store_revision)?, - subject.updated_at - ], - )?; - Ok(record) -} - -fn write_staging_candidate( - transaction: &Transaction<'_>, - workspace_id: &str, - record: SubjectStagingRecord, - raw: String, -) -> Result { - let existing = transaction - .query_row( - "SELECT record_json FROM staging_records - WHERE subject_id = ?1 AND candidate_id = ?2", - params![record.subject_id, record.id], - |row| row.get::<_, String>(0), - ) - .optional()?; - if let Some(existing) = existing { - if equivalent_staging_payload(&existing, &raw)? { - return parse_staging(workspace_id, &existing); - } - return Err(SubjektivError::CandidateConflict(record.id.clone())); - } - validate_staged_revision_proposal(transaction, &record)?; - transaction.execute( - "INSERT INTO staging_records ( - subject_id, candidate_id, kind, record_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - record.subject_id, - record.id, - candidate_kind_name(&record.kind), - raw, - record.created_at - ], - )?; - Ok(record) -} - -fn write_session_attribution( - transaction: &Transaction<'_>, - record: SubjectSessionAttribution, - raw: String, -) -> Result { - let existing = transaction - .query_row( - "SELECT record_json FROM subject_session_attributions WHERE session_id = ?1", - [&record.session_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - if let Some(existing) = existing { - let existing = parse_session_attribution(&existing)?; - if existing.subject_id != record.subject_id { - return Err(SubjektivError::SessionSubjectConflict { - session_id: record.session_id, - existing_subject_id: existing.subject_id, - requested_subject_id: record.subject_id, - }); - } - if equivalent_session_attribution(&existing, &record) { - return Ok(existing); - } - return Err(SubjektivError::SessionAttributionConflict( - record.session_id, - )); - } - transaction.execute( - "INSERT INTO subject_session_attributions ( - session_id, subject_id, runtime_id, worker_id, record_json, attributed_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - record.session_id, - record.subject_id, - record.runtime_id, - record.worker_id, - raw, - record.attributed_at - ], - )?; - Ok(record) -} - -fn insert_resolution( - transaction: &Transaction<'_>, - subject_id: &str, - candidate_id: &str, - action: StagingResolutionAction, - reason: &str, - affected_memory: &[MemoryRevisionRef], -) -> Result { - require_active_subject(transaction, subject_id)?; - reject_duplicate_refs("staging resolution", affected_memory)?; - validate_memory_refs(transaction, subject_id, affected_memory)?; - if action == StagingResolutionAction::Applied { - if affected_memory.is_empty() { - return Err(SubjektivError::InvalidRecord( - "an applied candidate requires an affected Memory revision".into(), - )); - } - for reference in affected_memory { - let reciprocal = transaction - .query_row( - "SELECT 1 FROM memory_revision_candidates - WHERE subject_id = ?1 AND memory_id = ?2 - AND revision = ?3 AND candidate_id = ?4", - params![ - subject_id, - reference.memory_id, - to_i64(reference.revision)?, - candidate_id - ], - |_| Ok(()), - ) - .optional()? - .is_some(); - if !reciprocal { - return Err(SubjektivError::InvalidRecord(format!( - "applied candidate `{candidate_id}` has no reciprocal provenance edge to `{}@{}`", - reference.memory_id, reference.revision - ))); - } - } - } - let candidate_raw = require_unresolved_candidate(transaction, subject_id, candidate_id)?; - let candidate = parse_staging_without_workspace(&candidate_raw)?; - if candidate.subject_id != subject_id { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: subject_id.to_string(), - reference: candidate_id.to_string(), - }); - } - let resolution = StagingResolution { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: issued_id("resolution"), - subject_id: subject_id.to_string(), - candidate_id: candidate_id.to_string(), - action, - reason: reason.to_string(), - affected_memory: affected_memory.to_vec(), - candidate, - resolved_at: now(), - }; - let affected_raw = serde_json::to_string(affected_memory)?; - let resolution_raw = serde_json::to_string(&resolution)?; - transaction.execute( - "INSERT INTO staging_resolutions ( - subject_id, candidate_id, resolution_id, action, reason, - affected_refs_json, staging_raw_json, resolution_json, resolved_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - subject_id, - candidate_id, - resolution.id, - resolution_action_name(action), - reason, - affected_raw, - candidate_raw, - resolution_raw, - resolution.resolved_at - ], - )?; - for reference in affected_memory { - transaction.execute( - "INSERT INTO staging_resolution_targets ( - subject_id, candidate_id, memory_id, revision - ) VALUES (?1, ?2, ?3, ?4)", - params![ - subject_id, - candidate_id, - reference.memory_id, - to_i64(reference.revision)? - ], - )?; - } - transaction.execute( - "INSERT INTO staging_resolution_seals (subject_id, candidate_id) - VALUES (?1, ?2)", - params![subject_id, candidate_id], - )?; - Ok(resolution) -} - -fn require_unresolved_candidate( - transaction: &Transaction<'_>, - subject_id: &str, - candidate_id: &str, -) -> Result { - let raw = transaction - .query_row( - "SELECT record_json FROM staging_records - WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject_id, candidate_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - let raw = match raw { - Some(raw) => raw, - None => { - let foreign = transaction - .query_row( - "SELECT subject_id FROM staging_records WHERE candidate_id = ?1 LIMIT 1", - [candidate_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - if foreign.is_some() { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: subject_id.to_string(), - reference: candidate_id.to_string(), - }); - } - return Err(SubjektivError::CandidateNotFound(candidate_id.to_string())); - } - }; - let resolved = transaction - .query_row( - "SELECT 1 FROM staging_resolutions - WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject_id, candidate_id], - |_| Ok(()), - ) - .optional()? - .is_some(); - if resolved { - return Err(SubjektivError::CandidateResolved(candidate_id.to_string())); - } - Ok(raw) -} - -fn require_subject(transaction: &Transaction<'_>, subject_id: &str) -> Result { - require_subject_in_connection(transaction, subject_id) -} - -fn require_subject_in_connection( - connection: &rusqlite::Connection, - subject_id: &str, -) -> Result { - let raw = connection - .query_row( - "SELECT record_json FROM subjects WHERE subject_id = ?1", - [subject_id], - |row| row.get::<_, String>(0), - ) - .optional()? - .ok_or_else(|| SubjektivError::SubjectNotFound(subject_id.to_string()))?; - parse_subject(&raw) -} - -fn require_active_subject( - transaction: &Transaction<'_>, - subject_id: &str, -) -> Result { - let subject = require_subject(transaction, subject_id)?; - if subject.state == SubjectState::Retired { - return Err(SubjektivError::SubjectRetired(subject_id.to_string())); - } - Ok(subject) -} - -fn validate_candidate_refs( - transaction: &Transaction<'_>, - subject_id: &str, - candidate_ids: &[String], -) -> Result<()> { - for candidate_id in candidate_ids { - let exists = transaction - .query_row( - "SELECT 1 FROM staging_records - WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject_id, candidate_id], - |_| Ok(()), - ) - .optional()? - .is_some(); - if !exists { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: subject_id.to_string(), - reference: candidate_id.clone(), - }); - } - } - Ok(()) -} - -fn validate_memory_refs( - transaction: &Transaction<'_>, - subject_id: &str, - references: &[MemoryRevisionRef], -) -> Result<()> { - for reference in references { - if reference.revision == 0 { - return Err(SubjektivError::InvalidRecord(format!( - "Memory `{}` revision must be greater than zero", - reference.memory_id - ))); - } - let exists = transaction - .query_row( - "SELECT 1 FROM memory_revisions - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3", - params![subject_id, reference.memory_id, to_i64(reference.revision)?], - |_| Ok(()), - ) - .optional()? - .is_some(); - if !exists { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: subject_id.to_string(), - reference: format!("{}@{}", reference.memory_id, reference.revision), - }); - } - } - Ok(()) -} - -fn validate_session_attribution(record: &SubjectSessionAttribution) -> Result<()> { - validate_schema_version(record.schema_version, "subject Session attribution")?; - validate_label("subject id", &record.subject_id)?; - validate_label("runtime id", &record.runtime_id)?; - validate_label("worker id", &record.worker_id)?; - validate_label("session id", &record.session_id)?; - validate_label("Session attribution timestamp", &record.attributed_at)?; - chrono::DateTime::parse_from_rfc3339(&record.attributed_at).map_err(|error| { - SubjektivError::InvalidRecord(format!( - "Session attribution timestamp is not RFC 3339: {error}" - )) - })?; - Ok(()) -} - -fn attach_session_to_candidate(record: &mut SubjectStagingRecord, session_id: &str) -> Result<()> { - if record.source_refs.is_empty() { - record.source_refs.push(SourceEvidenceRef { - session_id: Some(session_id.to_string()), - segment_id: Some(record.source.segment_id.clone()), - entry_range: Some(record.source.range), - ..SourceEvidenceRef::default() - }); - return Ok(()); - } - for source in &mut record.source_refs { - match source.session_id.as_deref() { - Some(existing) if existing != session_id => { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: record.subject_id.clone(), - reference: format!( - "candidate source Session `{existing}` does not match attributed Session `{session_id}`" - ), - }); - } - Some(_) => {} - None => source.session_id = Some(session_id.to_string()), - } - } - Ok(()) -} - -fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) -> Result<()> { - if record.schema_version != STAGING_SCHEMA_VERSION { - return Err(SubjektivError::InvalidRecord(format!( - "staging schema version {} is unsupported", - record.schema_version - ))); - } - validate_label("candidate id", &record.id)?; - validate_label("subject id", &record.subject_id)?; - validate_label("extract run id", &record.extract_run_id)?; - validate_label("source segment id", &record.source.segment_id)?; - validate_nonempty("candidate claim", &record.claim)?; - validate_nonempty("candidate why_useful", &record.why_useful)?; - validate_range("candidate source", record.source.range)?; - if matches!(record.kind, CandidateKind::Preference) - && record.evidence.iter().any(|evidence| { - !matches!( - evidence.origin.as_ref().map(|origin| &origin.kind), - Some(memory::schema::EvidenceOriginKind::HumanInput) - ) - }) - { - return Err(SubjektivError::InvalidRecord( - "preference candidates require exclusively HumanInput evidence".to_string(), - )); - } - - let mut evidence_ids = HashSet::new(); - for evidence in &record.evidence { - validate_label("evidence id", &evidence.id)?; - if !evidence_ids.insert(evidence.id.as_str()) { - return Err(SubjektivError::InvalidRecord(format!( - "duplicate evidence id `{}`", - evidence.id - ))); - } - validate_nonempty("evidence kind", evidence.kind.as_str())?; - if let Some(range) = evidence.entry_range { - validate_range("evidence entry", range)?; - } - } - for source in &record.source_refs { - if let Some(session_id) = source.session_id.as_deref() { - validate_label("source session id", session_id)?; - } - if let Some(segment_id) = source.segment_id.as_deref() { - validate_label("source segment id", segment_id)?; - } - if let Some(range) = source.entry_range { - validate_range("source evidence entry", range)?; - } - if let Some(evidence_id) = source.evidence_id.as_deref() { - validate_label("source evidence id", evidence_id)?; - if !evidence_ids.contains(evidence_id) { - return Err(SubjektivError::InvalidRecord(format!( - "source evidence id `{evidence_id}` is not present in evidence" - ))); - } - } - if let Some(kind) = source.evidence_kind.as_ref() { - validate_nonempty("source evidence kind", kind.as_str())?; - } - } - for origin in record - .evidence - .iter() - .filter_map(|evidence| evidence.origin.as_ref()) - .chain( - record - .source_refs - .iter() - .filter_map(|source| source.origin.as_ref()), - ) - { - if let Some(origin_workspace) = origin.workspace_id.as_deref() - && origin_workspace != workspace_id - { - return Err(SubjektivError::SubjectScopeMismatch { - subject_id: record.subject_id.clone(), - reference: format!("evidence Workspace {origin_workspace}"), - }); - } - } - if let Some(proposal) = &record.revision_proposal { - validate_revision_proposal_metadata(proposal)?; - } - Ok(()) -} - -/// Applies the current write-admission contract separately from structural -/// validation. Persisted rows from older compatible implementations remain -/// readable even when a later release tightens admission limits. -fn validate_staging_admission(record: &SubjectStagingRecord) -> Result<()> { - if record.evidence.len() > MAX_STAGING_ANCHORS || record.source_refs.len() > MAX_STAGING_ANCHORS - { - return Err(SubjektivError::InvalidRecord(format!( - "staging evidence/source_refs are limited to {MAX_STAGING_ANCHORS} items each" - ))); - } - Ok(()) -} - -fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> { - validate_label("revision proposal Memory id", &proposal.memory_id)?; - if proposal.expected_revision == 0 { - return Err(SubjektivError::InvalidRecord( - "revision proposal expected_revision must be greater than zero".into(), - )); - } - validate_nonempty("revision proposal change_reason", &proposal.change_reason) -} - -fn validate_candidate_application( - transaction: &Transaction<'_>, - subject_id: &str, - candidate: &SubjectStagingRecord, - target: &MemoryRevisionTarget, - draft: &MemoryDraft, -) -> Result<()> { - let Some(proposal) = &candidate.revision_proposal else { - return Ok(()); - }; - let MemoryRevisionTarget::Revise { - memory_id, - expected_revision, - } = target - else { - return Err(SubjektivError::InvalidRecord( - "a revision proposal cannot create a different Memory".into(), - )); - }; - if memory_id != &proposal.memory_id || expected_revision != &proposal.expected_revision { - return Err(SubjektivError::InvalidRecord( - "candidate application target must exactly match revision proposal metadata".into(), - )); - } - let current = scoped_memory_in_connection(transaction, subject_id, memory_id)?; - if current.revision != *expected_revision { - return Err(SubjektivError::RevisionConflict { - memory_id: memory_id.clone(), - expected: *expected_revision, - actual: current.revision, - }); - } - let expected_state = match proposal.intent { - RevisionProposalIntent::Revise => current.state, - RevisionProposalIntent::Resolve => MemoryState::Resolved, - RevisionProposalIntent::Retract => MemoryState::Retracted, - RevisionProposalIntent::Reopen => MemoryState::Active, - }; - let intent_allowed = match proposal.intent { - RevisionProposalIntent::Revise => { - matches!(current.state, MemoryState::Active | MemoryState::Resolved) - } - RevisionProposalIntent::Resolve => current.state == MemoryState::Active, - RevisionProposalIntent::Retract => { - matches!(current.state, MemoryState::Active | MemoryState::Resolved) - } - RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, - }; - if !intent_allowed || draft.state != expected_state { - return Err(SubjektivError::InvalidStateTransition { - memory_id: memory_id.clone(), - from: current.state, - to: draft.state, - }); - } - if draft.kind != current.kind || draft.kind != candidate.kind { - return Err(SubjektivError::InvalidRecord( - "revision proposal application must preserve the target Memory kind".into(), - )); - } - if draft.change_reason != proposal.change_reason { - return Err(SubjektivError::InvalidRecord( - "revision proposal application must preserve change_reason".into(), - )); - } - Ok(()) -} - -fn validate_staged_revision_proposal( - transaction: &Transaction<'_>, - record: &SubjectStagingRecord, -) -> Result<()> { - let Some(proposal) = &record.revision_proposal else { - return Ok(()); - }; - let current = - scoped_memory_in_connection(transaction, &record.subject_id, &proposal.memory_id)?; - if current.revision != proposal.expected_revision { - return Err(SubjektivError::RevisionConflict { - memory_id: proposal.memory_id.clone(), - expected: proposal.expected_revision, - actual: current.revision, - }); - } - let to = match proposal.intent { - RevisionProposalIntent::Revise => current.state, - RevisionProposalIntent::Resolve => MemoryState::Resolved, - RevisionProposalIntent::Retract => MemoryState::Retracted, - RevisionProposalIntent::Reopen => MemoryState::Active, - }; - let allowed = match proposal.intent { - RevisionProposalIntent::Revise => { - matches!(current.state, MemoryState::Active | MemoryState::Resolved) - } - RevisionProposalIntent::Resolve => current.state == MemoryState::Active, - RevisionProposalIntent::Retract => { - matches!(current.state, MemoryState::Active | MemoryState::Resolved) - } - RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved, - }; - if allowed { - Ok(()) - } else { - Err(SubjektivError::InvalidStateTransition { - memory_id: proposal.memory_id.clone(), - from: current.state, - to, - }) - } -} - -fn scoped_memory_in_connection( - connection: &rusqlite::Connection, - subject_id: &str, - memory_id: &str, -) -> Result { - let raw = connection - .query_row( - "SELECT record_json FROM memory_records - WHERE subject_id = ?1 AND memory_id = ?2", - params![subject_id, memory_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - if let Some(raw) = raw { - return parse_memory(&raw); - } - let foreign_owner = connection - .query_row( - "SELECT subject_id FROM memory_records WHERE memory_id = ?1 LIMIT 1", - [memory_id], - |row| row.get::<_, String>(0), - ) - .optional()?; - if foreign_owner.is_some() { - Err(SubjektivError::SubjectScopeMismatch { - subject_id: subject_id.to_string(), - reference: memory_id.to_string(), - }) - } else { - Err(SubjektivError::MemoryNotFound(memory_id.to_string())) - } -} - -fn validate_memory_draft(draft: &MemoryDraft) -> Result<()> { - validate_nonempty("Memory claim", &draft.claim)?; - validate_nonempty("Memory why_useful", &draft.why_useful)?; - validate_nonempty("Memory change_reason", &draft.change_reason)?; - for candidate_id in &draft.source_candidate_ids { - validate_label("source candidate id", candidate_id)?; - } - for reference in &draft.derived_from { - validate_label("derived Memory id", &reference.memory_id)?; - } - Ok(()) -} - -fn validate_direct_memory_draft(draft: &MemoryDraft) -> Result<()> { - if draft.source_candidate_ids.is_empty() { - Ok(()) - } else { - Err(SubjektivError::InvalidRecord( - "candidate provenance must be committed through apply_candidate(s)".into(), - )) - } -} - -fn validate_state_transition(memory_id: &str, from: MemoryState, to: MemoryState) -> Result<()> { - let allowed = from != MemoryState::Retracted; - if allowed { - Ok(()) - } else { - Err(SubjektivError::InvalidStateTransition { - memory_id: memory_id.to_string(), - from, - to, - }) - } -} - -fn parse_subject(raw: &str) -> Result { - let record: SubjectRecord = serde_json::from_str(raw)?; - validate_schema_version(record.schema_version, "subject")?; - Ok(record) -} - -fn equivalent_session_attribution( - left: &SubjectSessionAttribution, - right: &SubjectSessionAttribution, -) -> bool { - left.schema_version == right.schema_version - && left.subject_id == right.subject_id - && left.runtime_id == right.runtime_id - && left.worker_id == right.worker_id - && left.session_id == right.session_id -} - -fn equivalent_staging_payload(left: &str, right: &str) -> Result { - let mut left = serde_json::from_str::(left)?; - let mut right = serde_json::from_str::(right)?; - let left_object = left.as_object_mut().ok_or_else(|| { - SubjektivError::InvalidRecord("stored staging record is not a JSON object".into()) - })?; - let right_object = right.as_object_mut().ok_or_else(|| { - SubjektivError::InvalidRecord("new staging record is not a JSON object".into()) - })?; - // A retry may reconstruct the host envelope after an ambiguous response. - // Identity and extracted content must match, while the first persisted - // host-issued staging timestamp remains authoritative. - left_object.remove("created_at"); - right_object.remove("created_at"); - Ok(left == right) -} - -fn parse_session_attribution(raw: &str) -> Result { - let record: SubjectSessionAttribution = serde_json::from_str(raw)?; - validate_session_attribution(&record)?; - Ok(record) -} - -fn parse_staging(workspace_id: &str, raw: &str) -> Result { - let record = parse_staging_without_workspace(raw)?; - validate_staging_record(workspace_id, &record)?; - Ok(record) -} - -fn parse_staging_without_workspace(raw: &str) -> Result { - let record: SubjectStagingRecord = serde_json::from_str(raw)?; - if record.schema_version != STAGING_SCHEMA_VERSION { - return Err(SubjektivError::InvalidRecord(format!( - "staging schema version {} is unsupported", - record.schema_version - ))); - } - Ok(record) -} - -fn parse_memory(raw: &str) -> Result { - let record: MemoryRecord = serde_json::from_str(raw)?; - validate_schema_version(record.schema_version, "Memory")?; - if record.revision == 0 { - return Err(SubjektivError::InvalidRecord( - "Memory revision must be greater than zero".to_string(), - )); - } - Ok(record) -} - -fn parse_resolution(raw: &str) -> Result { - let record: StagingResolution = serde_json::from_str(raw)?; - validate_schema_version(record.schema_version, "staging resolution")?; - Ok(record) -} - -fn parse_surface_snapshot(raw: &str) -> Result { - let record: SurfaceSnapshot = serde_json::from_str(raw)?; - validate_schema_version(record.schema_version, "surface snapshot")?; - Ok(record) -} - -fn validate_schema_version(version: u32, kind: &str) -> Result<()> { - if version == SUBJEKTIV_SCHEMA_VERSION { - Ok(()) - } else { - Err(SubjektivError::InvalidRecord(format!( - "{kind} schema version {version} is unsupported" - ))) - } -} - -fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> { - if range[0] <= range[1] { - Ok(()) - } else { - Err(SubjektivError::InvalidRecord(format!( - "{kind} range is reversed" - ))) - } -} - -fn resident_surface_in_connection( - connection: &Connection, - subject: &SubjectRecord, -) -> Result { - let state = connection - .query_row( - "SELECT store_revision, status, snapshot_id - FROM surface_generation_state WHERE subject_id = ?1", - [&subject.id], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - }, - ) - .optional()?; - let Some((revision, status, snapshot_id)) = state else { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Ungenerated, - snapshot: None, - }); - }; - let revision = u64::try_from(revision) - .map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?; - if revision != subject.store_revision || status == "dirty" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Stale, - snapshot: None, - }); - } - if status == "failed" { - return Ok(ResidentSurface { - availability: SurfaceAvailability::Failed, - snapshot: None, - }); - } - let snapshot_id = snapshot_id.ok_or_else(|| { - SubjektivError::InvalidRecord("ready surface state has no snapshot id".into()) - })?; - let raw = connection.query_row( - "SELECT snapshot_json FROM surface_snapshots - WHERE subject_id = ?1 AND snapshot_id = ?2", - params![subject.id, snapshot_id], - |row| row.get::<_, String>(0), - )?; - let snapshot = parse_surface_snapshot(&raw)?; - if snapshot.built_from_store_revision != subject.store_revision { - return Err(SubjektivError::InvalidRecord( - "ready surface snapshot generation does not match subject".into(), - )); - } - Ok(ResidentSurface { - availability: SurfaceAvailability::Ready, - snapshot: Some(snapshot), - }) -} - -pub fn validate_subject_behavior(value: &str) -> Result<()> { - if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES { - return Err(SubjektivError::InvalidRecord(format!( - "subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes" - ))); - } - if !value.is_empty() && value.trim().is_empty() { - return Err(SubjektivError::InvalidRecord( - "subject behavior must be empty or contain non-whitespace text".to_string(), - )); - } - if value - .chars() - .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) - { - return Err(SubjektivError::InvalidRecord( - "subject behavior contains an unsupported control character".to_string(), - )); - } - Ok(()) -} - -fn validate_label(kind: &str, value: &str) -> Result<()> { - validate_nonempty(kind, value)?; - if value.len() > 256 || value.chars().any(char::is_control) { - return Err(SubjektivError::InvalidRecord(format!( - "{kind} must be at most 256 bytes and contain no control characters" - ))); - } - Ok(()) -} - -fn validate_nonempty(kind: &str, value: &str) -> Result<()> { - if value.trim().is_empty() { - Err(SubjektivError::InvalidRecord(format!( - "{kind} must not be empty" - ))) - } else { - Ok(()) - } -} - -fn reject_duplicates(kind: &str, values: &[String]) -> Result<()> { - let mut seen = HashSet::new(); - if let Some(duplicate) = values.iter().find(|value| !seen.insert(value.as_str())) { - return Err(SubjektivError::InvalidRecord(format!( - "duplicate {kind} `{duplicate}`" - ))); - } - Ok(()) -} - -fn reject_duplicate_refs(kind: &str, values: &[MemoryRevisionRef]) -> Result<()> { - let mut seen = HashSet::new(); - if let Some(duplicate) = values - .iter() - .find(|value| !seen.insert((value.memory_id.as_str(), value.revision))) - { - return Err(SubjektivError::InvalidRecord(format!( - "duplicate {kind} `{}@{}`", - duplicate.memory_id, duplicate.revision - ))); - } - Ok(()) -} - -fn reject_duplicate_memory_ids(kind: &str, values: &[MemoryRevisionRef]) -> Result<()> { - let mut seen = HashSet::new(); - if let Some(duplicate) = values - .iter() - .find(|value| !seen.insert(value.memory_id.as_str())) - { - return Err(SubjektivError::InvalidRecord(format!( - "duplicate {kind} Memory `{}`", - duplicate.memory_id - ))); - } - Ok(()) -} - -fn require_job_generation( - connection: &Connection, - subject_id: &str, - generation_id: &str, - job_id: &str, - attempt_id: &str, -) -> Result { - let revision: Option = connection.query_row( - "SELECT store_revision FROM surface_generation_runs WHERE subject_id = ?1 AND generation_id = ?2 AND job_id = ?3 AND attempt_id = ?4", - params![subject_id, generation_id, job_id, attempt_id], |row| row.get(0), - ).optional()?; - revision - .and_then(|value| u64::try_from(value).ok()) - .ok_or_else(|| { - SubjektivError::InvalidRecord( - "surface generation is not owned by this Job attempt".into(), - ) - }) -} - -fn to_i64(value: u64) -> Result { - i64::try_from(value).map_err(|_| { - SubjektivError::InvalidRecord(format!("revision value {value} exceeds SQLite range")) - }) -} - -fn issued_id(kind: &str) -> String { - format!("{kind}-{}", Uuid::now_v7()) -} - -fn now() -> String { - Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) -} - -fn candidate_kind_name(kind: &CandidateKind) -> &'static str { - kind.as_str() -} - -fn memory_state_name(state: MemoryState) -> &'static str { - match state { - MemoryState::Active => "active", - MemoryState::Resolved => "resolved", - MemoryState::Retracted => "retracted", - } -} - -fn resolution_action_name(action: StagingResolutionAction) -> &'static str { - match action { - StagingResolutionAction::Applied => "applied", - StagingResolutionAction::Discarded => "discarded", - StagingResolutionAction::Invalid => "invalid", - StagingResolutionAction::Duplicate => "duplicate", - StagingResolutionAction::AlreadyCovered => "already_covered", - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Barrier}; - use std::thread; - use std::time::Duration; - - use memory::extract::{ExtractedCandidate, StagingEvidence}; - use memory::schema::{ - EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef, - }; - - use super::*; - use crate::FeatureStorage; - - static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new( - 1, - "create subjektiv subject memory store", - create_schema, - )]; - static V3_MIGRATIONS: &[FeatureMigration] = &[ - FeatureMigration::new(1, "create subjektiv subject memory store", create_schema), - FeatureMigration::new( - 2, - "add immutable subject session attribution", - add_subject_session_attribution, - ), - FeatureMigration::new( - 3, - "add idempotent atomic candidate decision receipts", - add_candidate_decision_receipts, - ), - ]; - - fn role() -> SubjectRole { - SubjectRole::new("workspace_companion").unwrap() - } - - fn candidate(subject_id: &str, candidate_id: &str, workspace_id: &str) -> SubjectStagingRecord { - let origin = EvidenceOrigin { - kind: EvidenceOriginKind::HumanInput, - account_id: Some("account-1".into()), - workspace_id: Some(workspace_id.into()), - runtime_id: None, - worker_id: None, - flow_selector: None, - flow_definition_id: None, - flow_definition_revision: None, - }; - let record = StagingRecord::from_candidate( - candidate_id, - "extract-run-1", - SourceRef { - segment_id: "segment-1".into(), - range: [10, 12], - }, - ExtractedCandidate { - kind: CandidateKind::Constraint, - claim: "Do not treat Memory text as authority".into(), - why_useful: "Keeps experience separate from authorization".into(), - staleness: Some("Revisit if the authority model changes".into()), - evidence_ids: vec!["evidence-1".into()], - }, - vec![StagingEvidence { - id: "evidence-1".into(), - kind: EvidenceKind::new(EvidenceKind::MESSAGE), - entry_range: Some([10, 12]), - origin: Some(origin.clone()), - excerpt: Some("Memory is not authority".into()), - summary: Some("A bounded source summary".into()), - }], - vec![SourceEvidenceRef { - session_id: Some("session-1".into()), - segment_id: Some("segment-1".into()), - entry_range: Some([10, 12]), - evidence_id: Some("evidence-1".into()), - origin: Some(origin), - evidence_kind: Some(EvidenceKind::new(EvidenceKind::MESSAGE)), - label: Some("design discussion".into()), - summary: Some("Authority boundary".into()), - }], - ); - SubjectStagingRecord::attach_at(subject_id, record, "2026-09-28T10:00:00.000Z".into()) - } - - fn attribution( - subject_id: &str, - runtime_id: &str, - worker_id: &str, - session_id: &str, - attributed_at: &str, - ) -> SubjectSessionAttribution { - let mut record = - SubjectSessionAttribution::new(subject_id, runtime_id, worker_id, session_id).unwrap(); - record.attributed_at = attributed_at.to_string(); - record - } - - fn draft(claim: &str, reason: &str) -> MemoryDraft { - MemoryDraft::active( - CandidateKind::Constraint, - claim, - "Context, applicability, and consequences.", - "It preserves the subject's learned boundary.", - reason, - ) - } - - fn proposal( - intent: RevisionProposalIntent, - memory_id: &str, - expected_revision: u64, - change_reason: &str, - ) -> RevisionProposal { - RevisionProposal::new(intent, memory_id, expected_revision, change_reason).unwrap() - } - - fn open_store( - root: &std::path::Path, - workspace_id: &str, - ) -> (FeatureStorage, WorkspaceFeatureStorage, SubjektivStore) { - let manager = FeatureStorage::new(root); - let workspace = manager.workspace(workspace_id).unwrap(); - let registration = SubjektivStore::register(&workspace).unwrap(); - let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - (manager, workspace, store) - } - - #[test] - fn subject_listing_is_deterministic_bounded_and_read_only() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let mut expected = vec![ - store.create_subject(role()).unwrap(), - store.create_subject(role()).unwrap(), - store.create_subject(role()).unwrap(), - ]; - expected.sort_by(|left, right| { - left.created_at - .cmp(&right.created_at) - .then_with(|| left.id.cmp(&right.id)) - }); - - let page = store.list_subjects(2).unwrap(); - assert_eq!(page.items, expected[..2]); - assert!(page.has_more); - let after = page.items.last().unwrap(); - let next = store - .list_subjects_after(2, Some((&after.created_at, &after.id))) - .unwrap(); - assert_eq!(next.items, expected[2..]); - assert!(!next.has_more); - assert!(store.list_subjects(0).is_err()); - assert!(store.list_subjects(101).is_err()); - - assert!(store.subject("missing-subject").unwrap().is_none()); - let complete = store.list_subjects(3).unwrap(); - assert_eq!(complete.items, expected); - assert!(!complete.has_more); - } - - #[test] - fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store - .create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string()) - .unwrap(); - assert_eq!(subject.behavior_revision, 0); - assert_eq!(subject.store_revision, 0); - - let updated = store - .update_subject_behavior( - &subject.id, - 0, - "Prefer explicit evidence.\nAsk when uncertain.".to_string(), - ) - .unwrap(); - assert_eq!(updated.behavior_revision, 1); - assert_eq!(updated.store_revision, 0); - - store - .create_memory( - &subject.id, - draft( - "A confirmed observation", - "exercise Memory lifecycle isolation", - ), - ) - .unwrap(); - let after_memory = store.subject(&subject.id).unwrap().unwrap(); - assert_eq!(after_memory.behavior_md, updated.behavior_md); - assert_eq!(after_memory.behavior_revision, 1); - assert_eq!(after_memory.store_revision, 1); - - let (_reopened_manager, _reopened_workspace, reopened) = - open_store(temp.path(), "workspace-a"); - let persisted = reopened.subject(&subject.id).unwrap().unwrap(); - assert_eq!(persisted.behavior_md, updated.behavior_md); - assert_eq!(persisted.behavior_revision, 1); - assert!(matches!( - store.update_subject_behavior(&subject.id, 0, String::new()), - Err(SubjektivError::SubjectBehaviorConflict { - expected: 0, - actual: 1, - .. - }) - )); - - let unchanged = store - .update_subject_behavior(&subject.id, 1, updated.behavior_md.clone()) - .unwrap(); - assert_eq!(unchanged.behavior_revision, 1); - let cleared = store - .update_subject_behavior(&subject.id, 1, String::new()) - .unwrap(); - assert_eq!(cleared.behavior_revision, 2); - assert!(cleared.behavior_md.is_empty()); - assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err()); - assert!(validate_subject_behavior(" ").is_err()); - } - - #[test] - fn attributed_candidates_preserve_revision_and_surface_reference_continuity() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let session = attribution( - &subject.id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T09:00:00.000Z", - ); - - store.record_session_attribution(session.clone()).unwrap(); - let (first_candidate, stored_session) = store - .stage_candidate_with_attribution( - candidate(&subject.id, "candidate-1", "workspace-a"), - session.clone(), - ) - .unwrap(); - assert_eq!(stored_session, session); - assert_eq!( - first_candidate.source_refs[0].session_id.as_deref(), - Some("session-1") - ); - - let create_request = CandidateDecisionRequest { - request_id: "decision-create-1".into(), - candidate_id: first_candidate.id.clone(), - reason: "adopt the attributed candidate".into(), - decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Create, - draft: draft( - "Keep the first confirmed rule", - "initial attributed decision", - ), - }, - }; - let created = store - .decide_candidate(&subject.id, create_request.clone()) - .unwrap(); - let first_revision = created.memory.unwrap(); - assert_eq!(first_revision.revision, 1); - assert_eq!( - first_revision.source_candidate_ids, - [first_candidate.id.clone()] - ); - assert_eq!(created.resolution.affected_memory.len(), 1); - assert_eq!(created.resolution.affected_memory[0].revision, 1); - - // Exact decision retries return the original receipt rather than creating - // a second revision or resolution. - let replayed = store.decide_candidate(&subject.id, create_request).unwrap(); - assert_eq!(replayed.store_revision, created.store_revision); - assert_eq!(replayed.memory.as_ref().unwrap().revision, 1); - - let (second_candidate, _) = store - .stage_candidate_with_attribution( - candidate(&subject.id, "candidate-2", "workspace-a"), - session, - ) - .unwrap(); - let revised = store - .decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: "decision-revise-2".into(), - candidate_id: second_candidate.id.clone(), - reason: "apply the attributed correction".into(), - decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { - memory_id: first_revision.id.clone(), - expected_revision: 1, - }, - draft: draft("Keep the corrected confirmed rule", "attributed correction"), - }, - }, - ) - .unwrap(); - let second_revision = revised.memory.unwrap(); - assert_eq!(second_revision.revision, 2); - assert_eq!(second_revision.source_candidate_ids, [second_candidate.id]); - - let historical = store - .scoped_memory_revision(&subject.id, &first_revision.id, 1) - .unwrap() - .unwrap(); - assert_eq!(historical.source_candidate_ids, [first_candidate.id]); - assert_eq!( - store - .session_attribution("session-1") - .unwrap() - .unwrap() - .subject_id, - subject.id - ); - - let generation = store.prepare_surface_generation(&subject.id).unwrap(); - assert_eq!(generation.materials.len(), 1); - assert_eq!(generation.materials[0].memory_id, second_revision.id); - assert_eq!(generation.materials[0].revision, 2); - let expected_ref = MemoryRevisionRef { - memory_id: second_revision.id.clone(), - revision: second_revision.revision, - }; - let published = store - .publish_surface_generation( - &subject.id, - &generation.id, - vec![SurfacePoint { - body_md: "- Keep the corrected confirmed rule".into(), - memory_refs: vec![expected_ref.clone()], - }], - ) - .unwrap(); - assert_eq!(published.memory_refs, [expected_ref.clone()]); - assert_eq!(published.built_from_store_revision, revised.store_revision); - - let resident = store.resident_surface(&subject.id).unwrap(); - assert_eq!(resident.availability, SurfaceAvailability::Ready); - assert_eq!(resident.snapshot.unwrap().memory_refs, [expected_ref]); - } - - #[test] - fn versioned_json_roundtrips_and_rejects_invalid_shapes() { - let staging = candidate("subject-1", "candidate-1", "workspace-a"); - let raw = serde_json::to_string_pretty(&staging).unwrap(); - let decoded: SubjectStagingRecord = serde_json::from_str(&raw).unwrap(); - assert_eq!(decoded.schema_version, STAGING_SCHEMA_VERSION); - assert_eq!(decoded.subject_id, "subject-1"); - assert_eq!( - decoded.source_refs[0].session_id.as_deref(), - Some("session-1") - ); - assert!(decoded.revision_proposal.is_none()); - assert!(!raw.contains("revision_proposal")); - assert!(raw.contains("human_input")); - - let proposed = staging - .clone() - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, - "memory-1", - 3, - "Incorporate the new bounded evidence", - )) - .unwrap(); - let proposed_raw = serde_json::to_string(&proposed).unwrap(); - let proposed_decoded: SubjectStagingRecord = serde_json::from_str(&proposed_raw).unwrap(); - assert_eq!( - proposed_decoded.revision_proposal, - Some(proposal( - RevisionProposalIntent::Revise, - "memory-1", - 3, - "Incorporate the new bounded evidence", - )) - ); - assert!( - RevisionProposal::new( - RevisionProposalIntent::Revise, - "memory-1", - 0, - "invalid zero revision" - ) - .is_err() - ); - assert!( - RevisionProposal::new(RevisionProposalIntent::Revise, "memory-1", 1, " ").is_err() - ); - assert!( - serde_json::from_value::(serde_json::json!({ - "intent": "revise", - "memory_id": "memory-1", - "expected_revision": 0, - "change_reason": "invalid" - })) - .is_err() - ); - - let mut value = serde_json::to_value(&staging).unwrap(); - value - .as_object_mut() - .unwrap() - .insert("invented".into(), serde_json::json!(true)); - assert!(serde_json::from_value::(value).is_err()); - - let memory = MemoryRecord { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: "memory-1".into(), - subject_id: "subject-1".into(), - revision: 1, - kind: CandidateKind::Constraint, - state: MemoryState::Active, - claim: "Memory is not authority".into(), - body_md: "Applies to every tool call.".into(), - why_useful: "Avoids privilege confusion".into(), - staleness: None, - source_candidate_ids: vec!["candidate-1".into()], - derived_from: vec![], - change_reason: "Accepted the staged constraint".into(), - created_at: "2026-09-28T10:00:00.000Z".into(), - updated_at: "2026-09-28T10:00:00.000Z".into(), - }; - let raw = serde_json::to_string(&memory).unwrap(); - let decoded: MemoryRecord = serde_json::from_str(&raw).unwrap(); - assert_eq!(decoded.revision, 1); - assert!(serde_json::from_str::(&raw.replace("active", "unknown")).is_err()); - } - - #[test] - fn v1_store_migrates_before_attributed_staging() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let subject_id = { - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - let registration = workspace - .register(FeatureRegistration::new( - SUBJEKTIV_FEATURE_ID, - V1_MIGRATIONS, - )) - .unwrap(); - let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let timestamp = now(); - let subject_id = issued_id("subject"); - let mut raw = serde_json::json!({ - "schema_version": SUBJEKTIV_SCHEMA_VERSION, - "id": subject_id, - "role": role(), - "state": SubjectState::Active, - "store_revision": 0, - "created_at": timestamp.clone(), - "updated_at": timestamp.clone(), - }); - let raw = serde_json::to_string(&raw.take()).unwrap(); - store - .database - .try_transaction(|transaction| -> Result<()> { - transaction.execute( - "INSERT INTO subjects ( - subject_id, role, state, store_revision, - record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", - params![subject_id, role().as_str(), raw, timestamp, timestamp], - )?; - Ok(()) - }) - .unwrap(); - subject_id - }; - - let (_manager, _workspace, store) = open_store(&root, "workspace-a"); - let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); - assert!(migrated_subject.behavior_md.is_empty()); - assert_eq!(migrated_subject.behavior_revision, 0); - let lifecycle_attribution = attribution( - &subject_id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T10:00:00.000Z", - ); - store - .record_session_attribution(lifecycle_attribution.clone()) - .unwrap(); - let (staged, stored_attribution) = store - .stage_candidate_with_attribution( - candidate(&subject_id, "candidate-after-migration", "workspace-a"), - lifecycle_attribution, - ) - .unwrap(); - - assert_eq!(staged.subject_id, subject_id); - assert_eq!(stored_attribution.session_id, "session-1"); - assert_eq!( - store.session_attribution("session-1").unwrap().unwrap(), - stored_attribution - ); - } - - #[test] - fn legacy_surface_snapshot_migrates_as_stale_until_regenerated() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let (subject_id, snapshot_id) = { - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - let registration = workspace - .register(FeatureRegistration::new( - SUBJEKTIV_FEATURE_ID, - V3_MIGRATIONS, - )) - .unwrap(); - let store = SubjektivStore::open(&workspace, ®istration).unwrap(); - let subject_id = issued_id("subject"); - let timestamp = now(); - let subject_json = serde_json::to_string(&serde_json::json!({ - "schema_version": SUBJEKTIV_SCHEMA_VERSION, - "id": subject_id, - "role": role(), - "state": SubjectState::Active, - "store_revision": 0, - "created_at": timestamp, - "updated_at": timestamp, - })) - .unwrap(); - let snapshot = SurfaceSnapshot { - schema_version: SUBJEKTIV_SCHEMA_VERSION, - id: "legacy-surface".into(), - subject_id: subject_id.clone(), - body_md: "Legacy summary without generation-policy evidence.".into(), - memory_refs: Vec::new(), - built_from_store_revision: 0, - created_at: now(), - }; - store - .database - .try_transaction(|transaction| -> Result<()> { - transaction.execute( - "INSERT INTO subjects ( - subject_id, role, state, store_revision, - record_json, created_at, updated_at - ) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)", - params![ - subject_id, - role().as_str(), - subject_json, - timestamp, - timestamp - ], - )?; - transaction.execute( - "INSERT INTO surface_snapshots ( - subject_id, snapshot_id, built_from_store_revision, - snapshot_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - subject_id, - snapshot.id, - to_i64(snapshot.built_from_store_revision)?, - serde_json::to_string(&snapshot)?, - snapshot.created_at - ], - )?; - transaction.execute( - "INSERT INTO surface_snapshot_seals (subject_id, snapshot_id) - VALUES (?1, ?2)", - params![subject_id, snapshot.id], - )?; - Ok(()) - }) - .unwrap(); - (subject_id, snapshot.id) - }; - - let (_manager, _workspace, store) = open_store(&root, "workspace-a"); - let resident = store.resident_surface(&subject_id).unwrap(); - assert_eq!(resident.availability, SurfaceAvailability::Stale); - assert!(resident.snapshot.is_none()); - assert!( - store - .surface_snapshot(&subject_id, &snapshot_id) - .unwrap() - .is_some(), - "migration retains the legacy snapshot as history without injecting it" - ); - - let generation = store.prepare_surface_generation(&subject_id).unwrap(); - let regenerated = store - .publish_surface_generation(&subject_id, &generation.id, Vec::new()) - .unwrap(); - assert_ne!(regenerated.id, snapshot_id); - let ready = store.resident_surface(&subject_id).unwrap(); - assert_eq!(ready.availability, SurfaceAvailability::Ready); - assert_eq!(ready.snapshot.unwrap().id, regenerated.id); - assert!( - store - .surface_snapshot(&subject_id, &snapshot_id) - .unwrap() - .is_some(), - "regeneration appends instead of mutating legacy history" - ); - let retried = store - .publish_surface_generation(&subject_id, &generation.id, Vec::new()) - .unwrap(); - assert_eq!(retried.id, regenerated.id); - } - - #[test] - fn attributed_staging_requires_lifecycle_record_and_retry_is_idempotent() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let first_attribution = attribution( - &subject.id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T10:00:00.000Z", - ); - assert!(matches!( - store.stage_candidate_with_attribution( - candidate(&subject.id, "candidate-unattributed", "workspace-a"), - first_attribution.clone(), - ), - Err(SubjektivError::InvalidRecord(message)) - if message.contains("has not completed Host attribution") - )); - assert!(store.session_attribution("session-1").unwrap().is_none()); - assert!( - store - .staging_candidate(&subject.id, "candidate-unattributed") - .unwrap() - .is_none() - ); - store - .record_session_attribution(first_attribution.clone()) - .unwrap(); - let (first_candidate, first_attribution) = store - .stage_candidate_with_attribution( - candidate(&subject.id, "candidate-1", "workspace-a"), - first_attribution, - ) - .unwrap(); - - let mut retried_candidate = first_candidate.clone(); - retried_candidate.created_at = "2026-09-28T11:00:00.000Z".into(); - let retried_attribution = attribution( - &subject.id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T11:00:00.000Z", - ); - let (retried_candidate, retried_attribution) = store - .stage_candidate_with_attribution(retried_candidate, retried_attribution) - .unwrap(); - assert_eq!(retried_candidate.created_at, first_candidate.created_at); - assert_eq!(retried_attribution, first_attribution); - - let mut conflicting_candidate = candidate(&subject.id, "candidate-1", "workspace-a"); - conflicting_candidate.claim = "different content".into(); - conflicting_candidate.source_refs[0].session_id = Some("session-2".into()); - let conflicting_attribution = attribution( - &subject.id, - "runtime-1", - "worker-1", - "session-2", - "2026-09-28T12:00:00.000Z", - ); - store - .record_session_attribution(conflicting_attribution.clone()) - .unwrap(); - let result = - store.stage_candidate_with_attribution(conflicting_candidate, conflicting_attribution); - assert!(matches!( - result, - Err(SubjektivError::CandidateConflict(candidate_id)) - if candidate_id == "candidate-1" - )); - assert!(store.session_attribution("session-2").unwrap().is_some()); - } - - #[test] - fn attribution_pages_are_bounded_descending_and_snapshot_fixed() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - for (session, at) in [ - ("session-1", "2026-09-28T10:00:00.000Z"), - ("session-2", "2026-09-28T11:00:00.000Z"), - ("session-3", "2026-09-28T11:00:00.000Z"), - ] { - store - .record_session_attribution(attribution( - &subject.id, - "runtime-1", - "worker-1", - session, - at, - )) - .unwrap(); - } - - let first = store - .subject_session_attribution_page( - &subject.id, - None, - "2026-09-28T11:30:00.000Z", - None, - 2, - ) - .unwrap(); - assert_eq!( - first - .items - .iter() - .map(|item| item.session_id.as_str()) - .collect::>(), - vec!["session-2", "session-3"] - ); - assert!(first.has_more); - - store - .record_session_attribution(attribution( - &subject.id, - "runtime-2", - "worker-2", - "session-later", - "2026-09-28T12:00:00.000Z", - )) - .unwrap(); - let last = first.items.last().unwrap(); - let second = store - .subject_session_attribution_page( - &subject.id, - None, - "2026-09-28T11:30:00.000Z", - Some((&last.attributed_at, &last.session_id)), - 2, - ) - .unwrap(); - assert_eq!(second.items.len(), 1); - assert_eq!(second.items[0].session_id, "session-1"); - assert!(!second.has_more); - assert!( - second - .items - .iter() - .all(|item| item.session_id != "session-later") - ); - - let exact = store - .subject_session_attribution_page( - &subject.id, - Some("session-1"), - "2026-09-28T11:30:00.000Z", - None, - 20, - ) - .unwrap(); - assert_eq!(exact.items.len(), 1); - assert!(!exact.has_more); - } - - #[test] - fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let other_subject = store.create_subject(role()).unwrap(); - for lifecycle_attribution in [ - attribution( - &subject.id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T10:00:00.000Z", - ), - attribution( - &subject.id, - "runtime-2", - "worker-2", - "session-2", - "2026-09-28T11:00:00.000Z", - ), - ] { - store - .record_session_attribution(lifecycle_attribution) - .unwrap(); - } - - store - .stage_candidate_with_attribution( - candidate(&subject.id, "candidate-1", "workspace-a"), - attribution( - &subject.id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T10:00:00.000Z", - ), - ) - .unwrap(); - let mut replacement_candidate = candidate(&subject.id, "candidate-2", "workspace-a"); - replacement_candidate.source_refs[0].session_id = None; - let (replacement_candidate, _) = store - .stage_candidate_with_attribution( - replacement_candidate, - attribution( - &subject.id, - "runtime-2", - "worker-2", - "session-2", - "2026-09-28T11:00:00.000Z", - ), - ) - .unwrap(); - assert_eq!( - replacement_candidate.source_refs[0].session_id.as_deref(), - Some("session-2") - ); - - let history = store.subject_session_attributions(&subject.id).unwrap(); - assert_eq!(history.len(), 2); - assert_eq!(history[0].worker_id, "worker-1"); - assert_eq!(history[1].worker_id, "worker-2"); - assert!(history.iter().all(|entry| entry.subject_id == subject.id)); - - let update = store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "UPDATE subject_session_attributions SET worker_id = 'worker-other' - WHERE session_id = 'session-1'", - [], - )?; - Ok(()) - }); - assert!(update.is_err()); - let delete = store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "DELETE FROM subject_session_attributions WHERE session_id = 'session-1'", - [], - )?; - Ok(()) - }); - assert!(delete.is_err()); - - let mut foreign_candidate = - candidate(&other_subject.id, "candidate-foreign", "workspace-a"); - foreign_candidate.source_refs[0].session_id = Some("session-1".into()); - let result = store.stage_candidate_with_attribution( - foreign_candidate, - attribution( - &other_subject.id, - "runtime-1", - "worker-1", - "session-1", - "2026-09-28T12:00:00.000Z", - ), - ); - assert!(matches!( - result, - Err(SubjektivError::SessionSubjectConflict { - session_id, - existing_subject_id, - requested_subject_id, - }) if session_id == "session-1" - && existing_subject_id == subject.id - && requested_subject_id == other_subject.id - )); - assert!( - store - .staging_candidate(&other_subject.id, "candidate-foreign") - .unwrap() - .is_none() - ); - } - - #[test] - fn malformed_nested_provenance_is_rejected_without_staging() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - - let mut non_human_preference = - candidate(&subject.id, "candidate-preference", "workspace-a"); - non_human_preference.kind = CandidateKind::Preference; - non_human_preference.evidence[0] - .origin - .as_mut() - .unwrap() - .kind = EvidenceOriginKind::ModelOutput; - assert!(matches!( - store.stage_candidate(non_human_preference), - Err(SubjektivError::InvalidRecord(message)) - if message.contains("HumanInput") - )); - - let mut reversed = candidate(&subject.id, "candidate-reversed", "workspace-a"); - reversed.evidence[0].entry_range = Some([12, 10]); - assert!(matches!( - store.stage_candidate(reversed), - Err(SubjektivError::InvalidRecord(_)) - )); - - let mut duplicate = candidate(&subject.id, "candidate-duplicate", "workspace-a"); - duplicate.evidence.push(duplicate.evidence[0].clone()); - assert!(matches!( - store.stage_candidate(duplicate), - Err(SubjektivError::InvalidRecord(_)) - )); - - let mut dangling = candidate(&subject.id, "candidate-dangling", "workspace-a"); - dangling.source_refs[0].evidence_id = Some("missing-evidence".into()); - assert!(matches!( - store.stage_candidate(dangling), - Err(SubjektivError::InvalidRecord(_)) - )); - assert!( - store - .staging_candidate(&subject.id, "candidate-dangling") - .unwrap() - .is_none() - ); - } - - #[test] - fn revision_proposal_roundtrips_through_staging_and_resolution() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let memory = store - .create_memory(&subject.id, draft("Existing memory", "initial observation")) - .unwrap(); - let proposal = proposal( - RevisionProposalIntent::Revise, - &memory.id, - 1, - "Refine the wording from new evidence", - ); - let staged = store - .stage_candidate( - candidate(&subject.id, "candidate-proposed", "workspace-a") - .with_revision_proposal(proposal.clone()) - .unwrap(), - ) - .unwrap(); - assert_eq!(staged.revision_proposal, Some(proposal.clone())); - assert_eq!( - store - .staging_candidate(&subject.id, &staged.id) - .unwrap() - .unwrap() - .revision_proposal, - Some(proposal.clone()) - ); - - let resolution = store - .resolve_candidate( - &subject.id, - &staged.id, - StagingResolutionAction::Discarded, - "The current wording remains preferable", - vec![], - ) - .unwrap(); - assert_eq!( - resolution.candidate.revision_proposal, - Some(proposal.clone()) - ); - assert_eq!( - store - .staging_resolution(&subject.id, &staged.id) - .unwrap() - .unwrap() - .candidate - .revision_proposal, - Some(proposal) - ); - } - - #[test] - fn atomic_candidate_decision_is_idempotent_and_preserves_proposal_metadata() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let memory = store - .create_memory(&subject.id, draft("Original", "initial observation")) - .unwrap(); - let change_reason = "The remembered condition has ended"; - let staged = store - .stage_candidate( - candidate(&subject.id, "candidate-resolve-atomic", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Resolve, - &memory.id, - memory.revision, - change_reason, - )) - .unwrap(), - ) - .unwrap(); - let mut revised = draft("Resolved", change_reason); - revised.state = MemoryState::Resolved; - let request = CandidateDecisionRequest { - request_id: "decision-resolve-1".into(), - candidate_id: staged.id.clone(), - reason: "Accepted exact resolution proposal".into(), - decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { - memory_id: memory.id.clone(), - expected_revision: memory.revision, - }, - draft: revised, - }, - }; - - let first = store - .decide_candidate(&subject.id, request.clone()) - .unwrap(); - let retried = store.decide_candidate(&subject.id, request).unwrap(); - - assert_eq!(first.memory.as_ref().unwrap().revision, 2); - assert_eq!(retried.memory.as_ref().unwrap().revision, 2); - assert_eq!(first.resolution.id, retried.resolution.id); - assert_eq!(first.store_revision, 2); - assert!(first.surface_dirty); - assert_eq!( - first - .resolution - .candidate - .revision_proposal - .unwrap() - .change_reason, - change_reason - ); - assert_eq!( - store - .list_memory_revisions(&subject.id, &memory.id) - .unwrap() - .len(), - 2 - ); - } - - #[test] - fn candidate_decision_revalidates_stale_proposals_and_intent_transitions() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let memory = store - .create_memory(&subject.id, draft("Lifecycle", "initial")) - .unwrap(); - let stale = store - .stage_candidate( - candidate(&subject.id, "candidate-stale-apply", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, - &memory.id, - 1, - "stale correction", - )) - .unwrap(), - ) - .unwrap(); - store - .revise_memory(&subject.id, &memory.id, 1, draft("Advanced", "advance")) - .unwrap(); - let stale_result = store.decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: "decision-stale".into(), - candidate_id: stale.id.clone(), - reason: "must not auto-rebase".into(), - decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { - memory_id: memory.id.clone(), - expected_revision: 1, - }, - draft: draft("Stale", "stale correction"), - }, - }, - ); - assert!(matches!( - stale_result, - Err(SubjektivError::RevisionConflict { - expected: 1, - actual: 2, - .. - }) - )); - assert!( - store - .staging_resolution(&subject.id, &stale.id) - .unwrap() - .is_none() - ); - - let apply_intent = |store: &SubjektivStore, - candidate_id: &str, - intent: RevisionProposalIntent, - expected_revision: u64, - state: MemoryState, - reason: &str| { - let staged = store - .stage_candidate( - candidate(&subject.id, candidate_id, "workspace-a") - .with_revision_proposal(proposal( - intent, - &memory.id, - expected_revision, - reason, - )) - .unwrap(), - ) - .unwrap(); - let mut next = draft(candidate_id, reason); - next.state = state; - store - .decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: format!("decision-{candidate_id}"), - candidate_id: staged.id, - reason: format!("apply {candidate_id}"), - decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Revise { - memory_id: memory.id.clone(), - expected_revision, - }, - draft: next, - }, - }, - ) - .unwrap() - .memory - .unwrap() - }; - let resolved = apply_intent( - &store, - "resolve", - RevisionProposalIntent::Resolve, - 2, - MemoryState::Resolved, - "resolve reason", - ); - let reopened = apply_intent( - &store, - "reopen", - RevisionProposalIntent::Reopen, - resolved.revision, - MemoryState::Active, - "reopen reason", - ); - let retracted = apply_intent( - &store, - "retract", - RevisionProposalIntent::Retract, - reopened.revision, - MemoryState::Retracted, - "retract reason", - ); - assert_eq!(retracted.state, MemoryState::Retracted); - assert!(matches!( - store.stage_candidate( - candidate(&subject.id, "post-retract", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Reopen, - &memory.id, - retracted.revision, - "cannot revive", - )) - .unwrap() - ), - Err(SubjektivError::InvalidStateTransition { .. }) - )); - } - - #[test] - fn candidate_decisions_reject_conflicts_and_roll_back_partial_writes() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let other = store.create_subject(role()).unwrap(); - let staged = store - .stage_candidate(candidate(&subject.id, "candidate-rollback", "workspace-a")) - .unwrap(); - let foreign = store - .stage_candidate(candidate(&other.id, "candidate-foreign", "workspace-a")) - .unwrap(); - let mut invalid = draft("Would be written", "must roll back"); - invalid.derived_from.push(MemoryRevisionRef { - memory_id: "missing-memory".into(), - revision: 1, - }); - let failed = store.decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: "decision-rollback".into(), - candidate_id: staged.id.clone(), - reason: "invalid derivation".into(), - decision: CandidateDecision::Apply { - target: MemoryRevisionTarget::Create, - draft: invalid, - }, - }, - ); - assert!(matches!( - failed, - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - assert!( - store - .staging_resolution(&subject.id, &staged.id) - .unwrap() - .is_none() - ); - assert!(store.list_memories(&subject.id).unwrap().is_empty()); - - let cross_subject = store.decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: "decision-foreign".into(), - candidate_id: foreign.id, - reason: "must remain scoped".into(), - decision: CandidateDecision::Close { - action: StagingResolutionAction::Invalid, - affected_memory: vec![], - }, - }, - ); - assert!(matches!( - cross_subject, - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - - let closed = store - .decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: "decision-close".into(), - candidate_id: staged.id.clone(), - reason: "already represented elsewhere".into(), - decision: CandidateDecision::Close { - action: StagingResolutionAction::AlreadyCovered, - affected_memory: vec![], - }, - }, - ) - .unwrap(); - assert!(!closed.surface_dirty); - assert!(closed.memory.is_none()); - for (suffix, action) in [ - ("discarded", StagingResolutionAction::Discarded), - ("invalid", StagingResolutionAction::Invalid), - ("duplicate", StagingResolutionAction::Duplicate), - ] { - let candidate_id = format!("candidate-{suffix}"); - store - .stage_candidate(candidate(&subject.id, &candidate_id, "workspace-a")) - .unwrap(); - let receipt = store - .decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: format!("decision-{suffix}"), - candidate_id, - reason: format!("specific {suffix} reason"), - decision: CandidateDecision::Close { - action, - affected_memory: vec![], - }, - }, - ) - .unwrap(); - assert_eq!(receipt.resolution.action, action); - assert!(!receipt.surface_dirty); - } - let conflicting_retry = store.decide_candidate( - &subject.id, - CandidateDecisionRequest { - request_id: "decision-close".into(), - candidate_id: staged.id, - reason: "different disposition".into(), - decision: CandidateDecision::Close { - action: StagingResolutionAction::Discarded, - affected_memory: vec![], - }, - }, - ); - assert!(matches!( - conflicting_retry, - Err(SubjektivError::DecisionRequestConflict(_)) - )); - } - - #[test] - fn revision_proposals_enforce_scope_revision_and_state_transitions() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let other_subject = store.create_subject(role()).unwrap(); - let memory = store - .create_memory( - &subject.id, - draft("Transition target", "initial observation"), - ) - .unwrap(); - - let foreign = candidate(&other_subject.id, "candidate-foreign-target", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, - &memory.id, - 1, - "Must remain subject scoped", - )) - .unwrap(); - assert!(matches!( - store.stage_candidate(foreign), - Err(SubjektivError::SubjectScopeMismatch { - subject_id, - reference, - }) if subject_id == other_subject.id && reference == memory.id - )); - - let stale = candidate(&subject.id, "candidate-stale", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Resolve, - &memory.id, - 2, - "Resolve the active memory", - )) - .unwrap(); - assert!(matches!( - store.stage_candidate(stale), - Err(SubjektivError::RevisionConflict { - memory_id, - expected: 2, - actual: 1, - }) if memory_id == memory.id - )); - - let resolve_proposal = candidate(&subject.id, "candidate-resolve", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Resolve, - &memory.id, - 1, - "The active condition has ended", - )) - .unwrap(); - assert!(store.stage_candidate(resolve_proposal).is_ok()); - let invalid_reopen = candidate(&subject.id, "candidate-invalid-reopen", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Reopen, - &memory.id, - 1, - "An active Memory cannot be reopened", - )) - .unwrap(); - assert!(matches!( - store.stage_candidate(invalid_reopen), - Err(SubjektivError::InvalidStateTransition { - from: MemoryState::Active, - to: MemoryState::Active, - .. - }) - )); - - let mut resolve = draft("Resolved target", "the condition ended"); - resolve.state = MemoryState::Resolved; - let resolved = store - .revise_memory(&subject.id, &memory.id, 1, resolve) - .unwrap(); - let mut attributed_stale = - candidate(&subject.id, "candidate-attributed-stale", "workspace-a"); - attributed_stale.source_refs[0].session_id = None; - attributed_stale - .attach_revision_proposal(proposal( - RevisionProposalIntent::Revise, - &memory.id, - 1, - "This attribution must roll back with the stale proposal", - )) - .unwrap(); - let stale_attribution = attribution( - &subject.id, - "runtime-proposal", - "worker-proposal", - "session-proposal-stale", - "2026-09-28T12:00:00.000Z", - ); - store - .record_session_attribution(stale_attribution.clone()) - .unwrap(); - assert!(matches!( - store.stage_candidate_with_attribution(attributed_stale, stale_attribution,), - Err(SubjektivError::RevisionConflict { - expected: 1, - actual: 2, - .. - }) - )); - assert!( - store - .session_attribution("session-proposal-stale") - .unwrap() - .is_some() - ); - let reopen = candidate(&subject.id, "candidate-reopen", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Reopen, - &memory.id, - resolved.revision, - "The condition applies again", - )) - .unwrap(); - assert_eq!( - store - .stage_candidate(reopen) - .unwrap() - .revision_proposal - .unwrap() - .intent, - RevisionProposalIntent::Reopen - ); - let retract_proposal = candidate(&subject.id, "candidate-retract", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Retract, - &memory.id, - resolved.revision, - "The supporting evidence was invalidated", - )) - .unwrap(); - assert!(store.stage_candidate(retract_proposal).is_ok()); - - let mut retract = draft("Retracted target", "the evidence was invalidated"); - retract.state = MemoryState::Retracted; - let retracted = store - .revise_memory(&subject.id, &memory.id, resolved.revision, retract) - .unwrap(); - let terminal = candidate(&subject.id, "candidate-after-retract", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, - &memory.id, - retracted.revision, - "Retraction must remain terminal", - )) - .unwrap(); - assert!(matches!( - store.stage_candidate(terminal), - Err(SubjektivError::InvalidStateTransition { - memory_id, - from: MemoryState::Retracted, - to: MemoryState::Retracted, - }) if memory_id == memory.id - )); - } - - #[test] - fn exact_proposal_retry_remains_idempotent_after_target_advances() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let memory = store - .create_memory(&subject.id, draft("Retry target", "initial observation")) - .unwrap(); - let staged = store - .stage_candidate( - candidate(&subject.id, "candidate-retry-proposal", "workspace-a") - .with_revision_proposal(proposal( - RevisionProposalIntent::Revise, - &memory.id, - 1, - "Refine the current revision", - )) - .unwrap(), - ) - .unwrap(); - store - .revise_memory( - &subject.id, - &memory.id, - 1, - draft("Advanced target", "another accepted refinement"), - ) - .unwrap(); - - let mut retry = staged.clone(); - retry.created_at = "2026-09-28T11:00:00.000Z".into(); - let retried = store.stage_candidate(retry).unwrap(); - assert_eq!(retried.created_at, staged.created_at); - assert_eq!(retried.revision_proposal, staged.revision_proposal); - } - - #[test] - fn bounded_memory_reads_are_scoped_ordered_and_immutable() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let other_subject = store.create_subject(role()).unwrap(); - assert!(matches!( - store.list_memories("subject-missing"), - Err(SubjektivError::SubjectNotFound(subject_id)) if subject_id == "subject-missing" - )); - - let mut first_draft = draft("First memory", "first reason"); - first_draft.body_md = "Immutable first body".into(); - let first = store.create_memory(&subject.id, first_draft).unwrap(); - thread::sleep(Duration::from_millis(2)); - let second = store - .create_memory(&subject.id, draft("Second memory", "second reason")) - .unwrap(); - let memories = store.list_memories(&subject.id).unwrap(); - assert_eq!( - memories.iter().map(|record| &record.id).collect::>(), - vec![&second.id, &first.id] - ); - - thread::sleep(Duration::from_millis(2)); - let mut revision = draft("First memory revised", "second revision reason"); - revision.body_md = "New second body".into(); - let revised = store - .revise_memory(&subject.id, &first.id, 1, revision) - .unwrap(); - let memories = store.list_memories(&subject.id).unwrap(); - assert_eq!(memories[0].id, first.id); - assert_eq!(memories[0].revision, 2); - - let history = store.list_memory_revisions(&subject.id, &first.id).unwrap(); - assert_eq!( - history - .iter() - .map(|record| record.revision) - .collect::>(), - vec![2, 1] - ); - assert_eq!(history[0].body_md, "New second body"); - assert_eq!(history[0].change_reason, "second revision reason"); - assert_eq!(history[1].body_md, "Immutable first body"); - assert_eq!(history[1].change_reason, "first reason"); - assert_eq!( - store - .scoped_memory_revision(&subject.id, &first.id, 1) - .unwrap() - .unwrap() - .body_md, - "Immutable first body" - ); - let scoped_current = store.scoped_memory(&subject.id, &first.id).unwrap(); - assert_eq!(scoped_current.id, revised.id); - assert_eq!(scoped_current.revision, revised.revision); - assert_eq!(scoped_current.body_md, revised.body_md); - - assert!(matches!( - store.list_memory_revisions(&other_subject.id, &first.id), - Err(SubjektivError::SubjectScopeMismatch { - subject_id, - reference, - }) if subject_id == other_subject.id && reference == first.id - )); - assert!(matches!( - store.scoped_memory(&other_subject.id, &first.id), - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - } - - #[test] - fn candidate_apply_revision_history_and_resolution_are_atomic() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let staged = store - .stage_candidate(candidate(&subject.id, "candidate-1", "workspace-a")) - .unwrap(); - let mut retry = staged.clone(); - retry.created_at = "2026-09-28T11:00:00.000Z".into(); - let retried = store.stage_candidate(retry).unwrap(); - assert_eq!(retried.created_at, staged.created_at); - - let (first, resolution) = store - .apply_candidate( - &subject.id, - &staged.id, - MemoryRevisionTarget::Create, - draft( - "Memory content cannot authorize operations", - "accepted candidate", - ), - "Applied as a durable constraint", - ) - .unwrap(); - assert_eq!(first.revision, 1); - assert_eq!(resolution.action, StagingResolutionAction::Applied); - assert_eq!(resolution.affected_memory[0].revision, 1); - assert_eq!( - store.subject(&subject.id).unwrap().unwrap().store_revision, - 1 - ); - - let unrelated = store - .stage_candidate(candidate(&subject.id, "candidate-unrelated", "workspace-a")) - .unwrap(); - assert!(matches!( - store.resolve_candidate( - &subject.id, - &unrelated.id, - StagingResolutionAction::Applied, - "must not bypass atomic application", - vec![MemoryRevisionRef { - memory_id: first.id.clone(), - revision: 1, - }], - ), - Err(SubjektivError::InvalidRecord(_)) - )); - let mut bypass = draft("Bypass provenance", "must be rejected"); - bypass.source_candidate_ids.push(unrelated.id.clone()); - assert!(matches!( - store.create_memory(&subject.id, bypass), - Err(SubjektivError::InvalidRecord(_)) - )); - assert!( - store - .staging_resolution(&subject.id, &unrelated.id) - .unwrap() - .is_none() - ); - - let stale = store.revise_memory( - &subject.id, - &first.id, - 0, - draft("stale write", "wrong expected revision"), - ); - assert!(matches!( - stale, - Err(SubjektivError::RevisionConflict { - expected: 0, - actual: 1, - .. - }) - )); - assert_eq!( - store - .memory(&subject.id, &first.id) - .unwrap() - .unwrap() - .revision, - 1 - ); - - let mut second_draft = draft( - "Memory content never authorizes operations", - "made the scope explicit", - ); - second_draft.state = MemoryState::Resolved; - second_draft.derived_from.push(MemoryRevisionRef { - memory_id: first.id.clone(), - revision: 1, - }); - let second = store - .revise_memory(&subject.id, &first.id, 1, second_draft) - .unwrap(); - assert_eq!(second.revision, 2); - assert_eq!(second.state, MemoryState::Resolved); - assert_eq!(second.derived_from[0].revision, 1); - - let old = store - .memory_revision(&subject.id, &first.id, 1) - .unwrap() - .unwrap(); - assert_eq!(old.claim, "Memory content cannot authorize operations"); - assert_eq!(old.source_candidate_ids, ["candidate-1"]); - assert_eq!( - store.subject(&subject.id).unwrap().unwrap().store_revision, - 2 - ); - - let repeat = store.apply_candidate( - &subject.id, - &staged.id, - MemoryRevisionTarget::Revise { - memory_id: first.id.clone(), - expected_revision: 2, - }, - draft("must roll back", "candidate already resolved"), - "cannot apply twice", - ); - assert!(matches!(repeat, Err(SubjektivError::CandidateResolved(_)))); - assert_eq!( - store - .memory(&subject.id, &first.id) - .unwrap() - .unwrap() - .revision, - 2 - ); - assert_eq!( - store.subject(&subject.id).unwrap().unwrap().store_revision, - 2 - ); - } - - #[test] - fn concurrent_writers_get_one_typed_revision_conflict() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let first_manager = FeatureStorage::new(&root); - let first_registration = first_manager.register(REGISTRATION).unwrap(); - let first_workspace = first_manager.workspace("workspace-a").unwrap(); - let first_store = SubjektivStore::open(&first_workspace, &first_registration).unwrap(); - let subject = first_store.create_subject(role()).unwrap(); - let memory = first_store - .create_memory(&subject.id, draft("Revision basis", "initial")) - .unwrap(); - - let second_manager = FeatureStorage::new(&root); - let second_registration = second_manager.register(REGISTRATION).unwrap(); - let second_workspace = second_manager.workspace("workspace-a").unwrap(); - let second_store = SubjektivStore::open(&second_workspace, &second_registration).unwrap(); - let barrier = Arc::new(Barrier::new(3)); - let revise = |store: SubjektivStore, claim: &'static str, barrier: Arc| { - let subject_id = subject.id.clone(); - let memory_id = memory.id.clone(); - thread::spawn(move || { - barrier.wait(); - store.revise_memory( - &subject_id, - &memory_id, - 1, - draft(claim, "concurrent refinement"), - ) - }) - }; - let first = revise(first_store.clone(), "first writer", barrier.clone()); - let second = revise(second_store, "second writer", barrier.clone()); - barrier.wait(); - let results = [first.join().unwrap(), second.join().unwrap()]; - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!( - results - .iter() - .filter(|result| matches!(result, Err(SubjektivError::RevisionConflict { .. }))) - .count(), - 1 - ); - assert_eq!( - first_store - .subject(&subject.id) - .unwrap() - .unwrap() - .store_revision, - 2 - ); - } - - #[test] - fn scope_and_fixed_revision_references_are_enforced() { - let temp = tempfile::tempdir().unwrap(); - let manager = FeatureStorage::new(temp.path().join("storage")); - let registration = manager.register(REGISTRATION).unwrap(); - let workspace_a = manager.workspace("workspace-a").unwrap(); - let workspace_b = manager.workspace("workspace-b").unwrap(); - let store_a = SubjektivStore::open(&workspace_a, ®istration).unwrap(); - let store_b = SubjektivStore::open(&workspace_b, ®istration).unwrap(); - let subject_a = store_a.create_subject(role()).unwrap(); - let other_subject_a = store_a.create_subject(role()).unwrap(); - let subject_b = store_b.create_subject(role()).unwrap(); - let memory_a = store_a - .create_memory( - &subject_a.id, - draft("Workspace A experience", "direct host record"), - ) - .unwrap(); - - assert!(matches!( - store_b.create_memory(&subject_a.id, draft("foreign subject", "must be rejected")), - Err(SubjektivError::SubjectNotFound(_)) - )); - let mut foreign_derived = draft("Workspace B experience", "invalid derivation"); - foreign_derived.derived_from.push(MemoryRevisionRef { - memory_id: memory_a.id.clone(), - revision: 1, - }); - assert!(matches!( - store_b.create_memory(&subject_b.id, foreign_derived), - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - assert_eq!( - store_b - .subject(&subject_b.id) - .unwrap() - .unwrap() - .store_revision, - 0 - ); - - let mut other_subject_derived = draft("Same Workspace, other subject", "invalid scope"); - other_subject_derived.derived_from.push(MemoryRevisionRef { - memory_id: memory_a.id.clone(), - revision: 1, - }); - assert!(matches!( - store_a.create_memory(&other_subject_a.id, other_subject_derived), - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - let mut wrong_revision = draft("Missing fixed revision", "invalid revision"); - wrong_revision.derived_from.push(MemoryRevisionRef { - memory_id: memory_a.id.clone(), - revision: 2, - }); - assert!(matches!( - store_a.create_memory(&subject_a.id, wrong_revision), - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - assert_eq!( - store_a - .subject(&other_subject_a.id) - .unwrap() - .unwrap() - .store_revision, - 0 - ); - - let foreign_origin = candidate(&subject_a.id, "candidate-foreign", "workspace-b"); - assert!(matches!( - store_a.stage_candidate(foreign_origin), - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - } - - #[test] - fn memory_states_snapshots_and_retirement_have_explicit_boundaries() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let first = store - .create_memory(&subject.id, draft("A constraint", "initial observation")) - .unwrap(); - let snapshot = store - .create_surface_snapshot( - &subject.id, - "Bounded resident summary", - vec![MemoryRevisionRef { - memory_id: first.id.clone(), - revision: 1, - }], - 1, - ) - .unwrap(); - assert_eq!( - store - .surface_snapshot(&subject.id, &snapshot.id) - .unwrap() - .unwrap() - .memory_refs[0] - .revision, - 1 - ); - assert!( - store - .create_surface_snapshot(&subject.id, "stale", vec![], 0) - .is_err() - ); - - let mut retract = draft("Retracted constraint", "evidence invalidated it"); - retract.state = MemoryState::Retracted; - let retracted = store - .revise_memory(&subject.id, &first.id, 1, retract) - .unwrap(); - assert_eq!(retracted.state, MemoryState::Retracted); - assert!(matches!( - store.revise_memory( - &subject.id, - &first.id, - 2, - draft("cannot reactivate", "retraction is terminal") - ), - Err(SubjektivError::InvalidStateTransition { .. }) - )); - let mut retract_again = draft("still retracted", "no post-retraction revisions"); - retract_again.state = MemoryState::Retracted; - assert!(matches!( - store.revise_memory(&subject.id, &first.id, 2, retract_again), - Err(SubjektivError::InvalidStateTransition { .. }) - )); - - let retired = store.retire_subject(&subject.id).unwrap(); - assert_eq!(retired.state, SubjectState::Retired); - assert!(matches!( - store.create_memory(&subject.id, draft("after retirement", "invalid")), - Err(SubjektivError::SubjectRetired(_)) - )); - assert!(store.memory(&subject.id, &first.id).unwrap().is_some()); - } - - #[test] - fn history_rows_reject_update_and_delete() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let staged = store - .stage_candidate(candidate(&subject.id, "candidate-1", "workspace-a")) - .unwrap(); - let (memory, _) = store - .apply_candidate( - &subject.id, - &staged.id, - MemoryRevisionTarget::Create, - draft("Immutable history", "accept"), - "applied", - ) - .unwrap(); - let extra_candidate = store - .stage_candidate(candidate(&subject.id, "candidate-extra", "workspace-a")) - .unwrap(); - let other_memory = store - .create_memory(&subject.id, draft("Other memory", "snapshot seal target")) - .unwrap(); - let snapshot = store - .create_surface_snapshot( - &subject.id, - "sealed snapshot", - vec![MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: 1, - }], - 2, - ) - .unwrap(); - - let update = store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "UPDATE memory_revisions SET record_json = '{}' \ - WHERE subject_id = ?1 AND memory_id = ?2 AND revision = 1", - params![subject.id, memory.id], - )?; - Ok(()) - }); - assert!(update.is_err()); - let delete = store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "DELETE FROM staging_records WHERE subject_id = ?1 AND candidate_id = ?2", - params![subject.id, staged.id], - )?; - Ok(()) - }); - assert!(delete.is_err()); - - let late_evidence = store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "INSERT INTO memory_revision_candidates ( - subject_id, memory_id, revision, candidate_id - ) VALUES (?1, ?2, 1, ?3)", - params![subject.id, memory.id, extra_candidate.id], - )?; - Ok(()) - }); - assert!(late_evidence.is_err()); - let late_derivation = - store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "INSERT INTO memory_revision_derivations ( - subject_id, memory_id, revision, source_memory_id, source_revision - ) VALUES (?1, ?2, 1, ?3, 1)", - params![subject.id, memory.id, other_memory.id], - )?; - Ok(()) - }); - assert!(late_derivation.is_err()); - let late_resolution_target = - store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "INSERT INTO staging_resolution_targets ( - subject_id, candidate_id, memory_id, revision - ) VALUES (?1, ?2, ?3, 1)", - params![subject.id, staged.id, other_memory.id], - )?; - Ok(()) - }); - assert!(late_resolution_target.is_err()); - let late_snapshot_ref = - store - .database - .try_with_connection::<_, SubjektivError>(|connection| { - connection.execute( - "INSERT INTO surface_snapshot_refs ( - subject_id, snapshot_id, memory_id, revision - ) VALUES (?1, ?2, ?3, 1)", - params![subject.id, snapshot.id, other_memory.id], - )?; - Ok(()) - }); - assert!(late_snapshot_ref.is_err()); - - assert!( - store - .memory_revision(&subject.id, &memory.id, 1) - .unwrap() - .is_some() - ); - assert!( - store - .staging_candidate(&subject.id, &staged.id) - .unwrap() - .is_some() - ); - } - - #[test] - fn surface_material_selection_is_scoped_active_bounded_and_deterministic() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let other = store.create_subject(role()).unwrap(); - store - .create_memory(&other.id, draft("foreign", "foreign subject")) - .unwrap(); - - let kinds = [ - CandidateKind::Preference, - CandidateKind::Constraint, - CandidateKind::Decision, - CandidateKind::WorkingAssumption, - CandidateKind::OpenQuestion, - CandidateKind::Lesson, - ]; - let mut first_id = None; - for kind in &kinds { - for index in 0..9 { - let mut value = draft(&format!("{kind:?}-{index}"), "selection fixture"); - value.kind = kind.clone(); - let memory = store.create_memory(&subject.id, value).unwrap(); - first_id.get_or_insert(memory.id); - } - } - let resolved_id = first_id.unwrap(); - let current = store.memory(&subject.id, &resolved_id).unwrap().unwrap(); - let mut resolved = draft("resolved", "no longer active"); - resolved.kind = current.kind; - resolved.state = MemoryState::Resolved; - store - .revise_memory(&subject.id, &resolved_id, current.revision, resolved) - .unwrap(); - - let first = store.prepare_surface_generation(&subject.id).unwrap(); - let second = store.prepare_surface_generation(&subject.id).unwrap(); - assert_eq!(first.materials, second.materials); - assert_eq!(first.active_memory_count, 53); - assert_eq!(first.materials.len(), SURFACE_TOTAL_MATERIAL_LIMIT); - assert!( - first - .materials - .iter() - .all(|item| item.memory_id != resolved_id) - ); - assert!(first.materials.iter().all(|item| item.body_md != "foreign")); - for kind in &kinds { - assert!( - first - .materials - .iter() - .filter(|item| &item.kind == kind) - .count() - <= SURFACE_PER_KIND_LIMIT - ); - } - let serialized = serde_json::to_vec(&first.materials).unwrap(); - assert!(estimated_tokens_for_bytes(serialized.len()) <= SURFACE_MATERIAL_TOKEN_BUDGET); - } - - #[test] - fn oversized_kind_cannot_starve_other_surface_categories() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - - let mut oversized = draft("oversized preference", "category fairness fixture"); - oversized.kind = CandidateKind::Preference; - oversized.body_md = "x".repeat((SURFACE_MATERIAL_TOKEN_BUDGET - 100) * 4); - store.create_memory(&subject.id, oversized).unwrap(); - - for kind in [ - CandidateKind::Constraint, - CandidateKind::Decision, - CandidateKind::WorkingAssumption, - CandidateKind::OpenQuestion, - CandidateKind::Lesson, - ] { - let mut value = draft(&format!("{kind:?}"), "category fairness fixture"); - value.kind = kind; - store.create_memory(&subject.id, value).unwrap(); - } - - let generation = store.prepare_surface_generation(&subject.id).unwrap(); - for kind in [ - CandidateKind::Constraint, - CandidateKind::Decision, - CandidateKind::WorkingAssumption, - CandidateKind::OpenQuestion, - CandidateKind::Lesson, - ] { - assert!( - generation.materials.iter().any(|item| item.kind == kind), - "one large category must not exclude {kind:?}" - ); - } - assert!( - estimated_tokens_for_bytes(serde_json::to_vec(&generation.materials).unwrap().len()) - <= SURFACE_MATERIAL_TOKEN_BUDGET - ); - } - - #[test] - fn surface_material_selection_reclaims_unused_category_budget() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - - let mut small = draft("small preference", "soft category budget fixture"); - small.kind = CandidateKind::Preference; - store.create_memory(&subject.id, small).unwrap(); - let mut large = draft("large constraint", "soft category budget fixture"); - large.kind = CandidateKind::Constraint; - large.body_md = "x".repeat(22_000); - store.create_memory(&subject.id, large).unwrap(); - - let generation = store.prepare_surface_generation(&subject.id).unwrap(); - assert_eq!(generation.materials.len(), 2); - assert_eq!(generation.materials[0].kind, CandidateKind::Preference); - assert_eq!(generation.materials[1].kind, CandidateKind::Constraint); - assert!( - estimated_tokens_for_bytes(serde_json::to_vec(&generation.materials).unwrap().len()) - <= SURFACE_MATERIAL_TOKEN_BUDGET - ); - } - - #[test] - fn surface_states_validation_races_and_idempotency_fail_closed() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Ungenerated - ); - - let empty_generation = store.prepare_surface_generation(&subject.id).unwrap(); - let empty = store - .publish_surface_generation(&subject.id, &empty_generation.id, Vec::new()) - .unwrap(); - assert!(empty.body_md.is_empty()); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Ready - ); - - let memory = store - .create_memory(&subject.id, draft("Current constraint", "confirmed")) - .unwrap(); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Stale - ); - let stale_generation = store.prepare_surface_generation(&subject.id).unwrap(); - let invalid = store.publish_surface_generation( - &subject.id, - &stale_generation.id, - vec![SurfacePoint { - body_md: "Invented".into(), - memory_refs: vec![MemoryRevisionRef { - memory_id: "foreign-memory".into(), - revision: 1, - }], - }], - ); - assert!(matches!( - invalid, - Err(SubjektivError::SubjectScopeMismatch { .. }) - )); - store - .fail_surface_generation(&subject.id, &stale_generation.id, "invalid_output") - .unwrap(); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Failed - ); - assert_eq!( - store - .memory(&subject.id, &memory.id) - .unwrap() - .unwrap() - .revision, - 1, - "surface failure must not roll back confirmed Memory" - ); - - let raced = store.prepare_surface_generation(&subject.id).unwrap(); - store - .revise_memory( - &subject.id, - &memory.id, - 1, - draft("Corrected constraint", "correction"), - ) - .unwrap(); - assert!(matches!( - store.publish_surface_generation( - &subject.id, - &raced.id, - vec![SurfacePoint { - body_md: "Old wording".into(), - memory_refs: vec![MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: 1, - }], - }], - ), - Err(SubjektivError::SurfaceGenerationConflict(_)) - )); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Stale - ); - - let current = store.prepare_surface_generation(&subject.id).unwrap(); - let current_ref = MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: 2, - }; - let points = vec![SurfacePoint { - body_md: "Keep the corrected constraint, including its condition.".into(), - memory_refs: vec![current_ref.clone()], - }]; - let published = store - .publish_surface_generation(&subject.id, ¤t.id, points.clone()) - .unwrap(); - let retry = store - .publish_surface_generation(&subject.id, ¤t.id, points) - .unwrap(); - assert_eq!(published.id, retry.id); - let resident = store.resident_surface(&subject.id).unwrap(); - assert_eq!(resident.availability, SurfaceAvailability::Ready); - assert_eq!(resident.snapshot.unwrap().memory_refs, vec![current_ref]); - } - - #[test] - fn active_memory_that_cannot_fit_is_a_failure_not_a_normal_empty_surface() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let mut oversized = draft("oversized", "fixture"); - oversized.body_md = "x".repeat((SURFACE_MATERIAL_TOKEN_BUDGET + 1) * 4); - store.create_memory(&subject.id, oversized).unwrap(); - - let generation = store.prepare_surface_generation(&subject.id).unwrap(); - assert_eq!(generation.active_memory_count, 1); - assert!(generation.materials.is_empty()); - assert!( - store - .publish_surface_generation(&subject.id, &generation.id, Vec::new()) - .is_err() - ); - store - .fail_surface_generation(&subject.id, &generation.id, "input_budget_exhausted") - .unwrap(); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Failed - ); - } - - #[test] - fn concurrent_surface_generations_cannot_overwrite_one_revision() { - let temp = tempfile::tempdir().unwrap(); - let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let memory = store - .create_memory(&subject.id, draft("Concurrent constraint", "fixture")) - .unwrap(); - let first = store.prepare_surface_generation(&subject.id).unwrap(); - let second = store.prepare_surface_generation(&subject.id).unwrap(); - let barrier = Arc::new(Barrier::new(2)); - let mut joins = Vec::new(); - for (generation, body) in [(first, "first"), (second, "second")] { - let store = store.clone(); - let subject_id = subject.id.clone(); - let memory_id = memory.id.clone(); - let barrier = Arc::clone(&barrier); - joins.push(thread::spawn(move || { - barrier.wait(); - store.publish_surface_generation( - &subject_id, - &generation.id, - vec![SurfacePoint { - body_md: body.into(), - memory_refs: vec![MemoryRevisionRef { - memory_id, - revision: 1, - }], - }], - ) - })); - } - let results = joins - .into_iter() - .map(|join| join.join().unwrap()) - .collect::>(); - assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!( - results - .iter() - .filter(|result| matches!( - result, - Err(SubjektivError::SurfaceGenerationConflict(_)) - )) - .count(), - 1 - ); - assert_eq!( - store.resident_surface(&subject.id).unwrap().availability, - SurfaceAvailability::Ready - ); - } - - #[test] - fn reopen_and_backup_restore_preserve_exact_history() { - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("storage"); - let snapshot_path = temp.path().join("snapshot"); - let (manager, _workspace, store) = open_store(&root, "workspace-a"); - let subject = store.create_subject(role()).unwrap(); - let candidate = store - .stage_candidate(candidate(&subject.id, "candidate-1", "workspace-a")) - .unwrap(); - let (memory, _) = store - .apply_candidate( - &subject.id, - &candidate.id, - MemoryRevisionTarget::Create, - draft("Persist this revision", "accepted before restart"), - "applied", - ) - .unwrap(); - drop(store); - manager.shutdown().unwrap(); - drop(manager); - - let (manager, workspace, reopened) = open_store(&root, "workspace-a"); - assert_eq!( - reopened - .memory_revision(&subject.id, &memory.id, 1) - .unwrap() - .unwrap() - .claim, - "Persist this revision" - ); - assert_eq!( - reopened - .staging_resolution(&subject.id, &candidate.id) - .unwrap() - .unwrap() - .candidate - .source_refs[0] - .session_id - .as_deref(), - Some("session-1") - ); - let surface = reopened - .create_surface_snapshot( - &subject.id, - "Persisted resident surface", - vec![MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: 1, - }], - 1, - ) - .unwrap(); - workspace.backup(&snapshot_path).unwrap(); - let mut post_backup = draft("Post-backup revision", "not in snapshot"); - post_backup.derived_from.push(MemoryRevisionRef { - memory_id: memory.id.clone(), - revision: 1, - }); - let second = reopened - .revise_memory(&subject.id, &memory.id, 1, post_backup) - .unwrap(); - assert_eq!(second.revision, 2); - workspace.delete().unwrap(); - drop(reopened); - drop(workspace); - drop(manager); - - let manager = FeatureStorage::new(&root); - let workspace = manager.workspace("workspace-a").unwrap(); - workspace.restore(&snapshot_path).unwrap(); - let registration = SubjektivStore::register(&workspace).unwrap(); - let restored = SubjektivStore::open(&workspace, ®istration).unwrap(); - assert_eq!( - restored - .subject(&subject.id) - .unwrap() - .unwrap() - .store_revision, - 1 - ); - assert_eq!( - restored - .memory(&subject.id, &memory.id) - .unwrap() - .unwrap() - .claim, - "Persist this revision" - ); - assert!( - restored - .memory_revision(&subject.id, &memory.id, 2) - .unwrap() - .is_none() - ); - assert!( - restored - .staging_resolution(&subject.id, &candidate.id) - .unwrap() - .is_some() - ); - assert_eq!( - restored - .surface_snapshot(&subject.id, &surface.id) - .unwrap() - .unwrap() - .memory_refs, - vec![MemoryRevisionRef { - memory_id: memory.id, - revision: 1, - }] - ); - } -} +//! Backend compatibility facade for the shared subjektiv domain. +pub use subjektiv::*; diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 86dbdf81a..b951864da 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -20,6 +20,7 @@ server-api = { workspace = true } workdir = { workspace = true } tokio-tungstenite = { workspace = true } session-store = { workspace = true } +standalone = { workspace = true } session-analytics = { workspace = true } ticket = { workspace = true } tui = { workspace = true } diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 0958ea16a..618b8cf39 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -27,6 +27,7 @@ pub(crate) enum CliCommand { Plugin, Mcp, Session, + Subject, Login, } @@ -46,6 +47,7 @@ impl CliCommand { CliCommand::Plugin => "yoi plugin", CliCommand::Mcp => "yoi mcp", CliCommand::Session => "yoi session", + CliCommand::Subject => "yoi subject", CliCommand::Login => "yoi login", } } @@ -65,7 +67,8 @@ impl CliCommand { | CliCommand::WorkerCleanup | CliCommand::Plugin | CliCommand::Mcp - | CliCommand::Session => CliConnectionRequirement::LocalOnly, + | CliCommand::Session + | CliCommand::Subject => CliConnectionRequirement::LocalOnly, } } } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 1e72559f0..d7aa0967b 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -3,6 +3,7 @@ mod mcp_cli; mod objective_cli; mod plugin_cli; mod session_cli; +mod subject_cli; mod ticket_cli; mod worker_cleanup_cli; mod workspace_bootstrap; @@ -41,6 +42,10 @@ enum Mode { target: client::ResolvedTarget, }, Session(session_cli::SessionCli), + Subject { + cli: subject_cli::SubjectCli, + target: Box, + }, WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli), Ticket { cli: ticket_cli::TicketCli, @@ -171,6 +176,26 @@ async fn run(mode: Mode) -> ExitCode { } } } + Mode::Subject { cli, target } => { + let result = if cli == subject_cli::SubjectCli::Help { + Ok(subject_cli::HELP.to_owned()) + } else { + match target.spawn_worker() { + Ok(spawn) => subject_cli::run(cli, &spawn.state_dir), + Err(error) => Err(Box::new(error) as Box), + } + }; + match result { + Ok(output) => { + print!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("yoi subject: {error}"); + ExitCode::FAILURE + } + } + } Mode::Session(cli) => match session_cli::run(cli) { Ok(output) => { print!("{}", output.stdout); @@ -406,6 +431,7 @@ fn parse_args_slice_with_connection_resolver( LaunchMode::Spawn { worker_name: None, profile: None, + subject_id: None, } }; return Ok(Mode::Tui { @@ -454,6 +480,21 @@ fn parse_args_slice_with_connection_resolver( .map_err(|error| ParseError(error.to_string()))?; return Ok(Mode::Objective { cli, target }); } + "subject" => { + if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() { + return Err(ParseError( + "yoi subject is local-only and does not accept Backend selectors".to_owned(), + )); + } + if !target_selection.explicit_local { + return Err(ParseError( + "yoi subject requires explicit --local selection".to_owned(), + )); + } + let cli = subject_cli::parse(&args[1..])?; + let target = resolve_local_cli_connection(connection_resolver, CliCommand::Subject)?; + return Ok(Mode::Subject { cli, target }); + } "session" => { let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?; let session_cli = session_cli::parse_session_args(&args[1..]) @@ -828,6 +869,19 @@ fn required_option_value<'a>( Ok(value) } +fn set_subject_selector(selector: &mut Option, value: &str) -> Result<(), ParseError> { + if selector.is_some() { + return Err(ParseError( + "--subject must not be provided more than once".to_owned(), + )); + } + if value.trim().is_empty() || value.starts_with('-') { + return Err(ParseError("--subject requires a nonempty ID".to_owned())); + } + *selector = Some(value.to_owned()); + Ok(()) +} + fn parse_console_options( args: &[String], target_selection: &TargetSelection, @@ -837,6 +891,7 @@ fn parse_console_options( let mut worker_name = None; let mut session = None; let mut profile = None; + let mut subject_id = None; let mut socket_override = None; let mut runtime_id = None; let mut worker_id = None; @@ -884,6 +939,15 @@ fn parse_console_options( socket_override = Some(PathBuf::from(value)); i += 2; } + "--subject" => { + let value = required_option_value(args, i, "--subject")?; + set_subject_selector(&mut subject_id, value)?; + i += 2; + } + arg if arg.starts_with("--subject=") => { + set_subject_selector(&mut subject_id, &arg["--subject=".len()..])?; + i += 1; + } "--profile" => { let value = args .get(i + 1) @@ -989,6 +1053,24 @@ fn parse_console_options( } } + if subject_id.is_some() { + if session.is_some() + || socket_override.is_some() + || runtime_id.is_some() + || worker_id.is_some() + { + return Err(ParseError( + "--subject can only be used for fresh local spawn, not attach or restore" + .to_owned(), + )); + } + if target_selection.backend_url.is_some() || target_selection.workspace_id.is_some() { + return Err(ParseError( + "--subject is local-only and does not accept Backend selectors".to_owned(), + )); + } + } + if worker_id.is_some() && runtime_id.is_none() { return Err(ParseError( "--worker-id requires --runtime-id for Runtime API attach".to_string(), @@ -1018,12 +1100,29 @@ fn parse_console_options( return Err(ParseError("--socket requires --worker".to_string())); } - let target = resolve_tui_target( - connection_resolver, - CliCommand::DefaultTui, - target_selection, - &workspace_root, - )?; + let target = if subject_id.is_some() { + // A local Subject selector must never trigger Backend Workspace discovery. + let target = resolve_connection_aware_cli_connection( + connection_resolver, + CliCommand::DefaultTui, + target_selection.explicit_local, + None, + None, + )?; + if target.kind() != TargetKind::Standalone { + return Err(ParseError( + "--subject requires a local target; use --local".to_owned(), + )); + } + target + } else { + resolve_tui_target( + connection_resolver, + CliCommand::DefaultTui, + target_selection, + &workspace_root, + )? + }; if target.kind() == TargetKind::Standalone { if runtime_id.is_some() || worker_id.is_some() { @@ -1081,6 +1180,7 @@ fn parse_console_options( LaunchMode::Spawn { worker_name, profile, + subject_id, } } else { if worker_name.is_some() @@ -1912,6 +2012,7 @@ Connection-aware commands: Console options: --workspace Standalone cwd or client display scope (defaults to cwd) --profile Select the Standalone Profile recipe + --subject Select an existing local Subject for fresh spawn only --runtime-id Backend Runtime id --worker-id Backend Worker id; requires --runtime-id @@ -1926,6 +2027,7 @@ Host commands: plugin Author/check/pack explicit Plugin packages mcp Inspect configured MCP servers session Inspect/prune Standalone session logs + --local subject Explicitly create/list local Subjects (create , list) Standalone binaries: yoi-server Workspace Backend server/admin CLI @@ -2618,11 +2720,115 @@ backend = "shared" LaunchMode::Spawn { worker_name: Some(ref name), profile: None, + subject_id: None, } if name == "my-local-worker" )); assert!(target.spawn_worker().unwrap().state_dir.is_absolute()); } + #[test] + fn subject_selection_is_carried_only_to_local_fresh_spawn() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + for selector in [ + vec!["--subject", "subject-id"], + vec!["--subject=subject-id"], + ] { + let args = [ + vec!["--local", "--profile", "builtin:standalone-subjektiv"], + selector, + ] + .concat() + .into_iter() + .map(String::from) + .collect::>(); + let Mode::Tui { + target, + mode: LaunchMode::Spawn { subject_id, .. }, + .. + } = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected local spawn"); + }; + assert_eq!(target.kind(), TargetKind::Standalone); + assert_eq!(subject_id.as_deref(), Some("subject-id")); + } + for input in [ + vec!["--subject", "subject-id"], + vec![ + "--backend", + "http://backend.example", + "--subject", + "subject-id", + ], + vec!["--local", "--workspace-id", "ws", "--subject=id"], + vec!["--local", "--subject", ""], + vec!["--local", "--subject", " "], + vec!["--local", "--subject="], + vec!["--local", "--subject=id", "--subject=other"], + vec!["--local", "--subject=id", "--subject", "other"], + vec!["--local", "--subject=id", "--session", "session"], + vec![ + "--local", + "--subject=id", + "--socket", + "socket", + "--worker", + "worker", + ], + vec!["--local", "--subject=id", "--runtime-id", "runtime"], + vec!["--local", "--subject=id", "--worker-id", "worker"], + vec!["--local", "resume", "--subject=id"], + vec!["--local", "resume", "--subject", "id"], + ] { + let args = input + .iter() + .map(|arg| (*arg).to_owned()) + .collect::>(); + assert!( + parse_args_slice_with_connection_resolver(&args, &resolver).is_err(), + "{input:?}" + ); + } + } + + #[test] + fn subject_commands_require_explicit_local_target() { + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + for command in [vec!["create", "assistant"], vec!["list"], vec!["--help"]] { + let args = [vec!["--local", "subject"], command] + .concat() + .into_iter() + .map(String::from) + .collect::>(); + let Mode::Subject { target, .. } = + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected Subject mode"); + }; + assert_eq!(target.kind(), TargetKind::Standalone); + assert!(target.spawn_worker().unwrap().state_dir.is_absolute()); + } + for input in [ + vec!["subject", "list"], + vec!["--backend", "http://backend.example", "subject", "list"], + vec!["--local", "--workspace-id=ws", "subject", "list"], + vec!["--local", "subject", "list", "--backend=url"], + ] { + let args = input + .iter() + .map(|arg| (*arg).to_owned()) + .collect::>(); + assert!( + parse_args_slice_with_connection_resolver(&args, &resolver).is_err(), + "{input:?}" + ); + } + } + #[test] fn parser_rejects_removed_top_level_resume_flags() { for (args, expected) in [ @@ -3346,6 +3552,7 @@ backend = "shared" LaunchMode::Spawn { worker_name, profile, + subject_id: None, }, workspace_root, .. diff --git a/crates/yoi/src/subject_cli.rs b/crates/yoi/src/subject_cli.rs new file mode 100644 index 000000000..a6c18cb8d --- /dev/null +++ b/crates/yoi/src/subject_cli.rs @@ -0,0 +1,184 @@ +use std::path::Path; + +use standalone::subjektiv::StandaloneSubjects; + +use super::ParseError; + +pub(crate) const HELP: &str = "Local Subjects (operator-managed standalone state): + yoi --local subject create [--behavior-md ] + yoi --local subject list + yoi --local --subject [--profile builtin:standalone-subjektiv] + +Create prints a Subject record as JSON; list prints a JSON array. +Selection is explicit and only applies to fresh local launches. +Resume restores the original Subject binding and does not accept --subject. +"; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum SubjectCli { + Help, + Create { + role: String, + behavior_md: Option, + }, + List, +} + +pub(crate) fn parse(args: &[String]) -> Result { + match args.first().map(String::as_str) { + None => Ok(SubjectCli::Help), + Some("--help" | "-h") if args.len() == 1 => Ok(SubjectCli::Help), + Some("list") if args.len() == 1 => Ok(SubjectCli::List), + Some("create") => { + let role = args + .get(1) + .filter(|role| !role.starts_with('-') && !role.trim().is_empty()) + .ok_or_else(|| ParseError("subject create requires a nonempty ROLE".to_owned()))?; + let mut behavior_md = None; + let mut i = 2; + while i < args.len() { + if behavior_md.is_some() { + return Err(ParseError( + "subject create accepts --behavior-md only once".to_owned(), + )); + } + match args[i].as_str() { + "--behavior-md" => { + let text = args + .get(i + 1) + .filter(|text| !text.starts_with("--")) + .ok_or_else(|| ParseError("--behavior-md requires TEXT".to_owned()))?; + behavior_md = Some(text.clone()); + i += 2; + } + arg if arg.starts_with("--behavior-md=") => { + behavior_md = Some(arg.trim_start_matches("--behavior-md=").to_owned()); + i += 1; + } + arg => { + return Err(ParseError(format!( + "unknown subject create argument `{arg}`" + ))); + } + } + } + Ok(SubjectCli::Create { + role: role.clone(), + behavior_md, + }) + } + Some(arg) => Err(ParseError(format!( + "invalid subject command or arguments `{arg}`; use `yoi --local subject --help`" + ))), + } +} + +pub(crate) fn run(cli: SubjectCli, state_dir: &Path) -> Result> { + if cli == SubjectCli::Help { + return Ok(HELP.to_owned()); + } + let subjects = StandaloneSubjects::open(state_dir)?; + let json = match cli { + SubjectCli::Create { role, behavior_md } => { + serde_json::to_string_pretty(&subjects.create(&role, behavior_md.as_deref())?)? + } + SubjectCli::List => serde_json::to_string_pretty(&subjects.list()?)?, + SubjectCli::Help => unreachable!(), + }; + Ok(format!("{json}\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() + } + + #[test] + fn parse_explicit_create_and_list() { + assert_eq!( + parse(&args(&[ + "create", + "assistant", + "--behavior-md", + "Remember decisions." + ])) + .unwrap(), + SubjectCli::Create { + role: "assistant".to_owned(), + behavior_md: Some("Remember decisions.".to_owned()) + } + ); + assert_eq!(parse(&args(&["list"])).unwrap(), SubjectCli::List); + } + + #[test] + fn reject_missing_empty_duplicate_and_unknown_arguments() { + for input in [ + vec!["create"], + vec!["create", " "], + vec!["create", "role", "--behavior-md"], + vec!["create", "role", "--behavior-md=x", "--behavior-md=y"], + vec!["list", "--subject=id"], + vec!["create", "role", "--backend=url"], + ] { + assert!(parse(&args(&input)).is_err(), "{input:?}"); + } + } + + #[test] + fn create_and_list_share_only_the_selected_state_root() { + let root = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + // New state roots are created privately; temporary parent directories + // need not meet the state-root permission contract. + let state_dir = root.path().join("state"); + let other_state_dir = other.path().join("state"); + let created: serde_json::Value = serde_json::from_str( + &run( + SubjectCli::Create { + role: "assistant".to_owned(), + behavior_md: Some("Be precise.".to_owned()), + }, + &state_dir, + ) + .unwrap(), + ) + .unwrap(); + let listed: serde_json::Value = + serde_json::from_str(&run(SubjectCli::List, &state_dir).unwrap()).unwrap(); + assert_eq!(listed, serde_json::json!([created])); + let elsewhere: serde_json::Value = + serde_json::from_str(&run(SubjectCli::List, &other_state_dir).unwrap()).unwrap(); + assert_eq!(elsewhere, serde_json::json!([])); + } + + #[cfg(unix)] + #[test] + fn nonprivate_existing_root_requires_explicit_operator_permission_change() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(run(SubjectCli::List, root.path()).is_err()); + assert_eq!( + std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert!(!root.path().join("subjektiv").exists()); + std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let listed: serde_json::Value = + serde_json::from_str(&run(SubjectCli::List, root.path()).unwrap()).unwrap(); + assert_eq!(listed, serde_json::json!([])); + } + + #[test] + fn help_does_not_open_state() { + let root = tempfile::tempdir().unwrap(); + let absent = root.path().join("absent"); + assert_eq!(run(SubjectCli::Help, &absent).unwrap(), HELP); + assert!(!absent.exists()); + } +} diff --git a/docs/design/standalone-subjektiv.md b/docs/design/standalone-subjektiv.md new file mode 100644 index 000000000..88d36325e --- /dev/null +++ b/docs/design/standalone-subjektiv.md @@ -0,0 +1,41 @@ +# Standalone subjektiv (T-709) + +## Implementation design, recorded before implementation + +Base: `13055d7ee18f9d29edc641d0bd0e57c3d034dfa0`, including T-704 and T-708. No Backend database, fake Workspace, local fallback, new daemon, or subjektiv-specific Internal Worker runner is introduced. + +The operator-managed standalone state directory (existing CLI/XDG standalone state setting, not cwd or a repository file) is the local sharing boundary. `/subjektiv/` is a private directory. A persisted random storage-scope identity is issued here; Subject IDs use the common domain UUID issuer, never cwd/Profile/Worker name. The common Feature SQLite manager opens the subjektiv database under this root; it owns connection options, migration execution, close/reopen and transaction locking. The common subjektiv crate owns the existing schema/migrations/domain/CAS/receipt/surface rules. Existing Backend layout and physical schema remain unchanged. Job domain state is not embedded into Memory tables or Session text. + +Subjects are explicitly created/listed in that state scope and selected at normal launch; enabled policy alone does not create or attach one. Each selected Subject has a separate OS advisory lock file under `/subjektiv/leases/`. The Host holds the exclusive lock for body execution and all delegated Jobs until confirmed shutdown. A second process cannot acquire the same lock. Process death releases the kernel lock, not a PID/Profile guess; uncertain in-process cleanup retains ownership. A separate Subject can run concurrently, with SQLite serializing common database transactions. Symlinks and non-private state nodes must be rejected at this boundary. Knowledge of an ID/path is not a model capability. + +The selected Subject and local storage-scope identity are persisted in the standalone Worker record. Restore verifies them and reacquires the original Subject lock; resume never silently changes Subjects. Subject-free and disabled-policy launches retain the old path with no subjektiv persistence side effects. Ordinary SubWorkers/Reviewers receive no independent Subject/extraction connection. + +The Feature receives an explicit Host-scoped subjektiv capability, separate from WorkspaceClient and Profile policy. Backend adapts its existing authenticated API operations; standalone binds one local store, Subject, Worker and committed Session authority. Session attribution is established by the Host, including Sessions without candidates. Reads use the existing committed public Session projection and standalone Session store, not a copy in the Memory database. No system prompt, hidden reasoning, uncommitted history or arbitrary filesystem/foreign-Subject access is granted. + +Consolidation uses the existing generic standalone Jobs service and common request/result contracts. For a selected Subject the Host owns `/subjektiv/jobs//jobs.sqlite3` under that Subject lease, allowing durable intents/unknown outcomes/results to follow it across Workers. Subject-free Hosts retain the T-708 Worker-local location. Subjektiv selects the consolidation Profile. Job capabilities are explicitly attenuated to the immutable candidate batch, recall/decision/surface operations and public Session references; they never confer the Subject body's execution lease or ambient parent filesystem/Workspace tools. Common receipt/CAS rules handle partial apply and response loss; unknown execution is never automatically replayed. Surface failure leaves confirmed Memory intact. The generic runner owns cancellation, timeout, result binding, resource cleanup and shutdown; processing stops with the standalone process and pending durable work is recovered only on a subsequent explicit Host connection. + +Validation must distinguish scripted model/Host and real test-owned process/lock runs from live-provider model quality. Root `cargo check`, changed-crate tests and narrow Backend/Feature/Job regression tests are required, plus format/diff checks. Concrete CLI/UI usage and recovery are documented in [standalone.md](../standalone.md). + +## Implemented boundaries and validation + +- `feature-storage` owns the shared SQLite connection policy/Feature manager; `subjektiv` owns the unchanged domain schema/migrations, Host-neutral API dispatch, and T-704 consolidation request/result/surface validation. Server modules remain thin adapters; Worker/standalone do not depend on Workspace Server. +- `worker::subjektiv` exposes a nonserializable live Host capability plus a separate attenuated consolidation capability. Backend creation still requires the actual authenticated client and bound snapshot. Local creation is explicit Host injection; unavailable Backend never falls back locally. +- The local catalog keeps private scope/language settings and common Feature storage under `subjektiv/`, separately from generic Subject Job ledgers. The common attribution table's existing `runtime_id` lookup key is the local storage namespace for local rows, not a running Runtime identity; public local evidence leaves `runtime_id`, Workspace, and Account fields absent. Worker IDs, Subject binding, and operator-managed scope must all match before Session content is read. +- The shared strict FsStore public index is also used for committed evidence admission. Stable references are intersected with visible committed entries; numeric hints accept the two existing capture producers. Open-Run evidence is deferred only before a request, never exposed by Session tools or recorded before commit. Persisted local human provenance is distinct from Backend Account provenance and maps to the existing public `HumanInput` class. +- Standalone integration tests use real Hosts/SQLite, scripted extraction/consolidation/surface models, and test-owned second processes. Nine cases cover inert/disabled policy, reopen, saved-binding restore, cross-Worker recall/source discovery, exact explicit commit staging, normal extract/pointer → generic Job → CAS apply → resident surface, same-Subject cross-process exclusion/abnormal-exit recovery, and transport loss after apply followed by surface-only repair without duplicate Memory revision. +- Generic Job tests additionally cover immutable grant persistence, missing/foreign/revision/consumer grants, per-attempt/deadline revocation, cancellation, timeout, cleanup failure, and stale capabilities. Session adapter tests cover no-candidate discovery, foreign Subject/scope/Worker denial, bounded UTF-8 and JSON output, filter/generation-bound cursors, missing/corrupt storage, and hidden/uncommitted/partial-tail exclusion without observation writes. + +Verified commands: + +```sh +cargo test -p standalone --test subjektiv -- --test-threads=1 +cargo test -p protocol -p feature-storage -p subjektiv -p manifest -p session-store -p standalone -p worker -p tui -p yoi -p yoi-workspace-server +cargo test -p worker-runtime --lib protocol_ +cargo check +cargo check -p protocol --features typescript,json-schema +cargo check -p tui --features e2e-test +cargo fmt --all -- --check +git diff --check HEAD +``` + +The changed-crate run passed, including 836 Worker unit tests, 603 Workspace Server unit tests (authorization/singleton/T-704 regression), 48 standalone unit tests, and nine standalone Subject integration tests. Existing ignored tests remain ignored. TypeScript generation check reports existing unsupported serde-attribute warnings but succeeds. These commands prove mechanism/authorization/storage contracts, not live-model quality or full interactive terminal E2E behavior. diff --git a/docs/standalone.md b/docs/standalone.md new file mode 100644 index 000000000..ff36f4fac --- /dev/null +++ b/docs/standalone.md @@ -0,0 +1,52 @@ +# Standalone local Subjects + +Standalone runs one client-owned Worker in the current process, without a Backend or Workspace. Local Subjects are explicitly created and selected; enabling a subjektiv Profile alone neither creates nor attaches a Subject. + +## Create and list + +```sh +yoi --local subject create assistant --behavior-md 'Keep durable decisions and preferences.' +yoi --local subject list +``` + +`create ` prints the created Subject record as JSON. `--behavior-md ` is optional inline text, not a file path. `list` prints a JSON array of records in the selected standalone state scope. Both commands require explicit `--local` and reject Backend/Workspace selectors. + +The state directory is obtained from the same local connection target as normal standalone launch. Its base follows the existing `YOI_DATA_DIR`, `YOI_HOME`, `XDG_DATA_HOME`, or `HOME` resolution, with `client/standalone/workers` appended. Subjects live under `/subjektiv/`; cwd, repository files, Worker names, and Profiles are not Subject identity or sharing boundaries. Use the returned common-domain Subject ID (`subject-`), not the role, for selection. + +The user-managed state root must be operator-owned and private (mode **0700**). A missing root is created with mode 0700; an existing root is validated, not automatically chmodded. An older nonprivate root is rejected. The operator can explicitly run `chmod 700 ` on their own state root before retrying; do not substitute a repository directory or assume the CLI repairs permissions. Symlink and nonprivate Subject storage nodes are also rejected. + +## Select for a new Worker + +```sh +yoi --local --subject --profile builtin:standalone-subjektiv +``` + +`--subject=` is also accepted. The selector must be nonempty and may only be supplied once. It applies only to fresh local spawn: Backend selection, Runtime attach, socket/session attach, and resume combinations are rejected. A selected Subject requires a compatible subjektiv policy; launch validation rejects incompatible policy before Worker startup side effects. + +The interactive local spawn form has an optional `subject` field, initially `(none)`. Press **F2** to switch between editing the Worker name and Subject ID; **Tab/Up/Down** continue to cycle Profiles. Leave the Subject field empty for the existing Subject-free launch path. The form does not create Subjects or open Subject storage just to display the field. The optional built-in `builtin:standalone-subjektiv` Profile enables local Subject behavior; its consolidation Profile is `builtin:standalone-subjektiv-consolidation` and does not require Backend resources. + +## Resume + +```sh +yoi --local resume +``` + +Resume restores the original persisted Subject/storage-scope binding and reacquires its execution lease. It does not accept a new `--subject` selector or silently change Subjects. The Host holds an exclusive per-Subject kernel lease for body execution and delegated Jobs until confirmed shutdown; one Subject cannot be executed concurrently by two processes. Process death releases the kernel lease, while uncertain in-process cleanup retains ownership. Separate Subjects may run concurrently in the same state scope. + +## Subject Jobs and recovery + +A selected Subject uses `/subjektiv/jobs//jobs.sqlite3`, shared across Worker IDs for that Subject and protected by its exclusive execution lease. Subject-free Hosts retain the existing Worker-local Job location. There is no separate Subject runner or background daemon. + +On explicit selected-Subject Host startup, reserved Jobs resume automatically. An interrupted Job whose execution outcome is unknown is **never automatically replayed**. A successful result that has not yet been acknowledged is delivered from durable storage without retrying the model. Recovery is driven by an explicit Host startup, not an automatic process restart or independent scheduler. + +## Memory and historical Sessions + +The normal body Profile has recall, revision reads, explicit candidate tools, and bounded historical Session exploration. It cannot directly apply Memory. Explicit Remember/Revise without `entry_refs` returns a pending-commit receipt; the Host stages its exact tool-call evidence only after the logical Run is append-only committed. Pre-request retry may defer an open Run, but commit/rewrite failures are not silently treated as success. A candidate remains unconfirmed until the restricted consolidation Job applies or rejects it using the shared CAS/receipt rules. + +Normal extraction uses the existing committed-capture lifecycle, restricted extraction tools, and append-only `subjektiv.extract.v1` pointer. The selected Profile owns extraction thresholds/model policy. An explicit extraction model override chooses that provider, not an injected dialog transport. Local Memory language defaults to English and is stored in operator-managed `subjektiv/language.json`, never loaded from repository policy files. + +The Host stamps human input on its owned in-process client channel as persisted `local_human_input`, projected as the existing `HumanInput` provenance. It neither invents a Backend account nor trusts serialized source claims. Ordinary SubWorker/Job input retains its own nonhuman origin. Local evidence has no Workspace/account/Runtime identity; local storage-scope identity and Worker binding authorize discovery separately. Historical Sessions are not copied into SQLite. Reads/search exclude system prompts, hidden reasoning, incomplete logical Runs, partial JSONL tails, and foreign bindings; cursor continuations revalidate scope/Subject/Worker attribution and committed generation. + +## Validation boundary + +Host injection tests pass a clone of the injected model transport into delegated Jobs, so scripted body and Job calls use the same test-owned transport. These are local contract tests, not live-provider validation or evidence of model/consolidation quality. Generic Job finalizer and Host integration proofs are validated separately from the CLI/parser and spawn-form tests. diff --git a/resources/profiles/standalone-subjektiv-consolidation.dcdl b/resources/profiles/standalone-subjektiv-consolidation.dcdl new file mode 100644 index 000000000..f451ae08a --- /dev/null +++ b/resources/profiles/standalone-subjektiv-consolidation.dcdl @@ -0,0 +1,8 @@ +import "./job.dcdl" // { + slug = "standalone-subjektiv-consolidation"; + description = "Subject consolidation policy; local Host grants a bounded batch, not body authority."; + engine = { instruction = "internal.subjektiv_memory_consolidation_system"; }; + feature = { + subjektiv = { enabled = true; consolidation_tools = true; extraction = { enabled = false; }; }; + }; +} diff --git a/resources/profiles/standalone-subjektiv.dcdl b/resources/profiles/standalone-subjektiv.dcdl new file mode 100644 index 000000000..b03c625f5 --- /dev/null +++ b/resources/profiles/standalone-subjektiv.dcdl @@ -0,0 +1,7 @@ +import "./standalone.dcdl" // { + slug = "standalone-subjektiv"; + description = "Standalone coding with an explicitly selected local Subject."; + feature = { + subjektiv = { enabled = true; extraction = { enabled = true; }; }; + }; +}