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