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
6extern crate alloc;
55
56pub const VERSION: &str = env!("CARGO_PKG_VERSION");
58pub const SCHEMA_BASE_URL: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1";
60
61pub mod bindings;
62pub mod capabilities;
63#[cfg(feature = "std")]
64pub mod cbor;
65pub mod component;
66pub mod deployment;
67pub mod distributor;
68pub mod events;
69pub mod events_provider;
70pub mod flow;
71pub mod messaging;
72pub mod pack_manifest;
73pub mod provider;
74pub mod store;
75pub mod supply_chain;
76pub mod worker;
77
78pub mod context;
79pub mod error;
80pub mod outcome;
81pub mod pack;
82pub mod policy;
83pub mod run;
84#[cfg(all(feature = "schemars", feature = "std"))]
85pub mod schema;
86pub mod secrets;
87pub mod session;
88pub mod state;
89pub mod telemetry;
90pub mod tenant;
91pub mod tenant_config;
92
93pub use bindings::hints::{
94 BindingsHints, EnvHints, McpHints, McpServer, NetworkHints, SecretsHints,
95};
96pub use capabilities::{
97 Capabilities, FsCaps, HttpCaps, KvCaps, Limits, NetCaps, SecretsCaps, TelemetrySpec, ToolsCaps,
98};
99#[cfg(feature = "std")]
100pub use cbor::{CborError, decode_pack_manifest, encode_pack_manifest};
101pub use component::{
102 ComponentCapabilities, ComponentConfigurators, ComponentDevFlow, ComponentManifest,
103 ComponentOperation, ComponentProfileError, ComponentProfiles, EnvCapabilities,
104 EventsCapabilities, FilesystemCapabilities, FilesystemMode, FilesystemMount, HostCapabilities,
105 HttpCapabilities, IaCCapabilities, MessagingCapabilities, ResourceHints, SecretsCapabilities,
106 StateCapabilities, TelemetryCapabilities, TelemetryScope, WasiCapabilities,
107};
108pub use context::{Cloud, DeploymentCtx, Platform};
109pub use deployment::{
110 ChannelPlan, DeploymentPlan, MessagingPlan, MessagingSubjectPlan, OAuthPlan, RunnerPlan,
111 TelemetryPlan,
112};
113pub use distributor::{
114 ArtifactLocation, CacheInfo, ComponentDigest, ComponentStatus, DistributorEnvironmentId,
115 PackStatusResponseV2, ResolveComponentRequest, ResolveComponentResponse, SignatureSummary,
116};
117pub use error::{ErrorCode, GResult, GreenticError};
118pub use events::{EventEnvelope, EventId, EventMetadata};
119pub use events_provider::{
120 EventProviderDescriptor, EventProviderKind, OrderingKind, ReliabilityKind, TransportKind,
121};
122pub use flow::{
123 ComponentRef as FlowComponentRef, Flow, FlowKind, FlowMetadata, InputMapping, Node,
124 OutputMapping, Routing, TelemetryHints,
125};
126pub use messaging::{Attachment, ChannelMessageEnvelope, MessageMetadata};
127pub use outcome::Outcome;
128pub use pack::{PackRef, Signature, SignatureAlgorithm};
129pub use pack_manifest::{
130 BootstrapSpec, ComponentCapability, ExtensionRef, PackDependency, PackFlowEntry, PackKind,
131 PackManifest, PackSignatures,
132};
133pub use policy::{AllowList, NetworkPolicy, PolicyDecision, PolicyDecisionStatus, Protocol};
134pub use provider::{ProviderDecl, ProviderExtensionInline, ProviderManifest, ProviderRuntimeRef};
135#[cfg(feature = "time")]
136pub use run::RunResult;
137pub use run::{NodeFailure, NodeStatus, NodeSummary, RunStatus, TranscriptOffset};
138pub use secrets::{SecretFormat, SecretKey, SecretRequirement, SecretScope};
139pub use session::canonical_session_key;
140pub use session::{SessionCursor, SessionData, SessionKey};
141pub use state::{StateKey, StatePath};
142pub use store::{
143 ArtifactSelector, BundleSpec, CapabilityMap, Collection, ConnectionKind, DesiredState,
144 DesiredStateExportSpec, DesiredSubscriptionEntry, Environment, LayoutSection,
145 LayoutSectionKind, PackOrComponentRef, PlanLimits, PriceModel, ProductOverride, RolloutState,
146 RolloutStatus, StoreFront, StorePlan, StoreProduct, StoreProductKind, Subscription,
147 SubscriptionStatus, Theme, VersionStrategy,
148};
149pub use supply_chain::{
150 AttestationStatement, BuildPlan, BuildStatus, BuildStatusKind, MetadataRecord, PredicateType,
151 RepoContext, ScanKind, ScanRequest, ScanResult, ScanStatusKind, SignRequest, StoreContext,
152 VerifyRequest, VerifyResult,
153};
154#[cfg(feature = "otel-keys")]
155pub use telemetry::OtlpKeys;
156pub use telemetry::SpanContext;
157#[cfg(feature = "telemetry-autoinit")]
158pub use telemetry::TelemetryCtx;
159pub use tenant::{Impersonation, TenantIdentity};
160pub use tenant_config::{
161 DefaultPipeline, DidContext, DidService, DistributorTarget, EnabledPacks,
162 IdentityProviderOption, RepoAuth, RepoConfigFeatures, RepoSkin, RepoSkinLayout, RepoSkinLinks,
163 RepoSkinTheme, RepoTenantConfig, RepoWorkerPanel, StoreTarget, TenantDidDocument,
164 VerificationMethod,
165};
166pub use worker::{WorkerMessage, WorkerRequest, WorkerResponse};
167
168#[cfg(feature = "schemars")]
169use alloc::borrow::Cow;
170use alloc::{borrow::ToOwned, collections::BTreeMap, format, string::String, vec::Vec};
171use core::fmt;
172use core::str::FromStr;
173#[cfg(feature = "schemars")]
174use schemars::JsonSchema;
175use semver::VersionReq;
176#[cfg(feature = "time")]
177use time::OffsetDateTime;
178
179#[cfg(feature = "serde")]
180use serde::{Deserialize, Serialize};
181
182#[cfg(feature = "std")]
183use alloc::boxed::Box;
184
185#[cfg(feature = "std")]
186use std::error::Error as StdError;
187
188pub(crate) fn validate_identifier(value: &str, label: &str) -> GResult<()> {
190 if value.is_empty() {
191 return Err(GreenticError::new(
192 ErrorCode::InvalidInput,
193 format!("{label} must not be empty"),
194 ));
195 }
196 if value
197 .chars()
198 .any(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')))
199 {
200 return Err(GreenticError::new(
201 ErrorCode::InvalidInput,
202 format!("{label} must contain only ASCII letters, digits, '.', '-', or '_'"),
203 ));
204 }
205 Ok(())
206}
207
208pub(crate) fn validate_api_key_ref(value: &str) -> GResult<()> {
210 if value.trim().is_empty() {
211 return Err(GreenticError::new(
212 ErrorCode::InvalidInput,
213 "ApiKeyRef must not be empty",
214 ));
215 }
216 if value.chars().any(char::is_whitespace) {
217 return Err(GreenticError::new(
218 ErrorCode::InvalidInput,
219 "ApiKeyRef must not contain whitespace",
220 ));
221 }
222 if !value.is_ascii() {
223 return Err(GreenticError::new(
224 ErrorCode::InvalidInput,
225 "ApiKeyRef must contain only ASCII characters",
226 ));
227 }
228 Ok(())
229}
230
231pub mod ids {
233 pub const PACK_ID: &str =
235 "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-id.schema.json";
236 pub const COMPONENT_ID: &str =
238 "https://greentic-ai.github.io/greentic-types/schemas/v1/component-id.schema.json";
239 pub const FLOW_ID: &str =
241 "https://greentic-ai.github.io/greentic-types/schemas/v1/flow-id.schema.json";
242 pub const NODE_ID: &str =
244 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-id.schema.json";
245 pub const TENANT_CONTEXT: &str =
247 "https://greentic-ai.github.io/greentic-types/schemas/v1/tenant-context.schema.json";
248 pub const HASH_DIGEST: &str =
250 "https://greentic-ai.github.io/greentic-types/schemas/v1/hash-digest.schema.json";
251 pub const SEMVER_REQ: &str =
253 "https://greentic-ai.github.io/greentic-types/schemas/v1/semver-req.schema.json";
254 pub const REDACTION_PATH: &str =
256 "https://greentic-ai.github.io/greentic-types/schemas/v1/redaction-path.schema.json";
257 pub const CAPABILITIES: &str =
259 "https://greentic-ai.github.io/greentic-types/schemas/v1/capabilities.schema.json";
260 pub const REPO_SKIN: &str =
262 "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-skin.schema.json";
263 pub const REPO_AUTH: &str =
265 "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-auth.schema.json";
266 pub const REPO_TENANT_CONFIG: &str =
268 "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-tenant-config.schema.json";
269 pub const TENANT_DID_DOCUMENT: &str =
271 "https://greentic-ai.github.io/greentic-types/schemas/v1/tenant-did-document.schema.json";
272 pub const FLOW: &str = "greentic.flow.v1";
274 pub const NODE: &str =
276 "https://greentic-ai.github.io/greentic-types/schemas/v1/node.schema.json";
277 pub const COMPONENT_MANIFEST: &str =
279 "https://greentic-ai.github.io/greentic-types/schemas/v1/component-manifest.schema.json";
280 pub const PACK_MANIFEST: &str = "greentic.pack-manifest.v1";
282 pub const LIMITS: &str =
284 "https://greentic-ai.github.io/greentic-types/schemas/v1/limits.schema.json";
285 pub const TELEMETRY_SPEC: &str =
287 "https://greentic-ai.github.io/greentic-types/schemas/v1/telemetry-spec.schema.json";
288 pub const NODE_SUMMARY: &str =
290 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-summary.schema.json";
291 pub const NODE_FAILURE: &str =
293 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-failure.schema.json";
294 pub const NODE_STATUS: &str =
296 "https://greentic-ai.github.io/greentic-types/schemas/v1/node-status.schema.json";
297 pub const RUN_STATUS: &str =
299 "https://greentic-ai.github.io/greentic-types/schemas/v1/run-status.schema.json";
300 pub const TRANSCRIPT_OFFSET: &str =
302 "https://greentic-ai.github.io/greentic-types/schemas/v1/transcript-offset.schema.json";
303 pub const TOOLS_CAPS: &str =
305 "https://greentic-ai.github.io/greentic-types/schemas/v1/tools-caps.schema.json";
306 pub const SECRETS_CAPS: &str =
308 "https://greentic-ai.github.io/greentic-types/schemas/v1/secrets-caps.schema.json";
309 pub const BRANCH_REF: &str =
311 "https://greentic-ai.github.io/greentic-types/schemas/v1/branch-ref.schema.json";
312 pub const COMMIT_REF: &str =
314 "https://greentic-ai.github.io/greentic-types/schemas/v1/commit-ref.schema.json";
315 pub const GIT_PROVIDER_REF: &str =
317 "https://greentic-ai.github.io/greentic-types/schemas/v1/git-provider-ref.schema.json";
318 pub const SCANNER_REF: &str =
320 "https://greentic-ai.github.io/greentic-types/schemas/v1/scanner-ref.schema.json";
321 pub const WEBHOOK_ID: &str =
323 "https://greentic-ai.github.io/greentic-types/schemas/v1/webhook-id.schema.json";
324 pub const REPO_REF: &str =
326 "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-ref.schema.json";
327 pub const COMPONENT_REF: &str =
329 "https://greentic-ai.github.io/greentic-types/schemas/v1/component-ref.schema.json";
330 pub const VERSION_REF: &str =
332 "https://greentic-ai.github.io/greentic-types/schemas/v1/version-ref.schema.json";
333 pub const BUILD_REF: &str =
335 "https://greentic-ai.github.io/greentic-types/schemas/v1/build-ref.schema.json";
336 pub const SCAN_REF: &str =
338 "https://greentic-ai.github.io/greentic-types/schemas/v1/scan-ref.schema.json";
339 pub const ATTESTATION_REF: &str =
341 "https://greentic-ai.github.io/greentic-types/schemas/v1/attestation-ref.schema.json";
342 pub const ATTESTATION_ID: &str =
344 "https://greentic-ai.github.io/greentic-types/schemas/v1/attestation-id.schema.json";
345 pub const POLICY_REF: &str =
347 "https://greentic-ai.github.io/greentic-types/schemas/v1/policy-ref.schema.json";
348 pub const POLICY_INPUT_REF: &str =
350 "https://greentic-ai.github.io/greentic-types/schemas/v1/policy-input-ref.schema.json";
351 pub const STORE_REF: &str =
353 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-ref.schema.json";
354 pub const REGISTRY_REF: &str =
356 "https://greentic-ai.github.io/greentic-types/schemas/v1/registry-ref.schema.json";
357 pub const OCI_IMAGE_REF: &str =
359 "https://greentic-ai.github.io/greentic-types/schemas/v1/oci-image-ref.schema.json";
360 pub const ARTIFACT_REF: &str =
362 "https://greentic-ai.github.io/greentic-types/schemas/v1/artifact-ref.schema.json";
363 pub const SBOM_REF: &str =
365 "https://greentic-ai.github.io/greentic-types/schemas/v1/sbom-ref.schema.json";
366 pub const SIGNING_KEY_REF: &str =
368 "https://greentic-ai.github.io/greentic-types/schemas/v1/signing-key-ref.schema.json";
369 pub const SIGNATURE_REF: &str =
371 "https://greentic-ai.github.io/greentic-types/schemas/v1/signature-ref.schema.json";
372 pub const STATEMENT_REF: &str =
374 "https://greentic-ai.github.io/greentic-types/schemas/v1/statement-ref.schema.json";
375 pub const BUILD_LOG_REF: &str =
377 "https://greentic-ai.github.io/greentic-types/schemas/v1/build-log-ref.schema.json";
378 pub const METADATA_RECORD_REF: &str =
380 "https://greentic-ai.github.io/greentic-types/schemas/v1/metadata-record-ref.schema.json";
381 pub const API_KEY_REF: &str =
383 "https://greentic-ai.github.io/greentic-types/schemas/v1/api-key-ref.schema.json";
384 pub const ENVIRONMENT_REF: &str =
386 "https://greentic-ai.github.io/greentic-types/schemas/v1/environment-ref.schema.json";
387 pub const DISTRIBUTOR_REF: &str =
389 "https://greentic-ai.github.io/greentic-types/schemas/v1/distributor-ref.schema.json";
390 pub const STOREFRONT_ID: &str =
392 "https://greentic-ai.github.io/greentic-types/schemas/v1/storefront-id.schema.json";
393 pub const STORE_PRODUCT_ID: &str =
395 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-product-id.schema.json";
396 pub const STORE_PLAN_ID: &str =
398 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-plan-id.schema.json";
399 pub const SUBSCRIPTION_ID: &str =
401 "https://greentic-ai.github.io/greentic-types/schemas/v1/subscription-id.schema.json";
402 pub const BUNDLE_ID: &str =
404 "https://greentic-ai.github.io/greentic-types/schemas/v1/bundle-id.schema.json";
405 pub const COLLECTION_ID: &str =
407 "https://greentic-ai.github.io/greentic-types/schemas/v1/collection-id.schema.json";
408 pub const ARTIFACT_SELECTOR: &str =
410 "https://greentic-ai.github.io/greentic-types/schemas/v1/artifact-selector.schema.json";
411 pub const CAPABILITY_MAP: &str =
413 "https://greentic-ai.github.io/greentic-types/schemas/v1/capability-map.schema.json";
414 pub const STORE_PRODUCT_KIND: &str =
416 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-product-kind.schema.json";
417 pub const VERSION_STRATEGY: &str =
419 "https://greentic-ai.github.io/greentic-types/schemas/v1/version-strategy.schema.json";
420 pub const ROLLOUT_STATUS: &str =
422 "https://greentic-ai.github.io/greentic-types/schemas/v1/rollout-status.schema.json";
423 pub const CONNECTION_KIND: &str =
425 "https://greentic-ai.github.io/greentic-types/schemas/v1/connection-kind.schema.json";
426 pub const PACK_OR_COMPONENT_REF: &str =
428 "https://greentic-ai.github.io/greentic-types/schemas/v1/pack-or-component-ref.schema.json";
429 pub const PLAN_LIMITS: &str =
431 "https://greentic-ai.github.io/greentic-types/schemas/v1/plan-limits.schema.json";
432 pub const PRICE_MODEL: &str =
434 "https://greentic-ai.github.io/greentic-types/schemas/v1/price-model.schema.json";
435 pub const SUBSCRIPTION_STATUS: &str =
437 "https://greentic-ai.github.io/greentic-types/schemas/v1/subscription-status.schema.json";
438 pub const BUILD_PLAN: &str =
440 "https://greentic-ai.github.io/greentic-types/schemas/v1/build-plan.schema.json";
441 pub const BUILD_STATUS: &str =
443 "https://greentic-ai.github.io/greentic-types/schemas/v1/build-status.schema.json";
444 pub const SCAN_REQUEST: &str =
446 "https://greentic-ai.github.io/greentic-types/schemas/v1/scan-request.schema.json";
447 pub const SCAN_RESULT: &str =
449 "https://greentic-ai.github.io/greentic-types/schemas/v1/scan-result.schema.json";
450 pub const SIGN_REQUEST: &str =
452 "https://greentic-ai.github.io/greentic-types/schemas/v1/sign-request.schema.json";
453 pub const VERIFY_REQUEST: &str =
455 "https://greentic-ai.github.io/greentic-types/schemas/v1/verify-request.schema.json";
456 pub const VERIFY_RESULT: &str =
458 "https://greentic-ai.github.io/greentic-types/schemas/v1/verify-result.schema.json";
459 pub const ATTESTATION_STATEMENT: &str =
461 "https://greentic-ai.github.io/greentic-types/schemas/v1/attestation-statement.schema.json";
462 pub const METADATA_RECORD: &str =
464 "https://greentic-ai.github.io/greentic-types/schemas/v1/metadata-record.schema.json";
465 pub const REPO_CONTEXT: &str =
467 "https://greentic-ai.github.io/greentic-types/schemas/v1/repo-context.schema.json";
468 pub const STORE_CONTEXT: &str =
470 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-context.schema.json";
471 pub const BUNDLE: &str =
473 "https://greentic-ai.github.io/greentic-types/schemas/v1/bundle.schema.json";
474 pub const DESIRED_STATE_EXPORT: &str =
476 "https://greentic-ai.github.io/greentic-types/schemas/v1/desired-state-export.schema.json";
477 pub const DESIRED_STATE: &str =
479 "https://greentic-ai.github.io/greentic-types/schemas/v1/desired-state.schema.json";
480 pub const DESIRED_SUBSCRIPTION_ENTRY: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/desired-subscription-entry.schema.json";
482 pub const STOREFRONT: &str =
484 "https://greentic-ai.github.io/greentic-types/schemas/v1/storefront.schema.json";
485 pub const STORE_PRODUCT: &str =
487 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-product.schema.json";
488 pub const STORE_PLAN: &str =
490 "https://greentic-ai.github.io/greentic-types/schemas/v1/store-plan.schema.json";
491 pub const SUBSCRIPTION: &str =
493 "https://greentic-ai.github.io/greentic-types/schemas/v1/subscription.schema.json";
494 pub const ENVIRONMENT: &str =
496 "https://greentic-ai.github.io/greentic-types/schemas/v1/environment.schema.json";
497 pub const THEME: &str =
499 "https://greentic-ai.github.io/greentic-types/schemas/v1/theme.schema.json";
500 pub const LAYOUT_SECTION: &str =
502 "https://greentic-ai.github.io/greentic-types/schemas/v1/layout-section.schema.json";
503 pub const COLLECTION: &str =
505 "https://greentic-ai.github.io/greentic-types/schemas/v1/collection.schema.json";
506 pub const PRODUCT_OVERRIDE: &str =
508 "https://greentic-ai.github.io/greentic-types/schemas/v1/product-override.schema.json";
509 pub const EVENT_ENVELOPE: &str =
511 "https://greentic-ai.github.io/greentic-types/schemas/v1/event-envelope.schema.json";
512 pub const EVENT_PROVIDER_DESCRIPTOR: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/event-provider-descriptor.schema.json";
514 pub const CHANNEL_MESSAGE_ENVELOPE: &str = "https://greentic-ai.github.io/greentic-types/schemas/v1/channel-message-envelope.schema.json";
516 pub const ATTACHMENT: &str =
518 "https://greentic-ai.github.io/greentic-types/schemas/v1/attachment.schema.json";
519 pub const WORKER_REQUEST: &str =
521 "https://greentic-ai.github.io/greentic-types/schemas/v1/worker-request.schema.json";
522 pub const WORKER_MESSAGE: &str =
524 "https://greentic-ai.github.io/greentic-types/schemas/v1/worker-message.schema.json";
525 pub const WORKER_RESPONSE: &str =
527 "https://greentic-ai.github.io/greentic-types/schemas/v1/worker-response.schema.json";
528 pub const OTLP_KEYS: &str =
530 "https://greentic-ai.github.io/greentic-types/schemas/v1/otlp-keys.schema.json";
531 pub const RUN_RESULT: &str =
533 "https://greentic-ai.github.io/greentic-types/schemas/v1/run-result.schema.json";
534}
535
536#[cfg(all(feature = "schema", feature = "std"))]
537pub fn write_all_schemas(out_dir: &std::path::Path) -> anyhow::Result<()> {
539 use anyhow::Context;
540 use std::fs;
541
542 fs::create_dir_all(out_dir)
543 .with_context(|| format!("failed to create {}", out_dir.display()))?;
544
545 for entry in crate::schema::entries() {
546 let schema = (entry.generator)();
547 let path = out_dir.join(entry.file_name);
548 if let Some(parent) = path.parent() {
549 fs::create_dir_all(parent)
550 .with_context(|| format!("failed to create {}", parent.display()))?;
551 }
552
553 let json =
554 serde_json::to_vec_pretty(&schema).context("failed to serialize schema to JSON")?;
555 fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
556 }
557
558 Ok(())
559}
560
561macro_rules! id_newtype {
562 ($name:ident, $doc:literal) => {
563 #[doc = $doc]
564 #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
565 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
566 #[cfg_attr(feature = "schemars", derive(JsonSchema))]
567 #[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
568 pub struct $name(pub String);
569
570 impl $name {
571 pub fn as_str(&self) -> &str {
573 &self.0
574 }
575
576 pub fn new(value: impl AsRef<str>) -> GResult<Self> {
578 value.as_ref().parse()
579 }
580 }
581
582 impl From<$name> for String {
583 fn from(value: $name) -> Self {
584 value.0
585 }
586 }
587
588 impl AsRef<str> for $name {
589 fn as_ref(&self) -> &str {
590 self.as_str()
591 }
592 }
593
594 impl fmt::Display for $name {
595 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596 f.write_str(self.as_str())
597 }
598 }
599
600 impl FromStr for $name {
601 type Err = GreenticError;
602
603 fn from_str(value: &str) -> Result<Self, Self::Err> {
604 validate_identifier(value, stringify!($name))?;
605 Ok(Self(value.to_owned()))
606 }
607 }
608
609 impl TryFrom<String> for $name {
610 type Error = GreenticError;
611
612 fn try_from(value: String) -> Result<Self, Self::Error> {
613 $name::from_str(&value)
614 }
615 }
616
617 impl TryFrom<&str> for $name {
618 type Error = GreenticError;
619
620 fn try_from(value: &str) -> Result<Self, Self::Error> {
621 $name::from_str(value)
622 }
623 }
624 };
625}
626
627id_newtype!(EnvId, "Environment identifier for a tenant context.");
628id_newtype!(TenantId, "Tenant identifier within an environment.");
629id_newtype!(TeamId, "Team identifier belonging to a tenant.");
630id_newtype!(UserId, "User identifier within a tenant.");
631id_newtype!(BranchRef, "Reference to a source control branch.");
632id_newtype!(CommitRef, "Reference to a source control commit.");
633id_newtype!(
634 GitProviderRef,
635 "Identifier referencing a source control provider."
636);
637id_newtype!(ScannerRef, "Identifier referencing a scanner provider.");
638id_newtype!(WebhookId, "Identifier referencing a registered webhook.");
639id_newtype!(PackId, "Globally unique pack identifier.");
640id_newtype!(
641 ComponentId,
642 "Identifier referencing a component binding in a pack."
643);
644id_newtype!(FlowId, "Identifier referencing a flow inside a pack.");
645id_newtype!(NodeId, "Identifier referencing a node inside a flow graph.");
646id_newtype!(
647 EnvironmentRef,
648 "Identifier referencing a deployment environment."
649);
650id_newtype!(
651 DistributorRef,
652 "Identifier referencing a distributor instance."
653);
654id_newtype!(StoreFrontId, "Identifier referencing a storefront.");
655id_newtype!(
656 StoreProductId,
657 "Identifier referencing a product in the store catalog."
658);
659id_newtype!(
660 StorePlanId,
661 "Identifier referencing a plan for a store product."
662);
663id_newtype!(
664 SubscriptionId,
665 "Identifier referencing a subscription entry."
666);
667id_newtype!(BundleId, "Identifier referencing a distributor bundle.");
668id_newtype!(CollectionId, "Identifier referencing a product collection.");
669id_newtype!(RepoRef, "Repository reference within a supply chain.");
670id_newtype!(
671 ComponentRef,
672 "Supply-chain component reference (distinct from pack ComponentId)."
673);
674id_newtype!(
675 VersionRef,
676 "Version reference for a component or metadata record."
677);
678id_newtype!(BuildRef, "Build reference within a supply chain.");
679id_newtype!(ScanRef, "Scan reference within a supply chain.");
680id_newtype!(
681 AttestationRef,
682 "Attestation reference within a supply chain."
683);
684id_newtype!(AttestationId, "Identifier referencing an attestation.");
685id_newtype!(PolicyRef, "Policy reference within a supply chain.");
686id_newtype!(
687 PolicyInputRef,
688 "Reference to a policy input payload for evaluation."
689);
690id_newtype!(StoreRef, "Content store reference within a supply chain.");
691id_newtype!(
692 RegistryRef,
693 "Registry reference for OCI or artifact storage."
694);
695id_newtype!(OciImageRef, "Reference to an OCI image for distribution.");
696id_newtype!(
697 ArtifactRef,
698 "Artifact reference within a build or scan result."
699);
700id_newtype!(
701 SbomRef,
702 "Reference to a Software Bill of Materials artifact."
703);
704id_newtype!(SigningKeyRef, "Reference to a signing key handle.");
705id_newtype!(SignatureRef, "Reference to a generated signature.");
706id_newtype!(StatementRef, "Reference to an attestation statement.");
707id_newtype!(
708 BuildLogRef,
709 "Reference to a build log output produced during execution."
710);
711id_newtype!(
712 MetadataRecordRef,
713 "Reference to a metadata record attached to artifacts or bundles."
714);
715
716#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
718#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
719#[cfg_attr(feature = "schemars", derive(JsonSchema))]
720#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
721pub struct ApiKeyRef(pub String);
722
723impl ApiKeyRef {
724 pub fn as_str(&self) -> &str {
726 &self.0
727 }
728
729 pub fn new(value: impl AsRef<str>) -> GResult<Self> {
731 value.as_ref().parse()
732 }
733}
734
735impl fmt::Display for ApiKeyRef {
736 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
737 f.write_str(self.as_str())
738 }
739}
740
741impl FromStr for ApiKeyRef {
742 type Err = GreenticError;
743
744 fn from_str(value: &str) -> Result<Self, Self::Err> {
745 validate_api_key_ref(value)?;
746 Ok(Self(value.to_owned()))
747 }
748}
749
750impl TryFrom<String> for ApiKeyRef {
751 type Error = GreenticError;
752
753 fn try_from(value: String) -> Result<Self, Self::Error> {
754 ApiKeyRef::from_str(&value)
755 }
756}
757
758impl TryFrom<&str> for ApiKeyRef {
759 type Error = GreenticError;
760
761 fn try_from(value: &str) -> Result<Self, Self::Error> {
762 ApiKeyRef::from_str(value)
763 }
764}
765
766impl From<ApiKeyRef> for String {
767 fn from(value: ApiKeyRef) -> Self {
768 value.0
769 }
770}
771
772impl AsRef<str> for ApiKeyRef {
773 fn as_ref(&self) -> &str {
774 self.as_str()
775 }
776}
777
778#[derive(Clone, Debug, PartialEq, Eq, Hash)]
780#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
781#[cfg_attr(feature = "schemars", derive(JsonSchema))]
782pub struct TenantContext {
783 pub tenant_id: TenantId,
785 #[cfg_attr(
787 feature = "serde",
788 serde(default, skip_serializing_if = "Option::is_none")
789 )]
790 pub team_id: Option<TeamId>,
791 #[cfg_attr(
793 feature = "serde",
794 serde(default, skip_serializing_if = "Option::is_none")
795 )]
796 pub user_id: Option<UserId>,
797 #[cfg_attr(
799 feature = "serde",
800 serde(default, skip_serializing_if = "Option::is_none")
801 )]
802 pub session_id: Option<String>,
803 #[cfg_attr(
805 feature = "serde",
806 serde(default, skip_serializing_if = "BTreeMap::is_empty")
807 )]
808 pub attributes: BTreeMap<String, String>,
809}
810
811impl TenantContext {
812 pub fn new(tenant_id: TenantId) -> Self {
814 Self {
815 tenant_id,
816 team_id: None,
817 user_id: None,
818 session_id: None,
819 attributes: BTreeMap::new(),
820 }
821 }
822}
823
824impl From<&TenantCtx> for TenantContext {
825 fn from(ctx: &TenantCtx) -> Self {
826 Self {
827 tenant_id: ctx.tenant_id.clone(),
828 team_id: ctx.team_id.clone().or_else(|| ctx.team.clone()),
829 user_id: ctx.user_id.clone().or_else(|| ctx.user.clone()),
830 session_id: ctx.session_id.clone(),
831 attributes: ctx.attributes.clone(),
832 }
833 }
834}
835
836#[derive(Clone, Debug, PartialEq, Eq, Hash)]
838#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
839#[cfg_attr(feature = "schemars", derive(JsonSchema))]
840#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
841pub enum HashAlgorithm {
842 Blake3,
844 Other(String),
846}
847
848#[derive(Clone, Debug, PartialEq, Eq, Hash)]
850#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
851#[cfg_attr(
852 feature = "serde",
853 serde(into = "HashDigestRepr", try_from = "HashDigestRepr")
854)]
855#[cfg_attr(feature = "schemars", derive(JsonSchema))]
856pub struct HashDigest {
857 pub algo: HashAlgorithm,
859 pub hex: String,
861}
862
863impl HashDigest {
864 pub fn new(algo: HashAlgorithm, hex: impl Into<String>) -> GResult<Self> {
866 let hex = hex.into();
867 validate_hex(&hex)?;
868 Ok(Self { algo, hex })
869 }
870
871 pub fn blake3(hex: impl Into<String>) -> GResult<Self> {
873 Self::new(HashAlgorithm::Blake3, hex)
874 }
875}
876
877#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
878#[cfg_attr(feature = "schemars", derive(JsonSchema))]
879struct HashDigestRepr {
880 algo: HashAlgorithm,
881 hex: String,
882}
883
884impl From<HashDigest> for HashDigestRepr {
885 fn from(value: HashDigest) -> Self {
886 Self {
887 algo: value.algo,
888 hex: value.hex,
889 }
890 }
891}
892
893impl TryFrom<HashDigestRepr> for HashDigest {
894 type Error = GreenticError;
895
896 fn try_from(value: HashDigestRepr) -> Result<Self, Self::Error> {
897 HashDigest::new(value.algo, value.hex)
898 }
899}
900
901fn validate_hex(hex: &str) -> GResult<()> {
902 if hex.is_empty() {
903 return Err(GreenticError::new(
904 ErrorCode::InvalidInput,
905 "digest hex payload must not be empty",
906 ));
907 }
908 if hex.len() % 2 != 0 {
909 return Err(GreenticError::new(
910 ErrorCode::InvalidInput,
911 "digest hex payload must have an even number of digits",
912 ));
913 }
914 if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
915 return Err(GreenticError::new(
916 ErrorCode::InvalidInput,
917 "digest hex payload must be hexadecimal",
918 ));
919 }
920 Ok(())
921}
922
923#[derive(Clone, Debug, PartialEq, Eq, Hash)]
925#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
926#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
927pub struct SemverReq(String);
928
929impl SemverReq {
930 pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
932 let value = value.as_ref();
933 VersionReq::parse(value).map_err(|err| {
934 GreenticError::new(
935 ErrorCode::InvalidInput,
936 format!("invalid semver requirement '{value}': {err}"),
937 )
938 })?;
939 Ok(Self(value.to_owned()))
940 }
941
942 pub fn as_str(&self) -> &str {
944 &self.0
945 }
946
947 pub fn to_version_req(&self) -> VersionReq {
949 VersionReq::parse(&self.0)
950 .unwrap_or_else(|err| unreachable!("SemverReq::parse validated inputs: {err}"))
951 }
952}
953
954impl fmt::Display for SemverReq {
955 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956 f.write_str(self.as_str())
957 }
958}
959
960impl From<SemverReq> for String {
961 fn from(value: SemverReq) -> Self {
962 value.0
963 }
964}
965
966impl TryFrom<String> for SemverReq {
967 type Error = GreenticError;
968
969 fn try_from(value: String) -> Result<Self, Self::Error> {
970 SemverReq::parse(&value)
971 }
972}
973
974impl TryFrom<&str> for SemverReq {
975 type Error = GreenticError;
976
977 fn try_from(value: &str) -> Result<Self, Self::Error> {
978 SemverReq::parse(value)
979 }
980}
981
982impl FromStr for SemverReq {
983 type Err = GreenticError;
984
985 fn from_str(s: &str) -> Result<Self, Self::Err> {
986 SemverReq::parse(s)
987 }
988}
989
990#[derive(Clone, Debug, PartialEq, Eq, Hash)]
992#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
993#[cfg_attr(feature = "serde", serde(into = "String", try_from = "String"))]
994pub struct RedactionPath(String);
995
996impl RedactionPath {
997 pub fn parse(value: impl AsRef<str>) -> GResult<Self> {
999 let value = value.as_ref();
1000 validate_jsonpath(value)?;
1001 Ok(Self(value.to_owned()))
1002 }
1003
1004 pub fn as_str(&self) -> &str {
1006 &self.0
1007 }
1008}
1009
1010impl fmt::Display for RedactionPath {
1011 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1012 f.write_str(self.as_str())
1013 }
1014}
1015
1016impl From<RedactionPath> for String {
1017 fn from(value: RedactionPath) -> Self {
1018 value.0
1019 }
1020}
1021
1022impl TryFrom<String> for RedactionPath {
1023 type Error = GreenticError;
1024
1025 fn try_from(value: String) -> Result<Self, Self::Error> {
1026 RedactionPath::parse(&value)
1027 }
1028}
1029
1030impl TryFrom<&str> for RedactionPath {
1031 type Error = GreenticError;
1032
1033 fn try_from(value: &str) -> Result<Self, Self::Error> {
1034 RedactionPath::parse(value)
1035 }
1036}
1037
1038fn validate_jsonpath(path: &str) -> GResult<()> {
1039 if path.is_empty() {
1040 return Err(GreenticError::new(
1041 ErrorCode::InvalidInput,
1042 "redaction path cannot be empty",
1043 ));
1044 }
1045 if !path.starts_with('$') {
1046 return Err(GreenticError::new(
1047 ErrorCode::InvalidInput,
1048 "redaction path must start with '$'",
1049 ));
1050 }
1051 if path.chars().any(|c| c.is_control()) {
1052 return Err(GreenticError::new(
1053 ErrorCode::InvalidInput,
1054 "redaction path cannot contain control characters",
1055 ));
1056 }
1057 Ok(())
1058}
1059
1060#[cfg(feature = "schemars")]
1061impl JsonSchema for SemverReq {
1062 fn schema_name() -> Cow<'static, str> {
1063 Cow::Borrowed("SemverReq")
1064 }
1065
1066 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1067 let mut schema = <String>::json_schema(generator);
1068 if schema.get("description").is_none() {
1069 schema.insert(
1070 "description".into(),
1071 "Validated semantic version requirement string".into(),
1072 );
1073 }
1074 schema
1075 }
1076}
1077
1078#[cfg(feature = "schemars")]
1079impl JsonSchema for RedactionPath {
1080 fn schema_name() -> Cow<'static, str> {
1081 Cow::Borrowed("RedactionPath")
1082 }
1083
1084 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1085 let mut schema = <String>::json_schema(generator);
1086 if schema.get("description").is_none() {
1087 schema.insert(
1088 "description".into(),
1089 "JSONPath expression used for runtime redaction".into(),
1090 );
1091 }
1092 schema
1093 }
1094}
1095
1096#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1098#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1099#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1100pub struct InvocationDeadline {
1101 unix_millis: i128,
1102}
1103
1104impl InvocationDeadline {
1105 pub const fn from_unix_millis(unix_millis: i128) -> Self {
1107 Self { unix_millis }
1108 }
1109
1110 pub const fn unix_millis(&self) -> i128 {
1112 self.unix_millis
1113 }
1114
1115 #[cfg(feature = "time")]
1117 pub fn to_offset_date_time(&self) -> Result<OffsetDateTime, time::error::ComponentRange> {
1118 OffsetDateTime::from_unix_timestamp_nanos(self.unix_millis * 1_000_000)
1119 }
1120
1121 #[cfg(feature = "time")]
1123 pub fn from_offset_date_time(value: OffsetDateTime) -> Self {
1124 let nanos = value.unix_timestamp_nanos();
1125 Self {
1126 unix_millis: nanos / 1_000_000,
1127 }
1128 }
1129}
1130
1131#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1133#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1134#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1135pub struct TenantCtx {
1136 pub env: EnvId,
1138 pub tenant: TenantId,
1140 pub tenant_id: TenantId,
1142 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1144 pub team: Option<TeamId>,
1145 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1147 pub team_id: Option<TeamId>,
1148 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1150 pub user: Option<UserId>,
1151 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1153 pub user_id: Option<UserId>,
1154 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1156 pub session_id: Option<String>,
1157 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1159 pub flow_id: Option<String>,
1160 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1162 pub node_id: Option<String>,
1163 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1165 pub provider_id: Option<String>,
1166 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1168 pub trace_id: Option<String>,
1169 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1171 pub correlation_id: Option<String>,
1172 #[cfg_attr(
1174 feature = "serde",
1175 serde(default, skip_serializing_if = "BTreeMap::is_empty")
1176 )]
1177 pub attributes: BTreeMap<String, String>,
1178 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1180 pub deadline: Option<InvocationDeadline>,
1181 pub attempt: u32,
1183 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1185 pub idempotency_key: Option<String>,
1186 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
1188 pub impersonation: Option<Impersonation>,
1189}
1190
1191impl TenantCtx {
1192 pub fn new(env: EnvId, tenant: TenantId) -> Self {
1194 let tenant_id = tenant.clone();
1195 Self {
1196 env,
1197 tenant: tenant.clone(),
1198 tenant_id,
1199 team: None,
1200 team_id: None,
1201 user: None,
1202 user_id: None,
1203 session_id: None,
1204 flow_id: None,
1205 node_id: None,
1206 provider_id: None,
1207 trace_id: None,
1208 correlation_id: None,
1209 attributes: BTreeMap::new(),
1210 deadline: None,
1211 attempt: 0,
1212 idempotency_key: None,
1213 impersonation: None,
1214 }
1215 }
1216
1217 pub fn with_team(mut self, team: Option<TeamId>) -> Self {
1219 self.team = team.clone();
1220 self.team_id = team;
1221 self
1222 }
1223
1224 pub fn with_user(mut self, user: Option<UserId>) -> Self {
1226 self.user = user.clone();
1227 self.user_id = user;
1228 self
1229 }
1230
1231 pub fn with_session(mut self, session: impl Into<String>) -> Self {
1233 self.session_id = Some(session.into());
1234 self
1235 }
1236
1237 pub fn with_flow(mut self, flow: impl Into<String>) -> Self {
1239 self.flow_id = Some(flow.into());
1240 self
1241 }
1242
1243 pub fn with_node(mut self, node: impl Into<String>) -> Self {
1245 self.node_id = Some(node.into());
1246 self
1247 }
1248
1249 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
1251 self.provider_id = Some(provider.into());
1252 self
1253 }
1254
1255 pub fn with_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
1257 self.attributes = attributes;
1258 self
1259 }
1260
1261 pub fn with_impersonation(mut self, impersonation: Option<Impersonation>) -> Self {
1263 self.impersonation = impersonation;
1264 self
1265 }
1266
1267 pub fn with_attempt(mut self, attempt: u32) -> Self {
1269 self.attempt = attempt;
1270 self
1271 }
1272
1273 pub fn with_deadline(mut self, deadline: Option<InvocationDeadline>) -> Self {
1275 self.deadline = deadline;
1276 self
1277 }
1278
1279 pub fn session_id(&self) -> Option<&str> {
1281 self.session_id.as_deref()
1282 }
1283
1284 pub fn flow_id(&self) -> Option<&str> {
1286 self.flow_id.as_deref()
1287 }
1288
1289 pub fn node_id(&self) -> Option<&str> {
1291 self.node_id.as_deref()
1292 }
1293
1294 pub fn provider_id(&self) -> Option<&str> {
1296 self.provider_id.as_deref()
1297 }
1298}
1299
1300pub type BinaryPayload = Vec<u8>;
1302
1303#[derive(Clone, Debug, PartialEq, Eq)]
1305#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1306#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1307pub struct InvocationEnvelope {
1308 pub ctx: TenantCtx,
1310 pub flow_id: String,
1312 pub node_id: Option<String>,
1314 pub op: String,
1316 pub payload: BinaryPayload,
1318 pub metadata: BinaryPayload,
1320}
1321
1322#[derive(Clone, Debug, PartialEq, Eq)]
1324#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1325#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1326pub enum ErrorDetail {
1327 Text(String),
1329 Binary(BinaryPayload),
1331}
1332
1333#[derive(Debug)]
1335#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1336#[cfg_attr(feature = "schemars", derive(JsonSchema))]
1337pub struct NodeError {
1338 pub code: String,
1340 pub message: String,
1342 pub retryable: bool,
1344 pub backoff_ms: Option<u64>,
1346 pub details: Option<ErrorDetail>,
1348 #[cfg(feature = "std")]
1349 #[cfg_attr(feature = "serde", serde(skip, default = "default_source"))]
1350 #[cfg_attr(feature = "schemars", schemars(skip))]
1351 source: Option<Box<dyn StdError + Send + Sync>>,
1352}
1353
1354#[cfg(all(feature = "std", feature = "serde"))]
1355fn default_source() -> Option<Box<dyn StdError + Send + Sync>> {
1356 None
1357}
1358
1359impl NodeError {
1360 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
1362 Self {
1363 code: code.into(),
1364 message: message.into(),
1365 retryable: false,
1366 backoff_ms: None,
1367 details: None,
1368 #[cfg(feature = "std")]
1369 source: None,
1370 }
1371 }
1372
1373 pub fn with_retry(mut self, backoff_ms: Option<u64>) -> Self {
1375 self.retryable = true;
1376 self.backoff_ms = backoff_ms;
1377 self
1378 }
1379
1380 pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
1382 self.details = Some(detail);
1383 self
1384 }
1385
1386 pub fn with_detail_text(mut self, detail: impl Into<String>) -> Self {
1388 self.details = Some(ErrorDetail::Text(detail.into()));
1389 self
1390 }
1391
1392 pub fn with_detail_binary(mut self, detail: BinaryPayload) -> Self {
1394 self.details = Some(ErrorDetail::Binary(detail));
1395 self
1396 }
1397
1398 #[cfg(feature = "std")]
1399 pub fn with_source<E>(mut self, source: E) -> Self
1401 where
1402 E: StdError + Send + Sync + 'static,
1403 {
1404 self.source = Some(Box::new(source));
1405 self
1406 }
1407
1408 pub fn detail(&self) -> Option<&ErrorDetail> {
1410 self.details.as_ref()
1411 }
1412
1413 #[cfg(feature = "std")]
1414 pub fn source(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> {
1416 self.source.as_deref()
1417 }
1418}
1419
1420impl fmt::Display for NodeError {
1421 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1422 write!(f, "{}: {}", self.code, self.message)
1423 }
1424}
1425
1426#[cfg(feature = "std")]
1427impl StdError for NodeError {
1428 fn source(&self) -> Option<&(dyn StdError + 'static)> {
1429 self.source
1430 .as_ref()
1431 .map(|err| err.as_ref() as &(dyn StdError + 'static))
1432 }
1433}
1434
1435pub type NodeResult<T> = Result<T, NodeError>;
1437
1438pub fn make_idempotency_key(
1443 ctx: &TenantCtx,
1444 flow_id: &str,
1445 node_id: Option<&str>,
1446 correlation: Option<&str>,
1447) -> String {
1448 let node_segment = node_id.unwrap_or_default();
1449 let correlation_segment = correlation
1450 .or(ctx.correlation_id.as_deref())
1451 .unwrap_or_default();
1452 let input = format!(
1453 "{}|{}|{}|{}",
1454 ctx.tenant_id.as_str(),
1455 flow_id,
1456 node_segment,
1457 correlation_segment
1458 );
1459 fnv1a_128_hex(input.as_bytes())
1460}
1461
1462const FNV_OFFSET_BASIS: u128 = 0x6c62272e07bb014262b821756295c58d;
1463const FNV_PRIME: u128 = 0x0000000001000000000000000000013b;
1464
1465fn fnv1a_128_hex(bytes: &[u8]) -> String {
1466 let mut hash = FNV_OFFSET_BASIS;
1467 for &byte in bytes {
1468 hash ^= byte as u128;
1469 hash = hash.wrapping_mul(FNV_PRIME);
1470 }
1471
1472 let mut output = String::with_capacity(32);
1473 for shift in (0..32).rev() {
1474 let nibble = ((hash >> (shift * 4)) & 0x0f) as u8;
1475 output.push(match nibble {
1476 0..=9 => (b'0' + nibble) as char,
1477 _ => (b'a' + (nibble - 10)) as char,
1478 });
1479 }
1480 output
1481}
1482
1483#[cfg(test)]
1484mod tests {
1485 use super::*;
1486 use core::convert::TryFrom;
1487 use time::OffsetDateTime;
1488
1489 fn sample_ctx() -> TenantCtx {
1490 let env = EnvId::try_from("prod").unwrap_or_else(|err| panic!("{err}"));
1491 let tenant = TenantId::try_from("tenant-123").unwrap_or_else(|err| panic!("{err}"));
1492 let team = TeamId::try_from("team-456").unwrap_or_else(|err| panic!("{err}"));
1493 let user = UserId::try_from("user-789").unwrap_or_else(|err| panic!("{err}"));
1494
1495 let mut ctx = TenantCtx::new(env, tenant)
1496 .with_team(Some(team))
1497 .with_user(Some(user))
1498 .with_attempt(2)
1499 .with_deadline(Some(InvocationDeadline::from_unix_millis(
1500 1_700_000_000_000,
1501 )));
1502 ctx.trace_id = Some("trace-abc".to_owned());
1503 ctx.correlation_id = Some("corr-xyz".to_owned());
1504 ctx.idempotency_key = Some("key-123".to_owned());
1505 ctx
1506 }
1507
1508 #[test]
1509 fn idempotent_key_stable() {
1510 let ctx = sample_ctx();
1511 let key_a = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1512 let key_b = make_idempotency_key(&ctx, "flow-1", Some("node-1"), Some("corr-override"));
1513 assert_eq!(key_a, key_b);
1514 assert_eq!(key_a.len(), 32);
1515 }
1516
1517 #[test]
1518 fn idempotent_key_uses_context_correlation() {
1519 let ctx = sample_ctx();
1520 let key = make_idempotency_key(&ctx, "flow-1", None, None);
1521 let expected = make_idempotency_key(&ctx, "flow-1", None, ctx.correlation_id.as_deref());
1522 assert_eq!(key, expected);
1523 }
1524
1525 #[test]
1526 #[cfg(feature = "time")]
1527 fn deadline_roundtrips_through_offset_datetime() {
1528 let dt = OffsetDateTime::from_unix_timestamp(1_700_000_000)
1529 .unwrap_or_else(|err| panic!("valid timestamp: {err}"));
1530 let deadline = InvocationDeadline::from_offset_date_time(dt);
1531 let roundtrip = deadline
1532 .to_offset_date_time()
1533 .unwrap_or_else(|err| panic!("round-trip conversion failed: {err}"));
1534 let millis = dt.unix_timestamp_nanos() / 1_000_000;
1535 assert_eq!(deadline.unix_millis(), millis);
1536 assert_eq!(roundtrip.unix_timestamp_nanos() / 1_000_000, millis);
1537 }
1538
1539 #[test]
1540 fn node_error_builder_sets_fields() {
1541 let err = NodeError::new("TEST", "example")
1542 .with_retry(Some(500))
1543 .with_detail_text("context");
1544
1545 assert!(err.retryable);
1546 assert_eq!(err.backoff_ms, Some(500));
1547 match err.detail() {
1548 Some(ErrorDetail::Text(detail)) => assert_eq!(detail, "context"),
1549 other => panic!("unexpected detail {other:?}"),
1550 }
1551 }
1552
1553 #[cfg(feature = "std")]
1554 #[test]
1555 fn node_error_source_roundtrips() {
1556 use std::io::Error;
1557
1558 let source = Error::other("boom");
1559 let err = NodeError::new("TEST", "example").with_source(source);
1560 assert!(err.source().is_some());
1561 }
1562}