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