diff --git a/Cargo.lock b/Cargo.lock index c0879f5e5..8aa84f3b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,13 @@ dependencies = [ "serde", ] +[[package]] +name = "builtin-source" +version = "0.1.0" +dependencies = [ + "sha2 0.11.0", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -766,6 +773,7 @@ dependencies = [ name = "config-source" version = "0.1.0" dependencies = [ + "builtin-source", "decodal", "decodal-language-service", "decodal-language-tools", @@ -1396,6 +1404,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" @@ -2339,6 +2357,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "job" +version = "0.1.0" +dependencies = [ + "manifest", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -2577,6 +2606,7 @@ version = "0.1.0" dependencies = [ "agen", "arc-swap", + "builtin-source", "decodal", "protocol", "secrets", @@ -4623,13 +4653,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", @@ -4706,6 +4743,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" @@ -6432,6 +6486,7 @@ dependencies = [ "futures-util", "http", "include_dir", + "job", "jsonschema", "libc", "manifest", @@ -6569,6 +6624,7 @@ dependencies = [ "server-api", "session-analytics", "session-store", + "standalone", "tempfile", "ticket", "tokio", @@ -6613,8 +6669,10 @@ dependencies = [ "axum", "chrono", "config-source", + "feature-storage", "flow", "futures", + "job", "manifest", "memory", "merge-request", @@ -6631,6 +6689,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 b04334dea..07b6674a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/api-macros-impl", "crates/session-store", "crates/standalone", + "crates/job", "crates/secrets", "crates/manifest", "crates/mcp", @@ -24,11 +25,14 @@ members = [ "crates/tools", "crates/fs-operation", "crates/flow", + "crates/builtin-source", "crates/config-source", "crates/config-source-wasm", "crates/workdir", "crates/tui", "crates/memory", + "crates/feature-storage", + "crates/subjektiv", "crates/ticket", "crates/merge-request", "crates/project-record", @@ -43,6 +47,7 @@ default-members = [ "crates/api-macros-impl", "crates/session-store", "crates/standalone", + "crates/job", "crates/secrets", "crates/manifest", "crates/mcp", @@ -59,11 +64,14 @@ default-members = [ "crates/tools", "crates/fs-operation", "crates/flow", + "crates/builtin-source", "crates/config-source", "crates/config-source-wasm", "crates/workdir", "crates/tui", "crates/memory", + "crates/feature-storage", + "crates/subjektiv", "crates/ticket", "crates/merge-request", "crates/project-record", @@ -91,6 +99,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" } @@ -103,10 +113,12 @@ session-metrics = { path = "crates/session-metrics" } session-analytics = { path = "crates/session-analytics" } session-store = { path = "crates/session-store" } standalone = { path = "crates/standalone" } +job = { path = "crates/job" } runtime-api = { path = "crates/runtime-api" } server-api = { path = "crates/server-api" } secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } +builtin-source = { path = "crates/builtin-source" } config-source = { path = "crates/config-source" } fs-operation = { path = "crates/fs-operation" } workdir = { path = "crates/workdir" } diff --git a/crates/builtin-source/Cargo.toml b/crates/builtin-source/Cargo.toml new file mode 100644 index 000000000..be1dcf665 --- /dev/null +++ b/crates/builtin-source/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "builtin-source" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +sha2.workspace = true diff --git a/crates/builtin-source/src/lib.rs b/crates/builtin-source/src/lib.rs new file mode 100644 index 000000000..e539484ab --- /dev/null +++ b/crates/builtin-source/src/lib.rs @@ -0,0 +1,214 @@ +//! Explicit compile-time public Decodal source catalog, shared by native and WASM hosts. +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2"; +pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default"; +pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProfileImport { + pub specifier: &'static str, + pub resolved_path: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltinProfileResource { + pub selector: Option<&'static str>, + pub path: &'static str, + pub source: &'static str, + pub description: &'static str, + pub imports: &'static [BuiltinProfileImport], +} + +const BASE_PATH: &str = "profiles/base.dcdl"; +pub const DEFAULT_PATH: &str = "profiles/default.dcdl"; +const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { + specifier: "./base.dcdl", + resolved_path: BASE_PATH, +}]; +const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { + specifier: "./default.dcdl", + resolved_path: DEFAULT_PATH, +}]; +const NO_IMPORTS: &[BuiltinProfileImport] = &[]; + +pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ + BuiltinProfileResource { + selector: None, + path: BASE_PATH, + source: include_str!("../../../resources/profiles/base.dcdl"), + description: "Shared built-in Profile defaults.", + imports: NO_IMPORTS, + }, + BuiltinProfileResource { + selector: Some(BUILTIN_DEFAULT_PROFILE), + path: DEFAULT_PATH, + source: include_str!("../../../resources/profiles/default.dcdl"), + description: "Default Yoi coding profile.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some(BUILTIN_STANDALONE_PROFILE), + path: "profiles/standalone.dcdl", + source: include_str!("../../../resources/profiles/standalone.dcdl"), + 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", + source: include_str!("../../../resources/profiles/coder.dcdl"), + description: "Ticket implementation with direct Reviewer SubWorkers.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:companion"), + path: "profiles/companion.dcdl", + source: include_str!("../../../resources/profiles/companion.dcdl"), + description: "General assistance with Workspace tools.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:intake"), + path: "profiles/intake.dcdl", + source: include_str!("../../../resources/profiles/intake.dcdl"), + description: "Read-only intake and planning.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:reviewer"), + path: "profiles/reviewer.dcdl", + source: include_str!("../../../resources/profiles/reviewer.dcdl"), + description: "Independent review of a published Merge Request source.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:orchestrator"), + path: "profiles/orchestrator.dcdl", + source: include_str!("../../../resources/profiles/orchestrator.dcdl"), + description: "Workspace orchestration and Worker control.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:job"), + path: "profiles/job.dcdl", + source: include_str!("../../../resources/profiles/job.dcdl"), + description: "Result-only caller-selected Job policy.", + imports: NO_IMPORTS, + }, + BuiltinProfileResource { + selector: Some("builtin:backend-job"), + path: "profiles/backend-job.dcdl", + source: include_str!("../../../resources/profiles/backend-job.dcdl"), + description: "Backend-owned bounded Job Worker.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:memory-consolidation"), + path: "profiles/memory-consolidation.dcdl", + source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"), + description: "Internal Memory consolidation service.", + imports: BASE_IMPORT, + }, + BuiltinProfileResource { + selector: Some("builtin:subjektiv-memory-consolidation"), + path: "profiles/subjektiv-memory-consolidation.dcdl", + source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"), + description: "Delegated subject Memory consolidation service.", + imports: BASE_IMPORT, + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltinProfileCatalogSnapshot { + pub id: &'static str, + pub sources: BTreeMap, + pub entrypoints: BTreeMap, + pub imports: BTreeMap, +} + +impl BuiltinProfileCatalogSnapshot { + pub fn digest(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.id.as_bytes()); + for (path, source) in &self.sources { + hasher.update((path.len() as u64).to_le_bytes()); + hasher.update(path.as_bytes()); + hasher.update((source.len() as u64).to_le_bytes()); + hasher.update(source.as_bytes()); + } + for (selector, path) in &self.entrypoints { + hasher.update((selector.len() as u64).to_le_bytes()); + hasher.update(selector.as_bytes()); + hasher.update((path.len() as u64).to_le_bytes()); + hasher.update(path.as_bytes()); + } + for (request, resolved_path) in &self.imports { + hasher.update((request.len() as u64).to_le_bytes()); + hasher.update(request.as_bytes()); + hasher.update((resolved_path.len() as u64).to_le_bytes()); + hasher.update(resolved_path.as_bytes()); + } + format!( + "sha256:{}", + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) + } +} + +pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot { + let mut sources = BTreeMap::new(); + let mut entrypoints = BTreeMap::new(); + let mut imports = BTreeMap::new(); + + for resource in BUILTIN_PROFILE_RESOURCES { + sources.insert(resource.path.to_owned(), resource.source.to_owned()); + for import in resource.imports { + imports.insert( + format!("{}\0{}", resource.path, import.specifier), + import.resolved_path.to_owned(), + ); + } + if let Some(selector) = resource.selector { + entrypoints.insert(selector.to_owned(), resource.path.to_owned()); + } + } + + BuiltinProfileCatalogSnapshot { + id: BUILTIN_PROFILE_CATALOG_ID, + sources, + entrypoints, + imports, + } +} + +pub fn builtin_profile_entrypoints() -> impl Iterator { + BUILTIN_PROFILE_RESOURCES + .iter() + .filter(|resource| resource.selector.is_some()) +} diff --git a/crates/builtin-source/tests/catalog.rs b/crates/builtin-source/tests/catalog.rs new file mode 100644 index 000000000..55ead2cb9 --- /dev/null +++ b/crates/builtin-source/tests/catalog.rs @@ -0,0 +1,41 @@ +use builtin_source::{BUILTIN_PROFILE_RESOURCES, builtin_profile_catalog_snapshot}; +use std::collections::BTreeSet; + +#[test] +fn public_catalog_is_closed_and_explicit() { + let catalog = builtin_profile_catalog_snapshot(); + assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len()); + assert!(catalog.digest().starts_with("sha256:")); + for resource in BUILTIN_PROFILE_RESOURCES { + assert!(resource.path.starts_with("profiles/")); + assert!(resource.path.ends_with(".dcdl")); + assert_eq!(catalog.sources[resource.path], resource.source); + for import in resource.imports { + assert!(catalog.sources.contains_key(import.resolved_path)); + assert!( + resource + .source + .contains(&format!("import {:?}", import.specifier)) + ); + } + } + assert!(!catalog.sources.contains_key("prompts/default.md")); +} + +#[test] +fn catalog_import_metadata_has_no_cycles() { + fn visit(path: &str, visiting: &mut BTreeSet) { + assert!(visiting.insert(path.to_string()), "catalog cycle at {path}"); + let resource = BUILTIN_PROFILE_RESOURCES + .iter() + .find(|resource| resource.path == path) + .unwrap(); + for import in resource.imports { + visit(import.resolved_path, visiting); + } + visiting.remove(path); + } + for resource in BUILTIN_PROFILE_RESOURCES { + visit(resource.path, &mut BTreeSet::new()); + } +} diff --git a/crates/config-source-wasm/src/lib.rs b/crates/config-source-wasm/src/lib.rs index 5752b64a0..2196469f4 100644 --- a/crates/config-source-wasm/src/lib.rs +++ b/crates/config-source-wasm/src/lib.rs @@ -98,31 +98,47 @@ pub fn complete_current( let snapshot = session .as_ref() .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; - let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?; - let result = session_environment(snapshot.clone()) - .complete_config(&entrypoint, &source, utf8_byte_offset, explicit) - .map_err(|error| JsValue::from_str(&format!("{error:?}")))?; - let result = result - .map(|result| { - Ok::(WasmCompletionResult { - from: utf8_to_utf16_offset(&source, result.from)?, - items: result - .items - .into_iter() - .map(|item| WasmCompletionItem { - label: item.label, - kind: format!("{:?}", item.kind).to_lowercase(), - detail: item.detail, - priority: item.priority, - }) - .collect(), - }) - }) - .transpose()?; - encode(result) + encode(complete_snapshot( + snapshot, + &entrypoint, + &source, + utf16_offset, + explicit, + )?) }) } +fn complete_snapshot( + snapshot: &ConfigTreeSnapshot, + entrypoint: &VirtualPath, + source: &str, + utf16_offset: usize, + explicit: bool, +) -> Result, JsValue> { + let utf8_byte_offset = utf16_to_utf8_offset(source, utf16_offset)?; + let result = session_environment(snapshot.clone()) + .complete_config(entrypoint, source, utf8_byte_offset, explicit) + .map_err(|error| JsValue::from_str(&format!("{error:?}")))?; + let result = result + .map(|result| { + Ok::(WasmCompletionResult { + from: utf8_to_utf16_offset(source, result.from)?, + items: result + .items + .into_iter() + .map(|item| WasmCompletionItem { + label: item.label, + kind: format!("{:?}", item.kind).to_lowercase(), + detail: item.detail, + priority: item.priority, + }) + .collect(), + }) + }) + .transpose()?; + Ok(result) +} + #[wasm_bindgen] pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result { let snapshot: ConfigTreeSnapshot = decode(snapshot)?; @@ -207,3 +223,98 @@ fn js_error(error: impl std::fmt::Display) -> JsValue { #[allow(dead_code)] fn _assert_serializable(_: EvaluationResult) {} + +#[cfg(test)] +mod tests { + use super::*; + use config_source::{ConfigContentType, ConfigEntry}; + + fn snapshot() -> ConfigTreeSnapshot { + ConfigTreeSnapshot::from_entries( + 7, + [ + ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + // A same-suffix workspace file must not be an unknown builtin fallback. + ConfigEntry::new( + VirtualPath::parse("profiles/missing.dcdl").unwrap(), + ConfigContentType::Decodal, + "{}", + ) + .unwrap(), + ], + ) + .unwrap() + } + + #[test] + fn wasm_completion_boundary_preserves_read_only_builtin_detail() { + let snapshot = snapshot(); + let source = "let 名 = 1; import \"$builtin/profiles/comp\""; + let cursor = source.encode_utf16().count() - 1; + let result = complete_snapshot( + &snapshot, + &VirtualPath::parse("main.dcdl").unwrap(), + source, + cursor, + true, + ) + .unwrap() + .unwrap(); + assert_eq!(result.from, "let 名 = 1; import \"".encode_utf16().count()); + let item = result + .items + .iter() + .find(|item| item.label == "$builtin/profiles/companion.dcdl") + .unwrap(); + assert_eq!(item.kind, "file"); + assert_eq!( + item.detail.as_deref(), + Some("read-only builtin Decodal source") + ); + assert_eq!(snapshot.entries.len(), 2); + assert!( + snapshot + .entries + .keys() + .all(|path| !path.as_str().starts_with("$builtin/")) + ); + } + + #[test] + fn wasm_analysis_boundary_reports_unknown_builtin_without_workspace_fallback() { + let snapshot = snapshot(); + let path = VirtualPath::parse("main.dcdl").unwrap(); + let diagnostics = session_environment(snapshot.clone()) + .analyze(&path, Some("import \"$builtin/profiles/missing.dcdl\"")); + assert!(!diagnostics.is_empty()); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic + .message + .contains("unknown or non-public read-only builtin source") + }) + .unwrap(); + assert_eq!(diagnostic.path, path); + assert_eq!(diagnostic.revision, 7); + assert_eq!(diagnostic.tree_digest, snapshot.digest); + assert!( + diagnostic + .message + .contains("$builtin/profiles/missing.dcdl") + ); + assert!( + session_environment(snapshot) + .analyze( + &VirtualPath::parse("main.dcdl").unwrap(), + Some("import \"./profiles/missing.dcdl\"") + ) + .is_empty() + ); + } +} diff --git a/crates/config-source/Cargo.toml b/crates/config-source/Cargo.toml index b9ee3ecf7..61fe280e5 100644 --- a/crates/config-source/Cargo.toml +++ b/crates/config-source/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true publish = false [dependencies] +builtin-source.workspace = true decodal.workspace = true decodal-language-service.workspace = true decodal-language-tools.workspace = true diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index a5df85dce..0e377d0a9 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -1,5 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use std::sync::{Arc, Mutex}; + +use builtin_source::BUILTIN_PROFILE_RESOURCES; use decodal::{ Data, Diagnostic, DiagnosticKind, Engine, HostEnvironment, ImportCandidate, ImportLoader, @@ -22,6 +25,7 @@ pub const MAX_CHANGE_COUNT: usize = 256; pub const MAX_ENTRY_BYTES: usize = 256 * 1024; pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024; pub const MAX_PATH_BYTES: usize = 512; +pub const MAX_IMPORT_DEPTH: usize = 32; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)] pub struct VirtualPath(String); @@ -249,6 +253,7 @@ impl ConfigEntry { content_type: ConfigContentType, content: impl Into, ) -> Result { + validate_workspace_path(&path)?; let content = content.into(); if content.len() > MAX_ENTRY_BYTES { return Err(ConfigTreeError::LimitExceeded("entry bytes")); @@ -282,6 +287,10 @@ impl ConfigTreeSnapshot { let mut ordered = BTreeMap::new(); let mut total = 0usize; for entry in entries { + validate_workspace_path(&entry.path)?; + if entry.content.len() > MAX_ENTRY_BYTES { + return Err(ConfigTreeError::LimitExceeded("entry bytes")); + } total = total .checked_add(entry.content.len()) .ok_or(ConfigTreeError::LimitExceeded("total bytes"))?; @@ -363,6 +372,7 @@ impl ConfigTreeSnapshot { let mut touched = BTreeSet::new(); for change in changes { for path in change.paths() { + validate_workspace_path(path)?; if !touched.insert(path.clone()) { return Err(ConfigTreeError::PathChangedMoreThanOnce(path.clone())); } @@ -487,6 +497,10 @@ pub struct ConfigSchemaContribution { pub namespace: String, pub version: String, pub source: String, + // Optional editor shape for completion and non-materializing constraint analysis; + // never used to materialize or validate the persisted config projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authoring_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub projection_validator: Option, pub source_digest: String, @@ -529,16 +543,31 @@ impl ConfigSchemaContribution { version, source_digest: digest_bytes(source.as_bytes()), source, + authoring_source: None, projection_validator: None, }) } + pub fn with_authoring_source(mut self, source: impl Into) -> Self { + self.authoring_source = Some(source.into()); + self + } + pub fn with_projection_validator(mut self, validator: ConfigProjectionValidator) -> Self { self.projection_validator = Some(validator); self } fn validate(&self) -> Result<(), ConfigTreeError> { + if self + .authoring_source + .as_ref() + .is_some_and(|source| source.trim().is_empty()) + { + return Err(ConfigTreeError::InvalidSchemaContribution( + "authoring schema source must not be empty".to_string(), + )); + } let expected = digest_bytes(self.source.as_bytes()); if self.source_digest != expected { return Err(ConfigTreeError::InvalidSchemaContribution(format!( @@ -591,27 +620,41 @@ impl WorkspaceConfigSchemaBundle { .collect::>() .join(" & ") }; - let fingerprint = digest_bytes( - serde_json::to_vec(&( - CONFIG_SOURCE_CONTRACT_VERSION, - DECODAL_VERSION, - contributions - .iter() - .map(|contribution| { - ( - contribution.provider_id.as_str(), - contribution.namespace.as_str(), - contribution.version.as_str(), - contribution.source_digest.as_str(), - contribution.projection_validator.as_ref(), - ) - }) - .collect::>(), - digest_bytes(source.as_bytes()), - )) - .expect("schema bundle fingerprint input serializes") - .as_slice(), - ); + let mut fingerprint_input = serde_json::to_vec(&( + CONFIG_SOURCE_CONTRACT_VERSION, + DECODAL_VERSION, + contributions + .iter() + .map(|contribution| { + ( + contribution.provider_id.as_str(), + contribution.namespace.as_str(), + contribution.version.as_str(), + contribution.source_digest.as_str(), + contribution.projection_validator.as_ref(), + ) + }) + .collect::>(), + digest_bytes(source.as_bytes()), + )) + .expect("schema bundle fingerprint input serializes"); + let authoring_sources = contributions + .iter() + .filter_map(|contribution| { + contribution + .authoring_source + .as_deref() + .map(|source| (contribution.provider_id.as_str(), source)) + }) + .collect::>(); + // Preserve legacy fingerprints when no editor-only metadata is present. + if !authoring_sources.is_empty() { + fingerprint_input.extend( + serde_json::to_vec(&("authoring_sources", authoring_sources)) + .expect("authoring schema fingerprint input serializes"), + ); + } + let fingerprint = digest_bytes(&fingerprint_input); Ok(Self { contributions, source, @@ -619,6 +662,25 @@ impl WorkspaceConfigSchemaBundle { }) } + fn authoring_source(&self) -> String { + if self.contributions.is_empty() { + return self.source.clone(); + } + self.contributions + .iter() + .map(|contribution| { + format!( + "({})", + contribution + .authoring_source + .as_deref() + .unwrap_or(&contribution.source) + ) + }) + .collect::>() + .join(" & ") + } + pub fn empty() -> Self { Self::compose(Vec::new()).expect("empty schema bundle is valid") } @@ -751,26 +813,32 @@ pub struct EvaluationResult { pub projection_digest: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +enum ConfigCompletionPathSegment { + Field(String), + Index(usize), +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ConfigFieldCompletionContext { - schema_path: Vec, + schema_path: Vec, from: usize, } #[derive(Debug)] enum ConfigCompletionContainer { Object { - schema_path: Vec, + schema_path: Vec, pending_path: Vec, last_identifier_from: Option, trailing_dot: bool, reading_value: bool, }, Array { - schema_path: Vec, + schema_path: Vec, }, Other { - schema_path: Vec, + schema_path: Vec, }, } @@ -855,9 +923,18 @@ fn config_field_completion_context( }); } SyntaxTokenKind::LBracket => { - let schema_path = pending_container_path(&containers); + let mut schema_path = pending_container_path(&containers); + schema_path.push(ConfigCompletionPathSegment::Index(0)); containers.push(ConfigCompletionContainer::Array { schema_path }); } + SyntaxTokenKind::Comma => { + if let Some(ConfigCompletionContainer::Array { schema_path }) = + containers.last_mut() + && let Some(ConfigCompletionPathSegment::Index(index)) = schema_path.last_mut() + { + *index += 1; + } + } SyntaxTokenKind::RBracket => { pop_container(&mut containers, |container| { matches!(container, ConfigCompletionContainer::Array { .. }) @@ -943,7 +1020,12 @@ fn config_field_completion_context( return None; }; let mut schema_path = schema_path.clone(); - schema_path.extend(pending_path.iter().cloned()); + schema_path.extend( + pending_path + .iter() + .cloned() + .map(ConfigCompletionPathSegment::Field), + ); Some(ConfigFieldCompletionContext { schema_path, from: if *trailing_dot { @@ -954,14 +1036,25 @@ fn config_field_completion_context( }) } -fn pending_container_path(containers: &[ConfigCompletionContainer]) -> Vec { +fn pending_container_path( + containers: &[ConfigCompletionContainer], +) -> Vec { match containers.last() { Some(ConfigCompletionContainer::Object { schema_path, pending_path, reading_value: true, .. - }) => schema_path.iter().chain(pending_path).cloned().collect(), + }) => schema_path + .iter() + .cloned() + .chain( + pending_path + .iter() + .cloned() + .map(ConfigCompletionPathSegment::Field), + ) + .collect(), Some(ConfigCompletionContainer::Array { schema_path }) | Some(ConfigCompletionContainer::Other { schema_path }) => schema_path.clone(), _ => Vec::new(), @@ -1003,6 +1096,26 @@ impl SnapshotEnvironment { pub fn evaluate_contract( &self, contract: &ToolchainContract, + ) -> Result> { + self.evaluate_with_builtin_sources(contract, Arc::default()) + } + + /// Capture only builtin sources actually loaded by evaluation, including transitive imports. + /// These immutable bytes are provenance for the evaluated value, not Runtime inputs. + pub fn builtin_import_sources( + &self, + contract: &ToolchainContract, + ) -> Result, Vec> { + let sources = Arc::default(); + self.evaluate_with_builtin_sources(contract, Arc::clone(&sources))?; + let captured = sources.lock().expect("builtin source capture lock").clone(); + Ok(captured) + } + + fn evaluate_with_builtin_sources( + &self, + contract: &ToolchainContract, + sources: Arc>>, ) -> Result> { if let Err(error) = contract.validate() { return Err(vec![self.config_error( @@ -1061,9 +1174,9 @@ impl SnapshotEnvironment { "configured entrypoint is not Decodal source", )]); } - let mut engine = decodal::Engine::new(SnapshotImportLoader { - snapshot: self.snapshot.clone(), - }); + let mut loader = SnapshotImportLoader::new(self.snapshot.clone()); + loader.builtin_sources = sources; + let mut engine = decodal::Engine::new(loader); let schema_module = engine .add_root_source( WORKSPACE_CONFIG_SCHEMA_SOURCE, @@ -1168,17 +1281,83 @@ impl SnapshotEnvironment { "configured entrypoint is missing", )]; }; - let service = LanguageService::new(self); - service - .analyze( - entrypoint.as_str(), - entrypoint.as_str(), - source_override.unwrap_or(&entry.content), - ) + let source = source_override.unwrap_or(&entry.content); + let diagnostics: Vec<_> = LanguageService::new(self) + .analyze(entrypoint.as_str(), entrypoint.as_str(), source) .diagnostics .iter() .map(|diagnostic| project_diagnostic(&self.snapshot, entrypoint.clone(), diagnostic)) - .collect() + .collect(); + if !diagnostics.is_empty() + || !self.schema_bundle.as_ref().is_some_and(|bundle| { + bundle + .contributions + .iter() + .any(|entry| entry.authoring_source.is_some()) + }) + { + return diagnostics; + } + + // Check authoring constraints separately, without materializing the shape. + // Partial records may omit any recipe field: authoring metadata must neither + // make those fields required nor supply defaults to the saved projection. + let environment = ConfigAuthoringEnvironment { + environment: self, + schema_path: &[], + }; + let mut engine = match environment.create_engine() { + Ok(engine) => engine, + Err(diagnostic) => { + return vec![project_diagnostic( + &self.snapshot, + entrypoint.clone(), + &diagnostic, + )]; + } + }; + let result = engine + .add_root_source(entrypoint.as_str(), entrypoint.as_str(), source) + .and_then(|module| engine.eval_module(module)); + match result { + Ok(_) => Vec::new(), + Err(mut diagnostic) => { + // Constraint failures often point at the schema declaration. Anchor + // the editor diagnostic at the narrowest supplied value instead; + // label spans have no file field, so retain only that source's labels. + if !engine + .source_name(diagnostic.span.source) + .is_some_and(|name| { + VirtualPath::parse(name) + .is_ok_and(|path| self.snapshot.get(&path).is_some()) + }) + && let Some(span) = diagnostic + .labels + .iter() + .map(|label| label.span) + .filter(|span| { + engine.source_name(span.source).is_some_and(|name| { + VirtualPath::parse(name) + .is_ok_and(|path| self.snapshot.get(&path).is_some()) + }) + }) + .min_by_key(|span| span.end.saturating_sub(span.start)) + { + diagnostic.span = span; + } + diagnostic + .labels + .retain(|label| label.span.source == diagnostic.span.source); + let mut projected = project_engine_diagnostic( + &engine, + &self.snapshot, + entrypoint.clone(), + &diagnostic, + ); + projected.kind = diagnostic_kind(diagnostic.kind).to_string(); + vec![projected] + } + } } pub fn complete( @@ -1201,12 +1380,37 @@ impl SnapshotEnvironment { if self.schema_bundle.is_some() && let Some(context) = config_field_completion_context(source, utf8_byte_offset) { - let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}."); - member_source.push_str(&context.schema_path.join(".")); - if !context.schema_path.is_empty() && context.from == utf8_byte_offset { - member_source.push('.'); + let (schema_path, prefix) = if context.from == utf8_byte_offset { + (context.schema_path.as_slice(), "") + } else { + let Some((ConfigCompletionPathSegment::Field(prefix), schema_path)) = + context.schema_path.split_last() + else { + return self.complete(entrypoint, source, utf8_byte_offset, explicit); + }; + (schema_path, prefix.as_str()) + }; + let has_array = schema_path + .iter() + .any(|segment| matches!(segment, ConfigCompletionPathSegment::Index(_))); + let mut member_source = WORKSPACE_CONFIG_SCHEMA_GLOBAL.to_owned(); + // Ordinary object paths retain the language service's member traversal, + // but both branches install the composed authoring schema. + if !has_array { + for segment in schema_path { + if let ConfigCompletionPathSegment::Field(field) = segment { + member_source.push('.'); + member_source.push_str(field); + } + } } - let mut completion = LanguageService::new(self).complete( + member_source.push('.'); + member_source.push_str(prefix); + let completion_environment = ConfigAuthoringEnvironment { + environment: self, + schema_path: if has_array { schema_path } else { &[] }, + }; + let mut completion = LanguageService::new(completion_environment).complete( entrypoint.as_str(), &member_source, member_source.len(), @@ -1250,9 +1454,7 @@ impl HostEnvironment for &SnapshotEnvironment { type Loader = SnapshotImportLoader; fn create_loader(&self) -> Self::Loader { - SnapshotImportLoader { - snapshot: self.snapshot.clone(), - } + SnapshotImportLoader::new(self.snapshot.clone()) } fn configure_engine(&self, engine: &mut Engine) -> decodal::Result<()> { @@ -1270,12 +1472,106 @@ impl HostEnvironment for &SnapshotEnvironment { } } +// Decodal has no indexed member syntax. Resolve array ranges only in this +// completion environment, then reuse the language service's member behavior. +struct ConfigAuthoringEnvironment<'a> { + environment: &'a SnapshotEnvironment, + schema_path: &'a [ConfigCompletionPathSegment], +} + +impl HostEnvironment for ConfigAuthoringEnvironment<'_> { + type Loader = SnapshotImportLoader; + + fn create_loader(&self) -> Self::Loader { + self.environment.create_loader() + } + + fn configure_engine(&self, engine: &mut Engine) -> decodal::Result<()> { + use decodal::runtime::{ConcreteValue, ObjectField, ObjectValue, RuntimeValue}; + + let Some(bundle) = &self.environment.schema_bundle else { + return Ok(()); + }; + let module = engine.add_root_source( + WORKSPACE_CONFIG_SCHEMA_SOURCE, + WORKSPACE_CONFIG_SCHEMA_SOURCE, + &bundle.authoring_source(), + )?; + let mut schema = Some(engine.eval_module(module)?); + for segment in self.schema_path { + let Some(value) = schema else { + break; + }; + schema = match segment { + ConfigCompletionPathSegment::Field(field) => engine + .value_fields(&value)? + .unwrap_or_default() + .into_iter() + .find(|(name, _)| name == field) + .map(|(_, value)| value), + ConfigCompletionPathSegment::Index(index) => { + let element = match value { + RuntimeValue::Concrete(ConcreteValue::Array(elements)) => { + elements.get(*index).copied() + } + RuntimeValue::Abstract(range) => { + range.constraints.iter().find_map(|entry| { + if let decodal::Constraint::ArrayItems(element) = entry.constraint { + Some(element) + } else { + None + } + }) + } + _ => None, + }; + if let Some(element) = element { + // value_fields is the public API for forcing lazy fields. + // Wrap the element thunk without materializing its schema. + let object = RuntimeValue::Concrete(ConcreteValue::Object(ObjectValue { + fields: vec![ObjectField { + name: "element".into(), + value: element, + span: Span::default(), + }], + rest: None, + })); + engine + .value_fields(&object)? + .and_then(|fields| fields.into_iter().next().map(|(_, value)| value)) + } else { + None + } + } + }; + } + if let Some(schema) = schema { + engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema); + } else { + engine.bind_global( + WORKSPACE_CONFIG_SCHEMA_GLOBAL, + Value::object([] as [(&str, Value); 0]), + )?; + } + Ok(()) + } +} + #[derive(Debug, Clone)] pub struct SnapshotImportLoader { snapshot: ConfigTreeSnapshot, + builtin_sources: Arc>>, + import_edges: BTreeMap>, } impl SnapshotImportLoader { + fn new(snapshot: ConfigTreeSnapshot) -> Self { + Self { + snapshot, + builtin_sources: Arc::default(), + import_edges: BTreeMap::new(), + } + } pub fn resolve( &self, current_key: Option<&str>, @@ -1296,6 +1592,70 @@ impl ImportLoader for SnapshotImportLoader { specifier: &str, ) -> decodal::Result { let path = self.resolve(current_key, specifier).map_err(import_error)?; + // Guard the import graph before returning a module to Decodal. In particular, + // cycles hidden inside lazy object fields must not recurse during materialization. + let current = current_key + .expect("resolve requires a source context") + .split("@sha256:") + .next() + .unwrap(); + if self + .import_edges + .entry(current.to_string()) + .or_default() + .insert(path.to_string()) + { + let mut depths = BTreeMap::new(); + let validation = self.import_edges.keys().try_for_each(|root| { + validate_import_graph(&self.import_edges, root, &mut BTreeSet::new(), &mut depths) + .map(|_| ()) + }); + if let Err(diagnostic) = validation { + // A language service can keep using its loader after a diagnostic. + // Only successful edges may be cached as already validated. + self.import_edges + .get_mut(current) + .unwrap() + .remove(path.as_str()); + return Err(diagnostic); + } + } + if is_builtin_path(&path) { + let public_path = path.as_str().strip_prefix("$builtin/").unwrap_or(""); + let resource = BUILTIN_PROFILE_RESOURCES + .iter() + .find(|resource| resource.path == public_path) + .ok_or_else(|| { + Diagnostic::new( + DiagnosticKind::Import, + Span::default(), + format!("unknown or non-public read-only builtin source: {path}"), + ) + })?; + let mut captured = self + .builtin_sources + .lock() + .expect("builtin source capture lock"); + captured.insert(path.to_string(), resource.source.to_string()); + if captured.len() + self.snapshot.entries.len() > MAX_ENTRY_COUNT { + return Err(import_error(ConfigTreeError::LimitExceeded("entry count"))); + } + let bytes: usize = captured.values().map(String::len).sum::() + + self + .snapshot + .entries + .values() + .map(|entry| entry.content.len()) + .sum::(); + if resource.source.len() > MAX_ENTRY_BYTES || bytes > MAX_TOTAL_BYTES { + return Err(import_error(ConfigTreeError::LimitExceeded("total bytes"))); + } + return Ok(LoadedImport::source( + format!("{path}@{}", digest_bytes(resource.source.as_bytes())), + path.as_str(), + resource.source, + )); + } let entry = self.snapshot.get(&path).ok_or_else(|| { Diagnostic::new( DiagnosticKind::Import, @@ -1339,11 +1699,52 @@ impl ImportLoader for SnapshotImportLoader { .map_err(import_error)?; Ok(import_completions(&self.snapshot, current.as_ref(), prefix) .into_iter() - .map(|specifier| ImportCandidate::new(specifier).with_detail("virtual config source")) + .map(|specifier| { + let builtin = specifier.starts_with("$builtin/") + || current.as_ref().is_some_and(|p| is_builtin_path(p)); + ImportCandidate::new(specifier).with_detail(if builtin { + "read-only builtin Decodal source" + } else { + "virtual config source" + }) + }) .collect()) } } +fn validate_import_graph( + edges: &BTreeMap>, + current: &str, + visiting: &mut BTreeSet, + depths: &mut BTreeMap, +) -> decodal::Result { + if let Some(depth) = depths.get(current) { + return Ok(*depth); + } + if !visiting.insert(current.to_string()) { + return Err(Diagnostic::new( + DiagnosticKind::Cycle, + Span::default(), + format!("virtual config import cycle at {current}"), + )); + } + if visiting.len() > MAX_IMPORT_DEPTH + 1 { + return Err(import_error(ConfigTreeError::LimitExceeded("import depth"))); + } + let mut depth = 0; + if let Some(children) = edges.get(current) { + for child in children { + depth = depth.max(validate_import_graph(edges, child, visiting, depths)? + 1); + } + } + if depth > MAX_IMPORT_DEPTH { + return Err(import_error(ConfigTreeError::LimitExceeded("import depth"))); + } + visiting.remove(current); + depths.insert(current.to_string(), depth); + Ok(depth) +} + fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { // The source id remains the virtual path for diagnostics and relative-import // resolution. The cache identity also includes immutable source content so @@ -1351,6 +1752,19 @@ fn snapshot_import_cache_key(entry: &ConfigEntry) -> String { format!("{}@{}", entry.path, entry.content_digest) } +fn is_builtin_path(path: &VirtualPath) -> bool { + path.as_str() == "$builtin" || path.as_str().starts_with("$builtin/") +} + +fn validate_workspace_path(path: &VirtualPath) -> Result<(), ConfigTreeError> { + if is_builtin_path(path) { + return Err(ConfigTreeError::InvalidPath(format!( + "{path}: builtin namespace is read-only" + ))); + } + Ok(()) +} + pub fn resolve_import( current: &VirtualPath, specifier: &str, @@ -1364,6 +1778,15 @@ pub fn resolve_import( { return Err(ConfigTreeError::InvalidImport(specifier.into())); } + let current_builtin = is_builtin_path(current); + let explicit_builtin = specifier == "$builtin" || specifier.starts_with("$builtin/"); + if current_builtin + && !explicit_builtin + && !specifier.starts_with("./") + && !specifier.starts_with("../") + { + return Err(ConfigTreeError::InvalidImport(specifier.into())); + } let mut components = if specifier.starts_with("./") || specifier.starts_with("../") { current.parent_components() } else { @@ -1373,6 +1796,9 @@ pub fn resolve_import( match component { "" | "." => {} ".." => { + if (current_builtin || explicit_builtin) && components.len() <= 1 { + return Err(ConfigTreeError::ImportEscape(specifier.into())); + } components .pop() .ok_or_else(|| ConfigTreeError::ImportEscape(specifier.into()))?; @@ -1389,6 +1815,26 @@ pub fn import_completions( prefix: &str, ) -> Vec { let mut candidates = BTreeSet::new(); + for resource in BUILTIN_PROFILE_RESOURCES { + let specifier = format!("$builtin/{}", resource.path); + if specifier.starts_with(prefix) { + candidates.insert(specifier); + } + if let Some(current) = current.filter(|path| is_builtin_path(path)) { + let parent = current.parent_components().join("/"); + if let Some(relative) = + format!("$builtin/{}", resource.path).strip_prefix(&format!("{parent}/")) + { + let relative = format!("./{relative}"); + if relative.starts_with(prefix) { + candidates.insert(relative); + } + } + } + } + if current.is_some_and(is_builtin_path) { + return candidates.into_iter().collect(); + } for path in snapshot.entries.keys() { if current == Some(path) { continue; @@ -1890,7 +2336,7 @@ mod tests { assert_eq!( config_field_completion_context(root, root_cursor), Some(ConfigFieldCompletionContext { - schema_path: vec!["pro".into()], + schema_path: vec![ConfigCompletionPathSegment::Field("pro".into())], from: root_cursor - 3, }) ); @@ -1899,7 +2345,10 @@ mod tests { assert_eq!( config_field_completion_context(nested, nested_cursor), Some(ConfigFieldCompletionContext { - schema_path: vec!["profile".into(), "def".into()], + schema_path: vec![ + ConfigCompletionPathSegment::Field("profile".into()), + ConfigCompletionPathSegment::Field("def".into()), + ], from: nested_cursor - 3, }) ); @@ -1908,7 +2357,12 @@ mod tests { assert_eq!( config_field_completion_context(array, array_cursor), Some(ConfigFieldCompletionContext { - schema_path: vec!["profile".into(), "entries".into(), "sel".into()], + schema_path: vec![ + ConfigCompletionPathSegment::Field("profile".into()), + ConfigCompletionPathSegment::Field("entries".into()), + ConfigCompletionPathSegment::Index(0), + ConfigCompletionPathSegment::Field("sel".into()), + ], from: array_cursor - 3, }) ); @@ -1991,6 +2445,707 @@ mod tests { ); } + fn profile_entries_completion_schema() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:profile", + "profile", + "1", + "{ profile = { default_profile = String; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }", + ) + .unwrap()]) + .unwrap() + } + + #[test] + fn completion_projects_array_element_and_nested_profile_fields() { + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot) + .with_schema_bundle(profile_entries_completion_schema()); + for (marked_source, expected, prefix_len) in [ + ( + "{ profile.entries = [{ sel| }] } as WorkspaceConfigSchema", + "selector", + 3, + ), + ( + "{ profile = { entries = [{ pro| }] } } as WorkspaceConfigSchema", + "profile", + 3, + ), + ( + "{ profile.entries = [{ | }] } as WorkspaceConfigSchema", + "selector", + 0, + ), + ( + "{ profile.entries = [{ | }] } as WorkspaceConfigSchema", + "profile", + 0, + ), + ( + "{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema", + "slug", + 3, + ), + ( + "{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema", + "worker", + 0, + ), + ( + "{ profile.entries = [{ profile.worker.mo| }] } as WorkspaceConfigSchema", + "mode", + 2, + ), + ( + "{ profile.entries = [{ profile.worker.| }] } as WorkspaceConfigSchema", + "mode", + 0, + ), + ( + "{ profile.entries = [{ profile = { worker = { mo| } } }] } as WorkspaceConfigSchema", + "mode", + 2, + ), + ( + "{ profile.entries = [{}, { sel| }] } as WorkspaceConfigSchema", + "selector", + 3, + ), + ( + "{ profile.entries = [{ selector = \"日本語\"; profile = { slu|g } }] } as WorkspaceConfigSchema", + "slug", + 3, + ), + ( + "{ profile.entries = [{ pro|file }] } as WorkspaceConfigSchema", + "profile", + 3, + ), + ( + "{ profile.entries = [({ profile = { worker = { mo| } } })] } as WorkspaceConfigSchema", + "mode", + 2, + ), + ] { + let cursor = marked_source.find('|').unwrap(); + let source = marked_source.replace('|', ""); + let completion = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap_or_else(|| panic!("missing completion for {marked_source}")); + assert_eq!(completion.from, cursor - prefix_len, "{marked_source}"); + assert!( + completion.items.iter().any(|item| item.label == expected), + "{marked_source}: {completion:?}" + ); + } + } + + #[test] + fn completion_tracks_fixed_array_positions_and_nested_array_ranges() { + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:profile", + "profile", + "1", + "{ profile = { entries = [{ selector = String; }, { profile = { slug = String; worker = { mode = String; }; }; }]; matrix = [...[...{ selector = String; }]]; }; }", + ) + .unwrap()]) + .unwrap(); + let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema); + for (marked_source, expected, unexpected) in [ + ( + "{ profile.entries = [{ | }] } as WorkspaceConfigSchema", + "selector", + "profile", + ), + ( + "{ profile.entries = [{}, { | }] } as WorkspaceConfigSchema", + "profile", + "selector", + ), + ( + "{ profile.entries = [{}, { profile.worker.mo| }] } as WorkspaceConfigSchema", + "mode", + "selector", + ), + ( + "{ profile.matrix = [[{}, { sel| }]] } as WorkspaceConfigSchema", + "selector", + "profile", + ), + ( + "{ profile.matrix = [[], [{ sel| }]] } as WorkspaceConfigSchema", + "selector", + "profile", + ), + ] { + let cursor = marked_source.find('|').unwrap(); + let source = marked_source.replace('|', ""); + let completion = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap_or_else(|| panic!("missing completion for {marked_source}")); + assert!( + completion.items.iter().any(|item| item.label == expected), + "{marked_source}: {completion:?}" + ); + assert!( + !completion.items.iter().any(|item| item.label == unexpected), + "{marked_source}: {completion:?}" + ); + } + for marked_source in [ + "{ profile.entries = [{}, {}, { | }] } as WorkspaceConfigSchema", + "{ profile.entries = [{ missing = { | } }] } as WorkspaceConfigSchema", + ] { + let cursor = marked_source.find('|').unwrap(); + let source = marked_source.replace('|', ""); + let completion = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap(); + assert!( + completion.is_none_or(|completion| !completion + .items + .iter() + .any(|item| matches!(item.label.as_str(), "selector" | "profile"))), + "{marked_source}" + ); + } + } + + #[test] + fn completion_preserves_general_schema_member_behavior() { + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot) + .with_schema_bundle(profile_entries_completion_schema()); + for source in [ + "WorkspaceConfigSchema.pro", + "WorkspaceConfigSchema.profile.", + "WorkspaceConfigSchema.profile.entries.", + "WorkspaceConfigSchema.profile.default_pro", + "let schema = WorkspaceConfigSchema; in schema.profile.def", + ] { + assert_eq!( + environment + .complete_config(&path("main.dcdl"), source, source.len(), true) + .unwrap(), + environment + .complete(&path("main.dcdl"), source, source.len(), true) + .unwrap(), + "{source}" + ); + } + } + + #[test] + fn completion_array_schema_retains_nested_profile_diagnostics() { + let valid = "{ profile = { default_profile = \"default\"; entries = [{ selector = \"default\"; profile = { slug = \"default\"; worker = { mode = \"interactive\"; }; }; }]; }; } as WorkspaceConfigSchema"; + let snapshot = ConfigTreeSnapshot::from_entries(7, [entry("main.dcdl", valid)]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot) + .with_schema_bundle(profile_entries_completion_schema()); + assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); + for (source, field) in [ + ( + valid.replace("selector = \"default\"", "selector = 42"), + "selector", + ), + (valid.replace("slug = \"default\"", "slug = 42"), "slug"), + (valid.replace("mode = \"interactive\"", "mode = 42"), "mode"), + ( + valid.replace("mode = \"interactive\"", "typo = \"interactive\""), + "typo", + ), + ] { + let diagnostics = environment.analyze(&path("main.dcdl"), Some(&source)); + assert!( + diagnostics.iter().any(|diagnostic| { + diagnostic.kind == "constraint_violation" + && diagnostic.path == path("main.dcdl") + && diagnostic.revision == 7 + && diagnostic.span.end_byte > diagnostic.span.start_byte + && (diagnostic.message.contains(field) + || diagnostic + .labels + .iter() + .any(|label| label.message.contains(field))) + }), + "{field}: {diagnostics:#?}" + ); + } + } + + #[test] + fn completion_authoring_schema_preserves_omitted_profile_fields_in_evaluation() { + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:profile", + "profile", + "1", + "{ profile = { defaults = {...Unknown}; entries = [...{ selector = String; profile = {...Unknown}; }]; }; }", + ) + .unwrap() + .with_authoring_source( + "{ profile = { defaults = { slug = String; worker = { mode = String; }; }; entries = [...{ selector = String; profile = { slug = String; worker = { mode = String; }; }; }]; }; }", + )]) + .unwrap(); + let source = "{ profile = { defaults = {}; entries = [{ selector = \"default\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); + assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); + let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); + let evaluated = environment.evaluate_contract(&contract).unwrap(); + assert_eq!( + evaluated.projections[0].data_json, + serde_json::json!({"profile": {"defaults": {}, "entries": [{"selector": "default", "profile": {}}]}}) + ); + // Both ordinary object paths and array-element paths use editor metadata. + for (marked_source, expected) in [ + ( + "{ profile.defaults = { slu| } } as WorkspaceConfigSchema", + "slug", + ), + ( + "{ profile.defaults.worker = { mo| } } as WorkspaceConfigSchema", + "mode", + ), + ( + "{ profile.entries = [{ pro| }] } as WorkspaceConfigSchema", + "profile", + ), + ( + "{ profile.entries = [{ profile = { slu| } }] } as WorkspaceConfigSchema", + "slug", + ), + ( + "{ profile.entries = [{ profile.worker = { mo| } }] } as WorkspaceConfigSchema", + "mode", + ), + ( + "{ profile.entries = [{ profile = { | } }] } as WorkspaceConfigSchema", + "worker", + ), + ] { + let cursor = marked_source.find('|').unwrap(); + let source = marked_source.replace('|', ""); + let completion = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap_or_else(|| panic!("missing completion for {marked_source}")); + assert!( + completion.items.iter().any(|item| item.label == expected), + "{marked_source}: {completion:?}" + ); + } + // Completion does not install its schema into later analysis/evaluation. + assert!(environment.analyze(&path("main.dcdl"), None).is_empty()); + assert_eq!(environment.evaluate_contract(&contract).unwrap(), evaluated); + } + + fn actual_profile_schema() -> WorkspaceConfigSchemaBundle { + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:profile", + "profile", + "2", + include_str!("../../../resources/config-schema/profile.dcdl"), + ) + .unwrap() + .with_authoring_source(include_str!( + "../../../resources/config-schema/profile-authoring.dcdl" + ))]) + .unwrap() + } + + #[test] + fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() { + let schema = actual_profile_schema(); + let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema"; + let snapshot = ConfigTreeSnapshot::from_entries( + 8, + [ + entry("main.dcdl", source), + entry("recipe.dcdl", "{ worker = { mode = 42; }; }"), + ], + ) + .unwrap(); + let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); + let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); + let before = environment.evaluate_contract(&contract).unwrap(); + for value in [ + "{}", + "{ worker = {}; }", + "{ worker = { mode = \"wip\"; }; }", + "{ feature = { task = {}; }; }", + ] { + let source = source.replace("profile = {};", &format!("profile = {value};")); + assert!( + environment + .analyze(&path("main.dcdl"), Some(&source)) + .is_empty(), + "{source}" + ); + } + for (value, field, expected_path) in [ + ("{ worker = 42; }", "worker", "main.dcdl"), + ("{ worker = { mode = 42; }; }", "mode", "main.dcdl"), + ( + "{ feature = { task = { enabled = 42; }; }; }", + "enabled", + "main.dcdl", + ), + ("{ worker = { typo = \"wip\"; }; }", "typo", "main.dcdl"), + ("import \"./recipe.dcdl\"", "mode", "recipe.dcdl"), + ( + "(import \"./recipe.dcdl\") // { worker = { mode = 42; }; }", + "mode", + "main.dcdl", + ), + ] { + let source = source.replace("profile = {};", &format!("profile = {value};")); + let diagnostics = environment.analyze(&path("main.dcdl"), Some(&source)); + let diagnostic = diagnostics + .first() + .unwrap_or_else(|| panic!("missing {field} diagnostic for {source}")); + assert!( + matches!( + diagnostic.kind.as_str(), + "constraint_violation" | "type_mismatch" + ), + "{diagnostic:?}" + ); + assert_eq!(diagnostic.path, path(expected_path), "{diagnostic:?}"); + assert_eq!(diagnostic.revision, 8); + let diagnostic_source = if expected_path == "main.dcdl" { + &source + } else { + "{ worker = { mode = 42; }; }" + }; + assert!( + diagnostic.span.end_byte > diagnostic.span.start_byte + && diagnostic.span.end_byte <= diagnostic_source.len() as u32, + "{diagnostic:?}" + ); + assert!( + diagnostic.message.contains(field) + || diagnostic + .labels + .iter() + .any(|label| label.message.contains(field)), + "{diagnostic:?}" + ); + assert!( + diagnostic + .labels + .iter() + .all(|label| label.span.end_byte <= diagnostic_source.len() as u32) + ); + } + // Analysis never changes the evaluation schema or transports authoring fields. + assert_eq!(environment.evaluate_contract(&contract).unwrap(), before); + assert_eq!( + before.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&serde_json::json!({})) + ); + } + + #[test] + fn actual_profile_schema_preserves_scalar_and_table_scope_forms_and_completion() { + for (recipe, expected) in [ + ( + r#"{ scope = "workspace_read"; delegation_scope = "workspace_write"; }"#, + serde_json::json!({"scope": "workspace_read", "delegation_scope": "workspace_write"}), + ), + ( + r#"{ scope = "workspace_write"; delegation_scope = "workspace_read"; }"#, + serde_json::json!({"scope": "workspace_write", "delegation_scope": "workspace_read"}), + ), + ( + r#"{ scope = { intent = "workspace_read"; }; delegation_scope = { intent = "workspace_write"; deny_write = ["private"]; symlink_policy = "resolved"; }; }"#, + serde_json::json!({"scope": {"intent": "workspace_read"}, "delegation_scope": {"intent": "workspace_write", "deny_write": ["private"], "symlink_policy": "resolved"}}), + ), + ] { + for form in [ + recipe.to_owned(), + r#"import "./scoped.dcdl""#.into(), + format!(r#"(import "./scoped.dcdl") // {{ description = "patched"; }}"#), + ] { + let source = format!( + r#"{{ profile.entries = [{{ selector = "project:scoped"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# + ); + let snapshot = ConfigTreeSnapshot::from_entries( + 9, + [entry("main.dcdl", &source), entry("scoped.dcdl", recipe)], + ) + .unwrap(); + let schema = actual_profile_schema(); + let environment = + SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); + let contract = + ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); + let before = environment.evaluate_contract(&contract).unwrap(); + assert!( + environment.analyze(&path("main.dcdl"), None).is_empty(), + "{source}" + ); + let mut expected = expected.clone(); + if form.contains("patched") { + expected["description"] = serde_json::json!("patched"); + } + assert_eq!( + before.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&expected) + ); + for field in ["scope", "delegation_scope"] { + for (prefix, label) in [ + ("int", "intent"), + ("deny", "deny_write"), + ("sym", "symlink_policy"), + ] { + let marked = format!( + "{{ profile.entries = [{{ profile.{field} = {{ {prefix}| }}; }}]; }} as WorkspaceConfigSchema" + ); + let cursor = marked.find('|').unwrap(); + let source = marked.replace('|', ""); + let result = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap(); + assert!( + result.items.iter().any(|item| item.label == label), + "{source}: {result:?}" + ); + } + } + assert_eq!(environment.evaluate_contract(&contract).unwrap(), before); + } + } + } + + fn fixture_decodal(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Object(fields) => format!( + "{{ {} }}", + fields + .iter() + .map(|(key, value)| format!("{key} = {};", fixture_decodal(value))) + .collect::>() + .join(" ") + ), + serde_json::Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(fixture_decodal) + .collect::>() + .join(", ") + ), + serde_json::Value::Null => panic!("fixture omission must not be represented as null"), + value => value.to_string(), + } + } + + #[test] + fn actual_profile_authoring_value_corpus_preserves_supported_forms() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../resources/config-schema/profile-authoring-values.json" + )) + .unwrap(); + for case in cases { + let recipe = fixture_decodal(&case["profile"]); + for (form, patched) in [ + (recipe.clone(), false), + (r#"import "./recipe.dcdl""#.into(), false), + ( + r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(), + true, + ), + ] { + let source = format!( + r#"{{ profile.entries = [{{ selector = "project:corpus"; profile = {form}; }}]; }} as WorkspaceConfigSchema"# + ); + let snapshot = ConfigTreeSnapshot::from_entries( + 10, + [entry("main.dcdl", &source), entry("recipe.dcdl", &recipe)], + ) + .unwrap(); + let schema = actual_profile_schema(); + let environment = + SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone()); + let contract = + ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema); + let before = environment.evaluate_contract(&contract).unwrap(); + let diagnostics = environment.analyze(&path("main.dcdl"), None); + assert!( + diagnostics.is_empty(), + "{}: {form}: {diagnostics:?}", + case["name"] + ); + let mut expected = case["profile"].clone(); + if patched { + expected["description"] = serde_json::json!("patched"); + } + assert_eq!( + before.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&expected), + "{}", + case["name"] + ); + assert_eq!(environment.evaluate_contract(&contract).unwrap(), before); + } + } + let source = "{} as WorkspaceConfigSchema"; + let environment = SnapshotEnvironment::new( + ConfigTreeSnapshot::from_entries(10, [entry("main.dcdl", source)]).unwrap(), + ) + .with_schema_bundle(actual_profile_schema()); + for (profile_path, prefix, label) in [ + ("compaction", "reta", "retained_tokens"), + ("compaction", "prune_min", "prune_min_savings"), + ("compaction", "req", "request"), + ("compaction", "wor", "worker"), + ("compaction", "compact_ret", "compact_retained_tokens"), + ("feature.memory.extraction", "reas", "reasoning"), + ("feature.subjektiv.extraction", "reas", "reasoning"), + ] { + let marked = format!( + "{{ profile.entries = [{{ profile.{profile_path} = {{ {prefix}| }}; }}]; }} as WorkspaceConfigSchema" + ); + let cursor = marked.find('|').unwrap(); + let source = marked.replace('|', ""); + let result = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap(); + assert!( + result.items.iter().any(|item| item.label == label), + "{source}: {result:?}" + ); + } + } + + #[test] + fn completion_authoring_sources_compose_with_validation_source_fallbacks() { + let schema = WorkspaceConfigSchemaBundle::compose([ + ConfigSchemaContribution::new( + "builtin:editor", + "editor", + "1", + "{ editor = {...Unknown}; }", + ) + .unwrap() + .with_authoring_source( + "{ editor = { theme = String; settings = { enabled = Bool; }; }; }", + ), + ConfigSchemaContribution::new( + "builtin:web", + "web", + "1", + "{ web = { enabled = Bool; }; }", + ) + .unwrap(), + ]) + .unwrap(); + let snapshot = ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{}")]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema); + for (marked_source, expected) in [ + ("{ ed| } as WorkspaceConfigSchema", "editor"), + ("{ editor = { th| } } as WorkspaceConfigSchema", "theme"), + ( + "{ editor.settings = { en| } } as WorkspaceConfigSchema", + "enabled", + ), + ("{ web = { en| } } as WorkspaceConfigSchema", "enabled"), + ] { + let cursor = marked_source.find('|').unwrap(); + let source = marked_source.replace('|', ""); + let completion = environment + .complete_config(&path("main.dcdl"), &source, cursor, true) + .unwrap() + .unwrap(); + assert!( + completion.items.iter().any(|item| item.label == expected), + "{marked_source}: {completion:?}" + ); + } + } + + #[test] + fn authoring_schema_metadata_is_optional_validated_and_fingerprinted() { + let contribution = ConfigSchemaContribution::new( + "builtin:editor", + "editor", + "1", + "{ editor = {...Unknown}; }", + ) + .unwrap(); + let serialized = serde_json::to_value(&contribution).unwrap(); + assert!(serialized.get("authoring_source").is_none()); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + contribution + ); + let legacy = WorkspaceConfigSchemaBundle::compose([contribution.clone()]).unwrap(); + let legacy_input = serde_json::to_vec(&( + CONFIG_SOURCE_CONTRACT_VERSION, + DECODAL_VERSION, + vec![( + contribution.provider_id.as_str(), + contribution.namespace.as_str(), + contribution.version.as_str(), + contribution.source_digest.as_str(), + contribution.projection_validator.as_ref(), + )], + digest_bytes(legacy.source.as_bytes()), + )) + .unwrap(); + assert_eq!(legacy.fingerprint, digest_bytes(&legacy_input)); + legacy.validate().unwrap(); + + let authoring = contribution + .clone() + .with_authoring_source("{ editor = { theme = String; }; }"); + assert_eq!(authoring.source_digest, contribution.source_digest); + let serialized = serde_json::to_value(&authoring).unwrap(); + assert_eq!( + serialized["authoring_source"], + "{ editor = { theme = String; }; }" + ); + assert_eq!( + serde_json::from_value::(serialized).unwrap(), + authoring + ); + let authored = WorkspaceConfigSchemaBundle::compose([authoring]).unwrap(); + authored.validate().unwrap(); + assert_eq!(authored.source, legacy.source); + assert_ne!(authored.fingerprint, legacy.fingerprint); + assert_ne!( + ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, authored.clone()) + .fingerprint, + ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, legacy) + .fingerprint, + ); + let changed = WorkspaceConfigSchemaBundle::compose([contribution + .clone() + .with_authoring_source("{ editor = { theme = String; mode = String; }; }")]) + .unwrap(); + assert_ne!(changed.fingerprint, authored.fingerprint); + let mut tampered = authored; + tampered.contributions[0].authoring_source = + changed.contributions[0].authoring_source.clone(); + assert!(tampered.validate().is_err()); + for source in ["", " \n\t "] { + assert!( + matches!(WorkspaceConfigSchemaBundle::compose([contribution.clone().with_authoring_source(source)]), Err(ConfigTreeError::InvalidSchemaContribution(message)) if message.contains("authoring")) + ); + } + } + #[test] fn relative_imports_and_completion_share_the_snapshot_namespace() { let snapshot = ConfigTreeSnapshot::from_entries( @@ -2400,6 +3555,254 @@ mod tests { assert!(diagnostics[0].message.contains("skills/broken/SKILL.md")); } + #[test] + fn builtin_imports_are_ordinary_values_with_confined_transitive_sources() { + let snapshot = ConfigTreeSnapshot::from_entries(1, [ + entry("main.dcdl", r#"import "$builtin/profiles/companion.dcdl" // { worker = { mode = "wip"; }; feature = { workspace_config = { enabled = true; }; }; }"#), + entry("profiles/base.dcdl", "{ slug = \"workspace-shadow\"; }"), + ]).unwrap(); + let environment = SnapshotEnvironment::new(snapshot); + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + let result = environment.evaluate_contract(&contract).unwrap(); + let value = &result.projections[0].data_json; + assert_eq!(value["slug"], "companion"); + assert_eq!(value["worker"]["mode"], "wip"); + assert_eq!(value["feature"]["workspace_config"]["enabled"], true); + assert_eq!(value["feature"]["ticket"]["enabled"], true); + let closure = environment.builtin_import_sources(&contract).unwrap(); + assert_eq!( + closure.keys().cloned().collect::>(), + vec![ + "$builtin/profiles/base.dcdl", + "$builtin/profiles/companion.dcdl" + ] + ); + assert_eq!( + closure["$builtin/profiles/base.dcdl"], + BUILTIN_PROFILE_RESOURCES[0].source + ); + + // Intersections conflict on explicit builtin values, exactly like any other import. + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [entry( + "main.dcdl", + r#"import "$builtin/profiles/companion.dcdl" & { slug = "different"; }"#, + )], + ) + .unwrap(); + assert!( + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .is_err() + ); + } + + #[test] + fn builtin_namespace_rejects_unknown_sources_escape_and_host_paths() { + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + for specifier in [ + "$builtin/profiles/private.dcdl", + "$builtin/prompts/default.md", + "$builtin/../profiles/base.dcdl", + "$builtin/profiles/../../profiles/base.dcdl", + "/etc/passwd", + "file:///etc/passwd", + "https://example.com/main.dcdl", + "builtin:companion", + ] { + let source = format!("import {specifier:?}"); + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [ + entry("main.dcdl", &source), + entry("profiles/base.dcdl", "{ spoof = true; }"), + ], + ) + .unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap_err(); + assert_eq!( + diagnostics[0].kind, "import", + "{specifier}: {diagnostics:?}" + ); + assert!(!diagnostics[0].message.is_empty()); + } + let current = path("$builtin/profiles/companion.dcdl"); + assert_eq!( + resolve_import(¤t, "./base.dcdl").unwrap(), + path("$builtin/profiles/base.dcdl") + ); + assert!(resolve_import(¤t, "../../main.dcdl").is_err()); + assert!(resolve_import(¤t, "main.dcdl").is_err()); + assert!(resolve_import(¤t, "/main.dcdl").is_err()); + } + + #[test] + fn builtin_namespace_is_not_a_mutable_workspace_tree() { + let builtin = path("$builtin/profiles/companion.dcdl"); + assert!( + ConfigEntry::new(builtin.clone(), ConfigContentType::Decodal, "{}") + .unwrap_err() + .to_string() + .contains("read-only") + ); + for change in [ + ConfigTreeChange::Create { + path: builtin.clone(), + content_type: ConfigContentType::Decodal, + content: "{}".into(), + }, + ConfigTreeChange::Delete { + path: builtin.clone(), + expected_digest: "digest".into(), + }, + ConfigTreeChange::Rename { + from: path("main.dcdl"), + to: builtin, + expected_digest: "digest".into(), + }, + ] { + assert!( + ConfigTreeSnapshot::empty() + .apply(&[change]) + .unwrap_err() + .to_string() + .contains("read-only") + ); + } + let environment = SnapshotEnvironment::new(ConfigTreeSnapshot::empty()); + let completions = import_completions( + environment.snapshot(), + Some(&path("main.dcdl")), + "$builtin/profiles/", + ); + assert_eq!(completions.len(), BUILTIN_PROFILE_RESOURCES.len()); + assert!(environment.snapshot().list_prefix(None).is_empty()); + } + + #[test] + fn builtin_values_do_not_bypass_cycle_or_path_limits() { + let snapshot = ConfigTreeSnapshot::from_entries(1, [ + entry("main.dcdl", r#"{ builtin = import "$builtin/profiles/companion.dcdl"; cycle = import "./cycle.dcdl"; }"#), + entry("cycle.dcdl", r#"import "./main.dcdl""#), + ]).unwrap(); + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap_err(); + assert_eq!(diagnostics[0].kind, "cycle"); + let mut loader = SnapshotImportLoader::new(ConfigTreeSnapshot::empty()); + for index in 0..=MAX_IMPORT_DEPTH { + loader.import_edges.insert( + format!("{index}.dcdl"), + BTreeSet::from([format!("{}.dcdl", index + 1)]), + ); + } + let diagnostic = validate_import_graph( + &loader.import_edges, + "0.dcdl", + &mut BTreeSet::new(), + &mut BTreeMap::new(), + ) + .unwrap_err(); + assert!(diagnostic.message.contains("import depth")); + let oversized = format!("$builtin/profiles/{}.dcdl", "x".repeat(MAX_PATH_BYTES)); + assert!(matches!( + resolve_import(&path("main.dcdl"), &oversized), + Err(ConfigTreeError::LimitExceeded("path bytes")) + )); + } + + #[test] + fn rejected_import_graph_edges_stay_rejected_after_diagnostics() { + let mut loader = SnapshotImportLoader::new( + ConfigTreeSnapshot::from_entries(1, [entry("a.dcdl", "{}"), entry("b.dcdl", "{}")]) + .unwrap(), + ); + loader.load(Some("a.dcdl"), "./b.dcdl").unwrap(); + for _ in 0..2 { + let diagnostic = loader.load(Some("b.dcdl"), "./a.dcdl").unwrap_err(); + assert_eq!(diagnostic.kind, DiagnosticKind::Cycle); + } + } + + #[test] + fn import_graph_shared_dependencies_are_memoized_without_losing_depth() { + let mut edges = BTreeMap::new(); + for index in 0..=MAX_IMPORT_DEPTH { + edges.insert( + format!("{index}"), + BTreeSet::from([format!("{}", index + 1), format!("{}", index + 2)]), + ); + } + let mut depths = BTreeMap::new(); + assert!(validate_import_graph(&edges, "0", &mut BTreeSet::new(), &mut depths).is_err()); + edges.remove(&MAX_IMPORT_DEPTH.to_string()); + let mut depths = BTreeMap::new(); + assert_eq!( + validate_import_graph(&edges, "0", &mut BTreeSet::new(), &mut depths).unwrap(), + MAX_IMPORT_DEPTH + ); + assert_eq!(depths.len(), MAX_IMPORT_DEPTH + 2); + } + + #[test] + fn snapshot_import_depth_is_checked_before_evaluation_recurses() { + let mut entries = Vec::new(); + for index in 0..=MAX_IMPORT_DEPTH + 1 { + let source = if index == MAX_IMPORT_DEPTH + 1 { + "{}".to_string() + } else { + format!("import \"./{}.dcdl\"", index + 1) + }; + entries.push(entry(&format!("{index}.dcdl"), &source)); + } + let diagnostics = + SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()) + .evaluate_contract(&ToolchainContract::new(1, vec![path("0.dcdl")], 1)) + .unwrap_err(); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("import depth")) + ); + } + + #[test] + fn builtin_dependencies_share_snapshot_count_and_byte_budgets() { + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + let mut entries = vec![entry( + "main.dcdl", + r#"import "$builtin/profiles/companion.dcdl""#, + )]; + for i in 1..MAX_ENTRY_COUNT { + entries.push(text_entry(&format!("unused/{i}.txt"), "")); + } + let environment = + SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()); + let diagnostics = environment.evaluate_contract(&contract).unwrap_err(); + assert!(diagnostics[0].message.contains("entry count")); + let mut entries = vec![entry( + "main.dcdl", + r#"import "$builtin/profiles/companion.dcdl""#, + )]; + let source_len = entries[0].content.len(); + for i in 0..16 { + let bytes = if i == 15 { + MAX_ENTRY_BYTES - source_len + } else { + MAX_ENTRY_BYTES + }; + entries.push(text_entry(&format!("unused/{i}.txt"), &"a".repeat(bytes))); + } + let environment = + SnapshotEnvironment::new(ConfigTreeSnapshot::from_entries(1, entries).unwrap()); + let diagnostics = environment.evaluate_contract(&contract).unwrap_err(); + assert!(diagnostics[0].message.contains("total bytes")); + } + #[test] fn host_environment_evaluation_uses_only_snapshot_imports() { let snapshot = ConfigTreeSnapshot::from_entries( 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/job/Cargo.toml b/crates/job/Cargo.toml new file mode 100644 index 000000000..934910479 --- /dev/null +++ b/crates/job/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "job" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +manifest.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true diff --git a/crates/job/src/lib.rs b/crates/job/src/lib.rs new file mode 100644 index 000000000..8426b4b2c --- /dev/null +++ b/crates/job/src/lib.rs @@ -0,0 +1,336 @@ +//! Transport-neutral immutable Job intent and structured-result contract. +//! +//! Hosts own execution, authority, persistence and lifecycle state. This crate +//! deliberately contains no runner, state machine, Runtime or domain grants. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8; +pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64; +pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120; +pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600; +pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024; +pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024; +pub const DEFAULT_MAX_ATTEMPTS: u8 = 2; +pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3; +pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024; +pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024; +pub const MAX_JOB_PURPOSE_BYTES: usize = 120; +pub const MAX_JOB_REFERENCE_BYTES: usize = 512; +pub const MAX_JOB_FAILURE_BYTES: usize = 1024; +pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum JobError { + #[error("{0}")] + InvalidInput(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct JobLimits { + #[serde(default = "default_max_concurrent_jobs")] + pub max_concurrent_jobs: u16, + #[serde(default = "default_job_timeout_seconds")] + pub timeout_seconds: u32, + #[serde(default = "default_max_result_bytes")] + pub max_result_bytes: u32, + #[serde(default = "default_max_attempts")] + pub max_attempts: u8, +} + +impl Default for JobLimits { + fn default() -> Self { + Self { + max_concurrent_jobs: DEFAULT_MAX_CONCURRENT_JOBS, + timeout_seconds: DEFAULT_JOB_TIMEOUT_SECONDS, + max_result_bytes: DEFAULT_MAX_RESULT_BYTES, + max_attempts: DEFAULT_MAX_ATTEMPTS, + } + } +} + +impl JobLimits { + pub fn validate(&self) -> Result<()> { + if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS + { + return Err(JobError::InvalidInput(format!( + "Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}" + ))); + } + if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS { + return Err(JobError::InvalidInput(format!( + "Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}" + ))); + } + if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES { + return Err(JobError::InvalidInput(format!( + "Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}" + ))); + } + if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS { + return Err(JobError::InvalidInput(format!( + "Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}" + ))); + } + Ok(()) + } +} + +const fn default_max_concurrent_jobs() -> u16 { + DEFAULT_MAX_CONCURRENT_JOBS +} +const fn default_job_timeout_seconds() -> u32 { + DEFAULT_JOB_TIMEOUT_SECONDS +} +const fn default_max_result_bytes() -> u32 { + DEFAULT_MAX_RESULT_BYTES +} +const fn default_max_attempts() -> u8 { + DEFAULT_MAX_ATTEMPTS +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct JobRequest { + /// Stable caller-selected idempotency key within the host's namespace. + pub job_id: String, + pub purpose: String, + /// Revision fenced by the structured result. + pub input_revision: String, + pub input_ref: String, + /// Bounded immutable snapshot; must not contain credentials. + pub input: Value, + pub instruction: String, + /// Manifest registry selector, never raw source or a filesystem path. + pub profile: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub serialization_key: Option, + #[serde(default)] + pub limits: JobLimits, +} + +impl JobRequest { + pub fn validate(&self) -> Result<()> { + self.validate_fields()?; + self.validate_input()?; + self.limits.validate() + } + + /// Request metadata validation, separable so adapters can retain their + /// domain-validation ordering before validating input and limits. + pub fn validate_fields(&self) -> Result<()> { + validate_identifier("job_id", &self.job_id, 256)?; + validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?; + validate_identifier("input_revision", &self.input_revision, 256)?; + validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?; + validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?; + self.profile_selector()?; + if let Some(key) = &self.serialization_key { + validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?; + } + Ok(()) + } + + pub fn validate_input(&self) -> Result<()> { + let input = serde_json::to_vec(&self.input) + .map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?; + if input.len() > MAX_JOB_INPUT_BYTES { + return Err(JobError::InvalidInput(format!( + "Job input exceeds {MAX_JOB_INPUT_BYTES} bytes" + ))); + } + Ok(()) + } + + /// Syntax only; registry existence and resolution are host authority. + pub fn profile_selector(&self) -> Result { + profile_selector(&self.profile) + } + + pub fn fingerprint(&self) -> Result { + self.validate()?; + fingerprint(self) + } + + pub fn worker_input(&self, attempt_id: &str) -> Result { + worker_input(self, attempt_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct JobResultSubmission { + pub job_id: String, + pub attempt_id: String, + pub input_revision: String, + pub result: Value, +} + +/// Client-facing lifecycle vocabulary, not a runner or transition policy. +/// A variant's presence does not imply every host supports that operation. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum JobState { + Pending, + Completed, + Failed, + Unknown, + Cancelled, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum JobAttemptState { + Reserved, + Dispatching, + Dispatched, + Completed, + Failed, + Unknown, + Cancelled, +} + +/// Transport-neutral read snapshot. Job status/result and the corresponding +/// attempt identity/status remain distinct; host identities, delivery and +/// resource cleanup are deliberately not part of the model outcome. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct JobOutcome { + pub job_id: String, + pub input_revision: String, + pub attempt_id: String, + pub attempt: u8, + pub state: JobState, + pub attempt_state: JobAttemptState, + pub result: Option, + pub failure_category: Option, + pub failure_detail: Option, +} + +pub fn profile_selector(profile: &str) -> Result { + use manifest::ProfileSelector; + validate_identifier("profile", profile, 256)?; + let selector = ProfileSelector::parse_cli(profile); + let valid = match &selector { + ProfileSelector::Default => true, + ProfileSelector::Path { .. } => false, + ProfileSelector::Named { source, name } => { + !name.is_empty() + && !name.starts_with('.') + && !name.contains(['/', '\\', ':']) + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + && ![".toml", ".json", ".dcdl", ".nix"] + .iter() + .any(|suffix| name.ends_with(suffix)) + && (source.is_some() || name != "inherit") + } + }; + if !valid { + return Err(JobError::InvalidInput( + "Job profile must be a manifest registry selector, not raw source or a path".into(), + )); + } + Ok(selector) +} + +/// Hash the full serialized host intent, not just its neutral projection. +/// Callers must validate the complete intent first. JSON field ordering and +/// omission rules are intentionally preserved for durable replay compatibility. +pub fn fingerprint(intent: &T) -> Result { + let encoded = serde_json::to_vec(intent) + .map_err(|error| JobError::InvalidInput(format!("serialize Job intent: {error}")))?; + Ok(sha256(&encoded)) +} + +pub fn worker_input(request: &JobRequest, attempt_id: &str) -> Result { + worker_input_with_label(request, attempt_id, "Job") +} + +/// Adapter-specific capability label; only the host, never the model, selects it. +/// Keeps an existing host's prompt envelope byte-for-byte compatible. +pub fn worker_input_with_label( + request: &JobRequest, + attempt_id: &str, + label: &str, +) -> Result { + let input = serde_json::to_string(&request.input) + .map_err(|error| JobError::InvalidInput(format!("serialize Job input: {error}")))?; + Ok(format!( + "{}\n\n{label} envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured {label} result capability. Final prose and Worker Idle/Stopped state are not result authority.", + request.instruction, + request.job_id, + attempt_id, + request.input_revision, + request.input_ref, + input + )) +} + +pub fn attempt_id(job_id: &str, attempt: u8) -> String { + format!("{job_id}:attempt:{attempt}") +} + +/// Bound the encoded JSON before hashing. Returns (digest, exact JSON). +pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> { + let encoded = serde_json::to_string(result) + .map_err(|error| JobError::InvalidInput(format!("serialize Job result: {error}")))?; + if encoded.len() > max_bytes as usize { + return Err(JobError::InvalidInput(format!( + "Job result exceeds {max_bytes} bytes" + ))); + } + Ok((sha256(encoded.as_bytes()), encoded)) +} + +pub fn bounded_failure_detail(detail: &str) -> String { + if detail.len() <= MAX_JOB_FAILURE_BYTES { + return detail.to_string(); + } + let mut end = MAX_JOB_FAILURE_BYTES; + while !detail.is_char_boundary(end) { + end -= 1; + } + detail[..end].to_string() +} + +pub fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> { + validate_bounded_text(field, value, max_bytes)?; + if value.chars().any(char::is_control) { + return Err(JobError::InvalidInput(format!( + "Job {field} must not contain control characters" + ))); + } + Ok(()) +} + +pub fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> { + if value.is_empty() || value.trim() != value { + return Err(JobError::InvalidInput(format!( + "Job {field} must be non-empty and trimmed" + ))); + } + if value.len() > max_bytes { + return Err(JobError::InvalidInput(format!( + "Job {field} exceeds {max_bytes} bytes" + ))); + } + Ok(()) +} + +pub fn sha256(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut output = String::with_capacity(7 + digest.len() * 2); + output.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(output, "{byte:02x}"); + } + output +} diff --git a/crates/job/tests/contract.rs b/crates/job/tests/contract.rs new file mode 100644 index 000000000..fdfd328ff --- /dev/null +++ b/crates/job/tests/contract.rs @@ -0,0 +1,308 @@ +use job::*; +use serde_json::json; + +fn request() -> JobRequest { + JobRequest { + job_id: "check:r7".into(), + purpose: "snapshot_check".into(), + input_revision: "7".into(), + input_ref: "snapshot://7".into(), + input: json!({"title": "Check me"}), + instruction: "Check this immutable snapshot.".into(), + profile: "project:custom-check".into(), + serialization_key: None, + limits: JobLimits::default(), + } +} + +#[test] +fn shared_lifecycle_enums_round_trip_all_host_states() { + for (state, name) in [ + (JobState::Pending, "pending"), + (JobState::Completed, "completed"), + (JobState::Failed, "failed"), + (JobState::Unknown, "unknown"), + (JobState::Cancelled, "cancelled"), + ] { + assert_eq!(serde_json::to_value(state).unwrap(), json!(name)); + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + state + ); + } + for (state, name) in [ + (JobAttemptState::Reserved, "reserved"), + (JobAttemptState::Dispatching, "dispatching"), + (JobAttemptState::Dispatched, "dispatched"), + (JobAttemptState::Completed, "completed"), + (JobAttemptState::Failed, "failed"), + (JobAttemptState::Unknown, "unknown"), + (JobAttemptState::Cancelled, "cancelled"), + ] { + assert_eq!(serde_json::to_value(state).unwrap(), json!(name)); + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + state + ); + } + assert!(serde_json::from_value::(json!("running")).is_err()); + assert!(serde_json::from_value::(json!("idle")).is_err()); +} + +#[test] +fn outcome_is_a_neutral_read_snapshot_with_optional_result_and_failure() { + let mut outcome = JobOutcome { + job_id: "job-1".into(), + input_revision: "7".into(), + attempt_id: "job-1:attempt:2".into(), + attempt: 2, + state: JobState::Pending, + attempt_state: JobAttemptState::Dispatching, + result: None, + failure_category: None, + failure_detail: None, + }; + let encoded = serde_json::to_value(&outcome).unwrap(); + assert_eq!( + encoded, + json!({ + "job_id": "job-1", "input_revision": "7", "attempt_id": "job-1:attempt:2", + "attempt": 2, "state": "pending", "attempt_state": "dispatching", + "result": null, "failure_category": null, "failure_detail": null + }) + ); + assert_eq!( + serde_json::from_value::(encoded.clone()).unwrap(), + outcome + ); + let mut with_host_identity = encoded; + with_host_identity["workspace_id"] = json!("workspace-a"); + assert!(serde_json::from_value::(with_host_identity).is_err()); + for (state, attempt_state, result, category, detail) in [ + ( + JobState::Completed, + JobAttemptState::Completed, + Some(json!({"ok": true})), + None, + None, + ), + ( + JobState::Failed, + JobAttemptState::Failed, + None, + Some("execution"), + Some("known failure"), + ), + ( + JobState::Unknown, + JobAttemptState::Unknown, + None, + Some("interrupted"), + Some("requires reconciliation"), + ), + ( + JobState::Cancelled, + JobAttemptState::Cancelled, + None, + Some("cancelled"), + Some("caller cancelled"), + ), + ] { + outcome.state = state; + outcome.attempt_state = attempt_state; + outcome.result = result; + outcome.failure_category = category.map(str::to_owned); + outcome.failure_detail = detail.map(str::to_owned); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&outcome).unwrap()).unwrap(), + outcome + ); + } +} + +#[test] +fn neutral_request_and_result_have_strict_round_trip_contracts() { + let request = request(); + let encoded = serde_json::to_value(&request).unwrap(); + assert_eq!(encoded.as_object().unwrap().len(), 8); + assert!(encoded.get("serialization_key").is_none()); + assert_eq!( + serde_json::from_value::(encoded.clone()).unwrap(), + request + ); + let mut with_grant = encoded.clone(); + with_grant["grants"] = json!({}); + assert!(serde_json::from_value::(with_grant).is_err()); + let mut with_provenance = encoded; + with_provenance["source_worker"] = json!({}); + assert!(serde_json::from_value::(with_provenance).is_err()); + + let submission = JobResultSubmission { + job_id: request.job_id, + attempt_id: "check:r7:attempt:1".into(), + input_revision: request.input_revision, + result: json!({"ok": true}), + }; + let mut encoded = serde_json::to_value(&submission).unwrap(); + assert_eq!( + serde_json::from_value::(encoded.clone()).unwrap(), + submission + ); + encoded["authority"] = json!("profile"); + assert!(serde_json::from_value::(encoded).is_err()); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + JobLimits::default() + ); + assert!(serde_json::from_str::(r#"{"turns":999}"#).is_err()); +} + +#[test] +fn fingerprint_covers_every_immutable_field_and_preserves_replay() { + let request = request(); + let digest = request.fingerprint().unwrap(); + let decoded: JobRequest = + serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap(); + assert_eq!(digest, decoded.fingerprint().unwrap()); + assert_eq!(digest, fingerprint(&request).unwrap()); + for mutate in [ + |r: &mut JobRequest| r.job_id.push('x'), + |r: &mut JobRequest| r.purpose.push('x'), + |r: &mut JobRequest| r.input_revision.push('x'), + |r: &mut JobRequest| r.input_ref.push('x'), + |r: &mut JobRequest| r.input = json!({"title": "changed"}), + |r: &mut JobRequest| r.instruction.push('x'), + |r: &mut JobRequest| r.profile = "builtin:backend-job".into(), + |r: &mut JobRequest| r.serialization_key = Some("resource:7".into()), + |r: &mut JobRequest| r.limits.max_attempts = 3, + ] { + let mut changed = request.clone(); + mutate(&mut changed); + assert_ne!(digest, changed.fingerprint().unwrap()); + } +} + +#[test] +fn registry_profiles_are_not_authority_or_a_fixed_allowlist() { + for profile in [ + "default", + "custom", + "project:custom", + "user:custom", + "builtin:future-job", + ] { + profile_selector(profile).unwrap(); + } + for profile in [ + "", + " inherit", + "inherit", + "path:custom", + "./a.toml", + "/tmp/a", + "project:../a", + "project:a.toml", + "builtin:", + "unknown:a", + "project:a:b", + "C:\\custom", + "{\"worker\":{}}", + ] { + assert!(profile_selector(profile).is_err(), "accepted {profile}"); + } +} + +#[test] +fn validation_enforces_utf8_bytes_and_metadata_bounds() { + let mut r = request(); + r.job_id = "x".repeat(256); + r.input_revision = "x".repeat(256); + r.purpose = "x".repeat(MAX_JOB_PURPOSE_BYTES); + r.input_ref = "x".repeat(MAX_JOB_REFERENCE_BYTES); + r.instruction = "x".repeat(MAX_JOB_INSTRUCTION_BYTES); + r.serialization_key = Some("x".repeat(MAX_JOB_REFERENCE_BYTES)); + r.input = json!("x".repeat(MAX_JOB_INPUT_BYTES - 2)); + r.validate().unwrap(); + for mutate in [ + |r: &mut JobRequest| r.job_id.push('x'), + |r: &mut JobRequest| r.input_revision.push('x'), + |r: &mut JobRequest| r.purpose.push('x'), + |r: &mut JobRequest| r.input_ref.push('x'), + |r: &mut JobRequest| r.instruction.push('x'), + |r: &mut JobRequest| r.serialization_key.as_mut().unwrap().push('x'), + |r: &mut JobRequest| r.input = json!("x".repeat(MAX_JOB_INPUT_BYTES - 1)), + ] { + let mut changed = r.clone(); + mutate(&mut changed); + assert!(changed.validate().is_err()); + assert!(changed.fingerprint().is_err()); + } + assert!(validate_identifier("id", "a\nb", 256).is_err()); + assert!(validate_bounded_text("text", " untrimmed", 256).is_err()); + assert!(validate_bounded_text("text", "", 256).is_err()); + assert!(validate_bounded_text("text", "界", 2).is_err()); + validate_bounded_text("text", "a\nb", 256).unwrap(); +} + +#[test] +fn all_limit_dimensions_reject_zero_and_above_absolute_bounds() { + let maximum = JobLimits { + max_concurrent_jobs: ABSOLUTE_MAX_CONCURRENT_JOBS, + timeout_seconds: MAX_JOB_TIMEOUT_SECONDS, + max_result_bytes: ABSOLUTE_MAX_RESULT_BYTES, + max_attempts: ABSOLUTE_MAX_ATTEMPTS, + }; + maximum.validate().unwrap(); + for mutate in [ + |l: &mut JobLimits| l.max_concurrent_jobs = 0, + |l: &mut JobLimits| l.max_concurrent_jobs += 1, + |l: &mut JobLimits| l.timeout_seconds = 0, + |l: &mut JobLimits| l.timeout_seconds += 1, + |l: &mut JobLimits| l.max_result_bytes = 0, + |l: &mut JobLimits| l.max_result_bytes += 1, + |l: &mut JobLimits| l.max_attempts = 0, + |l: &mut JobLimits| l.max_attempts += 1, + ] { + let mut changed = maximum.clone(); + mutate(&mut changed); + assert!(changed.validate().is_err()); + } +} + +#[test] +fn result_digest_and_failure_detail_bound_exact_utf8_encoding() { + let result = json!({"ok": true}); + let (digest, encoded) = result_digest(&result, 11).unwrap(); + assert_eq!(encoded, r#"{"ok":true}"#); + assert_eq!(digest, sha256(encoded.as_bytes())); + assert!(result_digest(&result, 10).is_err()); + let unicode = json!("界"); + assert!(result_digest(&unicode, 4).is_err()); + result_digest(&unicode, 5).unwrap(); + let detail = "界".repeat(MAX_JOB_FAILURE_BYTES); + let bounded = bounded_failure_detail(&detail); + assert_eq!(bounded.len(), MAX_JOB_FAILURE_BYTES - 1); + assert!(detail.starts_with(&bounded)); + assert_eq!(bounded_failure_detail("known failure"), "known failure"); +} + +#[test] +fn worker_input_is_revision_and_attempt_bound_not_final_prose_authority() { + let request = request(); + let attempt = attempt_id(&request.job_id, 2); + assert_eq!(attempt, "check:r7:attempt:2"); + let input = request.worker_input(&attempt).unwrap(); + assert!(input.starts_with(&request.instruction)); + for field in [ + "Job envelope (immutable):", + "job_id: check:r7", + "attempt_id: check:r7:attempt:2", + "input_revision: 7", + "input_ref: snapshot://7", + r#"input_json: {"title":"Check me"}"#, + "structured Job result capability", + "Final prose and Worker Idle/Stopped state are not result authority.", + ] { + assert!(input.contains(field), "missing {field}"); + } +} diff --git a/crates/manifest/Cargo.toml b/crates/manifest/Cargo.toml index 6f6fe2275..abdc916a2 100644 --- a/crates/manifest/Cargo.toml +++ b/crates/manifest/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version.workspace = true [dependencies] +builtin-source.workspace = true arc-swap = "1" agen = { workspace = true } decodal.workspace = true diff --git a/crates/manifest/src/builtin_profile.rs b/crates/manifest/src/builtin_profile.rs index 59fbfadc7..a0deb6ada 100644 --- a/crates/manifest/src/builtin_profile.rs +++ b/crates/manifest/src/builtin_profile.rs @@ -2,186 +2,10 @@ use std::collections::BTreeMap; use decodal::{Data, Engine, ImportLoader, LoadedImport}; use serde_json::{Map, Number, Value}; -use sha2::{Digest, Sha256}; use crate::profile::ProfileError; -pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2"; -pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default"; -pub const BUILTIN_STANDALONE_PROFILE: &str = "builtin:standalone"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BuiltinProfileImport { - pub specifier: &'static str, - pub resolved_path: &'static str, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BuiltinProfileResource { - pub selector: Option<&'static str>, - pub path: &'static str, - pub source: &'static str, - pub description: &'static str, - pub imports: &'static [BuiltinProfileImport], -} - -const BASE_PATH: &str = "profiles/base.dcdl"; -const DEFAULT_PATH: &str = "profiles/default.dcdl"; -const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { - specifier: "./base.dcdl", - resolved_path: BASE_PATH, -}]; -const DEFAULT_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport { - specifier: "./default.dcdl", - resolved_path: DEFAULT_PATH, -}]; -const NO_IMPORTS: &[BuiltinProfileImport] = &[]; - -pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[ - BuiltinProfileResource { - selector: None, - path: BASE_PATH, - source: include_str!("../../../resources/profiles/base.dcdl"), - description: "Shared built-in Profile defaults.", - imports: NO_IMPORTS, - }, - BuiltinProfileResource { - selector: Some(BUILTIN_DEFAULT_PROFILE), - path: DEFAULT_PATH, - source: include_str!("../../../resources/profiles/default.dcdl"), - description: "Default Yoi coding profile.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some(BUILTIN_STANDALONE_PROFILE), - path: "profiles/standalone.dcdl", - source: include_str!("../../../resources/profiles/standalone.dcdl"), - description: "Standalone Yoi coding profile.", - imports: DEFAULT_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:coder"), - path: "profiles/coder.dcdl", - source: include_str!("../../../resources/profiles/coder.dcdl"), - description: "Ticket implementation with direct Reviewer SubWorkers.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:companion"), - path: "profiles/companion.dcdl", - source: include_str!("../../../resources/profiles/companion.dcdl"), - description: "General assistance with Workspace tools.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:intake"), - path: "profiles/intake.dcdl", - source: include_str!("../../../resources/profiles/intake.dcdl"), - description: "Read-only intake and planning.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:reviewer"), - path: "profiles/reviewer.dcdl", - source: include_str!("../../../resources/profiles/reviewer.dcdl"), - description: "Independent review of a published Merge Request source.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:orchestrator"), - path: "profiles/orchestrator.dcdl", - source: include_str!("../../../resources/profiles/orchestrator.dcdl"), - description: "Workspace orchestration and Worker control.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:backend-job"), - path: "profiles/backend-job.dcdl", - source: include_str!("../../../resources/profiles/backend-job.dcdl"), - description: "Backend-owned bounded Job Worker.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:memory-consolidation"), - path: "profiles/memory-consolidation.dcdl", - source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"), - description: "Internal Memory consolidation service.", - imports: BASE_IMPORT, - }, - BuiltinProfileResource { - selector: Some("builtin:subjektiv-memory-consolidation"), - path: "profiles/subjektiv-memory-consolidation.dcdl", - source: include_str!("../../../resources/profiles/subjektiv-memory-consolidation.dcdl"), - description: "Delegated subject Memory consolidation service.", - imports: BASE_IMPORT, - }, -]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BuiltinProfileCatalogSnapshot { - pub id: &'static str, - pub sources: BTreeMap, - pub entrypoints: BTreeMap, - pub imports: BTreeMap, -} - -impl BuiltinProfileCatalogSnapshot { - pub fn digest(&self) -> String { - let mut hasher = Sha256::new(); - hasher.update(self.id.as_bytes()); - for (path, source) in &self.sources { - hasher.update((path.len() as u64).to_le_bytes()); - hasher.update(path.as_bytes()); - hasher.update((source.len() as u64).to_le_bytes()); - hasher.update(source.as_bytes()); - } - for (selector, path) in &self.entrypoints { - hasher.update((selector.len() as u64).to_le_bytes()); - hasher.update(selector.as_bytes()); - hasher.update((path.len() as u64).to_le_bytes()); - hasher.update(path.as_bytes()); - } - for (request, resolved_path) in &self.imports { - hasher.update((request.len() as u64).to_le_bytes()); - hasher.update(request.as_bytes()); - hasher.update((resolved_path.len() as u64).to_le_bytes()); - hasher.update(resolved_path.as_bytes()); - } - format!("sha256:{:x}", hasher.finalize()) - } -} - -pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot { - let mut sources = BTreeMap::new(); - let mut entrypoints = BTreeMap::new(); - let mut imports = BTreeMap::new(); - - for resource in BUILTIN_PROFILE_RESOURCES { - sources.insert(resource.path.to_owned(), resource.source.to_owned()); - for import in resource.imports { - imports.insert( - format!("{}\0{}", resource.path, import.specifier), - import.resolved_path.to_owned(), - ); - } - if let Some(selector) = resource.selector { - entrypoints.insert(selector.to_owned(), resource.path.to_owned()); - } - } - - BuiltinProfileCatalogSnapshot { - id: BUILTIN_PROFILE_CATALOG_ID, - sources, - entrypoints, - imports, - } -} - -pub fn builtin_profile_entrypoints() -> impl Iterator { - BUILTIN_PROFILE_RESOURCES - .iter() - .filter(|resource| resource.selector.is_some()) -} +pub use builtin_source::*; pub(crate) fn resolve_builtin_profile_artifact( selector: &str, diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 7849a58db..9c7365361 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,6 +1259,23 @@ mod tests { ) .unwrap(); assert_eq!(resolved.manifest.worker.name, "role-worker"); + 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, + if entry.name == "job" { + "internal.job_system" + } else { + "internal.subjektiv_memory_consolidation_system" + } + ); + continue; + } } } @@ -1558,7 +1577,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/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 0062c3bc5..65c717d8b 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4496,6 +4496,10 @@ pub struct ConfigSchemaContribution { #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional))] pub projection_validator: Option, + /// Completion-only shape for partial values; never materialized as defaults. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional))] + pub authoring_source: Option, pub source_digest: String, } 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 8c5adc9df..bf05d8375 100644 --- a/crates/standalone/Cargo.toml +++ b/crates/standalone/Cargo.toml @@ -9,7 +9,15 @@ 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 protocol.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index c7e6e754d..09cd555e5 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -43,12 +43,16 @@ pub struct StandaloneHost { worker_store: FsWorkerStore, 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")] @@ -77,6 +81,8 @@ pub enum StandaloneStartupError { #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] pub enum StandaloneShutdownError { + #[error("standalone Job execution cleanup or terminal persistence could not be confirmed")] + JobCleanup, #[error("the standalone Worker did not stop before the shutdown deadline")] DeadlineExceeded, #[error("the standalone Worker shutdown confirmation was lost")] @@ -104,13 +110,66 @@ 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 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); + return 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())?; + } // 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. @@ -143,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) => { @@ -161,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(_) => { @@ -181,6 +247,8 @@ impl StandaloneHost { worker_store, record, allocation.into_lease(), + jobs, + subject, )) } @@ -216,6 +284,50 @@ impl StandaloneHost { let lease = store .acquire_lease(worker_id, StaleLeasePolicy::Recover) .map_err(classify_store_startup_error)?; + 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(); @@ -240,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); } @@ -247,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, @@ -270,6 +388,8 @@ impl StandaloneHost { worker_store, record, lease, + jobs, + subject, )) } @@ -279,7 +399,12 @@ impl StandaloneHost { worker_store: FsWorkerStore, 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), @@ -288,9 +413,17 @@ impl StandaloneHost { worker_store, record, lease: Some(lease), + jobs, + subject, } } + /// Explicit Host execution capability for local Features, separate from the + /// interactive Worker protocol and its model-created SubWorkers. + pub fn jobs(&self) -> crate::jobs::StandaloneJobs { + self.jobs.clone() + } + #[must_use] pub fn worker_id(&self) -> WorkerId { self.record.worker_id @@ -318,6 +451,7 @@ impl StandaloneHost { } pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> { + let jobs_result = self.jobs.shutdown().await; let command = protocol::WorkerCommandEnvelope::new(u64::MAX); let _ = self.handle.send(Method::Shutdown { command }).await; let Some(shutdown) = self.shutdown.take() else { @@ -335,6 +469,10 @@ impl StandaloneHost { return Err(StandaloneShutdownError::DeadlineExceeded); } } + if jobs_result.is_err() { + self.retain_lease(); + return Err(StandaloneShutdownError::JobCleanup); + } let active = match active_pointer(&self.worker_store, &self.record.storage_key) { Ok(active) => active, Err(_) => { @@ -355,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() @@ -364,12 +508,70 @@ 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(); } } } +impl Drop for StandaloneHost { + fn drop(&mut self) { + self.jobs.close(); + // A dropped Host cannot synchronously confirm either controller or Job + // cleanup. Keep its lease until explicit shutdown or process recovery, + // preventing another Host from reopening a still-live execution store. + self.retain_lease(); + } +} + +// 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, @@ -395,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 { @@ -535,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 => @@ -544,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 new file mode 100644 index 000000000..c0dd3da43 --- /dev/null +++ b/crates/standalone/src/job_store.rs @@ -0,0 +1,1549 @@ +//! Host-owned durable Jobs. Opening performs recovery and therefore requires the +//! StandaloneHost's Worker lease; this is not a user-facing database constructor. +//! +//! A result is committed here before the service releases its Internal Worker. +//! Worker termination, final prose, and consumer acknowledgement are not success +//! authority. The service owns the connection mutex and live-resource cleanup. + +use std::path::Path; + +use job::{JobRequest, JobResultSubmission}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +const SCHEMA_VERSION: i64 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobState { + Pending, + Completed, + Failed, + Unknown, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobAttemptState { + Reserved, + Dispatched, + Completed, + Failed, + Unknown, + Cancelled, +} + +impl JobAttemptState { + pub fn terminal(self) -> bool { + !matches!(self, Self::Reserved | Self::Dispatched) + } + + fn as_str(self) -> &'static str { + match self { + Self::Reserved => "reserved", + Self::Dispatched => "dispatched", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Unknown => "unknown", + Self::Cancelled => "cancelled", + } + } +} + +#[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, + pub acknowledged: bool, +} + +impl JobSnapshot { + /// Shared Feature-facing read contract; no Workspace or execution identity leaks. + pub fn outcome(&self) -> job::JobOutcome { + job::JobOutcome { + job_id: self.request.job_id.clone(), + input_revision: self.request.input_revision.clone(), + attempt_id: self.attempt.attempt_id.clone(), + attempt: self.attempt.number, + state: match self.state { + JobState::Pending => job::JobState::Pending, + JobState::Completed => job::JobState::Completed, + JobState::Failed => job::JobState::Failed, + JobState::Unknown => job::JobState::Unknown, + JobState::Cancelled => job::JobState::Cancelled, + }, + attempt_state: match self.attempt.state { + JobAttemptState::Reserved => job::JobAttemptState::Reserved, + JobAttemptState::Dispatched => job::JobAttemptState::Dispatched, + JobAttemptState::Completed => job::JobAttemptState::Completed, + JobAttemptState::Failed => job::JobAttemptState::Failed, + JobAttemptState::Unknown => job::JobAttemptState::Unknown, + JobAttemptState::Cancelled => job::JobAttemptState::Cancelled, + }, + result: self.result.clone(), + failure_category: self.attempt.failure.as_ref().map(|_| { + match self.attempt.state { + JobAttemptState::Cancelled => "cancelled", + JobAttemptState::Unknown => "unknown_outcome", + _ => "execution_failed", + } + .to_string() + }), + failure_detail: self.attempt.failure.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JobAttempt { + pub attempt_id: String, + pub number: u8, + pub state: JobAttemptState, + pub failure: Option, +} + +#[derive(Debug, Error)] +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)] + Job(#[from] job::JobError), + #[error("Job directory: {0}")] + Io(#[from] std::io::Error), + #[error("unsupported Job schema version {0}")] + UnsupportedSchema(i64), + #[error("corrupt Job database: {0}")] + Corrupt(String), + #[error("unknown Job `{0}`")] + NotFound(String), + #[error("Job id `{0}` was reused with different immutable intent")] + IntentConflict(String), + #[error("serialization key `{0}` is reserved by another Job")] + SerializationConflict(String), + #[error("Job concurrent execution limit reached")] + ConcurrentLimit, + #[error("result or lifecycle operation is not bound to the current Job attempt")] + AttemptMismatch, + #[error("result input revision does not match the immutable request")] + RevisionMismatch, + #[error("accepted Job result cannot be changed")] + ResultConflict, + #[error("invalid Job transition: {0}")] + InvalidTransition(&'static str), +} + +pub(crate) struct JobStore { + conn: Connection, +} + +impl JobStore { + /// The caller must hold the Host's Worker lease for this connection's entire + /// lifetime. Recovery must never run beside a live service for this Worker. + pub(crate) fn open(path: &Path) -> Result { + 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)?; + 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 => {} + other => return Err(JobStoreError::UnsupportedSchema(other)), + } + // An accepted result is the only evidence of success. Reserved attempts + // were never started and remain pending; cancelled attempts never resume. + tx.execute_batch( + "UPDATE job_attempts SET state = 'completed', failure = NULL + WHERE state = 'dispatched' AND result_json IS NOT NULL; + UPDATE job_attempts SET state = 'unknown', + failure = 'Host interrupted before an accepted result' + WHERE state = 'dispatched' AND result_json IS NULL; + UPDATE job_intents SET state = ( + SELECT state FROM job_attempts + WHERE job_id = job_intents.job_id AND number = current_attempt) + WHERE state = 'pending' AND EXISTS ( + SELECT 1 FROM job_attempts WHERE job_id = job_intents.job_id + AND number = current_attempt AND state IN ('completed', 'unknown'));", + )?; + tx.commit()?; + Ok(Self { conn }) + } + + 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 + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(snapshot) = read_snapshot(&tx, &request.job_id)? { + let stored: String = tx.query_row( + "SELECT fingerprint FROM job_intents WHERE job_id = ?1", + [&request.job_id], + |row| row.get(0), + )?; + if stored != fingerprint + || snapshot.request != request + || snapshot.domain_grant != grant + { + return Err(JobStoreError::IntentConflict(request.job_id)); + } + tx.commit()?; + return Ok(snapshot); + } + check_serialization(&tx, request.serialization_key.as_deref(), &request.job_id)?; + tx.execute( + "INSERT INTO job_intents + (job_id, request_json, fingerprint, serialization_key, max_concurrent, + state, current_attempt, acknowledged) + VALUES (?1, ?2, ?3, ?4, ?5, 'pending', 1, 0)", + params![ + request.job_id, + request_json, + fingerprint, + request.serialization_key, + request.limits.max_concurrent_jobs + ], + )?; + 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) + } + + /// Only definitive failure can create a new immutable attempt. Unknown + /// outcomes require caller reconciliation, never an automatic replay. + pub(crate) fn retry(&mut self, job_id: &str) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let previous = require_snapshot(&tx, job_id)?; + if previous.state != JobState::Failed || previous.attempt.state != JobAttemptState::Failed { + return Err(JobStoreError::InvalidTransition( + "retry requires known failure", + )); + } + if previous.attempt.number >= previous.request.limits.max_attempts { + return Err(JobStoreError::InvalidTransition("attempt limit reached")); + } + check_serialization(&tx, previous.request.serialization_key.as_deref(), job_id)?; + let next = previous.attempt.number + 1; + insert_attempt(&tx, &previous.request, next)?; + tx.execute( + "UPDATE job_intents SET state = 'pending', current_attempt = ?2 WHERE job_id = ?1", + params![job_id, next], + )?; + let snapshot = require_snapshot(&tx, job_id)?; + tx.commit()?; + Ok(snapshot) + } + + pub(crate) fn get(&self, job_id: &str) -> Result { + 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( + "SELECT i.job_id FROM job_intents i JOIN job_attempts a + ON a.job_id = i.job_id AND a.number = i.current_attempt + WHERE i.state = 'pending' AND a.state = 'reserved' ORDER BY i.rowid", + )?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + ids.into_iter() + .map(|id| require_snapshot(&self.conn, &id)) + .collect() + } + + /// A dispatch is a claim, not an idempotent instruction to spawn again. + /// Repeating it after the claim is rejected, so one attempt has one Worker. + pub(crate) fn dispatch( + &mut self, + job_id: &str, + attempt_id: &str, + max_concurrent: u16, + ) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let snapshot = require_snapshot(&tx, job_id)?; + check_attempt(&snapshot, attempt_id)?; + if snapshot.state != JobState::Pending + || snapshot.attempt.state != JobAttemptState::Reserved + { + return Err(JobStoreError::InvalidTransition( + "dispatch requires a reserved pending attempt", + )); + } + check_serialization(&tx, snapshot.request.serialization_key.as_deref(), job_id)?; + let (active, active_limit): (i64, Option) = tx.query_row( + "SELECT COUNT(*), MIN(i.max_concurrent) FROM job_attempts a JOIN job_intents i + ON i.job_id = a.job_id AND i.current_attempt = a.number + WHERE a.state = 'dispatched'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + // Respect the Host ceiling and every currently executing request's + // ceiling, not just a new request with a more permissive limit. + let limit = max_concurrent + .min(snapshot.request.limits.max_concurrent_jobs) + .min(active_limit.unwrap_or(u16::MAX)); + if active >= i64::from(limit) { + return Err(JobStoreError::ConcurrentLimit); + } + tx.execute( + "UPDATE job_attempts SET state = 'dispatched' WHERE job_id = ?1 AND attempt_id = ?2", + params![job_id, attempt_id], + )?; + let snapshot = require_snapshot(&tx, job_id)?; + tx.commit()?; + Ok(snapshot) + } + + pub(crate) fn accept( + &mut self, + bound: &JobResultSubmission, + ) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let snapshot = require_snapshot(&tx, &bound.job_id)?; + check_attempt(&snapshot, &bound.attempt_id)?; + if snapshot.request.input_revision != bound.input_revision { + return Err(JobStoreError::RevisionMismatch); + } + let (digest, encoded) = + job::result_digest(&bound.result, snapshot.request.limits.max_result_bytes)?; + if snapshot.state == JobState::Completed + && snapshot.attempt.state == JobAttemptState::Completed + { + let (stored_digest, stored_json): (String, String) = tx.query_row( + "SELECT result_digest, result_json FROM job_attempts WHERE attempt_id = ?1", + [&bound.attempt_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + if digest != stored_digest || encoded != stored_json { + return Err(JobStoreError::ResultConflict); + } + tx.commit()?; + return Ok(snapshot); + } + if snapshot.state != JobState::Pending + || snapshot.attempt.state != JobAttemptState::Dispatched + { + return Err(JobStoreError::InvalidTransition( + "result requires the active dispatched attempt", + )); + } + tx.execute( + "UPDATE job_attempts SET result_json = ?3, result_digest = ?4, + state = 'completed', failure = NULL WHERE job_id = ?1 AND attempt_id = ?2", + params![bound.job_id, bound.attempt_id, encoded, digest], + )?; + tx.execute( + "UPDATE job_intents SET state = 'completed' WHERE job_id = ?1", + [&bound.job_id], + )?; + let snapshot = require_snapshot(&tx, &bound.job_id)?; + tx.commit()?; + Ok(snapshot) + } + + pub(crate) fn finish( + &mut self, + job_id: &str, + attempt_id: &str, + state: JobAttemptState, + detail: Option<&str>, + ) -> Result { + if !state.terminal() { + return Err(JobStoreError::InvalidTransition( + "finish requires a terminal state", + )); + } + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let snapshot = require_snapshot(&tx, job_id)?; + check_attempt(&snapshot, attempt_id)?; + // Cleanup/failure signals racing a committed result cannot erase success. + if snapshot.state == JobState::Completed || snapshot.attempt.state == state { + tx.commit()?; + return Ok(snapshot); + } + if state == JobAttemptState::Completed { + return Err(JobStoreError::InvalidTransition( + "success requires an accepted result", + )); + } + if snapshot.attempt.state.terminal() { + return Err(JobStoreError::InvalidTransition( + "terminal attempt cannot be reclassified", + )); + } + set_terminal(&tx, &snapshot, state, detail)?; + let snapshot = require_snapshot(&tx, job_id)?; + tx.commit()?; + Ok(snapshot) + } + + /// Cancellation is durable. Cancelling unknown intent must not erase the + /// attempt's uncertain execution evidence or release its serialization fence. + /// A previously accepted result wins and remains success. + pub(crate) fn cancel(&mut self, job_id: &str) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let snapshot = require_snapshot(&tx, job_id)?; + if snapshot.state == JobState::Unknown { + tx.execute( + "UPDATE job_intents SET state = 'cancelled' WHERE job_id = ?1", + [job_id], + )?; + } else if !matches!(snapshot.state, JobState::Completed | JobState::Cancelled) { + set_terminal( + &tx, + &snapshot, + JobAttemptState::Cancelled, + Some("cancelled"), + )?; + } + let snapshot = require_snapshot(&tx, job_id)?; + tx.commit()?; + Ok(snapshot) + } + + pub(crate) fn ack(&mut self, job_id: &str) -> Result { + let tx = self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?; + let snapshot = require_snapshot(&tx, job_id)?; + if snapshot.state != JobState::Completed { + return Err(JobStoreError::InvalidTransition( + "acknowledgement requires a completed Job", + )); + } + tx.execute( + "UPDATE job_intents SET acknowledged = 1 WHERE job_id = ?1", + [job_id], + )?; + let snapshot = require_snapshot(&tx, job_id)?; + tx.commit()?; + Ok(snapshot) + } +} + +fn check_attempt(snapshot: &JobSnapshot, attempt_id: &str) -> Result<(), JobStoreError> { + if snapshot.attempt.attempt_id != attempt_id { + return Err(JobStoreError::AttemptMismatch); + } + Ok(()) +} + +fn check_serialization( + conn: &Connection, + key: Option<&str>, + job_id: &str, +) -> Result<(), JobStoreError> { + if let Some(key) = key { + let conflicting: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM job_intents i WHERE serialization_key = ?1 + AND job_id <> ?2 AND (state IN ('pending', 'unknown') OR EXISTS + (SELECT 1 FROM job_attempts a WHERE a.job_id = i.job_id AND a.state = 'unknown')))", + params![key, job_id], + |row| row.get(0), + )?; + if conflicting { + return Err(JobStoreError::SerializationConflict(key.to_owned())); + } + } + Ok(()) +} + +fn insert_attempt( + conn: &Connection, + request: &JobRequest, + number: u8, +) -> Result<(), JobStoreError> { + conn.execute( + "INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state) + VALUES (?1, ?2, ?3, ?4, 'reserved')", + params![ + request.job_id, + number, + job::attempt_id(&request.job_id, number), + request.input_revision + ], + )?; + Ok(()) +} + +fn set_terminal( + conn: &Connection, + snapshot: &JobSnapshot, + state: JobAttemptState, + detail: Option<&str>, +) -> Result<(), JobStoreError> { + let detail = detail.map(job::bounded_failure_detail); + conn.execute( + "UPDATE job_attempts SET state = ?3, failure = ?4 WHERE job_id = ?1 AND attempt_id = ?2", + params![ + snapshot.request.job_id, + snapshot.attempt.attempt_id, + state.as_str(), + detail + ], + )?; + conn.execute( + "UPDATE job_intents SET state = ?2 WHERE job_id = ?1", + params![snapshot.request.job_id, state.as_str()], + )?; + Ok(()) +} + +fn require_snapshot(conn: &Connection, job_id: &str) -> Result { + read_snapshot(conn, job_id)?.ok_or_else(|| JobStoreError::NotFound(job_id.to_owned())) +} + +fn read_snapshot(conn: &Connection, job_id: &str) -> Result, JobStoreError> { + let row = conn + .query_row( + "SELECT i.request_json, i.state, i.acknowledged, a.attempt_id, a.number, + a.state, a.failure, a.result_json FROM job_intents i JOIN job_attempts a + ON a.job_id = i.job_id AND a.number = i.current_attempt WHERE i.job_id = ?1", + [job_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, bool>(2)?, + row.get::<_, String>(3)?, + row.get::<_, u8>(4)?, + row.get::<_, String>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, Option>(7)?, + )) + }, + ) + .optional()?; + row.map( + |(request, state, acknowledged, attempt_id, number, attempt_state, failure, result)| { + let state = match state.as_str() { + "pending" => JobState::Pending, + "completed" => JobState::Completed, + "failed" => JobState::Failed, + "unknown" => JobState::Unknown, + "cancelled" => JobState::Cancelled, + _ => { + return Err(JobStoreError::Corrupt(format!( + "unknown intent state `{state}`" + ))); + } + }; + let attempt_state = match attempt_state.as_str() { + "reserved" => JobAttemptState::Reserved, + "dispatched" => JobAttemptState::Dispatched, + "completed" => JobAttemptState::Completed, + "failed" => JobAttemptState::Failed, + "unknown" => JobAttemptState::Unknown, + "cancelled" => JobAttemptState::Cancelled, + _ => { + return Err(JobStoreError::Corrupt(format!( + "unknown attempt state `{attempt_state}`" + ))); + } + }; + 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 { + attempt_id, + number, + state: attempt_state, + failure, + }, + result: result.map(|json| serde_json::from_str(&json)).transpose()?, + }) + }, + ) + .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, + request_json TEXT NOT NULL, + fingerprint TEXT NOT NULL, + serialization_key TEXT, + max_concurrent INTEGER NOT NULL CHECK (max_concurrent BETWEEN 1 AND 65535), + state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'unknown', 'cancelled')), + current_attempt INTEGER NOT NULL CHECK (current_attempt BETWEEN 1 AND 255), + acknowledged INTEGER NOT NULL DEFAULT 0 CHECK (acknowledged IN (0, 1)), + CHECK (acknowledged = 0 OR state = 'completed'), + FOREIGN KEY (job_id, current_attempt) REFERENCES job_attempts(job_id, number) + DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE job_attempts ( + job_id TEXT NOT NULL REFERENCES job_intents(job_id), + number INTEGER NOT NULL CHECK (number BETWEEN 1 AND 255), + attempt_id TEXT NOT NULL UNIQUE, + input_revision TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'completed', 'failed', 'unknown', 'cancelled')), + result_json TEXT, + result_digest TEXT, + failure TEXT, + PRIMARY KEY (job_id, number), + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK (state <> 'completed' OR result_json IS NOT NULL) +); +CREATE UNIQUE INDEX job_serialization_singleflight ON job_intents(serialization_key) + WHERE serialization_key IS NOT NULL AND state IN ('pending', 'unknown'); +CREATE INDEX job_attempt_state ON job_attempts(state); +CREATE TRIGGER job_intent_immutable BEFORE UPDATE OF + job_id, request_json, fingerprint, serialization_key, max_concurrent ON job_intents +BEGIN SELECT RAISE(ABORT, 'immutable Job intent'); END; +CREATE TRIGGER job_attempt_immutable BEFORE UPDATE OF + job_id, number, attempt_id, input_revision ON job_attempts +BEGIN SELECT RAISE(ABORT, 'immutable Job attempt identity'); END; +CREATE TRIGGER job_result_immutable BEFORE UPDATE OF result_json, result_digest ON job_attempts +WHEN OLD.result_json IS NOT NULL AND + (NEW.result_json IS NOT OLD.result_json OR NEW.result_digest IS NOT OLD.result_digest) +BEGIN SELECT RAISE(ABORT, 'immutable accepted Job result'); END; +"; + +#[cfg(test)] +mod tests { + use super::*; + use job::JobLimits; + use serde_json::json; + use std::sync::{Arc, Barrier}; + use tempfile::TempDir; + + fn request(id: &str) -> JobRequest { + JobRequest { + job_id: id.into(), + purpose: "fixture".into(), + input_revision: "revision-1".into(), + input_ref: "fixture:input".into(), + input: json!({"immutable": [1, 2, 3]}), + instruction: "Produce a structured result".into(), + profile: "builtin:default".into(), + serialization_key: None, + limits: JobLimits::default(), + } + } + + fn fixture() -> (TempDir, JobStore) { + let dir = tempfile::tempdir().unwrap(); + let store = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + (dir, store) + } + + fn submission(snapshot: &JobSnapshot, result: Value) -> JobResultSubmission { + JobResultSubmission { + job_id: snapshot.request.job_id.clone(), + attempt_id: snapshot.attempt.attempt_id.clone(), + input_revision: snapshot.request.input_revision.clone(), + result, + } + } + + fn dispatched(store: &mut JobStore, request: JobRequest) -> JobSnapshot { + let reserved = store.reserve(request).unwrap(); + store + .dispatch(&reserved.request.job_id, &reserved.attempt.attempt_id, 8) + .unwrap() + } + + #[test] + fn database_configuration_schema_and_identity_constraints() { + let (_dir, mut store) = fixture(); + let version: i64 = store + .conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + let foreign_keys: i64 = store + .conn + .pragma_query_value(None, "foreign_keys", |r| r.get(0)) + .unwrap(); + let synchronous: i64 = store + .conn + .pragma_query_value(None, "synchronous", |r| r.get(0)) + .unwrap(); + let journal: String = store + .conn + .pragma_query_value(None, "journal_mode", |r| r.get(0)) + .unwrap(); + assert_eq!((version, foreign_keys, synchronous), (SCHEMA_VERSION, 1, 2)); + assert_eq!(journal, "wal"); + store.reserve(request("identity")).unwrap(); + for sql in [ + "UPDATE job_intents SET request_json = '{}' WHERE job_id = 'identity'", + "UPDATE job_intents SET fingerprint = 'changed' WHERE job_id = 'identity'", + "UPDATE job_attempts SET input_revision = 'changed' WHERE job_id = 'identity'", + "UPDATE job_attempts SET attempt_id = 'changed' WHERE job_id = 'identity'", + "UPDATE job_intents SET current_attempt = 2 WHERE job_id = 'identity'", + "INSERT INTO job_attempts (job_id, number, attempt_id, input_revision, state) + VALUES ('absent', 1, 'absent:attempt:1', 'r', 'reserved')", + ] { + assert!( + store.conn.execute_batch(sql).is_err(), + "constraint accepted {sql}" + ); + } + let mut check = store.conn.prepare("PRAGMA foreign_key_check").unwrap(); + assert!(!check.exists([]).unwrap()); + } + + #[test] + fn schema_version_is_rejected_without_recovery_or_upgrade() { + let (dir, mut store) = fixture(); + dispatched(&mut store, request("running")); + store.conn.pragma_update(None, "user_version", 42).unwrap(); + drop(store); + let path = dir.path().join("jobs.sqlite3"); + assert!(matches!( + JobStore::open(&path), + Err(JobStoreError::UnsupportedSchema(42)) + )); + let conn = Connection::open(path).unwrap(); + let state: String = conn + .query_row("SELECT state FROM job_attempts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(state, "dispatched"); + assert_eq!( + conn.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)) + .unwrap(), + 42 + ); + } + + #[test] + fn exact_replay_is_stable_and_every_intent_change_conflicts() { + let (dir, mut store) = fixture(); + assert!(matches!( + store.get("absent"), + Err(JobStoreError::NotFound(_)) + )); + let original = request("replay"); + let snapshot = store.reserve(original.clone()).unwrap(); + assert_eq!(snapshot.state, JobState::Pending); + assert_eq!(snapshot.attempt.number, 1); + assert_eq!(snapshot.attempt.attempt_id, job::attempt_id("replay", 1)); + assert_eq!(snapshot.attempt.state, JobAttemptState::Reserved); + assert_eq!(snapshot.result, None); + assert!(!snapshot.acknowledged); + assert_eq!(store.reserve(original.clone()).unwrap(), snapshot); + let mut variants = Vec::new(); + let mut changed = original.clone(); + changed.input_revision = "revision-2".into(); + variants.push(changed); + let mut changed = original.clone(); + changed.input = json!({"changed": true}); + variants.push(changed); + let mut changed = original.clone(); + changed.input_ref = "other:input".into(); + variants.push(changed); + let mut changed = original.clone(); + changed.profile = "builtin:coder".into(); + variants.push(changed); + let mut changed = original.clone(); + changed.instruction = "Different instruction".into(); + variants.push(changed); + let mut changed = original.clone(); + changed.purpose = "other_purpose".into(); + variants.push(changed); + let mut changed = original.clone(); + changed.serialization_key = Some("key".into()); + variants.push(changed); + let mut changed = original.clone(); + changed.limits.max_attempts = 3; + variants.push(changed); + for variant in variants { + assert!(matches!( + store.reserve(variant), + Err(JobStoreError::IntentConflict(_)) + )); + } + drop(store); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.reserve(original).unwrap(), snapshot); + assert_eq!(reopened.pending().unwrap(), vec![snapshot]); + let count: i64 = reopened + .conn + .query_row("SELECT COUNT(*) FROM job_attempts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn invalid_requests_never_create_intent_or_attempt() { + let (_dir, mut store) = fixture(); + let mut invalid = request("invalid"); + invalid.limits.max_attempts = 0; + assert!(matches!(store.reserve(invalid), Err(JobStoreError::Job(_)))); + let mut invalid = request("invalid"); + invalid.profile = "/tmp/raw-profile.toml".into(); + assert!(matches!(store.reserve(invalid), Err(JobStoreError::Job(_)))); + let mut invalid = request("invalid"); + invalid.input = json!("x".repeat(job::MAX_JOB_INPUT_BYTES)); + assert!(matches!(store.reserve(invalid), Err(JobStoreError::Job(_)))); + assert!(store.pending().unwrap().is_empty()); + assert!(matches!( + store.get("invalid"), + Err(JobStoreError::NotFound(_)) + )); + } + + #[test] + fn retry_is_explicit_bounded_and_preserves_prior_attempt() { + let (dir, mut store) = fixture(); + let mut original = request("retry"); + original.limits.max_attempts = 2; + let first = dispatched(&mut store, original.clone()); + assert!(store.retry("retry").is_err()); + let failed = store + .finish( + "retry", + &first.attempt.attempt_id, + JobAttemptState::Failed, + Some("timeout"), + ) + .unwrap(); + assert_eq!(store.reserve(original.clone()).unwrap(), failed); + let second = store.retry("retry").unwrap(); + assert_eq!(second.request, original); + assert_eq!(second.attempt.number, 2); + assert_eq!(second.attempt.state, JobAttemptState::Reserved); + assert_eq!(second.attempt.failure, None); + assert_eq!(second.result, None); + assert!(matches!( + store.accept(&submission(&first, json!({}))), + Err(JobStoreError::AttemptMismatch) + )); + assert!(matches!( + store.finish( + "retry", + &first.attempt.attempt_id, + JobAttemptState::Failed, + None + ), + Err(JobStoreError::AttemptMismatch) + )); + assert!(matches!( + store.dispatch("retry", &first.attempt.attempt_id, 8), + Err(JobStoreError::AttemptMismatch) + )); + let prior: (String, String, String) = store.conn.query_row( + "SELECT state, failure, input_revision FROM job_attempts WHERE job_id = 'retry' AND number = 1", + [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).unwrap(); + assert_eq!( + prior, + ("failed".into(), "timeout".into(), "revision-1".into()) + ); + store + .finish( + "retry", + &second.attempt.attempt_id, + JobAttemptState::Failed, + None, + ) + .unwrap(); + assert!(matches!( + store.retry("retry"), + Err(JobStoreError::InvalidTransition("attempt limit reached")) + )); + drop(store); + let reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.get("retry").unwrap().attempt.number, 2); + } + + #[test] + fn result_fences_size_and_replay_are_enforced_transactionally() { + let (_dir, mut store) = fixture(); + let mut req = request("result"); + req.limits.max_result_bytes = 32; + let reserved = store.reserve(req).unwrap(); + let valid = submission(&reserved, json!({"ok": true})); + assert!(store.accept(&valid).is_err()); + let active = store + .dispatch("result", &reserved.attempt.attempt_id, 8) + .unwrap(); + assert!(store.pending().unwrap().is_empty()); + let mut invalid = valid.clone(); + invalid.attempt_id = "wrong-attempt".into(); + assert!(matches!( + store.accept(&invalid), + Err(JobStoreError::AttemptMismatch) + )); + let mut invalid = valid.clone(); + invalid.input_revision = "revision-2".into(); + assert!(matches!( + store.accept(&invalid), + Err(JobStoreError::RevisionMismatch) + )); + let mut invalid = valid.clone(); + invalid.result = json!("x".repeat(32)); + assert!(matches!(store.accept(&invalid), Err(JobStoreError::Job(_)))); + assert_eq!(store.get("result").unwrap(), active); + let completed = store.accept(&valid).unwrap(); + assert_eq!(completed.state, JobState::Completed); + assert_eq!(completed.attempt.state, JobAttemptState::Completed); + assert_eq!(completed.result, Some(valid.result.clone())); + assert_eq!(store.accept(&valid).unwrap(), completed); + let (digest, json): (String, String) = store + .conn + .query_row( + "SELECT result_digest, result_json FROM job_attempts WHERE job_id = 'result'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!( + (digest, json), + job::result_digest(&valid.result, 32).unwrap() + ); + let mut changed = valid.clone(); + changed.result = json!({"ok": false}); + assert!(matches!( + store.accept(&changed), + Err(JobStoreError::ResultConflict) + )); + assert!(store.conn.execute("UPDATE job_attempts SET result_json = 'null', result_digest = 'changed' WHERE job_id = 'result'", []).is_err()); + assert_eq!(store.get("result").unwrap(), completed); + } + + #[test] + fn exact_byte_boundary_and_json_null_are_accepted() { + let (_dir, mut store) = fixture(); + let mut req = request("boundary"); + req.limits.max_result_bytes = 4; + let active = dispatched(&mut store, req); + let completed = store.accept(&submission(&active, Value::Null)).unwrap(); + assert_eq!(completed.result, Some(Value::Null)); + let mut req = request("utf8"); + req.limits.max_result_bytes = 4; + let active = dispatched(&mut store, req); + assert!(store.accept(&submission(&active, json!("猫"))).is_err()); + assert_eq!(store.get("utf8").unwrap().state, JobState::Pending); + } + + #[test] + fn only_structured_result_is_success_and_cleanup_cannot_erase_it() { + let (_dir, mut store) = fixture(); + let active = dispatched(&mut store, request("success")); + for state in [ + JobAttemptState::Completed, + JobAttemptState::Reserved, + JobAttemptState::Dispatched, + ] { + assert!( + store + .finish( + "success", + &active.attempt.attempt_id, + state, + Some("final prose") + ) + .is_err() + ); + } + assert!(store.ack("success").is_err()); + let completed = store + .accept(&submission(&active, json!({"value": 1}))) + .unwrap(); + for state in [ + JobAttemptState::Failed, + JobAttemptState::Unknown, + JobAttemptState::Cancelled, + JobAttemptState::Completed, + ] { + assert_eq!( + store + .finish( + "success", + &active.attempt.attempt_id, + state, + Some("cleanup error") + ) + .unwrap(), + completed + ); + } + assert_eq!(store.cancel("success").unwrap(), completed); + assert!(store.retry("success").is_err()); + } + + #[test] + fn consumer_acknowledgement_is_independent_durable_and_idempotent() { + let (dir, mut store) = fixture(); + let active = dispatched(&mut store, request("ack")); + let accepted = store + .accept(&submission(&active, json!({"result": "retained"}))) + .unwrap(); + assert!(!accepted.acknowledged); + drop(store); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.get("ack").unwrap(), accepted.clone()); + assert!(reopened.pending().unwrap().is_empty()); + assert!(reopened.retry("ack").is_err()); + let acknowledged = reopened.ack("ack").unwrap(); + assert!(acknowledged.acknowledged); + assert_eq!(acknowledged.result, accepted.result); + assert_eq!(reopened.ack("ack").unwrap(), acknowledged); + assert_eq!( + reopened + .accept(&submission(&active, json!({"result": "retained"}))) + .unwrap(), + acknowledged + ); + drop(reopened); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.reserve(request("ack")).unwrap(), acknowledged); + } + + #[test] + fn reopen_keeps_reserved_and_marks_unaccepted_dispatch_unknown() { + let (dir, mut store) = fixture(); + let pending = store.reserve(request("pending")).unwrap(); + let active = dispatched(&mut store, request("interrupted")); + let failed = store.reserve(request("failed")).unwrap(); + store + .finish( + "failed", + &failed.attempt.attempt_id, + JobAttemptState::Failed, + Some("known failure"), + ) + .unwrap(); + drop(store); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.pending().unwrap(), vec![pending.clone()]); + assert_eq!(reopened.get("pending").unwrap(), pending); + let unknown = reopened.get("interrupted").unwrap(); + assert_eq!(unknown.state, JobState::Unknown); + assert_eq!(unknown.attempt.state, JobAttemptState::Unknown); + assert_eq!(unknown.result, None); + assert!(unknown.attempt.failure.is_some()); + assert_eq!(reopened.reserve(request("interrupted")).unwrap(), unknown); + assert!(reopened.retry("interrupted").is_err()); + assert!( + reopened + .accept(&submission(&active, json!({"late": true}))) + .is_err() + ); + assert!( + reopened + .dispatch("interrupted", &active.attempt.attempt_id, 8) + .is_err() + ); + assert_eq!(reopened.get("failed").unwrap().state, JobState::Failed); + drop(reopened); + let reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.get("interrupted").unwrap(), unknown); + } + + #[test] + fn recovery_retains_accepted_result_in_dispatched_fixture() { + let (dir, mut store) = fixture(); + let active = dispatched(&mut store, request("accepted-fixture")); + let bound = submission(&active, json!({"accepted": true})); + // Explicit recovery fixture, not a possible half-commit of accept(): + // accept() updates both states and the result in one transaction. + let (digest, encoded) = job::result_digest(&bound.result, 1024).unwrap(); + store.conn.execute( + "UPDATE job_attempts SET result_json = ?1, result_digest = ?2 WHERE attempt_id = ?3", + params![encoded, digest, bound.attempt_id], + ).unwrap(); + drop(store); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + let retained = reopened.get("accepted-fixture").unwrap(); + assert_eq!(retained.state, JobState::Completed); + assert_eq!(retained.attempt.state, JobAttemptState::Completed); + assert_eq!(retained.result, Some(bound.result.clone())); + assert_eq!(reopened.accept(&bound).unwrap(), retained); + assert!(reopened.pending().unwrap().is_empty()); + } + + #[test] + fn cancellation_never_resumes_and_rejects_late_results() { + let (dir, mut store) = fixture(); + for (id, start) in [("reserved-cancel", false), ("dispatched-cancel", true)] { + let snapshot = if start { + dispatched(&mut store, request(id)) + } else { + store.reserve(request(id)).unwrap() + }; + let cancelled = store.cancel(id).unwrap(); + assert_eq!(cancelled.state, JobState::Cancelled); + assert_eq!(cancelled.attempt.state, JobAttemptState::Cancelled); + assert_eq!(store.cancel(id).unwrap(), cancelled); + assert_eq!(store.reserve(request(id)).unwrap(), cancelled); + assert!(store.retry(id).is_err()); + assert!(store.dispatch(id, &snapshot.attempt.attempt_id, 8).is_err()); + assert!(store.accept(&submission(&snapshot, json!({}))).is_err()); + assert!( + store + .finish( + id, + &snapshot.attempt.attempt_id, + JobAttemptState::Failed, + None + ) + .is_err() + ); + assert!(store.ack(id).is_err()); + } + drop(store); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert!(reopened.pending().unwrap().is_empty()); + for id in ["reserved-cancel", "dispatched-cancel"] { + assert_eq!( + reopened.reserve(request(id)).unwrap().state, + JobState::Cancelled + ); + } + } + + #[test] + fn serialization_fences_pending_unknown_and_conflicting_retry() { + let (dir, mut store) = fixture(); + let mut first = request("first"); + first.serialization_key = Some("resource".into()); + let mut second = request("second"); + second.serialization_key = Some("resource".into()); + let reserved = store.reserve(first.clone()).unwrap(); + assert_eq!(store.reserve(first.clone()).unwrap(), reserved); + assert!(matches!( + store.reserve(second.clone()), + Err(JobStoreError::SerializationConflict(_)) + )); + store + .dispatch("first", &reserved.attempt.attempt_id, 8) + .unwrap(); + drop(store); + let mut reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert!(matches!( + reopened.reserve(second.clone()), + Err(JobStoreError::SerializationConflict(_)) + )); + let cancelled_unknown = reopened.cancel("first").unwrap(); + assert_eq!(cancelled_unknown.state, JobState::Cancelled); + assert_eq!(cancelled_unknown.attempt.state, JobAttemptState::Unknown); + assert!(matches!( + reopened.reserve(second.clone()), + Err(JobStoreError::SerializationConflict(_)) + )); + assert!(reopened.retry("first").is_err()); + // Another independent resource is not fenced by this uncertain attempt. + second.serialization_key = Some("independent-resource".into()); + let second_snapshot = reopened.reserve(second.clone()).unwrap(); + reopened + .finish( + "second", + &second_snapshot.attempt.attempt_id, + JobAttemptState::Failed, + None, + ) + .unwrap(); + let mut third = request("third"); + third.serialization_key = Some("independent-resource".into()); + reopened.reserve(third).unwrap(); + assert!(matches!( + reopened.retry("second"), + Err(JobStoreError::SerializationConflict(_)) + )); + assert_eq!(reopened.get("second").unwrap().attempt.number, 1); + reopened.cancel("third").unwrap(); + assert_eq!(reopened.retry("second").unwrap().attempt.number, 2); + } + + #[test] + fn dispatch_enforces_host_and_all_active_request_limits_without_double_start() { + let (_dir, mut store) = fixture(); + let mut strict = request("strict"); + strict.limits.max_concurrent_jobs = 1; + let strict = store.reserve(strict).unwrap(); + let other = store.reserve(request("other")).unwrap(); + assert!(matches!( + store.dispatch("strict", &strict.attempt.attempt_id, 0), + Err(JobStoreError::ConcurrentLimit) + )); + store + .dispatch("strict", &strict.attempt.attempt_id, 8) + .unwrap(); + assert!(matches!( + store.dispatch("other", &other.attempt.attempt_id, 8), + Err(JobStoreError::ConcurrentLimit) + )); + assert!( + store + .dispatch("strict", &strict.attempt.attempt_id, 8) + .is_err() + ); + store + .finish( + "strict", + &strict.attempt.attempt_id, + JobAttemptState::Failed, + None, + ) + .unwrap(); + store + .dispatch("other", &other.attempt.attempt_id, 8) + .unwrap(); + let strict = store.retry("strict").unwrap(); + assert!(matches!( + store.dispatch("strict", &strict.attempt.attempt_id, 8), + Err(JobStoreError::ConcurrentLimit) + )); + let host_bound = store.reserve(request("host-bound")).unwrap(); + assert!(matches!( + store.dispatch("host-bound", &host_bound.attempt.attempt_id, 1), + Err(JobStoreError::ConcurrentLimit) + )); + assert_eq!( + store.get("host-bound").unwrap().attempt.state, + JobAttemptState::Reserved + ); + store.accept(&submission(&other, json!({}))).unwrap(); + store + .dispatch("host-bound", &host_bound.attempt.attempt_id, 1) + .unwrap(); + } + + #[test] + fn competing_transactions_cannot_exceed_host_dispatch_limit() { + let (dir, mut one) = fixture(); + // Open all fixture connections before dispatch: production has one + // Host-leased connection and never invokes recovery beside live work. + let mut two = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + let first = one.reserve(request("one")).unwrap(); + let second = two.reserve(request("two")).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let threads = [(one, first), (two, second)] + .into_iter() + .map(|(mut store, snapshot)| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + barrier.wait(); + store.dispatch(&snapshot.request.job_id, &snapshot.attempt.attempt_id, 1) + }) + }) + .collect::>(); + let results = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|r| matches!(r, Err(JobStoreError::ConcurrentLimit))) + .count(), + 1 + ); + let conn = Connection::open(dir.path().join("jobs.sqlite3")).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM job_attempts WHERE state = 'dispatched'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn competing_reservations_enforce_singleflight_without_partial_intent() { + let (dir, one) = fixture(); + let two = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let threads = [(one, "one"), (two, "two")] + .into_iter() + .map(|(mut store, id)| { + let barrier = barrier.clone(); + std::thread::spawn(move || { + let mut req = request(id); + req.serialization_key = Some("singleflight".into()); + barrier.wait(); + store.reserve(req) + }) + }) + .collect::>(); + let results = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|r| matches!(r, Err(JobStoreError::SerializationConflict(_)))) + .count(), + 1 + ); + let reopened = JobStore::open(&dir.path().join("jobs.sqlite3")).unwrap(); + assert_eq!(reopened.pending().unwrap().len(), 1); + let count: i64 = reopened + .conn + .query_row("SELECT COUNT(*) FROM job_attempts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn failure_detail_is_utf8_bounded_and_terminal_classification_is_stable() { + let (_dir, mut store) = fixture(); + let reserved = store.reserve(request("failure")).unwrap(); + let failed = store + .finish( + "failure", + &reserved.attempt.attempt_id, + JobAttemptState::Failed, + Some(&"猫".repeat(1024)), + ) + .unwrap(); + let detail = failed.attempt.failure.as_ref().unwrap(); + assert!(detail.len() <= job::MAX_JOB_FAILURE_BYTES); + assert!(detail.ends_with('猫')); + assert_eq!( + store + .finish( + "failure", + &reserved.attempt.attempt_id, + JobAttemptState::Failed, + Some("different detail") + ) + .unwrap(), + failed + ); + assert!( + store + .finish( + "failure", + &reserved.attempt.attempt_id, + JobAttemptState::Unknown, + None + ) + .is_err() + ); + assert!( + store + .finish( + "failure", + &reserved.attempt.attempt_id, + JobAttemptState::Completed, + None + ) + .is_err() + ); + assert!(store.accept(&submission(&reserved, json!({}))).is_err()); + assert!(store.ack("failure").is_err()); + assert_eq!(store.cancel("failure").unwrap().state, JobState::Cancelled); + assert!(store.retry("failure").is_err()); + } + + #[test] + fn injected_sql_failures_roll_back_reserve_result_and_retry() { + let (_dir, mut store) = fixture(); + store + .conn + .execute_batch( + "CREATE TRIGGER fixture_abort_attempt BEFORE INSERT ON job_attempts + BEGIN SELECT RAISE(ABORT, 'fixture insertion failure'); END;", + ) + .unwrap(); + assert!(matches!( + store.reserve(request("atomic")), + Err(JobStoreError::Sqlite(_)) + )); + assert!(store.get("atomic").is_err()); + store + .conn + .execute_batch("DROP TRIGGER fixture_abort_attempt") + .unwrap(); + let active = dispatched(&mut store, request("atomic")); + store + .conn + .execute_batch( + "CREATE TRIGGER fixture_abort_intent BEFORE UPDATE OF state ON job_intents + BEGIN SELECT RAISE(ABORT, 'fixture intent failure'); END;", + ) + .unwrap(); + let bound = submission(&active, json!({"atomic": true})); + assert!(matches!( + store.accept(&bound), + Err(JobStoreError::Sqlite(_)) + )); + assert_eq!(store.get("atomic").unwrap(), active); + assert!(matches!( + store.cancel("atomic"), + Err(JobStoreError::Sqlite(_)) + )); + assert_eq!(store.get("atomic").unwrap(), active); + store + .conn + .execute_batch("DROP TRIGGER fixture_abort_intent") + .unwrap(); + let failed = store + .finish( + "atomic", + &active.attempt.attempt_id, + JobAttemptState::Failed, + Some("known failure"), + ) + .unwrap(); + store + .conn + .execute_batch( + "CREATE TRIGGER fixture_abort_intent BEFORE UPDATE OF state ON job_intents + BEGIN SELECT RAISE(ABORT, 'fixture intent failure'); END;", + ) + .unwrap(); + assert!(matches!( + store.retry("atomic"), + Err(JobStoreError::Sqlite(_)) + )); + assert_eq!(store.get("atomic").unwrap(), failed); + let count: i64 = store + .conn + .query_row("SELECT COUNT(*) FROM job_attempts", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + store + .conn + .execute_batch("DROP TRIGGER fixture_abort_intent") + .unwrap(); + let next = store.retry("atomic").unwrap(); + store + .dispatch("atomic", &next.attempt.attempt_id, 8) + .unwrap(); + assert_eq!( + store + .accept(&submission(&next, bound.result)) + .unwrap() + .state, + JobState::Completed + ); + } + + #[test] + fn unknown_job_operations_do_not_create_records() { + let (_dir, mut store) = fixture(); + assert!(matches!( + store.retry("missing"), + Err(JobStoreError::NotFound(_)) + )); + assert!(matches!( + store.dispatch("missing", "attempt", 8), + Err(JobStoreError::NotFound(_)) + )); + assert!(matches!( + store.finish("missing", "attempt", JobAttemptState::Failed, None), + Err(JobStoreError::NotFound(_)) + )); + assert!(matches!( + store.cancel("missing"), + Err(JobStoreError::NotFound(_)) + )); + assert!(matches!( + store.ack("missing"), + Err(JobStoreError::NotFound(_)) + )); + let absent = JobResultSubmission { + job_id: "missing".into(), + attempt_id: "attempt".into(), + input_revision: "r".into(), + result: json!({}), + }; + assert!(matches!( + store.accept(&absent), + Err(JobStoreError::NotFound(_)) + )); + assert!(store.pending().unwrap().is_empty()); + } +} diff --git a/crates/standalone/src/jobs.rs b/crates/standalone/src/jobs.rs new file mode 100644 index 000000000..0753e1937 --- /dev/null +++ b/crates/standalone/src/jobs.rs @@ -0,0 +1,508 @@ +//! Host-provided local Jobs. No Runtime registry, Workspace client or dialog-session state. +#[cfg(test)] +mod tests; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agen::llm_client::LlmClient; +use job::{JobRequest, JobResultSubmission}; +use tokio::sync::watch; + +use crate::job_store::JobStore; +pub use crate::job_store::{JobAttempt, JobAttemptState, JobSnapshot, JobState, JobStoreError}; + +#[derive(Debug, thiserror::Error)] +pub enum JobServiceError { + #[error(transparent)] + Store(#[from] JobStoreError), + #[error("standalone Job execution requirements are unavailable: {0}")] + Configuration(String), + #[error("the standalone Job Host is shutting down")] + Closed, + #[error("standalone Job execution capacity is exhausted")] + Capacity, + #[error("standalone Job cleanup failed: {0}")] + 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>>, + attempt_id: String, + resource_key: Option, + max_concurrent: u16, + task: tokio::task::JoinHandle>, +} +struct Executions { + closed: bool, + jobs: HashMap, +} + +/// Cloneable Feature-facing access to a Host-owned service. Cloning this handle +/// neither extends the Host's authority nor permits opening/recovering its store. +#[derive(Clone)] +pub struct StandaloneJobs { + store: Arc>>, + executions: Arc>, + cwd: PathBuf, + capacity: u16, + domain: Arc>>>, + model_client: Arc>>>, +} + +impl StandaloneJobs { + pub(crate) fn open(path: &Path, cwd: PathBuf) -> Result { + Ok(Self { + store: Arc::new(Mutex::new(Some(JobStore::open(path)?))), + executions: Arc::new(Mutex::new(Executions { + closed: false, + jobs: HashMap::new(), + })), + 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, + ) -> Result { + let mut slot = self.store.lock().expect("Job store poisoned"); + Ok(operation(slot.as_mut().ok_or(JobServiceError::Closed)?)?) + } + + /// Persist intent without starting a model. Exact resend returns the same + /// current attempt, including after acknowledgement or cancellation. + pub fn request(&self, request: JobRequest) -> Result { + let executions = self.executions.lock().expect("Job executions poisoned"); + if executions.closed { + return Err(JobServiceError::Closed); + } + Ok(self.with_store(|store| store.reserve(request))?) + } + + pub fn get(&self, job_id: &str) -> Result { + Ok(self.with_store(|store| store.get(job_id))?) + } + + /// Pending attempts survive shutdown. The consumer chooses when to resume + /// them; opening a Host never implicitly replays unknown side effects. + pub fn pending(&self) -> Result, JobServiceError> { + 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 { + let executions = self.executions.lock().expect("Job executions poisoned"); + if executions.closed { + return Err(JobServiceError::Closed); + } + if executions + .jobs + .get(job_id) + .is_some_and(|run| !run.task.is_finished()) + { + return Err(JobServiceError::Capacity); + } + Ok(self.with_store(|store| store.retry(job_id))?) + } + + /// Successful model processing and consumer postprocessing have independent + /// lifetimes. A failed delivery simply leaves this acknowledgement pending. + pub fn acknowledge(&self, job_id: &str) -> Result { + let executions = self.executions.lock().expect("Job executions poisoned"); + if executions.closed { + return Err(JobServiceError::Closed); + } + Ok(self.with_store(|store| store.ack(job_id))?) + } + + pub fn cancel(&self, job_id: &str) -> Result { + let executions = self.executions.lock().expect("Job executions poisoned"); + if executions.closed { + return Err(JobServiceError::Closed); + } + let snapshot = self.with_store(|store| store.cancel(job_id))?; + if let Some(execution) = executions.jobs.get(job_id) { + execution.cancel.send_replace(true); + } + Ok(snapshot) + } + + pub async fn start(&self, job_id: &str) -> Result<(), JobServiceError> { + self.start_with_optional_model_client(job_id, None).await + } + + /// Injectable model transport for embedders and scripted execution tests. + /// The requested registry Profile still resolves/validates normally; the + /// transport grants no filesystem, Feature or result-submission authority. + pub async fn start_with_model_client( + &self, + job_id: &str, + client: C, + ) -> Result<(), JobServiceError> + where + C: LlmClient + 'static, + { + self.start_with_optional_model_client(job_id, Some(Box::new(client))) + .await + } + + async fn start_with_optional_model_client( + &self, + 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 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); + } + executions.jobs.retain(|_, run| { + !(run.task.is_finished() && matches!(run.complete.borrow().as_ref(), Some(Ok(())))) + }); + // Count live resources, not only result states: acceptance can precede + // the Internal Worker's cleanup and must not free a concurrency slot. + let live = || { + executions + .jobs + .values() + .filter(|run| !run.task.is_finished()) + }; + let count = live().count(); + let active_limit = live() + .map(|run| run.max_concurrent) + .min() + .unwrap_or(self.capacity); + if count >= usize::from(self.capacity.min(active_limit)) + || count >= usize::from(snapshot.request.limits.max_concurrent_jobs) + || executions + .jobs + .get(job_id) + .is_some_and(|run| !run.task.is_finished()) + { + return Err(JobServiceError::Capacity); + } + let resource_key = snapshot.request.serialization_key.clone(); + if let Some(key) = &resource_key { + if executions + .jobs + .values() + .any(|run| !run.task.is_finished() && run.resource_key.as_ref() == Some(key)) + { + return Err(JobServiceError::Capacity); + } + } + // Retain errors from finished tasks until a caller observes them rather + // than silently swallowing store/cleanup failures. + if let Some(previous) = executions.jobs.get(job_id) { + if !previous.task.is_finished() { + return Err(JobServiceError::Capacity); + } + } + let snapshot = self.with_store(|store| { + store.dispatch(job_id, &snapshot.attempt.attempt_id, self.capacity) + })?; + let store = self.store.clone(); + let id = job_id.to_string(); + let attempt_id = snapshot.attempt.attempt_id.clone(); + 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, + }); + let (cancel, mut cancellation) = watch::channel(false); + let (complete_tx, complete) = watch::channel(None); + let execution_attempt = attempt_id.clone(); + let task = tokio::spawn(async move { + let outcome: Result<(), JobServiceError> = async { + let (sender_tx, mut sender_rx) = tokio::sync::oneshot::channel(); + let run = prepared.run(attempt_id.clone(), sink, move |sender| { let _ = sender_tx.send(sender); }); + tokio::pin!(run); + let reason = tokio::select! { + result = &mut run => { + let (state, detail) = match result { + Ok(_) => (JobAttemptState::Failed, "Worker finished without an accepted structured result".to_string()), + Err(error @ worker::job::JobExecutionError::ResultUnconfirmed(_)) => (JobAttemptState::Unknown, error.to_string()), + Err(error) => (JobAttemptState::Failed, error.to_string()), + }; + finish_if_pending(&store, &id, &attempt_id, state, &detail)?; + return Ok(()); + } + _ = tokio::time::sleep(timeout) => "timeout", + _ = cancellation.wait_for(|cancelled| *cancelled) => "Host cancellation/shutdown", + }; + // Fence timeout/shutdown before cancelling so a late tool call can + // never turn an interrupted unaccepted execution into success. + let fence_result = finish_if_pending(&store, &id, &attempt_id, JobAttemptState::Unknown, reason); + // Cancellation is routed through Engine; keep the future alive to + // allow normal Feature cleanup. Hard stop is a fenced unknown + // outcome, never an automatic retry. Result acceptance wins races. + if let Ok(sender) = sender_rx.try_recv() { + let _ = sender.try_send(()); + } else if let Ok(Ok(sender)) = tokio::time::timeout(Duration::from_secs(1), &mut sender_rx).await { + let _ = sender.try_send(()); + } + let cleanup = tokio::time::timeout(Duration::from_secs(2), &mut run).await; + fence_result?; + if cleanup.is_err() { return Err(JobServiceError::Cleanup("Internal Worker cancellation deadline exceeded; outcome fenced unknown".into())); } + Ok(()) + }.await; + complete_tx.send_replace(Some( + outcome.as_ref().map(|_| ()).map_err(ToString::to_string), + )); + outcome + }); + executions.jobs.insert( + job_id.to_string(), + Execution { + cancel, + complete, + task, + attempt_id: execution_attempt, + resource_key, + max_concurrent: snapshot.request.limits.max_concurrent_jobs, + }, + ); + Ok(()) + } + + /// Observe execution/cleanup, not success: read the durable result separately. + pub async fn wait(&self, job_id: &str) -> Result { + let execution = self + .executions + .lock() + .expect("Job executions poisoned") + .jobs + .get(job_id) + .map(|run| (run.attempt_id.clone(), run.complete.clone())); + if let Some((attempt, mut completion)) = execution { + let result = completion + .wait_for(Option::is_some) + .await + .map_err(|error| JobServiceError::Cleanup(error.to_string()))? + .clone() + .expect("completion"); + result.map_err(JobServiceError::Cleanup)?; + let task = { + let mut executions = self.executions.lock().expect("Job executions poisoned"); + if executions + .jobs + .get(job_id) + .is_some_and(|run| run.attempt_id == attempt) + { + executions.jobs.remove(job_id) + } else { + None + } + }; + if let Some(execution) = task { + execution + .task + .await + .map_err(|error| JobServiceError::Cleanup(error.to_string()))??; + } + } + self.get(job_id) + } + + pub(crate) fn close(&self) { + let mut executions = self.executions.lock().expect("Job executions poisoned"); + executions.closed = true; + for execution in executions.jobs.values() { + execution.cancel.send_replace(true); + } + } + + pub(crate) async fn shutdown(&self) -> Result<(), JobServiceError> { + self.close(); + let tasks = { + let mut executions = self.executions.lock().expect("Job executions poisoned"); + std::mem::take(&mut executions.jobs) + }; + let mut error = None; + for execution in tasks.into_values() { + match execution.task.await { + Ok(Ok(())) => {} + Ok(Err(failure)) => { + error = Some(failure); + } + Err(failure) => { + error = Some(JobServiceError::Cleanup(failure.to_string())); + } + } + } + // 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) + } +} + +fn finish_if_pending( + store: &Mutex>, + id: &str, + attempt: &str, + state: JobAttemptState, + detail: &str, +) -> Result<(), JobServiceError> { + let mut slot = store.lock().expect("Job store poisoned"); + let store = slot.as_mut().ok_or(JobServiceError::Closed)?; + let snapshot = store.get(id)?; + if snapshot.state == JobState::Pending { + store.finish(id, attempt, state, Some(detail))?; + } + Ok(()) +} + +struct BoundResultSink { + store: Arc>>, + job_id: String, + attempt_id: String, + input_revision: String, + deadline: tokio::time::Instant, +} +impl worker::job::JobResultSink for BoundResultSink { + fn submit(&self, submission: JobResultSubmission) -> Result<(), String> { + if submission.job_id != self.job_id + || submission.attempt_id != self.attempt_id + || submission.input_revision != self.input_revision + { + return Err("Job result capability binding mismatch".into()); + } + let mut slot = self + .store + .lock() + .map_err(|_| "Job store poisoned".to_string())?; + let store = slot.as_mut().ok_or_else(|| "Job Host closed".to_string())?; + let snapshot = store.get(&self.job_id).map_err(|error| error.to_string())?; + if tokio::time::Instant::now() >= self.deadline && snapshot.state != JobState::Completed { + return Err("Job result deadline expired".into()); + } + store + .accept(&submission) + .map(|_| ()) + .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 new file mode 100644 index 000000000..213afa6cf --- /dev/null +++ b/crates/standalone/src/jobs/tests.rs @@ -0,0 +1,471 @@ +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 { + JobRequest { + job_id: id.into(), + purpose: "test".into(), + input_revision: "r1".into(), + input_ref: "fixture:input".into(), + input: serde_json::json!({"data":42}), + instruction: "Return a structured result".into(), + profile: "builtin:job".into(), + serialization_key: None, + limits: job::JobLimits::default(), + } +} +fn sink(jobs: &StandaloneJobs, id: &str, attempt: &str) -> BoundResultSink { + BoundResultSink { + store: jobs.store.clone(), + job_id: id.into(), + attempt_id: attempt.into(), + input_revision: "r1".into(), + deadline: tokio::time::Instant::now() + Duration::from_secs(10), + } +} +fn result(id: &str, attempt: &str) -> JobResultSubmission { + JobResultSubmission { + job_id: id.into(), + attempt_id: attempt.into(), + input_revision: "r1".into(), + result: serde_json::json!({"valid":true}), + } +} + +#[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(); + let jobs = + StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); + let first = jobs.request(request("first")).unwrap(); + let second = jobs.request(request("second")).unwrap(); + for snapshot in [&first, &second] { + jobs.with_store(|store| { + store.dispatch(&snapshot.request.job_id, &snapshot.attempt.attempt_id, 8) + }) + .unwrap(); + } + let bound = sink(&jobs, "first", &first.attempt.attempt_id); + assert!( + bound + .submit(result("second", &second.attempt.attempt_id)) + .is_err() + ); + assert!(bound.submit(result("first", "first:attempt:2")).is_err()); + let mut stale = result("first", &first.attempt.attempt_id); + stale.input_revision = "r0".into(); + assert!(bound.submit(stale).is_err()); + let mut expired = sink(&jobs, "first", &first.attempt.attempt_id); + expired.deadline = tokio::time::Instant::now() - Duration::from_secs(1); + assert!( + expired + .submit(result("first", &first.attempt.attempt_id)) + .is_err() + ); + assert!(jobs.get("first").unwrap().result.is_none()); + bound + .submit(result("first", &first.attempt.attempt_id)) + .unwrap(); + // Accepted exact replay is still allowed after the deadline, never a new result. + expired + .submit(result("first", &first.attempt.attempt_id)) + .unwrap(); + let mut changed = result("first", &first.attempt.attempt_id); + changed.result = serde_json::json!({"valid":false}); + assert!(expired.submit(changed).is_err()); + assert_eq!(jobs.get("second").unwrap().state, JobState::Pending); + jobs.shutdown().await.unwrap(); + assert!( + bound + .submit(result("first", &first.attempt.attempt_id)) + .is_err() + ); +} + +#[tokio::test] +async fn cleanup_cannot_reclassify_success_or_durable_cancellation_and_stale_handles_are_closed() { + let temp = tempfile::tempdir().unwrap(); + let jobs = + StandaloneJobs::open(&temp.path().join("jobs.sqlite3"), temp.path().to_path_buf()).unwrap(); + let cancelled = jobs.request(request("cancelled")).unwrap(); + jobs.cancel("cancelled").unwrap(); + finish_if_pending( + &jobs.store, + "cancelled", + &cancelled.attempt.attempt_id, + JobAttemptState::Unknown, + "cleanup", + ) + .unwrap(); + assert_eq!(jobs.get("cancelled").unwrap().state, JobState::Cancelled); + let accepted = jobs.request(request("accepted")).unwrap(); + jobs.with_store(|store| store.dispatch("accepted", &accepted.attempt.attempt_id, 8)) + .unwrap(); + sink(&jobs, "accepted", &accepted.attempt.attempt_id) + .submit(result("accepted", &accepted.attempt.attempt_id)) + .unwrap(); + finish_if_pending( + &jobs.store, + "accepted", + &accepted.attempt.attempt_id, + JobAttemptState::Failed, + "model failed later", + ) + .unwrap(); + assert_eq!(jobs.get("accepted").unwrap().state, JobState::Completed); + jobs.shutdown().await.unwrap(); + assert!(jobs.store.lock().unwrap().is_none()); + for operation in [ + jobs.cancel("cancelled"), + jobs.acknowledge("accepted"), + jobs.retry("cancelled"), + jobs.request(request("new")), + ] { + assert!(matches!(operation, Err(JobServiceError::Closed))); + } +} 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 100537a96..2d8c6fa5d 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -5,8 +5,12 @@ //! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path. pub mod host; +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 f55dfefd0..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, @@ -352,6 +385,10 @@ impl StandaloneWorkerStore { } #[must_use] + pub(crate) fn jobs_path(&self, id: WorkerId) -> PathBuf { + self.worker_dir(id).join("jobs.sqlite3") + } + pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf { self.worker_dir(id).join("runtime") } @@ -789,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/jobs.rs b/crates/standalone/tests/jobs.rs new file mode 100644 index 000000000..2c4d71688 --- /dev/null +++ b/crates/standalone/tests/jobs.rs @@ -0,0 +1,478 @@ +use std::collections::VecDeque; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use agen::llm_client::{ + LlmClient, + error::ClientError, + event::{Event as LlmEvent, StopReason}, + types::Request, +}; +use async_trait::async_trait; +use futures::{Stream, stream}; +use job::{JobLimits, JobRequest}; +use standalone::jobs::{JobServiceError, JobState}; +use standalone::{StandaloneHost, StandaloneLaunchConfig}; + +#[derive(Clone)] +struct ScriptedClient { + responses: Arc>>>, + requests: Arc>>, + started: Arc, + stall: bool, + fail: bool, +} +impl ScriptedClient { + fn new(responses: Vec>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses.into())), + requests: Arc::new(Mutex::new(Vec::new())), + started: Arc::new(tokio::sync::Notify::new()), + stall: false, + fail: false, + } + } + fn stall() -> Self { + Self { + stall: true, + ..Self::new(Vec::new()) + } + } + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } +} +#[async_trait] +impl LlmClient for ScriptedClient { + async fn stream( + &self, + request: Request, + ) -> Result> + Send>>, ClientError> + { + self.requests.lock().unwrap().push(request); + self.started.notify_one(); + if self.fail { + return Err(ClientError::Config("scripted model failure".into())); + } + if self.stall { + return Ok(Box::pin(stream::pending())); + } + let events = self + .responses + .lock() + .unwrap() + .pop_front() + .expect("scripted response"); + Ok(Box::pin(stream::iter(events.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, "done"), + LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)), + ] +} +fn success() -> ScriptedClient { + ScriptedClient::new(vec![ + vec![ + LlmEvent::tool_use_start(0, "result-1", "SubmitJobResult"), + LlmEvent::tool_input_delta(0, r#"{"result":{"valid":true}}"#), + LlmEvent::tool_use_stop(0), + ], + prose(), + ]) +} +fn request(id: &str) -> JobRequest { + JobRequest { + job_id: id.into(), + purpose: "generic_test".into(), + input_revision: "r1".into(), + input_ref: "test:immutable".into(), + input: serde_json::json!({"test_input":42}), + instruction: "Return a structured validity result".into(), + profile: "builtin:job".into(), + serialization_key: None, + limits: JobLimits::default(), + } +} +async fn host(temp: &tempfile::TempDir) -> (StandaloneHost, ScriptedClient) { + let launch = StandaloneLaunchConfig::new( + temp.path(), + temp.path().join("state"), + manifest::ProfileSelector::Default, + "job-host", + ) + .resolve() + .unwrap(); + let client = ScriptedClient::new(Vec::new()); + let host = StandaloneHost::start_with_model_client(launch, client.clone()) + .await + .unwrap(); + (host, client) +} +async fn wait_started(client: &ScriptedClient) { + tokio::time::timeout(Duration::from_secs(10), client.started.notified()) + .await + .unwrap(); +} + +#[tokio::test] +async fn host_job_runs_without_dialog_and_result_survives_restore_and_ack() { + let temp = tempfile::tempdir().unwrap(); + let (host, dialog) = host(&temp).await; + let id = host.worker_id(); + let jobs = host.jobs(); + let intent = request("success"); + let reserved = jobs.request(intent.clone()).unwrap(); + assert_eq!(jobs.request(intent.clone()).unwrap(), reserved); + let model = success(); + jobs.start_with_model_client("success", model.clone()) + .await + .unwrap(); + let completed = jobs.wait("success").await.unwrap(); + assert_eq!(completed.state, JobState::Completed); + assert_eq!(completed.result, Some(serde_json::json!({"valid":true}))); + assert!(!completed.acknowledged); + let neutral = completed.outcome(); + assert_eq!(neutral.state, job::JobState::Completed); + assert_eq!(neutral.result, completed.result); + assert_eq!(neutral.attempt_state, job::JobAttemptState::Completed); + assert!( + dialog.requests().is_empty(), + "Job never runs on the interactive model/session" + ); + assert_eq!( + model.requests()[0] + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + ["SubmitJobResult"] + ); + assert_eq!(jobs.request(intent.clone()).unwrap(), completed); + assert!( + jobs.start_with_model_client("success", success()) + .await + .is_err() + ); + assert!(jobs.retry("success").is_err()); + let db = temp + .path() + .join("state") + .join(id.to_string()) + .join("jobs.sqlite3"); + assert!(db.exists()); + host.shutdown().await.unwrap(); + assert!(matches!( + jobs.request(request("after-shutdown")), + Err(JobServiceError::Closed) + )); + assert!(matches!( + jobs.cancel("success"), + Err(JobServiceError::Closed) + )); + assert!(matches!( + jobs.acknowledge("success"), + Err(JobServiceError::Closed) + )); + assert!(matches!( + jobs.start_with_model_client("success", success()).await, + Err(JobServiceError::Closed) + )); + let restored = StandaloneHost::restore_with_model_client( + temp.path().join("state"), + id, + ScriptedClient::new(Vec::new()), + ) + .await + .unwrap(); + let restored_jobs = restored.jobs(); + assert_eq!(restored_jobs.get("success").unwrap(), completed); + assert!(restored_jobs.acknowledge("success").unwrap().acknowledged); + restored.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn live_resource_limits_cancellation_and_wait_are_host_owned() { + let temp = tempfile::tempdir().unwrap(); + let (host, _) = host(&temp).await; + let jobs = host.jobs(); + let mut first = request("first"); + first.limits.max_concurrent_jobs = 1; + jobs.request(first).unwrap(); + jobs.request(request("second")).unwrap(); + let stalled = ScriptedClient::stall(); + jobs.start_with_model_client("first", stalled.clone()) + .await + .unwrap(); + wait_started(&stalled).await; + // Waiting must not remove the Host-owned task/cancellation capability. + let waiting_jobs = jobs.clone(); + let waiter = tokio::spawn(async move { waiting_jobs.wait("first").await }); + assert!(matches!( + jobs.start_with_model_client("second", success()).await, + Err(JobServiceError::Capacity) + )); + jobs.cancel("first").unwrap(); + let cancelled = tokio::time::timeout(Duration::from_secs(10), waiter) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(cancelled.state, JobState::Cancelled); + assert!(jobs.retry("first").is_err()); + jobs.start_with_model_client("second", success()) + .await + .unwrap(); + assert_eq!( + jobs.wait("second").await.unwrap().state, + JobState::Completed + ); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn prose_failure_requires_explicit_bounded_new_attempt_and_exact_replay_does_not_retry() { + let temp = tempfile::tempdir().unwrap(); + let (host, _) = host(&temp).await; + let jobs = host.jobs(); + let intent = request("prose"); + jobs.request(intent.clone()).unwrap(); + jobs.start_with_model_client("prose", ScriptedClient::new(vec![prose()])) + .await + .unwrap(); + let failed = jobs.wait("prose").await.unwrap(); + assert_eq!(failed.state, JobState::Failed); + assert!(failed.result.is_none()); + assert_eq!(jobs.request(intent).unwrap(), failed); + let retry = jobs.retry("prose").unwrap(); + assert_eq!(retry.attempt.number, 2); + assert_ne!(retry.attempt.attempt_id, failed.attempt.attempt_id); + jobs.start_with_model_client("prose", ScriptedClient::new(vec![prose()])) + .await + .unwrap(); + assert_eq!(jobs.wait("prose").await.unwrap().state, JobState::Failed); + assert!(jobs.retry("prose").is_err()); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn timeout_is_unknown_not_prose_success_or_automatic_retry() { + let temp = tempfile::tempdir().unwrap(); + let (host, _) = host(&temp).await; + let jobs = host.jobs(); + let mut intent = request("timeout"); + intent.limits.timeout_seconds = 1; + jobs.request(intent).unwrap(); + let stalled = ScriptedClient::stall(); + jobs.start_with_model_client("timeout", stalled.clone()) + .await + .unwrap(); + wait_started(&stalled).await; + let outcome = tokio::time::timeout(Duration::from_secs(10), jobs.wait("timeout")) + .await + .unwrap() + .unwrap(); + assert_eq!(outcome.state, JobState::Unknown); + assert!(outcome.result.is_none()); + assert!(jobs.retry("timeout").is_err()); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn shutdown_keeps_reserved_results_and_fences_running_attempts_on_reopen() { + let temp = tempfile::tempdir().unwrap(); + let (host, _) = host(&temp).await; + let id = host.worker_id(); + let jobs = host.jobs(); + jobs.request(request("reserved")).unwrap(); + jobs.request(request("cancelled")).unwrap(); + jobs.cancel("cancelled").unwrap(); + jobs.request(request("running")).unwrap(); + let stalled = ScriptedClient::stall(); + jobs.start_with_model_client("running", stalled.clone()) + .await + .unwrap(); + wait_started(&stalled).await; + host.shutdown().await.unwrap(); + assert!(matches!(jobs.get("running"), Err(JobServiceError::Closed))); + let restored = StandaloneHost::restore_with_model_client( + temp.path().join("state"), + id, + ScriptedClient::new(Vec::new()), + ) + .await + .unwrap(); + let jobs = restored.jobs(); + assert_eq!( + jobs.pending() + .unwrap() + .iter() + .map(|snapshot| snapshot.request.job_id.as_str()) + .collect::>(), + ["reserved"] + ); + assert_eq!(jobs.get("cancelled").unwrap().state, JobState::Cancelled); + assert_eq!(jobs.get("running").unwrap().state, JobState::Unknown); + assert!(jobs.retry("running").is_err()); + jobs.start_with_model_client("reserved", success()) + .await + .unwrap(); + assert_eq!( + jobs.wait("reserved").await.unwrap().state, + JobState::Completed + ); + restored.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn profile_requirements_are_rejected_without_model_execution_or_implicit_fallback() { + let temp = tempfile::tempdir().unwrap(); + let (host, _) = host(&temp).await; + let jobs = host.jobs(); + for profile in [ + "builtin:coder", + "builtin:backend-job", + "builtin:does-not-exist", + ] { + let mut intent = request(profile); + intent.profile = profile.into(); + jobs.request(intent).unwrap(); + let model = success(); + assert!(matches!( + jobs.start_with_model_client(profile, model.clone()).await, + Err(JobServiceError::Configuration(_)) + )); + assert!(model.requests().is_empty()); + assert_eq!(jobs.get(profile).unwrap().state, JobState::Pending); + } + host.shutdown().await.unwrap(); +} + +// Only this test harness starts a subprocess: production Jobs remain in-process. +// The child exits deliberately without destructors to prove actual process-loss +// recovery, rather than treating a hand-edited SQLite fixture as that evidence. +#[tokio::test] +#[ignore = "entry point for process_loss_recovers_durable_jobs_without_replaying_unknown"] +async fn process_job_fixture() { + let root = std::path::PathBuf::from( + std::env::var_os("YOI_JOB_PROCESS_FIXTURE_DIR").expect("fixture root"), + ); + let launch = StandaloneLaunchConfig::new( + &root, + root.join("state"), + manifest::ProfileSelector::Default, + "job-process-fixture", + ) + .resolve() + .unwrap(); + let host = StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())) + .await + .unwrap(); + std::fs::write(root.join("worker-id"), host.worker_id().to_string()).unwrap(); + let jobs = host.jobs(); + jobs.request(request("reserved")).unwrap(); + jobs.request(request("cancelled")).unwrap(); + jobs.cancel("cancelled").unwrap(); + jobs.request(request("accepted")).unwrap(); + jobs.start_with_model_client("accepted", success()) + .await + .unwrap(); + assert_eq!( + jobs.wait("accepted").await.unwrap().state, + JobState::Completed + ); + jobs.request(request("interrupted")).unwrap(); + let stalled = ScriptedClient::stall(); + jobs.start_with_model_client("interrupted", stalled.clone()) + .await + .unwrap(); + wait_started(&stalled).await; + // Test-owned process only, never the operating Yoi Worker or dogfood daemon. + std::process::exit(23); +} + +#[tokio::test] +async fn process_loss_recovers_durable_jobs_without_replaying_unknown() { + let temp = tempfile::tempdir().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "process_job_fixture", "--ignored", "--nocapture"]) + .env("YOI_JOB_PROCESS_FIXTURE_DIR", temp.path()) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(23), + "fixture failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let worker_id = std::fs::read_to_string(temp.path().join("worker-id")) + .unwrap() + .parse() + .unwrap(); + let host = StandaloneHost::restore_with_model_client( + temp.path().join("state"), + worker_id, + ScriptedClient::new(Vec::new()), + ) + .await + .unwrap(); + let jobs = host.jobs(); + assert_eq!(jobs.get("interrupted").unwrap().state, JobState::Unknown); + assert!(jobs.retry("interrupted").is_err()); + let completed = jobs.get("accepted").unwrap(); + assert_eq!(completed.state, JobState::Completed); + assert_eq!(completed.result, Some(serde_json::json!({"valid":true}))); + assert!(!completed.acknowledged); + assert_eq!(jobs.get("cancelled").unwrap().state, JobState::Cancelled); + assert_eq!(jobs.pending().unwrap().len(), 1); + jobs.start_with_model_client("reserved", success()) + .await + .unwrap(); + assert_eq!( + jobs.wait("reserved").await.unwrap().state, + JobState::Completed + ); + host.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn model_failure_is_durable_known_failure_with_explicit_successful_retry() { + let temp = tempfile::tempdir().unwrap(); + let (host, _) = host(&temp).await; + let jobs = host.jobs(); + jobs.request(request("model-failure")).unwrap(); + let client = ScriptedClient { + fail: true, + ..ScriptedClient::new(Vec::new()) + }; + jobs.start_with_model_client("model-failure", client) + .await + .unwrap(); + let failure = jobs.wait("model-failure").await.unwrap(); + assert_eq!(failure.state, JobState::Failed); + assert!(failure.result.is_none()); + assert!( + failure + .attempt + .failure + .unwrap() + .contains("scripted model failure") + ); + let next = jobs.retry("model-failure").unwrap(); + assert_eq!(next.attempt.number, 2); + jobs.start_with_model_client("model-failure", success()) + .await + .unwrap(); + assert_eq!( + jobs.wait("model-failure").await.unwrap().state, + JobState::Completed + ); + host.shutdown().await.unwrap(); +} 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-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index 6c056b878..225b03a2d 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -125,6 +125,51 @@ pub struct ProfileSourceArchiveInput { impl ProfileSourceArchive { pub fn build(input: ProfileSourceArchiveInput) -> Result { + Self::build_with_source_format(input, "decodal", "text/x-decodal") + } + + /// Archive an already evaluated Profile value without Decodal evaluation or conversion. + pub fn build_evaluated_profile( + id: String, + selector: String, + value: serde_json::Value, + ) -> Result { + Self::build_evaluated_profile_with_builtin_sources(id, selector, value, BTreeMap::new()) + } + + /// Seal the observed builtin import closure as provenance alongside the evaluated JSON. + /// Builtin sources are verified, but never evaluated to resolve this entrypoint. + pub fn build_evaluated_profile_with_builtin_sources( + id: String, + selector: String, + value: serde_json::Value, + sources: BTreeMap, + ) -> Result { + for key in sources.keys() { + validate_builtin_source_key(key)?; + } + let path = "profiles/evaluated.json".to_string(); + let source = serde_json::to_string(&value) + .map_err(|err| ProfileArchiveError::Json(err.to_string()))?; + let mut sources = sources; + sources.insert(path.clone(), source); + Self::build_with_source_format( + ProfileSourceArchiveInput { + id, + entrypoints: BTreeMap::from([(selector, path)]), + imports: BTreeMap::new(), + sources, + }, + "json", + "application/json", + ) + } + + fn build_with_source_format( + input: ProfileSourceArchiveInput, + kind: &str, + content_type: &str, + ) -> Result { if input.sources.len() > MAX_SOURCES { return Err(ProfileArchiveError::LimitExceeded("source count")); } @@ -142,11 +187,17 @@ impl ProfileSourceArchive { if total_source_bytes > MAX_TOTAL_BYTES { return Err(ProfileArchiveError::LimitExceeded("total source bytes")); } + let (kind, content_type) = if path.starts_with("$builtin/") { + validate_builtin_source_key(path)?; + ("decodal", "text/x-decodal") + } else { + (kind, content_type) + }; source_meta.push(ProfileSourceArchiveSource { path: path.clone(), source_key: path.clone(), - kind: "decodal".to_string(), - content_type: default_decodal_content_type(), + kind: kind.to_string(), + content_type: content_type.to_string(), digest: sha256_hex(source.as_bytes()), size_bytes: size, }); @@ -281,14 +332,35 @@ impl VerifiedProfileSourceArchive { let mut manifest_paths = BTreeSet::new(); for source in &manifest.sources { validate_archive_path(&source.path)?; - if !source.source_key.is_empty() && source.source_key != source.path { + if source.path.starts_with("$builtin/") { + validate_builtin_source_key(&source.path)?; + if source.kind != "decodal" { + return Err(ProfileArchiveError::UnsupportedSource { + path: source.path.clone(), + kind: source.kind.clone(), + }); + } + } + if (source.path.starts_with("$builtin/") || !source.source_key.is_empty()) + && source.source_key != source.path + { return Err(ProfileArchiveError::ImportMapMismatch { specifier: source.path.clone(), expected: source.path.clone(), actual: source.source_key.clone(), }); } - if source.content_type != default_decodal_content_type() { + let expected_content_type = match source.kind.as_str() { + "decodal" => "text/x-decodal", + "json" => "application/json", + _ => { + return Err(ProfileArchiveError::UnsupportedSource { + path: source.path.clone(), + kind: source.kind.clone(), + }); + } + }; + if source.content_type != expected_content_type { return Err(ProfileArchiveError::UnsupportedSource { path: source.path.clone(), kind: source.content_type.clone(), @@ -297,12 +369,6 @@ impl VerifiedProfileSourceArchive { if !manifest_paths.insert(source.path.clone()) { return Err(ProfileArchiveError::DuplicateSource(source.path.clone())); } - if source.kind != "decodal" { - return Err(ProfileArchiveError::UnsupportedSource { - path: source.path.clone(), - kind: source.kind.clone(), - }); - } let content = entries .get(&source.path) .ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?; @@ -373,30 +439,50 @@ impl VerifiedProfileSourceArchive { .entrypoints .get(selector) .ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?; - let mut engine = Engine::new(ArchiveSourceLoader { archive: self }); let source = self .sources .get(path) .ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?; - let module = engine.add_root_source(path, path, source).map_err(|err| { - ProfileArchiveError::Decodal { - path: path.clone(), - message: format!("{err:?}"), + let metadata = self + .manifest + .sources + .iter() + .find(|source| &source.path == path) + .ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?; + let json = match metadata.kind.as_str() { + "json" => serde_json::from_str(source) + .map_err(|err| ProfileArchiveError::Json(err.to_string()))?, + "decodal" => { + let mut engine = Engine::new(ArchiveSourceLoader { archive: self }); + let module = engine.add_root_source(path, path, source).map_err(|err| { + ProfileArchiveError::Decodal { + path: path.clone(), + message: format!("{err:?}"), + } + })?; + let value = + engine + .eval_module(module) + .map_err(|err| ProfileArchiveError::Decodal { + path: path.clone(), + message: format!("{err:?}"), + })?; + let data = + engine + .materialize(&value) + .map_err(|err| ProfileArchiveError::Decodal { + path: path.clone(), + message: format!("{err:?}"), + })?; + decodal_data_to_json(&data) } - })?; - let value = engine - .eval_module(module) - .map_err(|err| ProfileArchiveError::Decodal { - path: path.clone(), - message: format!("{err:?}"), - })?; - let data = engine - .materialize(&value) - .map_err(|err| ProfileArchiveError::Decodal { - path: path.clone(), - message: format!("{err:?}"), - })?; - let json = decodal_data_to_json(&data); + _ => { + return Err(ProfileArchiveError::UnsupportedSource { + path: path.clone(), + kind: metadata.kind.clone(), + }); + } + }; let resolved = resolve_profile_artifact_value( json, ProfileSource::Archive { @@ -609,6 +695,26 @@ fn append_bytes( .map_err(|err| ProfileArchiveError::Io(err.to_string())) } +fn validate_builtin_source_key(key: &str) -> Result<(), ProfileArchiveError> { + validate_archive_path(key)?; + let Some(relative) = key.strip_prefix("$builtin/profiles/") else { + return Err(ProfileArchiveError::UnsafePath(key.to_string())); + }; + if !relative.ends_with(".dcdl") + || relative.split('/').any(|part| { + part.is_empty() + || part == "." + || part == ".." + || !part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte)) + }) + { + return Err(ProfileArchiveError::UnsafePath(key.to_string())); + } + Ok(()) +} + fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> { if path.is_empty() || path == MANIFEST_PATH { return if path == MANIFEST_PATH { @@ -674,6 +780,320 @@ mod tests { .unwrap() } + fn evaluated_profile() -> serde_json::Value { + serde_json::json!({ + "slug": "evaluated", + "description": "Evaluated profile", + "scope": "workspace_read", + "worker": { "mode": "wip" }, + "feature": { + "task": { "enabled": true }, + "memory": { "enabled": true }, + "sub_worker": { "enabled": true } + } + }) + } + + fn evaluated_archive() -> ProfileSourceArchive { + ProfileSourceArchive::build_evaluated_profile( + "evaluated-archive".to_string(), + "project:evaluated".to_string(), + evaluated_profile(), + ) + .unwrap() + } + + #[test] + fn evaluated_json_preserves_explicit_worker_mode_and_features() { + let archive = evaluated_archive(); + let verified = archive.verify().unwrap(); + let source = &verified.manifest().sources[0]; + assert_eq!(source.kind, "json"); + assert_eq!(source.content_type, "application/json"); + assert_eq!(verified.manifest().imports.len(), 0); + assert_eq!( + serde_json::from_str::(&verified.sources[&source.path]).unwrap(), + evaluated_profile() + ); + let root = tempfile::tempdir().unwrap(); + let manifest = verified + .resolve_profile("project:evaluated", root.path(), "evaluated-worker") + .unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(manifest.worker.name, "evaluated-worker"); + assert_eq!(json["worker"]["mode"], "wip"); + assert_eq!(json["feature"]["task"]["enabled"], true); + assert_eq!(json["feature"]["memory"]["profile"]["enabled"], true); + assert_eq!(json["feature"]["sub_worker"]["enabled"], true); + } + + // Recompute the outer digest so verification reaches the source-level checks. + fn repack_archive( + archive: &mut ProfileSourceArchive, + manifest: &ProfileSourceArchiveManifest, + sources: &BTreeMap, + ) { + let mut content = Vec::new(); + { + let mut builder = Builder::new(&mut content); + append_bytes( + &mut builder, + MANIFEST_PATH, + &serde_json::to_vec(manifest).unwrap(), + ) + .unwrap(); + for (path, source) in sources { + append_bytes(&mut builder, path, source.as_bytes()).unwrap(); + } + builder.finish().unwrap(); + } + archive.reference.digest = sha256_hex(&content); + archive.reference.size_bytes = content.len() as u64; + archive.content = content; + } + + fn builtin_provenance_archive() -> ProfileSourceArchive { + ProfileSourceArchive::build_evaluated_profile_with_builtin_sources( + "builtin-provenance".into(), + "project:evaluated".into(), + evaluated_profile(), + BTreeMap::from([ + ( + "$builtin/profiles/companion.dcdl".into(), + "import \"./base.dcdl\"".into(), + ), + // Intentionally invalid Decodal: provenance must not be reevaluated. + ( + "$builtin/profiles/base.dcdl".into(), + "not valid Decodal !".into(), + ), + ]), + ) + .unwrap() + } + + #[test] + fn evaluated_json_seals_builtin_closure_without_runtime_evaluation() { + let archive = builtin_provenance_archive(); + let verified = archive.verify().unwrap(); + assert_eq!( + archive, + builtin_provenance_archive(), + "archive must be deterministic" + ); + assert_eq!(archive.reference.source_graph.source_count, 3); + assert!(verified.manifest.imports.is_empty()); + assert_eq!( + verified.manifest.entrypoints["project:evaluated"], + "profiles/evaluated.json" + ); + for source in &verified.manifest.sources { + assert_eq!(source.source_key, source.path); + assert_eq!( + source.digest, + sha256_hex(verified.sources[&source.path].as_bytes()) + ); + if source.path.starts_with("$builtin/") { + assert_eq!(source.kind, "decodal"); + assert_eq!(source.content_type, "text/x-decodal"); + } else { + assert_eq!(source.kind, "json"); + } + } + let root = tempfile::tempdir().unwrap(); + let resolved = verified + .resolve_profile("project:evaluated", root.path(), "sealed-worker") + .unwrap(); + assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip); + assert!(resolved.feature.task.enabled); + assert!(matches!( + verified.resolve_profile("builtin:companion", root.path(), "sealed-worker"), + Err(ProfileArchiveError::MissingEntrypoint(_)) + )); + } + + #[test] + fn evaluated_json_rejects_missing_or_corrupt_builtin_dependency() { + for missing in [true, false] { + let mut archive = builtin_provenance_archive(); + let mut verified = archive.verify().unwrap(); + let path = "$builtin/profiles/base.dcdl"; + if missing { + verified.sources.remove(path); + } else { + verified + .sources + .insert(path.into(), "corrupt dependency".into()); + } + repack_archive(&mut archive, &verified.manifest, &verified.sources); + match archive.verify().unwrap_err() { + ProfileArchiveError::MissingSource(actual) if missing => assert_eq!(actual, path), + ProfileArchiveError::SourceDigestMismatch { path: actual, .. } if !missing => { + assert_eq!(actual, path) + } + other => panic!("unexpected error: {other}"), + } + } + } + + #[test] + fn evaluated_json_builtin_provenance_rejects_unsafe_keys_and_reuses_limits() { + for key in [ + "profiles/companion.dcdl", + "builtin:profiles/companion.dcdl", + "$builtin/prompts/companion.dcdl", + "$builtin/profiles/../escape.dcdl", + "$builtin/profiles/./base.dcdl", + "$builtin/profiles//base.dcdl", + "$builtin/profiles/base\\other.dcdl", + "$builtin/profiles/base\0.dcdl", + "/$builtin/profiles/base.dcdl", + "$builtin/profiles/base.json", + "profiles/evaluated.json", + ] { + assert!( + matches!( + ProfileSourceArchive::build_evaluated_profile_with_builtin_sources( + "unsafe".into(), + "project:evaluated".into(), + evaluated_profile(), + BTreeMap::from([(key.into(), "{}".into())]), + ), + Err(ProfileArchiveError::UnsafePath(_)) + ), + "{key:?}" + ); + } + assert!(matches!( + ProfileSourceArchive::build_evaluated_profile_with_builtin_sources( + "oversized".into(), + "project:evaluated".into(), + evaluated_profile(), + BTreeMap::from([( + "$builtin/profiles/base.dcdl".into(), + "x".repeat(MAX_SOURCE_BYTES as usize + 1) + )]), + ), + Err(ProfileArchiveError::LimitExceeded("source bytes")) + )); + } + + #[test] + fn evaluated_json_builtin_provenance_requires_canonical_key_and_source_format() { + for key in ["", "profiles/base.dcdl", "$builtin/profiles/other.dcdl"] { + let mut archive = builtin_provenance_archive(); + let mut verified = archive.verify().unwrap(); + verified.manifest.sources[0].source_key = key.into(); + repack_archive(&mut archive, &verified.manifest, &verified.sources); + assert!(matches!( + archive.verify(), + Err(ProfileArchiveError::ImportMapMismatch { .. }) + )); + } + let mut archive = builtin_provenance_archive(); + let mut verified = archive.verify().unwrap(); + verified.manifest.sources[0].kind = "json".into(); + verified.manifest.sources[0].content_type = "application/json".into(); + repack_archive(&mut archive, &verified.manifest, &verified.sources); + assert!(matches!( + archive.verify(), + Err(ProfileArchiveError::UnsupportedSource { .. }) + )); + } + + #[test] + fn evaluated_json_reuses_source_size_limit() { + assert!(matches!( + ProfileSourceArchive::build_evaluated_profile( + "oversized".to_string(), + "default".to_string(), + serde_json::Value::String("x".repeat(MAX_SOURCE_BYTES as usize)), + ), + Err(ProfileArchiveError::LimitExceeded("source bytes")) + )); + } + + #[test] + fn evaluated_json_rejects_archive_and_source_tampering() { + let mut archive = evaluated_archive(); + archive.content[0] ^= 1; + assert!(matches!( + archive.verify(), + Err(ProfileArchiveError::ArchiveDigestMismatch { .. }) + )); + + let mut archive = evaluated_archive(); + let mut verified = archive.verify().unwrap(); + let path = verified.manifest.sources[0].path.clone(); + verified + .sources + .get_mut(&path) + .unwrap() + .replace_range(0..1, "["); + repack_archive(&mut archive, &verified.manifest, &verified.sources); + assert!(matches!( + archive.verify(), + Err(ProfileArchiveError::SourceDigestMismatch { path: actual, .. }) if actual == path + )); + } + + #[test] + fn evaluated_json_requires_matching_source_kind_and_content_type() { + for (kind, content_type) in [ + ("json", "text/x-decodal"), + ("decodal", "application/json"), + ("unknown", "application/json"), + ] { + let mut archive = evaluated_archive(); + let mut verified = archive.verify().unwrap(); + verified.manifest.sources[0].kind = kind.to_string(); + verified.manifest.sources[0].content_type = content_type.to_string(); + repack_archive(&mut archive, &verified.manifest, &verified.sources); + assert!(matches!( + archive.verify(), + Err(ProfileArchiveError::UnsupportedSource { .. }) + )); + } + } + + #[test] + fn evaluated_json_does_not_fallback_to_filesystem_or_default() { + let root = tempfile::tempdir().unwrap(); + let profiles = root.path().join("profiles"); + std::fs::create_dir(&profiles).unwrap(); + let local_profile = profiles.join("evaluated.json"); + std::fs::write( + &local_profile, + serde_json::to_vec(&evaluated_profile()).unwrap(), + ) + .unwrap(); + let verified = evaluated_archive().verify().unwrap(); + for selector in [ + "default", + "profiles/evaluated.json", + local_profile.to_str().unwrap(), + ] { + assert!(matches!( + verified.resolve_profile(selector, root.path(), "evaluated-worker"), + Err(ProfileArchiveError::MissingEntrypoint(actual)) if actual == selector + )); + } + + // A valid local file must not rescue an invalid archived Profile value either. + let invalid = ProfileSourceArchive::build_evaluated_profile( + "invalid".to_string(), + "project:evaluated".to_string(), + serde_json::json!({ "not_a_profile_field": true }), + ) + .unwrap() + .verify() + .unwrap(); + assert!(matches!( + invalid.resolve_profile("project:evaluated", root.path(), "evaluated-worker"), + Err(ProfileArchiveError::Profile { .. }) + )); + } + #[test] fn build_and_verify_archive() { let archive = sample_archive(); diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 597cf460c..2fd9691aa 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -16,6 +16,7 @@ agen = { workspace = true, features = ["codex"] } session-store = { workspace = true } secrets = { workspace = true } manifest = { workspace = true } +job = { workspace = true } mcp = { workspace = true } protocol = { workspace = true, features = ["json-schema"] } client = { workspace = true } 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/internal_worker.rs b/crates/worker/src/internal_worker.rs index ce6bff263..852b4d583 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -205,6 +205,9 @@ where .copied() .collect::>() .join(", "); + worker + .stop_feature_runtime("internal Worker feature installation failed") + .await; return Err(InternalWorkerError { source: WorkerError::FeatureInstall(format!( "internal Worker feature installation failed: {diagnostics}; missing tools: {missing}" diff --git a/crates/worker/src/job.rs b/crates/worker/src/job.rs new file mode 100644 index 000000000..1788f54dc --- /dev/null +++ b/crates/worker/src/job.rs @@ -0,0 +1,374 @@ +//! Host-facing, result-only Jobs executed by the private Internal Worker substrate. +//! +//! The host owns persistence, concurrency, timeout, retries and result acceptance. Normal +//! timeout/shutdown routes through the cancellation sender and awaits `run`, including Worker +//! resource cleanup. An emergency drop/abort does not confirm asynchronous cleanup; the Host +//! must retain its lease and reconcile any unknown outcome before allowing another execution. +//! The result-only Feature owns no background tasks, but an in-flight synchronous Host sink +//! cannot be preempted by dropping its tool future and must itself be bounded. + +mod result; +pub use result::{JobResultFeature, JobResultFinalizer, JobResultSink, SUBMIT_JOB_RESULT_TOOL}; + +use std::borrow::Cow; +use std::path::Path; +use std::sync::Arc; + +use agen::llm_client::LlmClient; +use manifest::{ProfileDiscovery, ProfileResolveOptions, ProfileResolver, Scope, WorkerManifest}; + +use crate::feature::FeatureRegistryBuilder; +use crate::internal_worker::{ + InternalWorkerAuthority, InternalWorkerIdentity, InternalWorkerSpec, + run_internal_worker_with_cancel_sender, +}; +use crate::{ + PromptCatalog, PromptCatalogSource, SystemPromptContext, SystemPromptTemplate, + WorkerFilesystemAuthority, WorkerPrompt, WorkerRunResult, WorkerWorkspaceContext, +}; + +/// Host adapter ceilings, applied even if the selected Profile leaves budgets unbounded. +pub const MAX_JOB_TURNS: u32 = 32; +pub const MAX_JOB_TOKENS: u32 = 8192; + +#[derive(Debug, thiserror::Error)] +pub enum JobExecutionError { + #[error(transparent)] + Request(#[from] ::job::JobError), + #[error("Job Profile resolution failed: {0}")] + Profile(#[from] manifest::ProfileError), + #[error("result-only Job cannot satisfy Profile requirements: {0}")] + UnsupportedProfile(String), + #[error("Job instruction resolution failed: {0}")] + Instruction(#[from] crate::SystemPromptError), + #[error("Job Prompt catalog resolution failed: {0}")] + Catalog(#[from] crate::CatalogError), + #[error("Job model configuration failed: {0}")] + Model(#[from] crate::ProviderError), + #[error("Job execution failed: {0}")] + Worker(String), + /// A sink error can follow durable acceptance. Hosts must reconcile their store and must + /// not classify this as a known retryable failure unless they can prove non-acceptance. + #[error( + "Host Job result acceptance is unconfirmed; reconcile the Host store before retrying: {0}" + )] + ResultUnconfirmed(String), + #[error("Job execution was cancelled without an accepted result")] + Cancelled, + #[error("Job ended without an accepted SubmitJobResult; final prose is not success")] + MissingResult, +} + +/// Confirms only a synchronous, successful Host acceptance. Lifecycle/prose cannot create this. +#[derive(Debug)] +pub struct InternalJobResult { + pub submission: ::job::JobResultSubmission, + pub usage: Option, +} + +/// Resolved policy/model/prompt, with no running resources and no inherited authority. +pub struct PreparedInternalJob { + request: ::job::JobRequest, + 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. +/// `cwd` is a resolution base only; it never becomes filesystem or Workspace authority. +/// An injected client is useful for scripted executions and does not bypass policy validation. +pub fn prepare_job( + request: &::job::JobRequest, + cwd: &Path, + client: Option>, +) -> Result { + let registry = ProfileDiscovery::user_settings().discover()?; + prepare_job_from_registry(request, cwd, client, ®istry) +} + +fn prepare_job_from_registry( + request: &::job::JobRequest, + 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() + .with_workspace_base(cwd) + .resolve_from_registry( + &request.profile_selector()?, + registry, + ProfileResolveOptions::with_worker_name("internal-job"), + )?; + let mut manifest = resolved.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( + &manifest.engine.instruction, + PromptCatalogSource::builtins_only(), + )?; + let mut system_prompt = template.render(&SystemPromptContext { + now: chrono::Utc::now(), + cwd: Cow::Borrowed("(no filesystem)"), + language: &manifest.engine.language, + scope: &scope, + tool_names, + feature_instructions: &[], + agents_md: None, + resident_summary: None, + prompts: &prompts, + })?; + system_prompt.push_str("\n\n"); + system_prompt.push_str(&prompts.render(WorkerPrompt::JobSystem, minijinja::context! {})?); + // Host budgets attenuate Profile output/turn policy, never grant another capability. + manifest.engine.max_turns = std::num::NonZeroU32::new( + manifest + .engine + .max_turns + .map(|n| n.get()) + .unwrap_or(MAX_JOB_TURNS) + .min(MAX_JOB_TURNS), + ); + manifest.engine.max_tokens = Some( + manifest + .engine + .max_tokens + .unwrap_or(MAX_JOB_TOKENS) + .min(MAX_JOB_TOKENS), + ); + let model_config = manifest::model_catalog::resolve_model_manifest(&manifest.model) + .map_err(crate::ProviderError::from)?; + let client = match client { + Some(client) => client, + None => crate::model_client::build_client_from_config(&model_config)?, + }; + Ok(PreparedInternalJob { + request: request.clone(), + manifest, + client, + system_prompt, + features: grant.features, + finalizer: grant.finalizer, + }) +} + +impl PreparedInternalJob { + /// Run one immutable attempt. The sink must perform fenced, durable acceptance before + /// returning Ok. Timeout is Host-owned: cancel and then await this future for cleanup. + /// A result already accepted by the sink remains success even if the model subsequently + /// fails, reaches its turn limit or is cancelled; callers never rerun an accepted result. + pub async fn run( + self, + attempt_id: String, + submit: Arc, + on_cancel_sender: F, + ) -> Result + where + F: FnOnce(tokio::sync::mpsc::Sender<()>), + { + 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 { + identity: InternalWorkerIdentity { + kind: "job", + run_id: uuid::Uuid::now_v7(), + }, + max_turns: engine_policy.max_turns.map(|n| n.get()), + engine_configurator: Some(Box::new(move |engine| { + crate::apply_worker_manifest(engine, &engine_policy); + })), + manifest: self.manifest, + client: self.client, + system_prompt: self.system_prompt, + input: self.request.worker_input(&attempt_id)?, + cache_key: None, + features: self.features.with_module(feature.clone()), + required_tools: &[SUBMIT_JOB_RESULT_TOOL], + authority: InternalWorkerAuthority { + workspace: WorkerWorkspaceContext::no_workspace(), + filesystem: WorkerFilesystemAuthority::None, + scope: Scope::empty(), + workdir_session: None, + }, + }; + let outcome = run_internal_worker_with_cancel_sender(spec, on_cancel_sender).await; + let usage = match &outcome { + Ok(run) => run.usage.clone(), + Err(error) => error.usage.clone(), + }; + if let Some(submission) = accepted.lock().expect("Job result state poisoned").clone() { + return Ok(InternalJobResult { submission, usage }); + } + if let Some(error) = feature.unconfirmed() { + return Err(JobExecutionError::ResultUnconfirmed(error)); + } + match outcome { + Err(error) => Err(JobExecutionError::Worker(error.source.to_string())), + Ok(run) + if matches!( + run.lifecycle, + WorkerRunResult::Cancelled | WorkerRunResult::RolledBack + ) => + { + Err(JobExecutionError::Cancelled) + } + Ok(_) => Err(JobExecutionError::MissingResult), + } + } +} + +/// Validate requirements, not Profile names. Nothing configured as active is silently stripped. + +fn validate_profile_with_grant( + manifest: &WorkerManifest, + satisfied: &[&str], +) -> Result<(), JobExecutionError> { + let manifest::FeatureConfig { + task, + memory, + subjektiv, + web, + image, + sub_worker, + flow, + worker, + workspace_worker_discovery, + objective, + manage_workdir, + workdir_catalog, + workspace_config, + ticket, + merge_request, + orchestration, + } = &manifest.feature; + let mut unavailable = Vec::new(); + for (name, enabled) in [ + ("feature.task", task.enabled), + ( + "feature.memory", + memory.profile.enabled + || memory.profile.staging_tools + || memory.workspace_settings.is_some(), + ), + ( + "feature.subjektiv", + subjektiv.profile.enabled + || subjektiv.profile.consolidation_tools + || subjektiv.workspace_settings.is_some(), + ), + ("feature.web", web.enabled), + ("feature.image", image.enabled), + ("feature.sub_worker", sub_worker.enabled), + ("feature.flow", flow.enabled), + ("feature.worker", worker.enabled), + ( + "feature.workspace_worker_discovery", + workspace_worker_discovery.enabled, + ), + ("feature.objective", objective.enabled), + ("feature.manage_workdir", manage_workdir.enabled), + ("feature.workspace_config", workspace_config.enabled), + ( + "feature.ticket", + ticket.enabled || ticket.authoring || ticket.thread || ticket.intake || ticket.workflow, + ), + ( + "feature.merge_request", + merge_request.show + || merge_request.open + || merge_request.review + || merge_request.readiness_check + || merge_request.complete, + ), + ("feature.orchestration", orchestration.enabled), + // The default WIP-only catalog is inert in Tools mode, not a tool requirement. + ( + "feature.workdir_catalog", + workdir_catalog.enabled && manifest.worker.mode == manifest::WorkerMode::Wip, + ), + ( + "worker.mode=wip", + manifest.worker.mode == manifest::WorkerMode::Wip, + ), + ("scope", !manifest.scope.allow.is_empty()), + ( + "delegation_scope", + !manifest.delegation_scope.allow.is_empty(), + ), + ("mcp", !manifest.mcp.stdio_servers.is_empty()), + ("skills", manifest.skills.is_some()), + ("compaction", manifest.compaction.is_some()), + ( + "web", + manifest + .web + .as_ref() + .is_some_and(|config| config.enabled == Some(true)), + ), + ( + "session.record_event_trace", + manifest.session.record_event_trace, + ), + ] { + if enabled && !satisfied.contains(&name) { + unavailable.push(name); + } + } + if unavailable.is_empty() { + Ok(()) + } else { + Err(JobExecutionError::UnsupportedProfile( + unavailable.join(", "), + )) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/worker/src/job/result.rs b/crates/worker/src/job/result.rs new file mode 100644 index 000000000..ee3fedb30 --- /dev/null +++ b/crates/worker/src/job/result.rs @@ -0,0 +1,263 @@ +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; +use async_trait::async_trait; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::Value; + +use super::JobExecutionError; +use crate::feature::{ + FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, + ToolDeclaration, +}; + +pub const SUBMIT_JOB_RESULT_TOOL: &str = "SubmitJobResult"; +const DESCRIPTION: &str = "Submit this Host Job's bounded structured result. Job, attempt and input revision are immutable Host bindings. Only result is accepted as input; final prose is not success."; + +/// Host acceptance authority. Implementations must validate the immutable fences and commit +/// acceptance durably before returning Ok. Err may mean an unknown outcome; identical retries +/// must be idempotent. This synchronous callback must be bounded and must not wait for async work. +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 { + result: Value, +} + +#[derive(Default)] +struct SubmissionState { + sealed: Option, + prepared: Option, + unconfirmed: Option, +} + +/// Single-purpose FeatureModule; Profile names never install it or confer result authority. +#[derive(Clone)] +pub struct JobResultFeature { + job_id: String, + attempt_id: String, + input_revision: String, + max_result_bytes: u32, + sink: Arc, + state: Arc>, + accepted: Arc>>, + cancelled: Arc>>, + serial: Arc>, + cancellation: crate::feature::background::BackgroundTaskCancellation, + finalizer: Option, +} + +impl JobResultFeature { + pub fn new( + request: &::job::JobRequest, + attempt_id: String, + sink: Arc, + ) -> Result { + request.validate()?; + ::job::validate_identifier("attempt_id", &attempt_id, 512)?; + Ok(Self { + job_id: request.job_id.clone(), + attempt_id, + input_revision: request.input_revision.clone(), + max_result_bytes: request.limits.max_result_bytes, + sink, + 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() + } + + pub(super) fn unconfirmed(&self) -> Option { + self.state + .lock() + .expect("Job submission state poisoned") + .unconfirmed + .clone() + } + + fn definition(&self) -> ToolDefinition { + let feature = self.clone(); + Arc::new(move || { + ( + ToolMeta::new(SUBMIT_JOB_RESULT_TOOL) + .description(DESCRIPTION) + .input_schema( + serde_json::to_value(schemars::schema_for!(SubmitJobResultInput)) + .expect("Job result schema serializes"), + ), + Arc::new(feature.clone()) as Arc, + ) + }) + } +} + +#[async_trait] +impl Tool for JobResultFeature { + async fn execute( + &self, + input_json: &str, + context: ToolExecutionContext, + ) -> Result { + let input: SubmitJobResultInput = serde_json::from_str(input_json) + .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 + .lock() + .expect("Job cancellation state poisoned") + .contains(&context.execution_id()) + { + return Err(ToolError::Cancelled(ToolOutput { + summary: "Job result submission cancelled".into(), + content: None, + attachments: vec![], + })); + } + if let Some(sealed) = &state.sealed { + if sealed != &input.result { + return Err(ToolError::InvalidArgument( + "Job result is sealed; retry only the identical result".into(), + )); + } + } 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: prepared, + }; + let mut accepted = self.accepted.lock().expect("Job result state poisoned"); + if accepted.is_none() { + if let Err(error) = self.sink.submit(submission.clone()) { + let error = ::job::bounded_failure_detail(&error); + state.unconfirmed = Some(error.clone()); + return Err(ToolError::ExecutionFailed(format!( + "Host Job result acceptance was not confirmed; retry only the identical result: {error}" + ))); + } + state.unconfirmed = None; + *accepted = Some(submission); + } + Ok(ToolOutput { + summary: "Host Job result accepted".into(), + content: None, + attachments: vec![], + }) + } + + async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> { + self.cancellation.cancel(); + self.cancelled + .lock() + .expect("Job cancellation state poisoned") + .insert(context.execution_id()); + Ok(()) + } +} + +impl FeatureModule for JobResultFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("host-job-result", "Host Job result") + .with_description("Result-only immutable Host Job submission capability.") + .with_tool(ToolDeclaration::new(SUBMIT_JOB_RESULT_TOOL, DESCRIPTION)) + } + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + context.tools().register(ToolContribution::new( + SUBMIT_JOB_RESULT_TOOL, + self.definition(), + )) + } +} diff --git a/crates/worker/src/job/tests.rs b/crates/worker/src/job/tests.rs new file mode 100644 index 000000000..3c0c66ec6 --- /dev/null +++ b/crates/worker/src/job/tests.rs @@ -0,0 +1,1125 @@ +//! 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 { + scripts: Arc>>>, + requests: Arc>>, + pending: bool, + entered: Arc, + fail: bool, +} +impl ScriptedClient { + fn new(scripts: Vec>) -> Self { + Self { + scripts: Arc::new(Mutex::new(scripts.into())), + requests: Arc::default(), + pending: false, + entered: Arc::default(), + fail: false, + } + } +} +#[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.requests.lock().unwrap().push(request); + if self.fail && self.scripts.lock().unwrap().is_empty() { + return Err(ClientError::Config("scripted failure".into())); + } + if self.pending && self.scripts.lock().unwrap().is_empty() { + self.entered.notify_one(); + return Ok(Box::pin(futures::stream::pending())); + } + let events = self + .scripts + .lock() + .unwrap() + .pop_front() + .expect("unexpected extra model turn"); + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } +} +#[derive(Default)] +struct RecordingSink(Mutex>); +impl JobResultSink for RecordingSink { + fn submit(&self, result: ::job::JobResultSubmission) -> Result<(), String> { + self.0.lock().unwrap().push(result); + Ok(()) + } +} +fn prose() -> Vec { + vec![ + Event::text_block_start(0), + Event::text_delta(0, "success"), + Event::text_block_stop(0, None), + Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] +} +fn call(name: &str, input: Value) -> Vec { + vec![ + Event::tool_use_start(0, "call", name), + Event::tool_input_delta(0, input.to_string()), + Event::tool_use_stop(0), + Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] +} +fn request(profile: &str) -> ::job::JobRequest { + ::job::JobRequest { + job_id: "job-a".into(), + purpose: "scripted".into(), + input_revision: "rev-1".into(), + input_ref: "snapshot:one".into(), + input: json!({"text":"input"}), + instruction: "Summarize the input".into(), + profile: profile.into(), + serialization_key: None, + limits: Default::default(), + } +} +fn artifact(instruction: &str, tokens: u32, turns: u32) -> Value { + json!({"model":{"scheme":"anthropic", "model_id":"scripted-model", "auth":{"kind":"none"}}, + "engine":{"instruction":instruction, "language":"English", "max_tokens":tokens, "max_turns":turns}}) +} +fn registry(root: &Path, a: Value, b: Value) -> manifest::ProfileRegistry { + std::fs::write(root.join("a.json"), a.to_string()).unwrap(); + std::fs::write(root.join("b.json"), b.to_string()).unwrap(); + let settings = root.join("profiles.toml"); + std::fs::write( + &settings, + "default = 'user:a'\n[profile]\na = 'a.json'\nb = 'b.json'\n", + ) + .unwrap(); + ProfileDiscovery::with_sources(Some(settings), None) + .discover() + .unwrap() +} +fn prepared( + root: &Path, + request: &::job::JobRequest, + client: ScriptedClient, +) -> PreparedInternalJob { + let registry = registry( + root, + artifact("default", 512, 3), + artifact("internal.flow_verifier_system", 100000, 1000), + ); + prepare_job_from_registry(request, root, Some(Box::new(client)), ®istry).unwrap() +} + +#[tokio::test] +async fn executes_selected_profiles_with_only_result_authority_and_bounded_policy() { + let root = tempfile::tempdir().unwrap(); + let registry = registry( + root.path(), + artifact("default", 512, 3), + artifact("internal.flow_verifier_system", 100000, 1000), + ); + let contract = PromptCatalog::builtins_only() + .unwrap() + .render(WorkerPrompt::JobSystem, minijinja::context! {}) + .unwrap(); + let mut prompts = vec![]; + for (profile, tokens) in [ + ("user:a", 512), + ("user:b", MAX_JOB_TOKENS), + ("default", 512), + ] { + let client = ScriptedClient::new(vec![ + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"ok":true}})), + prose(), + ]); + let sink = Arc::new(RecordingSink::default()); + let job = prepare_job_from_registry( + &request(profile), + root.path(), + Some(Box::new(client.clone())), + ®istry, + ) + .unwrap(); + assert_eq!( + job.manifest.engine.max_turns.unwrap().get(), + if profile == "user:b" { + MAX_JOB_TURNS + } else { + 3 + } + ); + let (tx, rx) = tokio::sync::oneshot::channel(); + let result = job + .run("attempt-7".into(), sink.clone(), move |sender| { + let _ = tx.send(sender); + }) + .await + .unwrap(); + assert!( + rx.await.unwrap().is_closed(), + "Worker must be released before returning acceptance" + ); + assert_eq!( + result.submission, + ::job::JobResultSubmission { + job_id: "job-a".into(), + attempt_id: "attempt-7".into(), + input_revision: "rev-1".into(), + result: json!({"ok":true}) + } + ); + assert_eq!(sink.0.lock().unwrap().len(), 1); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + for request in requests.iter() { + assert_eq!( + request + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + [SUBMIT_JOB_RESULT_TOOL] + ); + assert_eq!(request.config.max_tokens, Some(tokens)); + assert_eq!( + request.tools[0].input_schema["properties"] + .as_object() + .unwrap() + .keys() + .collect::>(), + ["result"] + ); + assert!(request.system_prompt.as_ref().unwrap().ends_with(&contract)); + } + prompts.push(requests[0].system_prompt.clone()); + } + assert_ne!(prompts[0], prompts[1]); + assert_eq!(prompts[0], prompts[2]); +} + +#[tokio::test] +async fn selected_builtin_job_executes_with_catalog_contract_and_only_result_tool() { + let root = tempfile::tempdir().unwrap(); + let registry = ProfileDiscovery::with_sources(None, None) + .discover() + .unwrap(); + let request = request("builtin:job"); + let client = ScriptedClient::new(vec![ + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":{"ok":true}})), + prose(), + ]); + let sink = Arc::new(RecordingSink::default()); + let job = prepare_job_from_registry( + &request, + root.path(), + Some(Box::new(client.clone())), + ®istry, + ) + .unwrap(); + assert_eq!( + job.manifest.engine.instruction, + WorkerPrompt::JobSystem.key() + ); + assert_eq!( + job.manifest.model.ref_.as_deref(), + Some("codex-oauth/gpt-5.6-luna") + ); + assert_eq!(job.manifest.engine.max_turns.unwrap().get(), MAX_JOB_TURNS); + assert!(job.manifest.scope.allow.is_empty()); + assert!(job.manifest.delegation_scope.allow.is_empty()); + assert!(WorkerPrompt::ALL.contains(&WorkerPrompt::JobSystem)); + let contract = PromptCatalog::builtins_only() + .unwrap() + .render(WorkerPrompt::JobSystem, minijinja::context! {}) + .unwrap(); + assert!(job.system_prompt.ends_with(&contract)); + let (tx, rx) = tokio::sync::oneshot::channel(); + let result = job + .run("builtin-attempt".into(), sink.clone(), move |sender| { + let _ = tx.send(sender); + }) + .await + .unwrap(); + assert!(rx.await.unwrap().is_closed()); + assert_eq!(result.submission.job_id, request.job_id); + assert_eq!(result.submission.attempt_id, "builtin-attempt"); + assert_eq!(result.submission.input_revision, request.input_revision); + assert_eq!(result.submission.result, json!({"ok":true})); + assert_eq!(sink.0.lock().unwrap().len(), 1); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + for request in requests.iter() { + assert_eq!( + request + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + [SUBMIT_JOB_RESULT_TOOL] + ); + assert_eq!(request.config.max_tokens, Some(MAX_JOB_TOKENS)); + assert!(request.system_prompt.as_ref().unwrap().ends_with(&contract)); + } +} + +#[tokio::test] +async fn final_prose_and_ungranted_tool_calls_are_not_success() { + let root = tempfile::tempdir().unwrap(); + for scripts in [ + vec![prose()], + vec![ + call("Bash", json!({"command":"touch should-not-exist"})), + prose(), + ], + vec![ + call("SubmitBackendJobResult", json!({"result":{"ok":true}})), + prose(), + ], + vec![ + call( + SUBMIT_JOB_RESULT_TOOL, + json!({"job_id":"forged","result":{"ok":true}}), + ), + prose(), + ], + ] { + let sink = Arc::new(RecordingSink::default()); + let outcome = prepared( + root.path(), + &request("user:a"), + ScriptedClient::new(scripts), + ) + .run("attempt".into(), sink.clone(), |_| {}) + .await; + assert!( + matches!(outcome, Err(JobExecutionError::MissingResult)), + "{outcome:?}" + ); + assert!(sink.0.lock().unwrap().is_empty()); + assert!(!root.path().join("should-not-exist").exists()); + } +} + +#[tokio::test] +async fn result_size_and_identity_are_fenced_before_host_submission() { + let root = tempfile::tempdir().unwrap(); + let mut request = request("user:a"); + request.limits.max_result_bytes = 5; + let sink = Arc::new(RecordingSink::default()); + let job = prepared( + root.path(), + &request, + ScriptedClient::new(vec![ + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":"too big"})), + prose(), + ]), + ); + assert!(matches!( + job.run("attempt".into(), sink.clone(), |_| {}).await, + Err(JobExecutionError::MissingResult) + )); + assert!(sink.0.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn cancellation_and_errors_return_without_acceptance() { + let root = tempfile::tempdir().unwrap(); + let mut client = ScriptedClient::new(vec![]); + client.pending = true; + let entered = client.entered.clone(); + let job = prepared(root.path(), &request("user:a"), client); + let sink = Arc::new(RecordingSink::default()); + let (tx, rx) = tokio::sync::oneshot::channel(); + let run = tokio::spawn(async move { + job.run("attempt".into(), sink, move |sender| { + let _ = tx.send(sender); + }) + .await + }); + let sender = rx.await.unwrap(); + entered.notified().await; + sender.send(()).await.unwrap(); + assert!(matches!( + tokio::time::timeout(std::time::Duration::from_secs(2), run) + .await + .unwrap() + .unwrap(), + Err(JobExecutionError::Cancelled) + )); + assert!( + sender.is_closed(), + "normal Worker release must close cancellation receiver" + ); + let mut client = ScriptedClient::new(vec![]); + client.fail = true; + let (tx, rx) = tokio::sync::oneshot::channel(); + let outcome = prepared(root.path(), &request("user:a"), client) + .run( + "attempt".into(), + Arc::new(RecordingSink::default()), + move |sender| { + let _ = tx.send(sender); + }, + ) + .await; + assert!(matches!(outcome, Err(JobExecutionError::Worker(_)))); + assert!( + rx.await.unwrap().is_closed(), + "error must release the Worker" + ); +} + +#[tokio::test] +async fn accepted_result_survives_later_provider_error_and_cancellation() { + let root = tempfile::tempdir().unwrap(); + for cancel in [false, true] { + let mut client = + ScriptedClient::new(vec![call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true}))]); + client.pending = cancel; + client.fail = !cancel; + let entered = client.entered.clone(); + let sink = Arc::new(RecordingSink::default()); + let job = prepared(root.path(), &request("user:a"), client); + let (tx, rx) = tokio::sync::oneshot::channel(); + let recording = sink.clone(); + let run = tokio::spawn(async move { + job.run("attempt".into(), recording, move |sender| { + let _ = tx.send(sender); + }) + .await + }); + let sender = rx.await.unwrap(); + if cancel { + entered.notified().await; + assert_eq!( + sink.0.lock().unwrap().len(), + 1, + "acceptance must precede cancellation" + ); + sender.send(()).await.unwrap(); + } + let result = tokio::time::timeout(std::time::Duration::from_secs(2), run) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.submission.result, json!(true)); + assert_eq!(sink.0.lock().unwrap().len(), 1); + assert!(sender.is_closed()); + } +} + +#[tokio::test] +async fn turn_limit_is_bounded_and_accepted_result_survives_later_failure() { + let root = tempfile::tempdir().unwrap(); + let registry = registry( + root.path(), + artifact("default", 512, 1), + artifact("default", 512, 1), + ); + for (tool, expected) in [("UnknownTool", false), (SUBMIT_JOB_RESULT_TOOL, true)] { + let client = ScriptedClient::new(vec![call(tool, json!({"result":true}))]); + let prepared = prepare_job_from_registry( + &request("user:a"), + root.path(), + Some(Box::new(client.clone())), + ®istry, + ) + .unwrap(); + let outcome = prepared + .run("attempt".into(), Arc::new(RecordingSink::default()), |_| {}) + .await; + assert_eq!(outcome.is_ok(), expected); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } +} + +#[test] +fn profiles_with_unavailable_requirements_are_rejected_without_name_allowlist_or_fallback() { + let root = tempfile::tempdir().unwrap(); + let base = artifact("default", 512, 3); + for (key, value, needle) in [ + ("feature", json!({"task":{"enabled":true}}), "feature.task"), + ( + "feature", + json!({"workspace_config":{"enabled":true}}), + "feature.workspace_config", + ), + ( + "feature", + json!({"subjektiv":{"consolidation_tools":true}}), + "feature.subjektiv", + ), + ( + "feature", + json!({"memory":{"staging_tools":true}}), + "feature.memory", + ), + ("scope", json!("workspace_read"), "scope"), + ( + "delegation_scope", + json!("workspace_write"), + "delegation_scope", + ), + ( + "session", + json!({"record_event_trace":true}), + "session.record_event_trace", + ), + ("web", json!({"enabled":true}), "web"), + ( + "compaction", + json!({"kind":"tokens","threshold":10000}), + "compaction", + ), + ("worker", json!({"mode":"wip"}), "worker.mode=wip"), + ("skills", json!({"directories":["skills"]}), "skills"), + ( + "mcp", + json!({"stdio_server":[{"name":"test","command":"fake"}]}), + "mcp", + ), + ] { + let mut profile = base.clone(); + profile[key] = value; + let registry = registry(root.path(), profile, base.clone()); + let error = prepare_job_from_registry( + &request("user:a"), + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry, + ) + .err() + .unwrap(); + assert!( + matches!(error, JobExecutionError::UnsupportedProfile(_)), + "{error}" + ); + assert!(error.to_string().contains(needle), "{error}"); + } + for name in [ + "web", + "image", + "sub_worker", + "flow", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "ticket", + "orchestration", + ] { + let mut profile = base.clone(); + profile["feature"] = json!({name:{"enabled":true}}); + let registry = registry(root.path(), profile, base.clone()); + assert!(matches!( + prepare_job_from_registry( + &request("user:a"), + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry + ), + Err(JobExecutionError::UnsupportedProfile(_)) + )); + } + let registry = registry(root.path(), base.clone(), base); + for selector in ["user:missing", "project:a", "./a.json", "inherit"] { + assert!( + prepare_job_from_registry( + &request(selector), + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry + ) + .is_err() + ); + } + let builtin = request("builtin:backend-job"); + assert!(matches!( + prepare_job_from_registry( + &builtin, + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry + ), + Err(JobExecutionError::UnsupportedProfile(_)) + )); +} + +#[test] +fn unknown_prompt_and_model_are_not_substituted_even_with_injected_client() { + let root = tempfile::tempdir().unwrap(); + let mut unknown_model = artifact("default", 512, 3); + unknown_model["model"] = json!({"ref":"not-a-provider/not-a-model"}); + let registry = registry( + root.path(), + artifact("internal.missing", 512, 3), + unknown_model, + ); + for (profile, prompt_error) in [("user:a", true), ("user:b", false)] { + let error = prepare_job_from_registry( + &request(profile), + root.path(), + Some(Box::new(ScriptedClient::new(vec![]))), + ®istry, + ) + .err() + .unwrap(); + if prompt_error { + assert!( + matches!(error, JobExecutionError::Instruction(_)), + "{error}" + ); + } else { + assert!(matches!(error, JobExecutionError::Model(_)), "{error}"); + } + } +} + +// 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> { + self.0.lock().unwrap().push(value); + Err("unknown".into()) + } +} + +#[tokio::test] +async fn unconfirmed_sink_acceptance_is_not_classified_as_missing_result_or_known_failure() { + let root = tempfile::tempdir().unwrap(); + let sink = Arc::new(UnknownSink(Mutex::new(vec![]))); + let job = prepared( + root.path(), + &request("user:a"), + ScriptedClient::new(vec![ + call(SUBMIT_JOB_RESULT_TOOL, json!({"result":true})), + prose(), + ]), + ); + assert!(matches!( + job.run("attempt".into(), sink.clone(), |_| {}).await, + Err(JobExecutionError::ResultUnconfirmed(_)) + )); + assert_eq!(sink.0.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn feature_registration_rejects_forged_fences_seals_unknown_outcomes_and_honors_cancel() { + let sink = Arc::new(UnknownSink(Mutex::new(vec![]))); + let feature = + JobResultFeature::new(&request("user:a"), "attempt".into(), sink.clone()).unwrap(); + let mut pending = vec![]; + let mut hooks = crate::HookRegistryBuilder::new(); + let report = FeatureRegistryBuilder::new() + .with_module(feature.clone()) + .install_into_pending(&mut pending, &mut hooks); + assert_eq!(report.installed_tool_names(), [SUBMIT_JOB_RESULT_TOOL]); + let (_, tool) = pending.remove(0)(); + assert!( + tool.execute( + r#"{"attempt_id":"forged","result":true}"#, + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + assert!(sink.0.lock().unwrap().is_empty()); + assert!( + tool.execute(r#"{"result":true}"#, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!( + tool.execute(r#"{"result":false}"#, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!( + tool.execute(r#"{"result":true}"#, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!(sink.0.lock().unwrap().len(), 2); + let context = ToolExecutionContext::new("cancelled", "batch", 0); + feature.cancel_execution(&context).await.unwrap(); + assert!(matches!( + tool.execute(r#"{"result":true}"#, context).await, + Err(agen::tool::ToolError::Cancelled(_)) + )); + assert_eq!(sink.0.lock().unwrap().len(), 2); +} diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 1dbc8a526..069938d1f 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -12,6 +12,7 @@ pub mod fs_view; pub mod hook; pub(crate) mod in_flight; pub mod ipc; +pub mod job; pub mod model_client; mod paste_artifact_tool; pub mod prompt; @@ -24,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/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 9b3e30459..1b72b8415 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -97,6 +97,7 @@ pub enum WorkerPrompt { SubjektivMemoryConsolidationSystem, SubjektivMemorySurfaceSystem, FlowVerifierSystem, + JobSystem, BackendJobSystem, NotifyWrapper, InterruptToolResultSummary, @@ -121,6 +122,7 @@ impl WorkerPrompt { } Self::SubjektivMemorySurfaceSystem => "internal.subjektiv_memory_surface_system", Self::FlowVerifierSystem => "internal.flow_verifier_system", + Self::JobSystem => "internal.job_system", Self::BackendJobSystem => "internal.backend_job_system", Self::NotifyWrapper => "internal.notify_wrapper", Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary", @@ -144,6 +146,7 @@ impl WorkerPrompt { WorkerPrompt::SubjektivMemoryConsolidationSystem, WorkerPrompt::SubjektivMemorySurfaceSystem, WorkerPrompt::FlowVerifierSystem, + WorkerPrompt::JobSystem, WorkerPrompt::BackendJobSystem, WorkerPrompt::NotifyWrapper, WorkerPrompt::InterruptToolResultSummary, 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/wip.rs b/crates/worker/src/wip.rs index bbce6e9cd..c2043edca 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -2426,7 +2426,7 @@ fn one() -> u32 { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct WipInspectInput { - /// Opaque interface reference returned by WipDiscover. + /// Opaque interface reference returned by Discover. interface: String, /// Explicitly supersede the cached descriptor observation. #[serde(default)] @@ -2435,11 +2435,11 @@ struct WipInspectInput { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct WipCallInput { - /// Exact object path returned by WipDiscover. + /// Exact object path returned by Discover. path: String, /// Exact interface reference selected from that object. interface: String, - /// Exact operation name from WipInspect. + /// Exact operation name from Inspect. operation: String, /// Original operation arguments. Compatibility operations accept the original tool object here. arguments: Json, @@ -2521,7 +2521,7 @@ fn wip_tool_definitions(runtime: Arc) -> Vec { Arc::new(move || { let schema = schemars::schema_for!(WipDiscoverInput); ( - ToolMeta::new("WipDiscover") + ToolMeta::new("Discover") .description("Explore the authorized WIP Worldspace and stateful Known Space. Start at `/`; use reset after reconnect or authority changes.") .input_schema(serde_json::to_value(schema).expect("WIP discover schema serializes")), Arc::new(WipDiscoverTool { @@ -2532,8 +2532,8 @@ fn wip_tool_definitions(runtime: Arc) -> Vec { Arc::new(move || { let schema = schemars::schema_for!(WipInspectInput); ( - ToolMeta::new("WipInspect") - .description("Fetch and validate one descriptor returned by WipDiscover. Inspect before calling an operation.") + ToolMeta::new("Inspect") + .description("Fetch and validate one descriptor returned by Discover. Inspect before calling an operation.") .input_schema(serde_json::to_value(schema).expect("WIP inspect schema serializes")), Arc::new(WipInspectTool { runtime: Arc::clone(&inspect_runtime), @@ -2543,7 +2543,7 @@ fn wip_tool_definitions(runtime: Arc) -> Vec { Arc::new(move || { let schema = schemars::schema_for!(WipCallInput); ( - ToolMeta::new("WipCall") + ToolMeta::new("Call") .description("Invoke an operation only from fresh object/interface observations. Stale validators and invalid original JSON Schema inputs fail closed; unknown write outcomes must not be retried automatically.") .input_schema(serde_json::to_value(schema).expect("WIP call schema serializes")), Arc::new(WipCallTool { @@ -3093,12 +3093,74 @@ mod tests { .into_iter() .map(|definition| definition.name) .collect::>(), - ["WipCall", "WipDiscover", "WipInspect"] + ["Call", "Discover", "Inspect"] ); let metrics = runtime.metrics(); assert!(metrics.ordinary_schema_bytes > metrics.wip_schema_bytes); } + #[tokio::test] + async fn renamed_gateway_tools_discover_inspect_and_call_through_registered_surface() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + engine.register_tool(echo_definition("Echo".into(), Arc::clone(&calls))); + install_wip_mode(&mut engine, None, "worker-a".into()).unwrap(); + let tools = engine.tool_server_handle(); + tools.flush_pending(); + + let discovered = transport_output_json( + tools + .call_tool( + "Discover", + r#"{"path":"/","depth":2}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); + let object = discovered["known_space"] + .as_array() + .unwrap() + .iter() + .find(|item| item["path"] == "/tools/Echo") + .unwrap(); + assert_eq!(object["state"], "fresh"); + let interface = object["object"]["interfaces"][0].as_str().unwrap(); + let inspected = transport_output_json( + tools + .call_tool( + "Inspect", + &json!({"interface": interface}).to_string(), + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); + assert_eq!(inspected["interface"], interface); + assert_eq!(inspected["state"], "fresh"); + assert_eq!(inspected["descriptor"]["operations"][0]["name"], "call"); + + let arguments = json!({"message": "hello"}); + let output = tools + .call_tool( + "Call", + &json!({ + "path": object["path"], + "interface": interface, + "operation": "call", + "arguments": arguments, + }) + .to_string(), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + assert_eq!(output.summary, arguments.to_string()); + assert!(output.content.is_none()); + assert!(output.attachments.is_empty()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + #[test] fn mount_collision_and_native_replacement_are_explicit() { struct Native; 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 521b7f4ac..25e7fc27b 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -16,8 +16,11 @@ 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 protocol = { workspace = true } runtime-api.workspace = true server-api.workspace = true diff --git a/crates/workspace-server/src/backend_job.rs b/crates/workspace-server/src/backend_job.rs index f5b54d7a4..f7c6fc827 100644 --- a/crates/workspace-server/src/backend_job.rs +++ b/crates/workspace-server/src/backend_job.rs @@ -7,26 +7,28 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use sha2::{Digest, Sha256}; use worker_runtime::identity::RuntimeWorkerRef; use crate::{Error, Result}; pub const BACKEND_JOB_RUNTIME_ID: &str = crate::hosts::EMBEDDED_RUNTIME_ID; -pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8; -pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64; -pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120; -pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600; -pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024; -pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024; -pub const DEFAULT_MAX_ATTEMPTS: u8 = 2; -pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3; -pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024; -pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024; -pub const MAX_JOB_PURPOSE_BYTES: usize = 120; -pub const MAX_JOB_REFERENCE_BYTES: usize = 512; -pub const MAX_JOB_FAILURE_BYTES: usize = 1024; -pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024; +pub use job::{ + ABSOLUTE_MAX_ATTEMPTS, ABSOLUTE_MAX_CONCURRENT_JOBS, ABSOLUTE_MAX_RESULT_BYTES, + DEFAULT_JOB_TIMEOUT_SECONDS, DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_CONCURRENT_JOBS, + DEFAULT_MAX_RESULT_BYTES, JobResultSubmission as BackendJobResultSubmission, + MAX_JOB_DELIVERY_BYTES, MAX_JOB_FAILURE_BYTES, MAX_JOB_INPUT_BYTES, MAX_JOB_INSTRUCTION_BYTES, + MAX_JOB_PURPOSE_BYTES, MAX_JOB_REFERENCE_BYTES, MAX_JOB_TIMEOUT_SECONDS, attempt_id, + bounded_failure_detail, +}; + +// Preserve the Backend InvalidInput surface and its exact historical messages. +fn backend_error(error: job::JobError) -> Error { + match error { + job::JobError::InvalidInput(detail) => { + Error::InvalidInput(detail.replacen("Job ", "Backend Job ", 1)) + } + } +} #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -37,6 +39,17 @@ pub enum BackendJobState { Unknown, } +impl From for job::JobState { + fn from(state: BackendJobState) -> Self { + match state { + BackendJobState::Pending => Self::Pending, + BackendJobState::Completed => Self::Completed, + BackendJobState::Failed => Self::Failed, + BackendJobState::Unknown => Self::Unknown, + } + } +} + impl BackendJobState { pub(crate) fn as_str(self) -> &'static str { match self { @@ -69,6 +82,19 @@ pub enum BackendJobAttemptState { Unknown, } +impl From for job::JobAttemptState { + fn from(state: BackendJobAttemptState) -> Self { + match state { + BackendJobAttemptState::Reserved => Self::Reserved, + BackendJobAttemptState::Dispatching => Self::Dispatching, + BackendJobAttemptState::Dispatched => Self::Dispatched, + BackendJobAttemptState::Completed => Self::Completed, + BackendJobAttemptState::Failed => Self::Failed, + BackendJobAttemptState::Unknown => Self::Unknown, + } + } +} + impl BackendJobAttemptState { pub(crate) fn as_str(self) -> &'static str { match self { @@ -161,28 +187,29 @@ impl Default for BackendJobLimits { impl BackendJobLimits { pub fn validate(&self) -> Result<()> { - if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS - { - return Err(Error::InvalidInput(format!( - "Backend Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}" - ))); + job::JobLimits::from(self).validate().map_err(backend_error) + } +} + +impl From<&BackendJobLimits> for job::JobLimits { + fn from(limits: &BackendJobLimits) -> Self { + Self { + max_concurrent_jobs: limits.max_concurrent_jobs, + timeout_seconds: limits.timeout_seconds, + max_result_bytes: limits.max_result_bytes, + max_attempts: limits.max_attempts, } - if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS { - return Err(Error::InvalidInput(format!( - "Backend Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}" - ))); + } +} + +impl From for BackendJobLimits { + fn from(limits: job::JobLimits) -> Self { + Self { + max_concurrent_jobs: limits.max_concurrent_jobs, + timeout_seconds: limits.timeout_seconds, + max_result_bytes: limits.max_result_bytes, + max_attempts: limits.max_attempts, } - if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES { - return Err(Error::InvalidInput(format!( - "Backend Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}" - ))); - } - if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS { - return Err(Error::InvalidInput(format!( - "Backend Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}" - ))); - } - Ok(()) } } @@ -261,17 +288,29 @@ pub struct BackendJobRequest { } impl BackendJobRequest { - pub fn validate(&self) -> Result<()> { - validate_identifier("job_id", &self.job_id, 256)?; - validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?; - validate_identifier("input_revision", &self.input_revision, 256)?; - validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?; - validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?; - validate_identifier("profile", &self.profile, 256)?; - self.profile_selector()?; - if let Some(key) = &self.serialization_key { - validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?; + /// Adapt neutral intent without granting domain access or writer/notification + /// authority. Validation remains the Backend submission boundary's job; + /// the caller-selected profile is retained exactly, with no fallback. + pub fn from_job_request(request: job::JobRequest) -> Self { + Self { + job_id: request.job_id, + purpose: request.purpose, + input_revision: request.input_revision, + input_ref: request.input_ref, + input: request.input, + instruction: request.instruction, + profile: request.profile, + grants: BackendJobGrants::default(), + serialization_key: request.serialization_key, + source_worker: None, + notification_target: None, + limits: request.limits.into(), } + } + + pub fn validate(&self) -> Result<()> { + let request = self.to_job_request(); + request.validate_fields().map_err(backend_error)?; if let Some(grant) = &self.grants.subjektiv_consolidation { validate_identifier("grant subject_id", &grant.subject_id, 256)?; if grant.candidate_ids.len() > 256 { @@ -297,44 +336,30 @@ impl BackendJobRequest { )); } } - let input = serde_json::to_vec(&self.input).map_err(|error| { - Error::InvalidInput(format!("serialize Backend Job input: {error}")) - })?; - if input.len() > MAX_JOB_INPUT_BYTES { - return Err(Error::InvalidInput(format!( - "Backend Job input exceeds {MAX_JOB_INPUT_BYTES} bytes" - ))); - } + request.validate_input().map_err(backend_error)?; self.limits.validate() } + /// Transport-neutral projection only. Domain grants, writer authority and + /// derived resource locks remain Backend-owned; never fingerprint this + /// projection as a substitute for the full Backend intent. + pub fn to_job_request(&self) -> job::JobRequest { + job::JobRequest { + job_id: self.job_id.clone(), + purpose: self.purpose.clone(), + input_revision: self.input_revision.clone(), + input_ref: self.input_ref.clone(), + input: self.input.clone(), + instruction: self.instruction.clone(), + profile: self.profile.clone(), + serialization_key: self.serialization_key.clone(), + limits: (&self.limits).into(), + } + } + /// Validate selector syntax only. Registry existence and resolution are dispatch authority. pub fn profile_selector(&self) -> Result { - use manifest::ProfileSelector; - validate_identifier("profile", &self.profile, 256)?; - let selector = ProfileSelector::parse_cli(&self.profile); - let valid = match &selector { - ProfileSelector::Default => true, - ProfileSelector::Path { .. } => false, - ProfileSelector::Named { source, name } => { - !name.is_empty() - && !name.starts_with('.') - && !name.contains(['/', '\\', ':']) - && name - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) - && ![".toml", ".json", ".dcdl", ".nix"] - .iter() - .any(|suffix| name.ends_with(suffix)) - && (source.is_some() || name != "inherit") - } - }; - if !valid { - return Err(Error::InvalidInput( - "Backend Job profile must be a manifest registry selector, not raw source or a path".into(), - )); - } - Ok(selector) + job::profile_selector(&self.profile).map_err(backend_error) } /// Effective single-flight key. Typed grants cannot bypass their subject fence. @@ -348,20 +373,12 @@ impl BackendJobRequest { pub fn fingerprint(&self) -> Result { self.validate()?; - let encoded = serde_json::to_vec(self).map_err(|error| { - Error::InvalidInput(format!("serialize Backend Job intent: {error}")) - })?; - Ok(sha256(&encoded)) + job::fingerprint(self).map_err(backend_error) } pub fn worker_input(&self, attempt_id: &str) -> Result { - let input = serde_json::to_string(&self.input).map_err(|error| { - Error::InvalidInput(format!("serialize Backend Job input: {error}")) - })?; - Ok(format!( - "{}\n\nBackend Job envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority.", - self.instruction, self.job_id, attempt_id, self.input_revision, self.input_ref, input - )) + job::worker_input_with_label(&self.to_job_request(), attempt_id, "Backend Job") + .map_err(backend_error) } } @@ -451,13 +468,10 @@ pub struct BackendJobReservation { pub resource_reused: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct BackendJobResultSubmission { - pub job_id: String, - pub attempt_id: String, - pub input_revision: String, - pub result: Value, +impl BackendJobReservation { + pub fn outcome(&self) -> job::JobOutcome { + job_outcome(&self.job, &self.attempt) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -467,6 +481,28 @@ pub struct BackendJobResultAcceptance { pub replayed: bool, } +impl BackendJobResultAcceptance { + pub fn outcome(&self) -> job::JobOutcome { + job_outcome(&self.job, &self.attempt) + } +} + +// Project the durable Job outcome, not Worker lifecycle, cleanup or delivery. +// The paired attempt supplies its own identity, number and execution state. +fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome { + job::JobOutcome { + job_id: job.request.job_id.clone(), + input_revision: job.request.input_revision.clone(), + attempt_id: attempt.attempt_id.clone(), + attempt: attempt.attempt, + state: job.state.into(), + attempt_state: attempt.state.into(), + result: job.result.clone(), + failure_category: job.failure_category.clone(), + failure_detail: job.failure_detail.clone(), + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BackendJobWorkerBinding { pub job_id: String, @@ -489,10 +525,6 @@ pub struct BackendJobDeliveryRecord { pub delivered_at: Option, } -pub fn attempt_id(job_id: &str, attempt: u8) -> String { - format!("{job_id}:attempt:{attempt}") -} - pub fn allocation_key(attempt_id: &str) -> String { format!("backend-job:{attempt_id}") } @@ -515,64 +547,15 @@ pub fn delivery_id(job_id: &str, attempt_id: &str) -> String { } fn tracked_request_id(kind: &str, identity: &[u8]) -> String { - format!("backend-job-{kind}:{}", sha256(identity)) + format!("backend-job-{kind}:{}", job::sha256(identity)) } pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> { - let encoded = serde_json::to_string(result) - .map_err(|error| Error::InvalidInput(format!("serialize Backend Job result: {error}")))?; - if encoded.len() > max_bytes as usize { - return Err(Error::InvalidInput(format!( - "Backend Job result exceeds {max_bytes} bytes" - ))); - } - Ok((sha256(encoded.as_bytes()), encoded)) -} - -pub(crate) fn bounded_failure_detail(detail: &str) -> String { - if detail.len() <= MAX_JOB_FAILURE_BYTES { - return detail.to_string(); - } - let mut end = MAX_JOB_FAILURE_BYTES; - while !detail.is_char_boundary(end) { - end -= 1; - } - detail[..end].to_string() + job::result_digest(result, max_bytes).map_err(backend_error) } fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> { - validate_bounded_text(field, value, max_bytes)?; - if value.chars().any(char::is_control) { - return Err(Error::InvalidInput(format!( - "Backend Job {field} must not contain control characters" - ))); - } - Ok(()) -} - -fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> { - if value.is_empty() || value.trim() != value { - return Err(Error::InvalidInput(format!( - "Backend Job {field} must be non-empty and trimmed" - ))); - } - if value.len() > max_bytes { - return Err(Error::InvalidInput(format!( - "Backend Job {field} exceeds {max_bytes} bytes" - ))); - } - Ok(()) -} - -fn sha256(bytes: &[u8]) -> String { - let digest = Sha256::digest(bytes); - let mut output = String::with_capacity(7 + digest.len() * 2); - output.push_str("sha256:"); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(output, "{byte:02x}"); - } - output + job::validate_identifier(field, value, max_bytes).map_err(backend_error) } #[cfg(test)] @@ -596,6 +579,258 @@ mod tests { } } + fn reservation() -> BackendJobReservation { + serde_json::from_value(serde_json::json!({ + "job": { + "workspace_id": "workspace-a", + "request": request(), + "intent_fingerprint": request().fingerprint().unwrap(), + "state": "pending", + "current_attempt": 2, + "created_at": "created", + "updated_at": "updated" + }, + "attempt": { + "workspace_id": "workspace-a", + "job_id": "ticket-check:T-1:r7", + "attempt_id": "ticket-check:T-1:r7:attempt:2", + "attempt": 2, + "input_revision": "7", + "state": "reserved", + "worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"}, + "runtime_run_id": "run-a", + "deadline_at": "deadline", + "created_at": "created", + "updated_at": "updated" + }, + "replayed": false, + "resource_reused": false + })) + .unwrap() + } + + #[test] + fn backend_state_projections_preserve_existing_wire_vocabulary() { + for state in [ + BackendJobState::Pending, + BackendJobState::Completed, + BackendJobState::Failed, + BackendJobState::Unknown, + ] { + let shared = job::JobState::from(state); + assert_eq!( + serde_json::to_value(state).unwrap(), + serde_json::to_value(shared).unwrap() + ); + } + for state in [ + BackendJobAttemptState::Reserved, + BackendJobAttemptState::Dispatching, + BackendJobAttemptState::Dispatched, + BackendJobAttemptState::Completed, + BackendJobAttemptState::Failed, + BackendJobAttemptState::Unknown, + ] { + let shared = job::JobAttemptState::from(state); + assert_eq!( + serde_json::to_value(state).unwrap(), + serde_json::to_value(shared).unwrap() + ); + } + // The shared read vocabulary does not add Backend cancellation support. + assert!(BackendJobState::parse("cancelled").is_err()); + assert!(BackendJobAttemptState::parse("cancelled").is_err()); + assert!(serde_json::from_str::("\"cancelled\"").is_err()); + assert!(serde_json::from_str::("\"cancelled\"").is_err()); + } + + #[test] + fn neutral_request_round_trip_does_not_infer_authority_or_replace_profile() { + for profile in ["builtin:backend-job", "builtin:job", "project:custom-job"] { + for key in [None, Some("resource:7".to_string())] { + let mut neutral = request().to_job_request(); + neutral.profile = profile.into(); + neutral.serialization_key = key; + neutral.limits = job::JobLimits { + max_concurrent_jobs: 3, + timeout_seconds: 17, + max_result_bytes: 777, + max_attempts: 1, + }; + let backend = BackendJobRequest::from_job_request(neutral.clone()); + backend.validate().unwrap(); + assert_eq!(backend.to_job_request(), neutral); + assert!(backend.grants.is_empty()); + assert!(backend.source_worker.is_none()); + assert!(backend.notification_target.is_none()); + let encoded = serde_json::to_value(&backend).unwrap(); + for field in ["grants", "source_worker", "notification_target"] { + assert!(encoded.get(field).is_none()); + } + assert_eq!(encoded["profile"], profile); + } + } + // Conversion is not validation and must not normalize an invalid request. + let mut neutral = request().to_job_request(); + neutral.limits.timeout_seconds = 0; + neutral.profile = "inherit".into(); + let backend = BackendJobRequest::from_job_request(neutral.clone()); + assert_eq!(backend.to_job_request(), neutral); + assert!(backend.validate().is_err()); + } + + #[test] + fn reservation_and_acceptance_outcomes_expose_durable_job_and_attempt_not_host_lifecycle() { + let mut reservation = reservation(); + let encoded_before = serde_json::to_value(&reservation).unwrap(); + assert_eq!( + reservation.outcome(), + job::JobOutcome { + job_id: reservation.job.request.job_id.clone(), + input_revision: "7".into(), + attempt_id: "ticket-check:T-1:r7:attempt:2".into(), + attempt: 2, + state: job::JobState::Pending, + attempt_state: job::JobAttemptState::Reserved, + result: None, + failure_category: None, + failure_detail: None, + } + ); + assert_eq!(serde_json::to_value(&reservation).unwrap(), encoded_before); + for (state, attempt_state) in [ + ( + BackendJobState::Pending, + BackendJobAttemptState::Dispatching, + ), + (BackendJobState::Pending, BackendJobAttemptState::Dispatched), + (BackendJobState::Failed, BackendJobAttemptState::Failed), + (BackendJobState::Unknown, BackendJobAttemptState::Unknown), + ] { + reservation.job.state = state; + reservation.attempt.state = attempt_state; + reservation.job.failure_category = Some("job-category".into()); + reservation.job.failure_detail = Some("job-detail".into()); + reservation.attempt.failure_category = Some("attempt-category".into()); + let outcome = reservation.outcome(); + assert_eq!(outcome.state, state.into()); + assert_eq!(outcome.attempt_state, attempt_state.into()); + assert_eq!(outcome.failure_category.as_deref(), Some("job-category")); + assert_eq!(outcome.failure_detail.as_deref(), Some("job-detail")); + assert!(outcome.result.is_none()); + let accepted = BackendJobResultAcceptance { + job: reservation.job.clone(), + attempt: reservation.attempt.clone(), + replayed: true, + }; + assert_eq!(accepted.outcome(), outcome); + } + reservation.job.state = BackendJobState::Completed; + reservation.attempt.state = BackendJobAttemptState::Completed; + reservation.job.result = Some(serde_json::json!({"ok": true})); + reservation.job.failure_category = None; + reservation.job.failure_detail = None; + reservation.attempt.worker_cleanup_state = Some(BackendJobWorkerCleanupState::Failed); + reservation.attempt.worker_cleanup_failure_detail = + Some("cleanup is not model failure".into()); + reservation.replayed = true; + reservation.resource_reused = true; + let acceptance = BackendJobResultAcceptance { + job: reservation.job.clone(), + attempt: reservation.attempt.clone(), + replayed: false, + }; + let outcome = acceptance.outcome(); + assert_eq!(outcome, reservation.outcome()); + assert_eq!(outcome.state, job::JobState::Completed); + assert_eq!(outcome.result, Some(serde_json::json!({"ok": true}))); + assert!(outcome.failure_category.is_none()); + assert!(outcome.failure_detail.is_none()); + let encoded = serde_json::to_value(outcome).unwrap(); + assert_eq!(encoded.as_object().unwrap().len(), 9); + for field in [ + "workspace_id", + "worker", + "runtime_run_id", + "request", + "replayed", + "resource_reused", + "worker_cleanup_state", + ] { + assert!(encoded.get(field).is_none()); + } + } + + #[test] + fn extraction_preserves_backend_wire_fingerprint_and_worker_envelope() { + let request = request(); + let encoded = serde_json::to_string(&request).unwrap(); + assert_eq!( + encoded, + r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_revision":"7","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"# + ); + assert_eq!( + request.fingerprint().unwrap(), + "sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60" + ); + let neutral = request.to_job_request(); + neutral.validate().unwrap(); + assert_ne!( + request.fingerprint().unwrap(), + neutral.fingerprint().unwrap() + ); + assert_eq!( + serde_json::to_value(&request.limits).unwrap(), + serde_json::to_value(&neutral.limits).unwrap() + ); + assert_eq!( + request.worker_input("attempt-1").unwrap(), + "Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_revision: 7\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority." + ); + } + + #[test] + fn shared_result_contract_preserves_backend_wire_and_errors() { + let submission = BackendJobResultSubmission { + job_id: "job-1".into(), + attempt_id: "job-1:attempt:1".into(), + input_revision: "7".into(), + result: serde_json::json!({"ok": true}), + }; + let encoded = serde_json::to_string(&submission).unwrap(); + assert_eq!( + encoded, + r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"# + ); + let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap(); + assert_eq!(neutral, submission); + assert_eq!( + result_digest(&submission.result, 128).unwrap(), + job::result_digest(&submission.result, 128).unwrap() + ); + assert!( + matches!(result_digest(&submission.result, 4), Err(Error::InvalidInput(detail)) if detail == "Backend Job result exceeds 4 bytes") + ); + let mut request = request(); + request.job_id = "".into(); + assert!( + matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job job_id must be non-empty and trimmed") + ); + request.job_id = "job-1".into(); + request.limits.timeout_seconds = 0; + assert!( + matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job timeout_seconds must be in 1..=600") + ); + // Preserve domain-before-input/limits error ordering as well. + request.grants.subjektiv_consolidation = Some(SubjektivConsolidationGrant { + subject_id: "".into(), + candidate_ids: vec![], + }); + assert!( + matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job grant subject_id must be non-empty and trimmed") + ); + } + #[test] fn fingerprint_covers_revision_profile_limits_and_notification_provenance() { let request = request(); diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 2a127bd9c..19d566416 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -140,7 +140,11 @@ pub(crate) fn validate_workspace_config_candidate_projections( }; crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?; if has_provider("builtin:profile") { - crate::profile_settings::project_profiles_from_workspace_config(workspace_id, &state)?; + crate::profile_settings::project_profiles_from_evaluation( + workspace_id, + &state, + &candidate.evaluation, + )?; } if has_provider("builtin:runtime") { crate::runtime_settings::project_runtime_from_workspace_config(workspace_id, &state)?; 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/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 5c38a099b..d88cb9bb6 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; -use std::path::{Component, Path, PathBuf}; +use std::path::Path; -use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; +use config_source::ConfigSchemaContribution; use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value}; use serde::Deserialize; use server_api::{ @@ -21,24 +21,19 @@ use crate::config_source::{ use crate::store::WorkspaceRecord; use crate::{Error, Result}; -const PROFILE_SCHEMA_SOURCE: &str = r#"{ - profile = { - default_profile = String default "builtin:companion"; - entries = [...{ - selector = String; - source = String; - label = String default ""; - description = String default ""; - }] default []; - }; -}"#; +const PROFILE_SCHEMA_SOURCE: &str = include_str!("../../../resources/config-schema/profile.dcdl"); #[derive(Debug, Default)] pub struct ProfileConfigSchemaProvider; impl WorkspaceConfigSchemaProvider for ProfileConfigSchemaProvider { fn contribution(&self) -> Result { - ConfigSchemaContribution::new("builtin:profile", "profile", "1", PROFILE_SCHEMA_SOURCE) + ConfigSchemaContribution::new("builtin:profile", "profile", "2", PROFILE_SCHEMA_SOURCE) + .map(|schema| { + schema.with_authoring_source(include_str!( + "../../../resources/config-schema/profile-authoring.dcdl" + )) + }) .map_err(|error| Error::Config(error.to_string())) } } @@ -59,7 +54,7 @@ struct VirtualProfileSection { #[serde(deny_unknown_fields)] struct VirtualProfileEntry { selector: String, - source: String, + profile: serde_json::Value, label: String, description: String, } @@ -68,7 +63,6 @@ struct VirtualProfileEntry { pub struct ProfileConfigProjection { pub settings: ProfileSettingsResponse, entries: BTreeMap, - sources: BTreeMap, } pub fn project_profiles_from_workspace_config( @@ -96,23 +90,42 @@ pub fn project_profiles_from_workspace_config( "Profile projection digest mismatch for Workspace {workspace_id}" ))); } + project_profiles_from_evaluation(workspace_id, state, &evaluation) +} + +pub(crate) fn project_profiles_from_evaluation( + workspace_id: &str, + state: &WorkspaceConfigState, + evaluation: &config_source::EvaluationResult, +) -> Result { + if evaluation.projection_digest != state.projection_digest { + return Err(Error::Config( + "Profile evaluation does not match Workspace config state".into(), + )); + } let projected = evaluation.projections.first().ok_or_else(|| { Error::RegistryInconsistency("Workspace config has no active projection".to_string()) })?; + if projected + .data_json + .pointer("/profile/entries") + .and_then(serde_json::Value::as_array) + .is_some_and(|entries| entries.iter().any(|entry| entry.get("source").is_some())) + { + return Err(profile_validation_error( + "profile_source_registration_removed", + "profile.entries.source is no longer supported: replace source = \"profiles/name.dcdl\" with profile = import \"./profiles/name.dcdl\" (relative to the containing config file). Saved config is not rewritten.", + )); + } let config: VirtualProfileConfig = serde_json::from_value(projected.data_json.clone()) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; let mut profiles = builtin_profile_summaries(Some(&config.profile.default_profile)); let mut entries = BTreeMap::new(); - let sources = state - .snapshot - .entries - .iter() - .filter(|(_, entry)| entry.content_type == ConfigContentType::Decodal) - .map(|(path, entry)| (path.as_str().to_string(), entry.content.clone())) - .collect::>(); let mut source_summaries = Vec::new(); for entry in config.profile.entries { - if !entry.selector.starts_with("project:") { + if !entry.selector.starts_with("project:") + || entry.selector.trim_start_matches("project:").is_empty() + { return Err(profile_validation_error( "profile_selector_invalid", "Workspace Profile selectors must use project:*", @@ -124,61 +137,43 @@ pub fn project_profiles_from_workspace_config( "Workspace Profile selectors must be unique", )); } - let source_path = VirtualPath::parse(&entry.source).map_err(|error| { - profile_validation_error("profile_source_path_invalid", &error.to_string()) - })?; - let source_entry = state.snapshot.get(&source_path).ok_or_else(|| { - profile_validation_error( - "profile_source_missing", - &format!( - "Profile source {:?} is missing from the active config revision", - entry.source - ), - ) - })?; - if source_entry.content_type != ConfigContentType::Decodal { - return Err(profile_validation_error( - "profile_source_type_invalid", - "Profile sources must use Decodal content", - )); - } - let archive_source = entry.source.clone(); let label = if entry.label.is_empty() { entry.selector.trim_start_matches("project:").to_string() } else { entry.label.clone() }; resolve_profile_artifact_value( - evaluate_profile_source(&state.snapshot, &source_path)?, + entry.profile.clone(), ProfileSource::Archive { archive_id: format!("workspace-config-r{}", state.snapshot.revision), - source: archive_source.clone(), + source: entry.selector.clone(), }, Path::new("/"), "workspace-config-validation", ) - .map_err(|error| profile_validation_error("profile_source_invalid", &error.to_string()))?; + .map_err(|error| profile_validation_error("profile_value_invalid", &error.to_string()))?; profiles.push(WorkspaceProfileSummary { profile_id: entry.selector.clone(), selector: entry.selector.clone(), label, source_kind: "project".to_string(), - profile_source_id: Some(entry.source.clone()), + profile_source_id: Some(entry.selector.clone()), description: (!entry.description.is_empty()).then(|| entry.description.clone()), editable: false, is_default: config.profile.default_profile == entry.selector, diagnostics: Vec::new(), }); + let value_bytes = serde_json::to_vec(&entry.profile).expect("Profile value serializes"); source_summaries.push(WorkspaceProfileSourceSummary { - profile_source_id: entry.source.clone(), - display_path: entry.source.clone(), - kind: "virtual_config".to_string(), - content_type: "decodal".to_string(), - content_digest: source_entry.content_digest.clone(), + profile_source_id: entry.selector.clone(), + display_path: format!("profile.entries[{}].profile", source_summaries.len()), + kind: "evaluated_config".to_string(), + content_type: "application/json".to_string(), + content_digest: config_source::digest_bytes(&value_bytes), provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, editable: false, revision: state.snapshot.revision.to_string(), - size_bytes: source_entry.content.len() as u64, + size_bytes: value_bytes.len() as u64, diagnostics: Vec::new(), }); entries.insert(entry.selector.clone(), entry); @@ -198,46 +193,16 @@ pub fn project_profiles_from_workspace_config( registry_revision: format!("config:{}", state.snapshot.revision), config_revision: Some(state.snapshot.revision), tree_digest: Some(state.snapshot.digest.clone()), - projection_digest: Some(evaluation.projection_digest), + projection_digest: Some(evaluation.projection_digest.clone()), default_profile: Some(config.profile.default_profile), profiles, sources: source_summaries, diagnostics: Vec::new(), }, entries, - sources, }) } -fn evaluate_profile_source( - snapshot: &config_source::ConfigTreeSnapshot, - source_path: &VirtualPath, -) -> Result { - let contract = config_source::ToolchainContract::with_schema_bundle( - config_source::DEFAULT_SCHEMA_VERSION, - vec![source_path.clone()], - config_source::DEFAULT_IMPORT_POLICY_VERSION, - config_source::WorkspaceConfigSchemaBundle::empty(), - ); - let evaluation = config_source::SnapshotEnvironment::new(snapshot.clone()) - .evaluate_contract(&contract) - .map_err(|diagnostics| { - profile_validation_error( - "profile_source_invalid", - &serde_json::to_string(&diagnostics) - .unwrap_or_else(|_| "Profile source evaluation failed".to_string()), - ) - })?; - evaluation - .projections - .into_iter() - .next() - .map(|projection| projection.data_json) - .ok_or_else(|| { - profile_validation_error("profile_source_invalid", "Profile source has no projection") - }) -} - pub fn selector_for_workspace_candidate( projection: &ProfileConfigProjection, profile: &str, @@ -420,6 +385,16 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection( selector: &str, prompt_projection: &worker::WorkspacePromptProjection, ) -> Result> { + if projection.settings.workspace_id != workspace_id + || projection.settings.config_revision != Some(state.snapshot.revision) + || projection.settings.tree_digest.as_deref() != Some(state.snapshot.digest.as_str()) + || projection.settings.projection_digest.as_deref() + != Some(state.projection_digest.as_str()) + { + return Err(Error::Config( + "Profile projection does not match Workspace config state".into(), + )); + } validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?; let prompt_catalog = prompt_projection.catalog().clone(); let profile_selector = @@ -429,12 +404,7 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection( )) })?; let archive = match projection.entries.get(selector) { - Some(entry) => Some(build_virtual_profile_archive( - selector, - entry, - &projection.sources, - state, - )?), + Some(entry) => Some(build_virtual_profile_archive(selector, entry, state)?), None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?), }; let bundle_id = virtual_profile_bundle_id( @@ -492,19 +462,24 @@ pub(crate) fn resolve_profile_manifest_from_config_bundle( fn build_virtual_profile_archive( selector: &str, entry: &VirtualProfileEntry, - all_sources: &BTreeMap, state: &WorkspaceConfigState, ) -> Result { - let mut closure = BTreeMap::new(); - let mut imports = BTreeMap::new(); - collect_profile_import_closure(&entry.source, all_sources, &mut closure, &mut imports)?; - ProfileSourceArchive::build(ProfileSourceArchiveInput { - id: format!("workspace-config-profile-r{}", state.snapshot.revision), - entrypoints: BTreeMap::from([(selector.to_string(), entry.source.clone())]), - imports, - sources: closure, - }) - .map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string())) + let builtin_sources = config_source::SnapshotEnvironment::new(state.snapshot.clone()) + .builtin_import_sources(&state.contract) + .map_err(|diagnostics| { + profile_validation_error( + "profile_builtin_snapshot_invalid", + &serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "builtin import snapshot failed".to_string()), + ) + })?; + ProfileSourceArchive::build_evaluated_profile_with_builtin_sources( + format!("workspace-config-profile-r{}", state.snapshot.revision), + selector.to_string(), + entry.profile.clone(), + builtin_sources, + ) + .map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string())) } pub fn workspace_metadata_settings( @@ -576,124 +551,6 @@ fn profile_validation_error(code: impl Into, message: impl Into) } } -fn collect_profile_import_closure( - current_path: &str, - all_sources: &BTreeMap, - closure_sources: &mut BTreeMap, - imports: &mut BTreeMap, -) -> Result<()> { - if closure_sources.contains_key(current_path) { - return Ok(()); - } - let content = all_sources.get(current_path).ok_or_else(|| { - profile_validation_error( - "profile_source_import_missing", - &format!("Profile source import closure is missing {current_path}"), - ) - })?; - closure_sources.insert(current_path.to_string(), content.clone()); - for specifier in collect_decodal_import_specifiers(content) { - let target = resolve_profile_source_import(current_path, &specifier)?; - if !all_sources.contains_key(&target) { - return Err(profile_validation_error( - "profile_source_import_missing", - &format!( - "Profile source import {specifier:?} from {current_path} resolves to missing {target}" - ), - )); - } - imports.insert(format!("{current_path}\0{specifier}"), target.clone()); - collect_profile_import_closure(&target, all_sources, closure_sources, imports)?; - } - Ok(()) -} - -fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result { - if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() { - return Err(profile_validation_error( - "profile_source_import_invalid", - "Profile source import must be a virtual relative path", - )); - } - let raw = specifier - .strip_prefix("project:") - .or_else(|| specifier.strip_prefix("workspace:")) - .unwrap_or(specifier); - if specifier.contains(':') && raw == specifier { - return Err(profile_validation_error( - "profile_source_import_invalid", - "Unsupported profile source import namespace", - )); - } - let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") { - PathBuf::from(raw) - } else { - Path::new(current_path) - .parent() - .map(Path::to_path_buf) - .unwrap_or_default() - .join(raw) - }; - let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?; - Ok(normalized) -} - -fn normalize_virtual_profile_source_path(path: &str) -> Result { - let mut normalized = PathBuf::new(); - for component in Path::new(path).components() { - match component { - Component::CurDir => {} - Component::Normal(value) => normalized.push(value), - Component::ParentDir => { - if !normalized.pop() { - return Err(profile_validation_error( - "profile_source_import_invalid", - "Profile source import escapes the virtual tree", - )); - } - } - Component::RootDir | Component::Prefix(_) => { - return Err(profile_validation_error( - "profile_source_import_invalid", - "Profile source import must be relative", - )); - } - } - } - Ok(normalized.to_string_lossy().replace('\\', "/")) -} - -fn collect_decodal_import_specifiers(content: &str) -> Vec { - let mut specifiers = Vec::new(); - for line in content.lines() { - let trimmed = line.trim_start(); - if trimmed.starts_with("//") || trimmed.starts_with('#') { - continue; - } - let Some(index) = trimmed.find("import") else { - continue; - }; - let after = trimmed[index + "import".len()..].trim_start(); - let Some(first) = after.chars().next() else { - continue; - }; - if first == '"' || first == '\'' { - if let Some(end) = after[1..].find(first) { - specifiers.push(after[1..1 + end].to_string()); - } - } else { - let ident: String = after - .chars() - .take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{') - .collect(); - if !ident.is_empty() { - specifiers.push(ident); - } - } - } - specifiers -} - pub fn selector_for_builtin_candidate( id: &str, ) -> Option { @@ -711,6 +568,7 @@ pub fn selector_for_builtin_candidate( #[cfg(test)] mod tests { use super::*; + use config_source::{ConfigContentType, VirtualPath}; #[test] fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() { @@ -779,7 +637,7 @@ mod tests { config_source::ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, - r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#, + r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; label = "Alpha"; }]; }; }"#, ) .unwrap(), config_source::ConfigEntry::new( @@ -819,18 +677,18 @@ mod tests { .entrypoints .get("project:alpha") .map(String::as_str), - Some("profiles/alpha.dcdl") + Some("profiles/evaluated.json") ); assert_eq!(archive.reference.source_graph.source_count, 1); } #[test] - fn virtual_config_projection_preserves_import_closure() { + fn virtual_config_projection_materializes_import_closure() { let state = virtual_state(vec![ config_source::ConfigEntry::new( VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, - r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#, + r#"{ profile = { entries = [{ selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }]; }; }"#, ) .unwrap(), config_source::ConfigEntry::new( @@ -857,8 +715,8 @@ mod tests { .unwrap() .unwrap(); let archive = bundle.profile_source_archive.unwrap(); - assert_eq!(archive.reference.source_graph.source_count, 2); - assert_eq!(archive.reference.source_graph.import_count, 1); + assert_eq!(archive.reference.source_graph.source_count, 1); + assert_eq!(archive.reference.source_graph.import_count, 0); } #[test] @@ -955,8 +813,8 @@ mod tests { VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, r#"{ profile = { entries = [ - { selector = "project:alpha"; source = "profiles/alpha.dcdl"; }, - { selector = "project:beta"; source = "profiles/beta.dcdl"; }, + { selector = "project:alpha"; profile = import "./profiles/alpha.dcdl"; }, + { selector = "project:beta"; profile = import "./profiles/beta.dcdl"; }, ]; }; }"#, ) .unwrap(), @@ -1002,20 +860,83 @@ mod tests { } #[test] - fn virtual_config_projection_rejects_missing_profile_source() { - let state = virtual_state(vec![ - config_source::ConfigEntry::new( - VirtualPath::parse("main.dcdl").unwrap(), - ConfigContentType::Decodal, - r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/missing.dcdl"; }]; }; }"#, - ) - .unwrap(), - ]); - let error = project_profiles_from_workspace_config("workspace-test", &state).unwrap_err(); - assert!( - error - .to_string() - .contains("missing from the active config revision") + fn value_profile_authoring_uses_current_provider_schema_without_inserting_defaults() { + let bundle = + config_source::WorkspaceConfigSchemaBundle::compose([ProfileConfigSchemaProvider + .contribution() + .unwrap()]) + .unwrap(); + let snapshot = config_source::ConfigTreeSnapshot::from_entries(1, [config_source::ConfigEntry::new( + VirtualPath::parse("main.dcdl").unwrap(), ConfigContentType::Decodal, + r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema"#, + ).unwrap()]).unwrap(); + let environment = config_source::SnapshotEnvironment::new(snapshot.clone()) + .with_schema_bundle(bundle.clone()); + for (body, token, label) in [ + ( + "{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema", + "sel", + "selector", + ), + ( + "{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema", + "pro", + "profile", + ), + ( + "{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema", + "wor", + "worker", + ), + ( + "{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema", + "mo", + "mode", + ), + ( + "{ profile = { entries = [{ profile = { feature = { ti } } }] } } as WorkspaceConfigSchema", + "ti", + "ticket", + ), + ] { + let offset = body.rfind(token).unwrap() + token.len(); + let completion = environment + .complete_config( + &VirtualPath::parse("main.dcdl").unwrap(), + body, + offset, + true, + ) + .unwrap() + .unwrap(); + assert!( + completion.items.iter().any(|item| item.label == label), + "{label}: {completion:?}" + ); + } + for invalid in [ + r#"{ profile = { entries = [{ selector = 42; profile = {}; }]; }; } as WorkspaceConfigSchema"#, + r#"{ profile = { entries = [{ selector = "project:alpha"; profile = 42; }]; }; } as WorkspaceConfigSchema"#, + ] { + assert!( + !environment + .analyze(&VirtualPath::parse("main.dcdl").unwrap(), Some(invalid)) + .is_empty() + ); + } + let evaluation = environment + .evaluate_contract(&config_source::ToolchainContract::with_schema_bundle( + config_source::DEFAULT_SCHEMA_VERSION, + vec![VirtualPath::parse("main.dcdl").unwrap()], + config_source::DEFAULT_IMPORT_POLICY_VERSION, + bundle, + )) + .unwrap(); + assert_eq!( + evaluation.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&serde_json::json!({})) ); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a610b777c..445a7566d 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; @@ -12403,6 +12404,7 @@ fn config_schema_bundle_to_api( namespace: contribution.namespace, version: contribution.version, source: contribution.source, + authoring_source: contribution.authoring_source, projection_validator: contribution .projection_validator .map(config_projection_validator_to_api), @@ -18193,36 +18195,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; @@ -19076,7 +19048,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()), ); } } @@ -19280,7 +19252,7 @@ fn subjektiv_session_read( Err(error) => { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.error.to_string(), + error.to_string(), ); } } @@ -19733,7 +19705,7 @@ fn subjektiv_session_search( Err(error) => { return subjektiv_session_error( server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.error.to_string(), + error.to_string(), ); } } @@ -20097,52 +20069,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, @@ -20153,1520 +20079,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 { @@ -21715,24 +20232,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)] @@ -21763,7 +20274,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 @@ -33551,6 +32062,7 @@ mod tests { mod auth_logging_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; + mod value_profiles_tests; mod worker_operations_tests; include!("server_workspace_config_tests.rs"); use super::*; @@ -41801,7 +40313,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( @@ -41878,8 +40390,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( @@ -41924,7 +40439,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, @@ -42304,12 +40822,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, @@ -60555,3 +59079,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/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index 78c191a4d..6905bfe58 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -454,7 +454,7 @@ async fn backend_job_project_recipe_is_resolved_and_delivered_without_system_ove entrypoints: state.contract.entrypoints.iter().map(|p| p.as_str().into()).collect(), changes: vec![server_api::ConfigTreeChange::Update { path: entrypoint.as_str().into(), expected_digest: root.content_digest.clone(), - content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; source = \"profiles/job-recipe.dcdl\"; }]; }; }".into(), + content: "{ profile = { entries = [{ selector = \"project:job-recipe\"; profile = import \"./profiles/job-recipe.dcdl\"; }]; }; }".into(), }, server_api::ConfigTreeChange::Create { path: "profiles/job-recipe.dcdl".into(), content_type: server_api::ConfigContentType::Decodal, content: "{ slug = \"job-recipe\"; scope = \"workspace_read\"; model = { id = \"fixture-model\"; }; engine = { instruction = \"role.default\"; }; feature = { task = { enabled = true; }; }; }".into(), diff --git a/crates/workspace-server/src/server/tests/value_profiles_tests.rs b/crates/workspace-server/src/server/tests/value_profiles_tests.rs new file mode 100644 index 000000000..8a4a7d307 --- /dev/null +++ b/crates/workspace-server/src/server/tests/value_profiles_tests.rs @@ -0,0 +1,859 @@ +use super::*; +use crate::profile_settings::{ + build_virtual_profile_config_bundle, project_profiles_from_workspace_config, +}; + +const PROFILE: &str = r#"{ + slug = "alpha"; + worker = { mode = "wip"; }; + model = { ref = "codex-oauth/gpt-5.6-sol"; }; + feature = { task = { enabled = true; }; ticket = { enabled = false; }; }; +}"#; + +fn request( + api: &WorkspaceApi, + source: &str, + extra: Vec, +) -> ConfigCommitRequest { + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let main = state + .snapshot + .get(&config_source::VirtualPath::parse("main.dcdl").unwrap()) + .unwrap(); + let mut changes = vec![config_source::ConfigTreeChange::Update { + path: main.path.clone(), + expected_digest: main.content_digest.clone(), + content: source.into(), + }]; + changes.extend(extra); + ConfigCommitRequest { + base_revision: state.snapshot.revision, + base_digest: state.snapshot.digest, + entrypoints: state.contract.entrypoints, + changes, + } +} + +fn source(value: &str) -> String { + format!( + r#"{{ profile = {{ default_profile = "project:alpha"; entries = [{{ selector = "project:alpha"; label = "Alpha"; description = "Value recipe"; profile = {value}; }}]; }}; }}"# + ) +} + +#[tokio::test] +async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let app = build_router(api.clone()); + let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); + let commit = format!("{tree}/commit"); + let initial = get_json_authenticated(app.clone(), &tree, &token).await; + let saved = request_json_authenticated( + app.clone(), + "POST", + &commit, + Some( + serde_json::to_value(request( + &api, + &source(r#"import "$builtin/profiles/companion.dcdl""#), + vec![config_source::ConfigTreeChange::Create { + // An ordinary Workspace file at the matching suffix must not rescue + // an unknown builtin import. + path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: PROFILE.into(), + }], + )) + .unwrap(), + ), + &token, + StatusCode::CREATED, + ) + .await; + assert_eq!( + saved["snapshot"]["revision"].as_u64().unwrap(), + initial["snapshot"]["revision"].as_u64().unwrap() + 1 + ); + let listed = get_json_authenticated(app.clone(), &tree, &token).await; + assert_eq!(listed, saved); + let entries = listed["snapshot"]["entries"].as_object().unwrap(); + assert!(entries.contains_key("profiles/private.dcdl")); + assert!( + entries + .keys() + .all(|key| key != "$builtin" && !key.starts_with("$builtin/")) + ); + let shadow = get_json_authenticated( + app.clone(), + &format!("{tree}/entries/profiles%2Fprivate.dcdl"), + &token, + ) + .await; + assert_eq!(shadow, entries["profiles/private.dcdl"]); + assert!( + shadow["content"] + .as_str() + .unwrap() + .contains("slug = \"alpha\"") + ); + let active = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap(); + let base = json!({ + "base_revision": listed["snapshot"]["revision"], + "base_digest": listed["snapshot"]["digest"], + "entrypoints": listed["contract"]["entrypoints"], + }); + let reserved = "$builtin/profiles/companion.dcdl"; + for change in [ + json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }), + json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }), + json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }), + json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }), + json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }), + json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }), + ] { + let mut body = base.clone(); + body["changes"] = json!([change]); + let error = request_json_authenticated( + app.clone(), + "POST", + &commit, + Some(body), + &token, + StatusCode::BAD_REQUEST, + ) + .await; + assert!( + error["message"] + .as_str() + .unwrap() + .contains("builtin namespace is read-only"), + "{error}" + ); + assert_eq!( + get_json_authenticated(app.clone(), &tree, &token).await, + listed + ); + } + let mut body = base; + body["changes"] = json!([{ + "kind": "update", "path": "main.dcdl", "expected_digest": main_digest, + "content": source(r#"import "$builtin/profiles/private.dcdl""#), + }]); + let error = request_json_authenticated( + app.clone(), + "POST", + &commit, + Some(body), + &token, + StatusCode::BAD_REQUEST, + ) + .await; + let message = error["message"].as_str().unwrap(); + assert!( + message.contains( + "unknown or non-public read-only builtin source: $builtin/profiles/private.dcdl" + ), + "{error}" + ); + assert!(message.contains("\"kind\":\"import\""), "{error}"); + assert_eq!(get_json_authenticated(app, &tree, &token).await, listed); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + active + ); +} + +#[tokio::test] +async fn value_profiles_builtin_companion_composition_seals_observed_sources() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let recipe = r#"(import "$builtin/profiles/companion.dcdl") // { + worker = { mode = "wip"; }; + feature = { + task = { enabled = true; }; + ticket = { enabled = false; }; + workspace_config = { enabled = true; }; + }; + }"#; + let state = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request( + &api, + &source(r#"import "./recipes/custom.dcdl""#), + vec![ + config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("recipes/custom.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: recipe.into(), + }, + // Builtin relative imports must not be shadowed by Workspace files. + config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("profiles/base.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: "{ slug = \"workspace-shadow\"; }".into(), + }, + ], + ), + ) + .unwrap(); + let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + TEST_WORKSPACE_ID, + "created", + "project:alpha", + ) + .unwrap() + .unwrap(); + assert_eq!(bundle.metadata.digest, bundle.computed_digest()); + let archive = bundle.profile_source_archive.as_ref().unwrap(); + let verified = archive.verify().unwrap(); + let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone()); + let builtin_sources = environment.builtin_import_sources(&state.contract).unwrap(); + assert_eq!( + builtin_sources + .keys() + .map(String::as_str) + .collect::>(), + vec![ + "$builtin/profiles/base.dcdl", + "$builtin/profiles/companion.dcdl", + ] + ); + assert_eq!( + archive.reference.source_graph.source_count, + builtin_sources.len() + 1 + ); + assert_eq!(archive.reference.source_graph.import_count, 0); + assert_eq!( + verified.manifest().entrypoints["project:alpha"], + "profiles/evaluated.json" + ); + for (key, bytes) in &builtin_sources { + let source = verified + .manifest() + .sources + .iter() + .find(|source| &source.source_key == key) + .unwrap(); + assert_eq!(source.path, *key); + assert_eq!(source.kind, "decodal"); + assert_eq!( + source.digest, + worker_runtime::profile_archive::sha256_hex(bytes.as_bytes()) + ); + assert_eq!(source.size_bytes, bytes.len() as u64); + } + assert!( + verified + .manifest() + .sources + .iter() + .any(|source| source.path == "profiles/evaluated.json" && source.kind == "json") + ); + // Resolve using the Runtime archive consumer, without access to the config tree. + let resolved = verified + .resolve_profile( + "project:alpha", + Path::new("/runtime-worker"), + "builtin-value-worker", + ) + .unwrap(); + assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip); + assert!(resolved.feature.task.enabled); + assert!(!resolved.feature.ticket.enabled); + assert!(resolved.feature.workspace_config.enabled); + let evaluation = environment.evaluate_contract(&state.contract).unwrap(); + assert_eq!( + resolved.model.ref_.as_deref(), + evaluation.projections[0] + .data_json + .pointer("/profile/entries/0/profile/model/ref") + .and_then(serde_json::Value::as_str) + ); + let persisted = manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap(); + let old_bundle_digest = bundle.metadata.digest.clone(); + + let updated = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request(&api, &source(PROFILE), vec![]), + ) + .unwrap(); + let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &updated).unwrap(); + let updated_bundle = build_virtual_profile_config_bundle( + &projection, + &updated, + TEST_WORKSPACE_ID, + "created", + "project:alpha", + ) + .unwrap() + .unwrap(); + assert_ne!(old_bundle_digest, updated_bundle.metadata.digest); + assert_eq!( + updated_bundle + .profile_source_archive + .as_ref() + .unwrap() + .reference + .source_graph + .source_count, + 1 + ); + // Editing config changes future snapshots, not a sealed bundle or persisted Worker. + assert_eq!(bundle.metadata.digest, old_bundle_digest); + assert_eq!( + serde_json::to_value(manifest::read_persisted_worker_manifest_snapshot(persisted).unwrap()) + .unwrap(), + serde_json::to_value(resolved).unwrap() + ); +} + +#[tokio::test] +async fn value_profiles_save_project_and_runtime_consume_the_same_revision() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let forms = [ + PROFILE.to_string(), + r#"import "./recipes/alpha.dcdl""#.into(), + r#"(import "./recipes/base.dcdl") // { worker = { mode = "wip"; }; feature.task.enabled = true; }"#.into(), + ]; + let mut manifests = Vec::new(); + let mut saved_manifest = None; + for (index, form) in forms.iter().enumerate() { + let extra = if index == 0 { + vec![ + config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: PROFILE.into(), + }, + config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("recipes/base.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: PROFILE + .replace("wip", "tools") + .replace("enabled = true", "enabled = false"), + }, + ] + } else { + vec![] + }; + let state = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request(&api, &source(form), extra), + ) + .unwrap(); + let loaded = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + assert_eq!(loaded, state); + // The Browser receives the same fingerprint-bound authoring metadata through REST. + let wire = workspace_config_state_to_api(loaded.clone()); + let decoded: config_source::WorkspaceConfigSchemaBundle = + serde_json::from_value(serde_json::to_value(wire.contract.schema_bundle).unwrap()) + .unwrap(); + decoded.validate().unwrap(); + assert_eq!(decoded, loaded.contract.schema_bundle); + assert!( + decoded + .contributions + .iter() + .find(|entry| entry.provider_id == "builtin:profile") + .unwrap() + .authoring_source + .is_some() + ); + let projection = + project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &loaded).unwrap(); + assert_eq!( + projection.settings.default_profile.as_deref(), + Some("project:alpha") + ); + assert_eq!( + projection.settings.config_revision, + Some(state.snapshot.revision) + ); + let selected = projection + .settings + .profiles + .iter() + .find(|entry| entry.selector == "project:alpha") + .unwrap(); + assert_eq!(selected.label, "Alpha"); + assert_eq!(selected.description.as_deref(), Some("Value recipe")); + assert!(selected.is_default); + assert_eq!(selected.profile_source_id.as_deref(), Some("project:alpha")); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + TEST_WORKSPACE_ID, + "2026-01-01T00:00:00Z", + "project:alpha", + ) + .unwrap() + .unwrap(); + assert_eq!(bundle.metadata.digest, bundle.computed_digest()); + assert_eq!( + bundle.metadata.revision, + state.snapshot.revision.to_string() + ); + let archive = bundle.profile_source_archive.as_ref().unwrap(); + let verified = archive.verify().unwrap(); + // This is the Runtime's archive consumer. It needs neither the Workspace tree nor imports. + let resolved = verified + .resolve_profile( + "project:alpha", + Path::new("/runtime-worker"), + "value-worker", + ) + .unwrap(); + assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip); + assert!(resolved.feature.task.enabled); + assert!(!resolved.feature.ticket.enabled); + assert!(!resolved.feature.ticket.authoring); + assert!(!resolved.feature.sub_worker.enabled); + assert_eq!(archive.reference.source_graph.import_count, 0); + let evaluation = config_source::SnapshotEnvironment::new(state.snapshot.clone()) + .evaluate_contract(&state.contract) + .unwrap(); + let value = evaluation.projections[0] + .data_json + .pointer("/profile/entries/0/profile") + .unwrap(); + assert!(value.is_object()); + assert!( + value.get("engine").is_none(), + "omission must remain omission in the Profile value" + ); + let mut snapshot = serde_json::to_value(&resolved).unwrap(); + snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only + manifests.push(snapshot); + if index == 0 { + saved_manifest = + Some(manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap()); + } + if index == 2 { + let mut other_state = state.clone(); + other_state.snapshot.revision += 1; + assert!( + build_virtual_profile_config_bundle( + &projection, + &other_state, + TEST_WORKSPACE_ID, + "created", + "project:alpha" + ) + .unwrap_err() + .to_string() + .contains("does not match") + ); + } + } + assert_eq!(manifests[0], manifests[1]); + assert_eq!(manifests[1], manifests[2]); + // Saving a new recipe is not an operation on existing Workers or their persisted Manifest. + let state = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request( + &api, + &source( + &PROFILE + .replace("wip", "tools") + .replace("enabled = true", "enabled = false"), + ), + vec![], + ), + ) + .unwrap(); + let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + TEST_WORKSPACE_ID, + "created", + "project:alpha", + ) + .unwrap() + .unwrap(); + let fresh = crate::profile_settings::resolve_profile_manifest_from_config_bundle( + &bundle, + "project:alpha", + ) + .unwrap(); + assert_eq!(fresh.worker.mode, manifest::WorkerMode::Tools); + assert!(!fresh.feature.task.enabled); + let restored = + manifest::read_persisted_worker_manifest_snapshot(saved_manifest.unwrap()).unwrap(); + assert_eq!(restored.worker.mode, manifest::WorkerMode::Wip); + assert!(restored.feature.task.enabled); +} + +#[tokio::test] +async fn value_profiles_scalar_and_table_scopes_analyze_save_and_resolve_equally() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let scalar = format!( + r#"{PROFILE} // {{ scope = "workspace_read"; delegation_scope = "workspace_write"; }}"# + ); + let table = format!( + r#"{PROFILE} // {{ scope = {{ intent = "workspace_read"; }}; delegation_scope = {{ intent = "workspace_write"; }}; }}"# + ); + let mut scopes = Vec::new(); + for (index, form) in [scalar.clone(), table, r#"import "./scoped.dcdl""#.into()] + .iter() + .enumerate() + { + let extra = if index == 0 { + vec![config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("scoped.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: scalar.clone(), + }] + } else { + vec![] + }; + let authored = format!("{} as WorkspaceConfigSchema", source(form)); + let state = + commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &authored, extra)) + .unwrap(); + let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone()) + .with_schema_bundle(state.contract.schema_bundle.clone()); + assert!( + environment + .analyze( + &config_source::VirtualPath::parse("main.dcdl").unwrap(), + None + ) + .is_empty(), + "{authored}" + ); + let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + TEST_WORKSPACE_ID, + "created", + "project:alpha", + ) + .unwrap() + .unwrap(); + let resolved = bundle + .profile_source_archive + .as_ref() + .unwrap() + .verify() + .unwrap() + .resolve_profile( + "project:alpha", + Path::new("/runtime-worker"), + "scoped-worker", + ) + .unwrap(); + assert_eq!(resolved.scope.allow.len(), 1); + assert_eq!(resolved.delegation_scope.allow.len(), 1); + let scope = serde_json::to_value(&resolved.scope).unwrap(); + let delegation = serde_json::to_value(&resolved.delegation_scope).unwrap(); + assert_eq!(scope["allow"][0]["target"], "/runtime-worker"); + assert_eq!(scope["allow"][0]["permission"], "read"); + assert_eq!(delegation["allow"][0]["target"], "/runtime-worker"); + assert_eq!(delegation["allow"][0]["permission"], "write"); + scopes.push((scope, delegation)); + } + assert_eq!(scopes[0], scopes[1]); + assert_eq!(scopes[1], scopes[2]); +} + +fn profile_fixture_decodal(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Object(fields) => format!( + "{{ {} }}", + fields + .iter() + .map(|(key, value)| format!("{key} = {};", profile_fixture_decodal(value))) + .collect::>() + .join(" ") + ), + serde_json::Value::Array(values) => format!( + "[{}]", + values + .iter() + .map(profile_fixture_decodal) + .collect::>() + .join(", ") + ), + serde_json::Value::Null => panic!("fixture omission must not be represented as null"), + value => value.to_string(), + } +} + +#[tokio::test] +async fn value_profiles_authoring_corpus_preserves_effective_runtime_settings() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../../../resources/config-schema/profile-authoring-values.json" + )) + .unwrap(); + // The canonical token fixture must exercise every serializable setting, + // so adding a field to CompactionConfigPartial cannot silently outgrow the + // editor-shape coverage. Aliases/ratio helpers have separate corpus cases. + let default = serde_json::to_value(manifest::CompactionConfigPartial::default()).unwrap(); + let canonical = cases + .iter() + .find(|case| case["name"] == "canonical_compaction_tokens") + .unwrap(); + assert_eq!( + default + .as_object() + .unwrap() + .keys() + .collect::>(), + canonical["profile"]["compaction"] + .as_object() + .unwrap() + .keys() + .filter(|name| name.as_str() != "kind") + .collect() + ); + for case in cases { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let recipe = profile_fixture_decodal(&case["profile"]); + for (index, (form, patched)) in [ + (recipe.clone(), false), + (r#"import "./recipe.dcdl""#.into(), false), + ( + r#"(import "./recipe.dcdl") // { description = "patched"; }"#.into(), + true, + ), + ] + .iter() + .enumerate() + { + let extra = if index == 0 { + vec![config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("recipe.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: recipe.clone(), + }] + } else { + vec![] + }; + let authored = format!("{} as WorkspaceConfigSchema", source(form)); + let state = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request(&api, &authored, extra), + ) + .unwrap(); + let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone()) + .with_schema_bundle(state.contract.schema_bundle.clone()); + let diagnostics = environment.analyze( + &config_source::VirtualPath::parse("main.dcdl").unwrap(), + None, + ); + assert!(diagnostics.is_empty(), "{}: {diagnostics:?}", case["name"]); + let mut expected = case["profile"].clone(); + if *patched { + expected["description"] = serde_json::json!("patched"); + } + let evaluated = environment.evaluate_contract(&state.contract).unwrap(); + assert_eq!( + evaluated.projections[0] + .data_json + .pointer("/profile/entries/0/profile"), + Some(&expected) + ); + let projection = + project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap(); + let bundle = build_virtual_profile_config_bundle( + &projection, + &state, + TEST_WORKSPACE_ID, + "created", + "project:alpha", + ) + .unwrap() + .unwrap(); + let resolved = bundle + .profile_source_archive + .as_ref() + .unwrap() + .verify() + .unwrap() + .resolve_profile( + "project:alpha", + Path::new("/runtime-worker"), + "corpus-worker", + ) + .unwrap(); + let manifest = serde_json::to_value(resolved).unwrap(); + for (pointer, value) in case["manifest_expect"].as_object().unwrap() { + assert_eq!( + manifest.pointer(pointer), + Some(value), + "{}: {pointer}", + case["name"] + ); + } + } + } +} + +#[tokio::test] +async fn value_profiles_invalid_saves_preserve_the_active_config() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let before = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request(&api, &source(PROFILE), vec![]), + ) + .unwrap(); + let invalid = [ + (source("42"), ""), + (source(r#"{ worker = { mode = "not-a-mode"; }; }"#), "profile_value_invalid"), + (source(r#"{ worker = { name = "runtime-only"; }; }"#), "profile_value_invalid"), + (source(r#"{ scope = 42; }"#), "profile_value_invalid"), + (source(r#"{ scope = "not-an-intent"; }"#), "profile_value_invalid"), + (source(r#"{ delegation_scope = { intent = 42; }; }"#), "profile_value_invalid"), + (source(r#"{ scope = { intent = "workspace_read"; unknown = true; }; }"#), "profile_value_invalid"), + (source(r#"{ scope = { intent = "workspace_read"; deny_write = 42; }; }"#), "profile_value_invalid"), + (source(r#"{ delegation_scope = { intent = "workspace_write"; symlink_policy = 42; }; }"#), "profile_value_invalid"), + (source(r#"import "./missing.dcdl""#), ""), + (source(r#"import "../outside.dcdl""#), ""), + (r#"{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }, { selector = "project:alpha"; profile = {}; }]; }; }"#.into(), "profile_selector_duplicate"), + (r#"{ profile = { default_profile = "project:unknown"; }; }"#.into(), "unknown_default_profile"), + (r#"{ profile = { entries = [{ selector = "project:"; profile = {}; }]; }; }"#.into(), "profile_selector_invalid"), + (r#"{ profile = { entries = [{ selector = "project:alpha"; source = "profiles/alpha.dcdl"; }]; }; }"#.into(), ""), + ]; + for (source, code) in invalid { + let error = + commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, &source, vec![])) + .unwrap_err(); + assert!(error.to_string().contains(code), "{error}"); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + before + ); + } +} + +#[tokio::test] +async fn value_profiles_legacy_source_config_requires_explicit_lossless_migration() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let mut contributions = api.config_schema_registry.compose().unwrap().contributions; + let profile_schema = contributions + .iter_mut() + .find(|entry| entry.provider_id == "builtin:profile") + .unwrap(); + *profile_schema = config_source::ConfigSchemaContribution::new("builtin:profile", "profile", "1", r#"{ + profile = { default_profile = String default "builtin:companion"; + entries = [...{ selector = String; source = String; label = String default ""; description = String default ""; }] default []; + }; + }"#).unwrap(); + let old_schema = config_source::WorkspaceConfigSchemaBundle::compose(contributions).unwrap(); + let old_source = r#"{ profile = { default_profile = "project:alpha"; entries = [{ selector = "project:alpha"; label = "Alpha"; source = "recipes/alpha.dcdl"; }]; }; }"#; + let candidate = api + .config_store + .evaluate_workspace_config_candidate_with_schema( + TEST_WORKSPACE_ID, + &request( + &api, + old_source, + vec![config_source::ConfigTreeChange::Create { + path: config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap(), + content_type: config_source::ConfigContentType::Decodal, + content: PROFILE.into(), + }], + ), + old_schema, + ) + .unwrap(); + // Fixture simulates a previously persisted v1 schema/revision. + let legacy = api + .config_store + .commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate) + .unwrap(); + let error = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &legacy).unwrap_err(); + assert!( + error + .to_string() + .contains("profile_source_registration_removed") + ); + assert!(error.to_string().contains("profile = import")); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + legacy + ); + assert!( + commit_workspace_config_tree(&api, TEST_WORKSPACE_ID, &request(&api, old_source, vec![])) + .is_err() + ); + assert_eq!( + api.config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(), + legacy + ); + let migrated_source = old_source.replace( + r#"source = "recipes/alpha.dcdl""#, + r#"profile = import "./recipes/alpha.dcdl""#, + ); + let migrated = commit_workspace_config_tree( + &api, + TEST_WORKSPACE_ID, + &request(&api, &migrated_source, vec![]), + ) + .unwrap(); + let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &migrated).unwrap(); + assert_eq!( + projection.settings.default_profile.as_deref(), + Some("project:alpha") + ); + assert_eq!( + migrated + .snapshot + .get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap()), + legacy + .snapshot + .get(&config_source::VirtualPath::parse("recipes/alpha.dcdl").unwrap()) + ); + assert_eq!( + projection + .settings + .profiles + .iter() + .find(|entry| entry.selector == "project:alpha") + .unwrap() + .label, + "Alpha" + ); +} 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/profiles-manifests-prompts.md b/docs/design/profiles-manifests-prompts.md index 4e98f622a..7c221df12 100644 --- a/docs/design/profiles-manifests-prompts.md +++ b/docs/design/profiles-manifests-prompts.md @@ -17,7 +17,54 @@ A Profile should not contain runtime-bound fields: Those fields depend on one run, one parent, or one machine. Putting them in a reusable Profile makes reuse unsafe. -Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Decodal source archives, or explicit JSON/TOML artifacts. Decodal is the authoring syntax used by the Workspace profile editor and Backend Runtime archive path; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch should not depend on executable profile scripts. +Yoi Profiles are data artifacts resolved from builtin profile definitions, `profiles.toml`, Workspace Config values, or explicit JSON/TOML artifacts. Decodal is the Workspace authoring syntax; JSON/TOML artifacts are the low-level resolver interchange format. Runtime launch does not depend on executable profile scripts. + +### Workspace registration and migration + +Register the Profile **value**, not a source pathname: + +```dcdl +{ + profile = { + default_profile = "project:my-companion"; + entries = [{ + selector = "project:my-companion"; + label = "My Companion"; + description = "My reusable Worker recipe"; + profile = import "./profiles/my-companion.dcdl"; + }]; + }; +} as WorkspaceConfigSchema +``` + +The imported file can contain `{ worker = { mode = "wip"; }; model = { ref = "codex-oauth/gpt-5.6-sol"; }; feature = { task = { enabled = true; }; }; }`. That record can also be written directly at `entries[0].profile`. Composition uses ordinary Decodal expressions, for example `(import "./profiles/base.dcdl") // { worker = { mode = "wip"; }; }` for an explicit patch, or `&` for compatible refinement. No dedicated Profile file or `profiles/` directory is required. Imports are relative to the **containing virtual config file**, not a host filesystem path; missing imports and virtual-tree escapes are rejected. + +`selector` must be a nonempty `project:*` selector, unique in the Workspace. `label` and `description` are optional display metadata. An omitted `default_profile` selects `builtin:companion`; an explicitly unknown default is an error, not a fallback. A project Profile is a partial recipe resolved against `WorkerManifestConfig::resolution_defaults()`, **not** implicitly inherited from a builtin role. Explicit values remain explicit and omitted Profile fields remain absent in the transported value. To inherit a builtin recipe, import its public value explicitly; there is no implicit builtin inheritance. + +### Public builtin imports + +Workspace Decodal can import embedded Profile values through the read-only `$builtin/` namespace: + +```dcdl +(import "$builtin/profiles/companion.dcdl") // { + worker = { mode = "wip"; }; + feature = { task = { enabled = true; }; ticket = { enabled = false; }; }; +} +``` + +Use this expression anywhere a Profile value is accepted, including `profile.entries[*].profile` or an imported Workspace recipe. `$builtin/profiles/companion.dcdl` is a source path, not the launch selector `builtin:companion`. Public paths under `$builtin/profiles/` are `base.dcdl`, `default.dcdl`, `standalone.dcdl`, `standalone-subjektiv.dcdl`, `standalone-subjektiv-consolidation.dcdl`, `coder.dcdl`, `companion.dcdl`, `intake.dcdl`, `reviewer.dcdl`, `orchestrator.dcdl`, `job.dcdl`, `backend-job.dcdl`, `memory-consolidation.dcdl`, and `subjektiv-memory-consolidation.dcdl`. This is an explicit embedded catalog, not arbitrary resource or filesystem access. Unknown paths, namespace escapes, host paths and network URLs are errors. + +Relative imports **inside a builtin source** resolve against its builtin virtual directory: companion's `./base.dcdl` means `$builtin/profiles/base.dcdl`, never the Workspace's `profiles/base.dcdl`. Relative imports in Workspace files continue to use their containing Workspace virtual directory. Builtin values obey ordinary Decodal composition: `//` applies an explicit patch and `&` refines compatible values (conflicting explicit values remain errors). Importing a builtin does not add a special merge or fallback mechanism. + +Builtin dependencies share the Workspace snapshot budgets: at most 256 entries across Workspace entries and distinct observed builtin sources, 256 KiB per source, and 4 MiB of total source bytes. Unused Workspace entries still count; repeated imports of the same builtin count once. The observed import graph must be acyclic and at most 32 edges deep, **including the synthetic evaluation wrapper's import of the entrypoint**. These guards apply before the loader returns sources to Decodal, including imports in lazy object fields; builtins do not bypass them. Launch archives retain their separate transport limits (64 sources, 256 KiB per source, 1 MiB total source bytes). + +The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based revisions remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config. + +The schema contribution separates its materialized registration schema from an optional, fingerprint-bound `authoring_source`. The latter supplies editor field suggestions inside array elements and partial Profile records, plus a separate constraint-only analysis pass for supplied nested values (including imported recipes); that pass never materializes the shape, makes omitted fields required, or supplies defaults to the saved projection. Editor diagnostics point to the supplied value's virtual file/span. The authoring shape is never substituted for the persisted evaluation schema. Decodal structural diagnostics cover registration and nested Profile field types; the shared Backend save boundary additionally checks full Profile/Manifest semantics (including mode values, forbidden runtime fields, duplicate selectors and default references). UI and WIP saves use that same boundary; invalid candidates leave the active config revision untouched. + +`scope` and `delegation_scope` retain both supported forms: a string such as `"workspace_read"` / `"workspace_write"`, or a table such as `{ intent = "workspace_read"; deny_write = ["private"]; symlink_policy = "resolved"; }`. Decodal 0.4 has no union range, so these fields are opaque to the editor constraint pass; a non-materialized table hint supplies nested completion without rejecting the string form or rewriting it. The shared Backend save validator checks scope forms, intent values and table fields (including their types and unknown keys). Other structurally typed fields such as `worker.mode` and `feature.task.enabled` retain editor type diagnostics. Completion hints never become Profile defaults or saved values. + +Extraction `reasoning` in both `feature.memory` and `feature.subjektiv`, like `engine.reasoning`, accepts either a string effort or a signed integer budget. These union values are also opaque to editor constraints and semantically checked on save. Compaction authoring covers the complete token settings and their supported `compact_*` aliases, plus the ratio helper's `request` / `worker` aliases; values are not renamed or defaulted during Workspace evaluation. The shared test corpus `resources/config-schema/profile-authoring-values.json` proves exact values in native/WASM analysis and effective settings through save/projection/Runtime consumption. Its canonical compaction case must cover every serialized `CompactionConfigPartial` field, making future contract drift visible in tests. ## Manifests @@ -27,7 +74,7 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno `--manifest ` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade. -Project Profiles are evaluated from the revisioned Virtual Config's Decodal source/import closure. The Backend packages that closure into a digest-bound Profile source archive, delivers it with the resolved launch bundle, and the Worker persists the resulting Manifest for restore. Files below the Workdir are not implicit Profile override layers. +Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. When Workspace evaluation imports builtins, the archive additionally seals the exact observed transitive builtin closure for the Workspace contract: canonical `$builtin/profiles/...` source keys, original Decodal bytes, content types, sizes and source digests, all bound by the outer archive and launch bundle digests. These are provenance dependencies, not runtime entrypoints or runtime import instructions. Missing or corrupt provenance fails verification even though the entrypoint is JSON; no local file or current embedded builtin can rescue it. Builtin Profiles selected directly retain their embedded Decodal archive path. Changing an embedded builtin so that it changes the evaluated Workspace value requires an **explicit config save/re-evaluation before a fresh launch**. The Backend compares current evaluation with the saved projection digest and rejects a mismatch rather than silently adopting the new value. T-711 retains the existing version-2 toolchain contract/fingerprint tuple so existing stored contracts remain readable; builtin provenance is bound by the archive and bundle digests, not a new catalog fingerprint field. Neither a builtin update nor a config save rewrites a sealed archive or silently rebases an existing Worker. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers. ## Local stdio MCP server declarations diff --git a/docs/design/standalone-agent-host.md b/docs/design/standalone-agent-host.md index e8ac94f20..984014456 100644 --- a/docs/design/standalone-agent-host.md +++ b/docs/design/standalone-agent-host.md @@ -26,7 +26,8 @@ worker - Controller transport は `InProcess` に固定する。通常起動で Worker subprocess、HTTP/WS server、Unix socket を作らない。 - model provider は通常の resolved Manifest から構築する。埋め込み host と deterministic test は `start_with_model_client` で同じ bootstrap に process-owned client を注入できる。 - feature plan/install は既存 `WorkerController` が行う。Task や optional direct SubWorker を standalone 側で再実装しない。 -- `shutdown()` は既存 `Method::Shutdown` を送り、controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。 +- `shutdown()` は Host-owned Job service の scheduling を閉じ、Internal Worker の取消・cleanup とローカル SQLite connection の close を確認してから、既存 `Method::Shutdown` を送る。controller が active run、SubWorker registry、Workdir session、MachineScope allocation を順に片付けた後の confirmation を待つ。cleanup を確認できない drop/error では lease を保持する。 +- `jobs()` は Feature へ明示的に渡す汎用 Job 実行 capability。対話 Session・モデル作成 SubWorker とは別で、Profile/result authority と永続化の契約は [Standalone Jobs](standalone-jobs.md) に記録する。 - startup error は category のみを公開し、credential、prompt 本文、session metadata、内部 path を error text に含めない。 ## CLI / TUI routing diff --git a/docs/design/standalone-jobs.md b/docs/design/standalone-jobs.md new file mode 100644 index 000000000..abbe6ce3e --- /dev/null +++ b/docs/design/standalone-jobs.md @@ -0,0 +1,152 @@ +# Host-owned standalone Jobs (T-708) + +## Implementation design recorded before code changes + +Base: `3c424fd17d1a5e8bb5542de2d0ad88f55338e0ea` (current origin/develop; T-704 integrated). T-709's subjektiv consumer is not part of this change. + +The reusable `job` crate owns the transport-neutral immutable request (registry Profile selector, input snapshot/reference/revision, caller Job ID, instruction, serialization key and bounded limits), result envelope/digest and validation. Backend retains its existing SQLite state machine, Runtime binding, domain grants, authorization, unknown-outcome and cleanup implementation; its adapter uses the shared request validation and result contract, not a second runner. + +Standalone owns a separate adapter, not a WorkspaceClient impersonation. `StandaloneHost` owns a Job service and its connection lifecycle. The concrete local placement is `//jobs.sqlite3` (using the existing Worker directory layout; alongside, not inside, interactive sessions). The `standalone` crate owns the versioned local schema: durable intents, immutable attempts, execution state, accepted result/digest, cancellation and consumer acknowledgement. A single Host connection is protected by the existing Worker lease. SQLite transactions serialize reserve, dispatch, result acceptance and terminal transitions. No subjektiv tables or fake Workspace/Runtime registry are created. + +Local adapter states follow the existing intent/attempt/result distinctions. Exact immutable request replay returns the same Job, never starts another attempt. Explicit retry creates a bounded new attempt only for known failure; unknown outcomes require caller reconciliation and never auto-replay. Unstarted pending attempts remain pending on shutdown/reopen. Interrupted dispatch/running attempts without an accepted result become unknown; accepted results survive restart and remain available until consumer acknowledgement. Cancellation is durable and never auto-resumed. Consumer acknowledgement/delivery failure is separate from model success and never reruns the model. + +Execution uses the existing private Internal Worker substrate with a new Host-facing bounded Job entry point. It gets a distinct ephemeral session, explicitly resolved selected Profile/system prompt/model, no ambient parent tools, filesystem, Workspace or Feature connections, and only a Job/attempt/revision-bound structured result submission capability. Profiles requiring unavailable tools/features are explicitly rejected rather than silently stripped. Result authority is independent of Profile name; final prose, Idle and Worker termination are never success evidence. This first generic adapter grants no domain operations; future consumers must provide explicit domain capabilities rather than gaining them through a profile tag. + +The Host limits concurrent active resources independently of caller limits, enforces timeout/turn/output limits, routes cancellation into the Internal Worker lifecycle and waits for resource cleanup. Accepted results are committed before resource release; required domain postprocessing must finish before submission (or be independently durable in its consumer). No daemon, pool, subprocess, HTTP/WS listener or Server dependency is introduced. + +Validation will distinguish scripted in-process Internal Worker executions and SQLite reopen/recovery fixtures from any real-provider/process tests. It will cover alternative Profiles, immutable retry, invalid/stale results, resource limits, failure/cancellation/timeout, shutdown and restart. Backend focused regression tests must still prove its existing fences and cleanup. + +## API and ownership + +`job::{JobRequest, JobLimits, JobResultSubmission, JobOutcome}` are the common +Feature-facing data contract. `JobOutcome` distinguishes intent state from attempt +state and omits Workspace/Runtime identities. These enums are read vocabulary, +not another transition engine. Backend converts neutral requests with +`BackendJobRequest::from_job_request` and projects reservation/acceptance with +`.outcome()`; Backend domain grants, provenance, delivery and cleanup remain on +its own adapter and retain their existing durable/wire format and fingerprints. + +Standalone consumers receive `StandaloneHost::jobs()` explicitly, not a fake +Workspace client and not a tool on the interactive Worker. They can persist +`request`, read `get`/`pending`, explicitly `start` or `retry`, `cancel`, and +`acknowledge`. `.wait()` observes execution cleanup; `.outcome()` on the returned +snapshot is the common read view. `Completed` with its accepted structured result +is success authority; merely returning from `wait` is not. The injected-client +entry point resolves/validates the same selected Profile and model configuration; +it replaces only the model transport for embedders/tests. + +A typical generic consumer supplies its own stable Job ID/revision/reference, +immutable JSON input, instruction and registry Profile: + +```rust,ignore +let jobs = host.jobs(); +let snapshot = jobs.request(request)?; +if snapshot.state == standalone::jobs::JobState::Pending { + jobs.start(&snapshot.request.job_id).await?; +} +let outcome = jobs.wait(&snapshot.request.job_id).await?.outcome(); +// Validate/apply the consumer's domain result here. A failed notification or +// delivery leaves the successful result unacknowledged; do not retry the model. +if outcome.state == job::JobState::Completed { + deliver_idempotently(&outcome)?; + jobs.acknowledge(&outcome.job_id)?; +} +``` + +The Host retains tasks/cancellation even while a consumer waits; dropping a wait +future does not detach cancellation ownership or release a concurrency slot. +Active resources are bounded to eight by the Host and by the minimum of all live +requests' ceilings, including after result acceptance and before cleanup. +Serialization keys likewise fence live resources, not just pending result state. +Busy `start` returns a typed capacity error and leaves the reserved attempt +unchanged; callers decide when to start/resume pending work. There is no pool, +background daemon, implicit retry or independent Feature-owned Internal Worker. + +## Profile and capability policy + +The caller must select an existing built-in or user registry Profile. Path/raw +selectors and `inherit` are not accepted. Registry existence, selected instruction +and model configuration are checked before dispatch. The ordinary user settings +registry and Prompt catalog are used; repository-local recipes are not discovered +by the standalone Host, and default never silently maps to a different Profile. + +`builtin:job` is an **optional**, compatible result-only policy. It is not a +runner-selected default or authority token. User registry Profiles with different +instructions/models/bounds execute the same way, as demonstrated by the scripted +Worker tests. This adapter deliberately rejects active unsupported Feature, +filesystem/delegation scope, MCP, skills, compaction and durable event-trace +requirements. Existing `builtin:backend-job` has such requirements and is rejected +by this standalone adapter, while its Backend behavior is unchanged. A future +consumer requiring domain operations must arrange explicit Host capabilities; +choosing `builtin:subjektiv-memory-consolidation` alone grants nothing. + +Every run uses the existing private Internal Worker substrate, a distinct identity +and ephemeral Session. Only `SubmitJobResult` is installed by a FeatureModule. +Its model input is `{result: ...}`; Job/attempt/input-revision bindings come from +the Host capability, not model arguments. The sink checks those bindings, the +current persisted attempt, deadline, byte limit and result digest. Exact result +replay is idempotent; changed/foreign/stale results are rejected. Submission is +sealed before acceptance, so uncertain responses permit only identical retries. +An ambiguous sink failure is reconciled against SQLite; absent accepted evidence +it is `Unknown`, not a known retryable model failure. No domain logic is embedded +in this sink. Required consumer/domain postprocessing must complete before +submission or have its own durable ownership outside the ephemeral Worker. + +Selected instructions are retained. The Host execution contract is rendered from +`resources/prompts/internal/job_system.md` before the run's initial history is +created, not injected into a previous/transient turn. Turn/output policy is +attenuated to at most 32 turns and 8192 output tokens per model request, preserving +tighter selected Profile bounds. Structured results retain the shared 16 KiB +default / 64 KiB absolute byte limits, not a larger standalone exception. + +## Persistence and termination + +Local schema version **1** is owned only by `standalone::job_store`: `job_intents` +contains immutable serialized intent/fingerprint, serialization key, current +attempt and acknowledgement; `job_attempts` contains immutable identity/revision, +execution state, failure and accepted result/digest. Foreign keys, immutable +identity/result triggers, transactions, `WAL` and `synchronous=FULL` protect the +boundary. No Server schema or subjektiv/Memory schema is copied or opened. +The Host opens and recovers the store only under its existing top-level Worker +lease; clones cannot open or recover it themselves. + +- **Exact request replay:** same immutable intent returns its current snapshot. + Changing any intent field under the same ID is a conflict, not a new attempt. +- **Known failure:** only explicit `retry` creates a new immutable attempt, up to + the intent's bounded maximum. Prior attempts/results remain in SQLite. +- **Timeout/shutdown before acceptance:** fence `Unknown` before requesting Engine + cancellation; late result submissions cannot create success. Await normal + cleanup; retain the Host lease and report an error if confirmation is lost. +- **Normal Host shutdown:** seal scheduling/mutations, cancel active runs, await + resource cleanup, close SQLite, then stop the interactive controller and release + its lease. Reserved/unstarted requests remain resumable. Old cloned handles + return `Closed`, rather than mutating a store reopened by another Host. +- **Unexpected Host drop:** request cancellation and retain the lease because + synchronous drop cannot attest cleanup. No daemon continues after process exit. +- **Process loss/reopen:** reserved attempts stay pending; unaccepted dispatched + attempts become unknown; accepted results/acknowledgements remain intact. + SQLite result evidence, never final prose or an ephemeral Session, decides success. +- **Cancellation:** durable and never automatically resumed. Cancelling already + unknown intent does not erase its attempt's uncertain execution evidence or + release its serialization fence. `retry` still refuses it. There is no generic + operation that declares uncertain domain side effects unexecuted; callers must + reconcile domain evidence rather than bypassing the fence with an implicit replay. +- **After acceptance:** model error, cancellation or consumer delivery failure + cannot erase success or rerun the model. Acknowledgement is independently durable + and idempotent. Execution resources are reclaimed independently of result lifetime. + +Emergency cancellation has a bounded grace (one second for cancellation setup, +two seconds for normal cleanup). Expiry is **not** cleanup confirmation: it returns +an error, keeps unknown evidence/accepted results, and the Host retains its lease. +The result-only Feature owns no async/background tasks or domain connections; +the dropped engine stream/tool pumps request abort. Synchronous SQLite submission +is bounded by its five-second busy timeout and cannot be preempted mid-transaction. +No resource or side effect is claimed rolled back merely because a future dropped. + +## Validation evidence + +See [T-708 validation ledger](../report/2026-10-06-t708-validation.md). It separates +scripted in-process Worker/Host runs, direct SQLite/reopen/rollback fixtures and +an actual test-owned child-process loss/Host restore test from live-provider or +live dogfood validation. T-709's subjektiv consumer is intentionally not migrated +here; Backend T-704 Profile selection and T-690 cleanup remain regression-tested. 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/design/workdir-wip-projection.md b/docs/design/workdir-wip-projection.md index bace39405..53a904cdf 100644 --- a/docs/design/workdir-wip-projection.md +++ b/docs/design/workdir-wip-projection.md @@ -165,7 +165,7 @@ Ticket/Objective/Merge Request routes retain their T-698 contracts. ## First-Workspace example -1. `WipDiscover(path="/", depth=1)` finds `repositories`, `workdirs` and +1. `Discover(path="/", depth=1)` finds `repositories`, `workdirs` and `workdir-attachments` when permitted. 2. Inspect the returned Repository collection Interface and call `list` with a bounded limit. An empty registered/visible catalog explicitly returns an empty diff --git a/docs/design/worker-wip-mode.md b/docs/design/worker-wip-mode.md index f77be0daf..4798fb19e 100644 --- a/docs/design/worker-wip-mode.md +++ b/docs/design/worker-wip-mode.md @@ -30,9 +30,9 @@ The implementation uses the crates.io `wip-client`, `wip-protocol`, and `wip-htt A WIP Worker exposes exactly these generic LLM tools instead of exposing every enabled ordinary tool twice: -- `WipDiscover`: observe an object path and bounded descendants, beginning at `/`. -- `WipInspect`: fetch the descriptor for an opaque interface reference returned by discovery. -- `WipCall`: call an operation using fresh object and interface observations. +- `Discover`: observe an object path and bounded descendants, beginning at `/`. +- `Inspect`: fetch the descriptor for an opaque interface reference returned by discovery. +- `Call`: call an operation using fresh object and interface observations. The compatibility projection mounts enabled ordinary tools at: @@ -46,7 +46,7 @@ Each object has one opaque interface reference: yoi.tool//v1 ``` -Its single operation is `call`. For this compatibility route, `WipCall.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them. +Its single operation is `call`. For this compatibility route, `Call.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them. The interface descriptor uses WIP `Json` for compatibility input and includes the exact original JSON Schema in descriptor documentation. Before execution, the Host validates the JSON value with that original schema and then delegates to the original async `Tool`. Constraints are therefore neither approximated nor silently dropped. A schema that cannot be compiled prevents WIP startup instead of creating a weaker projection. @@ -65,7 +65,7 @@ Object resolution and operations are separate contracts. One mounted `WipProject - A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route. - Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry. -`WipInspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation. +`Inspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation. The built-in native route migration is: @@ -103,13 +103,13 @@ The Worldspace contains only the operations registered from enabled Features and Filesystem scope, provider Workdir capability checks, Backend Workspace authority, Feature enablement, and input-specific restrictions stay in the original tool implementation and are checked again at invocation. `ask` permissions remain denied fail-closed because the runtime has no approval protocol. The gateway does not mint authority or expose authentication material. -Pre/post Engine history still records one bounded `WipCall` result, while the compatibility result retains the original `ToolOutput`, including attachments and normal Engine output pruning. WIP call audit records retain the route, operation, request identity, and terminal classification. +Pre/post Engine history still records one bounded `Call` result, while the compatibility result retains the original `ToolOutput`, including attachments and normal Engine output pruning. WIP call audit records retain the route, operation, request identity, and terminal classification. ## Client lifetime, restoration, and compaction The stateful `wip-client::Client` is owned by one Worker WIP runtime. Sessions are keyed by the canonical endpoint and an opaque security-context identity derived by the Host from Workspace, Worker, and session identity. Authentication credentials are not included in that identity and are never emitted to descriptors, tool output, or history. -Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `WipDiscover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority. +Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `Discover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority. Saved Session/history entries remain append-only evidence and are never rewritten or replayed as calls. In particular, historical `/features//...` text stays displayable but is not a current route alias. Passing such a legacy path to discovery or call returns `NotFound` with guidance to rediscover from `/`; the Host does not translate it, resolve it under another namespace, or expose old and new routes in parallel. diff --git a/docs/report/2026-10-06-review-finding-severity.md b/docs/report/2026-10-06-review-finding-severity.md new file mode 100644 index 000000000..72277abf6 --- /dev/null +++ b/docs/report/2026-10-06-review-finding-severity.md @@ -0,0 +1,12 @@ +# Review finding severity の入力契約 + +T-710 の独立レビュー中、Reviewer が `ReviewMergeRequest` に finding severity `medium` を渡し、HTTP 422 で拒否された。Backend の許可値は `blocker` / `major` / `minor` / `note` で、最初の呼び出しでは verdict が記録されなかった。 + +親は committed session と MR thread を確認して未記録であることを確かめ、Reviewer の新しい turn で同じ capability・同じ MR/source に対して `major` を使った訂正を依頼した。訂正は成功し、MR sequence 3 に `request_changes` が記録された。別の保存経路や Ticket comment で verdict を代替しなかった。 + +## 改善案 + +- finding severity は公開 tool schema でも Backend と同じ enum として示し、受理可能な語彙を入力前に確認できるようにする。 +- capability を使う操作の検証エラーでは、イベントが未記録であることと、訂正した入力を再送できるかを明確に返す。Reviewer が「一回の呼び出し」と「一回の有効な verdict 記録」を混同し、未記録のまま止まる障壁を減らせる。 + +これは observed rejection と訂正成功に基づくフィードバックであり、Backend の review authority を迂回する提案ではない。 diff --git a/docs/report/2026-10-06-t708-validation.md b/docs/report/2026-10-06-t708-validation.md new file mode 100644 index 000000000..8cae5eac1 --- /dev/null +++ b/docs/report/2026-10-06-t708-validation.md @@ -0,0 +1,134 @@ +# T-708 implementation and validation ledger + +Base: clean detached assigned checkout `3c424fd17d1a5e8bb5542de2d0ad88f55338e0ea`, +also the inspected `origin/develop`. T-704 was authoritative `done` with that +integrated source. Created `work/T-708-standalone-jobs` without overwriting an +existing branch or discarding changes. T-709's subjektiv consumer is separate. +The concrete SQLite placement/schema ownership was recorded in +[the design](../design/standalone-jobs.md) before implementation; it uses the +existing `/` directory layout, not a new `workers` subdirectory. + +Three scoped implementation children supplied the common contract/Backend +projection, Worker execution adapter, and local SQLite store. Parent inspected +committed sessions, exact source changes and concrete test results, then integrated +Host/service ownership, live-resource ceilings, deadline fencing, independent +acknowledgement, shared outcome projection, process-loss tests and documentation. +Independent published-MR review is separate from these implementation children. + +## Implemented boundaries + +- `job`: neutral immutable request/limits/result envelope, fingerprint, strict + registry selectors, bounded digests and read-only intent/attempt/outcome DTOs. + No HTTP, Workspace, Runtime identity, domain grants, runner or workflow DSL. +- Backend reuses validation/result helpers and neutral projections while retaining + its exact request wire/fingerprint/envelope, domain grants, persistent states, + Runtime authorization, unknown fences, resource serialization and cleanup. +- `worker::job`: selected registry Profile/instruction/model, explicit requirement + rejection, bounded policy, distinct ephemeral Internal Worker, result-only + FeatureModule and Host-fenced structured acceptance. No parent filesystem, + Workspace, Feature or domain connections; no Profile-name authorization. + Ambiguous sink acceptance is Unknown unless the Host's durable result proves success. +- Standalone Host owns schema-1 SQLite intent/attempt/result/ack state and a bounded + execution service. It opens and recovers under its Worker lease; shutdown cancels + and awaits executions, closes SQLite, then releases the lease after controller + confirmation. Old cloned handles are closed. Drop cannot attest cleanup and + retains the lease. Busy/unknown/cancelled/terminal work is not blindly replayed. +- Exact immutable request resend differs from explicit bounded new-attempt retry. + Results commit before resource release, survive model/notification failure, and + are independently acknowledged by the consumer. Waiting does not relinquish + Host ownership of cancellation/concurrency. Cancelling unknown intent retains + its uncertain attempt evidence and serialization fence. +- Optional `builtin:job` is a caller-selected compatible policy, not a fixed runner + default. Different user registry Profiles are demonstrated in Worker fixtures. + Existing `builtin:backend-job` retains Backend behavior; standalone rejects its + unavailable requirements rather than silently stripping or replacing them. +- No production Server/Runtime process, HTTP/WS listener, Worker registry, + subprocess Worker, daemon/pool or subjektiv-specific runner is added. + +## Validation results + +Development began with narrow contract/module targets before full changed-crate +validation. Final root check covers the normal default-members TUI/Server closure. + +| Command | Concrete result | +| --- | --- | +| `cargo test -p job` | 9 contract tests passed; no unit/doc tests | +| `cargo test -p standalone --lib job_store` | 19 fixtures passed: transactional replay/conflict, all limits, result fences, retry/old-attempt preservation, cancellation, concurrency races, SQL rollback, schema rejection and reopen recovery | +| `cargo test -p standalone --lib jobs::tests` | 2 passed: Host capability binding/deadline/replay and success/cancellation/closed-handle lifetime | +| `cargo test -p standalone --test jobs process_loss_recovers_durable_jobs_without_replaying_unknown` | Actual test-owned process exit and Host stale-lease/SQLite recovery passed | +| Final `cargo test -p standalone` | 25 library + 10 existing Host integration + 8 Job integration passed; 1 child-process fixture entry point marked ignored in parent harness, explicitly invoked with `--ignored` by the passing process-loss test | +| `cargo test -p manifest` | All 185 tests passed, including new optional builtin Profile catalogue resolution; no resolver/runtime/schema implementation changed | +| `cargo test -p worker` | All 826 library + 110 integration tests passed, including 11 new Job tests and 8 existing Internal Worker substrate tests | +| `cargo test -p yoi-workspace-server --lib backend_job` | All 38 passed: neutral adapter/wire/fingerprint, T-704 Profiles/no fallback, structured fences, unknown/recovery, resource serialization and T-690 cleanup | +| `cargo test -p yoi-workspace-server --bins` | All 11 binary tests passed | +| `cargo test -p worker-runtime --lib backend_job` | All 3 Job binding/dispatch regressions passed | +| `cargo test -p server-api --features typescript` | All 77 passed, including checked-in generated/OpenAPI/TypeScript freshness; existing ts-rs serde-attribute warnings | +| Root `cargo check` | Passed after integration and again after final local adapter/tests changes | +| `cargo fmt --all -- --check` / `git diff --check HEAD` | Passed | +| `cargo test -p yoi-workspace-server` | **Failed: 643 passed, 1 failed** in library; see retained anomaly below. Binary tests were therefore run separately. This is not a green full Server suite. | +| `cargo test -p yoi-workspace-server -- --test-threads=4` | **Failed: 643 passed, 1 failed**, same unchanged checker assertion; not claimed passed | + +Profile fixtures use two different existing user registry entries with distinct +instructions and budgets, plus optional `builtin:job`. They prove only +`SubmitJobResult` is exposed, selected instructions/policy are retained, scopes and +Backend/domain tools are absent, malformed/oversized/forged results are rejected, +uncertain sink submission permits only identical replay, final prose is not +success, provider failure/cancellation/turn limits do not manufacture success, +and accepted results survive later provider failure. + +Host Job integration additionally proves explicit known-model-failure retry, +request replay, live concurrency while a consumer waits, cancellation, timeout, +shutdown, pending resume, consumer acknowledgement and unsupported Profile +rejection without model calls. Direct-store fixtures prove persisted facts and +rollback/reopen behavior, not live model or process behavior. + +### Actual process verification versus fixtures + +`process_loss_recovers_durable_jobs_without_replaying_unknown` starts **only a +test-owned copy of the integration test executable**, using scripted model clients. +The child owns a real StandaloneHost/SQLite connection/Worker lease, persists a +reserved request, a cancelled request and an accepted result, then exits with code +23 while a separate Internal Worker is executing. It deliberately skips destructors +and graceful shutdown. The parent verifies that exit and restores the Host through +the real stale-process lease path: accepted result retained/unacknowledged, +cancelled work never resumed, reserved work explicitly resumable and interrupted +work Unknown/no automatic retry. The helper's ignored annotation is an entry-point +selector, not skipped semantic coverage. + +These are real Rust test/compiler processes plus scripted in-process model +executions and SQLite fixtures. They are **not** live-provider LLM calls, a live +standalone CLI/TUI session, a Backend/Runtime deployment or dogfood environment +update. No operating Yoi Worker or live dogfood process was stopped or updated. +Production Jobs do not create the subprocess used solely by that crash test. + +## Retained failures and evidence limits + +Initial integrated compilation exposed `get` snapshot/Option disagreement between +concurrently authored store/service; signatures were aligned and the subsequent +store/Host/full standalone runs passed. A private tool-type import during child +integration was corrected before focused/full Worker validation. No passing +result is attributed to those initial failing builds. + +Both full Server runs and one isolated run of +`server::tests::ticket_checker_suppresses_stale_and_clean_results_and_diagnoses_invalid_output` +failed the unchanged post-stale-result `assert!(execution.take_inputs().is_empty())` +at `server.rs:44748`. All 38 Job-filter tests pass, including unchanged Backend +envelope/fingerprint compatibility and late-result/cleanup fences. The same +checker assertion/race was independently reproduced on the earlier original base +in the [T-702 ledger](2026-10-06-t702-stale-checker-validation.md), and explicitly +retained as unfixed in the [T-704 ledger](2026-10-06-t704-validation.md). +No new isolated build of this Ticket's exact base was run, and its root cause is +not claimed proven or fixed here. The checker implementation/test was neither +changed nor skipped to manufacture a green suite. This validation anomaly remains +visible for Reviewer/Orchestrator attention. + +No whole-workspace tests, E2E, Nix/Docker builds or live-provider validation were +claimed. Backend generated contracts were regression checked despite no wire +change. Result-only Jobs reject unsupported Feature/authority requirements; +subjektiv consumer capabilities/postprocessing are T-709 work, not inferred grants. + +Publication must use only the clean committed Ticket work branch by normal +non-force push, verified with exact provider source ref. No target/tags/unrelated +refs, replacement MR, integration branch, merge or Ticket completion is performed +by this Coder. Final immutable source/MR/review identities belong in the MR thread +and approved Ticket handoff rather than being guessed in this ledger. diff --git a/docs/report/T-711-import-cycle.md b/docs/report/T-711-import-cycle.md new file mode 100644 index 000000000..37dbaf422 --- /dev/null +++ b/docs/report/T-711-import-cycle.md @@ -0,0 +1,66 @@ +# T-711: nested lazy-object import cycle + +Date: 2026-10-06 +Status: fixed at the snapshot import-loader boundary; regression coverage retained. + +## Dogfooding problem + +During T-711 builtin-import validation, a nested import cycle in a lazy object caused a stack overflow instead of a recoverable configuration diagnostic. A configuration author could therefore terminate evaluation while writing an invalid recipe, rather than receive an error and keep the active revision. This report records the implementation-session failure and the guard added by the parent config-source implementation; it is not a recommendation to rerun the crashing version inside a live Yoi Worker. + +## Minimal reproducer + +Build a `ConfigTreeSnapshot` containing these two Decodal entries: + +`main.dcdl`: + +```dcdl +{ + builtin = import "$builtin/profiles/companion.dcdl"; + cycle = import "./cycle.dcdl"; +} +``` + +`cycle.dcdl`: + +```dcdl +import "./main.dcdl" +``` + +Evaluate the snapshot with `SnapshotEnvironment::evaluate_contract` and a `ToolchainContract` whose entrypoint is `main.dcdl`. This fixture intentionally uses the plain evaluation contract rather than `WorkspaceConfigSchema`: it isolates import/materialization behavior, not Profile registration semantics. + +Before the loader guard, evaluating the root object could defer its `cycle` field until materialization. Importing the root again through `cycle.dcdl` then recursed through that lazy value instead of returning a structured cycle failure. The observed failure was a process stack overflow. Increasing the stack would only postpone the failure and would not provide a safe configuration boundary. + +## Fix + +`SnapshotImportLoader::load` now records canonical source-to-source edges and validates the observed graph **before returning a source to Decodal**. Content-digest cache suffixes are stripped for graph identity, so the same virtual source cannot evade cycle detection by appearing under a cache key. + +- A back edge returns a `cycle` diagnostic before lazy materialization can recurse. +- An import chain longer than 32 edges returns an import-depth limit diagnostic. The synthetic evaluation wrapper's entrypoint import counts toward that limit. +- Memoized dependency depths keep shared-subgraph validation bounded without losing the longest path. +- Rejected edges are removed from the successful-edge cache. A reused language-service loader must reject the same bad edge again, not treat it as already validated. +- Workspace and builtin imports use the same graph guard and shared source count/byte budgets. Embedded sources are not an exception. + +The fix belongs in `crates/config-source/src/lib.rs`; it does not require runtime Decodal evaluation. Project launch archives still transport the evaluated JSON entrypoint with builtin source provenance, and Runtime resolves only that evaluated value. + +## Regression evidence + +The following tests in `crates/config-source/src/lib.rs` cover the incident and adjacent guard behavior: + +- `builtin_values_do_not_bypass_cycle_or_path_limits`: the two-entry lazy-object fixture above returns `kind = "cycle"`; builtin imports do not bypass depth/path bounds. +- `rejected_import_graph_edges_stay_rejected_after_diagnostics`: repeated attempts to add the rejected back edge remain errors when the loader is reused. +- `import_graph_shared_dependencies_are_memoized_without_losing_depth`: shared dependencies preserve longest-path validation and the allowed depth boundary. +- `snapshot_import_depth_is_checked_before_evaluation_recurses`: a real source chain fails with an import-depth diagnostic before evaluation recurses beyond the guard. +- `builtin_dependencies_share_snapshot_count_and_byte_budgets`: builtin dependencies cannot exceed the aggregate entry-count or byte budget by living outside the authored tree. + +Run individual regressions with the normal narrow target, for example: + +```sh +cargo test -p config-source --lib builtin_values_do_not_bypass_cycle_or_path_limits +cargo test -p config-source --lib snapshot_import_depth_is_checked_before_evaluation_recurses +``` + +The server follow-up test `value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back` separately exercises authenticated HTTP commits: reserved builtin mutations and unknown imports are rejected without changing the active revision, even when a Workspace file exists at the unknown builtin's suffix. Editor import completions remain covered by the WASM editor tests; no server completion endpoint was introduced. + +## Operational lesson + +An evaluator's module cache is not sufficient evidence that lazy-value cycles are safe. Keep cycle and depth validation at the source-loader boundary and retain the lazy-object reproducer whenever import identity, caching, or materialization changes. Config errors should be recoverable diagnostics, never an implicit request to replace a running Worker's persisted Manifest or restart the live dogfooding process. 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/openapi/server-api.json b/openapi/server-api.json index c217d3c0f..9eb8e17d3 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1235,6 +1235,13 @@ "ConfigSchemaContribution": { "additionalProperties": false, "properties": { + "authoring_source": { + "description": "Completion-only shape for partial values; never materialized as defaults.", + "type": [ + "string", + "null" + ] + }, "namespace": { "type": "string" }, @@ -15474,7 +15481,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da" + "x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf" }, "openapi": "3.1.0", "paths": { diff --git a/resources/config-schema/profile-authoring-values.json b/resources/config-schema/profile-authoring-values.json new file mode 100644 index 000000000..4038f72f4 --- /dev/null +++ b/resources/config-schema/profile-authoring-values.json @@ -0,0 +1,116 @@ +[ + { + "name": "canonical_compaction_tokens", + "profile": { + "compaction": { + "kind": "tokens", + "prune_enabled": false, + "prune_protected_tokens": 3000, + "prune_min_savings": 1000, + "threshold": 80000, + "request_threshold": 90000, + "retained_tokens": 12000, + "overview_target_tokens": 1000, + "overview_warning_tokens": 2000, + "overview_deadline_tokens": 3000, + "worker_context_max_tokens": 20000, + "finish_warning_remaining_tokens": 1000, + "final_reserve_tokens": 2000, + "worker_max_turns": 5, + "summary_target_tokens": 1000, + "summary_max_tokens": 2000, + "auto_read_budget_tokens": 500, + "result_context_max_tokens": 4000, + "model": { "ref": "codex-oauth/gpt-5.6-sol" } + } + }, + "manifest_expect": { + "/compaction/prune_enabled": false, + "/compaction/prune_protected_tokens": 3000, + "/compaction/prune_min_savings": 1000, + "/compaction/threshold": 80000, + "/compaction/request_threshold": 90000, + "/compaction/retained_tokens": 12000, + "/compaction/overview_target_tokens": 1000, + "/compaction/overview_warning_tokens": 2000, + "/compaction/overview_deadline_tokens": 3000, + "/compaction/worker_context_max_tokens": 20000, + "/compaction/finish_warning_remaining_tokens": 1000, + "/compaction/final_reserve_tokens": 2000, + "/compaction/worker_max_turns": 5, + "/compaction/summary_target_tokens": 1000, + "/compaction/summary_max_tokens": 2000, + "/compaction/auto_read_budget_tokens": 500, + "/compaction/result_context_max_tokens": 4000, + "/compaction/model/ref": "codex-oauth/gpt-5.6-sol" + } + }, + { + "name": "compaction_token_aliases_without_kind", + "profile": { + "compaction": { + "compact_threshold": 80000, + "compact_request_threshold": 90000, + "compact_retained_tokens": 12000, + "compact_worker_max_input_tokens": 20000, + "compact_worker_max_turns": 5, + "compact_auto_read_budget": 500 + } + }, + "manifest_expect": { + "/compaction/threshold": 80000, + "/compaction/request_threshold": 90000, + "/compaction/retained_tokens": 12000, + "/compaction/worker_context_max_tokens": 20000, + "/compaction/worker_max_turns": 5, + "/compaction/auto_read_budget_tokens": 500 + } + }, + { + "name": "compaction_ratio_aliases", + "profile": { + "model": { "context_window": 100000 }, + "compaction": { + "kind": "ratio", + "threshold": 0.9, + "request": 0.8, + "worker": 0.2 + } + }, + "manifest_expect": { + "/compaction/threshold": 90000, + "/compaction/request_threshold": 80000, + "/compaction/worker_context_max_tokens": 20000 + } + }, + { + "name": "numeric_extraction_reasoning", + "profile": { + "engine": { "reasoning": -1 }, + "feature": { + "memory": { "extraction": { "enabled": true, "reasoning": 4096 } }, + "subjektiv": { "extraction": { "enabled": true, "reasoning": 4096 } } + } + }, + "manifest_expect": { + "/engine/reasoning": -1, + "/feature/memory/profile/extraction/reasoning": 4096, + "/feature/subjektiv/profile/extraction/reasoning": 4096 + } + }, + { + "name": "string_extraction_reasoning", + "profile": { + "feature": { + "memory": { "extraction": { "enabled": true, "reasoning": "high" } }, + "subjektiv": { + "extraction": { "enabled": true, "reasoning": "medium" } + } + } + }, + "manifest_expect": { + "/feature/memory/profile/extraction/reasoning": "high", + "/feature/subjektiv/profile/extraction/reasoning": "medium" + } + } +] diff --git a/resources/config-schema/profile-authoring.dcdl b/resources/config-schema/profile-authoring.dcdl new file mode 100644 index 000000000..d2cb97ce3 --- /dev/null +++ b/resources/config-schema/profile-authoring.dcdl @@ -0,0 +1,88 @@ +# Editor-only shape of partial Profile values, used for completion and constraint +# analysis without materialization. Never used as defaults: omitted fields remain +# omitted until Manifest resolution. +let + Flag = { enabled = Bool; }; + Model = { + ref = String; scheme = String; base_url = String; model_id = String; + auth = { kind = String; ref = String; file = String; }; + capability = Unknown; context_window = Int; max_context_window = Int; + }; + Extraction = { + # ReasoningControl accepts string effort or signed integer budget. + enabled = Bool; model = Model; reasoning = Unknown; + threshold = Int; worker_max_turns = Int; + }; + # Profile scope accepts either a string intent or a table. Decodal 0.4 has + # no union range; keep this field opaque to constraint analysis and expose + # the table only as a completion hint. This shape is never materialized; + # actual scope forms, intent values and table fields are checked on save. + Scope = Unknown default { intent = String; deny_write = [...String]; symlink_policy = String; }; + Profile = { + slug = String; + description = String; + worker = { mode = String; }; + model = Model; + engine = { + instruction = String; language = String; reasoning = Unknown; + max_tokens = Int; max_turns = Int; temperature = Unknown; top_p = Unknown; + top_k = Int; stop_sequences = [...String]; tool_call_dispatch = String; + tool_output = { default_max_bytes = Int; per_tool = { ...Int }; }; + file_upload = { max_bytes = Int; }; + }; + scope = Scope; + delegation_scope = Scope; + session = { + record_event_trace = Bool; + }; + permissions = { default_action = String; rule = [...{ tool = String; pattern = String; action = String; }]; }; + feature = { + task = Flag; web = Flag; image = Flag; sub_worker = Flag; flow = Flag; + worker = { enabled = Bool; direct_spawn = Bool; }; + workspace_worker_discovery = Flag; objective = Flag; manage_workdir = Flag; + workdir_catalog = Flag; workspace_config = Flag; orchestration = Flag; + ticket = { enabled = Bool; authoring = Bool; thread = Bool; intake = Bool; workflow = Bool; }; + merge_request = { show = Bool; open = Bool; review = Bool; readiness_check = Bool; complete = Bool; }; + memory = { + enabled = Bool; staging_tools = Bool; + resident = { inject_summary = Bool; }; + extraction = Extraction; + consolidation = { request_enabled = Bool; }; + }; + subjektiv = { enabled = Bool; consolidation_tools = Bool; extraction = Extraction; }; + }; + mcp = { stdio_server = [...{ + name = String; command = String; args = [...String]; + cwd = { kind = String; path = String; }; + env = { inherit = [...String]; set = { ...Unknown }; }; + }]; }; + compaction = { + # Token settings (CompactionConfigPartial) and ratio helper fields. + # Threshold/context values accept integer tokens or numeric ratios. + kind = String; threshold = Unknown; request_threshold = Unknown; + worker_context_max_tokens = Unknown; request = Unknown; worker = Unknown; + prune_enabled = Bool; prune_protected_tokens = Int; prune_min_savings = Int; + retained_tokens = Int; + overview_target_tokens = Int; overview_warning_tokens = Int; overview_deadline_tokens = Int; + finish_warning_remaining_tokens = Int; final_reserve_tokens = Int; + worker_max_turns = Int; summary_target_tokens = Int; summary_max_tokens = Int; + auto_read_budget_tokens = Int; result_context_max_tokens = Int; model = Model; + # Supported token aliases remain authorable without rewriting recipes. + compact_threshold = Int; compact_request_threshold = Int; compact_retained_tokens = Int; + compact_worker_max_input_tokens = Int; compact_worker_max_turns = Int; compact_auto_read_budget = Int; + }; + web = { + enabled = Bool; allow_private_addresses = Bool; + search = { enabled = Bool; provider = String; api_key_secret = String; timeout_secs = Int; base_url = String; country = String; search_lang = String; ui_lang = String; safesearch = String; }; + fetch = { enabled = Bool; timeout_secs = Int; redirect_limit = Int; max_response_bytes = Int; max_output_bytes = Int; allow_private_addresses = Bool; }; + }; + skills = { directories = [...String]; }; + }; +in { + profile = { + default_profile = String; + entries = [...{ + selector = String; profile = Profile; label = String; description = String; + }]; + }; +} diff --git a/resources/config-schema/profile.dcdl b/resources/config-schema/profile.dcdl new file mode 100644 index 000000000..aff4ccadc --- /dev/null +++ b/resources/config-schema/profile.dcdl @@ -0,0 +1,11 @@ +{ + profile = { + default_profile = String default "builtin:companion"; + entries = [...{ + selector = String; + profile = { ...Unknown }; + label = String default ""; + description = String default ""; + }] default []; + }; +} diff --git a/resources/profiles/job.dcdl b/resources/profiles/job.dcdl new file mode 100644 index 000000000..87683ac27 --- /dev/null +++ b/resources/profiles/job.dcdl @@ -0,0 +1,27 @@ +slug = "job"; +description = "Result-only Job policy; the Host grants attempt/result authority independently."; +model = { ref = "codex-oauth/gpt-5.6-luna"; }; +engine = { + instruction = "internal.job_system"; + reasoning = "medium"; + max_tokens = 8192; + max_turns = 32; +}; +session = { record_event_trace = false; }; +feature = { + task = { enabled = false; }; + memory = { enabled = false; staging_tools = false; }; + subjektiv = { enabled = false; consolidation_tools = false; extraction = { enabled = false; }; }; + web = { enabled = false; }; + image = { enabled = false; }; + sub_worker = { enabled = false; }; + flow = { enabled = false; }; + worker = { enabled = false; direct_spawn = false; }; + workspace_worker_discovery = { enabled = false; }; + objective = { enabled = false; }; + manage_workdir = { enabled = false; }; + workspace_config = { enabled = false; }; + ticket = { enabled = false; authoring = false; thread = false; intake = false; workflow = false; }; + merge_request = { show = false; open = false; review = false; readiness_check = false; complete = false; }; + orchestration = { enabled = false; }; +}; 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; }; }; + }; +} diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl index e1039ba32..991e702e9 100644 --- a/resources/prompts/catalog.dcdl +++ b/resources/prompts/catalog.dcdl @@ -21,6 +21,7 @@ roleReviewer = import "./role/reviewer.md"; internalCompactSystem = import "./internal/compact_system.md"; internalFlowVerifierSystem = import "./internal/flow_verifier_system.md"; internalBackendJobSystem = import "./internal/backend_job_system.md"; +internalJobSystem = import "./internal/job_system.md"; internalTicketItemChecker = import "./internal/ticket_item_checker.md"; internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md"; internalSubjektivMemoryConsolidationSystem = import "./internal/subjektiv_memory_consolidation_system.md"; @@ -62,6 +63,7 @@ in compact_system = internalCompactSystem.content; flow_verifier_system = internalFlowVerifierSystem.content; backend_job_system = internalBackendJobSystem.content; + job_system = internalJobSystem.content; ticket_item_checker = internalTicketItemChecker.content; memory_consolidation_system = internalMemoryConsolidationSystem.content; subjektiv_memory_consolidation_system = internalSubjektivMemoryConsolidationSystem.content; diff --git a/resources/prompts/internal/job_system.md b/resources/prompts/internal/job_system.md new file mode 100644 index 000000000..2b254ef7c --- /dev/null +++ b/resources/prompts/internal/job_system.md @@ -0,0 +1,7 @@ +You execute one bounded Host-owned Job against its immutable input envelope. + +The caller selects the Profile. The Host, not the Profile name or task prose, binds Job/attempt/input identity and grants tools. Use only the capabilities provided for this execution. Do not infer Workspace, filesystem, parent Worker, or domain permissions from the input. + +Submit a structured result through the Host-provided Job result tool. Complete any required postprocessing before submitting, unless its durable ownership is explicitly established independently of this Worker. Final prose, Idle, and Worker termination are not success authority. Do not claim a submission succeeded if the tool rejects it. + +On a definitive validation rejection, correct the result. If acceptance is uncertain, retry only the exact submitted result; never change a result already accepted. Never ask to run another attempt or access another Job's capabilities. diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 9479ef44f..e17a7fb8b 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "build": "deno run -A npm:vite@8.3.2 build", "preview": "deno run -A npm:vite@8.3.2 preview", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 8a530c53d..f85678a77 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da) +// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index 7a34b2e60..aeb99cdaf 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -237,6 +237,10 @@ export type ConfigSchemaContribution = { version: string; source: string; projection_validator?: ConfigProjectionValidator; + /** + * Completion-only shape for partial values; never materialized as defaults. + */ + authoring_source?: string; source_digest: string; }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 5b72d2098..2ac059cfb 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da) +// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 53eeae412..9197abbc9 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da) +// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 657aa7d1b..530b8fbe0 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da) +// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index f56aba174..2ec93ec9e 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da) +// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts index 6355eaa4d..4e28bd4f9 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.browser.spec.ts @@ -216,6 +216,51 @@ test("unmount during schema setup closes the toolchain", async () => { expect(toolchain.close).toHaveBeenCalledOnce(); }); +test("builtin diagnostics show read-only paths without adding editable workspace files", async () => { + toolchain.analyze.mockResolvedValue([{ + path: "$builtin/profiles/companion.dcdl", + revision: 7, + tree_digest: "sha256:tree", + kind: "constraint_violation", + span: { start_byte: 1, end_byte: 4 }, + message: "Imported value does not match the requested schema", + labels: [], + notes: [], + }]); + const view = renderEditor(); + await expectReady(view); + await waitFor(() => { + const diagnostic = view.container.querySelector(".config-source-diagnostics"); + expect(diagnostic?.textContent).toContain("$builtin/profiles/companion.dcdl (read-only builtin source)"); + expect(diagnostic?.textContent).toContain("bytes 1–4"); + }); + const paths = view.getByRole("navigation", { name: "Virtual configuration paths" }); + expect(paths.textContent).not.toContain("$builtin/"); + expect(paths.querySelectorAll("button")).toHaveLength(1); + expect(view.container.querySelector('.cm-content[contenteditable="true"]')).not.toBeNull(); +}); + +test("unknown builtin diagnostics retain the workspace importer path", async () => { + toolchain.analyze.mockResolvedValue([{ + path: "main.dcdl", + revision: 7, + tree_digest: "sha256:tree", + kind: "import", + span: { start_byte: 8, end_byte: 40 }, + message: "unknown or non-public read-only builtin source: $builtin/profiles/missing.dcdl", + labels: [], + notes: [], + }]); + const view = renderEditor(); + await expectReady(view); + await waitFor(() => { + const diagnostic = view.container.querySelector(".config-source-diagnostics"); + expect(diagnostic?.textContent).toContain("main.dcdl · bytes 8–40"); + expect(diagnostic?.textContent).toContain("unknown or non-public read-only builtin source"); + }); + expect(view.getByRole("navigation", { name: "Virtual configuration paths" }).textContent).not.toContain("$builtin/"); +}); + test("unmount during fetch does not start a toolchain", async () => { let finishFetch!: (response: Response) => void; fetcher.mockReturnValueOnce( diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index 78f9edb44..b1064abcd 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -377,7 +377,7 @@
  • {diagnostic.kind} {diagnostic.message} - bytes {diagnostic.span.start_byte}–{diagnostic.span.end_byte} + {diagnostic.path}{diagnostic.path.startsWith("$builtin/") ? " (read-only builtin source)" : ""} · bytes {diagnostic.span.start_byte}–{diagnostic.span.end_byte}
  • {/each} diff --git a/web/workspace/src/lib/workspace/config-source/api.ts b/web/workspace/src/lib/workspace/config-source/api.ts index c277ccc5d..2afb76407 100644 --- a/web/workspace/src/lib/workspace/config-source/api.ts +++ b/web/workspace/src/lib/workspace/config-source/api.ts @@ -187,7 +187,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution { const item = record( value, ["provider_id", "namespace", "version", "source", "source_digest"], - ["projection_validator"], + ["projection_validator", "authoring_source"], ); const projection = item.projection_validator === undefined ? undefined @@ -197,6 +197,12 @@ function parseContribution(value: unknown): ConfigSchemaContribution { namespace: boundedString(item.namespace), version: boundedString(item.version), source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES), + ...(item.authoring_source === undefined ? {} : { + authoring_source: boundedString( + item.authoring_source, + MAX_SCHEMA_SOURCE_BYTES, + ), + }), ...(projection === undefined ? {} : { projection_validator: projection }), source_digest: boundedString(item.source_digest, MAX_DIGEST_BYTES), }; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index 4662c4ac3..b6468fe93 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts index 69d5a595d..957a1a06a 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/types/ConfigSchemaContribution.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfigProjectionValidator } from "./ConfigProjectionValidator"; -export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, projection_validator?: ConfigProjectionValidator | null, source_digest: string, }; +export type ConfigSchemaContribution = { provider_id: string, namespace: string, version: string, source: string, authoring_source?: string | null, projection_validator?: ConfigProjectionValidator | null, source_digest: string, }; diff --git a/web/workspace/test/config-source/api.test.ts b/web/workspace/test/config-source/api.test.ts index d0269c215..819260527 100644 --- a/web/workspace/test/config-source/api.test.ts +++ b/web/workspace/test/config-source/api.test.ts @@ -226,6 +226,62 @@ Deno.test("config source API rejects unsafe revisions and oversized entry conten ); }); +Deno.test("config source API does not materialize imported builtins in the workspace file list", async () => { + const body = { + ...tree, + snapshot: { + ...snapshot, + entries: { + "profiles/main.dcdl": { + ...entry, + content: 'import "$builtin/profiles/companion.dcdl"', + }, + }, + }, + }; + const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; + const result = await fetchConfigTree("w", fetcher); + assertEquals(Object.keys(result.snapshot.entries), ["profiles/main.dcdl"]); + assertEquals( + result.snapshot.entries["profiles/main.dcdl"].content, + body.snapshot.entries["profiles/main.dcdl"].content, + ); +}); + +Deno.test("config source API preserves unknown read-only builtin diagnostics without fallback requests", async () => { + const diagnostic = { + path: "main.dcdl", + revision: 7, + tree_digest: "sha256:tree", + kind: "import", + span: { start_byte: 8, end_byte: 40 }, + message: + "unknown or non-public read-only builtin source: $builtin/profiles/missing.dcdl", + labels: [], + notes: [], + }; + const calls: string[] = []; + const fetcher = ((input: string | URL | Request) => { + calls.push(String(input)); + return Promise.resolve( + response({ error: JSON.stringify([diagnostic]) }, 400), + ); + }) as typeof fetch; + const error = await assertRejects( + () => + commitConfigTree("w", { + base_revision: 7, + base_digest: "sha256:tree", + changes: [], + entrypoints: ["main.dcdl"], + }, fetcher), + ConfigSourceApiError, + diagnostic.message, + ); + assert(String(error).includes("main.dcdl")); + assertEquals(calls, ["/api/w/w/config/source-tree/commit"]); +}); + Deno.test("config source API surfaces bounded failed evaluation details", async () => { const fetcher = (() => Promise.resolve( @@ -244,3 +300,41 @@ Deno.test("config source API surfaces bounded failed evaluation details", async } assert(message.includes("structured diagnostics")); }); + +Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => { + const body = withSchema("validation"); + const authoringSource = "shape\n".repeat(6000); + const contribution = { + ...body.contract.schema_bundle.contributions[0], + authoring_source: authoringSource, + }; + const withAuthoring = { + ...body, + contract: { + ...body.contract, + schema_bundle: { + ...body.contract.schema_bundle, + contributions: [contribution], + }, + }, + }; + const parsed = parseWorkspaceConfigTreeResponse(withAuthoring); + assertEquals( + parsed.contract.schema_bundle.contributions[0].authoring_source, + authoringSource, + ); + assertEquals(parsed.contract.schema_bundle.source, "validation"); + for (const invalid of [null, 42, {}, "é".repeat(4 * 1024 * 1024) + "x"]) { + assertThrows(() => + parseWorkspaceConfigTreeResponse({ + ...withAuthoring, + contract: { + ...body.contract, + schema_bundle: { + ...body.contract.schema_bundle, + contributions: [{ ...contribution, authoring_source: invalid }], + }, + }, + }), ConfigSourceApiError); + } +}); diff --git a/web/workspace/test/config-source/toolchain.test.ts b/web/workspace/test/config-source/toolchain.test.ts index 4e28fe2cf..630f250bf 100644 --- a/web/workspace/test/config-source/toolchain.test.ts +++ b/web/workspace/test/config-source/toolchain.test.ts @@ -89,6 +89,31 @@ Deno.test("completion starts only after non-whitespace typing", () => { ); }); +Deno.test("toolchain preserves read-only builtin import completion detail", () => { + const result = toCodeMirrorCompletion({ + from: 8, + items: [{ + label: "$builtin/profiles/companion.dcdl", + kind: "file", + detail: "read-only builtin Decodal source", + priority: 0, + }], + }); + assert(result !== null, "builtin completion should be available"); + assert( + result.options[0].label === "$builtin/profiles/companion.dcdl", + "builtin namespace must not become a workspace path", + ); + assert( + result.options[0].detail === "read-only builtin Decodal source", + "CodeMirror must display the read-only completion detail", + ); + assert( + result.options[0].type === "file", + "builtin imports remain file completions", + ); +}); + Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => { const result = toCodeMirrorCompletion({ from: "let 名 = ".length, diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index 23449ea63..305074c96 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -299,6 +299,150 @@ Deno.test("generated WASM returns completion items for the editor adapter", () = assertEquals(result.items[0].kind, "file"); }); +Deno.test("generated WASM exposes read-only builtin completions without editable builtin entries", () => { + const tree = schemaSnapshot("{}"); + set_snapshot(tree); + set_schema_bundle(emptySchemaBundle); + const source = 'let 名 = 1; import "$builtin/profiles/comp"'; + const result = complete_current( + "main.dcdl", + source, + source.length - 1, + true, + ) as { + from: number; + items: Array<{ label: string; kind: string; detail: string }>; + }; + const companion = result.items.find((item) => + item.label === "$builtin/profiles/companion.dcdl" + ); + assertEquals(result.from, 'let 名 = 1; import "'.length); + assertEquals(companion?.kind, "file"); + assertEquals(companion?.detail, "read-only builtin Decodal source"); + assertEquals(Object.keys(tree.entries), ["main.dcdl"]); + const evaluated = evaluate_snapshot( + schemaSnapshot('import "$builtin/profiles/companion.dcdl"'), + mainEntrypointContract, + ) as { + projections: Array<{ data_json: { slug: string } }>; + }; + assertEquals(evaluated.projections[0].data_json.slug, "companion"); +}); + +Deno.test("generated WASM rejects unknown builtin imports without same-suffix workspace fallback", () => { + const tree = schemaSnapshot('import "$builtin/profiles/missing.dcdl"'); + tree.entries["profiles/missing.dcdl"] = { + path: "profiles/missing.dcdl", + content_type: "decodal", + content: "{}", + content_digest: "sha256:workspace-fallback", + }; + set_schema_bundle(emptySchemaBundle); + const diagnostics = analyze_snapshot(tree, "main.dcdl", undefined) as Array<{ + path: string; + revision: number; + tree_digest: string; + message: string; + }>; + assertEquals(diagnostics.length > 0, true); + assertEquals(diagnostics[0].path, "main.dcdl"); + assertEquals(diagnostics[0].revision, tree.revision); + assertEquals(diagnostics[0].tree_digest, tree.digest); + assertEquals( + diagnostics[0].message.includes( + "unknown or non-public read-only builtin source", + ), + true, + ); + assertEquals( + diagnostics[0].message.includes("$builtin/profiles/missing.dcdl"), + true, + ); + let failed = false; + try { + evaluate_snapshot(tree, mainEntrypointContract); + } catch (error) { + failed = true; + assertEquals( + (error as Array<{ message: string }>)[0].message.includes( + "unknown or non-public read-only builtin source", + ), + true, + ); + } + assertEquals(failed, true); + assertEquals( + analyze_snapshot(tree, "main.dcdl", 'import "./profiles/missing.dcdl"'), + [], + ); +}); + +function importFailure(tree: ConfigTreeSnapshot): ProjectedDiagnostic[] { + try { + evaluate_snapshot(tree, mainEntrypointContract); + } catch (error) { + // A WASM trap/stack overflow must not count as a structured import failure. + assertEquals(Array.isArray(error), true); + const diagnostics = error as Array< + ProjectedDiagnostic & { + revision: number; + tree_digest: string; + } + >; + assertEquals(diagnostics.length > 0, true); + assertEquals(diagnostics[0].revision, tree.revision); + assertEquals(diagnostics[0].tree_digest, tree.digest); + return diagnostics; + } + throw new Error("expected a structured import diagnostic"); +} + +Deno.test("generated WASM rejects nested lazy-object import cycles without a stack overflow", () => { + const tree = schemaSnapshot('{ nested = import "./b.dcdl"; }'); + tree.entries["b.dcdl"] = { + path: "b.dcdl", + content_type: "decodal", + content: '{ nested = import "./main.dcdl"; }', + content_digest: "sha256:nested-cycle-b", + }; + const diagnostics = importFailure(tree); + assertEquals(diagnostics[0].kind, "cycle"); + assertEquals(diagnostics[0].message.includes("import cycle"), true); +}); + +Deno.test("generated WASM enforces the import depth budget on a real 33-deep chain", () => { + function chain(depth: number): ConfigTreeSnapshot { + const tree = schemaSnapshot('import "./chain/1.dcdl"'); + for (let index = 1; index <= depth; index++) { + const path = `chain/${index}.dcdl`; + tree.entries[path] = { + path, + content_type: "decodal", + content: index === depth + ? "{ answer = 42; }" + : `import "./${index + 1}.dcdl"`, + content_digest: `sha256:chain-${depth}-${index}`, + }; + } + return tree; + } + // The synthetic evaluation-wrapper import consumes one of the 32 edges. + const accepted = evaluate_snapshot(chain(31), mainEntrypointContract) as { + projections: Array<{ data_json: { answer: number } }>; + }; + assertEquals(accepted.projections[0].data_json, { answer: 42 }); + for (const depth of [32, 33]) { + const diagnostics = importFailure(chain(depth)); + assertEquals(diagnostics[0].kind, "import"); + assertEquals( + diagnostics.some((diagnostic) => + diagnostic.message.includes("import depth") + ), + true, + ); + } +}); + Deno.test("generated WASM completes blank nested schema positions after Unicode", () => { const source = '{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema'; @@ -381,3 +525,376 @@ Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => assertEquals(result.from, 2); assertEquals(result.items.some((item) => item.label === "profile"), true); }); + +function fixtureDecodal(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(fixtureDecodal).join(", ")}]`; + if (value !== null && typeof value === "object") { + return `{ ${ + Object.entries(value).map(([key, value]) => + `${key} = ${fixtureDecodal(value)};` + ).join(" ") + } }`; + } + if ( + typeof value === "string" || typeof value === "number" || + typeof value === "boolean" + ) return JSON.stringify(value); + throw new Error("fixture omission must not be represented as null/undefined"); +} + +Deno.test("generated WASM authors value-based Profiles using the Backend schema without materializing authoring fields", async () => { + const source = await Deno.readTextFile( + new URL( + "../../../../resources/config-schema/profile.dcdl", + import.meta.url, + ), + ); + const authoring_source = await Deno.readTextFile( + new URL( + "../../../../resources/config-schema/profile-authoring.dcdl", + import.meta.url, + ), + ); + const schema = compose_schema_bundle([{ + provider_id: "builtin:profile", + namespace: "profile", + version: "2", + source, + source_digest: await digestText(source), + authoring_source, + }]) as WorkspaceConfigSchemaBundle; + const content = + '{ profile = { entries = [{ selector = "project:alpha"; profile = {}; }]; }; } as WorkspaceConfigSchema'; + const tree: ConfigTreeSnapshot = { + revision: 8, + digest: "sha256:value-profile-tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content, + content_digest: await digestText(content), + }, + }, + }; + const valueContract = { + ...contract, + entrypoints: ["main.dcdl"], + schema_bundle: schema, + fingerprint: await toolchainFingerprint(["main.dcdl"], schema), + }; + set_snapshot(tree); + set_schema_bundle(schema); + const evaluated = evaluate_snapshot(tree, valueContract) as { + projections: Array< + { data_json: { profile: { entries: Array<{ profile: unknown }> } } } + >; + }; + assertEquals( + evaluated.projections[0].data_json.profile.entries[0].profile, + {}, + ); + for ( + const [body, token, label] of [ + [ + "{ profile = { entries = [{ sel }] } } as WorkspaceConfigSchema", + "sel", + "selector", + ], + [ + "{ profile = { entries = [{ pro }] } } as WorkspaceConfigSchema", + "pro", + "profile", + ], + [ + "{ profile = { entries = [{ profile = { wor } }] } } as WorkspaceConfigSchema", + "wor", + "worker", + ], + [ + "{ profile = { entries = [{ profile = { worker = { mo } } }] } } as WorkspaceConfigSchema", + "mo", + "mode", + ], + [ + "{ profile = { entries = [{ profile = { feature = { ta } } }] } } as WorkspaceConfigSchema", + "ta", + "task", + ], + ] + ) { + const cursor = body.lastIndexOf(token) + token.length; + const result = complete_current("main.dcdl", body, cursor, true) as { + from: number; + items: Array<{ label: string }>; + }; + assertEquals( + result.items.some((item) => item.label === label), + true, + label, + ); + assertEquals(result.from, cursor - token.length); + } + for ( + const value of [ + "{}", + "{ worker = {}; }", + '{ worker = { mode = "wip"; }; }', + "{ feature = { task = {}; }; }", + ] + ) { + const partialContent = content.replace( + "profile = {};", + `profile = ${value};`, + ); + assertEquals( + analyze_snapshot(tree, "main.dcdl", partialContent), + [], + value, + ); + } + const recipe = "{ worker = { mode = 42; }; }"; + const analysisTree = { + ...tree, + entries: { + ...tree.entries, + "recipe.dcdl": { + path: "recipe.dcdl", + content_type: "decodal", + content: recipe, + content_digest: await digestText(recipe), + }, + }, + }; + for ( + const [value, field, expectedPath] of [ + ["42", "profile", "main.dcdl"], + ["{ worker = 42; }", "worker", "main.dcdl"], + ["{ worker = { mode = 42; }; }", "mode", "main.dcdl"], + ["{ feature = { task = { enabled = 42; }; }; }", "enabled", "main.dcdl"], + ['{ worker = { typo = "wip"; }; }', "typo", "main.dcdl"], + ['import "./recipe.dcdl"', "mode", "recipe.dcdl"], + [ + '(import "./recipe.dcdl") // { worker = { mode = 42; }; }', + "mode", + "main.dcdl", + ], + ] + ) { + const invalidContent = content.replace( + "profile = {};", + `profile = ${value};`, + ); + const diagnostics = analyze_snapshot( + analysisTree, + "main.dcdl", + invalidContent, + ) as Array<{ + path: string; + revision: number; + kind: string; + span: { start_byte: number; end_byte: number }; + message: string; + labels: Array< + { span: { start_byte: number; end_byte: number }; message: string } + >; + }>; + assertEquals(diagnostics.length > 0, true, value); + const diagnostic = diagnostics[0]; + assertEquals(diagnostic.path, expectedPath); + assertEquals(diagnostic.revision, tree.revision); + assertEquals( + ["constraint_violation", "type_mismatch"].includes(diagnostic.kind), + true, + ); + assertEquals( + diagnostic.message.includes(field) || + diagnostic.labels.some((label) => label.message.includes(field)), + true, + ); + // New authoring failures must be anchored in the supplied value, not the + // much longer schema source, including relative imported recipes. + if (value !== "42") { + const length = new TextEncoder().encode( + expectedPath === "main.dcdl" ? invalidContent : recipe, + ).length; + assertEquals( + diagnostic.span.end_byte > diagnostic.span.start_byte && + diagnostic.span.end_byte <= length, + true, + ); + assertEquals( + diagnostic.labels.every((label) => label.span.end_byte <= length), + true, + ); + } + } + for (const field of ["scope", "delegation_scope"]) { + for ( + const [prefix, label] of [["int", "intent"], ["deny", "deny_write"], [ + "sym", + "symlink_policy", + ]] + ) { + const body = + `{ profile.entries = [{ profile.${field} = { ${prefix} } }]; } as WorkspaceConfigSchema`; + const cursor = body.lastIndexOf(prefix) + prefix.length; + const result = complete_current("main.dcdl", body, cursor, true) as { + items: Array<{ label: string }>; + }; + assertEquals( + result.items.some((item) => item.label === label), + true, + `${field}.${label}`, + ); + } + } + for ( + const [recipe, expected] of [ + ['{ scope = "workspace_read"; delegation_scope = "workspace_write"; }', { + scope: "workspace_read", + delegation_scope: "workspace_write", + }], + ['{ scope = "workspace_write"; delegation_scope = "workspace_read"; }', { + scope: "workspace_write", + delegation_scope: "workspace_read", + }], + [ + '{ scope = { intent = "workspace_read"; }; delegation_scope = { intent = "workspace_write"; deny_write = ["private"]; symlink_policy = "resolved"; }; }', + { + scope: { intent: "workspace_read" }, + delegation_scope: { + intent: "workspace_write", + deny_write: ["private"], + symlink_policy: "resolved", + }, + }, + ], + ] as const + ) { + for ( + const form of [ + recipe, + 'import "./scoped.dcdl"', + '(import "./scoped.dcdl") // { description = "patched"; }', + ] + ) { + const scopedContent = content.replace( + "profile = {};", + `profile = ${form};`, + ); + const scopedTree = { + ...tree, + entries: { + "main.dcdl": { + ...tree.entries["main.dcdl"], + content: scopedContent, + content_digest: await digestText(scopedContent), + }, + "scoped.dcdl": { + path: "scoped.dcdl", + content_type: "decodal", + content: recipe, + content_digest: await digestText(recipe), + }, + }, + }; + assertEquals( + analyze_snapshot(scopedTree, "main.dcdl", undefined), + [], + form, + ); + const result = evaluate_snapshot( + scopedTree, + valueContract, + ) as typeof evaluated; + assertEquals( + result.projections[0].data_json.profile.entries[0].profile, + form.includes("patched") + ? { ...expected, description: "patched" } + : expected, + ); + } + } + const cases = JSON.parse( + await Deno.readTextFile( + new URL( + "../../../../resources/config-schema/profile-authoring-values.json", + import.meta.url, + ), + ), + ) as Array<{ name: string; profile: Record }>; + for (const fixture of cases) { + const recipe = fixtureDecodal(fixture.profile); + for ( + const [form, patched] of [[recipe, false], [ + 'import "./recipe.dcdl"', + false, + ], [ + '(import "./recipe.dcdl") // { description = "patched"; }', + true, + ]] as const + ) { + const fixtureContent = content.replace( + "profile = {};", + `profile = ${form};`, + ); + const fixtureTree = { + ...tree, + entries: { + "main.dcdl": { + ...tree.entries["main.dcdl"], + content: fixtureContent, + content_digest: await digestText(fixtureContent), + }, + "recipe.dcdl": { + path: "recipe.dcdl", + content_type: "decodal", + content: recipe, + content_digest: await digestText(recipe), + }, + }, + }; + assertEquals( + analyze_snapshot(fixtureTree, "main.dcdl", undefined), + [], + `${fixture.name}: ${form}`, + ); + const result = evaluate_snapshot( + fixtureTree, + valueContract, + ) as typeof evaluated; + assertEquals( + result.projections[0].data_json.profile.entries[0].profile, + patched + ? { ...fixture.profile, description: "patched" } + : fixture.profile, + ); + } + } + for ( + const [path, prefix, label] of [ + ["compaction", "reta", "retained_tokens"], + ["compaction", "prune_min", "prune_min_savings"], + ["compaction", "req", "request"], + ["compaction", "wor", "worker"], + ["compaction", "compact_ret", "compact_retained_tokens"], + ["feature.memory.extraction", "reas", "reasoning"], + ["feature.subjektiv.extraction", "reas", "reasoning"], + ] + ) { + const body = + `{ profile.entries = [{ profile.${path} = { ${prefix} } }]; } as WorkspaceConfigSchema`; + const cursor = body.lastIndexOf(prefix) + prefix.length; + const result = complete_current("main.dcdl", body, cursor, true) as { + items: Array<{ label: string }>; + }; + assertEquals( + result.items.some((item) => item.label === label), + true, + `${path}.${label}`, + ); + } + // Neither completion nor diagnostic shape checking changes the saved value. + assertEquals(evaluate_snapshot(tree, valueContract), evaluated); +});