commit f1c987644d85bdea2cd2bffcc33a7e1ce2db4833
parent c0154720b35a9a0da24ab3ba42ed1323109cee0c
Author: triesap <tyson@radroots.org>
Date: Sat, 1 Aug 2026 21:04:29 +0000
storage: define private-artifact metadata contracts
- define metadata-only protected artifact identities and commitments
- separate expiry from minimum retention and deletion authority
- preserve redacted versioned provider secret references
- prove expiry tombstone commitment and revision invariants
Diffstat:
4 files changed, 651 insertions(+), 1 deletion(-)
diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs
@@ -72,6 +72,22 @@ pub enum Error {
InvalidEventIndexCheckpoint,
DuplicateEventIndexShard,
CorruptProjectionRecord,
+ InvalidPrivateArtifactId,
+ InvalidPrivateArtifactKind,
+ InvalidPrivateArtifactSchema,
+ InvalidPrivateArtifactSecretReference,
+ InvalidPrivateArtifactRetention,
+ InvalidPrivateArtifactRevision,
+ InvalidPrivateArtifactTimestamp,
+ InvalidPrivateArtifactMetadata,
+ PrivateArtifactNotFound,
+ PrivateArtifactConflict,
+ PrivateArtifactRevisionConflict,
+ PrivateArtifactRetentionActive,
+ PrivateArtifactNotExpired,
+ PrivateArtifactTombstoned,
+ InvalidExpiredArtifactQueryLimit,
+ CorruptPrivateArtifactMetadata,
}
impl fmt::Display for Error {
@@ -149,6 +165,34 @@ impl fmt::Display for Error {
Self::InvalidEventIndexCheckpoint => "storage event-index checkpoint is invalid",
Self::DuplicateEventIndexShard => "storage event-index shard is duplicated",
Self::CorruptProjectionRecord => "storage projection record is corrupt",
+ Self::InvalidPrivateArtifactId => "storage private-artifact id is invalid",
+ Self::InvalidPrivateArtifactKind => "storage private-artifact kind is invalid",
+ Self::InvalidPrivateArtifactSchema => "storage private-artifact schema is invalid",
+ Self::InvalidPrivateArtifactSecretReference => {
+ "storage private-artifact secret reference is invalid"
+ }
+ Self::InvalidPrivateArtifactRetention => {
+ "storage private-artifact retention is invalid"
+ }
+ Self::InvalidPrivateArtifactRevision => "storage private-artifact revision is invalid",
+ Self::InvalidPrivateArtifactTimestamp => {
+ "storage private-artifact timestamp is invalid"
+ }
+ Self::InvalidPrivateArtifactMetadata => "storage private-artifact metadata is invalid",
+ Self::PrivateArtifactNotFound => "storage private artifact was not found",
+ Self::PrivateArtifactConflict => {
+ "storage private-artifact metadata conflicts with durable state"
+ }
+ Self::PrivateArtifactRevisionConflict => {
+ "storage private-artifact revision conflicts with durable state"
+ }
+ Self::PrivateArtifactRetentionActive => "storage private-artifact retention is active",
+ Self::PrivateArtifactNotExpired => "storage private artifact has not expired",
+ Self::PrivateArtifactTombstoned => "storage private artifact is tombstoned",
+ Self::InvalidExpiredArtifactQueryLimit => {
+ "storage expired-artifact query limit is invalid"
+ }
+ Self::CorruptPrivateArtifactMetadata => "storage private-artifact metadata is corrupt",
})
}
}
diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs
@@ -18,4 +18,5 @@ pub use error::Error;
pub use event::EventStore;
pub use journal::Journal;
pub use outbox::Outbox;
+pub use private_artifact::PrivateArtifactStore;
pub use projection::ProjectionStore;
diff --git a/crates/storage/src/private_artifact.rs b/crates/storage/src/private_artifact.rs
@@ -1 +1,486 @@
-//! Private-artifact metadata contracts.
+//! Protected private-artifact metadata contracts.
+//!
+//! These contracts intentionally contain neither plaintext nor ciphertext.
+//! Encryption, key access, and backend schema remain implementation details.
+
+use core::fmt;
+use radroots_transport::BoxFuture;
+
+use crate::Error;
+
+pub const ARTIFACT_KIND_MAX_BYTES: usize = 128;
+pub const ARTIFACT_SCHEMA_MAX_BYTES: usize = 128;
+pub const SECRET_PROVIDER_MAX_BYTES: usize = 64;
+pub const SECRET_REFERENCE_MAX_BYTES: usize = 512;
+pub const EXPIRED_ARTIFACT_QUERY_LIMIT_MAX: u16 = 256;
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct PrivateArtifactId([u8; 16]);
+
+impl PrivateArtifactId {
+ pub const fn new(bytes: [u8; 16]) -> Result<Self, Error> {
+ if bytes_are_zero(&bytes) {
+ return Err(Error::InvalidPrivateArtifactId);
+ }
+ Ok(Self(bytes))
+ }
+ pub const fn as_bytes(&self) -> &[u8; 16] {
+ &self.0
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct ArtifactKind(String);
+
+impl ArtifactKind {
+ pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
+ let value = value.into();
+ if !valid_label(value.as_str(), ARTIFACT_KIND_MAX_BYTES) {
+ return Err(Error::InvalidPrivateArtifactKind);
+ }
+ Ok(Self(value))
+ }
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct ArtifactSchemaId(String);
+
+impl ArtifactSchemaId {
+ pub fn parse(value: impl Into<String>) -> Result<Self, Error> {
+ let value = value.into();
+ if !valid_label(value.as_str(), ARTIFACT_SCHEMA_MAX_BYTES) {
+ return Err(Error::InvalidPrivateArtifactSchema);
+ }
+ Ok(Self(value))
+ }
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+/// SHA-256 commitment to the exact protected representation.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct ArtifactCommitment([u8; 32]);
+
+impl ArtifactCommitment {
+ pub const fn new(bytes: [u8; 32]) -> Self {
+ Self(bytes)
+ }
+ pub const fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+}
+
+/// Persistable provider reference metadata, never a secret capability itself.
+#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct DurableSecretReference {
+ provider: String,
+ opaque_reference: String,
+ key_version: u32,
+}
+
+impl DurableSecretReference {
+ pub fn new(
+ provider: impl Into<String>,
+ opaque_reference: impl Into<String>,
+ key_version: u32,
+ ) -> Result<Self, Error> {
+ let provider = provider.into();
+ let opaque_reference = opaque_reference.into();
+ if !valid_label(provider.as_str(), SECRET_PROVIDER_MAX_BYTES)
+ || opaque_reference.is_empty()
+ || opaque_reference.len() > SECRET_REFERENCE_MAX_BYTES
+ || opaque_reference != opaque_reference.trim()
+ || opaque_reference.chars().any(char::is_control)
+ || key_version == 0
+ {
+ return Err(Error::InvalidPrivateArtifactSecretReference);
+ }
+ Ok(Self {
+ provider,
+ opaque_reference,
+ key_version,
+ })
+ }
+ pub fn provider(&self) -> &str {
+ self.provider.as_str()
+ }
+ pub fn opaque_reference(&self) -> &str {
+ self.opaque_reference.as_str()
+ }
+ pub const fn key_version(&self) -> u32 {
+ self.key_version
+ }
+}
+
+impl fmt::Debug for DurableSecretReference {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("DurableSecretReference")
+ .field("provider", &self.provider)
+ .field("opaque_reference", &"[REDACTED]")
+ .field("key_version", &self.key_version)
+ .finish()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl serde::Serialize for DurableSecretReference {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ use serde::ser::SerializeStruct;
+ let mut state = serializer.serialize_struct("DurableSecretReference", 3)?;
+ state.serialize_field("provider", &self.provider)?;
+ state.serialize_field("opaque_reference", &self.opaque_reference)?;
+ state.serialize_field("key_version", &self.key_version)?;
+ state.end()
+ }
+}
+
+#[cfg(feature = "serde")]
+impl<'de> serde::Deserialize<'de> for DurableSecretReference {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(serde::Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Wire {
+ provider: String,
+ opaque_reference: String,
+ key_version: u32,
+ }
+ let wire = Wire::deserialize(deserializer)?;
+ Self::new(wire.provider, wire.opaque_reference, wire.key_version)
+ .map_err(serde::de::Error::custom)
+ }
+}
+
+/// Minimum retention and optional automatic expiry policy.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct RetentionPolicy {
+ delete_not_before_unix_ms: Option<u64>,
+ expires_at_unix_ms: Option<u64>,
+}
+
+impl RetentionPolicy {
+ pub const fn indefinite() -> Self {
+ Self {
+ delete_not_before_unix_ms: None,
+ expires_at_unix_ms: None,
+ }
+ }
+ pub const fn new(
+ delete_not_before_unix_ms: Option<u64>,
+ expires_at_unix_ms: Option<u64>,
+ ) -> Result<Self, Error> {
+ if matches!(delete_not_before_unix_ms, Some(0)) || matches!(expires_at_unix_ms, Some(0)) {
+ return Err(Error::InvalidPrivateArtifactRetention);
+ }
+ Ok(Self {
+ delete_not_before_unix_ms,
+ expires_at_unix_ms,
+ })
+ }
+ pub const fn delete_not_before_unix_ms(self) -> Option<u64> {
+ self.delete_not_before_unix_ms
+ }
+ pub const fn expires_at_unix_ms(self) -> Option<u64> {
+ self.expires_at_unix_ms
+ }
+ pub const fn is_expired_at(self, unix_ms: u64) -> bool {
+ matches!(self.expires_at_unix_ms, Some(expires) if unix_ms >= expires)
+ }
+ pub const fn permits_deletion_at(self, unix_ms: u64) -> bool {
+ !matches!(self.delete_not_before_unix_ms, Some(not_before) if unix_ms < not_before)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct PrivateArtifactRevision(u64);
+
+impl PrivateArtifactRevision {
+ pub const INITIAL: Self = Self(1);
+ pub const fn new(value: u64) -> Result<Self, Error> {
+ if value == 0 {
+ Err(Error::InvalidPrivateArtifactRevision)
+ } else {
+ Ok(Self(value))
+ }
+ }
+ pub const fn get(self) -> u64 {
+ self.0
+ }
+ fn next(self) -> Result<Self, Error> {
+ self.0
+ .checked_add(1)
+ .map(Self)
+ .ok_or(Error::CorruptPrivateArtifactMetadata)
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrivateArtifactStage {
+ Active,
+ Expired,
+ Tombstoned,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum DeletionReason {
+ UserRequested,
+ RetentionExpired,
+ KeyRevoked,
+ IntegrityFailure,
+ OperatorRequested,
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ArtifactTombstone {
+ deleted_at_unix_ms: u64,
+ reason: DeletionReason,
+ commitment: ArtifactCommitment,
+}
+
+impl ArtifactTombstone {
+ pub const fn deleted_at_unix_ms(self) -> u64 {
+ self.deleted_at_unix_ms
+ }
+ pub const fn reason(self) -> DeletionReason {
+ self.reason
+ }
+ pub const fn commitment(self) -> ArtifactCommitment {
+ self.commitment
+ }
+}
+
+/// Metadata for one protected artifact; no protected bytes are present.
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PrivateArtifactMetadata {
+ artifact_id: PrivateArtifactId,
+ kind: ArtifactKind,
+ schema_id: ArtifactSchemaId,
+ commitment: ArtifactCommitment,
+ protected_size_bytes: u64,
+ secret_reference: DurableSecretReference,
+ retention: RetentionPolicy,
+ revision: PrivateArtifactRevision,
+ stage: PrivateArtifactStage,
+ created_at_unix_ms: u64,
+ updated_at_unix_ms: u64,
+ tombstone: Option<ArtifactTombstone>,
+}
+
+impl PrivateArtifactMetadata {
+ #[allow(clippy::too_many_arguments)]
+ pub fn new(
+ artifact_id: PrivateArtifactId,
+ kind: ArtifactKind,
+ schema_id: ArtifactSchemaId,
+ commitment: ArtifactCommitment,
+ protected_size_bytes: u64,
+ secret_reference: DurableSecretReference,
+ retention: RetentionPolicy,
+ created_at_unix_ms: u64,
+ ) -> Result<Self, Error> {
+ if protected_size_bytes == 0
+ || created_at_unix_ms == 0
+ || matches!(retention.delete_not_before_unix_ms(), Some(value) if value < created_at_unix_ms)
+ || matches!(retention.expires_at_unix_ms(), Some(value) if value < created_at_unix_ms)
+ {
+ return Err(Error::InvalidPrivateArtifactMetadata);
+ }
+ Ok(Self {
+ artifact_id,
+ kind,
+ schema_id,
+ commitment,
+ protected_size_bytes,
+ secret_reference,
+ retention,
+ revision: PrivateArtifactRevision::INITIAL,
+ stage: PrivateArtifactStage::Active,
+ created_at_unix_ms,
+ updated_at_unix_ms: created_at_unix_ms,
+ tombstone: None,
+ })
+ }
+ pub const fn artifact_id(&self) -> PrivateArtifactId {
+ self.artifact_id
+ }
+ pub const fn kind(&self) -> &ArtifactKind {
+ &self.kind
+ }
+ pub const fn schema_id(&self) -> &ArtifactSchemaId {
+ &self.schema_id
+ }
+ pub const fn commitment(&self) -> ArtifactCommitment {
+ self.commitment
+ }
+ pub const fn protected_size_bytes(&self) -> u64 {
+ self.protected_size_bytes
+ }
+ pub const fn secret_reference(&self) -> &DurableSecretReference {
+ &self.secret_reference
+ }
+ pub const fn retention(&self) -> RetentionPolicy {
+ self.retention
+ }
+ pub const fn revision(&self) -> PrivateArtifactRevision {
+ self.revision
+ }
+ pub const fn stage(&self) -> PrivateArtifactStage {
+ self.stage
+ }
+ pub const fn created_at_unix_ms(&self) -> u64 {
+ self.created_at_unix_ms
+ }
+ pub const fn updated_at_unix_ms(&self) -> u64 {
+ self.updated_at_unix_ms
+ }
+ pub const fn tombstone_record(&self) -> Option<ArtifactTombstone> {
+ self.tombstone
+ }
+
+ pub fn mark_expired(
+ &self,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ ) -> Result<Self, Error> {
+ self.validate_transition(expected_revision, at_unix_ms)?;
+ if self.stage != PrivateArtifactStage::Active || !self.retention.is_expired_at(at_unix_ms) {
+ return Err(Error::PrivateArtifactNotExpired);
+ }
+ let mut next = self.clone();
+ next.revision = self.revision.next()?;
+ next.stage = PrivateArtifactStage::Expired;
+ next.updated_at_unix_ms = at_unix_ms;
+ Ok(next)
+ }
+
+ pub fn tombstone(
+ &self,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ reason: DeletionReason,
+ ) -> Result<Self, Error> {
+ self.validate_transition(expected_revision, at_unix_ms)?;
+ if self.stage == PrivateArtifactStage::Tombstoned {
+ return Err(Error::PrivateArtifactTombstoned);
+ }
+ if !self.retention.permits_deletion_at(at_unix_ms) {
+ return Err(Error::PrivateArtifactRetentionActive);
+ }
+ if reason == DeletionReason::RetentionExpired && !self.retention.is_expired_at(at_unix_ms) {
+ return Err(Error::PrivateArtifactNotExpired);
+ }
+ let mut next = self.clone();
+ next.revision = self.revision.next()?;
+ next.stage = PrivateArtifactStage::Tombstoned;
+ next.updated_at_unix_ms = at_unix_ms;
+ next.tombstone = Some(ArtifactTombstone {
+ deleted_at_unix_ms: at_unix_ms,
+ reason,
+ commitment: self.commitment,
+ });
+ Ok(next)
+ }
+
+ fn validate_transition(
+ &self,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ ) -> Result<(), Error> {
+ if expected_revision != self.revision {
+ return Err(Error::PrivateArtifactRevisionConflict);
+ }
+ if at_unix_ms < self.updated_at_unix_ms {
+ return Err(Error::InvalidPrivateArtifactTimestamp);
+ }
+ Ok(())
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct PrivateArtifactStatus {
+ pub active: u64,
+ pub expired: u64,
+ pub tombstoned: u64,
+}
+
+impl PrivateArtifactStatus {
+ pub fn total(self) -> Option<u64> {
+ self.active
+ .checked_add(self.expired)?
+ .checked_add(self.tombstoned)
+ }
+}
+
+/// Backend-neutral metadata-only private-artifact SPI.
+pub trait PrivateArtifactStore: Send + Sync {
+ fn put_metadata(
+ &self,
+ metadata: PrivateArtifactMetadata,
+ ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>;
+ fn metadata(
+ &self,
+ artifact_id: PrivateArtifactId,
+ ) -> BoxFuture<'_, Result<Option<PrivateArtifactMetadata>, Error>>;
+ fn mark_expired(
+ &self,
+ artifact_id: PrivateArtifactId,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>;
+ fn tombstone(
+ &self,
+ artifact_id: PrivateArtifactId,
+ expected_revision: PrivateArtifactRevision,
+ at_unix_ms: u64,
+ reason: DeletionReason,
+ ) -> BoxFuture<'_, Result<PrivateArtifactMetadata, Error>>;
+ fn expired(
+ &self,
+ at_unix_ms: u64,
+ limit: u16,
+ ) -> BoxFuture<'_, Result<Vec<PrivateArtifactMetadata>, Error>>;
+ fn status(&self) -> BoxFuture<'_, Result<PrivateArtifactStatus, Error>>;
+}
+
+fn valid_label(value: &str, max: usize) -> bool {
+ !value.is_empty()
+ && value.len() <= max
+ && value == value.trim()
+ && value.bytes().all(|byte| {
+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-' | b'.')
+ })
+}
+
+const fn bytes_are_zero(bytes: &[u8; 16]) -> bool {
+ let mut index = 0;
+ while index < bytes.len() {
+ if bytes[index] != 0 {
+ return false;
+ }
+ index += 1;
+ }
+ true
+}
diff --git a/crates/storage/tests/private_artifact.rs b/crates/storage/tests/private_artifact.rs
@@ -0,0 +1,120 @@
+use radroots_storage::{
+ Error, PrivateArtifactStore,
+ private_artifact::{
+ ArtifactCommitment, ArtifactKind, ArtifactSchemaId, DeletionReason, DurableSecretReference,
+ PrivateArtifactId, PrivateArtifactMetadata, PrivateArtifactRevision, PrivateArtifactStage,
+ RetentionPolicy,
+ },
+};
+
+fn metadata(retention: RetentionPolicy) -> PrivateArtifactMetadata {
+ PrivateArtifactMetadata::new(
+ PrivateArtifactId::new([1; 16]).expect("artifact id"),
+ ArtifactKind::parse("trade.private_terms").expect("artifact kind"),
+ ArtifactSchemaId::parse("trade.private_terms.v1").expect("schema id"),
+ ArtifactCommitment::new([2; 32]),
+ 512,
+ DurableSecretReference::new("keyring", "opaque-key-token", 3).expect("secret reference"),
+ retention,
+ 100,
+ )
+ .expect("metadata")
+}
+
+#[test]
+fn retention_distinguishes_expiry_from_earliest_deletion() {
+ let policy = RetentionPolicy::new(Some(400), Some(300)).expect("retention policy");
+ assert!(!policy.is_expired_at(299));
+ assert!(policy.is_expired_at(300));
+ assert!(!policy.permits_deletion_at(399));
+ assert!(policy.permits_deletion_at(400));
+ assert_eq!(
+ RetentionPolicy::new(Some(0), None),
+ Err(Error::InvalidPrivateArtifactRetention)
+ );
+}
+
+#[test]
+fn expiry_is_revision_bound_monotonic_and_policy_driven() {
+ let active = metadata(RetentionPolicy::new(None, Some(300)).expect("retention"));
+ assert_eq!(
+ active.mark_expired(PrivateArtifactRevision::INITIAL, 299),
+ Err(Error::PrivateArtifactNotExpired)
+ );
+ let expired = active
+ .mark_expired(PrivateArtifactRevision::INITIAL, 300)
+ .expect("expired metadata");
+ assert_eq!(expired.stage(), PrivateArtifactStage::Expired);
+ assert_eq!(expired.revision().get(), 2);
+ assert_eq!(
+ expired.mark_expired(PrivateArtifactRevision::INITIAL, 301),
+ Err(Error::PrivateArtifactRevisionConflict)
+ );
+}
+
+#[test]
+fn tombstones_preserve_commitment_and_enforce_retention() {
+ let active = metadata(RetentionPolicy::new(Some(400), Some(300)).expect("retention"));
+ let expired = active
+ .mark_expired(PrivateArtifactRevision::INITIAL, 300)
+ .expect("expired metadata");
+ assert_eq!(
+ expired.tombstone(expired.revision(), 399, DeletionReason::RetentionExpired,),
+ Err(Error::PrivateArtifactRetentionActive)
+ );
+ let deleted = expired
+ .tombstone(expired.revision(), 400, DeletionReason::RetentionExpired)
+ .expect("tombstone");
+ assert_eq!(deleted.stage(), PrivateArtifactStage::Tombstoned);
+ let tombstone = deleted.tombstone_record().expect("tombstone record");
+ assert_eq!(tombstone.commitment(), active.commitment());
+ assert_eq!(tombstone.deleted_at_unix_ms(), 400);
+ assert_eq!(tombstone.reason(), DeletionReason::RetentionExpired);
+ assert_eq!(
+ deleted.tombstone(deleted.revision(), 401, DeletionReason::OperatorRequested,),
+ Err(Error::PrivateArtifactTombstoned)
+ );
+}
+
+#[test]
+fn secret_references_are_bounded_redacted_and_round_trip() {
+ let reference =
+ DurableSecretReference::new("keyring", "opaque-key-token", 3).expect("secret reference");
+ let diagnostic = format!("{reference:?}");
+ assert!(diagnostic.contains("[REDACTED]"));
+ assert!(!diagnostic.contains("opaque-key-token"));
+
+ let encoded = serde_json::to_string(&reference).expect("reference JSON");
+ let decoded: DurableSecretReference =
+ serde_json::from_str(encoded.as_str()).expect("reference round trip");
+ assert_eq!(decoded, reference);
+ assert!(
+ serde_json::from_str::<DurableSecretReference>(
+ r#"{"provider":"keyring","opaque_reference":"token","key_version":0}"#,
+ )
+ .is_err()
+ );
+ assert_eq!(
+ DurableSecretReference::new("keyring", "token", 0),
+ Err(Error::InvalidPrivateArtifactSecretReference)
+ );
+}
+
+#[test]
+fn metadata_contains_only_protected_size_commitment_and_reference() {
+ fn accepts_dyn(_: Option<&dyn PrivateArtifactStore>) {}
+ accepts_dyn(None);
+
+ let value = metadata(RetentionPolicy::indefinite());
+ assert_eq!(value.protected_size_bytes(), 512);
+ assert_eq!(value.secret_reference().provider(), "keyring");
+ assert_eq!(value.secret_reference().key_version(), 3);
+ assert_eq!(
+ PrivateArtifactId::new([0; 16]),
+ Err(Error::InvalidPrivateArtifactId)
+ );
+ assert_eq!(
+ ArtifactKind::parse("Not Canonical"),
+ Err(Error::InvalidPrivateArtifactKind)
+ );
+}