Skip to main content

tandem_memory/
governance.rs

1use serde::{Deserialize, Serialize};
2use tandem_enterprise_contract::DataClass;
3
4/// Governance-facing tier model for scoped memory access.
5///
6/// Note: `team` and `curated` are included for policy/capability contracts
7/// before storage-layer migrations complete.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum GovernedMemoryTier {
11    Session,
12    Project,
13    Team,
14    Curated,
15}
16
17impl std::fmt::Display for GovernedMemoryTier {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            Self::Session => write!(f, "session"),
21            Self::Project => write!(f, "project"),
22            Self::Team => write!(f, "team"),
23            Self::Curated => write!(f, "curated"),
24        }
25    }
26}
27
28/// Hard partition for memory operations in corporate/LAN environments.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct MemoryPartition {
31    pub org_id: String,
32    pub workspace_id: String,
33    pub project_id: String,
34    pub tier: GovernedMemoryTier,
35}
36
37impl MemoryPartition {
38    pub fn key(&self) -> String {
39        format!(
40            "{}/{}/{}/{}",
41            self.org_id, self.workspace_id, self.project_id, self.tier
42        )
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum MemoryClassification {
49    Internal,
50    Restricted,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum MemoryAuthorityOperation {
56    Read,
57    Write,
58    Promote,
59    Export,
60    Decrypt,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct MemoryAuthorityJobContext {
65    pub org_id: String,
66    pub workspace_id: String,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub deployment_id: Option<String>,
69    pub project_id: String,
70    pub actor_id: String,
71    pub run_id: String,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub node_id: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub task_id: Option<String>,
76    pub purpose: String,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub source_binding_id: Option<String>,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub data_class: Option<DataClass>,
81    pub classification: MemoryClassification,
82    pub operation: MemoryAuthorityOperation,
83    #[serde(default)]
84    pub source_memory_ids: Vec<String>,
85    #[serde(default)]
86    pub artifact_refs: Vec<String>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub policy_decision_id: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub grant_decision_id: Option<String>,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum MemoryAuthorityJobContextError {
95    Missing,
96    TenantMismatch,
97    ProjectMismatch,
98    ActorMissing,
99    ActorMismatch,
100    RunMismatch,
101    PurposeMissing,
102    OperationMismatch,
103    ClassificationMismatch,
104    SourceMemoryMismatch,
105}
106
107impl MemoryAuthorityJobContextError {
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Self::Missing => "memory authority job context missing",
111            Self::TenantMismatch => "memory authority job tenant mismatch",
112            Self::ProjectMismatch => "memory authority job project mismatch",
113            Self::ActorMissing => "memory authority job actor missing",
114            Self::ActorMismatch => "memory authority job actor mismatch",
115            Self::RunMismatch => "memory authority job run mismatch",
116            Self::PurposeMissing => "memory authority job purpose missing",
117            Self::OperationMismatch => "memory authority job operation mismatch",
118            Self::ClassificationMismatch => "memory authority job classification mismatch",
119            Self::SourceMemoryMismatch => "memory authority job source memory mismatch",
120        }
121    }
122}
123
124#[derive(Debug, Clone, Copy)]
125pub struct MemoryAuthorityJobValidation<'a> {
126    pub context: Option<&'a MemoryAuthorityJobContext>,
127    pub require_context: bool,
128    pub org_id: &'a str,
129    pub workspace_id: &'a str,
130    pub deployment_id: Option<&'a str>,
131    pub actor_id: Option<&'a str>,
132    pub run_id: &'a str,
133    pub partition: &'a MemoryPartition,
134    pub operation: MemoryAuthorityOperation,
135    pub classification: Option<MemoryClassification>,
136    pub source_memory_id: Option<&'a str>,
137}
138
139pub fn validate_memory_authority_job_context(
140    validation: MemoryAuthorityJobValidation<'_>,
141) -> Result<(), MemoryAuthorityJobContextError> {
142    let MemoryAuthorityJobValidation {
143        context,
144        require_context,
145        org_id,
146        workspace_id,
147        deployment_id,
148        actor_id,
149        run_id,
150        partition,
151        operation,
152        classification,
153        source_memory_id,
154    } = validation;
155    let Some(context) = context else {
156        return if require_context {
157            Err(MemoryAuthorityJobContextError::Missing)
158        } else {
159            Ok(())
160        };
161    };
162
163    if context.org_id != org_id
164        || context.workspace_id != workspace_id
165        || context.org_id != partition.org_id
166        || context.workspace_id != partition.workspace_id
167        || context.deployment_id.as_deref() != deployment_id
168    {
169        return Err(MemoryAuthorityJobContextError::TenantMismatch);
170    }
171    if context.project_id != partition.project_id {
172        return Err(MemoryAuthorityJobContextError::ProjectMismatch);
173    }
174    if context.actor_id.trim().is_empty() {
175        return Err(MemoryAuthorityJobContextError::ActorMissing);
176    }
177    if actor_id.is_some_and(|expected| context.actor_id != expected) {
178        return Err(MemoryAuthorityJobContextError::ActorMismatch);
179    }
180    if context.run_id != run_id {
181        return Err(MemoryAuthorityJobContextError::RunMismatch);
182    }
183    if context.purpose.trim().is_empty() {
184        return Err(MemoryAuthorityJobContextError::PurposeMissing);
185    }
186    if context.operation != operation {
187        return Err(MemoryAuthorityJobContextError::OperationMismatch);
188    }
189    if classification.is_some_and(|expected| context.classification != expected) {
190        return Err(MemoryAuthorityJobContextError::ClassificationMismatch);
191    }
192    if let Some(source_memory_id) = source_memory_id {
193        if !context
194            .source_memory_ids
195            .iter()
196            .any(|candidate| candidate == source_memory_id)
197        {
198            return Err(MemoryAuthorityJobContextError::SourceMemoryMismatch);
199        }
200    }
201
202    Ok(())
203}
204
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct MemoryCapabilities {
207    #[serde(default)]
208    pub read_tiers: Vec<GovernedMemoryTier>,
209    #[serde(default)]
210    pub write_tiers: Vec<GovernedMemoryTier>,
211    #[serde(default)]
212    pub promote_targets: Vec<GovernedMemoryTier>,
213    #[serde(default = "default_require_review_for_promote")]
214    pub require_review_for_promote: bool,
215    #[serde(default)]
216    pub allow_auto_use_tiers: Vec<GovernedMemoryTier>,
217}
218
219fn default_require_review_for_promote() -> bool {
220    true
221}
222
223impl Default for MemoryCapabilities {
224    fn default() -> Self {
225        Self {
226            read_tiers: vec![GovernedMemoryTier::Session, GovernedMemoryTier::Project],
227            write_tiers: vec![GovernedMemoryTier::Session],
228            promote_targets: Vec::new(),
229            require_review_for_promote: true,
230            allow_auto_use_tiers: vec![GovernedMemoryTier::Curated],
231        }
232    }
233}
234
235/// Run-scoped capability token claims for memory access.
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct MemoryCapabilityToken {
238    pub run_id: String,
239    pub subject: String,
240    pub org_id: String,
241    pub workspace_id: String,
242    pub project_id: String,
243    pub memory: MemoryCapabilities,
244    pub expires_at: u64,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
248pub struct MemoryRetrievalBudgets {
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub max_queries_per_window: Option<u32>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub window_ms: Option<u64>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub max_top_k: Option<usize>,
255    #[serde(default, skip_serializing_if = "Option::is_none")]
256    pub max_tokens: Option<i64>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub max_chars: Option<usize>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub max_results_per_window: Option<u32>,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub max_tokens_per_window: Option<i64>,
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub max_chars_per_window: Option<usize>,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct MemoryRetrievalGrant {
269    pub grant_id: String,
270    pub subject: String,
271    pub org_id: String,
272    pub workspace_id: String,
273    #[serde(default)]
274    pub project_ids: Vec<String>,
275    #[serde(default)]
276    pub source_binding_ids: Vec<String>,
277    #[serde(default)]
278    pub source_object_ids: Vec<String>,
279    #[serde(default)]
280    pub data_classes: Vec<DataClass>,
281    #[serde(default)]
282    pub budgets: MemoryRetrievalBudgets,
283    #[serde(default)]
284    pub revoked: bool,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub expires_at: Option<u64>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct MemoryRetrievalGatewayRequest {
291    pub grant: MemoryRetrievalGrant,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub session_id: Option<String>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub channel: Option<String>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub user_id: Option<String>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301pub struct MemoryRetrievalBudgetWindow {
302    pub started_at_ms: u64,
303    pub query_count: u32,
304    pub result_count: u32,
305    pub token_count: i64,
306    pub char_count: usize,
307}
308
309#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310#[serde(rename_all = "snake_case")]
311pub enum MemoryContentKind {
312    SolutionCapsule,
313    Note,
314    Fact,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "snake_case")]
319pub enum MemoryTrustLabel {
320    ExternalUserSupplied,
321    ConnectorSourced,
322    Verified,
323    HumanApproved,
324    SystemGenerated,
325}
326
327impl MemoryTrustLabel {
328    pub fn as_str(self) -> &'static str {
329        match self {
330            Self::ExternalUserSupplied => "external_user_supplied",
331            Self::ConnectorSourced => "connector_sourced",
332            Self::Verified => "verified",
333            Self::HumanApproved => "human_approved",
334            Self::SystemGenerated => "system_generated",
335        }
336    }
337
338    pub fn is_trusted_for_promotion(self) -> bool {
339        matches!(
340            self,
341            Self::Verified | Self::HumanApproved | Self::SystemGenerated
342        )
343    }
344}
345
346#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub struct MemoryPutRequest {
348    pub run_id: String,
349    pub partition: MemoryPartition,
350    pub kind: MemoryContentKind,
351    pub content: String,
352    #[serde(default)]
353    pub artifact_refs: Vec<String>,
354    pub classification: MemoryClassification,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub authority_job_context: Option<MemoryAuthorityJobContext>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub metadata: Option<serde_json::Value>,
359}
360
361#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
362pub struct MemoryPutResponse {
363    pub id: String,
364    pub stored: bool,
365    pub tier: GovernedMemoryTier,
366    pub partition_key: String,
367    pub audit_id: String,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
371pub struct PromotionReview {
372    pub required: bool,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub reviewer_id: Option<String>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub approval_id: Option<String>,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
380pub struct PromotionSourceOutcome {
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub status: Option<String>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub approved: Option<bool>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub source_run_id: Option<String>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub approval_id: Option<String>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub policy_decision_id: Option<String>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub audit_id: Option<String>,
393}
394
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396pub struct MemoryPromoteRequest {
397    pub run_id: String,
398    pub source_memory_id: String,
399    pub from_tier: GovernedMemoryTier,
400    pub to_tier: GovernedMemoryTier,
401    pub partition: MemoryPartition,
402    pub reason: String,
403    pub review: PromotionReview,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub authority_job_context: Option<MemoryAuthorityJobContext>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub source_outcome: Option<PromotionSourceOutcome>,
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum ScrubStatus {
413    Passed,
414    Redacted,
415    Blocked,
416}
417
418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419pub struct ScrubReport {
420    pub status: ScrubStatus,
421    pub redactions: u32,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub block_reason: Option<String>,
424}
425
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427pub struct MemoryPromoteResponse {
428    pub promoted: bool,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub new_memory_id: Option<String>,
431    pub to_tier: GovernedMemoryTier,
432    pub scrub_report: ScrubReport,
433    pub audit_id: String,
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub policy_decision_id: Option<String>,
436}
437
438#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
439pub struct MemorySearchRequest {
440    pub run_id: String,
441    pub query: String,
442    #[serde(default)]
443    pub read_scopes: Vec<GovernedMemoryTier>,
444    pub partition: MemoryPartition,
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub limit: Option<i64>,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub authority_job_context: Option<MemoryAuthorityJobContext>,
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub retrieval_gateway: Option<MemoryRetrievalGatewayRequest>,
451}
452
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454pub struct MemorySearchResponse {
455    #[serde(default)]
456    pub results: Vec<serde_json::Value>,
457    #[serde(default)]
458    pub scopes_used: Vec<GovernedMemoryTier>,
459    #[serde(default)]
460    pub blocked_scopes: Vec<GovernedMemoryTier>,
461    pub audit_id: String,
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn default_capabilities_are_fail_safe() {
470        let caps = MemoryCapabilities::default();
471        assert_eq!(
472            caps.read_tiers,
473            vec![GovernedMemoryTier::Session, GovernedMemoryTier::Project]
474        );
475        assert_eq!(caps.write_tiers, vec![GovernedMemoryTier::Session]);
476        assert!(caps.promote_targets.is_empty());
477        assert!(caps.require_review_for_promote);
478        assert_eq!(caps.allow_auto_use_tiers, vec![GovernedMemoryTier::Curated]);
479    }
480
481    #[test]
482    fn partition_key_is_stable() {
483        let partition = MemoryPartition {
484            org_id: "org_acme".to_string(),
485            workspace_id: "ws_tandem".to_string(),
486            project_id: "proj_engine".to_string(),
487            tier: GovernedMemoryTier::Project,
488        };
489        assert_eq!(
490            partition.key(),
491            "org_acme/ws_tandem/proj_engine/project".to_string()
492        );
493    }
494
495    fn authority_context() -> (MemoryPartition, MemoryAuthorityJobContext) {
496        let partition = MemoryPartition {
497            org_id: "org-1".to_string(),
498            workspace_id: "ws-1".to_string(),
499            project_id: "proj-1".to_string(),
500            tier: GovernedMemoryTier::Project,
501        };
502        let context = MemoryAuthorityJobContext {
503            org_id: partition.org_id.clone(),
504            workspace_id: partition.workspace_id.clone(),
505            deployment_id: Some("deploy-1".to_string()),
506            project_id: partition.project_id.clone(),
507            actor_id: "reviewer-1".to_string(),
508            run_id: "run-1".to_string(),
509            node_id: Some("node-1".to_string()),
510            task_id: Some("task-1".to_string()),
511            purpose: "promote approved memory".to_string(),
512            source_binding_id: Some("workflow:wf-1".to_string()),
513            data_class: Some(DataClass::Internal),
514            classification: MemoryClassification::Internal,
515            operation: MemoryAuthorityOperation::Promote,
516            source_memory_ids: vec!["mem-1".to_string()],
517            artifact_refs: vec!["artifact://run-1/task-1".to_string()],
518            policy_decision_id: Some("policy-1".to_string()),
519            grant_decision_id: Some("grant-1".to_string()),
520        };
521        (partition, context)
522    }
523
524    #[test]
525    fn authority_context_revalidates_execution_scope() {
526        let (partition, context) = authority_context();
527
528        let result = validate_memory_authority_job_context(MemoryAuthorityJobValidation {
529            context: Some(&context),
530            require_context: true,
531            org_id: "org-1",
532            workspace_id: "ws-1",
533            deployment_id: Some("deploy-1"),
534            actor_id: Some("reviewer-1"),
535            run_id: "run-1",
536            partition: &partition,
537            operation: MemoryAuthorityOperation::Promote,
538            classification: Some(MemoryClassification::Internal),
539            source_memory_id: Some("mem-1"),
540        });
541
542        assert_eq!(result, Ok(()));
543    }
544
545    #[test]
546    fn authority_context_fails_closed_for_tampered_scope() {
547        let (partition, mut context) = authority_context();
548        context.operation = MemoryAuthorityOperation::Write;
549
550        let result = validate_memory_authority_job_context(MemoryAuthorityJobValidation {
551            context: Some(&context),
552            require_context: true,
553            org_id: "org-1",
554            workspace_id: "ws-1",
555            deployment_id: Some("deploy-1"),
556            actor_id: Some("reviewer-1"),
557            run_id: "run-1",
558            partition: &partition,
559            operation: MemoryAuthorityOperation::Promote,
560            classification: Some(MemoryClassification::Internal),
561            source_memory_id: Some("mem-1"),
562        });
563
564        assert_eq!(
565            result,
566            Err(MemoryAuthorityJobContextError::OperationMismatch)
567        );
568        assert_eq!(
569            MemoryAuthorityJobContextError::OperationMismatch.as_str(),
570            "memory authority job operation mismatch"
571        );
572    }
573
574    #[test]
575    fn missing_authority_context_can_be_required() {
576        let (partition, _) = authority_context();
577
578        let result = validate_memory_authority_job_context(MemoryAuthorityJobValidation {
579            context: None,
580            require_context: true,
581            org_id: "org-1",
582            workspace_id: "ws-1",
583            deployment_id: Some("deploy-1"),
584            actor_id: Some("reviewer-1"),
585            run_id: "run-1",
586            partition: &partition,
587            operation: MemoryAuthorityOperation::Promote,
588            classification: None,
589            source_memory_id: None,
590        });
591
592        assert_eq!(result, Err(MemoryAuthorityJobContextError::Missing));
593    }
594}