feat: deliver committed Ticket events to plugin services

This commit is contained in:
2026-10-31 04:58:19 +09:00
parent 4f7dcfda61
commit b508fef698
94 changed files with 11852 additions and 247 deletions
+4 -4
View File
@@ -1,15 +1,15 @@
# yoi-plugin-runtime
Runtime-owned verification, byte caching and standalone execution of exact v2 Tool Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans or repository paths, and does not register Tools with a Worker.
Runtime-owned verification, byte caching and standalone execution of exact versioned Tool and Service Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans or repository paths, and does not register Tools with a Worker.
[`delivery`] caches canonical bytes under the authenticated Server authority and artifact digest. Its source adapter must reauthorize the exact saved plan selection on every hit and after transfer. Byte deduplication shares neither metadata nor installation/config/grant authority. A private Runtime directory, bounded staging, process-released locking and atomic publication prevent partial files from becoming executor inputs; abandoned staging is discarded under the same lock. Cache deletion never releases Server pins. The cache is not an offline execution lease, and verified bytes still require the execution Host's fresh admission.
[`host`] is an explicit asynchronous injection boundary, not an implementation of Server authority or HTTPS. Every invocation (including pure computation) requires live admission and result-publication authorization. Providers must bind their authenticated installation/plan/subject and narrowed grants to [`host::InvocationContext`], authorize every protected operation, and make pending work cancellation-safe. There is no default provider or product allow-all fallback. Dropping a provider future must release temporary resources; an already dispatched external effect is not rolled back. Durable effect receipts, secret resolution, URL/DNS/TLS policy and real network transport belong to the Host provider, not this sandbox.
Component instantiation is not a Tool export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop semantics are outside this crate. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
Component instantiation is not an export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop exports permit only logging; event exports reuse the same Host and Ticket/data resources as Tools. [`ServiceInvocationResult`] retains Host effect observations independently of guest acknowledgement, including a dispatch whose wait is cancelled. This observation is not a second receipt authority: the driver must pause on unknown effects and commit acknowledgement through its owner. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials.
The Tool sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
The sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. This standalone core is not connected to normal Worker launch/restore; product integration must supply the live Server provider.
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. Product integration must supply the live Server provider and owns Worker launch/restore and Service driver lifetime.
+1
View File
@@ -358,6 +358,7 @@ impl ArtifactCache {
|| d.world != m.runtime.world
|| d.required_imports != m.runtime.required_imports
|| !same_json(&d.tools, &m.tools)?
|| !same_json(&d.services, &m.services)?
|| !same_json(&d.connections, &m.connections)?
|| !same_json(&d.requested_grants, &m.requested_grants)?
|| !same_json(&d.data_collections, &m.data_collections)?
@@ -44,6 +44,7 @@ fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) {
world: m.runtime.world,
required_imports: m.runtime.required_imports,
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
services: vec![],
connections: vec![],
requested_grants: vec![],
data_collections: vec![],
+20 -3
View File
@@ -1,3 +1,5 @@
mod service;
pub use service::{CompiledService, ServiceInvocationResult, ServiceOutput};
mod ticket;
use crate::sandbox::SandboxLimits;
use crate::{
@@ -283,6 +285,8 @@ impl Executor {
log_bytes: 0,
dropped_logs: 0,
store_limits: SandboxLimits::new(limits),
logging_only: false,
effects: Arc::new(service::Effects::default()),
};
let mut store = Store::new(&self.engine, state);
store.limiter(|state| &mut state.store_limits);
@@ -452,6 +456,8 @@ struct State {
log_bytes: usize,
dropped_logs: u64,
store_limits: SandboxLimits,
logging_only: bool,
effects: Arc<service::Effects>,
}
fn check_current(context: &InvocationContext) -> Result<(), errors::HostError> {
if context.cancellation.is_cancelled() {
@@ -470,10 +476,19 @@ impl State {
let context = self
.context
.as_ref()
.ok_or_else(|| wasmtime::Error::msg("Host import outside Tool export context"))?;
.ok_or_else(|| wasmtime::Error::msg("Host import outside export context"))?;
check_current(context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
Ok(context.clone())
}
fn protected_current(&self) -> wasmtime::Result<InvocationContext> {
let context = self.current()?;
if self.logging_only {
return Err(wasmtime::Error::msg(
"Service lifecycle permits logging only",
));
}
Ok(context)
}
fn operation(&mut self) -> Result<(), errors::HostError> {
if self.operations >= self.limits.host_operations {
return Err(host::error(
@@ -498,7 +513,7 @@ impl bindings::yoi::host::connections::Host for State {
&mut self,
slot: String,
) -> wasmtime::Result<Result<Resource<Connection>, errors::HostError>> {
let context = self.current()?;
let context = self.protected_current()?;
if let Err(e) = self.operation() {
return Ok(Err(e));
}
@@ -539,7 +554,7 @@ impl bindings::yoi::host::http::Host for State {
target: Resource<Connection>,
mut input: http::Request,
) -> wasmtime::Result<Result<http::Response, errors::HostError>> {
let context = self.current()?;
let context = self.protected_current()?;
if let Err(e) = self.operation() {
return Ok(Err(e));
}
@@ -628,6 +643,7 @@ impl bindings::yoi::host::http::Host for State {
"HTTP deadline exceeded before dispatch",
)));
}
let effect = self.effects.begin();
let response = match tokio::time::timeout_at(
send_deadline,
self.provider.send(&context, binding.as_ref(), input),
@@ -642,6 +658,7 @@ impl bindings::yoi::host::http::Host for State {
effect: errors::EffectObservation::Indeterminate,
}),
};
effect.finish(&response);
check_current(&context).map_err(|_| wasmtime::Error::msg("late HTTP result"))?;
Ok(response.and_then(|response| {
if response.body.len() > self.limits.http_body_bytes
@@ -0,0 +1,437 @@
use super::*;
use crate::service_bindings::{self, exports::yoi::plugin::service as abi};
use manifest::plugin::{PluginServiceManifest, PluginTicketEventType};
use server_api::{PluginServiceEventResult, PluginTicketEventEnvelope};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
/// Shares compiled bytes and the existing Host/resource implementation, not authority.
pub struct CompiledService(CompiledTool);
impl CompiledService {
pub fn identity(&self) -> &ArtifactIdentity {
self.0.identity()
}
pub fn services(&self) -> &[PluginServiceManifest] {
&self.0.manifest.services
}
}
/// Guest acknowledgement is only a proposal. The driver must persist the cursor
/// through its owner and pause on an indeterminate Host effect, even on guest ack.
#[derive(Debug)]
pub struct ServiceInvocationResult {
pub outcome: Result<ServiceOutput, ExecutionError>,
pub effect: errors::EffectObservation,
}
#[derive(Debug)]
pub enum ServiceOutput {
Started,
Event(PluginServiceEventResult),
Stopped,
}
enum Call<'a> {
Start(&'a str),
Event {
envelope: &'a PluginTicketEventEnvelope,
config: &'a str,
},
Stop(&'a str),
}
/// Kept outside the Store so cancellation, timeout and traps cannot erase a
/// dispatched-but-unobserved effect. This is observation, not a receipt ledger.
#[derive(Default)]
pub(super) struct Effects {
pending: AtomicUsize,
unknown: AtomicBool,
response: AtomicBool,
}
impl Effects {
pub(super) fn begin(self: &Arc<Self>) -> EffectWait {
self.pending.fetch_add(1, Ordering::SeqCst);
EffectWait {
effects: self.clone(),
completed: false,
}
}
pub(super) fn unresolved(&self) {
self.unknown.store(true, Ordering::SeqCst);
}
pub(super) fn observe(&self, error: &errors::HostError) {
if matches!(error.effect, errors::EffectObservation::Indeterminate)
|| matches!(
error.code,
errors::Code::Indeterminate | errors::Code::ReceiptExpired
)
{
self.unknown.store(true, Ordering::SeqCst);
}
if matches!(error.effect, errors::EffectObservation::ResponseObserved) {
self.response.store(true, Ordering::SeqCst);
}
}
fn snapshot(&self) -> errors::EffectObservation {
if self.unknown.load(Ordering::SeqCst) || self.pending.load(Ordering::SeqCst) != 0 {
errors::EffectObservation::Indeterminate
} else if self.response.load(Ordering::SeqCst) {
errors::EffectObservation::ResponseObserved
} else {
errors::EffectObservation::NotDispatched
}
}
}
pub(super) struct EffectWait {
effects: Arc<Effects>,
completed: bool,
}
impl EffectWait {
pub(super) fn finish<T>(mut self, result: &Result<T, errors::HostError>) {
match result {
Ok(_) => {
self.effects.response.store(true, Ordering::SeqCst);
}
Err(e) => self.effects.observe(e),
}
self.completed = true;
}
}
impl Drop for EffectWait {
fn drop(&mut self) {
if !self.completed {
self.effects.unknown.store(true, Ordering::SeqCst);
}
self.effects.pending.fetch_sub(1, Ordering::SeqCst);
}
}
impl Executor {
pub fn compile_service(
&self,
artifact: &[u8],
package_digest: &str,
artifact_digest: &str,
artifact_size: u64,
) -> Result<CompiledService, ExecutionError> {
let compiled = self.compile(artifact, package_digest, artifact_digest, artifact_size)?;
if compiled.manifest.runtime.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD {
return Err(ExecutionError::Artifact(
"not the exact Service world".into(),
));
}
Ok(CompiledService(compiled))
}
pub async fn service_start(
&self,
compiled: &CompiledService,
context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
config_json: &str,
) -> ServiceInvocationResult {
self.service_call(
compiled,
context,
provider,
service_name,
Call::Start(config_json),
)
.await
}
pub async fn service_event(
&self,
compiled: &CompiledService,
context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
envelope: &PluginTicketEventEnvelope,
config_json: &str,
) -> ServiceInvocationResult {
self.service_call(
compiled,
context,
provider,
service_name,
Call::Event {
envelope,
config: config_json,
},
)
.await
}
pub async fn service_stop(
&self,
compiled: &CompiledService,
context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
reason: &str,
) -> ServiceInvocationResult {
self.service_call(
compiled,
context,
provider,
service_name,
Call::Stop(reason),
)
.await
}
async fn service_call(
&self,
compiled: &CompiledService,
mut context: InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
call: Call<'_>,
) -> ServiceInvocationResult {
let ceiling = match call {
Call::Start(_) => Duration::from_secs(5),
Call::Stop(_) => Duration::from_secs(2),
Call::Event { .. } => self.limits.wall_time,
};
context.deadline = context
.deadline
.min(Instant::now() + ceiling.min(self.limits.wall_time));
context.cancellation = context.cancellation.child_token();
let _lifetime = InvocationLifetime(context.cancellation.clone());
let effects = Arc::new(Effects::default());
let execution = self.run_service(
compiled,
&context,
provider,
service_name,
call,
effects.clone(),
);
tokio::pin!(execution);
let mut tick = tokio::time::interval(Duration::from_millis(10));
let outcome = loop {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => break Err(ExecutionError::Cancelled),
_ = tokio::time::sleep_until(context.deadline) => break Err(ExecutionError::Deadline),
result = &mut execution => break result,
_ = tick.tick() => self.engine.increment_epoch(),
}
};
ServiceInvocationResult {
outcome,
effect: effects.snapshot(),
}
}
async fn run_service(
&self,
compiled: &CompiledService,
context: &InvocationContext,
provider: Arc<dyn Host>,
service_name: &str,
call: Call<'_>,
effects: Arc<Effects>,
) -> Result<ServiceOutput, ExecutionError> {
let compiled = &compiled.0;
if !Engine::same(&self.engine, compiled.component.engine())
|| context.artifact != compiled.identity
|| context.tool_name != service_name
{
return Err(ExecutionError::InvalidInput(
"Service artifact/name does not match Host context".into(),
));
}
let declaration = compiled
.manifest
.services
.iter()
.find(|s| s.name == service_name)
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Service".into()))?;
if context.invocation_id.is_empty() || context.invocation_id.len() > 1024 {
return Err(ExecutionError::InvalidInput(
"invalid invocation identity".into(),
));
}
let config = match &call {
Call::Start(c) | Call::Event { config: c, .. } => Some(*c),
Call::Stop(_) => None,
};
if let Some(config) = config {
if config.len() > self.limits.config_bytes {
return Err(ExecutionError::InvalidInput("config exceeds limit".into()));
}
serde_json::from_str::<serde_json::Value>(config)
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
}
if let Call::Event { envelope: e, .. } = &call {
let kind = match e.event_type {
server_api::PluginTicketEventType::Changed => PluginTicketEventType::TicketChanged,
server_api::PluginTicketEventType::Deleted => PluginTicketEventType::TicketDeleted,
};
if !context.grants.ticket_events
|| e.version != 1
|| e.attempt == 0
|| !declaration.subscriptions.contains(&kind)
|| serde_json::to_vec(e)
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?
.len()
> self.limits.payload_bytes
|| [&e.event_id, &e.ticket_id, &e.domain_event_ref]
.iter()
.any(|s| s.is_empty() || s.len() > 1024)
{
return Err(ExecutionError::InvalidInput(
"invalid, oversized or ungranted Service event".into(),
));
}
}
if let Call::Stop(reason) = &call {
if reason.len() > self.limits.payload_bytes {
return Err(ExecutionError::InvalidInput(
"stop reason exceeds limit".into(),
));
}
}
provider
.authorize(context, Operation::Admit)
.await
.map_err(ExecutionError::Host)?;
check_current(context).map_err(ExecutionError::Host)?;
let limits = &self.limits;
let mut store = Store::new(
&self.engine,
State {
context: None,
provider: provider.clone(),
limits: limits.clone(),
manifest: compiled.manifest.clone(),
table: ResourceTable::new(),
operations: 0,
resources: 0,
log_bytes: 0,
dropped_logs: 0,
store_limits: SandboxLimits::new(limits),
logging_only: !matches!(call, Call::Event { .. }),
effects,
},
);
store.limiter(|s| &mut s.store_limits);
store.set_fuel(limits.fuel).map_err(trap)?;
store
.fuel_async_yield_interval(Some(10_000))
.map_err(trap)?;
store.set_epoch_deadline(1);
store.epoch_deadline_async_yield_and_update(1);
let mut linker = Linker::new(&self.engine);
service_bindings::TicketServicePlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
&mut linker,
|s| s,
)
.map_err(trap)?;
let instance = service_bindings::TicketServicePlugin::instantiate_async(
&mut store,
&compiled.component,
&linker,
)
.await
.map_err(trap)?;
check_current(context).map_err(ExecutionError::Host)?;
// Instantiation/start sections never receive an export context.
store.data_mut().context = Some(context.clone());
let service = instance.yoi_plugin_service();
let output = match call {
Call::Start(config) => {
service
.call_on_start(&mut store, config)
.await
.map_err(trap)?
.map_err(guest_error)?;
ServiceOutput::Started
}
Call::Stop(reason) => {
service
.call_on_stop(&mut store, reason)
.await
.map_err(trap)?
.map_err(guest_error)?;
ServiceOutput::Stopped
}
Call::Event {
envelope: e,
config,
} => {
let input = abi::Envelope {
event_id: e.event_id.clone(),
event_type: match e.event_type {
server_api::PluginTicketEventType::Changed => {
abi::TicketEventType::TicketChanged
}
server_api::PluginTicketEventType::Deleted => {
abi::TicketEventType::TicketDeleted
}
},
event_version: e.version,
occurred_at: e.occurred_at.clone(),
ticket_ref: e.ticket_id.clone(),
domain_event_id: e.domain_event_ref.clone(),
content_digest: if e.content_digest.is_empty() {
None
} else {
Some(e.content_digest.clone())
},
correlation_id: if e.correlation_id.is_empty() {
None
} else {
Some(e.correlation_id.clone())
},
causation_id: e.causation_id.clone(),
invocation_id: context.invocation_id.clone(),
service_name: service_name.into(),
config_json: config.into(),
attempt: e.attempt,
remaining_ms: context
.deadline
.saturating_duration_since(Instant::now())
.as_millis()
.min(u32::MAX as u128) as u32,
};
let result = match service
.call_on_event(&mut store, &input)
.await
.map_err(trap)?
{
abi::EventResult::Ack => PluginServiceEventResult::Ack,
abi::EventResult::RetryAfter(delay_ms) if delay_ms <= 300_000 => {
PluginServiceEventResult::RetryAfter { delay_ms }
}
abi::EventResult::Reject(reason)
if reason.len() <= self.limits.payload_bytes =>
{
PluginServiceEventResult::Reject
}
_ => {
return Err(ExecutionError::InvalidOutput(
"Service result exceeds limit".into(),
));
}
};
ServiceOutput::Event(result)
}
};
check_current(context).map_err(ExecutionError::Host)?;
provider
.authorize(context, Operation::Publish)
.await
.map_err(ExecutionError::Host)?;
check_current(context).map_err(ExecutionError::Host)?;
Ok(output)
}
}
fn guest_error(error: abi::ServiceError) -> ExecutionError {
if error.code.len().saturating_add(error.message.len()) > 4096 {
ExecutionError::InvalidOutput("Service error exceeds limit".into())
} else {
ExecutionError::Guest {
code: "plugin-failed".into(),
message: error.message,
}
}
}
+29 -8
View File
@@ -51,7 +51,10 @@ fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt {
}
impl State {
fn ticket_world(&self) -> Result<(), errors::HostError> {
if self.manifest.runtime.world != plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
if !matches!(
self.manifest.runtime.world.as_str(),
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD | plugin::PLUGIN_COMPONENT_SERVICE_WORLD
) {
return Err(denied());
}
Ok(())
@@ -111,7 +114,7 @@ impl State {
target: Resource<Ticket>,
operation: Op,
) -> wasmtime::Result<Result<Reply, errors::HostError>> {
let context = self.current()?;
let context = self.protected_current()?;
let checked = (|| {
self.operation()?;
self.ticket_world()?;
@@ -152,20 +155,37 @@ impl State {
None
};
let mutating = matches!(&operation, Op::Mutate { .. });
let lookup = matches!(&operation, Op::LookupMutation { .. });
let expected_reply = match &operation {
Op::Get(_) => 0,
Op::Mutate { .. } => 1,
Op::List { .. } => 2,
Op::LookupMutation { .. } => 3,
};
let reply = match self
let effect = mutating.then(|| self.effects.begin());
let reply = self
.provider
.ticket_data(&context, binding.as_ref(), operation)
.await
{
.await;
if let Some(effect) = effect {
effect.finish(&reply);
}
let reply = match reply {
Ok(reply) => reply,
Err(e) => return Ok(Err(e)),
Err(e) => {
self.effects.observe(&e);
if lookup {
self.effects.unresolved();
}
return Ok(Err(e));
}
};
if matches!(
&reply,
Reply::MutationLookup(server_api::PluginDataMutationLookup::Expired)
) {
self.effects.unresolved();
}
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?;
let actual_reply = match &reply {
Reply::Cell(_) => 0,
@@ -179,6 +199,7 @@ impl State {
error.code = errors::Code::Indeterminate;
error.effect = errors::EffectObservation::Indeterminate;
}
self.effects.observe(&error);
return Ok(Err(error));
}
let valid = match &reply {
@@ -224,7 +245,7 @@ impl objects::Host for State {
ticket_ref: String,
requested: objects::Access,
) -> wasmtime::Result<Result<Resource<Ticket>, errors::HostError>> {
let context = self.current()?;
let context = self.protected_current()?;
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
return Ok(Err(e));
}
@@ -288,7 +309,7 @@ impl ticket_read::Host for State {
&mut self,
target: Resource<Ticket>,
) -> wasmtime::Result<Result<ticket_read::Snapshot, errors::HostError>> {
let context = self.current()?;
let context = self.protected_current()?;
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
return Ok(Err(e));
}
+1
View File
@@ -32,6 +32,7 @@ pub struct Grants {
pub logging: bool,
pub connection_slots: BTreeSet<String>,
pub ticket_read: bool,
pub ticket_events: bool,
pub ticket_data_read: BTreeSet<(String, String, String)>,
pub ticket_data_write: BTreeSet<(String, String, String)>,
}
+19
View File
@@ -30,3 +30,22 @@ pub mod ticket_bindings {
},
});
}
/// Service exports reuse the existing Host resources and their exact import ABI.
pub mod service_bindings {
wasmtime::component::bindgen!({
path: "../../resources/plugin/wit-service",
world: "ticket-service-plugin",
imports: { default: async | trappable },
exports: { default: async },
with: {
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
"yoi:host/connections@2.0.0": crate::bindings::yoi::host::connections,
"yoi:host/http@2.0.0": crate::bindings::yoi::host::http,
"yoi:host/logging@2.0.0": crate::bindings::yoi::host::logging,
"yoi:host/objects@2.1.0": crate::host::objects,
"yoi:host/ticket-read@2.1.0": crate::host::ticket_read,
"yoi:host/extension-data@2.1.0": crate::host::extension_data,
},
});
}
+7
View File
@@ -219,6 +219,7 @@ impl OwnerHost {
|| context.invocation_id != bound.invocation_id
|| context.artifact != bound.artifact
|| context.tool_name != bound.tool_name
|| context.grants.ticket_events != bound.grants.ticket_events
|| context.grants.ticket_read != bound.grants.ticket_read
|| context.grants.ticket_data_read != bound.grants.ticket_data_read
|| context.grants.ticket_data_write != bound.grants.ticket_data_write
@@ -609,6 +610,12 @@ mod tests {
});
}
Ok(match request {
PluginHostRequest::Service { .. } => {
return Err(local_error(
PluginHostErrorCode::Unsupported,
PluginEffectObservation::NotDispatched,
));
}
PluginHostRequest::Prepare { .. } => PluginHostResponse::Authorized,
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
invocation_id: "owner-issued".into(),
+367
View File
@@ -0,0 +1,367 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "service-test-guest"
version = "0.1.0"
dependencies = [
"yoi-plugin-pdk",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"bitflags",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn 2.0.119",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.119",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "yoi-plugin-pdk"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror",
"wit-bindgen",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,13 @@
[package]
name = "service-test-guest"
version = "0.1.0"
edition = "2024"
publish = false
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
@@ -0,0 +1,47 @@
schema_version = 2
id = "service-test"
name = "Service test"
version = "1.0.0"
surfaces = ["service"]
config_schema = "{ mode = String }"
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/ticket-service-plugin@2.2.0"
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0", "yoi:host/logging@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
[[services]]
name = "ticket_events"
subscriptions = ["ticket_changed", "ticket_deleted"]
[[connections]]
name = "api"
methods = ["POST"]
path_prefixes = ["/events"]
[[data_collections]]
collection = "notes"
schema_id = "note"
schema_version = "1"
schema = { type = "string", maxLength = 64 }
[[requested_grants]]
kind = "logging"
[[requested_grants]]
kind = "https"
slot = "api"
[[requested_grants]]
kind = "ticket_events"
[[requested_grants]]
kind = "ticket_read"
[[requested_grants]]
kind = "ticket_data_read"
collection = "notes"
schema_id = "note"
schema_version = "1"
[[requested_grants]]
kind = "ticket_data_write"
collection = "notes"
schema_id = "note"
schema_version = "1"
@@ -0,0 +1,135 @@
use std::sync::atomic::{AtomicU32, Ordering};
use yoi_plugin_pdk::{ToolError, wit_bindgen};
wit_bindgen::generate!({
path: "../../../../resources/plugin/wit-service",
world: "ticket-service-plugin",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
use exports::yoi::plugin::service::{Envelope, EventResult, TicketEventType};
use yoi::host2_0_0::{connections, errors, http, logging};
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
static CALLS: AtomicU32 = AtomicU32::new(0);
fn fresh() {
assert_eq!(CALLS.fetch_add(1, Ordering::SeqCst), 0);
}
fn failure(e: errors::HostError) -> ToolError {
ToolError::failed(format!("{:?}", e.code))
}
fn address() -> data::Address {
data::Address {
namespace: data::Collection {
collection: "notes".into(),
schema_id: "note".into(),
schema_version: "1".into(),
},
key: "main".into(),
}
}
fn request() -> http::Request {
http::Request {
method: http::Method::Post,
path: "/events".into(),
query: vec![],
headers: vec![],
body: b"event".to_vec(),
timeout_ms: 15_000,
effect_key: "event-hook".into(),
}
}
fn lifecycle(mode: String) -> yoi_plugin_pdk::Result<()> {
fresh();
match mode.as_str() {
"open" => {
connections::open("api").map_err(failure)?;
}
"resolve" => {
objects::resolve_ticket("T-796", objects::Access::Read).map_err(failure)?;
}
"read" => {
let t = unsafe { objects::Ticket::from_handle(0) };
ticket_read::read(&t).map_err(failure)?;
}
"data" => {
let t = unsafe { objects::Ticket::from_handle(0) };
data::get(&t, &address()).map_err(failure)?;
}
"http" => {
let c = unsafe { connections::Connection::from_handle(0) };
http::send(&c, &request()).map_err(failure)?;
}
"loop" => loop {
std::hint::spin_loop();
},
_ => {
logging::log(&logging::Entry {
level: logging::Level::Info,
event_code: "lifecycle".into(),
message: "configured".into(),
fields: vec![],
})
.map_err(failure)?;
}
}
Ok(())
}
fn start(config: String) -> yoi_plugin_pdk::Result<()> {
let config: std::collections::BTreeMap<String, String> =
yoi_plugin_pdk::parse_json_input(&config)?;
lifecycle(config.get("mode").cloned().unwrap_or_default())
}
fn stop(reason: String) -> yoi_plugin_pdk::Result<()> {
lifecycle(reason)
}
fn work(input: &Envelope) -> yoi_plugin_pdk::Result<()> {
if matches!(input.event_type, TicketEventType::TicketDeleted) {
return Ok(());
}
let ticket = objects::resolve_ticket(&input.ticket_ref, objects::Access::ExtensionWrite)
.map_err(failure)?;
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
if input.config_json == r#"{"mode":"abi-probe"}"# {
data::list(&ticket, &address().namespace, None, 1).map_err(failure)?;
data::delete(&ticket, &address(), "observed", "delete").map_err(failure)?;
}
// A saved mutation is checked before reading the current cell or rebuilding
// its CAS request. Failed lookup is never interpreted as not-recorded.
match data::lookup_mutation(&ticket, &address(), "event-note").map_err(failure)? {
data::MutationLookup::Committed(_) => (),
data::MutationLookup::Expired => return Err(ToolError::failed("expired")),
data::MutationLookup::NotRecorded => {
let expected = match data::get(&ticket, &address()).map_err(failure)? {
data::Cell::NeverWritten => data::Expectation::NeverWritten,
data::Cell::Deleted(last) => data::Expectation::LastWrite(last),
data::Cell::Present(cell) => data::Expectation::LastWrite(cell.last_write),
};
let value = yoi_plugin_pdk::extension_data_json(&snapshot.title)?;
data::put(&ticket, &address(), &expected, "event-note", &value).map_err(failure)?;
}
}
let connection = connections::open("api").map_err(failure)?;
// Repeating this exact request uses the Host receipt, not a new effect key.
http::send(&connection, &request()).map_err(failure)?;
Ok(())
}
fn event(input: Envelope) -> EventResult {
fresh();
if input.config_json == r#"{"mode":"loop"}"# {
loop {
std::hint::spin_loop();
}
}
if input.config_json == r#"{"mode":"reject"}"# {
return EventResult::Reject("rejected".into());
}
if input.config_json == r#"{"mode":"oversize-retry"}"# {
return EventResult::RetryAfter(300_001);
}
if work(&input).is_err() && input.config_json != r#"{"mode":"swallow-unknown"}"# {
EventResult::RetryAfter(10)
} else {
EventResult::Ack
}
}
yoi_plugin_pdk::export_component_service!(ServiceGuest, start, event, stop);
@@ -0,0 +1,877 @@
//! Authored Rust -> PDK -> validated Component -> fresh Wasmtime Store -> OwnerHost.
//! The deterministic owner replaces storage/network, not the execution adapter;
//! transaction/lease/live policy guarantees are tested by the domain owner suites.
use async_trait::async_trait;
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
use server_api::*;
use std::{
collections::BTreeMap,
fs,
path::Path,
process::Command,
sync::{Arc, Mutex, OnceLock},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tokio::time::Instant;
use yoi_plugin_runtime::{
host::{CancellationToken, Grants, InvocationContext},
owner::{OwnerHost, PluginHostOwner},
*,
};
fn component() -> &'static [u8] {
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
COMPONENT.get_or_init(|| {
let target =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture/guest");
let output = Command::new(env!("CARGO"))
.args([
"build",
"--locked",
"--offline",
"--release",
"--target",
"wasm32-unknown-unknown",
"--manifest-path",
])
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/service-guest/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.output()
.unwrap();
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(
target.parent().unwrap().join("guest-build.log"),
&output.stderr,
)
.unwrap();
assert!(
output.status.success(),
"Service fixture requires wasm32 std + lld on PATH: {}",
String::from_utf8_lossy(&output.stderr)
);
let core = fs::read(target.join("wasm32-unknown-unknown/release/service_test_guest.wasm"))
.unwrap();
let bytes = wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap();
fs::write(
target.parent().unwrap().join("service.component.wasm"),
&bytes,
)
.unwrap();
bytes
})
}
const MANIFEST: &str = include_str!("service-guest/plugin.toml");
fn package(manifest: &str) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
package_bytes(manifest, component())
}
fn package_bytes(
manifest: &str,
bytes: &[u8],
) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture");
fs::create_dir_all(&target).unwrap();
let dir = tempfile::tempdir_in(target).unwrap();
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
fs::write(dir.path().join("plugin.component.wasm"), bytes).unwrap();
let materialized = plugin::read_plugin_directory(
dir.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)?;
plugin::pack_plugin_package(&materialized, &PluginPackageLimits::default())
}
fn compile(ex: &Executor) -> CompiledService {
let p = package(MANIFEST).unwrap();
ex.compile_service(
&p.bytes,
&p.package_digest,
&p.artifact_digest,
p.artifact_size,
)
.unwrap()
}
#[derive(Clone, Copy, Default)]
enum Fault {
#[default]
None,
HttpUnknown,
DataUnknown,
HttpWait,
DataWait,
LookupUnavailable,
LookupExpired,
ReadDenied,
}
#[derive(Default)]
struct State {
calls: Vec<PluginHostRequest>,
writes: usize,
sends: usize,
receipts: BTreeMap<String, PluginDataReceipt>,
http: BTreeMap<String, PluginHttpResponse>,
}
struct Owner {
state: Mutex<State>,
fault: Fault,
waiting: tokio::sync::Notify,
}
impl Owner {
fn new(fault: Fault) -> Arc<Self> {
Arc::new(Self {
state: Mutex::new(State::default()),
fault,
waiting: tokio::sync::Notify::new(),
})
}
}
fn rejected(code: PluginHostErrorCode, effect: PluginEffectObservation) -> PluginHostError {
PluginHostError {
code,
effect,
diagnostic_id: "test".into(),
}
}
#[async_trait]
impl PluginHostOwner for Owner {
async fn call(
&self,
request: PluginHostRequest,
) -> Result<PluginHostResponse, PluginHostError> {
if (matches!(self.fault, Fault::HttpWait)
&& matches!(request, PluginHostRequest::Send { .. }))
|| (matches!(self.fault, Fault::DataWait)
&& matches!(request, PluginHostRequest::DataMutate { .. }))
{
self.waiting.notify_one();
return std::future::pending().await;
}
let mut s = self.state.lock().unwrap();
s.calls.push(request.clone());
Ok(match request {
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
invocation_id: "stable-service-invocation".into(),
expires_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
+ 60_000,
},
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
assert_eq!(ticket_ref, "T-796");
PluginHostResponse::TicketResolved {
ticket_id: "canonical-ticket".into(),
}
}
PluginHostRequest::ReadTicket { .. } => {
if matches!(self.fault, Fault::ReadDenied) {
return Err(rejected(
PluginHostErrorCode::Denied,
PluginEffectObservation::NotDispatched,
));
}
PluginHostResponse::TicketRead {
snapshot: PluginTicketSnapshot {
title: "note".into(),
case_state: PluginTicketCaseState::Open,
content_digest: "digest".into(),
body: "body".into(),
truncated: false,
},
}
}
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
if matches!(self.fault, Fault::LookupUnavailable) {
return Err(rejected(
PluginHostErrorCode::Unavailable,
PluginEffectObservation::NotDispatched,
));
}
PluginHostResponse::DataMutationLookup {
outcome: if matches!(self.fault, Fault::LookupExpired) {
PluginDataMutationLookup::Expired
} else {
s.receipts
.get(&operation_key)
.cloned()
.map(PluginDataMutationLookup::Committed)
.unwrap_or(PluginDataMutationLookup::NotRecorded)
},
}
}
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
cell: PluginDataCell::NeverWritten,
},
PluginHostRequest::DataMutate { operation_key, .. } => {
if matches!(self.fault, Fault::DataUnknown) {
return Err(rejected(
PluginHostErrorCode::Indeterminate,
PluginEffectObservation::Indeterminate,
));
}
s.writes += 1;
let receipt = PluginDataReceipt {
mutation_id: "write-1".into(),
result_digest: "result".into(),
};
assert!(s.receipts.insert(operation_key, receipt.clone()).is_none());
PluginHostResponse::DataMutated { receipt }
}
PluginHostRequest::Open { .. } => PluginHostResponse::Opened,
PluginHostRequest::Send { request, .. } => {
if matches!(self.fault, Fault::HttpUnknown) {
return Err(rejected(
PluginHostErrorCode::Indeterminate,
PluginEffectObservation::Indeterminate,
));
}
let response = if let Some(response) = s.http.get(&request.effect_key) {
response.clone()
} else {
s.sends += 1;
let response = PluginHttpResponse {
status: 200,
headers: vec![],
body: b"ok".to_vec(),
};
s.http.insert(request.effect_key, response.clone());
response
};
PluginHostResponse::Response { response }
}
_ => {
return Err(rejected(
PluginHostErrorCode::Unsupported,
PluginEffectObservation::NotDispatched,
));
}
})
}
}
fn context(c: &CompiledService) -> InvocationContext {
let namespace = ("notes".into(), "note".into(), "1".into());
InvocationContext {
workspace: "workspace".into(),
installation: "installation".into(),
plan: "plan".into(),
subject: "worker".into(),
invocation_id: "ignored-before-begin".into(),
artifact: c.identity().clone(),
tool_name: "ticket_events".into(),
grants: Grants {
logging: true,
connection_slots: ["api".into()].into(),
ticket_events: true,
ticket_read: true,
ticket_data_read: [namespace.clone()].into(),
ticket_data_write: [namespace].into(),
},
deadline: Instant::now() + Duration::from_secs(10),
cancellation: CancellationToken::new(),
}
}
async fn host(c: &CompiledService, owner: Arc<Owner>) -> Arc<OwnerHost> {
Arc::new(
OwnerHost::begin(
owner,
context(c),
PluginConsumer::Consumer {
consumer_id: "consumer".into(),
event_id: "event".into(),
export_name: "ticket_events".into(),
},
)
.await
.unwrap(),
)
}
fn envelope() -> PluginTicketEventEnvelope {
PluginTicketEventEnvelope {
event_id: "event".into(),
event_type: PluginTicketEventType::Changed,
version: 1,
occurred_at: "2026-10-30T00:00:00Z".into(),
ticket_id: "T-796".into(),
domain_event_ref: "domain-event".into(),
content_digest: "digest".into(),
correlation_id: "correlation".into(),
causation_id: None,
attempt: 1,
}
}
#[tokio::test]
async fn authored_service_recovers_with_receipt_lookup_before_rebuilding_a_mutation() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
assert!(matches!(
ex.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
r#"{"mode":"normal"}"#
)
.await
.outcome,
Ok(ServiceOutput::Started)
));
for attempt in 1..=2 {
let mut e = envelope();
e.attempt = attempt;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
r#"{"mode":"normal"}"#,
)
.await;
assert!(
matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
),
"{result:?}"
);
assert!(matches!(
result.effect,
host::errors::EffectObservation::ResponseObserved
));
}
assert!(matches!(
ex.service_stop(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
"normal"
)
.await
.outcome,
Ok(ServiceOutput::Stopped)
));
let state = owner.state.lock().unwrap();
assert_eq!(state.writes, 1);
assert_eq!(state.sends, 1);
assert_eq!(
state
.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataGet { .. }))
.count(),
1
);
assert_eq!(
state
.calls
.iter()
.filter(|r| matches!(r, PluginHostRequest::DataLookupMutation { .. }))
.count(),
2
);
}
#[tokio::test]
async fn lifecycle_hooks_allow_logging_but_never_open_resolve_read_data_or_http() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
for mode in ["open", "resolve", "read", "data", "http"] {
let config = format!(r#"{{"mode":"{mode}"}}"#);
let start = ex
.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&config,
)
.await;
let stop = ex
.service_stop(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
mode,
)
.await;
assert!(
matches!(start.outcome, Err(ExecutionError::Trap(_))),
"{mode}: {start:?}"
);
assert!(
matches!(stop.outcome, Err(ExecutionError::Trap(_))),
"{mode}: {stop:?}"
);
}
let s = owner.state.lock().unwrap();
assert!(s.calls.iter().all(|r| matches!(
r,
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
)));
}
#[tokio::test]
async fn guest_ack_cannot_hide_unknown_http_data_or_failed_receipt_lookup() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
for fault in [
Fault::HttpUnknown,
Fault::DataUnknown,
Fault::LookupUnavailable,
Fault::LookupExpired,
] {
let host = host(&c, Owner::new(fault)).await;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"swallow-unknown"}"#,
)
.await;
assert!(
matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
),
"{result:?}"
);
assert!(
matches!(
result.effect,
host::errors::EffectObservation::Indeterminate
),
"{result:?}"
);
}
}
#[tokio::test]
async fn cancellation_during_effect_wait_preserves_unknown_observation() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
for fault in [Fault::HttpWait, Fault::DataWait] {
let owner = Owner::new(fault);
let host = host(&c, owner.clone()).await;
let cancellation = host.context().cancellation.clone();
let e = envelope();
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
r#"{"mode":"normal"}"#
),
async {
owner.waiting.notified().await;
cancellation.cancel();
}
)
})
.await
.expect("effect was not dispatched");
assert!(
matches!(result.outcome, Err(ExecutionError::Cancelled)),
"{result:?}"
);
assert!(
matches!(
result.effect,
host::errors::EffectObservation::Indeterminate
),
"{result:?}"
);
}
}
#[tokio::test]
async fn deleted_metadata_never_resolves_a_ticket_and_live_read_denial_remains_a_failure() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let owner = Owner::new(Fault::ReadDenied);
let host = host(&c, owner.clone()).await;
let result = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
result.outcome,
Ok(ServiceOutput::Event(
PluginServiceEventResult::RetryAfter { .. }
))
));
let before = owner.state.lock().unwrap().calls.len();
let mut e = envelope();
e.event_type = PluginTicketEventType::Deleted;
e.content_digest.clear();
let result = ex
.service_event(
&c,
host.context().clone(),
host,
"ticket_events",
&e,
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
result.outcome,
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
));
assert!(
owner.state.lock().unwrap().calls[before..]
.iter()
.all(|r| matches!(r, PluginHostRequest::Authorize { .. }))
);
}
#[test]
fn service_manifest_rejects_old_world_subscription_and_grant_mismatches() {
package(MANIFEST).unwrap();
for bad in [
MANIFEST.replace("@2.2.0", "@2.1.0"),
MANIFEST.replace("ticket_changed", "arbitrary_event"),
MANIFEST.replace("kind = \"ticket_events\"", "kind = \"ticket_read\""),
MANIFEST.replace(
"ticket_changed\", \"ticket_deleted",
"ticket_changed\", \"ticket_changed",
),
MANIFEST.replace("surfaces = [\"service\"]", "surfaces = [\"tool\"]"),
] {
assert!(package(&bad).is_err());
}
}
#[tokio::test]
async fn service_host_and_guest_limits_fail_before_publication() {
let ex = Executor::new(Limits::default()).unwrap();
let c = compile(&ex);
let host = host(&c, Owner::new(Fault::None)).await;
let invalid = ex
.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"oversize-retry"}"#,
)
.await;
assert!(matches!(
invalid.outcome,
Err(ExecutionError::InvalidOutput(_))
));
let mut ctx = host.context().clone();
ctx.grants.ticket_events = false;
let denied = ex
.service_event(
&c,
ctx,
host.clone(),
"ticket_events",
&envelope(),
r#"{"mode":"normal"}"#,
)
.await;
assert!(matches!(
denied.outcome,
Err(ExecutionError::InvalidInput(_))
));
let mut e = envelope();
e.version = 2;
assert!(matches!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
"{}"
)
.await
.outcome,
Err(ExecutionError::InvalidInput(_))
));
e = envelope();
e.correlation_id = "x".repeat(65536);
assert!(matches!(
ex.service_event(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
&e,
"{}"
)
.await
.outcome,
Err(ExecutionError::InvalidInput(_))
));
let ctx = host.context().clone();
ctx.cancellation.cancel();
assert!(matches!(
ex.service_start(&c, ctx, host.clone(), "ticket_events", "{}")
.await
.outcome,
Err(ExecutionError::Cancelled)
));
}
fn startup_component(logging: bool) -> Vec<u8> {
let mut resolve = wit_parser::Resolve::default();
let (package, _) = resolve
.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources/plugin/wit-service"))
.unwrap();
let world = resolve
.select_world(&[package], Some("ticket-service-plugin"))
.unwrap();
let core = wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
let wat = wasmprinter::print_bytes(core).unwrap();
let init = wat
.lines()
.find(|l| l.contains("(export \"cm32p2_initialize\""))
.unwrap();
let index = init
.split("(func ")
.nth(1)
.unwrap()
.split(')')
.next()
.unwrap();
let needle = wat
.lines()
.find(|l| l.contains(&format!("(func (;{index};)")))
.unwrap();
assert!(needle.trim_end().ends_with("))"), "{needle}");
let import = wat
.lines()
.find(|l| {
l.contains(if logging {
"yoi:host/logging@2"
} else {
"yoi:host/connections@2"
}) && l.contains(if logging { "\"log\"" } else { "\"open\"" })
})
.unwrap();
let imported_index = import
.split("(func (;")
.nth(1)
.unwrap()
.split(';')
.next()
.unwrap();
let body = if logging {
"i32.const 0 ".repeat(7) + &format!("i32.const 128 call {imported_index}")
} else {
format!("i32.const 0 i32.const 0 i32.const 128 call {imported_index}")
};
let replacement = format!("{} {body})", needle.trim_end().strip_suffix(')').unwrap());
let wat = wat
.replace(needle, &replacement)
.replace("(memory (;0;) 0)", "(memory (;0;) 1)");
let mut core = wat::parse_str(wat).unwrap();
wit_component::embed_component_metadata(
&mut core,
&resolve,
world,
wit_component::StringEncoding::UTF8,
)
.unwrap();
wit_component::ComponentEncoder::default()
.module(&core)
.unwrap()
.validate(true)
.encode()
.unwrap()
}
#[tokio::test]
async fn component_start_sections_never_receive_a_service_export_context() {
let ex = Executor::new(Limits::default()).unwrap();
for logging in [false, true] {
let p = package_bytes(MANIFEST, &startup_component(logging)).unwrap();
let c = ex
.compile_service(
&p.bytes,
&p.package_digest,
&p.artifact_digest,
p.artifact_size,
)
.unwrap();
let owner = Owner::new(Fault::None);
let host = host(&c, owner.clone()).await;
let result = ex
.service_start(
&c,
host.context().clone(),
host.clone(),
"ticket_events",
r#"{"mode":"normal"}"#,
)
.await;
assert!(
matches!(result.outcome, Err(ExecutionError::Trap(_))),
"{result:?}"
);
assert!(owner.state.lock().unwrap().calls.iter().all(|r| matches!(
r,
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
)));
}
}
#[tokio::test]
async fn artifact_delivery_binds_service_names_and_subscriptions_to_component_package() {
use delivery::{
ArtifactCache, ArtifactExpectation, ArtifactSource, ArtifactStream, CacheLimits,
DeliveryError,
};
struct Source {
bytes: Vec<u8>,
metadata: PluginArtifactDeliveryMetadata,
}
#[async_trait]
impl ArtifactSource for Source {
fn server_authority_identity(&self) -> &str {
"service-test-authority"
}
async fn authorize(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
Ok(self.metadata.clone())
}
async fn fetch(
&self,
_: &PluginArtifactDeliveryRequest,
) -> Result<ArtifactStream, DeliveryError> {
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
}
}
fn metadata<T: serde::Serialize, U: serde::de::DeserializeOwned>(value: &T) -> U {
serde_json::from_value(serde_json::to_value(value).unwrap()).unwrap()
}
let artifact = package(MANIFEST).unwrap();
let verified = plugin::verify_plugin_artifact(
&artifact.bytes,
&artifact.package_digest,
&artifact.artifact_digest,
artifact.artifact_size,
&PluginPackageLimits::default(),
)
.unwrap();
let m = &verified.package.manifest;
let schema = plugin::plugin_config_schema_identity(m);
let descriptor = PluginArtifact {
package_id: m.id.clone(),
name: m.name.clone(),
version: m.version.clone(),
description: m.description.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size.try_into().unwrap(),
schema_version: m.schema_version,
world: m.runtime.world.clone(),
required_imports: m.runtime.required_imports.clone(),
tools: metadata(&m.tools),
services: metadata(&m.services),
connections: metadata(&m.connections),
requested_grants: metadata(&m.requested_grants),
data_collections: metadata(&m.data_collections),
config_schema: PluginSchemaContribution {
identity: schema.identity,
version: schema.version,
digest: schema.digest,
namespace: schema.namespace,
dcdl: m.config_schema.clone(),
},
revoked: false,
revocation_reason: None,
};
assert_eq!(
descriptor.services[0].subscriptions,
["ticket_changed", "ticket_deleted"]
);
let request = PluginArtifactDeliveryRequest {
plan_id: "service-plan".into(),
plan_fingerprint: "service-plan-fingerprint".into(),
installation_id: "installation".into(),
artifact_digest: artifact.artifact_digest.clone(),
};
let directory = tempfile::tempdir().unwrap();
let cache = ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
let mut source = Source {
bytes: artifact.bytes.clone(),
metadata: PluginArtifactDeliveryMetadata {
workspace_id: "workspace".into(),
request: request.clone(),
artifact: descriptor.clone(),
},
};
let resolved = cache
.resolve(
&source,
"workspace",
&request,
&ArtifactExpectation::new(descriptor.clone()),
)
.await
.unwrap();
assert_eq!(resolved.bytes(), artifact.bytes);
let mut renamed = descriptor.clone();
renamed.services[0].name = "different_service".into();
let mut narrowed = descriptor.clone();
narrowed.services[0].subscriptions = vec!["ticket_changed".into()];
let mut unknown = descriptor.clone();
unknown.services[0].subscriptions = vec!["arbitrary_event".into()];
let mut missing = descriptor.clone();
missing.services.clear();
for tampered in [renamed, narrowed, unknown, missing] {
// Source metadata and the trusted descriptor agree; only verification
// against the actual package can detect this mismatch, including hits.
source.metadata.artifact = tampered.clone();
let expected = ArtifactExpectation::new(tampered);
assert!(matches!(
cache
.resolve(&source, "workspace", &request, &expected)
.await,
Err(DeliveryError::MetadataMismatch)
));
}
source.metadata.artifact = descriptor.clone();
assert_eq!(
cache
.resolve(
&source,
"workspace",
&request,
&ArtifactExpectation::new(descriptor)
)
.await
.unwrap()
.bytes(),
artifact.bytes
);
}