1use std::collections::{HashMap, HashSet};
16
17use async_trait::async_trait;
18use serde::{Deserialize, Serialize};
19
20use super::records::{
21 MemoryAuthor, MemoryId, MemoryScope, NewMemoryRecord, RecordStatus, TrustTier, content_hash,
22 validate_record_fields,
23};
24use crate::identity_first::agent_memory::{AgentMemoryError, AgentMemoryProvider};
25
26pub const DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS: u64 = 7 * 24 * 60 * 60 * 1000;
30
31pub const MAX_BATCH_OPS: usize = 1024;
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "op", rename_all = "snake_case")]
39pub enum StagedOp {
40 Create {
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 id: Option<MemoryId>,
43 scope: MemoryScope,
44 record: NewMemoryRecord,
45 trust: TrustTier,
46 #[serde(default)]
47 derived_from: Vec<MemoryId>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 rationale: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 created_at_ms: Option<u64>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 updated_at_ms: Option<u64>,
55 },
56 Supersede {
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 id: Option<MemoryId>,
59 prior: MemoryId,
60 record: NewMemoryRecord,
61 trust: TrustTier,
62 #[serde(default)]
63 derived_from: Vec<MemoryId>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 rationale: Option<String>,
66 },
67 Tombstone {
68 id: MemoryId,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 rationale: Option<String>,
71 },
72 Retier {
73 id: MemoryId,
74 trust: TrustTier,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 rationale: Option<String>,
77 },
78 SetRank {
79 id: MemoryId,
80 rank: Option<u32>,
81 },
82}
83
84impl StagedOp {
85 pub fn kind_str(&self) -> &'static str {
86 match self {
87 Self::Create { .. } => "create",
88 Self::Supersede { .. } => "supersede",
89 Self::Tombstone { .. } => "tombstone",
90 Self::Retier { .. } => "retier",
91 Self::SetRank { .. } => "set_rank",
92 }
93 }
94}
95
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum StagedBatchKind {
106 #[default]
108 FreshWrite,
109 ReviewVerdict,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct StagedMutationBatch {
120 pub realm: String,
121 pub author: MemoryAuthor,
122 #[serde(default)]
127 pub kind: StagedBatchKind,
128 pub ops: Vec<StagedOp>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct StageToken {
134 pub realm: String,
135 pub token: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct CommitReceipt {
142 pub token: String,
143 pub applied_ops: usize,
144 pub memory_ids: Vec<MemoryId>,
145}
146
147#[async_trait]
152pub trait StagedMemoryStore: AgentMemoryProvider {
153 async fn stage(&self, batch: StagedMutationBatch) -> Result<StageToken, AgentMemoryError>;
156
157 async fn commit(&self, token: StageToken) -> Result<CommitReceipt, AgentMemoryError>;
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct StagedRecordView {
165 pub scope: MemoryScope,
166 pub trust: TrustTier,
167 pub status: RecordStatus,
168 pub supersedes: Option<MemoryId>,
169 pub derived_from: Vec<MemoryId>,
170 pub content_hash: String,
171 pub has_verification: bool,
172 pub ever_quarantined: bool,
179}
180
181pub trait StagedBatchView {
184 fn record(&self, id: &str) -> Option<StagedRecordView>;
185 fn tombstoned_at_ms(&self, scope: &MemoryScope, content_hash: &str) -> Option<u64>;
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum StagedBatchError {
191 EmptyBatch,
192 TooManyOps {
193 max: usize,
194 got: usize,
195 },
196 InvalidRecord {
197 op_index: usize,
198 reason: String,
199 },
200 SecretDetected {
204 op_index: usize,
205 class: &'static str,
206 },
207 RealmMismatch {
208 op_index: usize,
209 expected: String,
210 got: String,
211 },
212 ScopeNotWritable {
213 op_index: usize,
214 reason: String,
215 },
216 TierNotStagedAssignable {
217 op_index: usize,
218 tier: TrustTier,
219 },
220 TierAboveAuthorCeiling {
221 op_index: usize,
222 tier: TrustTier,
223 ceiling: TrustTier,
224 },
225 TransitiveTaintCeiling {
226 op_index: usize,
227 tier: TrustTier,
228 },
229 UnverifiedRetier {
230 op_index: usize,
231 },
232 UnresolvableEvidence {
237 op_index: usize,
238 reason: String,
239 },
240 UnknownRecord {
241 op_index: usize,
242 id: MemoryId,
243 },
244 RecordExists {
245 op_index: usize,
246 id: MemoryId,
247 },
248 NotActive {
249 op_index: usize,
250 id: MemoryId,
251 },
252 AlreadyTombstoned {
253 op_index: usize,
254 id: MemoryId,
255 },
256 SupersedeCycle {
257 op_index: usize,
258 id: MemoryId,
259 },
260 TombstoneRecreation {
261 op_index: usize,
262 content_hash: String,
263 },
264}
265
266impl std::fmt::Display for StagedBatchError {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 match self {
269 Self::EmptyBatch => write!(f, "staged batch must contain at least one op"),
270 Self::TooManyOps { max, got } => {
271 write!(f, "staged batch has {got} ops; the cap is {max}")
272 }
273 Self::InvalidRecord { op_index, reason } => {
274 write!(f, "op {op_index}: invalid record: {reason}")
275 }
276 Self::SecretDetected { op_index, class } => write!(
277 f,
278 "op {op_index}: write refused: content matches the '{class}' secret pattern \
279 class (§10.4). Durable memory must never hold credentials — store a \
280 reference to where the credential lives instead. Do not retry with the \
281 secret paraphrased or split."
282 ),
283 Self::RealmMismatch {
284 op_index,
285 expected,
286 got,
287 } => write!(
288 f,
289 "op {op_index}: scope realm '{got}' does not match batch realm '{expected}'"
290 ),
291 Self::ScopeNotWritable { op_index, reason } => {
292 write!(f, "op {op_index}: scope not writable: {reason}")
293 }
294 Self::TierNotStagedAssignable { op_index, tier } => write!(
295 f,
296 "op {op_index}: trust tier '{}' is never assignable via a staged batch",
297 tier.as_str()
298 ),
299 Self::TierAboveAuthorCeiling {
300 op_index,
301 tier,
302 ceiling,
303 } => write!(
304 f,
305 "op {op_index}: trust tier '{}' exceeds the author's ceiling '{}'",
306 tier.as_str(),
307 ceiling.as_str()
308 ),
309 Self::TransitiveTaintCeiling { op_index, tier } => write!(
310 f,
311 "op {op_index}: trust tier '{}' rejected: provenance chain reaches \
312 untrusted/quarantined content (capped at agent_observed)",
313 tier.as_str()
314 ),
315 Self::UnverifiedRetier { op_index } => write!(
316 f,
317 "op {op_index}: retier to agent_verified requires a steward author and a \
318 verification claim on the record"
319 ),
320 Self::UnresolvableEvidence { op_index, reason } => write!(
321 f,
322 "op {op_index}: retier to agent_verified rejected: verification evidence \
323 does not resolve against the session store ({reason})"
324 ),
325 Self::UnknownRecord { op_index, id } => {
326 write!(f, "op {op_index}: record '{id}' does not exist")
327 }
328 Self::RecordExists { op_index, id } => {
329 write!(f, "op {op_index}: record '{id}' already exists")
330 }
331 Self::NotActive { op_index, id } => {
332 write!(f, "op {op_index}: record '{id}' is not active")
333 }
334 Self::AlreadyTombstoned { op_index, id } => {
335 write!(f, "op {op_index}: record '{id}' is already tombstoned")
336 }
337 Self::SupersedeCycle { op_index, id } => {
338 write!(
339 f,
340 "op {op_index}: supersede chain through '{id}' would form a cycle"
341 )
342 }
343 Self::TombstoneRecreation {
344 op_index,
345 content_hash,
346 } => write!(
347 f,
348 "op {op_index}: content (hash {content_hash}) was tombstoned within the \
349 recreation window and cannot be re-created"
350 ),
351 }
352 }
353}
354
355impl std::error::Error for StagedBatchError {}
356
357pub fn validate_batch(
363 batch: &StagedMutationBatch,
364 view: &dyn StagedBatchView,
365 tombstone_recreate_window_ms: u64,
366 now_ms: u64,
367) -> Result<(), StagedBatchError> {
368 if batch.ops.is_empty() {
369 return Err(StagedBatchError::EmptyBatch);
370 }
371 if batch.ops.len() > MAX_BATCH_OPS {
372 return Err(StagedBatchError::TooManyOps {
373 max: MAX_BATCH_OPS,
374 got: batch.ops.len(),
375 });
376 }
377
378 let mut overlay: HashMap<MemoryId, StagedRecordView> = HashMap::new();
381 let mut batch_tombstoned: HashSet<(MemoryScope, String)> = HashSet::new();
383
384 let lookup =
385 |overlay: &HashMap<MemoryId, StagedRecordView>, id: &str| -> Option<StagedRecordView> {
386 overlay.get(id).cloned().or_else(|| view.record(id))
387 };
388
389 for (op_index, op) in batch.ops.iter().enumerate() {
390 match op {
391 StagedOp::Create {
392 id,
393 scope,
394 record,
395 trust,
396 derived_from,
397 ..
398 } => {
399 check_record_payload(op_index, record)?;
400 check_scope(op_index, batch, scope)?;
401 check_tier_assignment(op_index, batch, *trust, false)?;
402 if let Some(id) = id
403 && lookup(&overlay, id).is_some()
404 {
405 return Err(StagedBatchError::RecordExists {
406 op_index,
407 id: id.clone(),
408 });
409 }
410 for source in derived_from {
411 if lookup(&overlay, source).is_none() {
412 return Err(StagedBatchError::UnknownRecord {
413 op_index,
414 id: source.clone(),
415 });
416 }
417 }
418 let tainted = chain_reaches_taint(&overlay, view, derived_from.iter());
419 if tainted && *trust > TrustTier::llm_write_ceiling() {
420 return Err(StagedBatchError::TransitiveTaintCeiling {
421 op_index,
422 tier: *trust,
423 });
424 }
425 let hash = content_hash(&record.title, &record.body);
426 if is_tombstone_recreation(
427 &batch.author,
428 view,
429 &batch_tombstoned,
430 scope,
431 &hash,
432 tombstone_recreate_window_ms,
433 now_ms,
434 ) {
435 return Err(StagedBatchError::TombstoneRecreation {
436 op_index,
437 content_hash: hash,
438 });
439 }
440 if let Some(id) = id {
441 overlay.insert(
442 id.clone(),
443 StagedRecordView {
444 scope: scope.clone(),
445 trust: *trust,
446 status: RecordStatus::Active,
447 supersedes: None,
448 derived_from: derived_from.clone(),
449 content_hash: hash,
450 has_verification: record.verification.is_some(),
451 ever_quarantined: tainted,
452 },
453 );
454 }
455 }
456 StagedOp::Supersede {
457 id,
458 prior,
459 record,
460 trust,
461 derived_from,
462 ..
463 } => {
464 check_record_payload(op_index, record)?;
465 check_tier_assignment(op_index, batch, *trust, false)?;
466 let Some(prior_view) = lookup(&overlay, prior) else {
467 return Err(StagedBatchError::UnknownRecord {
468 op_index,
469 id: prior.clone(),
470 });
471 };
472 if prior_view.status != RecordStatus::Active {
473 return Err(StagedBatchError::NotActive {
474 op_index,
475 id: prior.clone(),
476 });
477 }
478 check_scope(op_index, batch, &prior_view.scope)?;
481 if let Some(id) = id
482 && lookup(&overlay, id).is_some()
483 {
484 return Err(StagedBatchError::RecordExists {
485 op_index,
486 id: id.clone(),
487 });
488 }
489 for source in derived_from {
490 if lookup(&overlay, source).is_none() {
491 return Err(StagedBatchError::UnknownRecord {
492 op_index,
493 id: source.clone(),
494 });
495 }
496 }
497 if let Some(new_id) = id {
501 let mut visited = HashSet::new();
502 visited.insert(new_id.clone());
503 let mut cursor = Some(prior.clone());
504 while let Some(current) = cursor {
505 if !visited.insert(current.clone()) {
506 return Err(StagedBatchError::SupersedeCycle {
507 op_index,
508 id: current,
509 });
510 }
511 cursor = lookup(&overlay, ¤t).and_then(|r| r.supersedes);
512 }
513 }
514 let tainted = chain_reaches_taint(
515 &overlay,
516 view,
517 std::iter::once(prior).chain(derived_from.iter()),
518 );
519 if tainted && *trust > TrustTier::llm_write_ceiling() {
520 return Err(StagedBatchError::TransitiveTaintCeiling {
521 op_index,
522 tier: *trust,
523 });
524 }
525 let hash = content_hash(&record.title, &record.body);
526 if is_tombstone_recreation(
527 &batch.author,
528 view,
529 &batch_tombstoned,
530 &prior_view.scope,
531 &hash,
532 tombstone_recreate_window_ms,
533 now_ms,
534 ) {
535 return Err(StagedBatchError::TombstoneRecreation {
536 op_index,
537 content_hash: hash,
538 });
539 }
540 overlay.insert(
541 prior.clone(),
542 StagedRecordView {
543 status: RecordStatus::Superseded {
544 by: id.clone().unwrap_or_else(|| "<pending>".to_string()),
545 },
546 ..prior_view.clone()
547 },
548 );
549 if let Some(id) = id {
550 overlay.insert(
551 id.clone(),
552 StagedRecordView {
553 scope: prior_view.scope.clone(),
554 trust: *trust,
555 status: RecordStatus::Active,
556 supersedes: Some(prior.clone()),
557 derived_from: derived_from.clone(),
558 content_hash: hash,
559 has_verification: record.verification.is_some(),
560 ever_quarantined: tainted,
561 },
562 );
563 }
564 }
565 StagedOp::Tombstone { id, .. } => {
566 let Some(existing) = lookup(&overlay, id) else {
567 return Err(StagedBatchError::UnknownRecord {
568 op_index,
569 id: id.clone(),
570 });
571 };
572 if existing.status == RecordStatus::Tombstoned {
573 return Err(StagedBatchError::AlreadyTombstoned {
574 op_index,
575 id: id.clone(),
576 });
577 }
578 check_scope(op_index, batch, &existing.scope)?;
579 batch_tombstoned.insert((existing.scope.clone(), existing.content_hash.clone()));
580 let was_quarantined = matches!(existing.status, RecordStatus::Quarantined { .. });
584 overlay.insert(
585 id.clone(),
586 StagedRecordView {
587 status: RecordStatus::Tombstoned,
588 ever_quarantined: existing.ever_quarantined || was_quarantined,
589 ..existing
590 },
591 );
592 }
593 StagedOp::Retier { id, trust, .. } => {
594 check_tier_assignment(op_index, batch, *trust, true)?;
595 let Some(existing) = lookup(&overlay, id) else {
596 return Err(StagedBatchError::UnknownRecord {
597 op_index,
598 id: id.clone(),
599 });
600 };
601 if existing.status == RecordStatus::Tombstoned {
602 return Err(StagedBatchError::AlreadyTombstoned {
603 op_index,
604 id: id.clone(),
605 });
606 }
607 check_scope(op_index, batch, &existing.scope)?;
608 if *trust == TrustTier::AgentVerified {
609 let steward = matches!(batch.author, MemoryAuthor::Steward { .. });
614 if !steward || !existing.has_verification {
615 return Err(StagedBatchError::UnverifiedRetier { op_index });
616 }
617 }
618 if *trust > TrustTier::llm_write_ceiling() {
619 let tainted = chain_reaches_taint(&overlay, view, std::iter::once(id));
620 if tainted {
621 return Err(StagedBatchError::TransitiveTaintCeiling {
622 op_index,
623 tier: *trust,
624 });
625 }
626 }
627 overlay.insert(
628 id.clone(),
629 StagedRecordView {
630 trust: *trust,
631 ..existing
632 },
633 );
634 }
635 StagedOp::SetRank { id, .. } => {
636 let Some(existing) = lookup(&overlay, id) else {
637 return Err(StagedBatchError::UnknownRecord {
638 op_index,
639 id: id.clone(),
640 });
641 };
642 if existing.status == RecordStatus::Tombstoned {
643 return Err(StagedBatchError::AlreadyTombstoned {
644 op_index,
645 id: id.clone(),
646 });
647 }
648 check_scope(op_index, batch, &existing.scope)?;
649 }
650 }
651 }
652 Ok(())
653}
654
655fn check_record_payload(op_index: usize, record: &NewMemoryRecord) -> Result<(), StagedBatchError> {
656 validate_record_fields(&record.title, &record.description, &record.body)
657 .map_err(|reason| StagedBatchError::InvalidRecord { op_index, reason })?;
658 if let Some(class) = crate::memory::secrets::detect_record_secret(
662 &record.title,
663 &record.description,
664 &record.body,
665 &record.tags,
666 ) {
667 return Err(StagedBatchError::SecretDetected { op_index, class });
668 }
669 Ok(())
670}
671
672fn check_scope(
677 op_index: usize,
678 batch: &StagedMutationBatch,
679 scope: &MemoryScope,
680) -> Result<(), StagedBatchError> {
681 if scope.realm() != batch.realm {
682 return Err(StagedBatchError::RealmMismatch {
683 op_index,
684 expected: batch.realm.clone(),
685 got: scope.realm().to_string(),
686 });
687 }
688 if let MemoryAuthor::Agent { identity } = &batch.author {
689 match scope {
690 MemoryScope::Identity {
691 identity: scope_identity,
692 ..
693 } if scope_identity == identity => {}
694 other => {
695 return Err(StagedBatchError::ScopeNotWritable {
696 op_index,
697 reason: format!(
698 "agent '{identity}' may only write its own identity scope, not \
699 {} scope (mob/operator writes go through proposals)",
700 other.kind_str()
701 ),
702 });
703 }
704 }
705 }
706 Ok(())
707}
708
709fn check_tier_assignment(
710 op_index: usize,
711 batch: &StagedMutationBatch,
712 tier: TrustTier,
713 is_retier: bool,
714) -> Result<(), StagedBatchError> {
715 if !tier.assignable_via_staged_batch() {
716 return Err(StagedBatchError::TierNotStagedAssignable { op_index, tier });
717 }
718 if batch.author.is_llm() && tier > TrustTier::llm_write_ceiling() {
722 let steward_retier = is_retier && matches!(batch.author, MemoryAuthor::Steward { .. });
723 if !steward_retier {
724 return Err(StagedBatchError::TierAboveAuthorCeiling {
725 op_index,
726 tier,
727 ceiling: TrustTier::llm_write_ceiling(),
728 });
729 }
730 }
731 Ok(())
732}
733
734fn chain_reaches_taint<'a>(
742 overlay: &HashMap<MemoryId, StagedRecordView>,
743 view: &dyn StagedBatchView,
744 start: impl Iterator<Item = &'a MemoryId>,
745) -> bool {
746 let mut stack: Vec<MemoryId> = start.cloned().collect();
747 let mut visited: HashSet<MemoryId> = HashSet::new();
748 while let Some(id) = stack.pop() {
749 if !visited.insert(id.clone()) {
750 continue;
751 }
752 let Some(record) = overlay.get(&id).cloned().or_else(|| view.record(&id)) else {
753 continue;
754 };
755 if record.trust == TrustTier::Untrusted
756 || record.ever_quarantined
757 || matches!(record.status, RecordStatus::Quarantined { .. })
758 {
759 return true;
760 }
761 if let Some(prior) = record.supersedes {
762 stack.push(prior);
763 }
764 stack.extend(record.derived_from.iter().cloned());
765 }
766 false
767}
768
769fn is_tombstone_recreation(
774 author: &MemoryAuthor,
775 view: &dyn StagedBatchView,
776 batch_tombstoned: &HashSet<(MemoryScope, String)>,
777 scope: &MemoryScope,
778 hash: &str,
779 window_ms: u64,
780 now_ms: u64,
781) -> bool {
782 if !author.is_llm() {
783 return false;
784 }
785 if batch_tombstoned.contains(&(scope.clone(), hash.to_string())) {
786 return true;
787 }
788 if let Some(at_ms) = view.tombstoned_at_ms(scope, hash)
789 && now_ms.saturating_sub(at_ms) <= window_ms
790 {
791 return true;
792 }
793 false
794}
795
796#[cfg(test)]
797#[allow(clippy::expect_used, clippy::redundant_clone)]
798mod tests {
799 use super::*;
800 use crate::memory::records::MemoryKind;
801
802 struct MapView {
803 records: HashMap<MemoryId, StagedRecordView>,
804 tombstoned: HashMap<(MemoryScope, String), u64>,
805 }
806
807 impl MapView {
808 fn empty() -> Self {
809 Self {
810 records: HashMap::new(),
811 tombstoned: HashMap::new(),
812 }
813 }
814 }
815
816 impl StagedBatchView for MapView {
817 fn record(&self, id: &str) -> Option<StagedRecordView> {
818 self.records.get(id).cloned()
819 }
820
821 fn tombstoned_at_ms(&self, scope: &MemoryScope, hash: &str) -> Option<u64> {
822 self.tombstoned
823 .get(&(scope.clone(), hash.to_string()))
824 .copied()
825 }
826 }
827
828 fn identity_scope() -> MemoryScope {
829 MemoryScope::Identity {
830 realm: "family".to_string(),
831 identity: "identity:luka".to_string(),
832 }
833 }
834
835 fn payload(title: &str, body: &str) -> NewMemoryRecord {
836 NewMemoryRecord {
837 kind: MemoryKind::Fact,
838 title: title.to_string(),
839 description: String::new(),
840 body: body.to_string(),
841 tags: Vec::new(),
842 evidence: Vec::new(),
843 verification: None,
844 }
845 }
846
847 fn record_view(scope: MemoryScope, trust: TrustTier, status: RecordStatus) -> StagedRecordView {
848 StagedRecordView {
849 scope,
850 trust,
851 status,
852 supersedes: None,
853 derived_from: Vec::new(),
854 content_hash: content_hash("t", "b"),
855 has_verification: false,
856 ever_quarantined: false,
857 }
858 }
859
860 fn agent_batch(ops: Vec<StagedOp>) -> StagedMutationBatch {
861 StagedMutationBatch {
862 kind: StagedBatchKind::FreshWrite,
863 realm: "family".to_string(),
864 author: MemoryAuthor::Agent {
865 identity: "identity:luka".to_string(),
866 },
867 ops,
868 }
869 }
870
871 fn steward_batch(ops: Vec<StagedOp>) -> StagedMutationBatch {
872 StagedMutationBatch {
873 kind: StagedBatchKind::FreshWrite,
874 realm: "family".to_string(),
875 author: MemoryAuthor::Steward {
876 run_id: "dream-1".to_string(),
877 },
878 ops,
879 }
880 }
881
882 fn create_op(scope: MemoryScope, trust: TrustTier) -> StagedOp {
883 StagedOp::Create {
884 id: None,
885 scope,
886 record: payload("Fact title", "Fact body"),
887 trust,
888 derived_from: Vec::new(),
889 rationale: None,
890 created_at_ms: None,
891 updated_at_ms: None,
892 }
893 }
894
895 #[test]
896 fn empty_batch_rejected() {
897 let err = validate_batch(
898 &agent_batch(Vec::new()),
899 &MapView::empty(),
900 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
901 1_000,
902 )
903 .expect_err("empty batch");
904 assert_eq!(err, StagedBatchError::EmptyBatch);
905 }
906
907 #[test]
908 fn operator_and_application_tiers_rejected_for_every_author() {
909 for author in [
910 MemoryAuthor::Operator,
911 MemoryAuthor::Application,
912 MemoryAuthor::Steward {
913 run_id: "d".to_string(),
914 },
915 ] {
916 for tier in [TrustTier::Operator, TrustTier::Application] {
917 let batch = StagedMutationBatch {
918 kind: StagedBatchKind::FreshWrite,
919 realm: "family".to_string(),
920 author: author.clone(),
921 ops: vec![create_op(identity_scope(), tier)],
922 };
923 let err = validate_batch(
924 &batch,
925 &MapView::empty(),
926 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
927 1_000,
928 )
929 .expect_err("high tier must be rejected");
930 assert_eq!(
931 err,
932 StagedBatchError::TierNotStagedAssignable { op_index: 0, tier }
933 );
934 }
935 }
936 }
937
938 #[test]
939 fn agent_create_above_observed_rejected() {
940 let err = validate_batch(
941 &agent_batch(vec![create_op(identity_scope(), TrustTier::AgentVerified)]),
942 &MapView::empty(),
943 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
944 1_000,
945 )
946 .expect_err("agent above ceiling");
947 assert_eq!(
948 err,
949 StagedBatchError::TierAboveAuthorCeiling {
950 op_index: 0,
951 tier: TrustTier::AgentVerified,
952 ceiling: TrustTier::AgentObserved,
953 }
954 );
955 }
956
957 #[test]
958 fn agent_cannot_write_mob_scope() {
959 let mob_scope = MemoryScope::Mob {
960 realm: "family".to_string(),
961 mob: "mob:home".to_string(),
962 };
963 let err = validate_batch(
964 &agent_batch(vec![create_op(mob_scope, TrustTier::AgentObserved)]),
965 &MapView::empty(),
966 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
967 1_000,
968 )
969 .expect_err("mob scope is proposal-only for agents");
970 assert!(matches!(
971 err,
972 StagedBatchError::ScopeNotWritable { op_index: 0, .. }
973 ));
974 }
975
976 #[test]
977 fn realm_mismatch_rejected() {
978 let other_realm = MemoryScope::Identity {
979 realm: "work".to_string(),
980 identity: "identity:luka".to_string(),
981 };
982 let err = validate_batch(
983 &agent_batch(vec![create_op(other_realm, TrustTier::AgentObserved)]),
984 &MapView::empty(),
985 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
986 1_000,
987 )
988 .expect_err("cross-realm scope");
989 assert!(matches!(
990 err,
991 StagedBatchError::RealmMismatch { op_index: 0, .. }
992 ));
993 }
994
995 #[test]
996 fn oversized_payload_rejected() {
997 let mut op = create_op(identity_scope(), TrustTier::AgentObserved);
998 if let StagedOp::Create { record, .. } = &mut op {
999 record.body = "b".repeat(MAX_RECORD_BODY_BYTES_PLUS_ONE);
1000 }
1001 const MAX_RECORD_BODY_BYTES_PLUS_ONE: usize =
1002 crate::memory::records::MAX_RECORD_BODY_BYTES + 1;
1003 let err = validate_batch(
1004 &agent_batch(vec![op]),
1005 &MapView::empty(),
1006 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1007 1_000,
1008 )
1009 .expect_err("oversized body");
1010 assert!(matches!(
1011 err,
1012 StagedBatchError::InvalidRecord { op_index: 0, .. }
1013 ));
1014 }
1015
1016 #[test]
1017 fn supersede_of_missing_or_inactive_prior_rejected() {
1018 let mut view = MapView::empty();
1019 view.records.insert(
1020 "mem-superseded".to_string(),
1021 record_view(
1022 identity_scope(),
1023 TrustTier::AgentObserved,
1024 RecordStatus::Superseded {
1025 by: "mem-new".to_string(),
1026 },
1027 ),
1028 );
1029 let missing = agent_batch(vec![StagedOp::Supersede {
1030 id: None,
1031 prior: "mem-missing".to_string(),
1032 record: payload("t", "b"),
1033 trust: TrustTier::AgentObserved,
1034 derived_from: Vec::new(),
1035 rationale: None,
1036 }]);
1037 assert!(matches!(
1038 validate_batch(&missing, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1039 Err(StagedBatchError::UnknownRecord { op_index: 0, .. })
1040 ));
1041 let inactive = agent_batch(vec![StagedOp::Supersede {
1042 id: None,
1043 prior: "mem-superseded".to_string(),
1044 record: payload("t", "b"),
1045 trust: TrustTier::AgentObserved,
1046 derived_from: Vec::new(),
1047 rationale: None,
1048 }]);
1049 assert!(matches!(
1050 validate_batch(
1051 &inactive,
1052 &view,
1053 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1054 1_000
1055 ),
1056 Err(StagedBatchError::NotActive { op_index: 0, .. })
1057 ));
1058 }
1059
1060 #[test]
1061 fn explicit_id_supersede_cycle_rejected() {
1062 let batch = steward_batch(vec![
1064 StagedOp::Create {
1065 id: Some("mem-b".to_string()),
1066 scope: identity_scope(),
1067 record: payload("b", "body b"),
1068 trust: TrustTier::AgentObserved,
1069 derived_from: Vec::new(),
1070 rationale: None,
1071 created_at_ms: None,
1072 updated_at_ms: None,
1073 },
1074 StagedOp::Supersede {
1075 id: Some("mem-a".to_string()),
1076 prior: "mem-b".to_string(),
1077 record: payload("a", "body a"),
1078 trust: TrustTier::AgentObserved,
1079 derived_from: Vec::new(),
1080 rationale: None,
1081 },
1082 StagedOp::Supersede {
1083 id: Some("mem-b2".to_string()),
1084 prior: "mem-a".to_string(),
1085 record: payload("b2", "body b2"),
1086 trust: TrustTier::AgentObserved,
1087 derived_from: Vec::new(),
1088 rationale: None,
1089 },
1090 ]);
1091 validate_batch(
1093 &batch,
1094 &MapView::empty(),
1095 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1096 1_000,
1097 )
1098 .expect("acyclic chain validates");
1099
1100 let cyclic = steward_batch(vec![
1104 StagedOp::Create {
1105 id: Some("mem-b".to_string()),
1106 scope: identity_scope(),
1107 record: payload("b", "body b"),
1108 trust: TrustTier::AgentObserved,
1109 derived_from: Vec::new(),
1110 rationale: None,
1111 created_at_ms: None,
1112 updated_at_ms: None,
1113 },
1114 StagedOp::Supersede {
1115 id: Some("mem-a".to_string()),
1116 prior: "mem-b".to_string(),
1117 record: payload("a", "body a"),
1118 trust: TrustTier::AgentObserved,
1119 derived_from: Vec::new(),
1120 rationale: None,
1121 },
1122 StagedOp::Supersede {
1123 id: Some("mem-b".to_string()),
1124 prior: "mem-a".to_string(),
1125 record: payload("b3", "body b3"),
1126 trust: TrustTier::AgentObserved,
1127 derived_from: Vec::new(),
1128 rationale: None,
1129 },
1130 ]);
1131 let err = validate_batch(
1132 &cyclic,
1133 &MapView::empty(),
1134 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1135 1_000,
1136 )
1137 .expect_err("cycle must be rejected");
1138 assert!(matches!(
1139 err,
1140 StagedBatchError::RecordExists { op_index: 2, .. }
1141 | StagedBatchError::SupersedeCycle { op_index: 2, .. }
1142 ));
1143 }
1144
1145 #[test]
1146 fn cycle_through_preexisting_chain_rejected() {
1147 let mut view = MapView::empty();
1150 view.records.insert(
1151 "mem-a".to_string(),
1152 StagedRecordView {
1153 supersedes: Some("mem-b".to_string()),
1154 ..record_view(
1155 identity_scope(),
1156 TrustTier::AgentObserved,
1157 RecordStatus::Active,
1158 )
1159 },
1160 );
1161 let batch = steward_batch(vec![StagedOp::Supersede {
1162 id: Some("mem-b".to_string()),
1163 prior: "mem-a".to_string(),
1164 record: payload("b", "body b"),
1165 trust: TrustTier::AgentObserved,
1166 derived_from: Vec::new(),
1167 rationale: None,
1168 }]);
1169 let err = validate_batch(&batch, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000)
1170 .expect_err("preexisting-chain cycle must be rejected");
1171 assert!(matches!(
1172 err,
1173 StagedBatchError::SupersedeCycle { op_index: 0, .. }
1174 ));
1175 }
1176
1177 #[test]
1178 fn transitive_taint_laundering_rejected() {
1179 let mut view = MapView::empty();
1183 view.records.insert(
1184 "mem-q".to_string(),
1185 record_view(
1186 identity_scope(),
1187 TrustTier::AgentObserved,
1188 RecordStatus::Quarantined {
1189 reason: "tainted session".to_string(),
1190 },
1191 ),
1192 );
1193 let launder_by_merge = steward_batch(vec![
1194 StagedOp::Create {
1195 id: Some("mem-fresh".to_string()),
1196 scope: identity_scope(),
1197 record: {
1198 let mut p = payload("Consolidated", "Merged content");
1199 p.verification = Some(crate::memory::records::VerificationClaim {
1200 checked: "verified against evidence".to_string(),
1201 evidence: Vec::new(),
1202 });
1203 p
1204 },
1205 trust: TrustTier::AgentObserved,
1206 derived_from: vec!["mem-q".to_string()],
1207 rationale: Some("consolidation".to_string()),
1208 created_at_ms: None,
1209 updated_at_ms: None,
1210 },
1211 StagedOp::Retier {
1212 id: "mem-fresh".to_string(),
1213 trust: TrustTier::AgentVerified,
1214 rationale: Some("launder attempt".to_string()),
1215 },
1216 ]);
1217 let err = validate_batch(
1218 &launder_by_merge,
1219 &view,
1220 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1221 1_000,
1222 )
1223 .expect_err("laundering by consolidation must be rejected");
1224 assert_eq!(
1225 err,
1226 StagedBatchError::TransitiveTaintCeiling {
1227 op_index: 1,
1228 tier: TrustTier::AgentVerified,
1229 }
1230 );
1231
1232 let mut view = MapView::empty();
1235 view.records.insert(
1236 "mem-u".to_string(),
1237 record_view(
1238 identity_scope(),
1239 TrustTier::Untrusted,
1240 RecordStatus::Superseded {
1241 by: "mem-p".to_string(),
1242 },
1243 ),
1244 );
1245 view.records.insert(
1246 "mem-p".to_string(),
1247 StagedRecordView {
1248 supersedes: Some("mem-u".to_string()),
1249 ..record_view(
1250 identity_scope(),
1251 TrustTier::AgentObserved,
1252 RecordStatus::Active,
1253 )
1254 },
1255 );
1256 let launder_by_supersede = StagedMutationBatch {
1257 kind: StagedBatchKind::FreshWrite,
1258 realm: "family".to_string(),
1259 author: MemoryAuthor::Application,
1260 ops: vec![StagedOp::Supersede {
1261 id: None,
1262 prior: "mem-p".to_string(),
1263 record: payload("Fresh", "Fresh body"),
1264 trust: TrustTier::AgentVerified,
1265 derived_from: Vec::new(),
1266 rationale: None,
1267 }],
1268 };
1269 let err = validate_batch(
1270 &launder_by_supersede,
1271 &view,
1272 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1273 1_000,
1274 )
1275 .expect_err("tainted supersede chain must cap the tier");
1276 assert_eq!(
1277 err,
1278 StagedBatchError::TransitiveTaintCeiling {
1279 op_index: 0,
1280 tier: TrustTier::AgentVerified,
1281 }
1282 );
1283 }
1284
1285 #[test]
1286 fn tombstoned_formerly_quarantined_origin_still_caps_tier() {
1287 let mut view = MapView::empty();
1293 view.records.insert(
1294 "mem-origin".to_string(),
1295 StagedRecordView {
1296 ever_quarantined: true,
1297 ..record_view(
1298 identity_scope(),
1299 TrustTier::AgentObserved,
1300 RecordStatus::Tombstoned,
1301 )
1302 },
1303 );
1304 view.records.insert(
1305 "mem-released".to_string(),
1306 StagedRecordView {
1307 derived_from: vec!["mem-origin".to_string()],
1308 has_verification: true,
1309 ever_quarantined: true,
1310 ..record_view(
1311 identity_scope(),
1312 TrustTier::AgentObserved,
1313 RecordStatus::Active,
1314 )
1315 },
1316 );
1317 let retier = steward_batch(vec![StagedOp::Retier {
1318 id: "mem-released".to_string(),
1319 trust: TrustTier::AgentVerified,
1320 rationale: Some("post-release launder attempt".to_string()),
1321 }]);
1322 assert_eq!(
1323 validate_batch(&retier, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1324 Err(StagedBatchError::TransitiveTaintCeiling {
1325 op_index: 0,
1326 tier: TrustTier::AgentVerified,
1327 })
1328 );
1329
1330 view.records
1333 .get_mut("mem-released")
1334 .expect("present")
1335 .ever_quarantined = false;
1336 assert_eq!(
1337 validate_batch(&retier, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1338 Err(StagedBatchError::TransitiveTaintCeiling {
1339 op_index: 0,
1340 tier: TrustTier::AgentVerified,
1341 })
1342 );
1343
1344 let supersede = StagedMutationBatch {
1347 kind: StagedBatchKind::FreshWrite,
1348 realm: "family".to_string(),
1349 author: MemoryAuthor::Application,
1350 ops: vec![StagedOp::Supersede {
1351 id: None,
1352 prior: "mem-released".to_string(),
1353 record: payload("Fresh", "Fresh body"),
1354 trust: TrustTier::AgentVerified,
1355 derived_from: Vec::new(),
1356 rationale: None,
1357 }],
1358 };
1359 assert_eq!(
1360 validate_batch(
1361 &supersede,
1362 &view,
1363 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1364 1_000
1365 ),
1366 Err(StagedBatchError::TransitiveTaintCeiling {
1367 op_index: 0,
1368 tier: TrustTier::AgentVerified,
1369 })
1370 );
1371 }
1372
1373 #[test]
1374 fn intra_batch_tombstone_of_quarantined_origin_still_caps_tier() {
1375 let mut view = MapView::empty();
1379 view.records.insert(
1380 "mem-q".to_string(),
1381 record_view(
1382 identity_scope(),
1383 TrustTier::AgentObserved,
1384 RecordStatus::Quarantined {
1385 reason: "tainted session".to_string(),
1386 },
1387 ),
1388 );
1389 let batch = steward_batch(vec![
1390 StagedOp::Tombstone {
1391 id: "mem-q".to_string(),
1392 rationale: Some("clearing the queue".to_string()),
1393 },
1394 StagedOp::Create {
1395 id: Some("mem-fresh".to_string()),
1396 scope: identity_scope(),
1397 record: {
1398 let mut p = payload("Laundered", "Laundered body");
1399 p.verification = Some(crate::memory::records::VerificationClaim {
1400 checked: "supposedly checked".to_string(),
1401 evidence: Vec::new(),
1402 });
1403 p
1404 },
1405 trust: TrustTier::AgentObserved,
1406 derived_from: vec!["mem-q".to_string()],
1407 rationale: None,
1408 created_at_ms: None,
1409 updated_at_ms: None,
1410 },
1411 StagedOp::Retier {
1412 id: "mem-fresh".to_string(),
1413 trust: TrustTier::AgentVerified,
1414 rationale: Some("intra-batch launder attempt".to_string()),
1415 },
1416 ]);
1417 assert_eq!(
1418 validate_batch(&batch, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1419 Err(StagedBatchError::TransitiveTaintCeiling {
1420 op_index: 2,
1421 tier: TrustTier::AgentVerified,
1422 })
1423 );
1424 }
1425
1426 #[test]
1427 fn secret_bearing_payloads_are_refused_with_class_named() {
1428 let secrets = [
1429 (
1430 "aws key",
1431 "the key is AKIAIOSFODNN7EXAMPLE ok",
1432 "aws-access-key-id",
1433 ),
1434 (
1435 "github token",
1436 "token ghp_AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 works",
1437 "github-token",
1438 ),
1439 (
1440 "private key",
1441 "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----",
1442 "private-key",
1443 ),
1444 (
1445 "assignment",
1446 "api_key = \"zXy1aB2cD3eF4gH5iJ6k\"",
1447 "credential-assignment",
1448 ),
1449 ];
1450 for (title, body, class) in secrets {
1451 let batch = agent_batch(vec![StagedOp::Create {
1452 id: None,
1453 scope: identity_scope(),
1454 record: payload(title, body),
1455 trust: TrustTier::AgentObserved,
1456 derived_from: Vec::new(),
1457 rationale: None,
1458 created_at_ms: None,
1459 updated_at_ms: None,
1460 }]);
1461 let err = validate_batch(
1462 &batch,
1463 &MapView::empty(),
1464 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1465 1_000,
1466 )
1467 .expect_err("secret-bearing payload must be refused");
1468 assert_eq!(
1469 err,
1470 StagedBatchError::SecretDetected { op_index: 0, class },
1471 "wrong class for {title}"
1472 );
1473 let message = err.to_string();
1475 assert!(message.contains(class), "{message}");
1476 assert!(!message.contains("AKIA"), "{message}");
1477 assert!(!message.contains("ghp_"), "{message}");
1478 assert!(!message.contains("zXy1aB2cD3eF4gH5iJ6k"), "{message}");
1479 }
1480
1481 let mut op = create_op(identity_scope(), TrustTier::AgentObserved);
1483 if let StagedOp::Create { record, .. } = &mut op {
1484 record.tags = vec!["AKIAIOSFODNN7EXAMPLE".to_string()];
1485 }
1486 assert!(matches!(
1487 validate_batch(
1488 &agent_batch(vec![op]),
1489 &MapView::empty(),
1490 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1491 1_000
1492 ),
1493 Err(StagedBatchError::SecretDetected { op_index: 0, .. })
1494 ));
1495
1496 let clean = agent_batch(vec![StagedOp::Create {
1498 id: None,
1499 scope: identity_scope(),
1500 record: payload(
1501 "API key rotation procedure",
1502 "The api_key lives in the vault under service/deploy; rotate it quarterly \
1503 and never write the value down.",
1504 ),
1505 trust: TrustTier::AgentObserved,
1506 derived_from: Vec::new(),
1507 rationale: None,
1508 created_at_ms: None,
1509 updated_at_ms: None,
1510 }]);
1511 validate_batch(
1512 &clean,
1513 &MapView::empty(),
1514 DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1515 1_000,
1516 )
1517 .expect("clean payload about credentials passes");
1518 }
1519
1520 #[test]
1521 fn retier_to_verified_requires_steward_and_claim() {
1522 let mut view = MapView::empty();
1523 view.records.insert(
1524 "mem-1".to_string(),
1525 record_view(
1526 identity_scope(),
1527 TrustTier::AgentObserved,
1528 RecordStatus::Active,
1529 ),
1530 );
1531 let steward = steward_batch(vec![StagedOp::Retier {
1533 id: "mem-1".to_string(),
1534 trust: TrustTier::AgentVerified,
1535 rationale: None,
1536 }]);
1537 assert_eq!(
1538 validate_batch(&steward, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1539 Err(StagedBatchError::UnverifiedRetier { op_index: 0 })
1540 );
1541 view.records
1543 .get_mut("mem-1")
1544 .expect("present")
1545 .has_verification = true;
1546 validate_batch(&steward, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000)
1547 .expect("steward retier with claim");
1548 let agent = agent_batch(vec![StagedOp::Retier {
1549 id: "mem-1".to_string(),
1550 trust: TrustTier::AgentVerified,
1551 rationale: None,
1552 }]);
1553 assert_eq!(
1554 validate_batch(&agent, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1555 Err(StagedBatchError::TierAboveAuthorCeiling {
1556 op_index: 0,
1557 tier: TrustTier::AgentVerified,
1558 ceiling: TrustTier::AgentObserved,
1559 }),
1560 "non-steward LLM authors hit the blanket ceiling before the retier rule"
1561 );
1562 }
1563
1564 #[test]
1565 fn tombstone_recreation_rejected_inside_window_only() {
1566 let scope = identity_scope();
1567 let hash = content_hash("Fact title", "Fact body");
1568 let mut view = MapView::empty();
1569 view.tombstoned.insert((scope.clone(), hash), 1_000);
1570
1571 let batch = steward_batch(vec![create_op(scope.clone(), TrustTier::AgentObserved)]);
1572 assert!(matches!(
1574 validate_batch(&batch, &view, 10_000, 5_000),
1575 Err(StagedBatchError::TombstoneRecreation { op_index: 0, .. })
1576 ));
1577 validate_batch(&batch, &view, 10_000, 20_000).expect("window expired");
1579 }
1580
1581 #[test]
1582 fn tombstone_then_recreate_within_one_batch_rejected() {
1583 let mut view = MapView::empty();
1584 view.records.insert(
1585 "mem-1".to_string(),
1586 StagedRecordView {
1587 content_hash: content_hash("Fact title", "Fact body"),
1588 ..record_view(
1589 identity_scope(),
1590 TrustTier::AgentObserved,
1591 RecordStatus::Active,
1592 )
1593 },
1594 );
1595 let batch = steward_batch(vec![
1596 StagedOp::Tombstone {
1597 id: "mem-1".to_string(),
1598 rationale: None,
1599 },
1600 create_op(identity_scope(), TrustTier::AgentObserved),
1601 ]);
1602 assert!(matches!(
1603 validate_batch(&batch, &view, DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS, 1_000),
1604 Err(StagedBatchError::TombstoneRecreation { op_index: 1, .. })
1605 ));
1606 }
1607}