greentic_types/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2#![forbid(unsafe_code)]
3#![deny(missing_docs)]
4#![warn(clippy::unwrap_used, clippy::expect_used)]
5
6//! Shared types and helpers for Greentic multi-tenant flows.
7//!
8//! # Overview
9//! Greentic components share a single crate for tenancy, execution outcomes, network limits, and
10//! schema metadata. Use the strongly-typed identifiers to keep flows, packs, and components
11//! consistent across repositories and to benefit from serde + schema validation automatically.
12//!
13//! ## Tenant contexts
14//! ```
15//! use greentic_types::{EnvId, TenantCtx, TenantId};
16//!
17//! let ctx = TenantCtx::new("prod".parse().unwrap(), "tenant-42".parse().unwrap())
18//!     .with_team(Some("team-ops".parse().unwrap()))
19//!     .with_user(Some("agent-007".parse().unwrap()));
20//! assert_eq!(ctx.tenant_id.as_str(), "tenant-42");
21//! ```
22//!
23//! ## Run results & serialization
24//! ```
25//! # #[cfg(feature = "time")] {
26//! use greentic_types::{FlowId, PackId, RunResult, RunStatus, SessionKey};
27//! use semver::Version;
28//! use time::OffsetDateTime;
29//!
30//! let now = OffsetDateTime::UNIX_EPOCH;
31//! let result = RunResult {
32//!     session_id: SessionKey::from("sess-1"),
33//!     pack_id: "greentic.demo.pack".parse().unwrap(),
34//!     pack_version: Version::parse("1.0.0").unwrap(),
35//!     flow_id: "demo-flow".parse().unwrap(),
36//!     started_at_utc: now,
37//!     finished_at_utc: now,
38//!     status: RunStatus::Success,
39//!     node_summaries: Vec::new(),
40//!     failures: Vec::new(),
41//!     artifacts_dir: None,
42//! };
43//! println!("{}", serde_json::to_string_pretty(&result).unwrap());
44//! # }
45//! ```
46//!
47//! Published JSON Schemas are listed in [`SCHEMAS.md`](SCHEMAS.md) and hosted under
48//! <https://greentic-ai.github.io/greentic-types/schemas/v1/>.
49
50extern crate alloc;
51
52/// Crate version string exposed for telemetry and capability negotiation.
53pub const VERSION: &str = env!("CARGO_PKG_VERSION");
54/// Base URL for all published JSON Schemas.
55pub const SCHEMA_BASE_URL: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1";
56
57pub mod bindings;
58pub mod capabilities;
59pub mod component;
60pub mod deployment;
61pub mod flow;
62pub mod pack_manifest;
63pub mod pack_spec;
64
65pub mod context;
66pub mod error;
67pub mod outcome;
68pub mod pack;
69pub mod policy;
70pub mod run;
71#[cfg(all(feature = "schemars", feature = "std"))]
72pub mod schema;
73pub mod session;
74pub mod state;
75pub mod telemetry;
76pub mod tenant;
77
78pub use bindings::hints::{
79    BindingsHints, EnvHints, McpHints, McpServer, NetworkHints, SecretsHints,
80};
81pub use capabilities::{
82    Capabilities, FsCaps, HttpCaps, KvCaps, Limits, NetCaps, SecretsCaps, TelemetrySpec, ToolsCaps,
83};
84pub use component::{
85    ComponentCapabilities, ComponentConfigurators, ComponentManifest, ComponentProfileError,
86    ComponentProfiles, EnvCapabilities, EventsCapabilities, FilesystemCapabilities, FilesystemMode,
87    FilesystemMount, HostCapabilities, HttpCapabilities, IaCCapabilities, MessagingCapabilities,
88    SecretsCapabilities, StateCapabilities, TelemetryCapabilities, TelemetryScope,
89    WasiCapabilities,
90};
91pub use context::{Cloud, DeploymentCtx, Platform};
92pub use deployment::{
93    ChannelPlan, DeploymentPlan, MessagingPlan, MessagingSubjectPlan, OAuthPlan, RunnerPlan,
94    SecretPlan, TelemetryPlan,
95};
96pub use error::{ErrorCode, GResult, GreenticError};
97pub use flow::{Flow, FlowKind, FlowNodes, FlowValidationError, Node};
98pub use outcome::Outcome;
99pub use pack::{PackRef, Signature, SignatureAlgorithm};
100pub use pack_manifest::{PackComponentRef, PackFlowRef, PackKind, PackManifest};
101#[allow(deprecated)]
102pub use pack_spec::{PackSpec, ToolSpec};
103pub use policy::{AllowList, NetworkPolicy, PolicyDecision, Protocol};
104#[cfg(feature = "time")]
105pub use run::RunResult;
106pub use run::{NodeFailure, NodeStatus, NodeSummary, RunStatus, TranscriptOffset};
107pub use session::canonical_session_key;
108pub use session::{SessionCursor, SessionData, SessionKey};
109pub use state::{StateKey, StatePath};
110#[cfg(feature = "otel-keys")]
111pub use telemetry::OtlpKeys;
112pub use telemetry::SpanContext;
113#[cfg(feature = "telemetry-autoinit")]
114pub use telemetry::TelemetryCtx;
115pub use tenant::{Impersonation, TenantIdentity};
116
117#[cfg(feature = "schemars")]
118use alloc::borrow::Cow;
119use alloc::{borrow::ToOwned, format, string::String, vec::Vec};
120use core::fmt;
121use core::str::FromStr;
122#[cfg(feature = "schemars")]
123use schemars::JsonSchema;
124use semver::VersionReq;
125#[cfg(feature = "time")]
126use time::OffsetDateTime;
127
128#[cfg(feature = "serde")]
129use serde::{Deserialize, Serialize};
130
131#[cfg(feature = "std")]
132use alloc::boxed::Box;
133
134#[cfg(feature = "std")]
135use std::error::Error as StdError;
136
137fn validate_identifier(value: &str, label: &str) -> GResult<()> {
138    if value.is_empty() {
139        return Err(GreenticError::new(
140            ErrorCode::InvalidInput,
141            format!("{label} must not be empty"),
142        ));
143    }
144    if value
145        .chars()
146        .any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
147    {
148        return Err(GreenticError::new(
149            ErrorCode::InvalidInput,
150            format!("{label} must contain only ASCII letters, digits, '.', '-', or '_'"),
151        ));
152    }
153    Ok(())
154}
155
156/// Canonical schema IDs for the exported document types.
157pub mod ids {
158    /// Pack identifier schema.
159    pub const PACK_ID: &str =
160        "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-id.schema.json";
161    /// Component identifier schema.
162    pub const COMPONENT_ID: &str =
163        "https://greentic-ai.github.io/greentic-types/schemas/v1/component-id.schema.json";
164    /// Flow identifier schema.
165    pub const FLOW_ID: &str =
166        "https://greentic-ai.github.io/greentic-types/schemas/v1/flow-id.schema.json";
167    /// Node identifier schema.
168    pub const NODE_ID: &str =
169        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-id.schema.json";
170    /// Tenant context schema.
171    pub const TENANT_CONTEXT: &str =
172        "https://greentic-ai.github.io/greentic-types/schemas/v1/tenant-context.schema.json";
173    /// Hash digest schema.
174    pub const HASH_DIGEST: &str =
175        "https://greentic-ai.github.io/greentic-types/schemas/v1/hash-digest.schema.json";
176    /// Semantic version requirement schema.
177    pub const SEMVER_REQ: &str =
178        "https://greentic-ai.github.io/greentic-types/schemas/v1/semver-req.schema.json";
179    /// Redaction path schema.
180    pub const REDACTION_PATH: &str =
181        "https://greentic-ai.github.io/greentic-types/schemas/v1/redaction-path.schema.json";
182    /// Capabilities schema.
183    pub const CAPABILITIES: &str =
184        "https://greentic-ai.github.io/greentic-types/schemas/v1/capabilities.schema.json";
185    /// Flow schema.
186    pub const FLOW: &str =
187        "https://greentic-ai.github.io/greentic-types/schemas/v1/flow.schema.json";
188    /// Node schema.
189    pub const NODE: &str =
190        "https://greentic-ai.github.io/greentic-types/schemas/v1/node.schema.json";
191    /// Component manifest schema.
192    pub const COMPONENT_MANIFEST: &str =
193        "https://greentic-ai.github.io/greentic-types/schemas/v1/component-manifest.schema.json";
194    /// Pack manifest schema.
195    pub const PACK_MANIFEST: &str =
196        "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-manifest.schema.json";
197    /// Limits schema.
198    pub const LIMITS: &str =
199        "https://greentic-ai.github.io/greentic-types/schemas/v1/limits.schema.json";
200    /// Telemetry spec schema.
201    pub const TELEMETRY_SPEC: &str =
202        "https://greentic-ai.github.io/greentic-types/schemas/v1/telemetry-spec.schema.json";
203    /// Node summary schema.
204    pub const NODE_SUMMARY: &str =
205        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-summary.schema.json";
206    /// Node failure schema.
207    pub const NODE_FAILURE: &str =
208        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-failure.schema.json";
209    /// Node status schema.
210    pub const NODE_STATUS: &str =
211        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-status.schema.json";
212    /// Run status schema.
213    pub const RUN_STATUS: &str =
214        "https://greentic-ai.github.io/greentic-types/schemas/v1/run-status.schema.json";
215    /// Transcript offset schema.
216    pub const TRANSCRIPT_OFFSET: &str =
217        "https://greentic-ai.github.io/greentic-types/schemas/v1/transcript-offset.schema.json";
218    /// Tools capability schema.
219    pub const TOOLS_CAPS: &str =
220        "https://greentic-ai.github.io/greentic-types/schemas/v1/tools-caps.schema.json";
221    /// Secrets capability schema.
222    pub const SECRETS_CAPS: &str =
223        "https://greentic-ai.github.io/greentic-types/schemas/v1/secrets-caps.schema.json";
224    /// OTLP attribute key schema.
225    pub const OTLP_KEYS: &str =
226        "https://greentic-ai.github.io/greentic-types/schemas/v1/otlp-keys.schema.json";
227    /// Run result schema.
228    pub const RUN_RESULT: &str =
229        "https://greentic-ai.github.io/greentic-types/schemas/v1/run-result.schema.json";
230}
231
232#[cfg(all(feature = "schema", feature = "std"))]
233/// Writes every JSON Schema to the provided directory.
234pub fn write_all_schemas(out_dir: &std::path::Path) -> anyhow::Result<()> {
235    use anyhow::Context;
236    use std::fs;
237
238    fs::create_dir_all(out_dir)
239        .with_context(|| format!("failed to create {}", out_dir.display()))?;
240
241    for entry in crate::schema::entries() {
242        let schema = (entry.generator)();
243        let path = out_dir.join(entry.file_name);
244        if let Some(parent) = path.parent() {
245            fs::create_dir_all(parent)
246                .with_context(|| format!("failed to create {}", parent.display()))?;
247        }
248
249        let json =
250            serde_json::to_vec_pretty(&schema).context("failed to serialize schema to JSON")?;
251        fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
252    }
253
254    Ok(())
255}
256
257macro_rules! id_newtype {
258    ($name:ident, $doc:literal) => {
259        #[doc = $doc]
260        #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
261        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
262        #[cfg_attr(feature = "schemars", derive(JsonSchema))]
263        #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
264        pub struct $name(pub String);
265
266        impl $name {
267            /// Returns the identifier as a string slice.
268            pub fn as_str(&self) -> &str {
269                &self.0
270            }
271
272            /// Validates and constructs the identifier from the provided value.
273            pub fn new(value: impl AsRef<str>) -> GResult<Self> {
274                value.as_ref().parse()
275            }
276        }
277
278        impl From<$name> for String {
279            fn from(value: $name) -> Self {
280                value.0
281            }
282        }
283
284        impl AsRef<str> for $name {
285            fn as_ref(&self) -> &str {
286                self.as_str()
287            }
288        }
289
290        impl fmt::Display for $name {
291            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292                f.write_str(self.as_str())
293            }
294        }
295
296        impl FromStr for $name {
297            type Err = GreenticError;
298
299            fn from_str(value: &str) -> Result<Self, Self::Err> {
300                validate_identifier(value, stringify!($name))?;
301                Ok(Self(value.to_owned()))
302            }
303        }
304
305        impl TryFrom<String> for $name {
306            type Error = GreenticError;
307
308            fn try_from(value: String) -> Result<Self, Self::Error> {
309                $name::from_str(&value)
310            }
311        }
312
313        impl TryFrom<&str> for $name {
314            type Error = GreenticError;
315
316            fn try_from(value: &str) -> Result<Self, Self::Error> {
317                $name::from_str(value)
318            }
319        }
320    };
321}
322
323id_newtype!(EnvId, "Environment identifier for a tenant context.");
324id_newtype!(TenantId, "Tenant identifier within an environment.");
325id_newtype!(TeamId, "Team identifier belonging to a tenant.");
326id_newtype!(UserId, "User identifier within a tenant.");
327id_newtype!(PackId, "Globally unique pack identifier.");
328id_newtype!(
329    ComponentId,
330    "Identifier referencing a component binding in a pack."
331);
332id_newtype!(FlowId, "Identifier referencing a flow inside a pack.");
333id_newtype!(NodeId, "Identifier referencing a node inside a flow graph.");
334
335/// Compact tenant summary propagated to developers and tooling.
336#[derive(Clone, Debug, PartialEq, Eq, Hash)]
337#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
338#[cfg_attr(feature = "schemars", derive(JsonSchema))]
339pub struct TenantContext {
340    /// Tenant identifier owning the execution.
341    pub tenant_id: TenantId,
342    /// Optional team identifier scoped to the tenant.
343    #[cfg_attr(
344        feature = "serde",
345        serde(default, skip_serializing_if = "Option::is_none")
346    )]
347    pub team_id: Option<TeamId>,
348    /// Optional user identifier scoped to the tenant.
349    #[cfg_attr(
350        feature = "serde",
351        serde(default, skip_serializing_if = "Option::is_none")
352    )]
353    pub user_id: Option<UserId>,
354    /// Optional session identifier for end-to-end correlation.
355    #[cfg_attr(
356        feature = "serde",
357        serde(default, skip_serializing_if = "Option::is_none")
358    )]
359    pub session_id: Option<String>,
360}
361
362impl TenantContext {
363    /// Creates a new tenant context scoped to the provided tenant id.
364    pub fn new(tenant_id: TenantId) -> Self {
365        Self {
366            tenant_id,
367            team_id: None,
368            user_id: None,
369            session_id: None,
370        }
371    }
372}
373
374impl From<&TenantCtx> for TenantContext {
375    fn from(ctx: &TenantCtx) -> Self {
376        Self {
377            tenant_id: ctx.tenant_id.clone(),
378            team_id: ctx.team_id.clone().or_else(|| ctx.team.clone()),
379            user_id: ctx.user_id.clone().or_else(|| ctx.user.clone()),
380            session_id: ctx.session_id.clone(),
381        }
382    }
383}
384
385/// Supported hashing algorithms for pack content digests.
386#[derive(Clone, Debug, PartialEq, Eq, Hash)]
387#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
388#[cfg_attr(feature = "schemars", derive(JsonSchema))]
389#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
390pub enum HashAlgorithm {
391    /// Blake3 hashing algorithm.
392    Blake3,
393    /// Catch all for other algorithms.
394    Other(String),
395}
396
397/// Content digest describing a pack or artifact.
398#[derive(Clone, Debug, PartialEq, Eq, Hash)]
399#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
400#[cfg_attr(
401    feature = "serde",
402    serde(into = "HashDigestRepr", try_from = "HashDigestRepr")
403)]
404#[cfg_attr(feature = "schemars", derive(JsonSchema))]
405pub struct HashDigest {
406    /// Hash algorithm used to produce the digest.
407    pub algo: HashAlgorithm,
408    /// Hex encoded digest bytes.
409    pub hex: String,
410}
411
412impl HashDigest {
413    /// Creates a new digest ensuring the hex payload is valid.
414    pub fn new(algo: HashAlgorithm, hex: impl Into<String>) -> GResult<Self> {
415        let hex = hex.into();
416        validate_hex(&hex)?;
417        Ok(Self { algo, hex })
418    }
419
420    /// Convenience constructor for Blake3 digests.
421    pub fn blake3(hex: impl Into<String>) -> GResult<Self> {
422        Self::new(HashAlgorithm::Blake3, hex)
423    }
424}
425
426#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
427#[cfg_attr(feature = "schemars", derive(JsonSchema))]
428struct HashDigestRepr {
429    algo: HashAlgorithm,
430    hex: String,
431}
432
433impl From<HashDigest> for HashDigestRepr {
434    fn from(value: HashDigest) -> Self {
435        Self {
436            algo: value.algo,
437            hex: value.hex,
438        }
439    }
440}
441
442impl TryFrom<HashDigestRepr> for HashDigest {
443    type Error = GreenticError;
444
445    fn try_from(value: HashDigestRepr) -> Result<Self, Self::Error> {
446        HashDigest::new(value.algo, value.hex)
447    }
448}
449
450fn validate_hex(hex: &str) -> GResult<()> {
451    if hex.is_empty() {
452        return Err(GreenticError::new(
453            ErrorCode::InvalidInput,
454            "digest hex payload must not be empty",
455        ));
456    }
457    if hex.len() % 2 != 0 {
458        return Err(GreenticError::new(
459            ErrorCode::InvalidInput,
460            "digest hex payload must have an even number of digits",
461        ));
462    }
463    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
464        return Err(GreenticError::new(
465            ErrorCode::InvalidInput,
466            "digest hex payload must be hexadecimal",
467        ));
468    }
469    Ok(())
470}
471
472/// Semantic version requirement validated by [`semver`].
473#[derive(Clone, Debug, PartialEq, Eq, Hash)]
474#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
475#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
476pub struct SemverReq(String);
477
478impl SemverReq {
479    /// Parses and validates a semantic version requirement string.
480    pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
481        let value = value.as_ref();
482        VersionReq::parse(value).map_err(|err| {
483            GreenticError::new(
484                ErrorCode::InvalidInput,
485                format!("invalid semver requirement '{value}': {err}"),
486            )
487        })?;
488        Ok(Self(value.to_owned()))
489    }
490
491    /// Returns the underlying string slice.
492    pub fn as_str(&self) -> &str {
493        &self.0
494    }
495
496    /// Converts into a [`semver::VersionReq`].
497    pub fn to_version_req(&self) -> VersionReq {
498        VersionReq::parse(&self.0)
499            .unwrap_or_else(|err| unreachable!("SemverReq::parse validated inputs: {err}"))
500    }
501}
502
503impl fmt::Display for SemverReq {
504    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
505        f.write_str(self.as_str())
506    }
507}
508
509impl From<SemverReq> for String {
510    fn from(value: SemverReq) -> Self {
511        value.0
512    }
513}
514
515impl TryFrom<String> for SemverReq {
516    type Error = GreenticError;
517
518    fn try_from(value: String) -> Result<Self, Self::Error> {
519        SemverReq::parse(&value)
520    }
521}
522
523impl TryFrom<&str> for SemverReq {
524    type Error = GreenticError;
525
526    fn try_from(value: &str) -> Result<Self, Self::Error> {
527        SemverReq::parse(value)
528    }
529}
530
531impl FromStr for SemverReq {
532    type Err = GreenticError;
533
534    fn from_str(s: &str) -> Result<Self, Self::Err> {
535        SemverReq::parse(s)
536    }
537}
538
539/// JSONPath expression pointing at sensitive fields that should be redacted.
540#[derive(Clone, Debug, PartialEq, Eq, Hash)]
541#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
542#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
543pub struct RedactionPath(String);
544
545impl RedactionPath {
546    /// Validates and stores a JSONPath expression.
547    pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
548        let value = value.as_ref();
549        validate_jsonpath(value)?;
550        Ok(Self(value.to_owned()))
551    }
552
553    /// Returns the JSONPath string.
554    pub fn as_str(&self) -> &str {
555        &self.0
556    }
557}
558
559impl fmt::Display for RedactionPath {
560    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561        f.write_str(self.as_str())
562    }
563}
564
565impl From<RedactionPath> for String {
566    fn from(value: RedactionPath) -> Self {
567        value.0
568    }
569}
570
571impl TryFrom<String> for RedactionPath {
572    type Error = GreenticError;
573
574    fn try_from(value: String) -> Result<Self, Self::Error> {
575        RedactionPath::parse(&value)
576    }
577}
578
579impl TryFrom<&str> for RedactionPath {
580    type Error = GreenticError;
581
582    fn try_from(value: &str) -> Result<Self, Self::Error> {
583        RedactionPath::parse(value)
584    }
585}
586
587fn validate_jsonpath(path: &str) -> GResult<()> {
588    if path.is_empty() {
589        return Err(GreenticError::new(
590            ErrorCode::InvalidInput,
591            "redaction path cannot be empty",
592        ));
593    }
594    if !path.starts_with('$') {
595        return Err(GreenticError::new(
596            ErrorCode::InvalidInput,
597            "redaction path must start with '$'",
598        ));
599    }
600    if path.chars().any(|c| c.is_control()) {
601        return Err(GreenticError::new(
602            ErrorCode::InvalidInput,
603            "redaction path cannot contain control characters",
604        ));
605    }
606    Ok(())
607}
608
609#[cfg(feature = "schemars")]
610impl JsonSchema for SemverReq {
611    fn schema_name() -> Cow<'static, str> {
612        Cow::Borrowed("SemverReq")
613    }
614
615    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
616        let mut schema = <String>::json_schema(generator);
617        if schema.get("description").is_none() {
618            schema.insert(
619                "description".into(),
620                "Validated semantic version requirement string".into(),
621            );
622        }
623        schema
624    }
625}
626
627#[cfg(feature = "schemars")]
628impl JsonSchema for RedactionPath {
629    fn schema_name() -> Cow<'static, str> {
630        Cow::Borrowed("RedactionPath")
631    }
632
633    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
634        let mut schema = <String>::json_schema(generator);
635        if schema.get("description").is_none() {
636            schema.insert(
637                "description".into(),
638                "JSONPath expression used for runtime redaction".into(),
639            );
640        }
641        schema
642    }
643}
644
645/// Deadline metadata for an invocation, stored as Unix epoch milliseconds.
646#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
647#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
648#[cfg_attr(feature = "schemars", derive(JsonSchema))]
649pub struct InvocationDeadline {
650    unix_millis: i128,
651}
652
653impl InvocationDeadline {
654    /// Creates a deadline from a Unix timestamp expressed in milliseconds.
655    pub const fn from_unix_millis(unix_millis: i128) -> Self {
656        Self { unix_millis }
657    }
658
659    /// Returns the deadline as Unix epoch milliseconds.
660    pub const fn unix_millis(&self) -> i128 {
661        self.unix_millis
662    }
663
664    /// Converts the deadline into an [`OffsetDateTime`].
665    #[cfg(feature = "time")]
666    pub fn to_offset_date_time(&self) -> Result<OffsetDateTime, time::error::ComponentRange> {
667        OffsetDateTime::from_unix_timestamp_nanos(self.unix_millis * 1_000_000)
668    }
669
670    /// Creates a deadline from an [`OffsetDateTime`], truncating to milliseconds.
671    #[cfg(feature = "time")]
672    pub fn from_offset_date_time(value: OffsetDateTime) -> Self {
673        let nanos = value.unix_timestamp_nanos();
674        Self {
675            unix_millis: nanos / 1_000_000,
676        }
677    }
678}
679
680/// Context that accompanies every invocation across Greentic runtimes.
681#[derive(Clone, Debug, PartialEq, Eq, Hash)]
682#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
683#[cfg_attr(feature = "schemars", derive(JsonSchema))]
684pub struct TenantCtx {
685    /// Environment scope (for example `dev`, `staging`, or `prod`).
686    pub env: EnvId,
687    /// Tenant identifier for the current execution.
688    pub tenant: TenantId,
689    /// Stable tenant identifier reference used across systems.
690    pub tenant_id: TenantId,
691    /// Optional team identifier scoped to the tenant.
692    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
693    pub team: Option<TeamId>,
694    /// Optional team identifier accessible via the shared schema.
695    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
696    pub team_id: Option<TeamId>,
697    /// Optional user identifier scoped to the tenant.
698    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
699    pub user: Option<UserId>,
700    /// Optional user identifier aligned with the shared schema.
701    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
702    pub user_id: Option<UserId>,
703    /// Optional session identifier propagated by the runtime.
704    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
705    pub session_id: Option<String>,
706    /// Optional flow identifier for the current execution.
707    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
708    pub flow_id: Option<String>,
709    /// Optional node identifier within the flow.
710    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
711    pub node_id: Option<String>,
712    /// Optional provider identifier describing the runtime surface.
713    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
714    pub provider_id: Option<String>,
715    /// Distributed tracing identifier when available.
716    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
717    pub trace_id: Option<String>,
718    /// Correlation identifier for linking related events.
719    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
720    pub correlation_id: Option<String>,
721    /// Deadline when the invocation should finish.
722    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
723    pub deadline: Option<InvocationDeadline>,
724    /// Attempt counter for retried invocations (starting at zero).
725    pub attempt: u32,
726    /// Stable idempotency key propagated across retries.
727    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
728    pub idempotency_key: Option<String>,
729    /// Optional impersonation context describing the acting identity.
730    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
731    pub impersonation: Option<Impersonation>,
732}
733
734impl TenantCtx {
735    /// Creates a new tenant context with the provided environment and tenant identifiers.
736    pub fn new(env: EnvId, tenant: TenantId) -> Self {
737        let tenant_id = tenant.clone();
738        Self {
739            env,
740            tenant: tenant.clone(),
741            tenant_id,
742            team: None,
743            team_id: None,
744            user: None,
745            user_id: None,
746            session_id: None,
747            flow_id: None,
748            node_id: None,
749            provider_id: None,
750            trace_id: None,
751            correlation_id: None,
752            deadline: None,
753            attempt: 0,
754            idempotency_key: None,
755            impersonation: None,
756        }
757    }
758
759    /// Updates the team information ensuring legacy and shared fields stay aligned.
760    pub fn with_team(mut self, team: Option<TeamId>) -> Self {
761        self.team = team.clone();
762        self.team_id = team;
763        self
764    }
765
766    /// Updates the user information ensuring legacy and shared fields stay aligned.
767    pub fn with_user(mut self, user: Option<UserId>) -> Self {
768        self.user = user.clone();
769        self.user_id = user;
770        self
771    }
772
773    /// Updates the session identifier.
774    pub fn with_session(mut self, session: impl Into<String>) -> Self {
775        self.session_id = Some(session.into());
776        self
777    }
778
779    /// Updates the flow identifier.
780    pub fn with_flow(mut self, flow: impl Into<String>) -> Self {
781        self.flow_id = Some(flow.into());
782        self
783    }
784
785    /// Updates the node identifier.
786    pub fn with_node(mut self, node: impl Into<String>) -> Self {
787        self.node_id = Some(node.into());
788        self
789    }
790
791    /// Updates the provider identifier.
792    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
793        self.provider_id = Some(provider.into());
794        self
795    }
796
797    /// Sets the impersonation context.
798    pub fn with_impersonation(mut self, impersonation: Option<Impersonation>) -> Self {
799        self.impersonation = impersonation;
800        self
801    }
802
803    /// Returns a copy of the context with the provided attempt value.
804    pub fn with_attempt(mut self, attempt: u32) -> Self {
805        self.attempt = attempt;
806        self
807    }
808
809    /// Updates the deadline metadata for subsequent invocations.
810    pub fn with_deadline(mut self, deadline: Option<InvocationDeadline>) -> Self {
811        self.deadline = deadline;
812        self
813    }
814
815    /// Returns the session identifier, when present.
816    pub fn session_id(&self) -> Option<&str> {
817        self.session_id.as_deref()
818    }
819
820    /// Returns the flow identifier, when present.
821    pub fn flow_id(&self) -> Option<&str> {
822        self.flow_id.as_deref()
823    }
824
825    /// Returns the node identifier, when present.
826    pub fn node_id(&self) -> Option<&str> {
827        self.node_id.as_deref()
828    }
829
830    /// Returns the provider identifier, when present.
831    pub fn provider_id(&self) -> Option<&str> {
832        self.provider_id.as_deref()
833    }
834}
835
836/// Primary payload representation shared across envelopes.
837pub type BinaryPayload = Vec<u8>;
838
839/// Normalized ingress payload delivered to nodes.
840#[derive(Clone, Debug, PartialEq, Eq)]
841#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
842#[cfg_attr(feature = "schemars", derive(JsonSchema))]
843pub struct InvocationEnvelope {
844    /// Tenant context for the invocation.
845    pub ctx: TenantCtx,
846    /// Flow identifier the event belongs to.
847    pub flow_id: String,
848    /// Optional node identifier within the flow.
849    pub node_id: Option<String>,
850    /// Operation being invoked (for example `on_message` or `tick`).
851    pub op: String,
852    /// Normalized payload for the invocation.
853    pub payload: BinaryPayload,
854    /// Raw metadata propagated from the ingress surface.
855    pub metadata: BinaryPayload,
856}
857
858/// Structured detail payload attached to a node error.
859#[derive(Clone, Debug, PartialEq, Eq)]
860#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
861#[cfg_attr(feature = "schemars", derive(JsonSchema))]
862pub enum ErrorDetail {
863    /// UTF-8 encoded detail payload.
864    Text(String),
865    /// Binary payload detail (for example message pack or CBOR).
866    Binary(BinaryPayload),
867}
868
869/// Error type emitted by Greentic nodes.
870#[derive(Debug)]
871#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
872#[cfg_attr(feature = "schemars", derive(JsonSchema))]
873pub struct NodeError {
874    /// Machine readable error code.
875    pub code: String,
876    /// Human readable message explaining the failure.
877    pub message: String,
878    /// Whether the failure is retryable by the runtime.
879    pub retryable: bool,
880    /// Optional backoff duration in milliseconds for the next retry.
881    pub backoff_ms: Option<u64>,
882    /// Optional structured error detail payload.
883    pub details: Option<ErrorDetail>,
884    #[cfg(feature = "std")]
885    #[cfg_attr(feature = "serde", serde(skip, default = "default_source"))]
886    #[cfg_attr(feature = "schemars", schemars(skip))]
887    source: Option<Box<dyn StdError + Send + Sync>>,
888}
889
890#[cfg(all(feature = "std", feature = "serde"))]
891fn default_source() -> Option<Box<dyn StdError + Send + Sync>> {
892    None
893}
894
895impl NodeError {
896    /// Constructs a non-retryable failure with the supplied code and message.
897    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
898        Self {
899            code: code.into(),
900            message: message.into(),
901            retryable: false,
902            backoff_ms: None,
903            details: None,
904            #[cfg(feature = "std")]
905            source: None,
906        }
907    }
908
909    /// Marks the error as retryable with an optional backoff value.
910    pub fn with_retry(mut self, backoff_ms: Option<u64>) -> Self {
911        self.retryable = true;
912        self.backoff_ms = backoff_ms;
913        self
914    }
915
916    /// Attaches structured details to the error.
917    pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
918        self.details = Some(detail);
919        self
920    }
921
922    /// Attaches a textual detail payload to the error.
923    pub fn with_detail_text(mut self, detail: impl Into<String>) -> Self {
924        self.details = Some(ErrorDetail::Text(detail.into()));
925        self
926    }
927
928    /// Attaches a binary detail payload to the error.
929    pub fn with_detail_binary(mut self, detail: BinaryPayload) -> Self {
930        self.details = Some(ErrorDetail::Binary(detail));
931        self
932    }
933
934    #[cfg(feature = "std")]
935    /// Attaches a source error to the failure for debugging purposes.
936    pub fn with_source<E>(mut self, source: E) -> Self
937    where
938        E: StdError + Send + Sync + 'static,
939    {
940        self.source = Some(Box::new(source));
941        self
942    }
943
944    /// Returns the structured details, when available.
945    pub fn detail(&self) -> Option<&ErrorDetail> {
946        self.details.as_ref()
947    }
948
949    #[cfg(feature = "std")]
950    /// Returns the attached source error when one has been provided.
951    pub fn source(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
952        self.source.as_deref()
953    }
954}
955
956impl fmt::Display for NodeError {
957    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
958        write!(f, "{}: {}", self.code, self.message)
959    }
960}
961
962#[cfg(feature = "std")]
963impl StdError for NodeError {
964    fn source(&self) -> Option<&(dyn StdError + 'static)> {
965        self.source
966            .as_ref()
967            .map(|err| err.as_ref() as &(dyn StdError + 'static))
968    }
969}
970
971/// Alias for results returned by node handlers.
972pub type NodeResult<T> = Result<T, NodeError>;
973
974/// Generates a stable idempotency key for a node invocation.
975///
976/// The key uses tenant, flow, node, and correlation identifiers. Missing
977/// correlation values fall back to the value stored on the context.
978pub fn make_idempotency_key(
979    ctx: &TenantCtx,
980    flow_id: &str,
981    node_id: Option<&str>,
982    correlation: Option<&str>,
983) -> String {
984    let node_segment = node_id.unwrap_or_default();
985    let correlation_segment = correlation
986        .or(ctx.correlation_id.as_deref())
987        .unwrap_or_default();
988    let input = format!(
989        "{}|{}|{}|{}",
990        ctx.tenant_id.as_str(),
991        flow_id,
992        node_segment,
993        correlation_segment
994    );
995    fnv1a_128_hex(input.as_bytes())
996}
997
998const FNV_OFFSET_BASIS: u128 = 0x6c62272e07bb014262b821756295c58d;
999const FNV_PRIME: u128 = 0x0000000001000000000000000000013b;
1000
1001fn fnv1a_128_hex(bytes: &[u8]) -> String {
1002    let mut hash = FNV_OFFSET_BASIS;
1003    for &byte in bytes {
1004        hash ^= byte as u128;
1005        hash = hash.wrapping_mul(FNV_PRIME);
1006    }
1007
1008    let mut output = String::with_capacity(32);
1009    for shift in (0..32).rev() {
1010        let nibble = ((hash >> (shift * 4)) & 0x0f) as u8;
1011        output.push(match nibble {
1012            0..=9 => (b'0' + nibble) as char,
1013            _ => (b'a' + (nibble - 10)) as char,
1014        });
1015    }
1016    output
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use core::convert::TryFrom;
1023    use time::OffsetDateTime;
1024
1025    fn sample_ctx() -> TenantCtx {
1026        let env = EnvId::try_from("prod").unwrap_or_else(|err| panic!("{err}"));
1027        let tenant = TenantId::try_from("tenant-123").unwrap_or_else(|err| panic!("{err}"));
1028        let team = TeamId::try_from("team-456").unwrap_or_else(|err| panic!("{err}"));
1029        let user = UserId::try_from("user-789").unwrap_or_else(|err| panic!("{err}"));
1030
1031        let mut ctx = TenantCtx::new(env, tenant)
1032            .with_team(Some(team))
1033            .with_user(Some(user))
1034            .with_attempt(2)
1035            .with_deadline(Some(InvocationDeadline::from_unix_millis(
1036                1_700_000_000_000,
1037            )));
1038        ctx.trace_id = Some("trace-abc".to_owned());
1039        ctx.correlation_id = Some("corr-xyz".to_owned());
1040        ctx.idempotency_key = Some("key-123".to_owned());
1041        ctx
1042    }
1043
1044    #[test]
1045    fn idempotent_key_stable() {
1046        let ctx = sample_ctx();
1047        let key_a = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1048        let key_b = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1049        assert_eq!(key_a, key_b);
1050        assert_eq!(key_a.len(), 32);
1051    }
1052
1053    #[test]
1054    fn idempotent_key_uses_context_correlation() {
1055        let ctx = sample_ctx();
1056        let key = make_idempotency_key(&ctx, "flow-1", None, None);
1057        let expected = make_idempotency_key(&ctx, "flow-1", None, ctx.correlation_id.as_deref());
1058        assert_eq!(key, expected);
1059    }
1060
1061    #[test]
1062    #[cfg(feature = "time")]
1063    fn deadline_roundtrips_through_offset_datetime() {
1064        let dt = OffsetDateTime::from_unix_timestamp(1_700_000_000)
1065            .unwrap_or_else(|err| panic!("valid timestamp: {err}"));
1066        let deadline = InvocationDeadline::from_offset_date_time(dt);
1067        let roundtrip = deadline
1068            .to_offset_date_time()
1069            .unwrap_or_else(|err| panic!("round-trip conversion failed: {err}"));
1070        let millis = dt.unix_timestamp_nanos() / 1_000_000;
1071        assert_eq!(deadline.unix_millis(), millis);
1072        assert_eq!(roundtrip.unix_timestamp_nanos() / 1_000_000, millis);
1073    }
1074
1075    #[test]
1076    fn node_error_builder_sets_fields() {
1077        let err = NodeError::new("TEST", "example")
1078            .with_retry(Some(500))
1079            .with_detail_text("context");
1080
1081        assert!(err.retryable);
1082        assert_eq!(err.backoff_ms, Some(500));
1083        match err.detail() {
1084            Some(ErrorDetail::Text(detail)) => assert_eq!(detail, "context"),
1085            other => panic!("unexpected detail {other:?}"),
1086        }
1087    }
1088
1089    #[cfg(feature = "std")]
1090    #[test]
1091    fn node_error_source_roundtrips() {
1092        use std::io::Error;
1093
1094        let source = Error::other("boom");
1095        let err = NodeError::new("TEST", "example").with_source(source);
1096        assert!(err.source().is_some());
1097    }
1098}