use std::fmt;
use serde::{Deserialize, Serialize};
use crate::content::MemoryContent;
use crate::error::HirnError;
use crate::id::next_monotonic_ulid;
use crate::metadata::{Metadata, MetadataValue};
use crate::revision::{RevisionOperation, RevisionState};
use crate::timestamp::Timestamp;
use crate::types::{AgentId, Namespace};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ResourceId(ulid::Ulid);
impl ResourceId {
#[must_use]
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self(next_monotonic_ulid())
}
#[must_use]
pub const fn from_ulid(ulid: ulid::Ulid) -> Self {
Self(ulid)
}
#[must_use]
pub const fn as_ulid(&self) -> ulid::Ulid {
self.0
}
pub fn parse(s: &str) -> Result<Self, crate::HirnError> {
ulid::Ulid::from_string(s)
.map(Self)
.map_err(|e| crate::HirnError::InvalidInput(format!("invalid resource id '{s}': {e}")))
}
}
impl fmt::Display for ResourceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct LogicalResourceId(ulid::Ulid);
impl LogicalResourceId {
#[must_use]
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self(next_monotonic_ulid())
}
#[must_use]
pub const fn from_resource_id(id: ResourceId) -> Self {
Self(id.as_ulid())
}
pub fn parse(s: &str) -> Result<Self, crate::HirnError> {
ulid::Ulid::from_string(s).map(Self).map_err(|e| {
crate::HirnError::InvalidInput(format!("invalid logical resource id '{s}': {e}"))
})
}
}
impl fmt::Display for LogicalResourceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ResourceRevisionId(ulid::Ulid);
impl ResourceRevisionId {
#[must_use]
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self(next_monotonic_ulid())
}
#[must_use]
pub const fn from_resource_id(id: ResourceId) -> Self {
Self(id.as_ulid())
}
pub fn parse(s: &str) -> Result<Self, crate::HirnError> {
ulid::Ulid::from_string(s).map(Self).map_err(|e| {
crate::HirnError::InvalidInput(format!("invalid resource revision id '{s}': {e}"))
})
}
}
impl fmt::Display for ResourceRevisionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct DerivedArtifactId(ulid::Ulid);
impl DerivedArtifactId {
#[must_use]
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self(next_monotonic_ulid())
}
pub fn parse(s: &str) -> Result<Self, crate::HirnError> {
ulid::Ulid::from_string(s).map(Self).map_err(|e| {
crate::HirnError::InvalidInput(format!("invalid derived artifact id '{s}': {e}"))
})
}
}
impl fmt::Display for DerivedArtifactId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum ModalityProfile {
#[default]
Text,
Image,
Audio,
Code,
Structured,
Document,
Video,
Composite,
External,
}
impl ModalityProfile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Image => "image",
Self::Audio => "audio",
Self::Code => "code",
Self::Structured => "structured",
Self::Document => "document",
Self::Video => "video",
Self::Composite => "composite",
Self::External => "external",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"text" => Ok(Self::Text),
"image" => Ok(Self::Image),
"audio" => Ok(Self::Audio),
"code" => Ok(Self::Code),
"structured" => Ok(Self::Structured),
"document" => Ok(Self::Document),
"video" => Ok(Self::Video),
"composite" => Ok(Self::Composite),
"external" => Ok(Self::External),
_ => Err(HirnError::InvalidInput(format!(
"unknown modality profile: {value}"
))),
}
}
}
impl fmt::Display for ModalityProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl From<&MemoryContent> for ModalityProfile {
fn from(value: &MemoryContent) -> Self {
value.modality_profile()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum HydrationMode {
#[default]
MetadataOnly,
Preview,
Full,
}
impl HydrationMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MetadataOnly => "metadata_only",
Self::Preview => "preview",
Self::Full => "full",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"metadata" | "metadata_only" => Ok(Self::MetadataOnly),
"preview" => Ok(Self::Preview),
"full" => Ok(Self::Full),
_ => Err(HirnError::InvalidInput(format!(
"unknown hydration mode: {value}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResourceLocation {
Inline,
Blob { blob_index: u32 },
External { uri: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum EvidenceRole {
#[default]
Source,
Attachment,
Proof,
Output,
Preview,
Derived,
}
impl EvidenceRole {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Source => "source",
Self::Attachment => "attachment",
Self::Proof => "proof",
Self::Output => "output",
Self::Preview => "preview",
Self::Derived => "derived",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"source" => Ok(Self::Source),
"attachment" => Ok(Self::Attachment),
"proof" => Ok(Self::Proof),
"output" => Ok(Self::Output),
"preview" => Ok(Self::Preview),
"derived" => Ok(Self::Derived),
_ => Err(HirnError::InvalidInput(format!(
"unknown evidence role: {value}"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum EvidenceProvenance {
#[default]
ObservedResource,
GeneratedArtifact,
TransformedSummary,
}
impl EvidenceProvenance {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ObservedResource => "observed_resource",
Self::GeneratedArtifact => "generated_artifact",
Self::TransformedSummary => "transformed_summary",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"observed_resource" => Ok(Self::ObservedResource),
"generated_artifact" => Ok(Self::GeneratedArtifact),
"transformed_summary" => Ok(Self::TransformedSummary),
_ => Err(HirnError::InvalidInput(format!(
"unknown evidence provenance: {value}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EvidenceLink {
pub resource_id: ResourceId,
pub artifact_id: Option<DerivedArtifactId>,
pub role: EvidenceRole,
#[serde(default)]
pub provenance: EvidenceProvenance,
#[serde(default)]
pub part_index: Option<u32>,
pub description: Option<String>,
}
impl EvidenceLink {
#[must_use]
pub const fn new(resource_id: ResourceId, role: EvidenceRole) -> Self {
Self {
resource_id,
artifact_id: None,
role,
provenance: EvidenceProvenance::ObservedResource,
part_index: None,
description: None,
}
}
#[must_use]
pub const fn with_artifact(mut self, artifact_id: DerivedArtifactId) -> Self {
self.artifact_id = Some(artifact_id);
self
}
#[must_use]
pub const fn with_provenance(mut self, provenance: EvidenceProvenance) -> Self {
self.provenance = provenance;
self
}
#[must_use]
pub const fn with_part_index(mut self, part_index: u32) -> Self {
self.part_index = Some(part_index);
self
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum DerivedArtifactKind {
#[default]
Preview,
OcrText,
Transcript,
Caption,
Thumbnail,
SyntaxSummary,
SchemaSummary,
GenerationFailure,
}
impl DerivedArtifactKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Preview => "preview",
Self::OcrText => "ocr_text",
Self::Transcript => "transcript",
Self::Caption => "caption",
Self::Thumbnail => "thumbnail",
Self::SyntaxSummary => "syntax_summary",
Self::SchemaSummary => "schema_summary",
Self::GenerationFailure => "generation_failure",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"preview" => Ok(Self::Preview),
"ocr_text" => Ok(Self::OcrText),
"transcript" => Ok(Self::Transcript),
"caption" => Ok(Self::Caption),
"thumbnail" => Ok(Self::Thumbnail),
"syntax_summary" => Ok(Self::SyntaxSummary),
"schema_summary" => Ok(Self::SchemaSummary),
"generation_failure" => Ok(Self::GenerationFailure),
_ => Err(HirnError::InvalidInput(format!(
"unknown derived artifact kind: {value}"
))),
}
}
#[must_use]
pub const fn is_previewable(self) -> bool {
matches!(
self,
Self::Preview
| Self::OcrText
| Self::Transcript
| Self::Caption
| Self::Thumbnail
| Self::SyntaxSummary
| Self::SchemaSummary
)
}
#[must_use]
pub const fn evidence_provenance(self) -> EvidenceProvenance {
match self {
Self::OcrText | Self::Transcript | Self::Thumbnail | Self::GenerationFailure => {
EvidenceProvenance::GeneratedArtifact
}
Self::Preview | Self::Caption | Self::SyntaxSummary | Self::SchemaSummary => {
EvidenceProvenance::TransformedSummary
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum ResourceGovernanceState {
#[default]
Active,
Redacted,
Purged,
}
impl ResourceGovernanceState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Redacted => "redacted",
Self::Purged => "purged",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"active" => Ok(Self::Active),
"redacted" => Ok(Self::Redacted),
"purged" => Ok(Self::Purged),
_ => Err(HirnError::InvalidInput(format!(
"unknown resource governance state: {value}"
))),
}
}
#[must_use]
pub const fn hides_payload(self) -> bool {
!matches!(self, Self::Active)
}
#[must_use]
pub const fn placeholder_display_name(self) -> &'static str {
match self {
Self::Active => "resource",
Self::Redacted => "redacted resource",
Self::Purged => "purged resource",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum ResourceRetentionAction {
#[default]
Redact,
Purge,
}
impl ResourceRetentionAction {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Redact => "redact",
Self::Purge => "purge",
}
}
pub fn parse(value: &str) -> Result<Self, HirnError> {
match value {
"redact" => Ok(Self::Redact),
"purge" => Ok(Self::Purge),
_ => Err(HirnError::InvalidInput(format!(
"unknown resource retention action: {value}"
))),
}
}
#[must_use]
pub const fn severity(self) -> u8 {
match self {
Self::Redact => 1,
Self::Purge => 2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ResourceRetentionRule {
pub action: ResourceRetentionAction,
pub namespaces: Vec<Namespace>,
pub modalities: Vec<ModalityProfile>,
pub classifications: Vec<String>,
}
impl ResourceRetentionRule {
#[must_use]
pub const fn new(action: ResourceRetentionAction) -> Self {
Self {
action,
namespaces: Vec::new(),
modalities: Vec::new(),
classifications: Vec::new(),
}
}
#[must_use]
pub fn namespace(mut self, namespace: Namespace) -> Self {
self.namespaces.push(namespace);
self
}
#[must_use]
pub fn modality(mut self, modality: ModalityProfile) -> Self {
if !self.modalities.contains(&modality) {
self.modalities.push(modality);
}
self
}
#[must_use]
pub fn classification(mut self, classification: impl Into<String>) -> Self {
let classification = classification.into().trim().to_string();
if !classification.is_empty()
&& !self
.classifications
.iter()
.any(|existing| existing.eq_ignore_ascii_case(&classification))
{
self.classifications.push(classification);
}
self
}
pub fn validate(&self) -> Result<(), HirnError> {
if self.namespaces.is_empty()
&& self.modalities.is_empty()
&& self.classifications.is_empty()
{
return Err(HirnError::InvalidInput(
"resource retention rule must target at least one namespace, modality, or classification"
.into(),
));
}
if self
.classifications
.iter()
.any(|classification| classification.trim().is_empty())
{
return Err(HirnError::InvalidInput(
"resource retention classifications must be non-empty".into(),
));
}
Ok(())
}
#[must_use]
pub fn matches(&self, resource: &ResourceObject) -> bool {
let namespace_match =
self.namespaces.is_empty() || self.namespaces.contains(&resource.namespace);
let modality_match =
self.modalities.is_empty() || self.modalities.contains(&resource.modality);
let classification_match = if self.classifications.is_empty() {
true
} else {
resource.classification().is_some_and(|classification| {
self.classifications
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(classification))
})
};
namespace_match && modality_match && classification_match
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ResourceRetentionPolicy {
pub rules: Vec<ResourceRetentionRule>,
}
impl ResourceRetentionPolicy {
#[must_use]
pub fn with_rule(mut self, rule: ResourceRetentionRule) -> Self {
self.rules.push(rule);
self
}
pub fn validate(&self) -> Result<(), HirnError> {
for rule in &self.rules {
rule.validate()?;
}
Ok(())
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[must_use]
pub fn strongest_action_for(
&self,
resource: &ResourceObject,
) -> Option<ResourceRetentionAction> {
self.rules
.iter()
.filter(|rule| rule.matches(resource))
.map(|rule| rule.action)
.max_by_key(|action| action.severity())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ResourceQuotaScope {
Realm,
Namespace(Namespace),
Agent(AgentId),
}
impl ResourceQuotaScope {
#[must_use]
pub fn matches(&self, resource: &ResourceObject) -> bool {
match self {
Self::Realm => true,
Self::Namespace(namespace) => *namespace == resource.namespace,
Self::Agent(agent_id) => {
matches!(resource.owner_agent_id, Some(owner) if owner == *agent_id)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResourceQuotaRule {
pub scope: ResourceQuotaScope,
pub max_active_resources: Option<usize>,
pub max_total_bytes: Option<u64>,
}
impl ResourceQuotaRule {
#[must_use]
pub const fn new(scope: ResourceQuotaScope) -> Self {
Self {
scope,
max_active_resources: None,
max_total_bytes: None,
}
}
#[must_use]
pub const fn max_active_resources(mut self, max_active_resources: usize) -> Self {
self.max_active_resources = Some(max_active_resources);
self
}
#[must_use]
pub const fn max_total_bytes(mut self, max_total_bytes: u64) -> Self {
self.max_total_bytes = Some(max_total_bytes);
self
}
pub fn validate(&self) -> Result<(), HirnError> {
if self.max_active_resources.is_none() && self.max_total_bytes.is_none() {
return Err(HirnError::InvalidInput(
"resource quota rule must configure max_active_resources or max_total_bytes".into(),
));
}
if self.max_active_resources == Some(0) {
return Err(HirnError::InvalidInput(
"resource quota max_active_resources must be > 0".into(),
));
}
if self.max_total_bytes == Some(0) {
return Err(HirnError::InvalidInput(
"resource quota max_total_bytes must be > 0".into(),
));
}
Ok(())
}
#[must_use]
pub fn matches(&self, resource: &ResourceObject) -> bool {
self.scope.matches(resource)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ResourceQuotaPolicy {
pub rules: Vec<ResourceQuotaRule>,
}
impl ResourceQuotaPolicy {
#[must_use]
pub fn with_rule(mut self, rule: ResourceQuotaRule) -> Self {
self.rules.push(rule);
self
}
pub fn validate(&self) -> Result<(), HirnError> {
for rule in &self.rules {
rule.validate()?;
}
Ok(())
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn rules_for<'a>(
&'a self,
resource: &'a ResourceObject,
) -> impl Iterator<Item = &'a ResourceQuotaRule> + 'a {
self.rules.iter().filter(|rule| rule.matches(resource))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SecondaryIndexType {
#[default]
BTree,
Bitmap,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ResourceIndexRule {
pub modality: ModalityProfile,
pub index_type: SecondaryIndexType,
pub columns: Vec<String>,
}
impl ResourceIndexRule {
#[must_use]
pub const fn new(modality: ModalityProfile, index_type: SecondaryIndexType) -> Self {
Self {
modality,
index_type,
columns: Vec::new(),
}
}
#[must_use]
pub fn with_column(mut self, column: impl Into<String>) -> Self {
self.columns.push(column.into());
self
}
pub fn validate(&self) -> Result<(), HirnError> {
for column in &self.columns {
if column.trim().is_empty() {
return Err(HirnError::InvalidConfig {
field: "resource_index_policy.columns".into(),
value: column.clone(),
reason: "index column names must be non-empty".into(),
});
}
if !matches!(
column.as_str(),
"logical_resource_id"
| "revision_id"
| "mime_type"
| "display_name"
| "checksum"
| "size_bytes"
| "owner_agent_id"
| "governance_state"
| "namespace"
| "created_at_ms"
| "updated_at_ms"
) {
return Err(HirnError::InvalidConfig {
field: "resource_index_policy.columns".into(),
value: column.clone(),
reason: "unsupported resources index column".into(),
});
}
}
Ok(())
}
#[must_use]
pub fn scoped_columns(&self) -> Vec<String> {
let mut columns = vec!["modality".to_string()];
for column in &self.columns {
if column != "modality" && !columns.contains(column) {
columns.push(column.clone());
}
}
columns
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ResourceIndexPolicy {
pub rules: Vec<ResourceIndexRule>,
}
impl ResourceIndexPolicy {
#[must_use]
pub fn with_rule(mut self, rule: ResourceIndexRule) -> Self {
self.rules.push(rule);
self
}
pub fn validate(&self) -> Result<(), HirnError> {
for rule in &self.rules {
rule.validate()?;
}
Ok(())
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct DerivedArtifactIndexRule {
pub kind: DerivedArtifactKind,
pub index_type: SecondaryIndexType,
pub columns: Vec<String>,
}
impl DerivedArtifactIndexRule {
#[must_use]
pub const fn new(kind: DerivedArtifactKind, index_type: SecondaryIndexType) -> Self {
Self {
kind,
index_type,
columns: Vec::new(),
}
}
#[must_use]
pub fn with_column(mut self, column: impl Into<String>) -> Self {
self.columns.push(column.into());
self
}
pub fn validate(&self) -> Result<(), HirnError> {
for column in &self.columns {
if column.trim().is_empty() {
return Err(HirnError::InvalidConfig {
field: "derived_artifact_index_policy.columns".into(),
value: column.clone(),
reason: "index column names must be non-empty".into(),
});
}
if !matches!(
column.as_str(),
"resource_id"
| "modality"
| "mime_type"
| "checksum"
| "namespace"
| "created_at_ms"
) {
return Err(HirnError::InvalidConfig {
field: "derived_artifact_index_policy.columns".into(),
value: column.clone(),
reason: "unsupported derived_artifacts index column".into(),
});
}
}
Ok(())
}
#[must_use]
pub fn scoped_columns(&self) -> Vec<String> {
let mut columns = vec!["kind".to_string()];
for column in &self.columns {
if column != "kind" && !columns.contains(column) {
columns.push(column.clone());
}
}
columns
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct DerivedArtifactIndexPolicy {
pub rules: Vec<DerivedArtifactIndexRule>,
}
impl DerivedArtifactIndexPolicy {
#[must_use]
pub fn with_rule(mut self, rule: DerivedArtifactIndexRule) -> Self {
self.rules.push(rule);
self
}
pub fn validate(&self) -> Result<(), HirnError> {
for rule in &self.rules {
rule.validate()?;
}
Ok(())
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.rules.is_empty()
}
}
const fn resource_storage_ready_default() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceObject {
pub id: ResourceId,
pub logical_resource_id: LogicalResourceId,
pub revision_id: ResourceRevisionId,
pub version: u32,
pub revision_operation: RevisionOperation,
pub revision_reason: Option<String>,
pub revision_causation_id: Option<ResourceId>,
pub superseded_by: Option<ResourceId>,
pub modality: ModalityProfile,
pub mime_type: Option<String>,
pub display_name: Option<String>,
pub checksum: Option<String>,
pub size_bytes: u64,
pub location: ResourceLocation,
pub metadata: Metadata,
#[serde(default = "resource_storage_ready_default")]
pub storage_ready: bool,
pub owner_agent_id: Option<AgentId>,
pub governance_state: ResourceGovernanceState,
pub governance_reason: Option<String>,
pub governed_at: Option<Timestamp>,
pub namespace: Namespace,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
impl ResourceObject {
#[must_use]
pub fn builder() -> ResourceObjectBuilder {
ResourceObjectBuilder::default()
}
#[must_use]
pub const fn is_live(&self) -> bool {
self.superseded_by.is_none()
}
#[must_use]
pub const fn is_storage_ready(&self) -> bool {
self.storage_ready
}
#[must_use]
pub fn revision_state_against(&self, head: &Self) -> RevisionState {
if self.revision_id == head.revision_id {
RevisionState::Active
} else {
RevisionState::Superseded
}
}
#[must_use]
pub fn classification(&self) -> Option<&str> {
match self.metadata.get("classification") {
Some(MetadataValue::String(value)) => Some(value.as_str()),
_ => None,
}
}
#[must_use]
pub const fn owner_agent_id(&self) -> Option<AgentId> {
self.owner_agent_id
}
}
#[derive(Debug, Default)]
pub struct ResourceObjectBuilder {
modality: Option<ModalityProfile>,
mime_type: Option<String>,
display_name: Option<String>,
checksum: Option<String>,
size_bytes: Option<u64>,
location: Option<ResourceLocation>,
metadata: Metadata,
owner_agent_id: Option<AgentId>,
namespace: Option<Namespace>,
}
impl ResourceObjectBuilder {
#[must_use]
pub const fn modality(mut self, modality: ModalityProfile) -> Self {
self.modality = Some(modality);
self
}
#[must_use]
pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
#[must_use]
pub fn display_name(mut self, display_name: impl Into<String>) -> Self {
self.display_name = Some(display_name.into());
self
}
#[must_use]
pub fn checksum(mut self, checksum: impl Into<String>) -> Self {
self.checksum = Some(checksum.into());
self
}
#[must_use]
pub const fn size_bytes(mut self, size_bytes: u64) -> Self {
self.size_bytes = Some(size_bytes);
self
}
#[must_use]
pub fn location(mut self, location: ResourceLocation) -> Self {
self.location = Some(location);
self
}
#[must_use]
pub fn metadata_entry(
mut self,
key: impl Into<String>,
value: impl Into<MetadataValue>,
) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
#[must_use]
pub const fn owner_agent_id(mut self, owner_agent_id: AgentId) -> Self {
self.owner_agent_id = Some(owner_agent_id);
self
}
#[must_use]
pub fn namespace(mut self, namespace: Namespace) -> Self {
self.namespace = Some(namespace);
self
}
pub fn build(self) -> Result<ResourceObject, HirnError> {
let modality = self
.modality
.ok_or_else(|| HirnError::InvalidInput("resource modality is required".into()))?;
let location = self
.location
.ok_or_else(|| HirnError::InvalidInput("resource location is required".into()))?;
if let ResourceLocation::External { uri } = &location
&& uri.trim().is_empty()
{
return Err(HirnError::InvalidInput(
"resource external URI must be non-empty".into(),
));
}
let now = Timestamp::now();
let id = ResourceId::new();
Ok(ResourceObject {
id,
logical_resource_id: LogicalResourceId::from_resource_id(id),
revision_id: ResourceRevisionId::from_resource_id(id),
version: 1,
revision_operation: RevisionOperation::Create,
revision_reason: None,
revision_causation_id: None,
superseded_by: None,
modality,
mime_type: self.mime_type,
display_name: self.display_name,
checksum: self.checksum,
size_bytes: self.size_bytes.unwrap_or(0),
location,
metadata: self.metadata,
storage_ready: resource_storage_ready_default(),
owner_agent_id: self.owner_agent_id,
governance_state: ResourceGovernanceState::Active,
governance_reason: None,
governed_at: None,
namespace: self.namespace.unwrap_or_default(),
created_at: now,
updated_at: now,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DerivedArtifact {
pub id: DerivedArtifactId,
pub resource_id: ResourceId,
pub kind: DerivedArtifactKind,
pub modality: ModalityProfile,
pub mime_type: Option<String>,
pub text_content: Option<String>,
pub blob_index: Option<u32>,
pub checksum: Option<String>,
pub metadata: Metadata,
pub namespace: Namespace,
pub created_at: Timestamp,
}
impl DerivedArtifact {
#[must_use]
pub fn builder() -> DerivedArtifactBuilder {
DerivedArtifactBuilder::default()
}
}
#[derive(Debug, Default)]
pub struct DerivedArtifactBuilder {
resource_id: Option<ResourceId>,
kind: Option<DerivedArtifactKind>,
modality: Option<ModalityProfile>,
mime_type: Option<String>,
text_content: Option<String>,
blob_index: Option<u32>,
checksum: Option<String>,
metadata: Metadata,
namespace: Option<Namespace>,
}
impl DerivedArtifactBuilder {
#[must_use]
pub const fn resource_id(mut self, resource_id: ResourceId) -> Self {
self.resource_id = Some(resource_id);
self
}
#[must_use]
pub const fn kind(mut self, kind: DerivedArtifactKind) -> Self {
self.kind = Some(kind);
self
}
#[must_use]
pub const fn modality(mut self, modality: ModalityProfile) -> Self {
self.modality = Some(modality);
self
}
#[must_use]
pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
#[must_use]
pub fn text_content(mut self, text_content: impl Into<String>) -> Self {
self.text_content = Some(text_content.into());
self
}
#[must_use]
pub const fn blob_index(mut self, blob_index: u32) -> Self {
self.blob_index = Some(blob_index);
self
}
#[must_use]
pub fn checksum(mut self, checksum: impl Into<String>) -> Self {
self.checksum = Some(checksum.into());
self
}
#[must_use]
pub fn metadata_entry(
mut self,
key: impl Into<String>,
value: impl Into<MetadataValue>,
) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
#[must_use]
pub fn namespace(mut self, namespace: Namespace) -> Self {
self.namespace = Some(namespace);
self
}
pub fn build(self) -> Result<DerivedArtifact, HirnError> {
let resource_id = self
.resource_id
.ok_or_else(|| HirnError::InvalidInput("artifact resource_id is required".into()))?;
let kind = self
.kind
.ok_or_else(|| HirnError::InvalidInput("artifact kind is required".into()))?;
let modality = self
.modality
.ok_or_else(|| HirnError::InvalidInput("artifact modality is required".into()))?;
if self.text_content.is_none() && self.blob_index.is_none() {
return Err(HirnError::InvalidInput(
"artifact requires text_content or blob_index".into(),
));
}
Ok(DerivedArtifact {
id: DerivedArtifactId::new(),
resource_id,
kind,
modality,
mime_type: self.mime_type,
text_content: self.text_content,
blob_index: self.blob_index,
checksum: self.checksum,
metadata: self.metadata,
namespace: self.namespace.unwrap_or_default(),
created_at: Timestamp::now(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_id_round_trip() {
let id = ResourceId::new();
let parsed = ResourceId::parse(&id.to_string()).unwrap();
assert_eq!(id, parsed);
}
#[test]
fn modality_profile_from_memory_content() {
let content = MemoryContent::Image {
data: vec![1, 2, 3],
mime_type: "image/png".into(),
description: "preview".into(),
};
assert_eq!(ModalityProfile::from(&content), ModalityProfile::Image);
}
#[test]
fn resource_quota_policy_matches_agent_and_namespace_scopes() {
let agent = AgentId::well_known("quota-agent");
let namespace = Namespace::new("quota-ns").unwrap();
let resource = ResourceObject::builder()
.modality(ModalityProfile::Document)
.location(ResourceLocation::Inline)
.namespace(namespace)
.owner_agent_id(agent)
.build()
.unwrap();
let policy = ResourceQuotaPolicy::default()
.with_rule(
ResourceQuotaRule::new(ResourceQuotaScope::Agent(agent)).max_active_resources(2),
)
.with_rule(
ResourceQuotaRule::new(ResourceQuotaScope::Namespace(namespace))
.max_total_bytes(1024),
);
let matched = policy.rules_for(&resource).collect::<Vec<_>>();
assert_eq!(matched.len(), 2);
assert!(
matched
.iter()
.any(|rule| matches!(rule.scope, ResourceQuotaScope::Agent(_)))
);
assert!(
matched
.iter()
.any(|rule| matches!(rule.scope, ResourceQuotaScope::Namespace(_)))
);
}
#[test]
fn resource_quota_rule_requires_a_limit() {
let rule = ResourceQuotaRule::new(ResourceQuotaScope::Realm);
assert!(rule.validate().is_err());
}
#[test]
fn build_resource_object() {
let resource = ResourceObject::builder()
.modality(ModalityProfile::Document)
.mime_type("application/pdf")
.display_name("design-doc.pdf")
.checksum("blake3:abc")
.size_bytes(2048)
.location(ResourceLocation::External {
uri: "https://example.invalid/design-doc.pdf".into(),
})
.build()
.unwrap();
assert_eq!(resource.version, 1);
assert_eq!(resource.modality, ModalityProfile::Document);
assert!(resource.is_live());
assert!(resource.is_storage_ready());
}
#[test]
fn build_derived_artifact_requires_payload() {
let resource_id = ResourceId::new();
let result = DerivedArtifact::builder()
.resource_id(resource_id)
.kind(DerivedArtifactKind::Caption)
.modality(ModalityProfile::Text)
.build();
assert!(result.is_err());
}
#[test]
fn evidence_link_keeps_artifact_reference() {
let resource_id = ResourceId::new();
let artifact_id = DerivedArtifactId::new();
let link = EvidenceLink::new(resource_id, EvidenceRole::Preview)
.with_artifact(artifact_id)
.with_provenance(EvidenceProvenance::TransformedSummary)
.with_part_index(2)
.with_description("thumbnail");
assert_eq!(link.resource_id, resource_id);
assert_eq!(link.artifact_id, Some(artifact_id));
assert_eq!(link.role, EvidenceRole::Preview);
assert_eq!(link.provenance, EvidenceProvenance::TransformedSummary);
assert_eq!(link.part_index, Some(2));
}
#[test]
fn derived_artifact_kind_maps_to_provenance_class() {
assert_eq!(
DerivedArtifactKind::OcrText.evidence_provenance(),
EvidenceProvenance::GeneratedArtifact
);
assert_eq!(
DerivedArtifactKind::Caption.evidence_provenance(),
EvidenceProvenance::TransformedSummary
);
assert_eq!(
DerivedArtifactKind::Preview.evidence_provenance(),
EvidenceProvenance::TransformedSummary
);
}
#[test]
fn resource_index_policy_rejects_unknown_columns() {
let policy = ResourceIndexPolicy::default().with_rule(
ResourceIndexRule::new(ModalityProfile::Document, SecondaryIndexType::BTree)
.with_column("unsupported"),
);
assert!(policy.validate().is_err());
}
#[test]
fn derived_artifact_index_rule_scopes_columns_by_kind() {
let rule = DerivedArtifactIndexRule::new(
DerivedArtifactKind::Transcript,
SecondaryIndexType::Bitmap,
)
.with_column("modality")
.with_column("namespace");
assert_eq!(rule.scoped_columns(), vec!["kind", "modality", "namespace"]);
}
}