Skip to main content

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//! Component manifests now support optional development-time flows in `dev_flows`. These are JSON
14//! FlowIR structures used only during authoring (`greentic-dev`, `greentic-component`). Runtimes
15//! may safely ignore this field.
16//!
17//! ## Tenant contexts
18//! ```
19//! use greentic_types::{EnvId, TenantCtx, TenantId};
20//!
21//! let ctx = TenantCtx::new("prod".parse().unwrap(), "tenant-42".parse().unwrap())
22//!     .with_team(Some("team-ops".parse().unwrap()))
23//!     .with_user(Some("agent-007".parse().unwrap()));
24//! assert_eq!(ctx.tenant_id.as_str(), "tenant-42");
25//! ```
26//!
27//! ## Run results & serialization
28//! ```
29//! # #[cfg(feature = "time")] {
30//! use greentic_types::{FlowId, PackId, RunResult, RunStatus, SessionKey};
31//! use semver::Version;
32//! use time::OffsetDateTime;
33//!
34//! let now = OffsetDateTime::UNIX_EPOCH;
35//! let result = RunResult {
36//!     session_id: SessionKey::from("sess-1"),
37//!     pack_id: "greentic.demo.pack".parse().unwrap(),
38//!     pack_version: Version::parse("1.0.0").unwrap(),
39//!     flow_id: "demo-flow".parse().unwrap(),
40//!     started_at_utc: now,
41//!     finished_at_utc: now,
42//!     status: RunStatus::Success,
43//!     node_summaries: Vec::new(),
44//!     failures: Vec::new(),
45//!     artifacts_dir: None,
46//! };
47//! println!("{}", serde_json::to_string_pretty(&result).unwrap());
48//! # }
49//! ```
50//!
51//! Published JSON Schemas are listed in [`SCHEMAS.md`](SCHEMAS.md) and hosted under
52//! <https://greentic-ai.github.io/greentic-types/schemas/v1/>.
53
54extern crate alloc;
55
56/// Crate version string exposed for telemetry and capability negotiation.
57pub const VERSION: &str = env!("CARGO_PKG_VERSION");
58/// Base URL for all published JSON Schemas.
59pub const SCHEMA_BASE_URL: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1";
60
61pub mod adapters;
62pub mod bindings;
63pub mod capabilities;
64#[cfg(feature = "std")]
65pub mod cbor;
66pub mod cbor_bytes;
67pub mod component;
68pub mod component_source;
69pub mod contracts;
70pub mod deployment;
71pub mod distributor;
72pub mod envelope;
73pub mod events;
74pub mod events_provider;
75pub mod flow;
76pub mod flow_resolve;
77pub mod flow_resolve_summary;
78pub mod i18n;
79pub mod i18n_text;
80pub mod messaging;
81pub mod op_descriptor;
82pub mod pack_manifest;
83pub mod provider;
84pub mod provider_install;
85pub mod qa;
86pub mod schema_id;
87pub mod schema_registry;
88pub mod store;
89pub mod supply_chain;
90pub mod wizard;
91pub mod worker;
92
93pub mod context;
94pub mod error;
95pub mod outcome;
96pub mod pack;
97pub mod policy;
98pub mod run;
99#[cfg(all(feature = "schemars", feature = "std"))]
100pub mod schema;
101pub mod schemas;
102pub mod secrets;
103pub mod session;
104pub mod state;
105pub mod telemetry;
106pub mod tenant;
107pub mod tenant_config;
108pub mod validate;
109
110pub use bindings::hints::{
111    BindingsHints, EnvHints, McpHints, McpServer, NetworkHints, SecretsHints,
112};
113pub use capabilities::{
114    Capabilities, FsCaps, HttpCaps, KvCaps, Limits, NetCaps, SecretsCaps, TelemetrySpec, ToolsCaps,
115};
116#[cfg(feature = "std")]
117pub use cbor::{CborError, decode_pack_manifest, encode_pack_manifest};
118pub use cbor_bytes::{Blob, CborBytes};
119pub use component::{
120    ComponentCapabilities, ComponentConfigurators, ComponentDevFlow, ComponentManifest,
121    ComponentOperation, ComponentProfileError, ComponentProfiles, EnvCapabilities,
122    EventsCapabilities, FilesystemCapabilities, FilesystemMode, FilesystemMount, HostCapabilities,
123    HttpCapabilities, IaCCapabilities, MessagingCapabilities, ResourceHints, SecretsCapabilities,
124    StateCapabilities, TelemetryCapabilities, TelemetryScope, WasiCapabilities,
125};
126pub use component_source::{ComponentSourceRef, ComponentSourceRefError};
127pub use context::{Cloud, DeploymentCtx, Platform};
128pub use deployment::{
129    ChannelPlan, DeploymentPlan, MessagingPlan, MessagingSubjectPlan, OAuthPlan, RunnerPlan,
130    TelemetryPlan,
131};
132pub use distributor::{
133    ArtifactLocation, CacheInfo, ComponentDigest, ComponentStatus, DistributorEnvironmentId,
134    PackStatusResponseV2, ResolveComponentRequest, ResolveComponentResponse, SignatureSummary,
135};
136pub use envelope::Envelope;
137pub use error::{ErrorCode, GResult, GreenticError};
138pub use events::{EventEnvelope, EventId, EventMetadata};
139pub use events_provider::{
140    EventProviderDescriptor, EventProviderKind, OrderingKind, ReliabilityKind, TransportKind,
141};
142pub use flow::{
143    ComponentRef as FlowComponentRef, Flow, FlowKind, FlowMetadata, InputMapping, Node,
144    OutputMapping, Routing, TelemetryHints,
145};
146pub use flow_resolve::{
147    ComponentSourceRefV1, FLOW_RESOLVE_SCHEMA_VERSION, FlowResolveV1, NodeResolveV1, ResolveModeV1,
148};
149#[cfg(all(feature = "std", feature = "serde"))]
150pub use flow_resolve::{read_flow_resolve, write_flow_resolve};
151#[cfg(feature = "std")]
152pub use flow_resolve::{sidecar_path_for_flow, validate_flow_resolve};
153pub use flow_resolve_summary::{
154    FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION, FlowResolveSummaryManifestV1,
155    FlowResolveSummarySourceRefV1, FlowResolveSummaryV1, NodeResolveSummaryV1,
156};
157#[cfg(all(feature = "std", feature = "serde"))]
158pub use flow_resolve_summary::{read_flow_resolve_summary, write_flow_resolve_summary};
159#[cfg(feature = "std")]
160pub use flow_resolve_summary::{resolve_summary_path_for_flow, validate_flow_resolve_summary};
161pub use i18n::{Direction, I18nId, I18nTag, MinimalI18nProfile, id_for_tag};
162pub use i18n_text::I18nText;
163pub use messaging::{
164    Actor, Attachment, ChannelMessageEnvelope, Destination, MessageMetadata,
165    rendering::{
166        AdaptiveCardVersion, CapabilityProfile, RenderDiagnostics, RenderPlanHints, RendererMode,
167        Tier,
168    },
169    universal_dto::{
170        AuthUserRefV1, EncodeInV1, Header, HttpInV1, HttpOutV1, ProviderPayloadV1, RenderPlanInV1,
171        RenderPlanOutV1, SendPayloadInV1, SendPayloadResultV1, SubscriptionDeleteInV1,
172        SubscriptionDeleteOutV1, SubscriptionDeleteResultV1, SubscriptionEnsureInV1,
173        SubscriptionEnsureOutV1, SubscriptionEnsureResultV1, SubscriptionRenewInV1,
174        SubscriptionRenewOutV1, SubscriptionRenewalInV1, SubscriptionRenewalOutV1,
175    },
176};
177pub use op_descriptor::{IoSchema, OpDescriptor, OpExample};
178pub use outcome::Outcome;
179pub use pack::extensions::capabilities::{
180    CapabilitiesExtensionError, CapabilitiesExtensionV1, CapabilityHookAppliesToV1,
181    CapabilityOfferV1, CapabilityProviderRefV1, CapabilityScopeV1, CapabilitySetupV1,
182    EXT_CAPABILITIES_V1,
183};
184#[cfg(feature = "serde")]
185pub use pack::extensions::capabilities::{
186    decode_capabilities_extension_v1_from_cbor_bytes,
187    encode_capabilities_extension_v1_to_cbor_bytes,
188};
189pub use pack::extensions::component_manifests::{
190    ComponentManifestIndexEntryV1, ComponentManifestIndexError, ComponentManifestIndexV1,
191    EXT_COMPONENT_MANIFEST_INDEX_V1, ManifestEncoding,
192};
193#[cfg(feature = "serde")]
194pub use pack::extensions::component_manifests::{
195    decode_component_manifest_index_v1_from_cbor_bytes,
196    encode_component_manifest_index_v1_to_cbor_bytes,
197};
198pub use pack::extensions::component_sources::{
199    ArtifactLocationV1, ComponentSourceEntryV1, ComponentSourcesError, ComponentSourcesV1,
200    EXT_COMPONENT_SOURCES_V1, ResolvedComponentV1,
201};
202#[cfg(feature = "serde")]
203pub use pack::extensions::component_sources::{
204    decode_component_sources_v1_from_cbor_bytes, encode_component_sources_v1_to_cbor_bytes,
205};
206pub use pack::{PackRef, Signature, SignatureAlgorithm};
207pub use pack_manifest::{
208    BootstrapSpec, ComponentCapability, ExtensionInline, ExtensionRef, PackDependency,
209    PackFlowEntry, PackKind, PackManifest, PackSignatures,
210};
211pub use policy::{AllowList, NetworkPolicy, PolicyDecision, PolicyDecisionStatus, Protocol};
212pub use provider::{
213    PROVIDER_EXTENSION_ID, ProviderDecl, ProviderExtensionInline, ProviderManifest,
214    ProviderRuntimeRef,
215};
216pub use provider_install::{ProviderInstallRecord, ProviderInstallRefs};
217pub use qa::{
218    CanonicalPolicy, ExampleAnswers, QaSpecSource, SetupContract, SetupOutput, validate_answers,
219};
220#[cfg(feature = "time")]
221pub use run::RunResult;
222pub use run::{NodeFailure, NodeStatus, NodeSummary, RunStatus, TranscriptOffset};
223pub use schema_id::{IoSchemaSource, QaSchemaSource, SchemaId, SchemaSource, schema_id_for_cbor};
224pub use schema_registry::{SCHEMAS, SchemaDef};
225#[deprecated(
226    since = "0.4.52",
227    note = "use schemas::component::v0_6_0::ComponentQaSpec"
228)]
229pub use schemas::component::v0_5_0::LegacyComponentQaSpec;
230pub use schemas::component::v0_6_0::{
231    ComponentDescribe, ComponentInfo, ComponentOperation as ComponentDescribeOperation,
232    ComponentQaSpec, ComponentRunInput, ComponentRunOutput, QaMode as ComponentQaMode,
233    Question as ComponentQuestion, QuestionKind as ComponentQuestionKind,
234    RedactionKind as ComponentRedactionKind, RedactionRule as ComponentRedactionRule,
235};
236pub use schemas::pack::v0_6_0::{
237    CapabilityDescriptor, CapabilityMetadata, PackDescribe, PackInfo, PackQaSpec,
238    PackValidationResult,
239};
240pub use secrets::{SecretFormat, SecretKey, SecretRequirement, SecretScope};
241pub use session::canonical_session_key;
242#[allow(deprecated)]
243pub use session::{ReplyScope, SessionCursor, SessionData, SessionKey, WaitScope};
244pub use state::{StateKey, StatePath};
245pub use store::{
246    ArtifactSelector, BundleSpec, CapabilityMap, Collection, ConnectionKind, DesiredState,
247    DesiredStateExportSpec, DesiredSubscriptionEntry, Environment, LayoutSection,
248    LayoutSectionKind, PackOrComponentRef, PlanLimits, PriceModel, ProductOverride, RolloutState,
249    RolloutStatus, StoreFront, StorePlan, StoreProduct, StoreProductKind, Subscription,
250    SubscriptionStatus, Theme, VersionStrategy,
251};
252pub use supply_chain::{
253    AttestationStatement, BuildPlan, BuildStatus, BuildStatusKind, MetadataRecord, PredicateType,
254    RepoContext, ScanKind, ScanRequest, ScanResult, ScanStatusKind, SignRequest, StoreContext,
255    VerifyRequest, VerifyResult,
256};
257#[cfg(feature = "otel-keys")]
258pub use telemetry::OtlpKeys;
259pub use telemetry::SpanContext;
260#[cfg(feature = "telemetry-autoinit")]
261pub use telemetry::TelemetryCtx;
262pub use tenant::{Impersonation, TenantIdentity};
263pub use tenant_config::{
264    DefaultPipeline, DidContext, DidService, DistributorTarget, EnabledPacks,
265    IdentityProviderOption, RepoAuth, RepoConfigFeatures, RepoSkin, RepoSkinLayout, RepoSkinLinks,
266    RepoSkinTheme, RepoTenantConfig, RepoWorkerPanel, StoreTarget, TenantDidDocument,
267    VerificationMethod,
268};
269pub use validate::{
270    Diagnostic, PackValidator, Severity, ValidationCounts, ValidationReport,
271    validate_pack_manifest_core,
272};
273pub use wizard::{WizardId, WizardMode, WizardPlan, WizardPlanMeta, WizardStep, WizardTarget};
274pub use worker::{WorkerMessage, WorkerRequest, WorkerResponse};
275
276#[cfg(feature = "schemars")]
277use alloc::borrow::Cow;
278use alloc::{borrow::ToOwned, collections::BTreeMap, format, string::String, vec::Vec};
279use core::fmt;
280use core::str::FromStr;
281#[cfg(feature = "schemars")]
282use schemars::JsonSchema;
283use semver::VersionReq;
284#[cfg(feature = "time")]
285use time::OffsetDateTime;
286
287#[cfg(feature = "serde")]
288use serde::{Deserialize, Serialize};
289
290#[cfg(feature = "std")]
291use alloc::boxed::Box;
292
293#[cfg(feature = "std")]
294use std::error::Error as StdError;
295
296/// Validates identifiers to ensure they are non-empty and ASCII-safe.
297pub(crate) fn validate_identifier(value: &str, label: &str) -> GResult<()> {
298    if value.is_empty() {
299        return Err(GreenticError::new(
300            ErrorCode::InvalidInput,
301            format!("{label} must not be empty"),
302        ));
303    }
304    if value
305        .chars()
306        .any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
307    {
308        return Err(GreenticError::new(
309            ErrorCode::InvalidInput,
310            format!("{label} must contain only ASCII letters, digits, '.', '-', or '_'"),
311        ));
312    }
313    Ok(())
314}
315
316/// Validates API key references that may include URI-like prefixes.
317pub(crate) fn validate_api_key_ref(value: &str) -> GResult<()> {
318    if value.trim().is_empty() {
319        return Err(GreenticError::new(
320            ErrorCode::InvalidInput,
321            "ApiKeyRef must not be empty",
322        ));
323    }
324    if value.chars().any(char::is_whitespace) {
325        return Err(GreenticError::new(
326            ErrorCode::InvalidInput,
327            "ApiKeyRef must not contain whitespace",
328        ));
329    }
330    if !value.is_ascii() {
331        return Err(GreenticError::new(
332            ErrorCode::InvalidInput,
333            "ApiKeyRef must contain only ASCII characters",
334        ));
335    }
336    Ok(())
337}
338
339/// Canonical schema IDs for the exported document types.
340pub mod ids {
341    /// Pack identifier schema.
342    pub const PACK_ID: &str =
343        "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-id.schema.json";
344    /// Component identifier schema.
345    pub const COMPONENT_ID: &str =
346        "https://greentic-ai.github.io/greentic-types/schemas/v1/component-id.schema.json";
347    /// Flow identifier schema.
348    pub const FLOW_ID: &str =
349        "https://greentic-ai.github.io/greentic-types/schemas/v1/flow-id.schema.json";
350    /// Node identifier schema.
351    pub const NODE_ID: &str =
352        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-id.schema.json";
353    /// Tenant context schema.
354    pub const TENANT_CONTEXT: &str =
355        "https://greentic-ai.github.io/greentic-types/schemas/v1/tenant-context.schema.json";
356    /// Hash digest schema.
357    pub const HASH_DIGEST: &str =
358        "https://greentic-ai.github.io/greentic-types/schemas/v1/hash-digest.schema.json";
359    /// Semantic version requirement schema.
360    pub const SEMVER_REQ: &str =
361        "https://greentic-ai.github.io/greentic-types/schemas/v1/semver-req.schema.json";
362    /// Redaction path schema.
363    pub const REDACTION_PATH: &str =
364        "https://greentic-ai.github.io/greentic-types/schemas/v1/redaction-path.schema.json";
365    /// Capabilities schema.
366    pub const CAPABILITIES: &str =
367        "https://greentic-ai.github.io/greentic-types/schemas/v1/capabilities.schema.json";
368    /// RepoSkin (skin.json) schema.
369    pub const REPO_SKIN: &str =
370        "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-skin.schema.json";
371    /// RepoAuth (auth.json) schema.
372    pub const REPO_AUTH: &str =
373        "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-auth.schema.json";
374    /// RepoTenantConfig (config.json) schema.
375    pub const REPO_TENANT_CONFIG: &str =
376        "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-tenant-config.schema.json";
377    /// Tenant DID document (did.json) schema.
378    pub const TENANT_DID_DOCUMENT: &str =
379        "https://greentic-ai.github.io/greentic-types/schemas/v1/tenant-did-document.schema.json";
380    /// Flow schema.
381    pub const FLOW: &str = "greentic.flow.v1";
382    /// Flow resolve sidecar schema.
383    pub const FLOW_RESOLVE: &str = "greentic.flow.resolve.v1";
384    /// Flow resolve summary schema.
385    pub const FLOW_RESOLVE_SUMMARY: &str = "greentic.flow.resolve-summary.v1";
386    /// Node schema.
387    pub const NODE: &str =
388        "https://greentic-ai.github.io/greentic-types/schemas/v1/node.schema.json";
389    /// Component manifest schema.
390    pub const COMPONENT_MANIFEST: &str =
391        "https://greentic-ai.github.io/greentic-types/schemas/v1/component-manifest.schema.json";
392    /// Pack manifest schema.
393    pub const PACK_MANIFEST: &str = "greentic.pack-manifest.v1";
394    /// Validation severity schema.
395    pub const VALIDATION_SEVERITY: &str =
396        "https://greentic-ai.github.io/greentic-types/schemas/v1/validation-severity.schema.json";
397    /// Validation diagnostic schema.
398    pub const VALIDATION_DIAGNOSTIC: &str =
399        "https://greentic-ai.github.io/greentic-types/schemas/v1/validation-diagnostic.schema.json";
400    /// Validation report schema.
401    pub const VALIDATION_REPORT: &str =
402        "https://greentic-ai.github.io/greentic-types/schemas/v1/validation-report.schema.json";
403    /// Provider manifest schema.
404    pub const PROVIDER_MANIFEST: &str =
405        "https://greentic-ai.github.io/greentic-types/schemas/v1/provider-manifest.schema.json";
406    /// Provider runtime reference schema.
407    pub const PROVIDER_RUNTIME_REF: &str =
408        "https://greentic-ai.github.io/greentic-types/schemas/v1/provider-runtime-ref.schema.json";
409    /// Provider declaration schema.
410    pub const PROVIDER_DECL: &str =
411        "https://greentic-ai.github.io/greentic-types/schemas/v1/provider-decl.schema.json";
412    /// Inline provider extension payload schema.
413    pub const PROVIDER_EXTENSION_INLINE: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/provider-extension-inline.schema.json";
414    /// Provider installation record schema.
415    pub const PROVIDER_INSTALL_RECORD: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/provider-install-record.schema.json";
416    /// Limits schema.
417    pub const LIMITS: &str =
418        "https://greentic-ai.github.io/greentic-types/schemas/v1/limits.schema.json";
419    /// Telemetry spec schema.
420    pub const TELEMETRY_SPEC: &str =
421        "https://greentic-ai.github.io/greentic-types/schemas/v1/telemetry-spec.schema.json";
422    /// Node summary schema.
423    pub const NODE_SUMMARY: &str =
424        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-summary.schema.json";
425    /// Node failure schema.
426    pub const NODE_FAILURE: &str =
427        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-failure.schema.json";
428    /// Node status schema.
429    pub const NODE_STATUS: &str =
430        "https://greentic-ai.github.io/greentic-types/schemas/v1/node-status.schema.json";
431    /// Run status schema.
432    pub const RUN_STATUS: &str =
433        "https://greentic-ai.github.io/greentic-types/schemas/v1/run-status.schema.json";
434    /// Transcript offset schema.
435    pub const TRANSCRIPT_OFFSET: &str =
436        "https://greentic-ai.github.io/greentic-types/schemas/v1/transcript-offset.schema.json";
437    /// Tools capability schema.
438    pub const TOOLS_CAPS: &str =
439        "https://greentic-ai.github.io/greentic-types/schemas/v1/tools-caps.schema.json";
440    /// Secrets capability schema.
441    pub const SECRETS_CAPS: &str =
442        "https://greentic-ai.github.io/greentic-types/schemas/v1/secrets-caps.schema.json";
443    /// Branch reference schema.
444    pub const BRANCH_REF: &str =
445        "https://greentic-ai.github.io/greentic-types/schemas/v1/branch-ref.schema.json";
446    /// Commit reference schema.
447    pub const COMMIT_REF: &str =
448        "https://greentic-ai.github.io/greentic-types/schemas/v1/commit-ref.schema.json";
449    /// Git provider reference schema.
450    pub const GIT_PROVIDER_REF: &str =
451        "https://greentic-ai.github.io/greentic-types/schemas/v1/git-provider-ref.schema.json";
452    /// Scanner provider reference schema.
453    pub const SCANNER_REF: &str =
454        "https://greentic-ai.github.io/greentic-types/schemas/v1/scanner-ref.schema.json";
455    /// Webhook identifier schema.
456    pub const WEBHOOK_ID: &str =
457        "https://greentic-ai.github.io/greentic-types/schemas/v1/webhook-id.schema.json";
458    /// Provider installation identifier schema.
459    pub const PROVIDER_INSTALL_ID: &str =
460        "https://greentic-ai.github.io/greentic-types/schemas/v1/provider-install-id.schema.json";
461    /// Repository reference schema.
462    pub const REPO_REF: &str =
463        "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-ref.schema.json";
464    /// Component reference schema.
465    pub const COMPONENT_REF: &str =
466        "https://greentic-ai.github.io/greentic-types/schemas/v1/component-ref.schema.json";
467    /// Version reference schema.
468    pub const VERSION_REF: &str =
469        "https://greentic-ai.github.io/greentic-types/schemas/v1/version-ref.schema.json";
470    /// Build reference schema.
471    pub const BUILD_REF: &str =
472        "https://greentic-ai.github.io/greentic-types/schemas/v1/build-ref.schema.json";
473    /// Scan reference schema.
474    pub const SCAN_REF: &str =
475        "https://greentic-ai.github.io/greentic-types/schemas/v1/scan-ref.schema.json";
476    /// Attestation reference schema.
477    pub const ATTESTATION_REF: &str =
478        "https://greentic-ai.github.io/greentic-types/schemas/v1/attestation-ref.schema.json";
479    /// Attestation id schema.
480    pub const ATTESTATION_ID: &str =
481        "https://greentic-ai.github.io/greentic-types/schemas/v1/attestation-id.schema.json";
482    /// Policy reference schema.
483    pub const POLICY_REF: &str =
484        "https://greentic-ai.github.io/greentic-types/schemas/v1/policy-ref.schema.json";
485    /// Policy input reference schema.
486    pub const POLICY_INPUT_REF: &str =
487        "https://greentic-ai.github.io/greentic-types/schemas/v1/policy-input-ref.schema.json";
488    /// Store reference schema.
489    pub const STORE_REF: &str =
490        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-ref.schema.json";
491    /// Registry reference schema.
492    pub const REGISTRY_REF: &str =
493        "https://greentic-ai.github.io/greentic-types/schemas/v1/registry-ref.schema.json";
494    /// OCI image reference schema.
495    pub const OCI_IMAGE_REF: &str =
496        "https://greentic-ai.github.io/greentic-types/schemas/v1/oci-image-ref.schema.json";
497    /// Artifact reference schema.
498    pub const ARTIFACT_REF: &str =
499        "https://greentic-ai.github.io/greentic-types/schemas/v1/artifact-ref.schema.json";
500    /// SBOM reference schema.
501    pub const SBOM_REF: &str =
502        "https://greentic-ai.github.io/greentic-types/schemas/v1/sbom-ref.schema.json";
503    /// Signing key reference schema.
504    pub const SIGNING_KEY_REF: &str =
505        "https://greentic-ai.github.io/greentic-types/schemas/v1/signing-key-ref.schema.json";
506    /// Signature reference schema.
507    pub const SIGNATURE_REF: &str =
508        "https://greentic-ai.github.io/greentic-types/schemas/v1/signature-ref.schema.json";
509    /// Statement reference schema.
510    pub const STATEMENT_REF: &str =
511        "https://greentic-ai.github.io/greentic-types/schemas/v1/statement-ref.schema.json";
512    /// Build log reference schema.
513    pub const BUILD_LOG_REF: &str =
514        "https://greentic-ai.github.io/greentic-types/schemas/v1/build-log-ref.schema.json";
515    /// Metadata record reference schema.
516    pub const METADATA_RECORD_REF: &str =
517        "https://greentic-ai.github.io/greentic-types/schemas/v1/metadata-record-ref.schema.json";
518    /// API key reference schema.
519    pub const API_KEY_REF: &str =
520        "https://greentic-ai.github.io/greentic-types/schemas/v1/api-key-ref.schema.json";
521    /// Environment reference schema.
522    pub const ENVIRONMENT_REF: &str =
523        "https://greentic-ai.github.io/greentic-types/schemas/v1/environment-ref.schema.json";
524    /// Distributor reference schema.
525    pub const DISTRIBUTOR_REF: &str =
526        "https://greentic-ai.github.io/greentic-types/schemas/v1/distributor-ref.schema.json";
527    /// Storefront identifier schema.
528    pub const STOREFRONT_ID: &str =
529        "https://greentic-ai.github.io/greentic-types/schemas/v1/storefront-id.schema.json";
530    /// Store product identifier schema.
531    pub const STORE_PRODUCT_ID: &str =
532        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-product-id.schema.json";
533    /// Store plan identifier schema.
534    pub const STORE_PLAN_ID: &str =
535        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-plan-id.schema.json";
536    /// Subscription identifier schema.
537    pub const SUBSCRIPTION_ID: &str =
538        "https://greentic-ai.github.io/greentic-types/schemas/v1/subscription-id.schema.json";
539    /// Bundle identifier schema.
540    pub const BUNDLE_ID: &str =
541        "https://greentic-ai.github.io/greentic-types/schemas/v1/bundle-id.schema.json";
542    /// Collection identifier schema.
543    pub const COLLECTION_ID: &str =
544        "https://greentic-ai.github.io/greentic-types/schemas/v1/collection-id.schema.json";
545    /// Artifact selector schema.
546    pub const ARTIFACT_SELECTOR: &str =
547        "https://greentic-ai.github.io/greentic-types/schemas/v1/artifact-selector.schema.json";
548    /// Capability map schema.
549    pub const CAPABILITY_MAP: &str =
550        "https://greentic-ai.github.io/greentic-types/schemas/v1/capability-map.schema.json";
551    /// Store product kind schema.
552    pub const STORE_PRODUCT_KIND: &str =
553        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-product-kind.schema.json";
554    /// Version strategy schema.
555    pub const VERSION_STRATEGY: &str =
556        "https://greentic-ai.github.io/greentic-types/schemas/v1/version-strategy.schema.json";
557    /// Rollout status schema.
558    pub const ROLLOUT_STATUS: &str =
559        "https://greentic-ai.github.io/greentic-types/schemas/v1/rollout-status.schema.json";
560    /// Connection kind schema.
561    pub const CONNECTION_KIND: &str =
562        "https://greentic-ai.github.io/greentic-types/schemas/v1/connection-kind.schema.json";
563    /// Pack or component reference schema.
564    pub const PACK_OR_COMPONENT_REF: &str =
565        "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-or-component-ref.schema.json";
566    /// Plan limits schema.
567    pub const PLAN_LIMITS: &str =
568        "https://greentic-ai.github.io/greentic-types/schemas/v1/plan-limits.schema.json";
569    /// Price model schema.
570    pub const PRICE_MODEL: &str =
571        "https://greentic-ai.github.io/greentic-types/schemas/v1/price-model.schema.json";
572    /// Subscription status schema.
573    pub const SUBSCRIPTION_STATUS: &str =
574        "https://greentic-ai.github.io/greentic-types/schemas/v1/subscription-status.schema.json";
575    /// Build plan schema.
576    pub const BUILD_PLAN: &str =
577        "https://greentic-ai.github.io/greentic-types/schemas/v1/build-plan.schema.json";
578    /// Build status schema.
579    pub const BUILD_STATUS: &str =
580        "https://greentic-ai.github.io/greentic-types/schemas/v1/build-status.schema.json";
581    /// Scan request schema.
582    pub const SCAN_REQUEST: &str =
583        "https://greentic-ai.github.io/greentic-types/schemas/v1/scan-request.schema.json";
584    /// Scan result schema.
585    pub const SCAN_RESULT: &str =
586        "https://greentic-ai.github.io/greentic-types/schemas/v1/scan-result.schema.json";
587    /// Sign request schema.
588    pub const SIGN_REQUEST: &str =
589        "https://greentic-ai.github.io/greentic-types/schemas/v1/sign-request.schema.json";
590    /// Verify request schema.
591    pub const VERIFY_REQUEST: &str =
592        "https://greentic-ai.github.io/greentic-types/schemas/v1/verify-request.schema.json";
593    /// Verify result schema.
594    pub const VERIFY_RESULT: &str =
595        "https://greentic-ai.github.io/greentic-types/schemas/v1/verify-result.schema.json";
596    /// Attestation statement schema.
597    pub const ATTESTATION_STATEMENT: &str =
598        "https://greentic-ai.github.io/greentic-types/schemas/v1/attestation-statement.schema.json";
599    /// Metadata record schema.
600    pub const METADATA_RECORD: &str =
601        "https://greentic-ai.github.io/greentic-types/schemas/v1/metadata-record.schema.json";
602    /// Repository context schema.
603    pub const REPO_CONTEXT: &str =
604        "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-context.schema.json";
605    /// Store context schema.
606    pub const STORE_CONTEXT: &str =
607        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-context.schema.json";
608    /// Bundle schema.
609    pub const BUNDLE: &str =
610        "https://greentic-ai.github.io/greentic-types/schemas/v1/bundle.schema.json";
611    /// Bundle export specification schema.
612    pub const DESIRED_STATE_EXPORT: &str =
613        "https://greentic-ai.github.io/greentic-types/schemas/v1/desired-state-export.schema.json";
614    /// Desired state schema.
615    pub const DESIRED_STATE: &str =
616        "https://greentic-ai.github.io/greentic-types/schemas/v1/desired-state.schema.json";
617    /// Desired subscription entry schema.
618    pub const DESIRED_SUBSCRIPTION_ENTRY: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/desired-subscription-entry.schema.json";
619    /// Storefront schema.
620    pub const STOREFRONT: &str =
621        "https://greentic-ai.github.io/greentic-types/schemas/v1/storefront.schema.json";
622    /// Store product schema.
623    pub const STORE_PRODUCT: &str =
624        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-product.schema.json";
625    /// Store plan schema.
626    pub const STORE_PLAN: &str =
627        "https://greentic-ai.github.io/greentic-types/schemas/v1/store-plan.schema.json";
628    /// Subscription schema.
629    pub const SUBSCRIPTION: &str =
630        "https://greentic-ai.github.io/greentic-types/schemas/v1/subscription.schema.json";
631    /// Environment schema.
632    pub const ENVIRONMENT: &str =
633        "https://greentic-ai.github.io/greentic-types/schemas/v1/environment.schema.json";
634    /// Store theme schema.
635    pub const THEME: &str =
636        "https://greentic-ai.github.io/greentic-types/schemas/v1/theme.schema.json";
637    /// Layout section schema.
638    pub const LAYOUT_SECTION: &str =
639        "https://greentic-ai.github.io/greentic-types/schemas/v1/layout-section.schema.json";
640    /// Collection schema.
641    pub const COLLECTION: &str =
642        "https://greentic-ai.github.io/greentic-types/schemas/v1/collection.schema.json";
643    /// Product override schema.
644    pub const PRODUCT_OVERRIDE: &str =
645        "https://greentic-ai.github.io/greentic-types/schemas/v1/product-override.schema.json";
646    /// Event envelope schema.
647    pub const EVENT_ENVELOPE: &str =
648        "https://greentic-ai.github.io/greentic-types/schemas/v1/event-envelope.schema.json";
649    /// Event provider descriptor schema.
650    pub const EVENT_PROVIDER_DESCRIPTOR: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/event-provider-descriptor.schema.json";
651    /// Channel message envelope schema.
652    pub const CHANNEL_MESSAGE_ENVELOPE: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/channel-message-envelope.schema.json";
653    /// Attachment schema.
654    pub const ATTACHMENT: &str =
655        "https://greentic-ai.github.io/greentic-types/schemas/v1/attachment.schema.json";
656    /// Worker request envelope schema.
657    pub const WORKER_REQUEST: &str =
658        "https://greentic-ai.github.io/greentic-types/schemas/v1/worker-request.schema.json";
659    /// Worker message schema.
660    pub const WORKER_MESSAGE: &str =
661        "https://greentic-ai.github.io/greentic-types/schemas/v1/worker-message.schema.json";
662    /// Worker response envelope schema.
663    pub const WORKER_RESPONSE: &str =
664        "https://greentic-ai.github.io/greentic-types/schemas/v1/worker-response.schema.json";
665    /// OTLP attribute key schema.
666    pub const OTLP_KEYS: &str =
667        "https://greentic-ai.github.io/greentic-types/schemas/v1/otlp-keys.schema.json";
668    /// Run result schema.
669    pub const RUN_RESULT: &str =
670        "https://greentic-ai.github.io/greentic-types/schemas/v1/run-result.schema.json";
671}
672
673#[cfg(all(feature = "schema", feature = "std"))]
674/// Writes every JSON Schema to the provided directory.
675pub fn write_all_schemas(out_dir: &std::path::Path) -> anyhow::Result<()> {
676    use anyhow::Context;
677    use std::fs;
678
679    fs::create_dir_all(out_dir)
680        .with_context(|| format!("failed to create {}", out_dir.display()))?;
681
682    for entry in crate::schema::entries() {
683        let schema = (entry.generator)();
684        let path = out_dir.join(entry.file_name);
685        if let Some(parent) = path.parent() {
686            fs::create_dir_all(parent)
687                .with_context(|| format!("failed to create {}", parent.display()))?;
688        }
689
690        let json =
691            serde_json::to_vec_pretty(&schema).context("failed to serialize schema to JSON")?;
692        fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
693    }
694
695    Ok(())
696}
697
698macro_rules! id_newtype {
699    ($name:ident, $doc:literal) => {
700        #[doc = $doc]
701        #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
702        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
703        #[cfg_attr(feature = "schemars", derive(JsonSchema))]
704        #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
705        pub struct $name(pub String);
706
707        impl $name {
708            /// Returns the identifier as a string slice.
709            pub fn as_str(&self) -> &str {
710                &self.0
711            }
712
713            /// Validates and constructs the identifier from the provided value.
714            pub fn new(value: impl AsRef<str>) -> GResult<Self> {
715                value.as_ref().parse()
716            }
717        }
718
719        impl From<$name> for String {
720            fn from(value: $name) -> Self {
721                value.0
722            }
723        }
724
725        impl AsRef<str> for $name {
726            fn as_ref(&self) -> &str {
727                self.as_str()
728            }
729        }
730
731        impl fmt::Display for $name {
732            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733                f.write_str(self.as_str())
734            }
735        }
736
737        impl FromStr for $name {
738            type Err = GreenticError;
739
740            fn from_str(value: &str) -> Result<Self, Self::Err> {
741                validate_identifier(value, stringify!($name))?;
742                Ok(Self(value.to_owned()))
743            }
744        }
745
746        impl TryFrom<String> for $name {
747            type Error = GreenticError;
748
749            fn try_from(value: String) -> Result<Self, Self::Error> {
750                $name::from_str(&value)
751            }
752        }
753
754        impl TryFrom<&str> for $name {
755            type Error = GreenticError;
756
757            fn try_from(value: &str) -> Result<Self, Self::Error> {
758                $name::from_str(value)
759            }
760        }
761    };
762}
763
764id_newtype!(EnvId, "Environment identifier for a tenant context.");
765id_newtype!(TenantId, "Tenant identifier within an environment.");
766id_newtype!(TeamId, "Team identifier belonging to a tenant.");
767id_newtype!(UserId, "User identifier within a tenant.");
768id_newtype!(BranchRef, "Reference to a source control branch.");
769id_newtype!(CommitRef, "Reference to a source control commit.");
770id_newtype!(
771    GitProviderRef,
772    "Identifier referencing a source control provider."
773);
774id_newtype!(ScannerRef, "Identifier referencing a scanner provider.");
775id_newtype!(WebhookId, "Identifier referencing a registered webhook.");
776id_newtype!(
777    ProviderInstallId,
778    "Identifier referencing a provider installation record."
779);
780id_newtype!(PackId, "Globally unique pack identifier.");
781id_newtype!(
782    ComponentId,
783    "Identifier referencing a component binding in a pack."
784);
785id_newtype!(FlowId, "Identifier referencing a flow inside a pack.");
786id_newtype!(NodeId, "Identifier referencing a node inside a flow graph.");
787id_newtype!(
788    EnvironmentRef,
789    "Identifier referencing a deployment environment."
790);
791id_newtype!(
792    DistributorRef,
793    "Identifier referencing a distributor instance."
794);
795id_newtype!(StoreFrontId, "Identifier referencing a storefront.");
796id_newtype!(
797    StoreProductId,
798    "Identifier referencing a product in the store catalog."
799);
800id_newtype!(
801    StorePlanId,
802    "Identifier referencing a plan for a store product."
803);
804id_newtype!(
805    SubscriptionId,
806    "Identifier referencing a subscription entry."
807);
808id_newtype!(BundleId, "Identifier referencing a distributor bundle.");
809id_newtype!(CollectionId, "Identifier referencing a product collection.");
810id_newtype!(RepoRef, "Repository reference within a supply chain.");
811id_newtype!(
812    ComponentRef,
813    "Supply-chain component reference (distinct from pack ComponentId)."
814);
815id_newtype!(
816    VersionRef,
817    "Version reference for a component or metadata record."
818);
819id_newtype!(BuildRef, "Build reference within a supply chain.");
820id_newtype!(ScanRef, "Scan reference within a supply chain.");
821id_newtype!(
822    AttestationRef,
823    "Attestation reference within a supply chain."
824);
825id_newtype!(AttestationId, "Identifier referencing an attestation.");
826id_newtype!(PolicyRef, "Policy reference within a supply chain.");
827id_newtype!(
828    PolicyInputRef,
829    "Reference to a policy input payload for evaluation."
830);
831id_newtype!(StoreRef, "Content store reference within a supply chain.");
832id_newtype!(
833    RegistryRef,
834    "Registry reference for OCI or artifact storage."
835);
836id_newtype!(
837    OciImageRef,
838    "Reference to an OCI image for distribution (oci://repo/name:tag or oci://repo/name@sha256:...)."
839);
840id_newtype!(
841    ArtifactRef,
842    "Artifact reference within a build or scan result."
843);
844id_newtype!(
845    SbomRef,
846    "Reference to a Software Bill of Materials artifact."
847);
848id_newtype!(SigningKeyRef, "Reference to a signing key handle.");
849id_newtype!(SignatureRef, "Reference to a generated signature.");
850id_newtype!(StatementRef, "Reference to an attestation statement.");
851id_newtype!(
852    BuildLogRef,
853    "Reference to a build log output produced during execution."
854);
855id_newtype!(
856    MetadataRecordRef,
857    "Reference to a metadata record attached to artifacts or bundles."
858);
859
860/// API key reference used across secrets providers without exposing key material.
861#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
862#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
863#[cfg_attr(feature = "schemars", derive(JsonSchema))]
864#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
865pub struct ApiKeyRef(pub String);
866
867impl ApiKeyRef {
868    /// Returns the reference as a string slice.
869    pub fn as_str(&self) -> &str {
870        &self.0
871    }
872
873    /// Validates and constructs the reference from the provided value.
874    pub fn new(value: impl AsRef<str>) -> GResult<Self> {
875        value.as_ref().parse()
876    }
877}
878
879impl fmt::Display for ApiKeyRef {
880    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
881        f.write_str(self.as_str())
882    }
883}
884
885impl FromStr for ApiKeyRef {
886    type Err = GreenticError;
887
888    fn from_str(value: &str) -> Result<Self, Self::Err> {
889        validate_api_key_ref(value)?;
890        Ok(Self(value.to_owned()))
891    }
892}
893
894impl TryFrom<String> for ApiKeyRef {
895    type Error = GreenticError;
896
897    fn try_from(value: String) -> Result<Self, Self::Error> {
898        ApiKeyRef::from_str(&value)
899    }
900}
901
902impl TryFrom<&str> for ApiKeyRef {
903    type Error = GreenticError;
904
905    fn try_from(value: &str) -> Result<Self, Self::Error> {
906        ApiKeyRef::from_str(value)
907    }
908}
909
910impl From<ApiKeyRef> for String {
911    fn from(value: ApiKeyRef) -> Self {
912        value.0
913    }
914}
915
916impl AsRef<str> for ApiKeyRef {
917    fn as_ref(&self) -> &str {
918        self.as_str()
919    }
920}
921
922/// Compact tenant summary propagated to developers and tooling.
923#[derive(Clone, Debug, PartialEq, Eq, Hash)]
924#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
925#[cfg_attr(feature = "schemars", derive(JsonSchema))]
926pub struct TenantContext {
927    /// Tenant identifier owning the execution.
928    pub tenant_id: TenantId,
929    /// Optional team identifier scoped to the tenant.
930    #[cfg_attr(
931        feature = "serde",
932        serde(default, skip_serializing_if = "Option::is_none")
933    )]
934    pub team_id: Option<TeamId>,
935    /// Optional user identifier scoped to the tenant.
936    #[cfg_attr(
937        feature = "serde",
938        serde(default, skip_serializing_if = "Option::is_none")
939    )]
940    pub user_id: Option<UserId>,
941    /// Optional session identifier for end-to-end correlation.
942    #[cfg_attr(
943        feature = "serde",
944        serde(default, skip_serializing_if = "Option::is_none")
945    )]
946    pub session_id: Option<String>,
947    /// Optional attributes for routing and tracing.
948    #[cfg_attr(
949        feature = "serde",
950        serde(default, skip_serializing_if = "BTreeMap::is_empty")
951    )]
952    pub attributes: BTreeMap<String, String>,
953}
954
955impl TenantContext {
956    /// Creates a new tenant context scoped to the provided tenant id.
957    pub fn new(tenant_id: TenantId) -> Self {
958        Self {
959            tenant_id,
960            team_id: None,
961            user_id: None,
962            session_id: None,
963            attributes: BTreeMap::new(),
964        }
965    }
966}
967
968impl From<&TenantCtx> for TenantContext {
969    fn from(ctx: &TenantCtx) -> Self {
970        Self {
971            tenant_id: ctx.tenant_id.clone(),
972            team_id: ctx.team_id.clone().or_else(|| ctx.team.clone()),
973            user_id: ctx.user_id.clone().or_else(|| ctx.user.clone()),
974            session_id: ctx.session_id.clone(),
975            attributes: ctx.attributes.clone(),
976        }
977    }
978}
979
980/// Supported hashing algorithms for pack content digests.
981#[derive(Clone, Debug, PartialEq, Eq, Hash)]
982#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
983#[cfg_attr(feature = "schemars", derive(JsonSchema))]
984#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
985pub enum HashAlgorithm {
986    /// Blake3 hashing algorithm.
987    Blake3,
988    /// Catch all for other algorithms.
989    Other(String),
990}
991
992/// Content digest describing a pack or artifact.
993#[derive(Clone, Debug, PartialEq, Eq, Hash)]
994#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
995#[cfg_attr(
996    feature = "serde",
997    serde(into = "HashDigestRepr", try_from = "HashDigestRepr")
998)]
999#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1000pub struct HashDigest {
1001    /// Hash algorithm used to produce the digest.
1002    pub algo: HashAlgorithm,
1003    /// Hex encoded digest bytes.
1004    pub hex: String,
1005}
1006
1007impl HashDigest {
1008    /// Creates a new digest ensuring the hex payload is valid.
1009    pub fn new(algo: HashAlgorithm, hex: impl Into<String>) -> GResult<Self> {
1010        let hex = hex.into();
1011        validate_hex(&hex)?;
1012        Ok(Self { algo, hex })
1013    }
1014
1015    /// Convenience constructor for Blake3 digests.
1016    pub fn blake3(hex: impl Into<String>) -> GResult<Self> {
1017        Self::new(HashAlgorithm::Blake3, hex)
1018    }
1019}
1020
1021#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1022#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1023struct HashDigestRepr {
1024    algo: HashAlgorithm,
1025    hex: String,
1026}
1027
1028impl From<HashDigest> for HashDigestRepr {
1029    fn from(value: HashDigest) -> Self {
1030        Self {
1031            algo: value.algo,
1032            hex: value.hex,
1033        }
1034    }
1035}
1036
1037impl TryFrom<HashDigestRepr> for HashDigest {
1038    type Error = GreenticError;
1039
1040    fn try_from(value: HashDigestRepr) -> Result<Self, Self::Error> {
1041        HashDigest::new(value.algo, value.hex)
1042    }
1043}
1044
1045fn validate_hex(hex: &str) -> GResult<()> {
1046    if hex.is_empty() {
1047        return Err(GreenticError::new(
1048            ErrorCode::InvalidInput,
1049            "digest hex payload must not be empty",
1050        ));
1051    }
1052    if !hex.len().is_multiple_of(2) {
1053        return Err(GreenticError::new(
1054            ErrorCode::InvalidInput,
1055            "digest hex payload must have an even number of digits",
1056        ));
1057    }
1058    if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
1059        return Err(GreenticError::new(
1060            ErrorCode::InvalidInput,
1061            "digest hex payload must be hexadecimal",
1062        ));
1063    }
1064    Ok(())
1065}
1066
1067/// Semantic version requirement validated by [`semver`].
1068#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1069#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1070#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
1071pub struct SemverReq(String);
1072
1073impl SemverReq {
1074    /// Parses and validates a semantic version requirement string.
1075    pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
1076        let value = value.as_ref();
1077        VersionReq::parse(value).map_err(|err| {
1078            GreenticError::new(
1079                ErrorCode::InvalidInput,
1080                format!("invalid semver requirement '{value}': {err}"),
1081            )
1082        })?;
1083        Ok(Self(value.to_owned()))
1084    }
1085
1086    /// Returns the underlying string slice.
1087    pub fn as_str(&self) -> &str {
1088        &self.0
1089    }
1090
1091    /// Converts into a [`semver::VersionReq`].
1092    pub fn to_version_req(&self) -> VersionReq {
1093        VersionReq::parse(&self.0)
1094            .unwrap_or_else(|err| unreachable!("SemverReq::parse validated inputs: {err}"))
1095    }
1096}
1097
1098impl fmt::Display for SemverReq {
1099    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1100        f.write_str(self.as_str())
1101    }
1102}
1103
1104impl From<SemverReq> for String {
1105    fn from(value: SemverReq) -> Self {
1106        value.0
1107    }
1108}
1109
1110impl TryFrom<String> for SemverReq {
1111    type Error = GreenticError;
1112
1113    fn try_from(value: String) -> Result<Self, Self::Error> {
1114        SemverReq::parse(&value)
1115    }
1116}
1117
1118impl TryFrom<&str> for SemverReq {
1119    type Error = GreenticError;
1120
1121    fn try_from(value: &str) -> Result<Self, Self::Error> {
1122        SemverReq::parse(value)
1123    }
1124}
1125
1126impl FromStr for SemverReq {
1127    type Err = GreenticError;
1128
1129    fn from_str(s: &str) -> Result<Self, Self::Err> {
1130        SemverReq::parse(s)
1131    }
1132}
1133
1134/// JSONPath expression pointing at sensitive fields that should be redacted.
1135#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1136#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1137#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
1138pub struct RedactionPath(String);
1139
1140impl RedactionPath {
1141    /// Validates and stores a JSONPath expression.
1142    pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
1143        let value = value.as_ref();
1144        validate_jsonpath(value)?;
1145        Ok(Self(value.to_owned()))
1146    }
1147
1148    /// Returns the JSONPath string.
1149    pub fn as_str(&self) -> &str {
1150        &self.0
1151    }
1152}
1153
1154impl fmt::Display for RedactionPath {
1155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1156        f.write_str(self.as_str())
1157    }
1158}
1159
1160impl From<RedactionPath> for String {
1161    fn from(value: RedactionPath) -> Self {
1162        value.0
1163    }
1164}
1165
1166impl TryFrom<String> for RedactionPath {
1167    type Error = GreenticError;
1168
1169    fn try_from(value: String) -> Result<Self, Self::Error> {
1170        RedactionPath::parse(&value)
1171    }
1172}
1173
1174impl TryFrom<&str> for RedactionPath {
1175    type Error = GreenticError;
1176
1177    fn try_from(value: &str) -> Result<Self, Self::Error> {
1178        RedactionPath::parse(value)
1179    }
1180}
1181
1182fn validate_jsonpath(path: &str) -> GResult<()> {
1183    if path.is_empty() {
1184        return Err(GreenticError::new(
1185            ErrorCode::InvalidInput,
1186            "redaction path cannot be empty",
1187        ));
1188    }
1189    if !path.starts_with('$') {
1190        return Err(GreenticError::new(
1191            ErrorCode::InvalidInput,
1192            "redaction path must start with '$'",
1193        ));
1194    }
1195    if path.chars().any(|c| c.is_control()) {
1196        return Err(GreenticError::new(
1197            ErrorCode::InvalidInput,
1198            "redaction path cannot contain control characters",
1199        ));
1200    }
1201    Ok(())
1202}
1203
1204#[cfg(feature = "schemars")]
1205impl JsonSchema for SemverReq {
1206    fn schema_name() -> Cow<'static, str> {
1207        Cow::Borrowed("SemverReq")
1208    }
1209
1210    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1211        let mut schema = <String>::json_schema(generator);
1212        if schema.get("description").is_none() {
1213            schema.insert(
1214                "description".into(),
1215                "Validated semantic version requirement string".into(),
1216            );
1217        }
1218        schema
1219    }
1220}
1221
1222#[cfg(feature = "schemars")]
1223impl JsonSchema for RedactionPath {
1224    fn schema_name() -> Cow<'static, str> {
1225        Cow::Borrowed("RedactionPath")
1226    }
1227
1228    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1229        let mut schema = <String>::json_schema(generator);
1230        if schema.get("description").is_none() {
1231            schema.insert(
1232                "description".into(),
1233                "JSONPath expression used for runtime redaction".into(),
1234            );
1235        }
1236        schema
1237    }
1238}
1239
1240/// Deadline metadata for an invocation, stored as Unix epoch milliseconds.
1241#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1242#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1243#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1244pub struct InvocationDeadline {
1245    unix_millis: i128,
1246}
1247
1248impl InvocationDeadline {
1249    /// Creates a deadline from a Unix timestamp expressed in milliseconds.
1250    pub const fn from_unix_millis(unix_millis: i128) -> Self {
1251        Self { unix_millis }
1252    }
1253
1254    /// Returns the deadline as Unix epoch milliseconds.
1255    pub const fn unix_millis(&self) -> i128 {
1256        self.unix_millis
1257    }
1258
1259    /// Converts the deadline into an [`OffsetDateTime`].
1260    #[cfg(feature = "time")]
1261    pub fn to_offset_date_time(&self) -> Result<OffsetDateTime, time::error::ComponentRange> {
1262        OffsetDateTime::from_unix_timestamp_nanos(self.unix_millis * 1_000_000)
1263    }
1264
1265    /// Creates a deadline from an [`OffsetDateTime`], truncating to milliseconds.
1266    #[cfg(feature = "time")]
1267    pub fn from_offset_date_time(value: OffsetDateTime) -> Self {
1268        let nanos = value.unix_timestamp_nanos();
1269        Self {
1270            unix_millis: nanos / 1_000_000,
1271        }
1272    }
1273}
1274
1275/// Context that accompanies every invocation across Greentic runtimes.
1276#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1277#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1278#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1279pub struct TenantCtx {
1280    /// Environment scope (for example `dev`, `staging`, or `prod`).
1281    pub env: EnvId,
1282    /// Tenant identifier for the current execution.
1283    pub tenant: TenantId,
1284    /// Stable tenant identifier reference used across systems.
1285    pub tenant_id: TenantId,
1286    /// Optional team identifier scoped to the tenant.
1287    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1288    pub team: Option<TeamId>,
1289    /// Optional team identifier accessible via the shared schema.
1290    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1291    pub team_id: Option<TeamId>,
1292    /// Optional user identifier scoped to the tenant.
1293    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1294    pub user: Option<UserId>,
1295    /// Optional user identifier aligned with the shared schema.
1296    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1297    pub user_id: Option<UserId>,
1298    /// Optional session identifier propagated by the runtime.
1299    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1300    pub session_id: Option<String>,
1301    /// Optional flow identifier for the current execution.
1302    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1303    pub flow_id: Option<String>,
1304    /// Optional node identifier within the flow.
1305    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1306    pub node_id: Option<String>,
1307    /// Optional provider identifier describing the runtime surface.
1308    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1309    pub provider_id: Option<String>,
1310    /// Distributed tracing identifier when available.
1311    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1312    pub trace_id: Option<String>,
1313    /// Optional locale/translation identifier for the session.
1314    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1315    pub i18n_id: Option<String>,
1316    /// Correlation identifier for linking related events.
1317    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1318    pub correlation_id: Option<String>,
1319    /// Free-form attributes for routing and tracing.
1320    #[cfg_attr(
1321        feature = "serde",
1322        serde(default, skip_serializing_if = "BTreeMap::is_empty")
1323    )]
1324    pub attributes: BTreeMap<String, String>,
1325    /// Deadline when the invocation should finish.
1326    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1327    pub deadline: Option<InvocationDeadline>,
1328    /// Attempt counter for retried invocations (starting at zero).
1329    pub attempt: u32,
1330    /// Stable idempotency key propagated across retries.
1331    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1332    pub idempotency_key: Option<String>,
1333    /// Optional impersonation context describing the acting identity.
1334    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1335    pub impersonation: Option<Impersonation>,
1336}
1337
1338impl TenantCtx {
1339    /// Creates a new tenant context with the provided environment and tenant identifiers.
1340    pub fn new(env: EnvId, tenant: TenantId) -> Self {
1341        let tenant_id = tenant.clone();
1342        Self {
1343            env,
1344            tenant: tenant.clone(),
1345            tenant_id,
1346            team: None,
1347            team_id: None,
1348            user: None,
1349            user_id: None,
1350            session_id: None,
1351            flow_id: None,
1352            node_id: None,
1353            provider_id: None,
1354            trace_id: None,
1355            i18n_id: None,
1356            correlation_id: None,
1357            attributes: BTreeMap::new(),
1358            deadline: None,
1359            attempt: 0,
1360            idempotency_key: None,
1361            impersonation: None,
1362        }
1363    }
1364
1365    /// Updates the team information ensuring legacy and shared fields stay aligned.
1366    pub fn with_team(mut self, team: Option<TeamId>) -> Self {
1367        self.team = team.clone();
1368        self.team_id = team;
1369        self
1370    }
1371
1372    /// Updates the user information ensuring legacy and shared fields stay aligned.
1373    pub fn with_user(mut self, user: Option<UserId>) -> Self {
1374        self.user = user.clone();
1375        self.user_id = user;
1376        self
1377    }
1378
1379    /// Updates the session identifier.
1380    pub fn with_session(mut self, session: impl Into<String>) -> Self {
1381        self.session_id = Some(session.into());
1382        self
1383    }
1384
1385    /// Updates the flow identifier.
1386    pub fn with_flow(mut self, flow: impl Into<String>) -> Self {
1387        self.flow_id = Some(flow.into());
1388        self
1389    }
1390
1391    /// Updates the node identifier.
1392    pub fn with_node(mut self, node: impl Into<String>) -> Self {
1393        self.node_id = Some(node.into());
1394        self
1395    }
1396
1397    /// Updates the provider identifier.
1398    pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1399        self.provider_id = Some(provider.into());
1400        self
1401    }
1402
1403    /// Attaches or replaces the attributes map.
1404    pub fn with_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
1405        self.attributes = attributes;
1406        self
1407    }
1408
1409    /// Sets the impersonation context.
1410    pub fn with_impersonation(mut self, impersonation: Option<Impersonation>) -> Self {
1411        self.impersonation = impersonation;
1412        self
1413    }
1414
1415    /// Returns a copy of the context with the provided attempt value.
1416    pub fn with_attempt(mut self, attempt: u32) -> Self {
1417        self.attempt = attempt;
1418        self
1419    }
1420
1421    /// Updates the deadline metadata for subsequent invocations.
1422    pub fn with_deadline(mut self, deadline: Option<InvocationDeadline>) -> Self {
1423        self.deadline = deadline;
1424        self
1425    }
1426
1427    /// Returns the session identifier, when present.
1428    pub fn session_id(&self) -> Option<&str> {
1429        self.session_id.as_deref()
1430    }
1431
1432    /// Returns the flow identifier, when present.
1433    pub fn flow_id(&self) -> Option<&str> {
1434        self.flow_id.as_deref()
1435    }
1436
1437    /// Returns the node identifier, when present.
1438    pub fn node_id(&self) -> Option<&str> {
1439        self.node_id.as_deref()
1440    }
1441
1442    /// Returns the provider identifier, when present.
1443    pub fn provider_id(&self) -> Option<&str> {
1444        self.provider_id.as_deref()
1445    }
1446}
1447
1448/// Primary payload representation shared across envelopes.
1449pub type BinaryPayload = Vec<u8>;
1450
1451/// Normalized ingress payload delivered to nodes.
1452#[derive(Clone, Debug, PartialEq, Eq)]
1453#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1454#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1455pub struct InvocationEnvelope {
1456    /// Tenant context for the invocation.
1457    pub ctx: TenantCtx,
1458    /// Flow identifier the event belongs to.
1459    pub flow_id: String,
1460    /// Optional node identifier within the flow.
1461    pub node_id: Option<String>,
1462    /// Operation being invoked (for example `on_message` or `tick`).
1463    pub op: String,
1464    /// Normalized payload for the invocation.
1465    pub payload: BinaryPayload,
1466    /// Raw metadata propagated from the ingress surface.
1467    pub metadata: BinaryPayload,
1468}
1469
1470/// Structured detail payload attached to a node error.
1471#[derive(Clone, Debug, PartialEq, Eq)]
1472#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1473#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1474pub enum ErrorDetail {
1475    /// UTF-8 encoded detail payload.
1476    Text(String),
1477    /// Binary payload detail (for example message pack or CBOR).
1478    Binary(BinaryPayload),
1479}
1480
1481/// Error type emitted by Greentic nodes.
1482#[derive(Debug)]
1483#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1484#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1485pub struct NodeError {
1486    /// Machine readable error code.
1487    pub code: String,
1488    /// Human readable message explaining the failure.
1489    pub message: String,
1490    /// Whether the failure is retryable by the runtime.
1491    pub retryable: bool,
1492    /// Optional backoff duration in milliseconds for the next retry.
1493    pub backoff_ms: Option<u64>,
1494    /// Optional structured error detail payload.
1495    pub details: Option<ErrorDetail>,
1496    #[cfg(feature = "std")]
1497    #[cfg_attr(feature = "serde", serde(skip, default = "default_source"))]
1498    #[cfg_attr(feature = "schemars", schemars(skip))]
1499    source: Option<Box<dyn StdError + Send + Sync>>,
1500}
1501
1502#[cfg(all(feature = "std", feature = "serde"))]
1503fn default_source() -> Option<Box<dyn StdError + Send + Sync>> {
1504    None
1505}
1506
1507impl NodeError {
1508    /// Constructs a non-retryable failure with the supplied code and message.
1509    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
1510        Self {
1511            code: code.into(),
1512            message: message.into(),
1513            retryable: false,
1514            backoff_ms: None,
1515            details: None,
1516            #[cfg(feature = "std")]
1517            source: None,
1518        }
1519    }
1520
1521    /// Marks the error as retryable with an optional backoff value.
1522    pub fn with_retry(mut self, backoff_ms: Option<u64>) -> Self {
1523        self.retryable = true;
1524        self.backoff_ms = backoff_ms;
1525        self
1526    }
1527
1528    /// Attaches structured details to the error.
1529    pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
1530        self.details = Some(detail);
1531        self
1532    }
1533
1534    /// Attaches a textual detail payload to the error.
1535    pub fn with_detail_text(mut self, detail: impl Into<String>) -> Self {
1536        self.details = Some(ErrorDetail::Text(detail.into()));
1537        self
1538    }
1539
1540    /// Attaches a binary detail payload to the error.
1541    pub fn with_detail_binary(mut self, detail: BinaryPayload) -> Self {
1542        self.details = Some(ErrorDetail::Binary(detail));
1543        self
1544    }
1545
1546    #[cfg(feature = "std")]
1547    /// Attaches a source error to the failure for debugging purposes.
1548    pub fn with_source<E>(mut self, source: E) -> Self
1549    where
1550        E: StdError + Send + Sync + 'static,
1551    {
1552        self.source = Some(Box::new(source));
1553        self
1554    }
1555
1556    /// Returns the structured details, when available.
1557    pub fn detail(&self) -> Option<&ErrorDetail> {
1558        self.details.as_ref()
1559    }
1560
1561    #[cfg(feature = "std")]
1562    /// Returns the attached source error when one has been provided.
1563    pub fn source(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
1564        self.source.as_deref()
1565    }
1566}
1567
1568impl fmt::Display for NodeError {
1569    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1570        write!(f, "{}: {}", self.code, self.message)
1571    }
1572}
1573
1574#[cfg(feature = "std")]
1575impl StdError for NodeError {
1576    fn source(&self) -> Option<&(dyn StdError + 'static)> {
1577        self.source
1578            .as_ref()
1579            .map(|err| err.as_ref() as &(dyn StdError + 'static))
1580    }
1581}
1582
1583/// Alias for results returned by node handlers.
1584pub type NodeResult<T> = Result<T, NodeError>;
1585
1586/// Generates a stable idempotency key for a node invocation.
1587///
1588/// The key uses tenant, flow, node, and correlation identifiers. Missing
1589/// correlation values fall back to the value stored on the context.
1590pub fn make_idempotency_key(
1591    ctx: &TenantCtx,
1592    flow_id: &str,
1593    node_id: Option<&str>,
1594    correlation: Option<&str>,
1595) -> String {
1596    let node_segment = node_id.unwrap_or_default();
1597    let correlation_segment = correlation
1598        .or(ctx.correlation_id.as_deref())
1599        .unwrap_or_default();
1600    let input = format!(
1601        "{}|{}|{}|{}",
1602        ctx.tenant_id.as_str(),
1603        flow_id,
1604        node_segment,
1605        correlation_segment
1606    );
1607    fnv1a_128_hex(input.as_bytes())
1608}
1609
1610const FNV_OFFSET_BASIS: u128 = 0x6c62272e07bb014262b821756295c58d;
1611const FNV_PRIME: u128 = 0x0000000001000000000000000000013b;
1612
1613fn fnv1a_128_hex(bytes: &[u8]) -> String {
1614    let mut hash = FNV_OFFSET_BASIS;
1615    for &byte in bytes {
1616        hash ^= byte as u128;
1617        hash = hash.wrapping_mul(FNV_PRIME);
1618    }
1619
1620    let mut output = String::with_capacity(32);
1621    for shift in (0..32).rev() {
1622        let nibble = ((hash >> (shift * 4)) & 0x0f) as u8;
1623        output.push(match nibble {
1624            0..=9 => (b'0' + nibble) as char,
1625            _ => (b'a' + (nibble - 10)) as char,
1626        });
1627    }
1628    output
1629}
1630
1631#[cfg(test)]
1632mod tests {
1633    use super::*;
1634    use core::convert::TryFrom;
1635    use time::OffsetDateTime;
1636
1637    fn sample_ctx() -> TenantCtx {
1638        let env = EnvId::try_from("prod").unwrap_or_else(|err| panic!("{err}"));
1639        let tenant = TenantId::try_from("tenant-123").unwrap_or_else(|err| panic!("{err}"));
1640        let team = TeamId::try_from("team-456").unwrap_or_else(|err| panic!("{err}"));
1641        let user = UserId::try_from("user-789").unwrap_or_else(|err| panic!("{err}"));
1642
1643        let mut ctx = TenantCtx::new(env, tenant)
1644            .with_team(Some(team))
1645            .with_user(Some(user))
1646            .with_attempt(2)
1647            .with_deadline(Some(InvocationDeadline::from_unix_millis(
1648                1_700_000_000_000,
1649            )));
1650        ctx.trace_id = Some("trace-abc".to_owned());
1651        ctx.correlation_id = Some("corr-xyz".to_owned());
1652        ctx.idempotency_key = Some("key-123".to_owned());
1653        ctx
1654    }
1655
1656    #[test]
1657    fn idempotent_key_stable() {
1658        let ctx = sample_ctx();
1659        let key_a = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1660        let key_b = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1661        assert_eq!(key_a, key_b);
1662        assert_eq!(key_a.len(), 32);
1663    }
1664
1665    #[test]
1666    fn idempotent_key_uses_context_correlation() {
1667        let ctx = sample_ctx();
1668        let key = make_idempotency_key(&ctx, "flow-1", None, None);
1669        let expected = make_idempotency_key(&ctx, "flow-1", None, ctx.correlation_id.as_deref());
1670        assert_eq!(key, expected);
1671    }
1672
1673    #[test]
1674    #[cfg(feature = "time")]
1675    fn deadline_roundtrips_through_offset_datetime() {
1676        let dt = OffsetDateTime::from_unix_timestamp(1_700_000_000)
1677            .unwrap_or_else(|err| panic!("valid timestamp: {err}"));
1678        let deadline = InvocationDeadline::from_offset_date_time(dt);
1679        let roundtrip = deadline
1680            .to_offset_date_time()
1681            .unwrap_or_else(|err| panic!("round-trip conversion failed: {err}"));
1682        let millis = dt.unix_timestamp_nanos() / 1_000_000;
1683        assert_eq!(deadline.unix_millis(), millis);
1684        assert_eq!(roundtrip.unix_timestamp_nanos() / 1_000_000, millis);
1685    }
1686
1687    #[test]
1688    fn node_error_builder_sets_fields() {
1689        let err = NodeError::new("TEST", "example")
1690            .with_retry(Some(500))
1691            .with_detail_text("context");
1692
1693        assert!(err.retryable);
1694        assert_eq!(err.backoff_ms, Some(500));
1695        match err.detail() {
1696            Some(ErrorDetail::Text(detail)) => assert_eq!(detail, "context"),
1697            other => panic!("unexpected detail {other:?}"),
1698        }
1699    }
1700
1701    #[cfg(feature = "std")]
1702    #[test]
1703    fn node_error_source_roundtrips() {
1704        use std::io::Error;
1705
1706        let source = Error::other("boom");
1707        let err = NodeError::new("TEST", "example").with_source(source);
1708        assert!(err.source().is_some());
1709    }
1710}