Skip to main content

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}