diff --git a/crates/traverse-embedder/README.md b/crates/traverse-embedder/README.md index 6dcc20a0..8d617a9d 100644 --- a/crates/traverse-embedder/README.md +++ b/crates/traverse-embedder/README.md @@ -36,6 +36,41 @@ embedder.submit("my-app.process", &json!({ "note": "hello" })); embedder.shutdown(); ``` +## Optional host-owned local state + +Durable state is opt-in and remains owned by the embedding host. The host +chooses the root, creates the adapter, fixes the public/private +classification, and injects it after initialization. Traverse does not derive +a root, create one by default, or expose the store to capabilities. + +```rust,no_run +use serde_json::json; +use traverse_embedder::{BundleEmbedder, EmbedderConfig, HostDataStore}; +use traverse_runtime::data_store::{ + LocalDataClassification, LocalFileDataStore, StateRecord, +}; + +let mut embedder = BundleEmbedder::init(EmbedderConfig::new("app/app.manifest.json"))?; +let local_store = LocalFileDataStore::new("/host-selected/app-state")?; +embedder.inject_data_store(HostDataStore::new( + local_store, + LocalDataClassification::Private, +)); +embedder.data_store_write(StateRecord { + key: "last-opened".into(), + value: json!("document-42"), + lamport_clock: 1, + writer_id: "my-host".into(), +})?; +# Ok::<(), Box>(()) +``` + +Only explicit host `read`, `write`, and `delete` operations are available. +Their safe error codes include `data_store_not_configured`, `store_locked`, +`integrity_check_failed`, `durability_commit_failed`, and `storage_io_failed`. +DataStore telemetry reports only operation, outcome, and classification — never +the root, keys, or values. + ## Bundle input shape `init` consumes the `app.manifest.json` bundle defined by spec diff --git a/crates/traverse-embedder/src/lib.rs b/crates/traverse-embedder/src/lib.rs index 13ad9606..9f44921f 100644 --- a/crates/traverse-embedder/src/lib.rs +++ b/crates/traverse-embedder/src/lib.rs @@ -94,6 +94,9 @@ use traverse_registry::{ CapabilityRegistry, ComponentExecutionMode, EventRegistry, RegistryScope, WorkflowRegistry, load_application_bundle_manifest, }; +use traverse_runtime::data_store::{ + DataStore, DataStoreError, DataStoreErrorCode, LocalDataClassification, StateRecord, +}; use traverse_runtime::{ ArtifactRouter, ExecutionFailureReason, PlacementTarget, Runtime, RuntimeContext, RuntimeError, RuntimeErrorCode, RuntimeExecutionOutcome, RuntimeIntent, RuntimeLookup, RuntimeLookupScope, @@ -329,6 +332,62 @@ impl EmbedderConfig { } } +/// An explicitly host-owned local store that may be injected into a +/// [`BundleEmbedder`]. The host selects its root and lifecycle before +/// constructing this wrapper; Traverse never receives a root path. +pub struct HostDataStore { + adapter: Box, + classification: LocalDataClassification, +} + +impl HostDataStore { + /// Wrap a host-created adapter with its fixed data classification. + #[must_use] + pub fn new(adapter: A, classification: LocalDataClassification) -> Self + where + A: DataStore + 'static, + { + Self { + adapter: Box::new(adapter), + classification, + } + } +} + +/// Safe public projection of a `DataStore` failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedDataStoreError { + /// Stable machine-readable failure code. + pub code: &'static str, + /// Safe operation metadata; it never contains a key, value, or host path. + pub operation: &'static str, +} + +impl EmbeddedDataStoreError { + fn not_configured(operation: &'static str) -> Self { + Self { + code: "data_store_not_configured", + operation, + } + } + + fn from_error(operation: &'static str, error: &DataStoreError) -> Self { + let code = match error.code { + DataStoreErrorCode::IntegrityCheckFailed => "integrity_check_failed", + DataStoreErrorCode::StoreLocked => "store_locked", + DataStoreErrorCode::DurabilityCommitFailed => "durability_commit_failed", + DataStoreErrorCode::IoFailure => "storage_io_failed", + DataStoreErrorCode::InvalidKey => "invalid_key", + DataStoreErrorCode::SerializationFailure => "serialization_failed", + DataStoreErrorCode::SchemaValidationError => "schema_validation_failed", + DataStoreErrorCode::NoStateSchemaDeclared => "state_schema_unavailable", + DataStoreErrorCode::LamportClockOverflow => "lamport_clock_overflow", + DataStoreErrorCode::SyncFailure => "sync_failed", + }; + Self { code, operation } + } +} + /// Stable embedder-boundary error codes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EmbedderErrorCode { @@ -1068,6 +1127,7 @@ pub struct BundleEmbedder { wasm_targets: BTreeMap, workflow_targets: BTreeMap, wasm_component_evidence: Value, + data_store: Option, } impl BundleEmbedder { @@ -1152,9 +1212,98 @@ impl BundleEmbedder { wasm_targets: targets.wasm, workflow_targets: targets.workflows, wasm_component_evidence: Value::Array(targets.wasm_component_evidence), + data_store: None, }) } + /// Explicitly injects a host-owned `DataStore`. + /// + /// This additive host surface is deliberately separate from capability + /// execution. It accepts neither a root path nor a capability identity, + /// so the runtime cannot derive storage locations or grant capability + /// code direct access. + pub fn inject_data_store(&mut self, store: HostDataStore) { + self.data_store = Some(store); + } + + /// Reads one host-owned state record from the injected `DataStore`. + /// + /// Returns `None` when the injected store has no record for `key`. + /// + /// # Errors + /// + /// Returns a safe typed failure when no store was injected or its read + /// operation fails. + pub fn data_store_read( + &mut self, + key: &str, + ) -> Result, EmbeddedDataStoreError> { + let result = match self.data_store.as_ref() { + Some(store) => store + .adapter + .read(key) + .map_err(|error| EmbeddedDataStoreError::from_error("read", &error)), + None => Err(EmbeddedDataStoreError::not_configured("read")), + }; + self.record_data_store_operation("read", result.is_ok()); + result + } + + /// Writes one host-owned state record to the injected `DataStore`. + /// + /// # Errors + /// + /// Returns a safe typed failure when no store was injected or its write + /// operation fails. + pub fn data_store_write(&mut self, record: StateRecord) -> Result<(), EmbeddedDataStoreError> { + let result = match self.data_store.as_mut() { + Some(store) => store + .adapter + .write(record) + .map_err(|error| EmbeddedDataStoreError::from_error("write", &error)), + None => Err(EmbeddedDataStoreError::not_configured("write")), + }; + self.record_data_store_operation("write", result.is_ok()); + result + } + + /// Deletes one host-owned state record from the injected `DataStore`. + /// + /// # Errors + /// + /// Returns a safe typed failure when no store was injected or its delete + /// operation fails. + pub fn data_store_delete(&mut self, key: &str) -> Result<(), EmbeddedDataStoreError> { + let result = match self.data_store.as_mut() { + Some(store) => store + .adapter + .delete(key) + .map_err(|error| EmbeddedDataStoreError::from_error("delete", &error)), + None => Err(EmbeddedDataStoreError::not_configured("delete")), + }; + self.record_data_store_operation("delete", result.is_ok()); + result + } + + fn record_data_store_operation(&mut self, operation: &'static str, succeeded: bool) { + let classification = self + .data_store + .as_ref() + .map(|store| match store.classification { + LocalDataClassification::Public => "public", + LocalDataClassification::Private => "private", + }); + self.core.emit( + "data_store_operation", + None, + json!({ + "operation": operation, + "outcome": if succeeded { "completed" } else { "failed" }, + "classification": classification, + }), + ); + } + fn submit_workflow(&mut self, target_id: &str, input: &Value) -> SubmitOutcome { let workflow_version = self.workflow_targets[target_id].workflow_version.clone(); let session_id = self.core.next_session_id(); @@ -1644,6 +1793,52 @@ mod tests { } } + #[test] + fn datastore_errors_map_to_safe_stable_codes() { + let codes = [ + ( + DataStoreErrorCode::IntegrityCheckFailed, + "integrity_check_failed", + ), + (DataStoreErrorCode::StoreLocked, "store_locked"), + ( + DataStoreErrorCode::DurabilityCommitFailed, + "durability_commit_failed", + ), + (DataStoreErrorCode::IoFailure, "storage_io_failed"), + (DataStoreErrorCode::InvalidKey, "invalid_key"), + ( + DataStoreErrorCode::SerializationFailure, + "serialization_failed", + ), + ( + DataStoreErrorCode::SchemaValidationError, + "schema_validation_failed", + ), + ( + DataStoreErrorCode::NoStateSchemaDeclared, + "state_schema_unavailable", + ), + ( + DataStoreErrorCode::LamportClockOverflow, + "lamport_clock_overflow", + ), + (DataStoreErrorCode::SyncFailure, "sync_failed"), + ]; + for (code, expected) in codes { + let error = EmbeddedDataStoreError::from_error( + "read", + &DataStoreError { + code, + message: "host details must not cross the boundary".to_string(), + details: json!({ "path": "/host/private" }), + }, + ); + assert_eq!(error.code, expected); + assert_eq!(error.operation, "read"); + } + } + #[test] fn runtime_error_codes_render_stable_snake_case_strings() { let codes = [ diff --git a/crates/traverse-embedder/tests/conformance.rs b/crates/traverse-embedder/tests/conformance.rs index 25be2cfc..7e5f86f4 100644 --- a/crates/traverse-embedder/tests/conformance.rs +++ b/crates/traverse-embedder/tests/conformance.rs @@ -11,9 +11,45 @@ use common::{ use serde_json::{Value, json}; use traverse_embedder::{ BundleEmbedder, CompatibleLifecycleStatus, EMBEDDED_TRACE_API_VERSION, EmbeddedTraceApi, - EmbeddedTraceOutcome, EmbedderConfig, EmbedderErrorCode, SecurityPosture, SubmitStatus, - TraverseEmbedderApi, + EmbeddedTraceOutcome, EmbedderConfig, EmbedderErrorCode, HostDataStore, SecurityPosture, + SubmitStatus, TraverseEmbedderApi, }; +use traverse_runtime::data_store::{ + DataStore, DataStoreError, DataStoreErrorCode, LocalDataClassification, LocalFileDataStore, + StateRecord, +}; + +struct FailingDataStore { + code: DataStoreErrorCode, +} + +impl FailingDataStore { + fn error(&self) -> DataStoreError { + DataStoreError { + code: self.code, + message: "host adapter failure".to_string(), + details: Value::Null, + } + } +} + +impl DataStore for FailingDataStore { + fn read(&self, _key: &str) -> Result, DataStoreError> { + Err(self.error()) + } + + fn write(&mut self, _record: StateRecord) -> Result<(), DataStoreError> { + Err(self.error()) + } + + fn delete(&mut self, _key: &str) -> Result<(), DataStoreError> { + Err(self.error()) + } + + fn list_keys(&self) -> Result, DataStoreError> { + Err(self.error()) + } +} fn development_embedder(fixture: &BundleFixture, platform: &str) -> BundleEmbedder { let mut config = EmbedderConfig::new(fixture.manifest_path()); @@ -237,3 +273,175 @@ fn conformance_matches_unsupported_schema_rejection() { assert_eq!(error.code, EmbedderErrorCode::UnsupportedBundleSchema); assert!(error.message.contains("9.9.9")); } + +#[test] +fn host_injected_datastore_reopens_without_leaking_record_metadata_to_events() { + let fixture = BundleFixture::new("host-datastore"); + let root = std::env::temp_dir().join(format!( + "traverse-embedder-host-datastore-{}", + std::process::id() + )); + let mut first = development_embedder(&fixture, "linux"); + let events = collect_events(&mut first); + let store = LocalFileDataStore::new(&root).expect("host should create store"); + first.inject_data_store(HostDataStore::new(store, LocalDataClassification::Private)); + let record = StateRecord { + key: "host-note".to_string(), + value: json!({ "secret": "do-not-emit" }), + lamport_clock: 1, + writer_id: "host-writer".to_string(), + }; + first + .data_store_write(record.clone()) + .expect("explicit host write should persist"); + drop(first); + + let mut second = development_embedder(&fixture, "linux"); + let reopened = LocalFileDataStore::new(&root).expect("host should reopen released store"); + second.inject_data_store(HostDataStore::new( + reopened, + LocalDataClassification::Private, + )); + assert_eq!( + second + .data_store_read("host-note") + .expect("explicit host read should succeed"), + Some(record) + ); + second + .data_store_delete("host-note") + .expect("explicit host delete should succeed"); + + let public_root = root.with_extension("public"); + let mut public_embedder = development_embedder(&fixture, "linux"); + let public_store = + LocalFileDataStore::with_classification(&public_root, LocalDataClassification::Public) + .expect("host should create public store"); + public_embedder.inject_data_store(HostDataStore::new( + public_store, + LocalDataClassification::Public, + )); + assert_eq!( + public_embedder + .data_store_read("missing") + .expect("public store read should succeed"), + None + ); + + let telemetry = serde_json::to_string(&snapshot(&events)).expect("events should serialize"); + assert!(telemetry.contains("data_store_operation")); + assert!(!telemetry.contains("host-note")); + assert!(!telemetry.contains("do-not-emit")); + drop(second); + drop(public_embedder); + let _ignored = std::fs::remove_dir_all(root); + let _ignored = std::fs::remove_dir_all(public_root); +} + +#[test] +fn host_datastore_operations_fail_closed_without_explicit_injection() { + let fixture = BundleFixture::new("host-datastore-unconfigured"); + let mut embedder = development_embedder(&fixture, "linux"); + let error = embedder + .data_store_read("host-note") + .expect_err("runtime must not create an implicit store"); + assert_eq!(error.code, "data_store_not_configured"); + assert_eq!(error.operation, "read"); + let write_error = embedder + .data_store_write(StateRecord { + key: "host-note".to_string(), + value: json!(null), + lamport_clock: 1, + writer_id: "host".to_string(), + }) + .expect_err("runtime must not create an implicit store"); + assert_eq!(write_error.code, "data_store_not_configured"); + let delete_error = embedder + .data_store_delete("host-note") + .expect_err("runtime must not create an implicit store"); + assert_eq!(delete_error.code, "data_store_not_configured"); +} + +#[test] +fn host_datastore_failure_codes_are_safe_and_classified() { + let fixture = BundleFixture::new("host-datastore-failures"); + let cases = [ + ( + DataStoreErrorCode::IntegrityCheckFailed, + "integrity_check_failed", + ), + (DataStoreErrorCode::StoreLocked, "store_locked"), + ( + DataStoreErrorCode::DurabilityCommitFailed, + "durability_commit_failed", + ), + (DataStoreErrorCode::IoFailure, "storage_io_failed"), + (DataStoreErrorCode::InvalidKey, "invalid_key"), + ( + DataStoreErrorCode::SerializationFailure, + "serialization_failed", + ), + ( + DataStoreErrorCode::SchemaValidationError, + "schema_validation_failed", + ), + ( + DataStoreErrorCode::NoStateSchemaDeclared, + "state_schema_unavailable", + ), + ( + DataStoreErrorCode::LamportClockOverflow, + "lamport_clock_overflow", + ), + (DataStoreErrorCode::SyncFailure, "sync_failed"), + ]; + + for (code, expected) in cases { + let mut embedder = development_embedder(&fixture, "linux"); + let events = collect_events(&mut embedder); + embedder.inject_data_store(HostDataStore::new( + FailingDataStore { code }, + LocalDataClassification::Public, + )); + + let error = embedder + .data_store_read("host-note") + .expect_err("host adapter error should be safe"); + assert_eq!(error.code, expected); + assert_eq!(error.operation, "read"); + assert_eq!(snapshot(&events)[0]["data"]["classification"], "public"); + } +} + +#[test] +fn host_datastore_write_and_delete_errors_are_safe() { + let fixture = BundleFixture::new("host-datastore-write-delete-failures"); + let mut embedder = development_embedder(&fixture, "linux"); + embedder.inject_data_store(HostDataStore::new( + FailingDataStore { + code: DataStoreErrorCode::IoFailure, + }, + LocalDataClassification::Public, + )); + let record = StateRecord { + key: "host-note".to_string(), + value: Value::Null, + lamport_clock: 1, + writer_id: "host-writer".to_string(), + }; + + assert_eq!( + embedder + .data_store_write(record) + .expect_err("host write failure should be safe") + .operation, + "write" + ); + assert_eq!( + embedder + .data_store_delete("host-note") + .expect_err("host delete failure should be safe") + .operation, + "delete" + ); +} diff --git a/scripts/ci/repository_checks.sh b/scripts/ci/repository_checks.sh index 607c7d6e..a65e498c 100644 --- a/scripts/ci/repository_checks.sh +++ b/scripts/ci/repository_checks.sh @@ -480,6 +480,12 @@ grep -q "Traverse runtime authority" specs/022-mcp-wasm-server/spec.md grep -q "MCP transport concerns" specs/022-mcp-wasm-server/spec.md grep -q "## Governing Spec" .github/pull_request_template.md +# Keep the tracked local pre-push path aligned with the two strict CI gates +# that regularly catch Rust API regressions: Clippy and line coverage. +grep -q 'bash scripts/ci/rust_checks.sh' scripts/ci/local_preflight.sh +grep -q 'cargo clippy --workspace --all-targets -- -D warnings' scripts/ci/rust_checks.sh +grep -q 'bash scripts/ci/coverage_gate.sh' scripts/ci/local_preflight.sh + echo "Running new-capability scaffold smoke..." TRAVERSE_REPO_ROOT="$(pwd)" bash "$(pwd)/scripts/ci/new_capability_scaffold_smoke.sh"