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