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