Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions crates/traverse-embedder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>>(())
```

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
Expand Down
195 changes: 195 additions & 0 deletions crates/traverse-embedder/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<dyn DataStore>,
classification: LocalDataClassification,
}

impl HostDataStore {
/// Wrap a host-created adapter with its fixed data classification.
#[must_use]
pub fn new<A>(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 {
Expand Down Expand Up @@ -1068,6 +1127,7 @@ pub struct BundleEmbedder {
wasm_targets: BTreeMap<String, WasmTarget>,
workflow_targets: BTreeMap<String, WorkflowTarget>,
wasm_component_evidence: Value,
data_store: Option<HostDataStore>,
}

impl BundleEmbedder {
Expand Down Expand Up @@ -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<Option<StateRecord>, 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();
Expand Down Expand Up @@ -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 = [
Expand Down
Loading
Loading