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