1use serde::{Deserialize, Serialize};
2use tandem_enterprise_contract::DataClass;
3
4#[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#[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#[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}