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