Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8def817
refactor(cortex): simplify forget log entry handling
senamakel Oct 8, 2026
3d38a34
feat(cortex): support derived_only forget cascade
senamakel Oct 8, 2026
eacfc13
test(cortex): assert forget requests name the redact_events cascade
senamakel Oct 8, 2026
309533e
fix(cortex): erase log entries by id instead of by index
senamakel Oct 8, 2026
9357142
fix(cortex): surface transport failures instead of swallowing them
senamakel Oct 8, 2026
34fd2f9
feat(cortex): add testing routes for cortex integration
senamakel Oct 8, 2026
c9860cf
test(cortex): cover hosted erase passthrough and polling
senamakel Oct 8, 2026
06e37b5
docs(cortex): document hosted erase and forget cascade
senamakel Oct 8, 2026
76cf658
test(integrations): clarify erasure test comment
senamakel Oct 8, 2026
f77ef49
Merge origin/main into memory-deletion
senamakel Oct 8, 2026
f71c7c4
Merge remote-tracking branch 'origin/main' into memory-deletion
senamakel Oct 8, 2026
af13aeb
feat(cortex): add erase support to the log and engine
senamakel Oct 8, 2026
2d8ad43
test(cortex): add direct, erase, and transport failure tests
senamakel Oct 8, 2026
ed0e299
test(cortex): use CortexLog::default in direct tests
senamakel Oct 8, 2026
9f438b4
fix(cortex): retry only erasures proven incomplete
senamakel Oct 8, 2026
5d947c0
fix(cortex): reject out-of-range scopes count in erasure responses
senamakel Oct 8, 2026
5bcab70
test(cortex): cover unknown poll statuses and cross-engine erasure
senamakel Oct 8, 2026
50ff48a
fix(cortex): reject erasure answers that report scopes without receipts
senamakel Oct 8, 2026
ac0fabe
fix(cortex): reject blank hosted erasure receipts
senamakel Oct 8, 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
15 changes: 9 additions & 6 deletions crates/tinymemory-integrations/src/cortex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,15 @@ as prefixes, so they cannot be labelled and are filtered only client-side.
- **Forget.** `Ids` looks the items' labels up in every scope the engine
holds. `Filter` (which must be non-empty) walks the scopes it reads and
matches the full filter. Either way the matched events are then removed with
`selector.memory_ids`, in batches of 100. An empty selector is never sent,
and neither is `confirm_all`. `forgotten` counts items.
- **Erase.** Hosted erases only the whole tree (`whole_tree`), in one
`DELETE memory` that erases the caller's entire hosted memory, and refuses
anything narrower with `Unsupported`, because the backend proxies no
per-scope erasure. Direct lists the registered kind scopes in reach
`selector.memory_ids` and `cascade: "redact_events"`, in batches of 100.
The cascade is always named: CortexDB's default, `derived_only`, keeps the
events. An empty selector is never sent, and neither is `confirm_all`. `forgotten` counts items.
- **Erase.** Hosted erases the whole tree (`whole_tree`) in one
`DELETE memory` that erases the caller's entire hosted memory. Anything
narrower goes scope by scope, as on Direct, through the backend's
`memory/v1/erasures` passthrough (`{scope, audit_note}`, memory-api's
synchronous scoped erasure, unwrapped; a retriable `502
ERASURE_INCOMPLETE` is retried; a missing route is `Unsupported`). Direct lists the registered kind scopes in reach
with the complete scope listing, then sends `v1/erasures` with
`confirm_all` (never a selector) once per scope, deepest first, and
returns the erasure ids as receipts. CortexDB deletes an erased scope's
Expand Down
11 changes: 6 additions & 5 deletions crates/tinymemory-integrations/src/cortex/descriptor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ pub enum CortexWire {
/// and a bulk append route.
Direct,
/// CortexDB behind the TinyHumans backend's `/memory/*` routes:
/// `{success,data}` envelopes, typed `errorCode` failures, a strict answer
/// `{success,data}` envelopes (except the `/memory/v1/*` passthrough,
/// which answers in CortexDB's dialect), typed `errorCode` failures, a strict answer
/// schema, `Idempotency-Key` claims on writes, and no bulk, wait or health
/// route.
TinyHumans,
Expand Down Expand Up @@ -168,10 +169,10 @@ impl CortexWire {
(Self::TinyHumans, Route::BuildBeliefs) => "memory/beliefs/build",
// Never sent: hosted beliefs are read through recall only.
(Self::TinyHumans, Route::Beliefs) => "memory/beliefs",
// Never sent: the backend proxies no per-scope erasure route, so
// an `erase` narrower than the whole tree refuses on this wire
// without a request.
(Self::TinyHumans, Route::Erasures) => "memory/erasures",
// The backend's CortexDB-dialect passthrough (no envelope);
// memory-api pins the scope under the tenant's root. An `erase`
// narrower than the whole tree goes here, scope by scope.
(Self::TinyHumans, Route::Erasures) => "memory/v1/erasures",
Comment thread
senamakel marked this conversation as resolved.
// `DELETE memory`: erases the caller's entire hosted memory.
(Self::TinyHumans, Route::EraseAll) => "memory",
// Never sent: the hosted backend keeps its own tenancy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ impl CortexEngine {
visibility,
settle: visibility,
poll: Duration::from_millis(5),
erasure: Duration::from_millis(300),
};
self.log.client.set_read_backoff(Duration::from_millis(5));
self
Expand Down
29 changes: 13 additions & 16 deletions crates/tinymemory-integrations/src/cortex/engine/erase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,26 @@
//! first, so a layout that ever put a scope below another would not strand
//! redacted events behind held keys.
//!
//! On the TinyHumans wire only the whole tree erases (`whole_tree`: the
//! root, its descendants, every kind), in one `DELETE memory` that erases the
//! On the TinyHumans wire the whole tree (`whole_tree`: the root, its
//! descendants, every kind) erases in one `DELETE memory` that erases the
//! caller's entire hosted memory, every scope under its tenant (including any
//! another layout or client wrote there). The backend proxies no per-scope
//! erasure, so a narrower request refuses with `Unsupported` and sends
//! nothing.
//! another layout or client wrote there). A narrower request erases scope by
//! scope, as Direct does, through the backend's `memory/v1/erasures`
//! passthrough, which memory-api pins under the tenant's root (see
//! `log::erase`). A backend without that route answers `Unsupported`, so a
//! caller can fall back to `forget`.

use tinymemory_api::{EraseReport, EraseRequest, ItemKind};

use super::CortexEngine;
use crate::cortex::descriptor::CortexWire;
use crate::cortex::error::{Error, Result};
use crate::cortex::error::Result;

impl CortexEngine {
/// See the module docs.
pub(super) async fn erase_scopes(&self, req: EraseRequest) -> Result<EraseReport> {
req.validate()?;
if self.log.client.wire() == CortexWire::TinyHumans {
if !is_whole_tree(&req) {
return Err(Error::Unsupported(
"the TinyHumans backend erases only the whole memory (whole_tree)".to_string(),
));
}
if self.log.client.wire() == CortexWire::TinyHumans && is_whole_tree(&req) {
let erased_scopes = self.log.erase_all().await?;
log::debug!("[cortex] erased the whole hosted memory ({erased_scopes} scopes)");
return Ok(EraseReport {
Expand All @@ -48,10 +45,10 @@ impl CortexEngine {
scopes.sort_by_key(|scope| std::cmp::Reverse(scope.path.matches('/').count()));
let mut report = EraseReport::default();
for scope in scopes {
let receipt = self.log.erase(&scope.path).await?;
log::debug!("[cortex] erased {} ({receipt})", scope.path);
report.erased_scopes += 1;
report.receipts.push(receipt);
let erased = self.log.erase(&scope.path).await?;
Comment thread
senamakel marked this conversation as resolved.
log::debug!("[cortex] erased {} ({erased:?})", scope.path);
report.erased_scopes = report.erased_scopes.saturating_add(erased.scopes);
report.receipts.extend(erased.ids);
}
Ok(report)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,26 @@ async fn forget_batches_event_ids_at_one_hundred() {
assert_eq!(state.event_count(), 0);
}

#[tokio::test]
async fn every_forget_names_the_cascade_that_removes_the_events() {
// CortexDB's default cascade (`derived_only`) keeps the events, so a
// forget that left it to the default would delete nothing written.
let (endpoint, state) = direct_double().await;
Comment thread
senamakel marked this conversation as resolved.
let engine = direct_engine(&endpoint);
let item = sample_items().remove(0);
engine.store(item.clone()).await.unwrap();
engine
.forget(ForgetTarget::Ids(vec![item.fingerprint().into()]))
.await
.unwrap();
let forgets = state.seen.lock().unwrap().forgets.clone();
assert!(!forgets.is_empty());
for body in &forgets {
assert_eq!(body["cascade"], "redact_events", "{body}");
}
assert_eq!(state.event_count(), 0);
}

#[tokio::test]
async fn a_partially_applied_conversation_completes_on_retry() {
let (endpoint, state) = direct_double().await;
Expand Down Expand Up @@ -279,3 +299,18 @@ async fn reads_wait_out_a_scope_authorization_change() {
"six attempts, then it gives up"
);
}

#[test]
fn the_double_refuses_a_cascade_that_is_not_a_known_string() {
let mut log = crate::cortex::testing::CortexLog::default();
for cascade in [
serde_json::json!(null),
serde_json::json!(123),
serde_json::json!("nope"),
] {
let (code, answer) = log.forget(&serde_json::json!({
"scope": "app:x", "confirm_all": true, "cascade": cascade
}));
assert_eq!(code, 400, "{cascade}: {answer}");
}
}
Loading
Loading