From 2eb32fb0d58643d32b3e0447d0adf4a744036777 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 22 Oct 2026 20:05:45 +0530 Subject: [PATCH 01/11] fix(api): count a plan refusal as account-wide A hosted `*_PLAN_REQUIRED` refusal became `Error::Engine` in v2.0.1 so a host stops signing the user out, but `Error::is_account_wide` only knew Off, Unauthorized and InsufficientCredits. Every caller that asks "is this the account, not the item?" therefore treated a plan refusal as item-scoped: the job queue spent an attempt per run and dropped the job after MAX_ATTEMPTS, source sync retried a refused batch item by item, the layout migration did not pause, and a turn got no memory notice. Add `Error::is_plan_required` and count it in `is_account_wide`, so every caller is fixed in one place. Sync's private copy of the check and its suffix constant are removed; cortex's `is_plan_required` delegates to the method; the turn's refusal block names the plan. --- crates/tinymemory-api/src/error/mod.rs | 29 +++++++++++++++++-- crates/tinymemory-api/src/error/mod_tests.rs | 16 ++++++++++ .../src/cortex/error/mod.rs | 7 ++--- .../src/sources/sync/mod.rs | 15 +--------- crates/tinymemory-tools/src/lifecycle/turn.rs | 3 ++ .../tinymemory-tools/src/queue/mod_tests.rs | 19 ++++++++++++ docs/architecture/api.md | 4 ++- 7 files changed, 71 insertions(+), 22 deletions(-) diff --git a/crates/tinymemory-api/src/error/mod.rs b/crates/tinymemory-api/src/error/mod.rs index 829a9327..48cd565f 100644 --- a/crates/tinymemory-api/src/error/mod.rs +++ b/crates/tinymemory-api/src/error/mod.rs @@ -7,6 +7,10 @@ use serde::Serialize; +/// The suffix of a hosted engine's codes for a feature the account's plan +/// does not include (`MEMORY_PLAN_REQUIRED`). +pub const PLAN_REQUIRED_SUFFIX: &str = "_PLAN_REQUIRED"; + /// Every way a memory operation can fail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, thiserror::Error)] #[serde(rename_all = "snake_case")] @@ -62,7 +66,8 @@ impl Error { } } - /// Whether the failure is known to affect every request on the account. + /// Whether the failure is known to affect every request on the account, + /// including a plan refusal ([`Self::is_plan_required`]). /// `Unavailable` is deliberately excluded: it describes a transient /// request failure and carries no typed proof of an account-wide outage. #[must_use] @@ -70,7 +75,27 @@ impl Error { matches!( self, Self::Off(_) | Self::Unauthorized(_) | Self::InsufficientCredits(_) - ) + ) || self.is_plan_required() + } + + /// Whether the engine refused because the account's plan does not include + /// the feature: an [`Self::Engine`] message led by a well-formed + /// `[CODE] ` whose code ends in [`PLAN_REQUIRED_SUFFIX`]. Not a credential + /// fault, so a host keeps the session and shows an upgrade path. + #[must_use] + pub fn is_plan_required(&self) -> bool { + let Self::Engine(message) = self else { + return false; + }; + message + .strip_prefix('[') + .and_then(|rest| rest.split_once("] ")) + .is_some_and(|(code, _)| { + code.ends_with(PLAN_REQUIRED_SUFFIX) + && code + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + }) } /// Whether retrying the same call later may succeed. diff --git a/crates/tinymemory-api/src/error/mod_tests.rs b/crates/tinymemory-api/src/error/mod_tests.rs index 09a86499..2d3ff9b5 100644 --- a/crates/tinymemory-api/src/error/mod_tests.rs +++ b/crates/tinymemory-api/src/error/mod_tests.rs @@ -68,3 +68,19 @@ fn request_timeouts_and_rate_limits_are_transient_not_account_wide() { assert!(!error.is_account_wide(), "{error:?}"); } } + +#[test] +fn a_plan_refusal_is_account_wide_only_with_a_well_formed_code() { + let plan = Error::Engine("[MEMORY_PLAN_REQUIRED] memory API recall: not in the plan".into()); + assert!(plan.is_plan_required()); + assert!(plan.is_account_wide()); + for other in [ + Error::Engine("saw [MEMORY_PLAN_REQUIRED] mid-message".into()), + Error::Engine("[not a code_PLAN_REQUIRED] memory API x".into()), + Error::Engine("[USER_INSUFFICIENT_CREDITS] top up".into()), + Error::Unavailable("[MEMORY_PLAN_REQUIRED] x".into()), + ] { + assert!(!other.is_plan_required(), "{other:?}"); + assert!(!other.is_account_wide(), "{other:?}"); + } +} diff --git a/crates/tinymemory-integrations/src/cortex/error/mod.rs b/crates/tinymemory-integrations/src/cortex/error/mod.rs index d3f4b323..f2c7cf8e 100644 --- a/crates/tinymemory-integrations/src/cortex/error/mod.rs +++ b/crates/tinymemory-integrations/src/cortex/error/mod.rs @@ -60,9 +60,7 @@ pub type Result = std::result::Result; /// The TinyHumans backend's code for an exhausted credit balance (HTTP 402). pub(crate) const INSUFFICIENT_CREDITS_CODE: &str = "USER_INSUFFICIENT_CREDITS"; -/// The suffix of the TinyHumans backend's codes for a feature the account's -/// plan does not include (`MEMORY_PLAN_REQUIRED`). -pub(crate) const PLAN_REQUIRED_SUFFIX: &str = "_PLAN_REQUIRED"; +pub(crate) use tinymemory_api::error::PLAN_REQUIRED_SUFFIX; /// The message every variant carries. fn message_of(error: &Error) -> &str { @@ -101,8 +99,7 @@ pub fn error_code(error: &Error) -> Option<&str> { /// and shows an upgrade path. #[must_use] pub fn is_plan_required(error: &Error) -> bool { - matches!(error, Error::Engine(_)) - && error_code(error).is_some_and(|code| code.ends_with(PLAN_REQUIRED_SUFFIX)) + error.is_plan_required() } #[cfg(test)] diff --git a/crates/tinymemory-integrations/src/sources/sync/mod.rs b/crates/tinymemory-integrations/src/sources/sync/mod.rs index 8e7635f1..df65bae4 100644 --- a/crates/tinymemory-integrations/src/sources/sync/mod.rs +++ b/crates/tinymemory-integrations/src/sources/sync/mod.rs @@ -39,10 +39,6 @@ pub const MIN_SCHEDULE_MINS: u32 = 15; /// `ceil(N / 100)` instead of `N`. pub const STORE_CHUNK: usize = MAX_STORE_MANY; -/// The suffix of the hosted engine's codes for a feature the account's plan -/// does not include (`MEMORY_PLAN_REQUIRED`). -const PLAN_REQUIRED_SUFFIX: &str = "_PLAN_REQUIRED"; - /// Why a sync failed. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum SyncError { @@ -64,21 +60,12 @@ impl SyncError { #[must_use] pub fn is_account_wide(&self) -> bool { match self { - Self::Engine(error) => error.is_account_wide() || is_plan_refusal(error), + Self::Engine(error) => error.is_account_wide(), _ => false, } } } -/// Whether the hosted engine refused because the account's plan lacks the -/// feature: an `Engine` error led by a `[*_PLAN_REQUIRED] ` code. -fn is_plan_refusal(error: &tinymemory_api::Error) -> bool { - matches!(error, tinymemory_api::Error::Engine(message) if message - .strip_prefix('[') - .and_then(|rest| rest.split_once("] ")) - .is_some_and(|(code, _)| code.ends_with(PLAN_REQUIRED_SUFFIX))) -} - /// A source as the host's registry holds it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RegisteredSource { diff --git a/crates/tinymemory-tools/src/lifecycle/turn.rs b/crates/tinymemory-tools/src/lifecycle/turn.rs index d39209b5..a8f98cd0 100644 --- a/crates/tinymemory-tools/src/lifecycle/turn.rs +++ b/crates/tinymemory-tools/src/lifecycle/turn.rs @@ -214,6 +214,9 @@ impl TurnPack { "the account is out of credits; topping up restores it" } Error::Unavailable(_) => "the memory engine could not be reached", + Error::Engine(_) if error.is_plan_required() => { + "the account's plan does not include memory; upgrading restores it" + } _ => return None, }; let markdown = format!( diff --git a/crates/tinymemory-tools/src/queue/mod_tests.rs b/crates/tinymemory-tools/src/queue/mod_tests.rs index 46a9dcc8..1b98b3a7 100644 --- a/crates/tinymemory-tools/src/queue/mod_tests.rs +++ b/crates/tinymemory-tools/src/queue/mod_tests.rs @@ -159,3 +159,22 @@ async fn history_keeps_the_newest_runs_only() { } assert_eq!(queue.snapshot().await.history.len(), MAX_HISTORY); } + +/// A hosted `*_PLAN_REQUIRED` refusal arrives as `Engine` and refuses every +/// request on the account, so a job must keep its attempts while the account +/// lacks the plan; otherwise every queued belief build is dropped after +/// `MAX_ATTEMPTS` runs. +#[tokio::test] +async fn a_plan_refusal_never_uses_up_a_jobs_attempts() { + let queue = queue(); + queue.enqueue(&Namespace::ROOT, vec![build("a")]).await; + let engine: Arc = Arc::new(RefusingEngine::with( + tinymemory_api::Error::Engine("[MEMORY_PLAN_REQUIRED] not in the plan".into()), + )); + for _ in 0..MAX_ATTEMPTS + 2 { + queue.run(engine.clone(), Selection::All, 0).await.unwrap(); + } + let state = queue.snapshot().await; + assert_eq!(state.pending.len(), 1, "the job waits for the plan"); + assert_eq!(state.pending[0].attempts, 0); +} diff --git a/docs/architecture/api.md b/docs/architecture/api.md index 3913abb5..38c7c002 100644 --- a/docs/architecture/api.md +++ b/docs/architecture/api.md @@ -136,7 +136,9 @@ never carry a credential: an engine sanitises its own failure before it becomes `Error::Engine`. `Error` is `Clone + PartialEq + Eq` and serializes its variant and sanitized message so a caller can retain its category without parsing `Display` text. `Error::code()` returns the stable host code; `is_account_wide()` -identifies failures that apply to every request on the account. +identifies failures that apply to every request on the account, including a +plan refusal: an `Engine` message led by a `[*_PLAN_REQUIRED] ` code, which +`is_plan_required()` detects. | Variant | Display prefix | Used when | Raised by `tinymemory-api` itself? | | --- | --- | --- | --- | From fd1b072eb385dc744d80b3def1dc8b32265e0f8b Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 02:39:58 +0530 Subject: [PATCH 02/11] fix(deletion): never overwrite an unreadable queue; hold unsaved deletions An unreadable deletion store was read as empty, so the next enqueue saved [new] over it and every deletion already owed was lost. read() now reports the store as unreadable instead. enqueue, settle and pending never write over it: a deletion that cannot be saved is held in memory, counted by pending(), and merged back into the store on the next read that succeeds. --- crates/tinymemory-tools/src/deletion/mod.rs | 83 ++++++++++++++----- .../src/deletion/mod_tests.rs | 65 ++++++++++++--- 2 files changed, 116 insertions(+), 32 deletions(-) diff --git a/crates/tinymemory-tools/src/deletion/mod.rs b/crates/tinymemory-tools/src/deletion/mod.rs index 1dd86f8d..636f22c5 100644 --- a/crates/tinymemory-tools/src/deletion/mod.rs +++ b/crates/tinymemory-tools/src/deletion/mod.rs @@ -10,6 +10,10 @@ //! ([`Backoff`]): a tick skips it until it is due again, while a forced //! drain (a sign-in) runs everything now. //! +//! An unreadable store is never overwritten. A deletion that cannot be saved +//! is held in memory, counted by [`DeletionQueue::pending`], and written into +//! the store once it can be read again. +//! //! The crate links no executor and does no I/O: the host supplies the store //! and the [`DeletionExecutor`] that does the forget. @@ -112,8 +116,13 @@ struct Retry { pub struct DeletionQueue> { store: S, backoff: Backoff, - /// Serialises read-modify-writes of the store. - lock: Mutex<()>, + /// Serialises read-modify-writes of the store, and holds the deletions + /// not yet saved to it. + /// + /// ponytail: held deletions are lost if the process exits while the store + /// stays unreadable; a host that sets a corrupt file aside recovers them on + /// the next save. + held: Mutex>, /// Serialises drains, so two triggers never run one deletion twice. draining: AsyncMutex<()>, retries: Mutex>, @@ -134,36 +143,55 @@ impl> DeletionQueue { Self { store, backoff, - lock: Mutex::new(()), + held: Mutex::new(Vec::new()), draining: AsyncMutex::new(()), retries: Mutex::new(HashMap::new()), } } - fn locked(&self) -> std::sync::MutexGuard<'_, ()> { - self.lock + fn held(&self) -> std::sync::MutexGuard<'_, Vec> { + self.held .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn read(&self) -> Vec { - self.store.load().unwrap_or_else(|error| { - log::warn!("[tinymemory:deletion] pending deletions unreadable: {error}"); - Vec::new() - }) + /// The stored deletions with every `held` one written back into the + /// store, or `None` while the store cannot be read. + fn read(&self, held: &mut Vec) -> Option> { + let mut all = match self.store.load() { + Ok(all) => all, + Err(error) => { + log::warn!("[tinymemory:deletion] pending deletions unreadable: {error}"); + return None; + } + }; + if !held.is_empty() { + merge(&mut all, held.drain(..)); + self.write(&all, held); + } + Some(all) } - fn write(&self, all: &[PendingDeletion]) { + /// Saves `all`; when that fails, holds it until the next read succeeds. + fn write(&self, all: &[PendingDeletion], held: &mut Vec) { if let Err(error) = self.store.save(all) { log::warn!("[tinymemory:deletion] writing pending deletions failed: {error}"); + merge(held, all.iter().cloned()); } } /// Records `deletion` to run on the next drain. One already queued is not /// queued twice. pub fn enqueue(&self, deletion: PendingDeletion) { - let _guard = self.locked(); - let mut all = self.read(); + let mut held = self.held(); + let Some(mut all) = self.read(&mut held) else { + log::warn!( + "[tinymemory:deletion] {} deletion held until pending deletions are readable", + deletion.label() + ); + merge(&mut held, std::iter::once(deletion)); + return; + }; if all.contains(&deletion) { return; } @@ -172,23 +200,29 @@ impl> DeletionQueue { deletion.label() ); all.push(deletion); - self.write(&all); + self.write(&all, &mut held); } - /// Every deletion still owed, oldest first. + /// Every deletion still owed, oldest first, including those held while + /// the store is unreadable. #[must_use] pub fn pending(&self) -> Vec { - let _guard = self.locked(); - self.read() + let mut held = self.held(); + let mut all = self.read(&mut held).unwrap_or_default(); + merge(&mut all, held.iter().cloned()); + all } fn settle(&self, deletion: &PendingDeletion) { - let _guard = self.locked(); - let mut all = self.read(); + let mut held = self.held(); + held.retain(|queued| queued != deletion); + let Some(mut all) = self.read(&mut held) else { + return; + }; let before = all.len(); all.retain(|queued| queued != deletion); if all.len() != before { - self.write(&all); + self.write(&all, &mut held); } } @@ -261,6 +295,15 @@ impl> DeletionQueue { } } +/// Appends each of `extra` not already in `all`. +fn merge(all: &mut Vec, extra: impl IntoIterator) { + for deletion in extra { + if !all.contains(&deletion) { + all.push(deletion); + } + } +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/crates/tinymemory-tools/src/deletion/mod_tests.rs b/crates/tinymemory-tools/src/deletion/mod_tests.rs index ba907594..2face7a5 100644 --- a/crates/tinymemory-tools/src/deletion/mod_tests.rs +++ b/crates/tinymemory-tools/src/deletion/mod_tests.rs @@ -122,20 +122,61 @@ fn backoff_doubles_and_is_capped() { assert_eq!(backoff.delay(50), Duration::minutes(10)); } -#[test] -fn an_unreadable_store_is_treated_as_empty_without_clobbering() { - use crate::list_store::StoreError; - struct Broken; - impl ListStore for Broken { - fn load(&self) -> Result, StoreError> { - Err(StoreError("io".into())) - } - fn save(&self, _: &[PendingDeletion]) -> Result<(), StoreError> { - Ok(()) +/// A store whose reads fail until `readable` is set, recording every save. +#[derive(Default)] +struct Flaky { + readable: std::sync::atomic::AtomicBool, + stored: StdMutex>, + saves: StdMutex>>, +} + +impl ListStore for &Flaky { + fn load(&self) -> Result, crate::list_store::StoreError> { + if self.readable.load(std::sync::atomic::Ordering::SeqCst) { + Ok(self.stored.lock().unwrap().clone()) + } else { + Err(crate::list_store::StoreError("corrupt".into())) } } - let queue = DeletionQueue::new(Broken, Backoff::default()); - assert!(queue.pending().is_empty()); + fn save(&self, all: &[PendingDeletion]) -> Result<(), crate::list_store::StoreError> { + self.saves.lock().unwrap().push(all.to_vec()); + *self.stored.lock().unwrap() = all.to_vec(); + Ok(()) + } +} + +#[test] +fn enqueue_on_an_unreadable_store_does_not_overwrite_it() { + let store = Flaky::default(); + let queue = DeletionQueue::new(&store, Backoff::default()); + queue.enqueue(thread("t2")); + assert!( + store.saves.lock().unwrap().is_empty(), + "an unreadable queue was overwritten with {:?}", + store.saves.lock().unwrap() + ); + assert_eq!( + queue.pending(), + vec![thread("t2")], + "the new deletion was lost" + ); +} + +#[test] +fn a_held_deletion_is_flushed_once_the_store_is_readable() { + let store = Flaky::default(); + *store.stored.lock().unwrap() = vec![thread("t1")]; + let queue = DeletionQueue::new(&store, Backoff::default()); + queue.enqueue(thread("t2")); + + store + .readable + .store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!(queue.pending(), vec![thread("t1"), thread("t2")]); + assert_eq!( + *store.stored.lock().unwrap(), + vec![thread("t1"), thread("t2")] + ); } #[test] From 3143f384ac94405d1dd0f472d6d0dfddf099127e Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 03:12:35 +0530 Subject: [PATCH 03/11] fix(deletion): clear held deletions once a save succeeds A failed write-back during settle left the stored entries in the held list, so a later read wrote the settled deletion back and it ran again. Every list written already carries every held deletion except one being settled, so a successful save now clears the held list. --- crates/tinymemory-tools/src/deletion/mod.rs | 12 ++++-- .../src/deletion/mod_tests.rs | 39 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/tinymemory-tools/src/deletion/mod.rs b/crates/tinymemory-tools/src/deletion/mod.rs index 636f22c5..6130ee75 100644 --- a/crates/tinymemory-tools/src/deletion/mod.rs +++ b/crates/tinymemory-tools/src/deletion/mod.rs @@ -173,10 +173,16 @@ impl> DeletionQueue { } /// Saves `all`; when that fails, holds it until the next read succeeds. + /// + /// Every list written holds every `held` deletion except one being + /// settled, so a successful save leaves nothing to hold. fn write(&self, all: &[PendingDeletion], held: &mut Vec) { - if let Err(error) = self.store.save(all) { - log::warn!("[tinymemory:deletion] writing pending deletions failed: {error}"); - merge(held, all.iter().cloned()); + match self.store.save(all) { + Ok(()) => held.clear(), + Err(error) => { + log::warn!("[tinymemory:deletion] writing pending deletions failed: {error}"); + merge(held, all.iter().cloned()); + } } } diff --git a/crates/tinymemory-tools/src/deletion/mod_tests.rs b/crates/tinymemory-tools/src/deletion/mod_tests.rs index 2face7a5..55a9cdc8 100644 --- a/crates/tinymemory-tools/src/deletion/mod_tests.rs +++ b/crates/tinymemory-tools/src/deletion/mod_tests.rs @@ -122,10 +122,12 @@ fn backoff_doubles_and_is_capped() { assert_eq!(backoff.delay(50), Duration::minutes(10)); } -/// A store whose reads fail until `readable` is set, recording every save. +/// A store whose reads fail until `readable` is set and whose next +/// `failing_saves` saves fail, recording every save that succeeds. #[derive(Default)] struct Flaky { readable: std::sync::atomic::AtomicBool, + failing_saves: std::sync::atomic::AtomicUsize, stored: StdMutex>, saves: StdMutex>>, } @@ -139,6 +141,17 @@ impl ListStore for &Flaky { } } fn save(&self, all: &[PendingDeletion]) -> Result<(), crate::list_store::StoreError> { + let failing = &self.failing_saves; + if failing + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |left| left.checked_sub(1), + ) + .is_ok() + { + return Err(crate::list_store::StoreError("full".into())); + } self.saves.lock().unwrap().push(all.to_vec()); *self.stored.lock().unwrap() = all.to_vec(); Ok(()) @@ -184,3 +197,27 @@ fn the_wire_shape_matches_the_legacy_file() { let json = serde_json::to_string(&thread("t")).unwrap(); assert_eq!(json, r#"{"kind":"thread","thread_id":"t"}"#); } + +#[test] +fn a_settled_deletion_is_not_restored_after_a_failed_save() { + let store = Flaky::default(); + *store.stored.lock().unwrap() = vec![thread("a")]; + let queue = DeletionQueue::new(&store, Backoff::default()); + queue.enqueue(thread("b")); + + // Settling `a` first fails to write the held `b` back, then saves `[b]`. + store + .readable + .store(true, std::sync::atomic::Ordering::SeqCst); + store + .failing_saves + .store(1, std::sync::atomic::Ordering::SeqCst); + queue.settle(&thread("a")); + + assert_eq!( + queue.pending(), + vec![thread("b")], + "the settled deletion came back" + ); + assert_eq!(*store.stored.lock().unwrap(), vec![thread("b")]); +} From b09bbb0f19bb86547d278a9e26b3d8394cfbe406 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 03:13:06 +0530 Subject: [PATCH 04/11] fix(tinyredact): redact cards next to other digits and quoted or prefixed credentials A separated card run that failed Luhn as a whole (a card followed by a CVV or preceded by a quantity) was dropped, because the greedy card regex cannot give digits back. Such a run is now retried on windows of its digit groups; a window must pass Luhn and be corroborated. The credential patterns allowed no quote between the key and its separator, so JSON-quoted credentials in prose survived, and their leading word boundary never fired after an underscore, so DB_PASSWORD=, *_SECRET= and *_TOKEN= survived. Both now match; the trailing word boundary still keeps token_count, max_tokens and tokenizer clean. --- crates/tinyredact/src/pii/mod.rs | 74 +++++++++++++++------ crates/tinyredact/src/pii/mod_tests.rs | 40 +++++++++++ crates/tinyredact/src/policy/mod.rs | 4 +- crates/tinyredact/src/sanitize/mod_tests.rs | 41 ++++++++++++ crates/tinyredact/src/sanitize/patterns.rs | 6 +- 5 files changed, 141 insertions(+), 24 deletions(-) diff --git a/crates/tinyredact/src/pii/mod.rs b/crates/tinyredact/src/pii/mod.rs index 5238565e..eaa4e111 100644 --- a/crates/tinyredact/src/pii/mod.rs +++ b/crates/tinyredact/src/pii/mod.rs @@ -450,34 +450,68 @@ fn push_captured( /// ([`plausible_card_number`]) or a card keyword sits within /// [`CC_KEYWORD_WINDOW`] bytes. fn push_credit_cards(hits: &mut Vec, norm: &str, gate: BareCardGate) { - if gate == BareCardGate::LuhnOnly { - // Strictest behaviour: every Luhn-valid run, bare or separated. - push_checksum(hits, norm, &CC_RE, PII_CC, valid_luhn); - return; - } for m in CC_RE.find_iter(norm) { - let s = m.as_str(); - if !valid_luhn(s) { - continue; - } - // Judge bare-vs-separated on the interior of the digit run: the - // regex's per-digit `[\s\-]?` can capture one trailing separator - // (`"…773 "`), which is not the human 4-4-4-4 grouping this - // distinction is after. - let interior = s.trim_matches(|c: char| !c.is_ascii_digit()); - let bare = interior.bytes().all(|b| b.is_ascii_digit()); - let corroborated = - !bare || plausible_card_number(&digits(s)) || cc_keyword_near(norm, m.start(), m.end()); - if corroborated { + if let Some((start, end)) = card_span(norm, m.start(), m.as_str(), gate) { hits.push(Hit { - start: m.start(), - end: m.end(), + start, + end, token: PII_CC, }); } } } +/// The card inside one [`CC_RE`] match `run` found at byte `at` of `norm`. +/// +/// A run that passes Luhn is the card: with [`BareCardGate::LuhnOnly`], or +/// when it is separated, or when a bare run is corroborated. A separated run +/// that fails Luhn as a whole may still hold a card next to other digits +/// (`4111 1111 1111 1111 123`, `qty 2 4111 1111 1111 1111`), and `CC_RE` has +/// no lookaround to give back the extra digits. So its contiguous digit +/// groups are tried as windows of 13-19 digits, longest first, then leftmost. +/// A window must pass Luhn and be corroborated under either gate, because +/// splitting a run multiplies the chances of a chance Luhn pass. +fn card_span(norm: &str, at: usize, run: &str, gate: BareCardGate) -> Option<(usize, usize)> { + // Digit groups, as byte ranges of `norm`. The regex's per-digit + // `[\s\-]?` can capture one trailing separator (`"…773 "`), which is + // not a group boundary, so groups come from the digits themselves. + let mut groups: Vec<(usize, usize)> = Vec::new(); + for (i, c) in run.char_indices() { + let pos = at + i; + match (c.is_ascii_digit(), groups.last_mut()) { + (true, Some(last)) if last.1 == pos => last.1 = pos + 1, + (true, _) => groups.push((pos, pos + 1)), + (false, _) => {} + } + } + let corroborated = |start: usize, end: usize| { + plausible_card_number(&digits(&norm[start..end])) || cc_keyword_near(norm, start, end) + }; + if valid_luhn(run) { + let bare = groups.len() == 1; + let ok = gate == BareCardGate::LuhnOnly || !bare || corroborated(at, at + run.len()); + return ok.then_some((at, at + run.len())); + } + let mut windows: Vec<(usize, usize, usize)> = Vec::new(); + for i in 0..groups.len() { + let mut len = 0; + for j in i..groups.len() { + len += groups[j].1 - groups[j].0; + if len > 19 { + break; + } + let whole = i == 0 && j == groups.len() - 1; + if len >= 13 && !whole { + windows.push((len, groups[i].0, groups[j].1)); + } + } + } + windows.sort_by_key(|&(len, start, _)| (std::cmp::Reverse(len), start)); + windows.into_iter().find_map(|(_, start, end)| { + (valid_luhn(&norm[start..end]) && corroborated(start, end)).then_some((start, end)) + }) +} + /// Bytes of context searched either side of a bare digit run for a card /// keyword. 64 bytes rather than 32 because the window is counted in bytes /// while text is not: 32 bytes is only ~10 CJK characters or 8 emoji, so a diff --git a/crates/tinyredact/src/pii/mod_tests.rs b/crates/tinyredact/src/pii/mod_tests.rs index df2899ef..1699905a 100644 --- a/crates/tinyredact/src/pii/mod_tests.rs +++ b/crates/tinyredact/src/pii/mod_tests.rs @@ -669,3 +669,43 @@ fn prefilter_is_superset_of_legacy_screen() { } } } + +#[test] +fn credit_card_followed_by_space_and_digits_redacted() { + let input = "card 4111 1111 1111 1111 123"; + let survived: Vec<_> = [ + ("corroborated", redact_pii(input)), + ("default", redact_pii_with(input, Policy::default())), + ] + .into_iter() + .filter(|(_, out)| out.value.contains("4111 1111 1111 1111")) + .map(|(policy, out)| format!("{policy}: {:?}", out.value)) + .collect(); + assert!(survived.is_empty(), "card survived: {survived:?}"); +} + +#[test] +fn credit_card_after_a_leading_number_is_redacted_and_the_number_kept() { + let out = redact_pii_with("qty 2 4111 1111 1111 1111", Policy::default()); + assert!( + !out.value.contains("4111 1111"), + "card survived: {:?}", + out.value + ); + assert!( + out.value.starts_with("qty 2 "), + "the leading number went too: {:?}", + out.value + ); +} + +#[test] +fn a_separated_digit_list_with_no_card_is_left_alone() { + // The whole run fails Luhn and its only Luhn-valid window, + // `1000 2000 1000 0002`, has no network IIN and no card keyword nearby, + // so it is not taken as a card under either gate. + let input = "ids 123 1000 2000 1000 0002"; + for policy in [Policy::default(), Policy::corroborated()] { + assert_eq!(redact_pii_with(input, policy).value, input, "{policy:?}"); + } +} diff --git a/crates/tinyredact/src/policy/mod.rs b/crates/tinyredact/src/policy/mod.rs index 39b6d3fd..4682fd8b 100644 --- a/crates/tinyredact/src/policy/mod.rs +++ b/crates/tinyredact/src/policy/mod.rs @@ -7,7 +7,9 @@ /// How a bare (no separators) Luhn-valid 13-19 digit run is judged as a credit /// card by the content scrubber. Separated runs (`4111 1111 1111 1111`) are -/// always Luhn-gated only. +/// always Luhn-gated only. A separated run that fails Luhn as a whole is +/// retried on windows of its digit groups, and a card found that way must be +/// corroborated under either setting. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum BareCardGate { /// Redact every Luhn-valid run. The strictest behaviour and the default. diff --git a/crates/tinyredact/src/sanitize/mod_tests.rs b/crates/tinyredact/src/sanitize/mod_tests.rs index 5d951364..b5930cfd 100644 --- a/crates/tinyredact/src/sanitize/mod_tests.rs +++ b/crates/tinyredact/src/sanitize/mod_tests.rs @@ -158,3 +158,44 @@ fn sanitize_text_leaves_bearer_prose_alone() { assert_eq!(sanitized.value, prose); assert!(!sanitized.report.changed()); } + +#[test] +fn sanitize_text_redacts_json_quoted_credentials_in_prose() { + let input = r#"cfg {"password": "hunter2", "api_key":"abcd1234"}"#; + let survived: Vec<_> = [ + ("corroborated", sanitize_text(input)), + ("default", sanitize_text_with(input, Policy::default())), + ] + .into_iter() + .filter(|(_, out)| out.value.contains("hunter2") || out.value.contains("abcd1234")) + .map(|(policy, out)| format!("{policy}: {:?}", out.value)) + .collect(); + assert!(survived.is_empty(), "credential survived: {survived:?}"); +} + +#[test] +fn sanitize_text_redacts_prefixed_env_style_credentials() { + let input = "DB_PASSWORD=hunter2\nSTRIPE_SECRET=abcd1234\nGITHUB_TOKEN=zyxw9876"; + let survived: Vec<_> = [ + ("corroborated", sanitize_text(input)), + ("default", sanitize_text_with(input, Policy::default())), + ] + .into_iter() + .filter(|(_, out)| { + ["hunter2", "abcd1234", "zyxw9876"] + .iter() + .any(|s| out.value.contains(s)) + }) + .map(|(policy, out)| format!("{policy}: {:?}", out.value)) + .collect(); + assert!(survived.is_empty(), "credential survived: {survived:?}"); +} + +#[test] +fn sanitize_text_leaves_credential_lookalike_names_alone() { + let input = + "token_count=5 max_tokens: 10 tokenizer=x password_reset_sent: true secret_santa=bob"; + let sanitized = sanitize_text(input); + assert_eq!(sanitized.value, input); + assert!(!sanitized.report.changed()); +} diff --git a/crates/tinyredact/src/sanitize/patterns.rs b/crates/tinyredact/src/sanitize/patterns.rs index 36f08ccc..45c3fa70 100644 --- a/crates/tinyredact/src/sanitize/patterns.rs +++ b/crates/tinyredact/src/sanitize/patterns.rs @@ -32,12 +32,12 @@ pub(super) static REDACTION_PATTERNS: LazyLock> = Laz "${1}[REDACTED]", ), ( - literal(r#"(?i)(api[_-]?key\s*[=:\s]\s*["']?)[^\s"']+"#), + literal(r#"(?i)(api[_-]?key["']?\s*[=:\s]\s*["']?)[^\s"']+"#), "${1}[REDACTED]", ), ( literal( - r#"(?i)\b(token|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|secret)\b\s*[=:\s]\s*["']?[^\s"'&]+"#, + r#"(?i)(?:\b|_)(token|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|secret)\b["']?\s*[=:\s]\s*["']?[^\s"'&]+"#, ), "[REDACTED]", ), @@ -51,7 +51,7 @@ pub(super) static REDACTION_PATTERNS: LazyLock> = Laz ), ( literal( - r#"(?i)\b(access_token|refresh_token|id_token|authorization_code|code_verifier|code_challenge)\b\s*[=:\s]\s*["']?[^\s"'&]+"#, + r#"(?i)\b(access_token|refresh_token|id_token|authorization_code|code_verifier|code_challenge)\b["']?\s*[=:\s]\s*["']?[^\s"'&]+"#, ), "[REDACTED]", ), From dd7f427986e4781f84a54adc43dd09476f976687 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 03:13:42 +0530 Subject: [PATCH 05/11] fix(tinyredact): redact home paths in a URI's query or fragment redact_home_paths copied every scheme:// token whole, so a home path in a query or fragment (?path=/Users/alice/...) was kept. Only the scheme, authority and path are now copied as a unit; the query and fragment are scanned like any other text. --- crates/tinyredact/src/paths.rs | 8 +++++--- crates/tinyredact/src/paths_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/tinyredact/src/paths.rs b/crates/tinyredact/src/paths.rs index 3e037e29..b76ae0c2 100644 --- a/crates/tinyredact/src/paths.rs +++ b/crates/tinyredact/src/paths.rs @@ -33,11 +33,13 @@ pub fn redact_home_paths(text: &str) -> String { output.push('/'); } output.push_str(""); - output.push_str(&text[end..uri.end]); + output.push_str(&text[end..uri.path_end]); } else { - output.push_str(&text[position..uri.end]); + output.push_str(&text[position..uri.path_end]); } - position = uri.end; + // A query or fragment (`?path=/Users/…`) is ordinary text: the + // scan below still redacts a home path inside it. + position = uri.path_end; continue; } diff --git a/crates/tinyredact/src/paths_tests.rs b/crates/tinyredact/src/paths_tests.rs index 26b5c9c4..0d14f241 100644 --- a/crates/tinyredact/src/paths_tests.rs +++ b/crates/tinyredact/src/paths_tests.rs @@ -590,6 +590,28 @@ fn decomposed_unicode_username_marks_and_following_letters_are_scrubbed() { ]); } +#[test] +fn a_home_path_inside_a_uri_query_or_fragment_is_redacted() { + assert_home_path_cases(&[ + ( + "https://example.com/open?path=/Users/alice/secret.txt", + "https://example.com/open?path=/secret.txt", + ), + ( + "file:///tmp/x?ref=/home/alice/notes", + "file:///tmp/x?ref=/notes", + ), + ( + "(https://x.test/?f=/Users/alice/a)", + "(https://x.test/?f=/a)", + ), + ( + "https://x.test/doc#/home/alice/notes", + "https://x.test/doc#/notes", + ), + ]); +} + fn assert_home_path_cases(cases: &[(&str, &str)]) { let mismatches = cases .iter() From 1f4681b009b7dfb7c2438e0bd733ae900ebff6f1 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 03:21:34 +0530 Subject: [PATCH 06/11] fix(tinyredact): drop the URI end offset nothing reads any more --- crates/tinyredact/src/paths.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/tinyredact/src/paths.rs b/crates/tinyredact/src/paths.rs index b76ae0c2..b9f0eb2b 100644 --- a/crates/tinyredact/src/paths.rs +++ b/crates/tinyredact/src/paths.rs @@ -65,7 +65,6 @@ struct ParsedUri { is_file: bool, path_start: Option, path_end: usize, - end: usize, } fn parse_uri(text: &str, start: usize) -> Option { @@ -108,7 +107,6 @@ fn parse_uri(text: &str, start: usize) -> Option { is_file: bytes[start..scheme_end].eq_ignore_ascii_case(b"file"), path_start, path_end, - end, }) } From 528e35c14e5d31a8f49a0d8eac83997d23395eb1 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 05:03:01 +0530 Subject: [PATCH 07/11] fix(tinyredact): keep a partial overlap's tail redacted dedupe_overlaps dropped any hit that overlapped an earlier kept hit, so when two detectors matched overlapping ranges (0..10 and 5..15) the bytes 10..15 were left in clear text. Merge the overlapping hit into the kept one instead: the earlier, longer hit keeps its token and the combined range is redacted once. --- crates/tinyredact/src/pii/mod.rs | 12 +++++++----- crates/tinyredact/src/pii/mod_tests.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/crates/tinyredact/src/pii/mod.rs b/crates/tinyredact/src/pii/mod.rs index eaa4e111..7e64e191 100644 --- a/crates/tinyredact/src/pii/mod.rs +++ b/crates/tinyredact/src/pii/mod.rs @@ -535,8 +535,10 @@ fn cc_keyword_near(norm: &str, start: usize, end: usize) -> bool { CC_KEYWORD_RE.is_match(&norm[lo..hi]) } -// Sort by start asc, length desc. Then walk in order, dropping any hit whose -// range overlaps a kept hit. Result: earlier + longer wins; no double-redact. +// Sort by start asc, length desc. Then walk in order, merging any hit whose +// range overlaps a kept hit into it. Result: earlier + longer keeps its token, +// and a partial overlap's tail is still covered (fails closed); no +// double-redact. fn dedupe_overlaps(hits: &mut Vec) { hits.sort_by(|a, b| { a.start @@ -545,9 +547,9 @@ fn dedupe_overlaps(hits: &mut Vec) { }); let mut kept: Vec = Vec::with_capacity(hits.len()); for h in hits.drain(..) { - let overlaps = kept.last().is_some_and(|k| h.start < k.end); - if !overlaps { - kept.push(h); + match kept.last_mut() { + Some(k) if h.start < k.end => k.end = k.end.max(h.end), + _ => kept.push(h), } } *hits = kept; diff --git a/crates/tinyredact/src/pii/mod_tests.rs b/crates/tinyredact/src/pii/mod_tests.rs index 1699905a..7cd023df 100644 --- a/crates/tinyredact/src/pii/mod_tests.rs +++ b/crates/tinyredact/src/pii/mod_tests.rs @@ -709,3 +709,22 @@ fn a_separated_digit_list_with_no_card_is_left_alone() { assert_eq!(redact_pii_with(input, policy).value, input, "{policy:?}"); } } + +#[test] +fn a_partial_overlap_keeps_its_tail_redacted() { + let mut hits = vec![ + Hit { + start: 5, + end: 15, + token: PII_PHONE, + }, + Hit { + start: 0, + end: 10, + token: PII_CC, + }, + ]; + dedupe_overlaps(&mut hits); + let ranges: Vec<_> = hits.iter().map(|h| (h.start, h.end, h.token)).collect(); + assert_eq!(ranges, vec![(0, 15, PII_CC)]); +} From aecb07104d9fd8255e5c3e846c2a07655007858d Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 05:05:34 +0530 Subject: [PATCH 08/11] test(layout_migration): pin that a partly stored legacy item blocks the scope-wide erase cleanup only erases a legacy tree wholesale when export reports nothing incomplete. No test covered that guard: dropping it let erase run on a tree still holding part of a chunked item. The new test makes the legacy engine report one incomplete item and panic on erase. --- .../src/layout_migration/cleanup_tests.rs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/crates/tinymemory-tools/src/layout_migration/cleanup_tests.rs b/crates/tinymemory-tools/src/layout_migration/cleanup_tests.rs index 26f08630..d164b4ad 100644 --- a/crates/tinymemory-tools/src/layout_migration/cleanup_tests.rs +++ b/crates/tinymemory-tools/src/layout_migration/cleanup_tests.rs @@ -330,3 +330,81 @@ async fn after_an_unconfirmed_import_moved_items_are_forgotten_by_id_never_erase assert_eq!(state.phase, Phase::Cleaned); assert!(texts(legacy.as_ref()).await.is_empty()); } + +/// A legacy tree holding part of one chunked item: export names it in +/// `incomplete`, and its erase must never be called. +struct PartlyStored(ReferenceEngine); + +#[async_trait] +impl MemoryEngine for PartlyStored { + fn descriptor(&self) -> &EngineDescriptor { + self.0.descriptor() + } + async fn health(&self) -> EngineHealth { + self.0.health().await + } + async fn recall(&self, req: RecallRequest) -> Result { + self.0.recall(req).await + } + async fn fetch(&self, req: FetchRequest) -> Result { + self.0.fetch(req).await + } + async fn store(&self, item: StoreItem) -> Result { + self.0.store(item).await + } + async fn forget(&self, target: ForgetTarget) -> Result { + self.0.forget(target).await + } + async fn list(&self, req: ListRequest) -> Result { + self.0.list(req).await + } + async fn export(&self, req: ListRequest) -> Result { + let mut page = self.0.export(req).await?; + page.incomplete.push(ItemId("partly-stored".into())); + Ok(page) + } + async fn get(&self, req: GetRequest) -> Result> { + self.0.get(req).await + } + async fn erase(&self, _: EraseRequest) -> Result { + panic!("a legacy tree with a partly stored item is never erased scope-wide"); + } +} + +#[tokio::test] +async fn a_partly_stored_legacy_item_blocks_the_scope_wide_erase() { + let store = MemoryStateStore::default(); + let legacy = Arc::new(PartlyStored(ReferenceEngine::new())); + for i in 0..5 { + legacy + .store(StoreItem::learning( + format!("fact {i}"), + LearningKind::Fact, + 0.5, + MemoryMeta::default(), + )) + .await + .unwrap(); + } + let engines = Engines { + legacy: legacy.clone(), + tree: Arc::new(ReferenceEngine::new()), + }; + let mut state = copied(&store, &engines).await; + cleanup( + &store, + &engines, + &placement(), + false, + false, + &mut state, + || async { false }, + ) + .await + .unwrap(); + assert_eq!(state.phase, Phase::Cleaned); + assert!( + texts(legacy.as_ref()).await.is_empty(), + "moved items forgotten by id" + ); +} From a2fb39829ec279fd9f7a327f5a682d171cda4a5f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 05:08:20 +0530 Subject: [PATCH 09/11] test(import): pin the checkpoint when the one-by-one fallback stops When the engine refuses a bulk store because of an item, the importer stores the batch one item at a time. If a later item then fails with a non-skippable error, the checkpoint must stay on the last item actually stored. No test covered that path. The scripted engine gains a mode that refuses every bulk store and goes unavailable after N single stores. --- .../tests/legacy_importer.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/tinymemory-integrations/tests/legacy_importer.rs b/crates/tinymemory-integrations/tests/legacy_importer.rs index 27b74d7d..a9306e91 100644 --- a/crates/tinymemory-integrations/tests/legacy_importer.rs +++ b/crates/tinymemory-integrations/tests/legacy_importer.rs @@ -33,12 +33,16 @@ enum Mode { Credits, /// Refuses these legacy ids as invalid, taking the rest. Refuse(HashSet), + /// Refuses every bulk store as invalid, so items go one at a time, and + /// is unavailable after this many single stores. + DownAfter(usize), } struct Scripted { inner: ReferenceEngine, mode: Mutex, calls: AtomicUsize, + singles: AtomicUsize, } impl Scripted { @@ -47,6 +51,7 @@ impl Scripted { inner: ReferenceEngine::new(), mode: Mutex::new(mode), calls: AtomicUsize::new(0), + singles: AtomicUsize::new(0), }) } @@ -72,6 +77,16 @@ impl Scripted { Ok(()) } } + Mode::DownAfter(_) if items.len() > 1 => { + Err(Error::InvalidRequest("batch too large".into())) + } + Mode::DownAfter(limit) => { + if self.singles.fetch_add(1, Ordering::SeqCst) < limit { + Ok(()) + } else { + Err(Error::Unavailable("engine down".into())) + } + } } } } @@ -524,3 +539,17 @@ fn the_persisted_file_keeps_its_wire_shape() { serde_json::from_str(r#"{"state":{"phase":"done","imported":2,"total":2}}"#).unwrap(); assert_eq!(old.state.phase, ImportPhase::Done); } + +#[tokio::test] +async fn a_failure_in_the_one_by_one_fallback_checkpoints_the_last_stored_item() { + let dir = legacy(); + let engine = Scripted::new(Mode::DownAfter(5)); + let rig = rig(dir.path(), engine, true); + let file = import(&rig).await; + assert_eq!(file.state.imported, 5); + assert_eq!( + file.checkpoint.documents.as_deref(), + Some("d004"), + "the checkpoint must not pass an item that was never stored" + ); +} From a1ec38b9016f8310191a48bea8d6994faf2fb1da Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 05:10:23 +0530 Subject: [PATCH 10/11] test(cortex): pin that erasing only a proxied tenant's node never purges it On the proxied wire an erase of a tenant's user node purges the whole tenant only when it also reaches every descendant and every kind. The kinds case was tested; the exact-reach case was not, so dropping the descendants check went unnoticed. --- .../src/cortex/engine/mod_proxied_tests.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/tinymemory-integrations/src/cortex/engine/mod_proxied_tests.rs b/crates/tinymemory-integrations/src/cortex/engine/mod_proxied_tests.rs index 8a0883f5..99fd0330 100644 --- a/crates/tinymemory-integrations/src/cortex/engine/mod_proxied_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/engine/mod_proxied_tests.rs @@ -184,6 +184,28 @@ async fn a_narrower_erasure_forgets_each_scopes_events() { all_named(&proxy, "alice"); } +#[tokio::test] +async fn erasing_only_the_tenant_node_does_not_purge_the_tenant() { + let (endpoint, proxy) = proxied_double().await; + let engine = proxied_engine(&endpoint); + engine.store(learning("user:alice")).await.unwrap(); + engine + .store(learning("user:alice/agent:helper")) + .await + .unwrap(); + let alice = proxy.tenant("alice").unwrap(); + assert_eq!(alice.event_count(), 2); + engine + .erase(EraseRequest::new(Reach::exact( + "user:alice".parse().unwrap(), + ))) + .await + .unwrap(); + assert!(proxy.purged.lock().unwrap().is_empty(), "no tenant purge"); + assert_eq!(alice.event_count(), 1, "the agent's learning below is kept"); + all_named(&proxy, "alice"); +} + #[tokio::test] async fn erasing_the_whole_tree_names_no_tenant_and_is_refused() { let (endpoint, proxy) = proxied_double().await; From 0ab030f90752abf4f4fec324843d8b4833b450ba Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 05:15:27 +0530 Subject: [PATCH 11/11] test(cortex): pin forget batching and a retry that finds the events gone forget_events sends at most FORGET_BATCH (100) ids per request, and a hosted retry that answers 404 means an earlier send already removed them, which is success. Neither was tested. The new test forgets 250 ids against a backend that fails each batch's first send with 503 and answers its retry 404. --- .../src/cortex/engine/mod_hosted_tests.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/tinymemory-integrations/src/cortex/engine/mod_hosted_tests.rs b/crates/tinymemory-integrations/src/cortex/engine/mod_hosted_tests.rs index 6b6f9b41..0028641b 100644 --- a/crates/tinymemory-integrations/src/cortex/engine/mod_hosted_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/engine/mod_hosted_tests.rs @@ -521,3 +521,44 @@ async fn a_visibility_timeout_says_whether_the_listing_was_rate_limited() { "{missing}" ); } + +#[tokio::test] +async fn a_forget_is_sent_in_batches_and_a_retry_that_finds_them_gone_succeeds() { + use axum::http::StatusCode; + use axum::routing::post; + use axum::{Json, Router}; + use std::sync::Mutex; + let bodies: Arc>> = Arc::default(); + let seen = bodies.clone(); + // Each batch's first send fails transiently; its retry finds the + // events already gone (404), which is success for an idempotent removal. + let app = Router::new().route( + "/memory/forget", + post(move |Json(body): Json| { + let seen = seen.clone(); + async move { + let mut seen = seen.lock().unwrap(); + seen.push(body); + if seen.len() % 2 == 1 { + (StatusCode::SERVICE_UNAVAILABLE, Json(serde_json::json!({}))) + } else { + (StatusCode::NOT_FOUND, Json(serde_json::json!({}))) + } + } + }), + ); + let engine = hosted_engine(&serve(app).await); + let ids: Vec = (0..250).map(|i| format!("e{i}")).collect(); + engine + .log + .forget_events("user:alice/app:learnings", &ids) + .await + .unwrap(); + let sizes: Vec = bodies + .lock() + .unwrap() + .iter() + .map(|body| body["selector"]["memory_ids"].as_array().unwrap().len()) + .collect(); + assert_eq!(sizes, vec![100, 100, 100, 100, 50, 50]); +}