Skip to main content

meerkat_mobkit/memory/
staged.rs

1//! Staged mutation batches and the deterministic commit validator.
2//!
3//! §8.5 crash semantics: LLM output (and the markdown import path) never
4//! edits live records — it stages a `StagedMutationBatch`, a pure validator
5//! checks it, and `commit` applies it atomically with one audit entry per
6//! op. A producer that dies mid-run leaves a stage token that is
7//! garbage-collected, never applied.
8//!
9//! The validator is deterministic code only: schema/caps, scope legality,
10//! the §10.2 trust-tier transition lattice (including the transitive
11//! provenance ceiling), supersede-chain acyclicity, and
12//! tombstone-recreation rejection. Semantic judgment (is this merge right?)
13//! is the steward's; it never runs here.
14
15use 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
26/// Default window inside which re-creating tombstoned content is rejected
27/// (§8.4: a revocation-driven reset must not re-learn what was just
28/// revoked).
29pub const DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS: u64 = 7 * 24 * 60 * 60 * 1000;
30
31/// Hard cap on ops per batch — a schema guard, not a retention policy.
32pub const MAX_BATCH_OPS: usize = 1024;
33
34/// One mutation in a staged batch. `Create`/`Supersede` accept an explicit
35/// `id` so imports can preserve identifiers; `derived_from` records
36/// consolidation lineage so the transitive ceiling can see merges.
37#[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        /// Import paths preserve original timestamps; `None` means now.
51        #[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/// Semantic kind of a staged batch (§10.1). The `llm_writes =
97/// "quarantined"` posture keys off this, not off authorship: ALL steward
98/// dream groups carry `MemoryAuthor::Steward`, but only some of them ARE
99/// the review the posture defers to. Review verdicts (quarantine releases,
100/// gated-promotion commits, proposal accepts) commit at their reviewed
101/// status; fresh writes (consolidate/harvest/rank output, agent and
102/// distiller records) respect the posture knob.
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum StagedBatchKind {
106    /// First-pass content: no reviewer has judged it yet.
107    #[default]
108    FreshWrite,
109    /// A steward/operator review verdict over existing content — the
110    /// review itself, so the quarantine posture must not re-defer it.
111    ReviewVerdict,
112}
113
114/// A staged batch. Carries the realm (the store is per-realm; id-only ops
115/// like `Tombstone` have no scope to infer it from) and one author for the
116/// whole batch — a batch is one principal's proposal, and the lattice rules
117/// key off authorship.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct StagedMutationBatch {
120    pub realm: String,
121    pub author: MemoryAuthor,
122    /// §10.1 posture keying. `serde(default)`: batches staged before this
123    /// field existed deserialize as fresh writes — the conservative
124    /// direction (a pre-upgrade gated promotion re-quarantines under the
125    /// posture rather than silently landing Active).
126    #[serde(default)]
127    pub kind: StagedBatchKind,
128    pub ops: Vec<StagedOp>,
129}
130
131/// Opaque handle to a staged-but-uncommitted batch.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct StageToken {
134    pub realm: String,
135    pub token: String,
136}
137
138/// Result of an atomic commit: the affected record id per applied op, in op
139/// order (`None` for ops that touch no single record id — currently none).
140#[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/// The staging capability (§7.3): what consolidation requires, split from
148/// the storage provider so read-only or simple backends need not implement
149/// it. Atomicity is the implementor's contract — `commit` applies the whole
150/// batch or none of it, with one audit entry per op.
151#[async_trait]
152pub trait StagedMemoryStore: AgentMemoryProvider {
153    /// Validate the batch against current store state and persist it,
154    /// unapplied, under a token. Stale tokens are garbage-collected.
155    async fn stage(&self, batch: StagedMutationBatch) -> Result<StageToken, AgentMemoryError>;
156
157    /// Re-validate and apply the staged batch in a single transaction,
158    /// writing one audit entry per op and burning the token.
159    async fn commit(&self, token: StageToken) -> Result<CommitReceipt, AgentMemoryError>;
160}
161
162/// What the validator needs to know about an existing record.
163#[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    /// §10.2 durable taint marker: the record landed quarantined at some
173    /// point (or descends from one that did). The *current* status is not
174    /// enough — a quarantine release tombstones the origin, and the
175    /// tombstone erases `Quarantined` from the row, so without this flag
176    /// the "capped at agent_observed forever" ceiling would be lost the
177    /// moment the origin is tombstoned.
178    pub ever_quarantined: bool,
179}
180
181/// Read-only store view backing the validator, so validation stays pure
182/// code that a SQLite transaction or a test fixture can implement.
183pub trait StagedBatchView {
184    fn record(&self, id: &str) -> Option<StagedRecordView>;
185    /// Most recent tombstone time for content with this hash in this scope.
186    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    /// §10.4 secret hygiene: the payload matches a curated gitleaks-class
201    /// secret pattern. Refused, never silently redacted; the class is named
202    /// so the author knows WHY, but the matched text is never echoed.
203    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    /// §10.2 P3 extension: a retier to `agent_verified` cited evidence that
233    /// does not resolve against the session store. Enforced at the store
234    /// seam (the resolver needs session-store access the pure validator
235    /// does not have); the error lives here because it is validator law.
236    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
357/// Deterministic batch validation. Pure: reads only `view` and its inputs.
358///
359/// Ops are checked in order against an overlay of the batch's own earlier
360/// effects, so intra-batch sequences (create-then-supersede) validate the
361/// same way they will apply.
362pub 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    // Overlay of batch-local effects, keyed by record id. Only explicit-id
379    // creates/supersedes are addressable by later ops.
380    let mut overlay: HashMap<MemoryId, StagedRecordView> = HashMap::new();
381    // Content tombstoned earlier in this same batch, per scope.
382    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                // The new record lives in the prior's scope (§8.2: supersede
479                // stays within a single record's lineage in its own scope).
480                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                // Acyclicity: explicit-id supersedes can express cycles
498                // (imports of chains); walk the prior's chain with the new
499                // edge in place.
500                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, &current).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                // Overlaying `Tombstoned` over a `Quarantined` view must not
581                // launder the taint within this batch: carry the durable bit
582                // so a tombstone-then-derive sequence still hits the ceiling.
583                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                    // §10.2: agent_verified is granted only by a steward
610                    // staged op against a record carrying a verification
611                    // claim. (Evidence-ref resolvability and the dream's
612                    // endorsement are the P3 steward's half.)
613                    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    // §10.4 secret hygiene: this is the single write chokepoint — every
659    // write path (memory tool, RPC remember/update, Distiller, Steward,
660    // Hygienist, markdown import) stages a batch validated here.
661    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
672/// Scope legality (§7.2 write authority): scopes must live in the batch
673/// realm; agents write only their own identity scope (mob/operator writes
674/// are proposals, realm scope is application-side); steward/distiller and
675/// non-LLM principals may stage into any in-realm scope.
676fn 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    // §10.2: LLM-authored writes enter at agent_observed or below — steward
719    // included. The single exception is a steward *retier* to
720    // agent_verified, whose claim requirement is checked at the call site.
721    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
734/// Walks supersede + derivation edges from the given starting ids; true if
735/// any reachable record is `Untrusted`, `Quarantined`, or ever WAS
736/// quarantined (§10.2 transitive provenance ceiling). The durable
737/// `ever_quarantined` bit is what survives a quarantine release: the
738/// release tombstones the origin, and a tombstoned row no longer reads as
739/// `Quarantined` — without the bit the ceiling would only hold until the
740/// first release.
741fn 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
769/// §8.4 Distiller guard: LLM authors must not re-learn content that was
770/// just revoked. Deliberate non-LLM re-adds (operator/SDK forget-then-
771/// remember) are a human decision and pass. Callers turn a `true` into
772/// `StagedBatchError::TombstoneRecreation` with their op index.
773fn 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        // Import-shaped batch: A supersedes B, then B supersedes A.
1063        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        // The straight chain is fine.
1092        validate_batch(
1093            &batch,
1094            &MapView::empty(),
1095            DEFAULT_TOMBSTONE_RECREATE_WINDOW_MS,
1096            1_000,
1097        )
1098        .expect("acyclic chain validates");
1099
1100        // Reusing an id already in the chain must fail (either as an
1101        // existing-id conflict or as a detected cycle) — the store can never
1102        // apply a cyclic supersede graph.
1103        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        // Store already has A(active) supersedes B. A batch that recreates
1148        // "mem-b" as a supersede of A would close the loop A -> B -> A.
1149        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        // Quarantined record Q; active P supersedes U (untrusted). Both
1180        // laundering routes must be capped:
1181        // 1. Steward merges Q into a "fresh" record and retiers it up.
1182        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        // 2. Non-LLM author supersedes a record whose chain reaches
1233        //    untrusted provenance, asking for agent_verified.
1234        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        // Cross-batch: origin Q was quarantined, then a release tombstoned
1288        // it (the tombstone erased `Quarantined` from the status; only the
1289        // durable ever_quarantined bit remains). The released copy carries a
1290        // verification claim; a later steward retier to agent_verified must
1291        // still hit the §10.2 ceiling.
1292        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        // Even a copy whose own bit was somehow not set is capped through
1331        // the derivation walk reaching the flagged origin.
1332        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        // A non-LLM supersede over the released copy asking agent_verified
1345        // is capped the same way.
1346        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        // Single batch: tombstone the quarantined origin FIRST, then derive
1376        // a fresh record from it and retier up. The tombstone overlay must
1377        // carry the ever-quarantined bit or the walk sees only `Tombstoned`.
1378        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            // The refusal names the class but never echoes the secret.
1474            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        // Tags are scanned too.
1482        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        // Ordinary technical prose passes.
1497        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        // No verification claim: even the steward is rejected.
1532        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        // With a claim, the steward passes and an agent author still fails.
1542        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        // Inside the window: reject.
1573        assert!(matches!(
1574            validate_batch(&batch, &view, 10_000, 5_000),
1575            Err(StagedBatchError::TombstoneRecreation { op_index: 0, .. })
1576        ));
1577        // Outside the window: allowed.
1578        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}