lunaris/forget.rs
1//! Plan 04-05 — `Lunaris::forget(target) -> ForgetReceipt`.
2//!
3//! Single-entry-point ergonomic API closing OPS-01 (id), OPS-02 (scope),
4//! OPS-03 (before), OPS-04 (audit on every call). Three-variant builder with
5//! `.hard()` / `.dry_run()` flags + `ForgetConfirmation` two-step token for
6//! the D-21 hard-delete safety rail.
7//!
8//! ## Fixes baked in
9//!
10//! - **B-3**: hard-delete without token returns
11//! `LunarisError::Validate(ValidateError::ConfirmationRequired(_))` — uses
12//! the typed variant added in Task 0; NOT the non-existent
13//! `LunarisError::Validate(String)` shape.
14//! - **B-4**: every HLC stamp comes from `clock.tick()` (HlcClock method);
15//! `Hlc::now()` does not exist in the codebase.
16//! - **B-5**: soft-delete mutates the typed `BiTemporal::invalidate_sys(now)`
17//! helper. The mutated `bt` is then JSON-patched into `payload["bt"]["sys"][1]`
18//! so backends that derive persisted bt from the payload bytes (Moon HSET
19//! layer + Postgres row storage) see the change. Mirrors Plan 04-04 Task 4
20//! `apply_supersede` verbatim.
21//! - **W-1**: `match_before` deserializes `bt.valid.0` into a typed `Hlc` and
22//! uses the `<` operator (Hlc derives Ord). NOT a `format!("{hlc:?}")` lex
23//! compare.
24//! - **D-19 single atomic_write invariant**: every successful forget call
25//! issues at most ONE `storage.atomic_write` (zero for dry_run / no-match).
26//! - **D-22 audit emit**: every successful forget call ends with one
27//! `audit::publish_audit_event(storage, AuditEvent::Forget(receipt))`.
28
29use lunaris_core::storage::types::{Lsn, WriteOp};
30use lunaris_core::{
31 BiTemporal, Hlc, HlcClock, LunarisError, StorageError, StoragePort, ValidateError,
32};
33use serde::{Deserialize, Serialize};
34// Plan 05-05 OPS-05 — `Instrument::instrument` wraps the per-call body in the
35// `lunaris.forget` info_span so per-call `correlation_id` field-recording
36// + downstream child-span propagation works (CONTEXT.md D-24).
37use tracing::Instrument;
38use ulid::Ulid;
39
40use crate::audit::{AuditEvent, publish_audit_event};
41use crate::handle::Lunaris;
42
43// ---------------------------------------------------------------------------
44// Public DTOs (locked by Task 1 stub; Task 2 only adds the impl block + helpers)
45// ---------------------------------------------------------------------------
46
47/// Single-entry-point target for `Lunaris::forget` per D-18. Three variants
48/// closing OPS-01 / OPS-02 / OPS-03.
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[non_exhaustive]
51pub enum ForgetTarget {
52 /// OPS-01: single-target purge across KV + vector + graph indices.
53 Id(Ulid),
54 /// OPS-02: scope purge — soft-delete by default; `.hard()` requires
55 /// confirmation token (D-21 safety rail).
56 Scope(ScopeSpec),
57 /// OPS-03: temporal-bound purge using AS_OF semantics (D-19).
58 Before(Hlc),
59}
60
61/// Match language for `ForgetTarget::Scope` per D-20. v0 supports prefix-match
62/// on `source` (the helios:fs/ session-pruning case), exact metadata kv match,
63/// and exact episode-id match. Richer predicate languages are v1.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65#[non_exhaustive]
66pub enum ScopeSpec {
67 /// Prefix match on the JSON `source` field (e.g., `"helios:fs/session-42/"`).
68 BySource(String),
69 /// Exact match on a single `metadata.<key> == <value>` pair.
70 ByMetadata(String, String),
71 /// Exact match on the JSON `id` field.
72 ByEpisode(Ulid),
73}
74
75/// Tag for which storage indices a forget call touched. Mirrors the
76/// `lunaris_consolidate::types::IndexKind` shape so Plan 04-05 can wire them
77/// 1:1 when the audit emit surface is unified.
78#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
79#[non_exhaustive]
80pub enum IndexKind {
81 Kv,
82 Vector,
83 Graph,
84}
85
86/// Receipt returned by every `Lunaris::forget` call. Carries enough
87/// information for the caller to reconstruct what was attempted (preview)
88/// vs what was committed (rows_written / rows_deleted).
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90pub struct ForgetReceipt {
91 pub target: ForgetTarget,
92 pub indices_affected: Vec<IndexKind>,
93 /// **Episodes** the target matched — the blast radius a committing call
94 /// would have, counted in the unit the caller reasons in.
95 ///
96 /// Deliberately NOT the row count. W1.4 made forget sweep each matched
97 /// episode's chunk rows, so `rows_written` / `rows_deleted` now report 2+
98 /// where they used to report 1 — but "I am about to delete 2 things" is a
99 /// worse answer to `memory.forget --dry-run` than "1 episode", and the
100 /// MCP-facing DTO in `lunaris-memory-service` documents this as episodes.
101 /// Rows are an implementation detail of how an episode is stored; the
102 /// episode is what the caller asked to forget.
103 ///
104 /// Populated on EVERY path, including `dry_run`, where `rows_written` and
105 /// `rows_deleted` are both zero by construction: a preview whose only
106 /// counters are zeroes tells the caller nothing about the blast radius it
107 /// is being asked to confirm (0.6.2 Task F — the MCP `memory.forget`
108 /// preview surfaces this as `matched`).
109 ///
110 /// `#[serde(default)]` keeps receipts minted by pre-0.6.2 servers
111 /// deserializable — `POST /v1/forget` carries a serialized prior receipt
112 /// in `confirmation_token`, so an old client's token must still parse.
113 #[serde(default)]
114 pub matched: u64,
115 /// Soft-delete MVCC writes (zero for hard / dry-run).
116 pub rows_written: u64,
117 /// Irreversible deletes (hard-only; zero for soft / dry-run).
118 pub rows_deleted: u64,
119 /// `__lunaris_audit__` publish offset.
120 pub audit_lsn: Lsn,
121 /// `true` iff this was a `.dry_run()` preview that did NOT call
122 /// `atomic_write`.
123 pub preview: bool,
124}
125
126/// Opaque confirmation token returned by `Lunaris::confirm_hard_forget(dry_run)`
127/// per D-21 hard-delete safety rail. Cannot be constructed by the caller —
128/// only returned from the two-step protocol.
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130pub struct ForgetConfirmation {
131 pub(crate) for_audit_lsn: Lsn,
132}
133
134// ---------------------------------------------------------------------------
135// Builder (added in Task 2)
136// ---------------------------------------------------------------------------
137
138/// Internal options carried by `ForgetRequest`. Public so callers can match
139/// on the receipt's preview field without reaching into private state.
140#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
141pub struct ForgetOptions {
142 pub hard: bool,
143 pub dry_run: bool,
144 pub confirmation_token: Option<ForgetConfirmation>,
145}
146
147/// Combined target + options. The `From<ForgetTarget>` impl below means
148/// callers can pass a bare `ForgetTarget` to `Lunaris::forget(...)` for the
149/// soft-delete default case AND chain `.hard()` / `.dry_run()` on the target
150/// to get a full request.
151///
152/// Plan 08-02 Rule 3 additive: `Serialize + Deserialize` added so the codegen
153/// PyO3 wrapper for `Lunaris::forget` can accept a Python dict via
154/// `pythonize::depythonize`. Both inner fields (`ForgetTarget`, `ForgetOptions`)
155/// already derived these traits, so the change is mechanical with no
156/// semantic surface shift.
157#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
158pub struct ForgetRequest {
159 pub target: ForgetTarget,
160 pub options: ForgetOptions,
161}
162
163impl ForgetTarget {
164 /// Mark the request as a hard (irreversible) delete. The user MUST also
165 /// call `.with_token(token)` from a prior `confirm_hard_forget(dry_run)`
166 /// or the call returns
167 /// `LunarisError::Validate(ValidateError::ConfirmationRequired(_))` per
168 /// the D-21 safety rail (B-3 typed variant).
169 pub fn hard(self) -> ForgetRequest {
170 ForgetRequest { target: self, options: ForgetOptions { hard: true, ..Default::default() } }
171 }
172
173 /// Build a preview request that does NOT call `atomic_write`. Returns a
174 /// `ForgetReceipt { preview: true, rows_written: 0, ... }` so the caller
175 /// can inspect what would have been touched + (for hard delete) feed the
176 /// receipt to `confirm_hard_forget` to receive a token.
177 pub fn dry_run(self) -> ForgetRequest {
178 ForgetRequest {
179 target: self,
180 options: ForgetOptions { dry_run: true, ..Default::default() },
181 }
182 }
183
184 /// Wrap as a soft-delete request (default options).
185 pub fn into_request(self) -> ForgetRequest {
186 ForgetRequest { target: self, options: ForgetOptions::default() }
187 }
188}
189
190impl ForgetRequest {
191 /// Attach a confirmation token from a prior `confirm_hard_forget` call.
192 /// Required for `.hard()` requests per the D-21 safety rail.
193 pub fn with_token(mut self, token: ForgetConfirmation) -> Self {
194 self.options.confirmation_token = Some(token);
195 self
196 }
197
198 /// Convenience constructor for fully-confirmed hard delete in one expression.
199 pub fn hard_confirmed(target: ForgetTarget, token: ForgetConfirmation) -> Self {
200 ForgetRequest {
201 target,
202 options: ForgetOptions { hard: true, dry_run: false, confirmation_token: Some(token) },
203 }
204 }
205}
206
207impl From<ForgetTarget> for ForgetRequest {
208 fn from(target: ForgetTarget) -> Self {
209 target.into_request()
210 }
211}
212
213// ---------------------------------------------------------------------------
214// Lunaris impl block — `forget` + `confirm_hard_forget`
215// ---------------------------------------------------------------------------
216
217impl Lunaris {
218 /// `Lunaris::forget(target) -> Result<ForgetReceipt, LunarisError>` —
219 /// **DEPRECATED** as of P0 #1 Wave 2. Use
220 /// `engine.scoped(scope).forget(request)` instead so the forget call
221 /// inherits the bound partition key. The legacy path routes its
222 /// internal storage calls through `Scope::dev()` and silently returns
223 /// `rows_deleted = 0, rows_written = 0` under any non-`_dev_` scope.
224 /// See `docs/v0.3-known-debt.md` for the removal plan (v0.4 task).
225 ///
226 /// ## Behaviour matrix (unchanged from v0.2)
227 ///
228 /// | Request | Result |
229 /// |--------------------------------------|-----------------------------------------|
230 /// | `target` (soft) | One `atomic_write` of MVCC sys_to writes |
231 /// | `target.dry_run()` | No `atomic_write`; preview receipt |
232 /// | `target.hard()` (no token) | `Err(Validate(ConfirmationRequired))` |
233 /// | `target.hard().with_token(t)` | One `atomic_write` of `KvDelete` ops |
234 // scope-dev-allowed: deprecation-note — message text references the
235 // marker for adopters reading the rustc warning.
236 #[deprecated(
237 since = "0.3.0",
238 note = "use `engine.scoped(scope).forget(request)` — Lunaris::forget routes through Scope::dev() and returns zero matches under any non-_dev_ scope"
239 )]
240 pub async fn forget(
241 &self,
242 request: impl Into<ForgetRequest>,
243 ) -> Result<ForgetReceipt, LunarisError> {
244 let request = request.into();
245
246 // Plan 05-05 OPS-05 — `lunaris.forget` root span (CONTEXT.md D-24).
247 // `correlation_id` reserved as `tracing::field::Empty` for HTTP-layer
248 // propagation. `target` carries only the variant tag (`id` / `scope`
249 // / `before`) — the actual ulid / scope spec is NOT logged
250 // (T-05-05-04 accept disposition; richer redaction lands in v2
251 // OPS-V2-01).
252 let target_kind = match &request.target {
253 ForgetTarget::Id(_) => "id",
254 ForgetTarget::Scope(_) => "scope",
255 ForgetTarget::Before(_) => "before",
256 };
257 let span = tracing::info_span!(
258 "lunaris.forget",
259 correlation_id = tracing::field::Empty,
260 target = %target_kind,
261 hard = %request.options.hard,
262 dry_run = %request.options.dry_run,
263 );
264 async move {
265 // P-2 (v0.2 review) — operator-visible warning that this path is
266 // hard-coded to `Scope::dev()` for `atomic_write`, `read_as_of`,
267 // and `scan_range`. Under any non-`_dev_` scope the call silently
268 // returns `rows_deleted = 0, rows_written = 0` because RLS
269 // (Postgres) or the SCAN prefix (Moon) filters everything out.
270 // The full per-scope routing lands in v0.3 as
271 // `ScopedLunaris::forget(target)`. Emitting this every call (not
272 // once-per-process) so operators chasing "why didn't my forget
273 // work" see the warning in the line right above their forget
274 // invocation. Suppressible via the standard tracing filter
275 // (`LUNARIS_LOG=lunaris::forget=error` or similar).
276 // scope-dev-allowed: warn-string-mentions-marker — operator
277 // readability text; ScopedLunaris::forget is the canonical fix.
278 tracing::warn!(
279 target: "lunaris::forget",
280 "Lunaris::forget is hard-coded to Scope::dev() in v0.2.x — \
281 same-scope forget under any non-`_dev_` scope silently \
282 returns zero matches. ScopedLunaris::forget(target) lands \
283 in v0.3. See CHANGELOG.md v0.2.0 Known issues."
284 );
285
286 // B-3: hard delete must carry a confirmation token. Returns the typed
287 // ValidateError::ConfirmationRequired variant added in Task 0.
288 if request.options.hard && request.options.confirmation_token.is_none() {
289 return Err(LunarisError::Validate(ValidateError::ConfirmationRequired(
290 "hard-delete requires confirmation token from prior dry_run".into(),
291 )));
292 }
293
294 // B-4: scan_matches takes &HlcClock so it can call clock.tick() to
295 // stamp the as_of for the OPS-01 single-target read_as_of path.
296 let mut matches =
297 scan_matches(self.storage.as_ref(), &request.target, self.clock.as_ref()).await?;
298 // Same fix as the scoped path below. Deprecated or not, re-stamping
299 // an already-tombstoned row is a write that changes nothing and a
300 // `matched` that overstates what happened.
301 drop_already_closed(&mut matches, request.options.hard);
302
303 if request.options.dry_run {
304 // Dry-run: no atomic_write. Receipt carries preview=true.
305 let receipt = ForgetReceipt {
306 target: request.target.clone(),
307 indices_affected: classify_indices(&request.target),
308 matched: matches.len() as u64,
309 rows_written: 0,
310 rows_deleted: 0,
311 audit_lsn: Lsn { wall_ms: 0, counter: 0 },
312 preview: true,
313 };
314 let audit_offset = publish_audit_event(
315 &self.storage,
316 // The deprecated unscoped entry point already reads and
317 // writes under `Scope::dev()` (see the `atomic_write`
318 // in this same function); auditing anywhere else would
319 // file the receipt against a partition the operation
320 // never touched.
321 // scope-dev-allowed: deprecated-Lunaris::forget-routes-here-until-v0.4
322 &lunaris_core::Scope::dev(),
323 AuditEvent::Forget((&receipt).into()),
324 )
325 .await
326 .unwrap_or(0);
327 return Ok(ForgetReceipt {
328 audit_lsn: Lsn { wall_ms: audit_offset, counter: 0 },
329 ..receipt
330 });
331 }
332
333 // Build the WriteOp set: hard → KvDelete; soft → KvPut with MVCC
334 // sys_to-stamped payload (B-4 + B-5).
335 let ops: Vec<WriteOp> = if request.options.hard {
336 matches.iter().map(|m| WriteOp::KvDelete { key: m.key.clone() }).collect()
337 } else {
338 matches
339 .iter()
340 .map(|m| build_soft_delete_op(m, self.clock.as_ref()))
341 .collect::<Result<Vec<_>, _>>()?
342 };
343
344 // D-19 single-call invariant: at most ONE atomic_write per forget
345 // call. Skip when there are zero matches (publishing an empty op
346 // batch would be a no-op anyway and some backends reject it).
347 if !ops.is_empty() {
348 // RFC 0001 Wave 0: use Scope::dev() until per-scope routing (Wave 1D).
349 // scope-dev-allowed: deprecated-Lunaris::forget-routes-here-until-v0.4
350 let _lsn = self
351 .storage
352 .atomic_write(&lunaris_core::Scope::dev(), &ops)
353 .await
354 .map_err(LunarisError::Storage)?;
355 }
356
357 let receipt = ForgetReceipt {
358 target: request.target.clone(),
359 indices_affected: classify_indices(&request.target),
360 matched: matches.len() as u64,
361 rows_written: if request.options.hard { 0 } else { matches.len() as u64 },
362 rows_deleted: if request.options.hard { matches.len() as u64 } else { 0 },
363 audit_lsn: Lsn { wall_ms: 0, counter: 0 },
364 preview: false,
365 };
366 let audit_offset = publish_audit_event(
367 &self.storage,
368 // The deprecated unscoped entry point already reads and
369 // writes under `Scope::dev()` (see the `atomic_write`
370 // in this same function); auditing anywhere else would
371 // file the receipt against a partition the operation
372 // never touched.
373 // scope-dev-allowed: deprecated-Lunaris::forget-routes-here-until-v0.4
374 &lunaris_core::Scope::dev(),
375 AuditEvent::Forget((&receipt).into()),
376 )
377 .await
378 .unwrap_or(0);
379 Ok(ForgetReceipt { audit_lsn: Lsn { wall_ms: audit_offset, counter: 0 }, ..receipt })
380 }
381 .instrument(span)
382 .await
383 }
384
385 /// Two-step hard-delete safety rail per D-21. Caller MUST first run
386 /// `forget(target.dry_run())` to receive a preview receipt, then pass that
387 /// receipt to this method to obtain a `ForgetConfirmation` token. The
388 /// token then unlocks `forget(target.hard().with_token(token))`.
389 ///
390 /// Returns `Err(Validate(ConfirmationRequired))` when called with a
391 /// non-preview receipt (B-3 typed variant).
392 pub async fn confirm_hard_forget(
393 &self,
394 dry_run_receipt: ForgetReceipt,
395 ) -> Result<ForgetConfirmation, LunarisError> {
396 if !dry_run_receipt.preview {
397 return Err(LunarisError::Validate(ValidateError::ConfirmationRequired(
398 "confirm_hard_forget requires a dry_run receipt (preview=true)".into(),
399 )));
400 }
401 Ok(ForgetConfirmation { for_audit_lsn: dry_run_receipt.audit_lsn })
402 }
403}
404
405// ---------------------------------------------------------------------------
406// Internal helpers
407// ---------------------------------------------------------------------------
408
409/// One match yielded by [`scan_matches`]. Carries the original BiTemporal so
410/// [`build_soft_delete_op`] can mutate the typed struct via `invalidate_sys`
411/// (B-5) instead of pure JSON manipulation.
412#[derive(Clone, Debug)]
413struct ForgetMatch {
414 key: Vec<u8>,
415 payload: Vec<u8>,
416 bt: BiTemporal,
417}
418
419/// Walk the storage layer to find every primitive matching `target`. The
420/// OPS-01 single-target path uses `read_as_of` directly; OPS-02 (Scope) and
421/// OPS-03 (Before) walk `scan_range` and apply the typed predicate at the
422/// Lunaris boundary.
423///
424/// B-4: takes `&HlcClock` so each row read can stamp a fresh `now` via
425/// `clock.tick()` — preserves HLC monotony across the per-row read loop.
426async fn scan_matches(
427 storage: &dyn StoragePort,
428 target: &ForgetTarget,
429 clock: &HlcClock,
430) -> Result<Vec<ForgetMatch>, LunarisError> {
431 use futures::stream::StreamExt;
432
433 // OPS-01 fast path: single-target read_as_of. Avoid the full prefix scan.
434 if let ForgetTarget::Id(ulid) = target {
435 let key_str = format!("episode:{ulid}");
436 // B-4: clock.tick() — Hlc::now() doesn't exist in this codebase.
437 let now = clock.tick();
438 // scope-dev-allowed: deprecated-Lunaris::forget-routes-here-until-v0.4
439 let row = storage
440 .read_as_of(&lunaris_core::Scope::dev(), key_str.as_bytes(), now)
441 .await
442 .map_err(LunarisError::Storage)?;
443 return Ok(match row {
444 Some(r) => {
445 vec![ForgetMatch { key: key_str.into_bytes(), payload: r.value.to_vec(), bt: r.bt }]
446 }
447 None => Vec::new(),
448 });
449 }
450
451 // OPS-02 / OPS-03 path: prefix scan + typed predicate. v0 only walks the
452 // `episode:` prefix; richer scope languages (chunk:, fact:, ...) are v1.
453 let prefix: &[u8] = b"episode:";
454
455 // RFC 0001 Wave 0: use Scope::dev() until per-scope routing (Wave 1D).
456 // scope-dev-allowed: deprecated-Lunaris::forget-routes-here-until-v0.4
457 let mut stream = storage
458 .scan_range(&lunaris_core::Scope::dev(), prefix, None)
459 .await
460 .map_err(LunarisError::Storage)?;
461 let mut out = Vec::new();
462 while let Some(item) = stream.next().await {
463 let (k, v) = item.map_err(LunarisError::Storage)?;
464 if matches_target(&k, &v, target) {
465 // B-5: load the typed BiTemporal via read_as_of so build_soft_delete_op
466 // can mutate the typed struct via `invalidate_sys`. now is bumped
467 // per row to preserve HLC monotony.
468 let now = clock.tick();
469 // scope-dev-allowed: deprecated-Lunaris::forget-routes-here-until-v0.4
470 let row_opt = storage
471 .read_as_of(&lunaris_core::Scope::dev(), &k, now)
472 .await
473 .map_err(LunarisError::Storage)?;
474 let bt = row_opt
475 .as_ref()
476 .map(|r| r.bt)
477 .unwrap_or_else(|| BiTemporal { valid: (Hlc::ZERO, None), sys: (Hlc::ZERO, None) });
478 out.push(ForgetMatch { key: k.to_vec(), payload: v.to_vec(), bt });
479 }
480 }
481 Ok(out)
482}
483
484/// Predicate dispatch over the three target variants. The OPS-01 case is
485/// already short-circuited in [`scan_matches`] above so this only sees Scope
486/// + Before during the prefix-scan loop.
487fn matches_target(_key: &[u8], value: &[u8], target: &ForgetTarget) -> bool {
488 match target {
489 ForgetTarget::Id(_) => true,
490 ForgetTarget::Scope(spec) => match_scope(value, spec),
491 ForgetTarget::Before(hlc) => match_before(value, *hlc),
492 }
493}
494
495/// D-20 scope-match languages. v0 supports BySource (prefix), ByMetadata
496/// (exact key/value), ByEpisode (exact id). Future versions may add
497/// substring / regex / typed-filter trees.
498fn match_scope(value: &[u8], spec: &ScopeSpec) -> bool {
499 let json: serde_json::Value = match serde_json::from_slice(value) {
500 Ok(j) => j,
501 Err(_) => return false,
502 };
503 match spec {
504 ScopeSpec::BySource(prefix) => json
505 .get("source")
506 .and_then(|s| s.as_str())
507 .map(|s| s.starts_with(prefix))
508 .unwrap_or(false),
509 ScopeSpec::ByMetadata(key, expected) => json
510 .get("metadata")
511 .and_then(|m| m.get(key))
512 .and_then(|v| v.as_str())
513 .map(|v| v == expected)
514 .unwrap_or(false),
515 ScopeSpec::ByEpisode(ulid) => {
516 json.get("id").and_then(|v| v.as_str()).map(|s| s == ulid.to_string()).unwrap_or(false)
517 }
518 }
519}
520
521/// W-1 fix: typed Hlc Ord compare on the `bt.valid.0` tuple element. The
522/// previous draft used `format!("{hlc:?}")` lex compare which is incorrect
523/// (lex compare on a struct's Debug repr ≠ field-wise Ord).
524fn match_before(value: &[u8], hlc: Hlc) -> bool {
525 let json: serde_json::Value = match serde_json::from_slice(value) {
526 Ok(j) => j,
527 Err(_) => return false,
528 };
529 json.get("bt")
530 .and_then(|bt| bt.get("valid"))
531 // BiTemporal::valid serializes as a 2-tuple `[Hlc, Option<Hlc>]`.
532 // `.get(0)` indexes the first tuple element (valid_from).
533 .and_then(|valid| valid.get(0))
534 .and_then(|valid_from_json| {
535 // W-1: typed deserialize + Ord compare. Hlc derives PartialOrd + Ord.
536 serde_json::from_value::<Hlc>(valid_from_json.clone()).ok()
537 })
538 .map(|valid_from| valid_from < hlc)
539 .unwrap_or(false)
540}
541
542/// Build the soft-delete WriteOp for one matched primitive.
543///
544/// - **B-4**: `clock.tick()` for the new HLC stamp; `Hlc::now()` does not
545/// exist as a free function in this codebase.
546/// - **B-5 (typed-first)**: mutate the in-memory BiTemporal via
547/// `invalidate_sys(now)`, then JSON-patch the result into
548/// `payload["bt"]["sys"][1]` so backends that derive persisted bt from the
549/// payload bytes (Moon HSET layer, Postgres row storage) see the change.
550/// Mirrors Plan 04-04 Task 4 `apply_supersede` (B-2 + B-2-RESIDUAL fix)
551/// verbatim — `WriteOp::KvPut` has no separate `bt` field, so the mutation
552/// MUST ride inside the value bytes.
553fn build_soft_delete_op(m: &ForgetMatch, clock: &HlcClock) -> Result<WriteOp, LunarisError> {
554 // B-4: clock.tick() — HlcClock method, not Hlc::now().
555 let now = clock.tick();
556
557 // B-5 typed-first: mutate the typed BiTemporal so any downstream code
558 // using the returned struct sees the update. We then propagate the
559 // mutation into the payload bytes below so Moon/Postgres pick it up.
560 let mut bt = m.bt;
561 bt.invalidate_sys(now);
562
563 // B-5 JSON-patch: parse the existing payload, find the `bt` object's
564 // `sys` tuple, and set the second element (sys_to) to Some(now). This is
565 // the same pattern Plan 04-04 Task 4 uses for apply_supersede — the bt
566 // field is derived from the payload bytes by Moon's HSET layer + the
567 // Postgres row-storage layer, so a typed-only mutation is silently lost
568 // unless we also patch the JSON.
569 let mut json: serde_json::Value = serde_json::from_slice(&m.payload).map_err(|e| {
570 LunarisError::Storage(StorageError::Backend(format!("forget payload parse: {e}")))
571 })?;
572 if let Some(bt_obj) = json.get_mut("bt")
573 && let Some(sys_arr) = bt_obj.get_mut("sys")
574 && let Some(arr) = sys_arr.as_array_mut()
575 && arr.len() == 2
576 {
577 // Set the SECOND element (index 1 = sys_to) to the new
578 // `now`. The first element (index 0 = sys_from) stays.
579 arr[1] = serde_json::to_value(now).unwrap_or(serde_json::Value::Null);
580 }
581 let value = serde_json::to_vec(&json).map_err(|e| {
582 LunarisError::Storage(StorageError::Backend(format!("forget payload serialize: {e}")))
583 })?;
584 Ok(WriteOp::KvPut { key: m.key.clone(), value })
585}
586
587/// All forget targets affect KV + vector + graph indices in v0. The
588/// receipt carries this so callers can see the blast radius even on a
589/// `dry_run`. Future targeted forms (e.g., "vector-only forget") would
590/// return a narrower set.
591fn classify_indices(target: &ForgetTarget) -> Vec<IndexKind> {
592 match target {
593 ForgetTarget::Id(_) | ForgetTarget::Scope(_) | ForgetTarget::Before(_) => {
594 vec![IndexKind::Kv, IndexKind::Vector, IndexKind::Graph]
595 }
596 }
597}
598
599// ---------------------------------------------------------------------------
600// Wave 1D — scope-aware forget pipeline (ADD task forget-scope-routing)
601// ---------------------------------------------------------------------------
602
603/// Scope-aware forget — the canonical pipeline behind
604/// [`crate::handle::ScopedLunaris::forget`].
605///
606/// Same behaviour matrix as the deprecated dev-scope path (soft-default /
607/// dry-run preview / hard-needs-token / one `atomic_write` per call / audit
608/// on every call), with every storage operation routed through the CALLER's
609/// scope: the Id fast path reads `keyspace::episode_key(scope, ulid)`, the
610/// Scope/Before scan walks `keyspace::scope_prefix(scope) + "episode:"`, and
611/// the write commits under `scope` — never `Scope::dev()`.
612///
613/// The 2026-07-14 live deep test proved the shim it replaces silently
614/// returned `rows_written = 0` under every real scope (both prefix and
615/// exact-ULID targets) because scan + write ran in the `_dev_` partition.
616pub(crate) async fn forget_scoped(
617 storage: &std::sync::Arc<dyn StoragePort>,
618 clock: &HlcClock,
619 scope: &lunaris_core::Scope,
620 request: ForgetRequest,
621) -> Result<ForgetReceipt, LunarisError> {
622 let target_kind = match &request.target {
623 ForgetTarget::Id(_) => "id",
624 ForgetTarget::Scope(_) => "scope",
625 ForgetTarget::Before(_) => "before",
626 };
627 let span = tracing::info_span!(
628 "lunaris.forget",
629 correlation_id = tracing::field::Empty,
630 target = %target_kind,
631 scope = %scope.as_str(),
632 hard = %request.options.hard,
633 dry_run = %request.options.dry_run,
634 );
635 async move {
636 // B-3: hard delete must carry a confirmation token (D-21 rail).
637 if request.options.hard && request.options.confirmation_token.is_none() {
638 return Err(LunarisError::Validate(ValidateError::ConfirmationRequired(
639 "hard-delete requires confirmation token from prior dry_run".into(),
640 )));
641 }
642
643 let mut matches =
644 scan_matches_scoped(storage.as_ref(), scope, &request.target, clock).await?;
645 drop_already_closed(&mut matches, request.options.hard);
646 // W1.4: `matches` now holds chunk rows too. `matched` stays a count of
647 // EPISODES so the preview keeps meaning what its docs say.
648 let matched = episode_match_count(&matches, scope);
649
650 if request.options.dry_run {
651 let receipt = ForgetReceipt {
652 target: request.target.clone(),
653 indices_affected: classify_indices(&request.target),
654 matched,
655 rows_written: 0,
656 rows_deleted: 0,
657 audit_lsn: Lsn { wall_ms: 0, counter: 0 },
658 preview: true,
659 };
660 let audit_offset =
661 publish_audit_event(storage, scope, AuditEvent::Forget((&receipt).into()))
662 .await
663 .unwrap_or(0);
664 return Ok(ForgetReceipt {
665 audit_lsn: Lsn { wall_ms: audit_offset, counter: 0 },
666 ..receipt
667 });
668 }
669
670 let ops: Vec<WriteOp> = if request.options.hard {
671 matches.iter().map(|m| WriteOp::KvDelete { key: m.key.clone() }).collect()
672 } else {
673 matches.iter().map(|m| build_soft_delete_op(m, clock)).collect::<Result<Vec<_>, _>>()?
674 };
675
676 // D-19: at most ONE atomic_write per forget call — under the REAL scope.
677 if !ops.is_empty() {
678 let _lsn = storage.atomic_write(scope, &ops).await.map_err(LunarisError::Storage)?;
679 }
680
681 let receipt = ForgetReceipt {
682 target: request.target.clone(),
683 indices_affected: classify_indices(&request.target),
684 matched,
685 rows_written: if request.options.hard { 0 } else { matches.len() as u64 },
686 rows_deleted: if request.options.hard { matches.len() as u64 } else { 0 },
687 audit_lsn: Lsn { wall_ms: 0, counter: 0 },
688 preview: false,
689 };
690 let audit_offset =
691 publish_audit_event(storage, scope, AuditEvent::Forget((&receipt).into()))
692 .await
693 .unwrap_or(0);
694 Ok(ForgetReceipt { audit_lsn: Lsn { wall_ms: audit_offset, counter: 0 }, ..receipt })
695 }
696 .instrument(span)
697 .await
698}
699
700/// Scope-aware twin of [`scan_matches`]: keys are minted via
701/// `lunaris_core::keyspace` (RC-1 — no local key formats) and every read
702/// runs under the caller's scope.
703async fn scan_matches_scoped(
704 storage: &dyn StoragePort,
705 scope: &lunaris_core::Scope,
706 target: &ForgetTarget,
707 clock: &HlcClock,
708) -> Result<Vec<ForgetMatch>, LunarisError> {
709 let mut out = scan_episode_matches_scoped(storage, scope, target, clock).await?;
710
711 // W1.4: the episode row is not the content. Its chunks are, and until now
712 // nothing in a forget ever touched them.
713 //
714 // A SOFT forget appeared to work because `hydrate`'s episode pass drops any
715 // chunk whose parent episode is sys-closed. A HARD forget deletes the
716 // episode row outright, so that lookup returns `None` instead of
717 // `Some((_, true))` — and the gate stops firing. The chunk hydrates again,
718 // with an empty `source`, after a confirmed irreversible delete that
719 // reported `rows_deleted`.
720 //
721 // Reaching the chunks fixes both paths at once: soft stamps them, hard
722 // deletes them, and neither depends on the episode row surviving.
723 let episode_ids = matched_episode_ids(&out);
724 if !episode_ids.is_empty() {
725 out.extend(scan_chunk_matches_scoped(storage, scope, &episode_ids, clock).await?);
726 }
727 Ok(out)
728}
729
730/// The episode half — unchanged behaviour, lifted out so the chunk sweep below
731/// runs for the `ForgetTarget::Id` fast path too.
732async fn scan_episode_matches_scoped(
733 storage: &dyn StoragePort,
734 scope: &lunaris_core::Scope,
735 target: &ForgetTarget,
736 clock: &HlcClock,
737) -> Result<Vec<ForgetMatch>, LunarisError> {
738 use futures::stream::StreamExt;
739
740 // OPS-01 fast path: single-target read_as_of on the canonical scoped key.
741 if let ForgetTarget::Id(ulid) = target {
742 let key = lunaris_core::keyspace::episode_key(scope, *ulid);
743 let now = clock.tick();
744 let row = storage.read_as_of(scope, &key, now).await.map_err(LunarisError::Storage)?;
745 return Ok(match row {
746 Some(r) => vec![ForgetMatch { key, payload: r.value.to_vec(), bt: r.bt }],
747 None => Vec::new(),
748 });
749 }
750
751 // OPS-02 / OPS-03: walk this scope's episode partition.
752 let prefix = format!("{}episode:", lunaris_core::keyspace::scope_prefix(scope)).into_bytes();
753
754 let mut stream =
755 storage.scan_range(scope, &prefix, None).await.map_err(LunarisError::Storage)?;
756 let mut out = Vec::new();
757 while let Some(item) = stream.next().await {
758 let (k, v) = item.map_err(LunarisError::Storage)?;
759 if matches_target(&k, &v, target) {
760 let now = clock.tick();
761 let row_opt =
762 storage.read_as_of(scope, &k, now).await.map_err(LunarisError::Storage)?;
763 let bt = row_opt
764 .as_ref()
765 .map(|r| r.bt)
766 .unwrap_or_else(|| BiTemporal { valid: (Hlc::ZERO, None), sys: (Hlc::ZERO, None) });
767 out.push(ForgetMatch { key: k.to_vec(), payload: v.to_vec(), bt });
768 }
769 }
770 Ok(out)
771}
772
773/// Whether a row is already sys-closed — soft-deleted by an earlier forget,
774/// or superseded by the verifier.
775///
776/// Read from the payload, not from `ForgetMatch::bt`: `build_soft_delete_op`
777/// stamps `bt.sys[1]` INTO the payload JSON (the backends derive `bt` from
778/// those bytes), so the payload is the authority and is what a re-scan reads
779/// back.
780fn is_sys_closed(payload: &[u8]) -> bool {
781 serde_json::from_slice::<serde_json::Value>(payload)
782 .ok()
783 .and_then(|v| {
784 v.get("bt")
785 .and_then(|bt| bt.get("sys"))
786 .and_then(|sys| sys.as_array())
787 .and_then(|a| a.get(1))
788 .map(|to| !to.is_null())
789 })
790 .unwrap_or(false)
791}
792
793/// Drop rows a SOFT forget has nothing to do to.
794///
795/// W4.6 / D6.4. `scan_matches_scoped` filters on the target predicate alone,
796/// with no check for rows already sys-closed. For a one-shot `forget` that
797/// showed up only as an inflated `matched` on a repeat call — the "`matched`
798/// over-count on soft-deleted records" the D6 decision recorded as an open
799/// follow-up. Retention is what makes it matter: a policy sweep runs on a
800/// schedule, so every pass re-stamped every row it had ever swept, and the
801/// scope's write volume grew without bound while `rows_written` reported work
802/// that changed nothing.
803///
804/// Soft only. A HARD forget must still be able to delete a tombstoned row —
805/// the row and its content are still there, and "already hidden" is not
806/// "already gone".
807fn drop_already_closed(matches: &mut Vec<ForgetMatch>, hard: bool) {
808 if !hard {
809 matches.retain(|m| !is_sys_closed(&m.payload));
810 }
811}
812
813/// How many EPISODES a scan matched, as opposed to how many rows it will touch.
814///
815/// Counted by key prefix rather than by payload shape: a `Chunk` also carries
816/// an `id` field, so the payload-parsing sibling below would happily count one.
817/// The key is the only thing that says which kind a row is.
818fn episode_match_count(matches: &[ForgetMatch], scope: &lunaris_core::Scope) -> u64 {
819 let prefix = lunaris_core::keyspace::episode_prefix(scope);
820 matches.iter().filter(|m| m.key.starts_with(&prefix)).count() as u64
821}
822
823/// The ulids of the episodes a scan matched, read from the payloads.
824///
825/// Parsed from the value rather than the key: the key format belongs to
826/// `lunaris_core::keyspace` and this module must not re-derive it (RC-1).
827fn matched_episode_ids(matches: &[ForgetMatch]) -> std::collections::HashSet<Ulid> {
828 matches
829 .iter()
830 .filter_map(|m| serde_json::from_slice::<serde_json::Value>(&m.payload).ok())
831 .filter_map(|v| v.get("id").and_then(|id| id.as_str()).and_then(|s| s.parse::<Ulid>().ok()))
832 .collect()
833}
834
835/// Every chunk row belonging to one of `episode_ids`.
836///
837/// This is a prefix scan of the scope's chunk partition, and it runs even on
838/// the `ForgetTarget::Id` fast path, which is a real cost: ingest writes
839/// doctree / episode / chunk / vector ops and **no** reverse episode->chunk
840/// index, so there is nothing cheaper to consult. Forget is not a hot path and
841/// correctness wins here; a back-link written at ingest would remove the scan
842/// and is the obvious follow-up if a large scope ever makes this hurt.
843///
844/// Deliberately NOT extended to facts, entities, relations or communities.
845/// Those are keyed by a content hash — `FactId = blake3(subject || predicate
846/// || object)`, `EntityId = blake3(name || type)` — so two episodes asserting
847/// the same thing write the SAME row, and no provenance links a row back to
848/// the episodes that contributed it (`RawExtraction::source_chunk_id` is
849/// dropped when the validated `Fact` is persisted). Deleting one of those on a
850/// single episode's forget would erase another episode's assertion. Doing it
851/// properly needs contributing-episode provenance plus reference-counted
852/// deletion — a schema change, tracked separately.
853async fn scan_chunk_matches_scoped(
854 storage: &dyn StoragePort,
855 scope: &lunaris_core::Scope,
856 episode_ids: &std::collections::HashSet<Ulid>,
857 clock: &HlcClock,
858) -> Result<Vec<ForgetMatch>, LunarisError> {
859 use futures::stream::StreamExt;
860
861 let prefix = lunaris_core::keyspace::chunk_prefix(scope);
862 let mut stream =
863 storage.scan_range(scope, &prefix, None).await.map_err(LunarisError::Storage)?;
864 let mut out = Vec::new();
865 while let Some(item) = stream.next().await {
866 let (k, v) = item.map_err(LunarisError::Storage)?;
867 let Ok(chunk) = serde_json::from_slice::<lunaris_core::Chunk>(&v) else {
868 continue; // not a chunk row, or a shape this version cannot read
869 };
870 if !episode_ids.contains(&chunk.episode_id) {
871 continue;
872 }
873 // Same typed-bt load the episode loop does: `build_soft_delete_op`
874 // mutates the typed BiTemporal before patching it into the payload.
875 let now = clock.tick();
876 let row_opt = storage.read_as_of(scope, &k, now).await.map_err(LunarisError::Storage)?;
877 let bt = row_opt
878 .as_ref()
879 .map(|r| r.bt)
880 .unwrap_or_else(|| BiTemporal { valid: (Hlc::ZERO, None), sys: (Hlc::ZERO, None) });
881 out.push(ForgetMatch { key: k.to_vec(), payload: v.to_vec(), bt });
882 }
883 Ok(out)
884}
885
886// ---------------------------------------------------------------------------
887// Tests
888// ---------------------------------------------------------------------------
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn dry_run_builder_sets_options() {
896 let req = ForgetTarget::Id(Ulid::new()).dry_run();
897 assert!(req.options.dry_run);
898 assert!(!req.options.hard);
899 assert!(req.options.confirmation_token.is_none());
900 }
901
902 #[test]
903 fn hard_builder_sets_options() {
904 let req = ForgetTarget::Id(Ulid::new()).hard();
905 assert!(req.options.hard);
906 assert!(!req.options.dry_run);
907 assert!(req.options.confirmation_token.is_none());
908 }
909
910 #[test]
911 fn with_token_attaches_confirmation() {
912 let token = ForgetConfirmation { for_audit_lsn: Lsn { wall_ms: 42, counter: 0 } };
913 let req = ForgetTarget::Id(Ulid::new()).hard().with_token(token.clone());
914 assert_eq!(req.options.confirmation_token, Some(token));
915 }
916
917 #[test]
918 fn classify_indices_returns_three_indices() {
919 let v = classify_indices(&ForgetTarget::Id(Ulid::new()));
920 assert!(v.contains(&IndexKind::Kv));
921 assert!(v.contains(&IndexKind::Vector));
922 assert!(v.contains(&IndexKind::Graph));
923 }
924
925 #[test]
926 fn match_scope_by_source_prefix() {
927 let payload = serde_json::to_vec(&serde_json::json!({
928 "source": "helios:fs/session-42/foo.md",
929 }))
930 .unwrap();
931 assert!(match_scope(&payload, &ScopeSpec::BySource("helios:fs/session-42/".into())));
932 assert!(!match_scope(&payload, &ScopeSpec::BySource("helios:fs/session-99/".into())));
933 }
934
935 #[test]
936 fn match_scope_by_metadata_exact() {
937 let payload = serde_json::to_vec(&serde_json::json!({
938 "metadata": { "tenant": "acme" },
939 }))
940 .unwrap();
941 assert!(match_scope(&payload, &ScopeSpec::ByMetadata("tenant".into(), "acme".into())));
942 assert!(!match_scope(&payload, &ScopeSpec::ByMetadata("tenant".into(), "other".into())));
943 }
944
945 /// W-1: typed Hlc Ord compare on the valid-from tuple element.
946 #[test]
947 fn match_before_uses_typed_hlc_compare() {
948 let earlier = Hlc { wall_ms: 100, counter: 0, node_id: 0 };
949 let later = Hlc { wall_ms: 200, counter: 0, node_id: 0 };
950 let cutoff = Hlc { wall_ms: 150, counter: 0, node_id: 0 };
951
952 let payload_earlier = serde_json::to_vec(&serde_json::json!({
953 "bt": { "valid": [earlier, null], "sys": [earlier, null] }
954 }))
955 .unwrap();
956 let payload_later = serde_json::to_vec(&serde_json::json!({
957 "bt": { "valid": [later, null], "sys": [later, null] }
958 }))
959 .unwrap();
960
961 assert!(match_before(&payload_earlier, cutoff), "earlier < cutoff");
962 assert!(!match_before(&payload_later, cutoff), "later >= cutoff");
963 }
964
965 #[test]
966 fn forget_target_into_request_defaults_to_soft() {
967 let req: ForgetRequest = ForgetTarget::Id(Ulid::new()).into_request();
968 assert!(!req.options.hard);
969 assert!(!req.options.dry_run);
970 }
971
972 #[test]
973 fn from_impl_allows_bare_target_in_forget_arg() {
974 let req: ForgetRequest = ForgetTarget::Id(Ulid::new()).into();
975 assert!(!req.options.hard);
976 }
977
978 /// B-5: build_soft_delete_op JSON-patches `payload["bt"]["sys"][1]`. Set
979 /// up a payload with a tuple-shaped bt object and verify the patched
980 /// payload has sys[1] non-null after a build_soft_delete_op pass.
981 #[test]
982 fn build_soft_delete_op_patches_bt_sys_to() {
983 let t0 = Hlc { wall_ms: 1, counter: 0, node_id: 0 };
984 let payload = serde_json::to_vec(&serde_json::json!({
985 "id": Ulid::new().to_string(),
986 "source": "test:src",
987 "bt": { "valid": [t0, null], "sys": [t0, null] },
988 }))
989 .unwrap();
990 let bt = BiTemporal { valid: (t0, None), sys: (t0, None) };
991 let m = ForgetMatch { key: b"episode:test".to_vec(), payload, bt };
992 let clock = HlcClock::new(0);
993 let op = build_soft_delete_op(&m, clock.as_ref()).expect("build_soft_delete_op");
994 match op {
995 WriteOp::KvPut { key, value } => {
996 assert_eq!(key, b"episode:test");
997 let json: serde_json::Value = serde_json::from_slice(&value).unwrap();
998 let sys_to = json
999 .get("bt")
1000 .and_then(|bt| bt.get("sys"))
1001 .and_then(|sys| sys.get(1))
1002 .cloned()
1003 .unwrap_or(serde_json::Value::Null);
1004 assert!(!sys_to.is_null(), "sys[1] (sys_to) MUST be patched non-null");
1005 }
1006 other => panic!("expected KvPut, got {other:?}"),
1007 }
1008 }
1009}