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