Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6018a91
Merge pull request #305 from senamakel/epic/8089-test-ownership
senamakel Oct 22, 2026
2eb32fb
fix(api): count a plan refusal as account-wide
M3gA-Mind Oct 22, 2026
1be003e
Merge pull request #306 from M3gA-Mind/fix/plan-refusal-account-wide
M3gA-Mind Oct 22, 2026
fd1b072
fix(deletion): never overwrite an unreadable queue; hold unsaved dele…
M3gA-Mind Oct 22, 2026
3143f38
fix(deletion): clear held deletions once a save succeeds
M3gA-Mind Oct 22, 2026
b09bbb0
fix(tinyredact): redact cards next to other digits and quoted or pref…
M3gA-Mind Oct 22, 2026
dd7f427
fix(tinyredact): redact home paths in a URI's query or fragment
M3gA-Mind Oct 22, 2026
1f4681b
fix(tinyredact): drop the URI end offset nothing reads any more
M3gA-Mind Oct 22, 2026
7cfe89e
Merge pull request #307 from M3gA-Mind/fix/deletion-unreadable-queue
M3gA-Mind Oct 22, 2026
09e3741
Merge pull request #308 from M3gA-Mind/fix/tinyredact-card-and-creden…
M3gA-Mind Oct 22, 2026
4f5145c
Merge pull request #309 from M3gA-Mind/fix/tinyredact-home-path-in-ur…
M3gA-Mind Oct 22, 2026
528e35c
fix(tinyredact): keep a partial overlap's tail redacted
M3gA-Mind Oct 22, 2026
aecb071
test(layout_migration): pin that a partly stored legacy item blocks t…
M3gA-Mind Oct 22, 2026
a2fb398
test(import): pin the checkpoint when the one-by-one fallback stops
M3gA-Mind Oct 22, 2026
a1ec38b
test(cortex): pin that erasing only a proxied tenant's node never pur…
M3gA-Mind Oct 22, 2026
0ab030f
test(cortex): pin forget batching and a retry that finds the events gone
M3gA-Mind Oct 22, 2026
58f36a0
Merge pull request #310 from M3gA-Mind/fix/pii-merge-partial-overlaps
M3gA-Mind Oct 23, 2026
ce38a02
Merge pull request #311 from M3gA-Mind/test/cleanup-partly-stored-leg…
M3gA-Mind Oct 23, 2026
0638374
Merge pull request #312 from M3gA-Mind/test/importer-fallback-checkpoint
M3gA-Mind Oct 23, 2026
0544a41
Merge pull request #313 from M3gA-Mind/test/proxied-exact-reach-erase
M3gA-Mind Oct 23, 2026
8b919fd
Merge pull request #314 from M3gA-Mind/test/forget-batches-and-gone-r…
M3gA-Mind Oct 23, 2026
6e1fb86
Merge remote-tracking branch 'senamakel/epic/8089-test-ownership' int…
senamakel Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions crates/tinymemory-api/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -62,15 +66,36 @@ 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]
pub fn is_account_wide(&self) -> bool {
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.
Expand Down
16 changes: 16 additions & 0 deletions crates/tinymemory-api/src/error/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<Vec<serde_json::Value>>> = 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<serde_json::Value>| {
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<String> = (0..250).map(|i| format!("e{i}")).collect();
engine
.log
.forget_events("user:alice/app:learnings", &ids)
.await
.unwrap();
let sizes: Vec<usize> = 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]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions crates/tinymemory-integrations/src/cortex/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ pub type Result<T> = std::result::Result<T, Error>;
/// 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 {
Expand Down Expand Up @@ -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)]
Expand Down
15 changes: 1 addition & 14 deletions crates/tinymemory-integrations/src/sources/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions crates/tinymemory-integrations/tests/legacy_importer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ enum Mode {
Credits,
/// Refuses these legacy ids as invalid, taking the rest.
Refuse(HashSet<String>),
/// 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<Mode>,
calls: AtomicUsize,
singles: AtomicUsize,
}

impl Scripted {
Expand All @@ -47,6 +51,7 @@ impl Scripted {
inner: ReferenceEngine::new(),
mode: Mutex::new(mode),
calls: AtomicUsize::new(0),
singles: AtomicUsize::new(0),
})
}

Expand All @@ -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()))
}
}
}
}
}
Expand Down Expand Up @@ -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"
);
}
Loading
Loading