Skip to main content

ledgence_worker_api/
lib.rs

1//! Portable, vendor-independent worker contracts.
2//!
3//! Program identity, immutable artifact preparation, reusable execution sessions,
4//! CloudEvents, monotonic deadlines, runtime requests, metrics and tracing form
5//! the adapter boundary. Implementations must retain process and artifact
6//! ownership until cleanup is confirmed. This crate supplies contracts, not a
7//! worker runtime, program store, process sandbox or durable orchestration service.
8//!
9//! See the [program package contract](https://github.com/Ledgence/ledgence/blob/main/docs/program-packages.md)
10//! and [worker delivery contract](https://github.com/Ledgence/ledgence/blob/main/docs/worker-delivery.md).
11
12pub mod metrics;
13mod trace;
14pub use trace::{NoopTraceBridge, TraceBridge, TraceContext};
15mod execution;
16pub use execution::{
17    ExecutionContext, ExecutionFailure, ExecutionReport, ExecutionRequest, ExecutionResult, Phase,
18    RuntimeExtension, RuntimeInvocation, RuntimeReply, RuntimeRequest,
19};
20mod invocation;
21mod json;
22pub use json::decode_json;
23mod wire;
24pub use invocation::InvocationIdentity;
25pub use wire::{
26    APPLICATION_INPUT_MAX_BYTES, DEFAULT_RUNTIME_FRAME_MAX_BYTES, MAX_RUNTIME_VALUE_DEPTH,
27    MAX_WIRE_VALUE_DEPTH, RUNTIME_EXTENSION_MAX_BYTES, RUNTIME_REQUEST_MAX_BYTES,
28    validate_runtime_payload, validate_wire_value,
29};
30
31use iri_string::types::{UriAbsoluteStr, UriReferenceStr};
32use serde::{Deserialize, Serialize};
33use serde_json::{Map, Value};
34use std::{
35    fmt,
36    future::Future,
37    path::{Path, PathBuf},
38    pin::Pin,
39    sync::{
40        Arc,
41        atomic::{AtomicBool, Ordering},
42    },
43    time::{Duration, Instant},
44};
45use time::{OffsetDateTime, format_description::well_known::Rfc3339};
46
47/// An adapter operation. Shared clients may be cloned/borrowed by their owner.
48pub type PortFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
49pub type Result<T> = std::result::Result<T, Error>;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ErrorKind {
54    InvalidInput,
55    NotFound,
56    Integrity,
57    Incompatible,
58    Unavailable,
59    Cancelled,
60    TimedOut,
61    Runtime,
62    Protocol,
63    Io,
64    Capacity,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Error {
69    pub kind: ErrorKind,
70    pub message: String,
71}
72impl Error {
73    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
74        Self {
75            kind,
76            message: message.into(),
77        }
78    }
79}
80impl fmt::Display for Error {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "{:?}: {}", self.kind, self.message)
83    }
84}
85impl std::error::Error for Error {}
86impl From<std::io::Error> for Error {
87    fn from(value: std::io::Error) -> Self {
88        Self::new(ErrorKind::Io, value.to_string())
89    }
90}
91
92/// Names are filesystem-safe, lowercase, immutable release identifiers.
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ProgramRef {
96    pub id: String,
97    pub version: String,
98}
99impl ProgramRef {
100    pub fn validate(&self) -> Result<()> {
101        for (field, value) in [("program id", &self.id), ("program version", &self.version)] {
102            if value.is_empty()
103                || value.len() > 128
104                || value == "."
105                || value == ".."
106                || !value
107                    .bytes()
108                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b))
109            {
110                return Err(Error::new(
111                    ErrorKind::InvalidInput,
112                    format!("invalid {field}"),
113                ));
114            }
115        }
116        Ok(())
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
121#[serde(transparent)]
122pub struct Digest(pub String);
123impl Digest {
124    pub fn validate(&self) -> Result<()> {
125        if self.0.len() != 71
126            || !self.0.starts_with("sha256:")
127            || !self.0[7..]
128                .bytes()
129                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
130        {
131            return Err(Error::new(
132                ErrorKind::InvalidInput,
133                "expected sha256: followed by 64 lowercase hexadecimal digits",
134            ));
135        }
136        Ok(())
137    }
138    pub fn hex(&self) -> &str {
139        self.0.strip_prefix("sha256:").unwrap_or(&self.0)
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct ProgramDescriptor {
146    pub program: ProgramRef,
147    pub digest: Digest,
148    pub size: u64,
149}
150impl ProgramDescriptor {
151    pub fn validate(&self) -> Result<()> {
152        self.program.validate()?;
153        self.digest.validate()?;
154        if self.size == 0 {
155            return Err(Error::new(ErrorKind::InvalidInput, "empty program archive"));
156        }
157        Ok(())
158    }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct PythonRuntime {
164    pub kind: String,
165    pub python: String,
166    pub protocol: u32,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(deny_unknown_fields)]
171pub struct Platform {
172    pub os: String,
173    pub arch: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct ProgramManifest {
179    pub schema_version: u32,
180    pub program: ProgramRef,
181    pub runtime: PythonRuntime,
182    pub handler: String,
183    pub platform: Platform,
184}
185impl ProgramManifest {
186    pub fn validate(&self) -> Result<()> {
187        self.program.validate()?;
188        if self.schema_version != 1
189            || self.runtime.kind != "python"
190            || !matches!(self.runtime.protocol, 1..=3)
191        {
192            return Err(Error::new(
193                ErrorKind::Incompatible,
194                "unsupported manifest, runtime, or protocol version",
195            ));
196        }
197        let Some(minor) = self
198            .runtime
199            .python
200            .strip_prefix("3.")
201            .and_then(|s| s.parse::<u32>().ok())
202        else {
203            return Err(Error::new(
204                ErrorKind::InvalidInput,
205                "python must be an exact 3.minor version",
206            ));
207        };
208        if minor < 11 || self.runtime.python != format!("3.{minor}") {
209            return Err(Error::new(
210                ErrorKind::Incompatible,
211                "CPython 3.11 or newer is required",
212            ));
213        }
214        let valid_identifier = |part: &str| {
215            !part.is_empty()
216                && part.bytes().enumerate().all(|(i, b)| {
217                    b.is_ascii_alphabetic() || b == b'_' || (i > 0 && b.is_ascii_digit())
218                })
219        };
220        let Some((module, function)) = self.handler.split_once(':') else {
221            return Err(Error::new(
222                ErrorKind::InvalidInput,
223                "handler must be module:function",
224            ));
225        };
226        if !module.split('.').all(valid_identifier) || !valid_identifier(function) {
227            return Err(Error::new(
228                ErrorKind::InvalidInput,
229                "invalid Python handler",
230            ));
231        }
232        if !["linux", "macos"].contains(&self.platform.os.as_str())
233            || !["x86_64", "aarch64"].contains(&self.platform.arch.as_str())
234        {
235            return Err(Error::new(
236                ErrorKind::Incompatible,
237                "initial platforms are Linux/macOS on x86_64/aarch64",
238            ));
239        }
240        Ok(())
241    }
242    pub fn validate_host(&self) -> Result<()> {
243        self.validate()?;
244        if self.platform.os != std::env::consts::OS || self.platform.arch != std::env::consts::ARCH
245        {
246            return Err(Error::new(
247                ErrorKind::Incompatible,
248                "package platform differs from this worker",
249            ));
250        }
251        Ok(())
252    }
253}
254
255/// Validate the shared JSON CloudEvents profile without requiring invocation IDs.
256/// Context and trace attributes retain their original representation; `data` is
257/// user-owned JSON. Callers separately enforce their payload size/depth limits.
258pub fn validate_json_cloudevent(value: &Value) -> Result<()> {
259    validate_cloudevent_context(value)?;
260    let object = value.as_object().expect("validated CloudEvent object");
261    if !object.contains_key("data") || object.contains_key("data_base64") {
262        return Err(Error::new(
263            ErrorKind::InvalidInput,
264            "this profile requires user-owned JSON data",
265        ));
266    }
267    if object.get("datacontenttype").and_then(Value::as_str) != Some("application/json") {
268        return Err(Error::new(
269            ErrorKind::InvalidInput,
270            "datacontenttype must be application/json",
271        ));
272    }
273    Ok(())
274}
275
276/// Validate CloudEvents context attributes and W3C carriers independently of data.
277/// A concrete profile must separately constrain data presence, encoding, and size.
278pub fn validate_cloudevent_context(value: &Value) -> Result<()> {
279    let object = value
280        .as_object()
281        .ok_or_else(|| Error::new(ErrorKind::InvalidInput, "CloudEvent must be an object"))?;
282    for (name, field) in object {
283        if name == "data" {
284            continue;
285        }
286        if name.is_empty()
287            || !name
288                .bytes()
289                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
290        {
291            return Err(Error::new(
292                ErrorKind::InvalidInput,
293                format!("invalid CloudEvent context name {name}"),
294            ));
295        }
296        match field {
297            Value::String(text) if valid_context_string(text) => {}
298            Value::Bool(_) => {}
299            Value::Number(number)
300                if number
301                    .as_i64()
302                    .and_then(|n| i32::try_from(n).ok())
303                    .is_some() => {}
304            _ => {
305                return Err(Error::new(
306                    ErrorKind::InvalidInput,
307                    format!("invalid CloudEvent context value for {name}"),
308                ));
309            }
310        }
311    }
312    if object.get("specversion").and_then(Value::as_str) != Some("1.0") {
313        return Err(Error::new(
314            ErrorKind::InvalidInput,
315            "CloudEvent specversion must be 1.0",
316        ));
317    }
318    for key in ["id", "source", "type"] {
319        if object
320            .get(key)
321            .and_then(Value::as_str)
322            .is_none_or(str::is_empty)
323        {
324            return Err(Error::new(
325                ErrorKind::InvalidInput,
326                format!("missing nonempty CloudEvent {key}"),
327            ));
328        }
329    }
330    let source = context_string(object, "source")?.expect("required source checked");
331    UriReferenceStr::new(source)
332        .map_err(|_| Error::new(ErrorKind::InvalidInput, "source must be a URI-reference"))?;
333    if let Some(schema) = context_string(object, "dataschema")? {
334        UriAbsoluteStr::new(schema).map_err(|_| {
335            Error::new(
336                ErrorKind::InvalidInput,
337                "dataschema must be an absolute URI without a fragment",
338            )
339        })?;
340    }
341    if context_string(object, "subject")?.is_some_and(str::is_empty) {
342        return Err(Error::new(
343            ErrorKind::InvalidInput,
344            "subject must be nonempty when present",
345        ));
346    }
347    if let Some(timestamp) = context_string(object, "time")? {
348        // time's parser also accepts non-RFC3339 separators. Keep the shared
349        // event profile aligned with Python before accepting a durable wake.
350        if !matches!(timestamp.as_bytes().get(10), Some(b'T' | b't')) {
351            return Err(Error::new(
352                ErrorKind::InvalidInput,
353                "time must use an RFC 3339 T separator",
354            ));
355        }
356        OffsetDateTime::parse(timestamp, &Rfc3339).map_err(|_| {
357            Error::new(
358                ErrorKind::InvalidInput,
359                "time must be an RFC 3339 timestamp",
360            )
361        })?;
362    }
363    if let Some(trace) = object.get("traceparent") {
364        let Some(trace) = trace.as_str() else {
365            return Err(Error::new(
366                ErrorKind::InvalidInput,
367                "traceparent must be a string",
368            ));
369        };
370        validate_traceparent(trace)?;
371    }
372    if let Some(state) = context_string(object, "tracestate")? {
373        if !object.contains_key("traceparent") {
374            return Err(Error::new(
375                ErrorKind::InvalidInput,
376                "tracestate requires traceparent in this invocation profile",
377            ));
378        }
379        validate_tracestate(state)?;
380    }
381    Ok(())
382}
383
384/// Owns the original logical JSON event without rewriting user-owned `data`.
385///
386/// This invocation profile uses CloudEvents 1.0.2 context names/types, requires
387/// JSON data and Ledgence execution identifiers, and accepts W3C traceparent
388/// version 00. Optional tracestate requires traceparent and is limited to 512
389/// ASCII bytes and 32 members. Invalid metadata is rejected, never repaired.
390/// Optional context attributes must be omitted rather than encoded as null.
391#[derive(Debug, Clone, PartialEq, Serialize)]
392#[serde(transparent)]
393pub struct CloudEvent(Value);
394impl CloudEvent {
395    pub fn new(value: Value) -> Result<Self> {
396        validate_json_cloudevent(&value)?;
397        let object = value.as_object().expect("validated CloudEvent object");
398        for key in [
399            "ldgtenantid",
400            "ldgnamespace",
401            "ldgrunid",
402            "ldgtaskid",
403            "ldgattemptid",
404        ] {
405            if object
406                .get(key)
407                .and_then(Value::as_str)
408                .is_none_or(str::is_empty)
409            {
410                return Err(Error::new(
411                    ErrorKind::InvalidInput,
412                    format!("missing nonempty CloudEvent {key}"),
413                ));
414            }
415        }
416        if object
417            .get("ldgattemptno")
418            .and_then(Value::as_i64)
419            .is_none_or(|n| !(1..=i64::from(i32::MAX)).contains(&n))
420        {
421            return Err(Error::new(
422                ErrorKind::InvalidInput,
423                "ldgattemptno must be a positive signed 32-bit integer",
424            ));
425        }
426        for key in [
427            "ldgworkflowid",
428            "ldgactivationid",
429            "ldgparentworkflowid",
430            "ldgrootworkflowid",
431        ] {
432            if let Some(id) = context_string(object, key)?
433                && (id.is_empty() || id.len() > 128)
434            {
435                return Err(Error::new(
436                    ErrorKind::InvalidInput,
437                    format!("{key} must contain 1..=128 bytes"),
438                ));
439            }
440        }
441        if let Some(activation) = context_string(object, "ldgactivationid")?
442            && (!object.contains_key("ldgworkflowid")
443                || Some(activation) != object.get("ldgtaskid").and_then(Value::as_str))
444        {
445            return Err(Error::new(
446                ErrorKind::InvalidInput,
447                "workflow activation requires its workflow ID and matching task ID",
448            ));
449        }
450        match (
451            context_string(object, "ldgworkflowid")?,
452            context_string(object, "ldgparentworkflowid")?,
453            context_string(object, "ldgrootworkflowid")?,
454        ) {
455            (_, None, None) => {}
456            (Some(workflow), Some(parent), Some(root))
457                if workflow != parent && workflow != root => {}
458            _ => {
459                return Err(Error::new(
460                    ErrorKind::InvalidInput,
461                    "nested workflow requires paired parent/root IDs distinct from itself",
462                ));
463            }
464        }
465        Ok(Self(value))
466    }
467    pub fn value(&self) -> &Value {
468        &self.0
469    }
470    pub fn into_value(self) -> Value {
471        self.0
472    }
473    pub fn id(&self) -> &str {
474        self.string("id")
475    }
476    pub fn attempt_id(&self) -> &str {
477        self.string("ldgattemptid")
478    }
479    pub fn task_id(&self) -> &str {
480        self.string("ldgtaskid")
481    }
482    pub fn tenant_id(&self) -> &str {
483        self.string("ldgtenantid")
484    }
485    pub fn namespace(&self) -> &str {
486        self.string("ldgnamespace")
487    }
488    pub fn traceparent(&self) -> Option<&str> {
489        self.0.get("traceparent").and_then(Value::as_str)
490    }
491    fn string(&self, key: &str) -> &str {
492        self.0[key].as_str().expect("validated CloudEvent string")
493    }
494}
495impl<'de> Deserialize<'de> for CloudEvent {
496    fn deserialize<D: serde::Deserializer<'de>>(
497        deserializer: D,
498    ) -> std::result::Result<Self, D::Error> {
499        struct EventVisitor;
500        impl<'de> serde::de::Visitor<'de> for EventVisitor {
501            type Value = CloudEvent;
502            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
503                formatter.write_str("a CloudEvent object with unique context attributes")
504            }
505            fn visit_map<A: serde::de::MapAccess<'de>>(
506                self,
507                mut access: A,
508            ) -> std::result::Result<Self::Value, A::Error> {
509                let mut object = Map::new();
510                while let Some((key, value)) = access.next_entry::<String, Value>()? {
511                    if object.insert(key.clone(), value).is_some() {
512                        return Err(serde::de::Error::custom(format!(
513                            "duplicate CloudEvent field {key}"
514                        )));
515                    }
516                }
517                CloudEvent::new(Value::Object(object)).map_err(serde::de::Error::custom)
518            }
519        }
520        deserializer.deserialize_map(EventVisitor)
521    }
522}
523
524fn valid_context_string(value: &str) -> bool {
525    value.chars().all(|character| {
526        let code = u32::from(character);
527        !character.is_control() && !(0xfdd0..=0xfdef).contains(&code) && (code & 0xfffe) != 0xfffe
528    })
529}
530
531fn context_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>> {
532    object
533        .get(key)
534        .map(|value| {
535            value.as_str().ok_or_else(|| {
536                Error::new(ErrorKind::InvalidInput, format!("{key} must be a string"))
537            })
538        })
539        .transpose()
540}
541
542fn validate_tracestate(state: &str) -> Result<()> {
543    let invalid = || Error::new(ErrorKind::InvalidInput, "invalid or oversized tracestate");
544    if state.len() > 512 || !state.is_ascii() {
545        return Err(invalid());
546    }
547    let mut keys = std::collections::HashSet::new();
548    for (index, member) in state.split(',').enumerate() {
549        if index >= 32 {
550            return Err(invalid());
551        }
552        // CloudEvents String already excludes tabs/control characters. Spaces
553        // surrounding W3C list members are allowed without changing the event.
554        let member = member.trim_matches(' ');
555        if member.is_empty() {
556            continue;
557        }
558        let Some((key, value)) = member.split_once('=') else {
559            return Err(invalid());
560        };
561        if !valid_tracestate_key(key)
562            || !keys.insert(key)
563            || value.is_empty()
564            || value.len() > 256
565            || !value
566                .bytes()
567                .all(|b| (0x20..=0x7e).contains(&b) && b != b',' && b != b'=')
568        {
569            return Err(invalid());
570        }
571    }
572    Ok(())
573}
574
575fn valid_tracestate_key(key: &str) -> bool {
576    let allowed = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_-*/".contains(&b);
577    let starts_lower = |part: &str| part.bytes().next().is_some_and(|b| b.is_ascii_lowercase());
578    if let Some((tenant, system)) = key.split_once('@') {
579        !tenant.is_empty()
580            && tenant.len() <= 241
581            && tenant
582                .bytes()
583                .next()
584                .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
585            && tenant.bytes().all(allowed)
586            && !system.is_empty()
587            && system.len() <= 14
588            && starts_lower(system)
589            && system.bytes().all(allowed)
590    } else {
591        !key.is_empty() && key.len() <= 256 && starts_lower(key) && key.bytes().all(allowed)
592    }
593}
594
595fn validate_traceparent(trace: &str) -> Result<()> {
596    // The first protocol version intentionally supports the W3C version-00 shape.
597    let segments: Vec<_> = trace.split('-').collect();
598    if segments.len() != 4
599        || segments[0] != "00"
600        || segments[1].len() != 32
601        || segments[2].len() != 16
602        || segments[3].len() != 2
603        || !segments.iter().all(|s| {
604            s.bytes()
605                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
606        })
607        || segments[1].bytes().all(|b| b == b'0')
608        || segments[2].bytes().all(|b| b == b'0')
609    {
610        return Err(Error::new(
611            ErrorKind::InvalidInput,
612            "invalid or unsupported traceparent",
613        ));
614    }
615    Ok(())
616}
617
618/// Cancellation and a monotonic deadline; contains no runtime-specific types.
619#[derive(Debug, Clone)]
620pub struct RunControl {
621    cancelled: Arc<AtomicBool>,
622    deadline: Instant,
623}
624impl RunControl {
625    /// Bind to an already established monotonic execution deadline.
626    pub fn with_deadline(deadline: Instant) -> Self {
627        Self {
628            cancelled: Arc::new(AtomicBool::new(false)),
629            deadline,
630        }
631    }
632
633    /// Constructs a deadline. An unrepresentable duration fails closed as an
634    /// immediately expired control; use `try_new` to report invalid input.
635    pub fn new(timeout: Duration) -> Self {
636        let now = Instant::now();
637        Self {
638            cancelled: Arc::new(AtomicBool::new(false)),
639            deadline: now.checked_add(timeout).unwrap_or(now),
640        }
641    }
642    /// Constructs a deadline, rejecting durations that overflow the host clock.
643    pub fn try_new(timeout: Duration) -> Result<Self> {
644        let deadline = Instant::now().checked_add(timeout).ok_or_else(|| {
645            Error::new(
646                ErrorKind::InvalidInput,
647                "timeout exceeds the host clock range",
648            )
649        })?;
650        Ok(Self {
651            cancelled: Arc::new(AtomicBool::new(false)),
652            deadline,
653        })
654    }
655    pub fn cancel(&self) {
656        self.cancelled.store(true, Ordering::Release);
657    }
658    pub fn deadline(&self) -> Instant {
659        self.deadline
660    }
661    pub fn is_cancelled(&self) -> bool {
662        self.cancelled.load(Ordering::Acquire)
663    }
664    pub fn check(&self) -> Result<()> {
665        if self.is_cancelled() {
666            Err(Error::new(ErrorKind::Cancelled, "invocation cancelled"))
667        } else if Instant::now() >= self.deadline {
668            Err(Error::new(
669                ErrorKind::TimedOut,
670                "invocation deadline expired",
671            ))
672        } else {
673            Ok(())
674        }
675    }
676}
677
678pub trait ArtifactLease: Send + Sync {}
679impl<T: Send + Sync> ArtifactLease for T {}
680
681/// Clones retain an opaque cache lease until the last runtime/consumer releases it.
682#[derive(Clone)]
683pub struct PreparedArtifact {
684    root: PathBuf,
685    manifest: ProgramManifest,
686    digest: Digest,
687    lease: Arc<dyn ArtifactLease>,
688}
689impl PreparedArtifact {
690    pub fn new(
691        root: PathBuf,
692        manifest: ProgramManifest,
693        digest: Digest,
694        lease: Arc<dyn ArtifactLease>,
695    ) -> Self {
696        Self {
697            root,
698            manifest,
699            digest,
700            lease,
701        }
702    }
703    pub fn root(&self) -> &Path {
704        &self.root
705    }
706    pub fn manifest(&self) -> &ProgramManifest {
707        &self.manifest
708    }
709    pub fn digest(&self) -> &Digest {
710        &self.digest
711    }
712}
713impl fmt::Debug for PreparedArtifact {
714    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
715        f.debug_struct("PreparedArtifact")
716            .field("root", &self.root)
717            .field("manifest", &self.manifest)
718            .field("digest", &self.digest)
719            .field("pin_count", &Arc::strong_count(&self.lease))
720            .finish()
721    }
722}
723
724#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
725#[serde(tag = "status", rename_all = "snake_case")]
726pub enum ProgramOutcome {
727    Success { output: Value },
728    Failure { kind: String, message: String },
729}
730
731/// Store futures must retain their underlying I/O until completion. Dropping a
732/// future is not proof that blocking or remote work stopped. The worker retains
733/// an admitted fetch after its response deadline until that future completes.
734/// Adapters must report completion only after their owned work has finished.
735pub trait ProgramStore: Send + Sync {
736    fn resolve<'a>(&'a self, program: &'a ProgramRef) -> PortFuture<'a, ProgramDescriptor>;
737    fn fetch<'a>(&'a self, descriptor: &'a ProgramDescriptor) -> PortFuture<'a, Vec<u8>>;
738}
739pub trait ArtifactCache: Send + Sync {
740    fn lookup<'a>(
741        &'a self,
742        descriptor: &'a ProgramDescriptor,
743    ) -> PortFuture<'a, Option<PreparedArtifact>>;
744    fn publish<'a>(
745        &'a self,
746        descriptor: &'a ProgramDescriptor,
747        archive: Vec<u8>,
748    ) -> PortFuture<'a, PreparedArtifact>;
749}
750/// Startup retains ownership whenever cleanup cannot be confirmed.
751pub enum StartOutcome {
752    Ready(Box<dyn ExecutionSession>),
753    CleanupRequired {
754        error: Error,
755        session: Box<dyn ExecutionSession>,
756    },
757}
758
759pub trait ExecutionRuntime: Send + Sync {
760    /// An outer error guarantees no process remains owned. Otherwise return a
761    /// ready session or a cleanup handle that must continue occupying pool capacity.
762    fn start<'a>(
763        &'a self,
764        artifact: PreparedArtifact,
765        control: RunControl,
766    ) -> PortFuture<'a, StartOutcome>;
767}
768/// Invocation-scoped callback. Implementations bind authority outside the child payload.
769/// Dropping this future may leave a remote write uncertain; handlers must fence
770/// stale attempts and make repeated operations idempotent. A successful reply
771/// must acknowledge the requested operation before execution may continue.
772pub trait RuntimeRequestHandler: Send + Sync {
773    fn handle<'a>(
774        &'a self,
775        request: RuntimeRequest,
776        control: RunControl,
777    ) -> PortFuture<'a, RuntimeReply>;
778}
779
780pub trait ExecutionSession: Send {
781    fn pid(&self) -> u32;
782    /// Runtime/protocol errors require retiring the session; business Failure may be reused.
783    /// The session must retain process ownership if this future is dropped or panics,
784    /// so `close` can still confirm cleanup. Adapter panics close worker admission.
785    fn execute<'a>(
786        &'a mut self,
787        invocation: RuntimeInvocation,
788        control: RunControl,
789    ) -> PortFuture<'a, ProgramOutcome>;
790    /// Opt-in execution with intermediate request/reply exchanges. Unsupported
791    /// runtimes reject this explicitly without executing the program. The same
792    /// process ownership and cleanup obligations as `execute` apply; callback
793    /// failure, cancellation, or an uncertain exchange must retire the session.
794    fn execute_with_requests<'a>(
795        &'a mut self,
796        _invocation: RuntimeInvocation,
797        _control: RunControl,
798        _handler: Arc<dyn RuntimeRequestHandler>,
799    ) -> PortFuture<'a, ProgramOutcome> {
800        Box::pin(async {
801            Err(Error::new(
802                ErrorKind::Incompatible,
803                "runtime does not support interactive execution",
804            ))
805        })
806    }
807    /// Resolves successfully only once the process group is stopped and child reaped.
808    /// Calls must be retryable after cancellation or failure, retaining confirmed
809    /// cleanup progress and the artifact pin until cleanup is complete.
810    fn close(&mut self) -> PortFuture<'_, ()>;
811}
812
813/// Helpers for constructing complete events in test/demo adapters; callers own IDs.
814pub fn event_extensions(value: &CloudEvent) -> Map<String, Value> {
815    value
816        .value()
817        .as_object()
818        .expect("validated object")
819        .iter()
820        .filter(|(k, _)| k.starts_with("ldg") || *k == "traceparent" || *k == "tracestate")
821        .map(|(k, v)| (k.clone(), v.clone()))
822        .collect()
823}