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