feat(cli): purge-project previews what it would delete before --confirm

Without --confirm, purge-project only refused with a generic
"destructive and irreversible" message; the counts of what would be
deleted appeared only after a confirmed run actually deleted them. An
operator purged a project believing it held 0 sessions / 0 pages, and
it actually held 1,063 observations reachable only through a different
project's session (a pre-#871 Windows path-casing split) -- recovered
only from a backup taken a minute earlier.

POST /admin/purge-project now accepts an optional "dry_run": true,
which always wins over "confirm" -- {"confirm": true, "dry_run": true}
still only previews, the same way reclaim-ledger-versions treats its
own dry_run field, so a preview request can never become destructive.
The preview runs the same lookups and counts a confirmed purge uses to
decide what to delete (same 404/409), including two new counts for
rows a purge collaterally deletes or orphans in a DIFFERENT project via
its sessions cascading (collateral_observations_deleted,
collateral_handoffs_denulled -- observations.session_id is ON DELETE
CASCADE and handoffs.from_session_id/accepted_by_session are ON DELETE
SET NULL, neither scoped to the purged project_id). It never issues the
DELETE: PurgeMode::{Commit, Preview} replaces the earlier commit: bool,
and Preview returns right after counting instead of running the delete
and rolling it back, so a preview of a large project doesn't hold the
single-writer actor for as long as a real purge would and starve every
hook capture queued behind it. Preview also skips wiki file removal,
admission webhook dispatch, and both checkpoints; a 200 preview is
therefore not a guarantee the confirmed purge will succeed, since
admission only runs on the confirmed path.

The CLI asks for this preview (bounded to a few seconds, including auth
refresh) before refusing, and prints "Would purge <ws>/<proj>: N pages,
..." plus any collateral-damage counts. A 404/409/403 (or anything else
unexpected) prints the server's own error before the refusal; a plain
400 (older server), a timeout, or an unreachable server fall back
silently to the existing refusal, so no existing script's behavior
changes except gaining information.

Store-level tests cover: preview reports the same counts a confirmed
purge then produces; preview changes nothing (every project-scoped
table's row count, including purged_scopes and audit_log); the incident
shape (an observation stamped into the purged project from a session
that lives elsewhere); and the mirror shape (purging a project
collaterally deletes an observation, and orphans a handoff's session
reference, in a different project). Admin-level tests add the HTTP
equivalents, confirm {"confirm": true, "dry_run": true} deletes
nothing, no webhook dispatch on preview, 404 parity, a 409 on a live
managed-run lease without --force, and root-vs-DB-user-vs-anonymous
auth on the preview payload. CLI tests cover the summary-line wording,
the pure outcome-to-message mapping, and run_preview's HTTP-status
classification against a real local server (400/404/200/connection
failure).
This commit is contained in:
Maxsuel Einstein
2026-09-27 19:49:12 -03:00
parent 3d84623b98
commit c8aab5bf5e
13 changed files with 1655 additions and 25 deletions
+11
View File
@@ -223,6 +223,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`session-end` cannot double-close a session. (#623 follow-up, #933)
### Changed
- `ai-memory purge-project` without `--confirm` now previews what a confirmed
purge would delete before refusing: `Would purge <ws>/<proj>: N pages, N
sessions, N observations, …`, with the same 404/409 a real purge gives.
Before, the counts only appeared after the rows were gone. The server takes
a new `dry_run` field on `POST /admin/purge-project` that always wins over
`confirm`. It only counts; no delete, wiki removal, webhook, audit row or
checkpoint runs. The preview and the confirmed report also count what the
purge cascades into *other* projects through this project's sessions
(`collateral_observations_deleted`, `collateral_handoffs_denulled`). The
CLI still exits non-zero without `--confirm`; against an older server, or
if the preview times out, it prints only the existing refusal. (#945)
- A capture into a project its author may not write is dropped server-side
and counted as `dropped_unauthorized` in status, never stored. The native
hook client treats a 403 from the server as final and drops the event
+12
View File
@@ -1202,6 +1202,18 @@ pub struct PurgeProjectArgs {
pub project: Option<String>,
/// REQUIRED for the purge to run. Without this flag the CLI errors
/// out — purging is destructive and irreversible.
///
/// Before erroring, the CLI asks the server for a preview (bounded to a
/// few seconds, auth refresh included): the reported counts (pages,
/// sessions, observations, handoffs, embeddings, workstreams, managed
/// runs, plus any collateral rows a purge of this project would delete
/// or orphan in *another* project) come from the same queries a
/// confirmed purge itself uses to decide what to delete. The preview is
/// best-effort and never changes the outcome, only what gets printed
/// before it: a 404/409/403 (or anything else unexpected) prints the
/// server's own error first; a timeout, an unreachable server, or an
/// older server that predates this preview just gets the plain refusal,
/// same as before this existed.
#[arg(long)]
pub confirm: bool,
/// Also reclaim the freed bytes: rebuild the FTS indexes and VACUUM the
@@ -1,11 +1,13 @@
//! `ai-memory purge-project` — thin HTTP client for project purge.
use std::time::Duration;
use anyhow::{Result, bail};
use serde::Serialize;
use crate::cli::PurgeProjectArgs;
use crate::config::Config;
use crate::http_client::{ServerEndpoint, post_json};
use crate::http_client::{ServerEndpoint, ServerResponseError, post_json};
/// Request sent to `POST /admin/purge-project`.
#[derive(Serialize)]
@@ -17,6 +19,146 @@ struct PurgeProjectRequest {
force: bool,
/// Rebuild the FTS indexes and VACUUM after the delete commits.
compact: bool,
/// Preview only: wins over `confirm` on the server (mirrors
/// `reclaim-ledger-versions`), so this is always sent alongside
/// `confirm: false` here — never both true. Older servers that predate
/// this field simply never look at it (an unknown JSON field is not a
/// deserialize error), which is why the fallback below only has to
/// handle the request failing outright, never the field being silently
/// misread.
dry_run: bool,
}
/// How long the preview request (auth resolution plus the HTTP round trip)
/// is allowed to take before this falls back to the plain refusal. The
/// preview is optional information layered on top of a refusal that must
/// still happen either way, so it must never be the reason `--confirm` takes
/// noticeably longer than it used to.
const PREVIEW_TIMEOUT: Duration = Duration::from_secs(5);
/// One line naming what a purge (real or previewed) removed, in the fixed
/// order the operator can grep for: pages, sessions, observations, handoffs,
/// embeddings, workstreams, managed runs. `verb` is `"Purged"` for a
/// confirmed run or `"Would purge"` for a preview — everything else about
/// the line is identical, so a script matching one also matches the other.
fn purge_summary_line(verb: &str, label: &str, report: &serde_json::Value) -> String {
let pages = report["pages_deleted"].as_u64().unwrap_or(0);
let sessions = report["sessions_deleted"].as_u64().unwrap_or(0);
let observations = report["observations_deleted"].as_u64().unwrap_or(0);
let handoffs = report["handoffs_deleted"].as_u64().unwrap_or(0);
let embeddings = report["embeddings_deleted"].as_u64().unwrap_or(0);
// Workstreams cascade out of the project row, so a scope that looks empty
// by every other counter can still be carrying a managed workstream and
// its portable event ledger. Always name them.
let workstreams = report["workstreams_deleted"].as_u64().unwrap_or(0);
let managed_runs = report["managed_runs_deleted"].as_u64().unwrap_or(0);
let mut line = format!(
"{verb} {label}: {pages} pages, {sessions} sessions, \
{observations} observations, {handoffs} handoffs, {embeddings} embeddings, \
{workstreams} workstreams, {managed_runs} managed runs."
);
// The mirror of the incident this command guards against: purging this
// scope also collaterally deletes/orphans rows that live in *other*
// projects, via `sessions` cascading out of this one. Silent when zero
// so the common case reads exactly as before.
let collateral_observations = report["collateral_observations_deleted"]
.as_u64()
.unwrap_or(0);
if collateral_observations > 0 {
line.push_str(&format!(
" Plus {collateral_observations} observations in other projects via their sessions."
));
}
let collateral_handoffs = report["collateral_handoffs_denulled"].as_u64().unwrap_or(0);
if collateral_handoffs > 0 {
line.push_str(&format!(
" Plus {collateral_handoffs} handoffs in other projects that will lose their \
session reference (set to NULL, not deleted)."
));
}
line
}
/// What became of the best-effort preview request, reduced to what deciding
/// whether — and what — to print needs. Kept separate from the network call
/// itself so the printing decision is a pure function and testable without a
/// server.
enum PreviewOutcome {
/// A 200 with `"dry_run": true`: the server understood the request and
/// ran the preview.
Previewed(serde_json::Value),
/// A 200 without `dry_run` set: an old-enough server both predates the
/// field AND happens to 200 an unrecognized shape. Treated the same as
/// not getting a preview at all.
Ignored,
/// A non-2xx response with a body worth showing: the scope resolved to
/// something the operator should know about before the refusal (a 404
/// naming the missing project, a 409 naming the live managed run, a 403
/// naming the auth problem), or an unexpected status this command has no
/// specific handling for.
Refused { status: u16, body: String },
/// The request predates `dry_run` support (400, the pre-existing
/// "confirm=true" refusal body) — not worth repeating, since `run` below
/// prints its own version of exactly that message next regardless.
OlderServer,
/// Timed out or never reached a server at all (DNS/connect failure,
/// auth-refresh hang, etc). Indistinguishable from the operator's
/// perspective, and neither is this command's business to diagnose.
Unreachable,
}
/// Decide what to print, if anything, before the refusal — pure, so it is
/// unit-tested without a server. `fallback_label` is used only when the
/// server's own report has no `label` field.
fn preview_message(outcome: &PreviewOutcome, fallback_label: &str) -> Option<String> {
match outcome {
PreviewOutcome::Previewed(report) => {
let label = report["label"].as_str().unwrap_or(fallback_label);
Some(purge_summary_line("Would purge", label, report))
}
PreviewOutcome::Refused { status, body } => {
let message = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_string))
.unwrap_or_else(|| body.clone());
Some(format!("Preview refused ({status}): {message}"))
}
PreviewOutcome::Ignored | PreviewOutcome::OlderServer | PreviewOutcome::Unreachable => None,
}
}
/// Run the preview request under [`PREVIEW_TIMEOUT`] and classify the
/// result. Auth resolution (`ServerEndpoint::from_config_resolving_auth`,
/// which can itself refresh an OIDC token over the network) runs inside the
/// same timeout so a hung refresh cannot silently make `--confirm`-less
/// purge-project block far longer than the rest of this command ever has.
async fn run_preview(config: &Config, request: &PurgeProjectRequest) -> PreviewOutcome {
let attempt = tokio::time::timeout(PREVIEW_TIMEOUT, async {
let endpoint = ServerEndpoint::from_config_resolving_auth(config).await;
post_json::<_, serde_json::Value>(&endpoint, "/admin/purge-project", request).await
})
.await;
let Ok(result) = attempt else {
return PreviewOutcome::Unreachable;
};
match result {
Ok(report) if report["dry_run"].as_bool().unwrap_or(false) => {
PreviewOutcome::Previewed(report)
}
Ok(_) => PreviewOutcome::Ignored,
Err(e) => match e.downcast_ref::<ServerResponseError>() {
Some(resp) if resp.status().as_u16() == 400 => PreviewOutcome::OlderServer,
Some(resp) => PreviewOutcome::Refused {
status: resp.status().as_u16(),
body: resp.body().to_string(),
},
// Not an HTTP response at all: connect/DNS failure, request
// timeout already handled above, or a body that failed to
// deserialize as JSON.
None => PreviewOutcome::Unreachable,
},
}
}
/// Run the `purge-project` subcommand.
@@ -25,14 +167,41 @@ struct PurgeProjectRequest {
/// `--project` is omitted), requires `--confirm` before sending the
/// destructive request, then prints the JSON summary.
///
/// Without `--confirm`, first asks the server for a preview (`dry_run:
/// true`, which wins over `confirm` server-side): the server reports the
/// counts a confirmed purge would produce without deleting anything. That
/// preview is best-effort, bounded by [`PREVIEW_TIMEOUT`], and never changes
/// the outcome — only what gets printed before it:
/// - a successful preview prints the "Would purge ..." line;
/// - a 404/409/403 (or any other unexpected status) prints the server's own
/// error first, since the operator asked what would happen and the server
/// has an answer, just not the one this command expected;
/// - a plain 400 (an older server that predates `dry_run`), a timeout, or an
/// unreachable server print nothing extra — the refusal below already
/// says everything a 400 would.
///
/// # Errors
/// Returns an error when `--confirm` is absent, the server is unreachable,
/// or the server returns a non-2xx response.
/// Returns an error when `--confirm` is absent (after printing whatever the
/// preview surfaced), the server is unreachable, or the server returns a
/// non-2xx response.
pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
let (workspace, project) =
super::resolve_scope(config, args.workspace.as_deref(), args.project.as_deref())?;
if !args.confirm {
let request = PurgeProjectRequest {
workspace: workspace.clone(),
project: project.clone(),
confirm: false,
force: args.force,
compact: args.compact,
dry_run: true,
};
let outcome = run_preview(config, &request).await;
let fallback_label = format!("{}/{}", workspace, project);
if let Some(line) = preview_message(&outcome, &fallback_label) {
println!("{line}");
}
bail!(
"purge-project is destructive and irreversible.\n\
Re-run with --confirm to proceed:\n\n \
@@ -52,6 +221,7 @@ pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
confirm: true,
force: args.force,
compact: args.compact,
dry_run: false,
},
)
.await?;
@@ -59,21 +229,7 @@ pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
// Human-friendly one-liner followed by the raw JSON for scripting.
let fallback_label = format!("{}/{}", workspace, project);
let label = report["label"].as_str().unwrap_or(&fallback_label);
let pages = report["pages_deleted"].as_u64().unwrap_or(0);
let sessions = report["sessions_deleted"].as_u64().unwrap_or(0);
let observations = report["observations_deleted"].as_u64().unwrap_or(0);
let handoffs = report["handoffs_deleted"].as_u64().unwrap_or(0);
let embeddings = report["embeddings_deleted"].as_u64().unwrap_or(0);
// Workstreams cascade out of the project row, so a scope that looks empty
// by every other counter can still be carrying a managed workstream and
// its portable event ledger. Always name them.
let workstreams = report["workstreams_deleted"].as_u64().unwrap_or(0);
let managed_runs = report["managed_runs_deleted"].as_u64().unwrap_or(0);
println!(
"Purged {label}: {pages} pages, {sessions} sessions, \
{observations} observations, {handoffs} handoffs, {embeddings} embeddings, \
{workstreams} workstreams, {managed_runs} managed runs."
);
println!("{}", purge_summary_line("Purged", label, &report));
if let Some(ids) = report["workstream_ids"].as_array()
&& !ids.is_empty()
{
@@ -105,3 +261,265 @@ pub async fn run(config: &Config, args: PurgeProjectArgs) -> Result<()> {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn report(dry_run: bool) -> serde_json::Value {
serde_json::json!({
"label": "default/my-project",
"pages_deleted": 3,
"sessions_deleted": 1,
"observations_deleted": 1063,
"handoffs_deleted": 0,
"embeddings_deleted": 3,
"collateral_observations_deleted": 0,
"collateral_handoffs_denulled": 0,
"workstreams_deleted": 0,
"managed_runs_deleted": 0,
"workstream_ids": [],
"files_deleted": [],
"files_failed": [],
"compacted": false,
"dry_run": dry_run,
})
}
/// Pins the exact wording and field order a script would grep for.
/// `purge_summary_line` is the single source for both the confirmed
/// "Purged" line and the preview's "Would purge" line, so this also
/// proves the two can never drift apart.
#[test]
fn purge_summary_line_matches_the_documented_wording() {
let confirmed = report(false);
assert_eq!(
purge_summary_line("Purged", "default/my-project", &confirmed),
"Purged default/my-project: 3 pages, 1 sessions, 1063 observations, \
0 handoffs, 3 embeddings, 0 workstreams, 0 managed runs."
);
let preview = report(true);
assert_eq!(
purge_summary_line("Would purge", "default/my-project", &preview),
"Would purge default/my-project: 3 pages, 1 sessions, 1063 observations, \
0 handoffs, 3 embeddings, 0 workstreams, 0 managed runs."
);
}
/// Missing counters must not panic and must not silently show as
/// non-zero: a malformed or truncated reply reads as all-zero, which the
/// operator can visibly tell apart from a real "0 pages, 0 sessions".
#[test]
fn purge_summary_line_defaults_missing_counters_to_zero() {
let empty = serde_json::json!({});
assert_eq!(
purge_summary_line("Would purge", "default/x", &empty),
"Would purge default/x: 0 pages, 0 sessions, 0 observations, \
0 handoffs, 0 embeddings, 0 workstreams, 0 managed runs."
);
}
/// The mirror-of-the-incident collateral counts, when present, must be
/// visible in the same line the operator already reads — silent when
/// zero (the common case), spelled out when not.
#[test]
fn purge_summary_line_calls_out_collateral_damage_when_present() {
let mut r = report(true);
r["collateral_observations_deleted"] = serde_json::json!(7);
r["collateral_handoffs_denulled"] = serde_json::json!(2);
let line = purge_summary_line("Would purge", "default/looks-empty", &r);
assert!(
line.contains("Plus 7 observations in other projects via their sessions."),
"collateral observations must be called out: {line}"
);
assert!(
line.contains("Plus 2 handoffs in other projects"),
"collateral handoffs must be called out: {line}"
);
}
/// A successful preview prints the "Would purge" line.
#[test]
fn preview_message_prints_the_would_purge_line_on_success() {
let outcome = PreviewOutcome::Previewed(report(true));
let msg = preview_message(&outcome, "default/fallback").expect("must print a line");
assert!(msg.starts_with("Would purge default/my-project:"));
}
/// A 404 (unknown scope) is worth showing before the refusal: the
/// operator asked what would happen, and 404 is the server's answer.
#[test]
fn preview_message_surfaces_a_404_before_the_refusal() {
let outcome = PreviewOutcome::Refused {
status: 404,
body: r#"{"error":"project 'ghost' not found in workspace 'default'"}"#.to_string(),
};
let msg = preview_message(&outcome, "default/ghost").expect("must print a line");
assert_eq!(
msg,
"Preview refused (404): project 'ghost' not found in workspace 'default'"
);
}
/// A 409 (live managed run) is the same: surfaced, not swallowed.
#[test]
fn preview_message_surfaces_a_409_before_the_refusal() {
let outcome = PreviewOutcome::Refused {
status: 409,
body: r#"{"error":"managed run lease is active for 'main' (claude-code)"}"#.to_string(),
};
let msg = preview_message(&outcome, "default/x").expect("must print a line");
assert_eq!(
msg,
"Preview refused (409): managed run lease is active for 'main' (claude-code)"
);
}
/// A body that isn't the expected `{"error": ...}` shape still prints
/// something rather than nothing — the raw body, verbatim.
#[test]
fn preview_message_falls_back_to_the_raw_body_when_not_json() {
let outcome = PreviewOutcome::Refused {
status: 403,
body: "Forbidden".to_string(),
};
let msg = preview_message(&outcome, "default/x").expect("must print a line");
assert_eq!(msg, "Preview refused (403): Forbidden");
}
/// The three silent-fallback cases: an older server's plain 400, a
/// timeout/connect failure, and a 200 that oddly never set `dry_run`.
/// None of these should print anything — the refusal that follows in
/// `run` already says everything a 400 would, and there is nothing
/// useful to say about a request that never got an answer.
#[test]
fn preview_message_is_silent_for_older_server_unreachable_and_ignored() {
assert!(preview_message(&PreviewOutcome::OlderServer, "default/x").is_none());
assert!(preview_message(&PreviewOutcome::Unreachable, "default/x").is_none());
assert!(preview_message(&PreviewOutcome::Ignored, "default/x").is_none());
}
// -----------------------------------------------------------------
// `run_preview` against a real (local) server: proves the HTTP-status
// classification end to end, not just the pure `preview_message` mapping
// above.
// -----------------------------------------------------------------
fn config_for(tmp: &tempfile::TempDir, server_url: String) -> Config {
Config {
data_dir: tmp.path().to_path_buf(),
server_url,
..Config::default()
}
}
async fn spawn_fixed_response(status: u16, body: &'static str) -> String {
let app = axum::Router::new().route(
"/admin/purge-project",
axum::routing::post(move || async move {
(
axum::http::StatusCode::from_u16(status).unwrap(),
axum::Json(serde_json::from_str::<serde_json::Value>(body).unwrap()),
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
fn preview_request() -> PurgeProjectRequest {
PurgeProjectRequest {
workspace: "default".into(),
project: "scratch".into(),
confirm: false,
force: false,
compact: false,
dry_run: true,
}
}
/// An older server that predates `dry_run` answers the plain 400
/// "confirm=true" refusal it always has — the CLI must fall back
/// silently, not print anything extra.
#[tokio::test]
async fn run_preview_classifies_a_400_as_older_server() {
let tmp = tempfile::TempDir::new().unwrap();
let url = spawn_fixed_response(
400,
r#"{"error": "destructive operation requires confirm=true"}"#,
)
.await;
let config = config_for(&tmp, url);
let outcome = run_preview(&config, &preview_request()).await;
assert!(matches!(outcome, PreviewOutcome::OlderServer));
assert!(preview_message(&outcome, "default/scratch").is_none());
}
/// A 404 is surfaced: the operator asked what a purge of this scope
/// would do, and "no such scope" is a real, useful answer.
#[tokio::test]
async fn run_preview_classifies_a_404_as_refused_and_surfaces_the_message() {
let tmp = tempfile::TempDir::new().unwrap();
let url = spawn_fixed_response(
404,
r#"{"error": "project 'scratch' not found in workspace 'default'"}"#,
)
.await;
let config = config_for(&tmp, url);
let outcome = run_preview(&config, &preview_request()).await;
match &outcome {
PreviewOutcome::Refused { status, body } => {
assert_eq!(*status, 404);
assert!(body.contains("not found"));
}
_ => panic!("expected Refused, got a different outcome"),
}
let msg = preview_message(&outcome, "default/scratch").expect("must print a line");
assert_eq!(
msg,
"Preview refused (404): project 'scratch' not found in workspace 'default'"
);
}
/// A successful preview (200, `dry_run: true`) is a `Previewed` outcome
/// carrying the report through untouched.
#[tokio::test]
async fn run_preview_classifies_a_successful_preview() {
let tmp = tempfile::TempDir::new().unwrap();
let url = spawn_fixed_response(
200,
r#"{"label": "default/scratch", "pages_deleted": 3, "sessions_deleted": 1,
"observations_deleted": 1063, "handoffs_deleted": 0, "embeddings_deleted": 3,
"collateral_observations_deleted": 0, "collateral_handoffs_denulled": 0,
"workstreams_deleted": 0, "managed_runs_deleted": 0, "workstream_ids": [],
"files_deleted": [], "files_failed": [], "compacted": false, "dry_run": true}"#,
)
.await;
let config = config_for(&tmp, url);
let outcome = run_preview(&config, &preview_request()).await;
let msg = preview_message(&outcome, "default/scratch").expect("must print a line");
assert!(msg.starts_with("Would purge default/scratch: 3 pages, 1 sessions, 1063"));
}
/// Nothing listening at all (connection refused) must classify as
/// `Unreachable`, the same as a timeout — both are silent fallbacks.
#[tokio::test]
async fn run_preview_classifies_a_connection_failure_as_unreachable() {
let tmp = tempfile::TempDir::new().unwrap();
// Bind then drop immediately: the port is very likely free again by
// the time the request lands, and nothing else can be listening on
// it inside this test's short lifetime.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let config = config_for(&tmp, format!("http://{addr}"));
let outcome = run_preview(&config, &preview_request()).await;
assert!(matches!(outcome, PreviewOutcome::Unreachable));
assert!(preview_message(&outcome, "default/scratch").is_none());
}
}
+2
View File
@@ -7888,6 +7888,7 @@ mod tests {
None,
false,
ai_memory_store::Compaction::Skip,
ai_memory_store::PurgeMode::Commit,
)
.await
.unwrap();
@@ -8710,6 +8711,7 @@ mod tests {
None,
false,
ai_memory_store::Compaction::Skip,
ai_memory_store::PurgeMode::Commit,
)
.await
.unwrap();
+242 -1
View File
@@ -4142,6 +4142,21 @@ struct PurgeProjectRequest {
/// the cost and for what it does not guarantee.
#[serde(default)]
compact: bool,
/// Preview only: report the counts a purge of this scope would produce
/// without deleting anything. Wins over `confirm` — `{"confirm": true,
/// "dry_run": true}` still only previews, exactly like
/// `reclaim-ledger-versions` treats its own `dry_run` field — so a
/// preview request can never accidentally become destructive.
/// `#[serde(default)]` here is what keeps an *old* CLI (built before this
/// field existed, so it never sends `dry_run` at all) parsing the same
/// request it always sent. The other direction — a *new* CLI talking to
/// an *old* server that doesn't know this field yet — needs nothing on
/// this struct at all: an unknown JSON field is simply not a
/// `Deserialize` error on the server, so the old server ignores it and
/// the new CLI's fallback (see `commands/purge_project.rs`) handles the
/// resulting plain 400.
#[serde(default)]
dry_run: bool,
}
/// Wire-format summary returned by `POST /admin/purge-project`.
@@ -4159,6 +4174,19 @@ pub struct PurgeProjectReport {
pub handoffs_deleted: u64,
/// Number of `page_embeddings` rows deleted.
pub embeddings_deleted: u64,
/// `observations` rows in a **different** project, deleted collaterally
/// because their `session_id` belongs to a session that lives in this
/// one (`observations.session_id` is `ON DELETE CASCADE`, without regard
/// to the observation's own `project_id`) — the mirror of the incident
/// this preview guards against. Always present, zero when there is none,
/// so a caller can tell "no collateral damage" apart from "field absent
/// on an older server".
pub collateral_observations_deleted: u64,
/// `handoffs` rows in a different project whose `from_session_id` or
/// `accepted_by_session` is set to `NULL` (not deleted — those columns
/// are `ON DELETE SET NULL`) because they referenced a session that
/// lives in this project.
pub collateral_handoffs_denulled: u64,
/// Number of managed `workstreams` rows deleted via cascade.
pub workstreams_deleted: u64,
/// Number of `managed_runs` rows deleted via cascade.
@@ -4180,6 +4208,16 @@ pub struct PurgeProjectReport {
/// Post-purge checkpoint, if the purge changed the tree.
#[serde(skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<String>,
/// `true` for a preview: `dry_run: true` in the request always wins over
/// `confirm`, so this is only ever `false` on a run that actually
/// deleted rows. The counts above are read directly by the same queries
/// a confirmed purge uses to decide what to delete — never by running
/// the delete and rolling it back — so `files_deleted`/`files_failed`
/// are always empty and `pre_checkpoint`/`checkpoint` always absent: no
/// wiki file was touched, no admission webhook ran, and no audit row was
/// written.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dry_run: bool,
}
async fn remove_workstream_segment_storage(
@@ -4259,6 +4297,13 @@ async fn handle_purge_project(
Json(req): Json<PurgeProjectRequest>,
) -> impl IntoResponse {
let author_id = author_ext.map(|axum::Extension(u)| u);
// `dry_run` always wins, exactly like `reclaim-ledger-versions` (its
// handler passes `req.dry_run` straight into the op regardless of any
// other field): `{"confirm": true, "dry_run": true}` must never run the
// destructive path just because `confirm` also happened to be set.
if req.dry_run {
return purge_project_preview(&state, &req, author_id).await;
}
if !req.confirm {
return (
StatusCode::BAD_REQUEST,
@@ -4316,7 +4361,15 @@ async fn handle_purge_project(
let summary = match state
.writer
.purge_project(ws_id, proj_id, &label, author_id, req.force, compaction)
.purge_project(
ws_id,
proj_id,
&label,
author_id,
req.force,
compaction,
ai_memory_store::PurgeMode::Commit,
)
.await
{
Ok(s) => s,
@@ -4369,6 +4422,8 @@ async fn handle_purge_project(
observations_deleted: summary.observations_deleted,
handoffs_deleted: summary.handoffs_deleted,
embeddings_deleted: summary.embeddings_deleted,
collateral_observations_deleted: summary.collateral_observations_deleted,
collateral_handoffs_denulled: summary.collateral_handoffs_denulled,
workstreams_deleted: summary.workstreams_deleted,
managed_runs_deleted: summary.managed_runs_deleted,
workstream_ids: summary.workstream_ids,
@@ -4377,6 +4432,102 @@ async fn handle_purge_project(
files_failed,
pre_checkpoint,
checkpoint,
dry_run: false,
};
(
StatusCode::OK,
Json(serde_json::to_value(&report).unwrap_or_else(|_| serde_json::json!({}))),
)
}
/// Preview branch of `POST /admin/purge-project`: reached whenever `dry_run`
/// is true, regardless of `confirm` (see the `dry_run`-always-wins check in
/// `handle_purge_project`). `ai_memory_store::purge_project` under
/// `PurgeMode::Preview` counts every row a confirmed purge would delete —
/// via the same `SELECT`s the confirmed path itself uses to decide what to
/// delete, not a separately-maintained estimate — and returns without ever
/// issuing the `DELETE`, so there is no cascade to roll back, unlike
/// `move-session`'s dry run (which does run its write and rolls it back: a
/// session's rows are cheap; a whole project's are not, and this preview is
/// now the default no-`--confirm` behavior of a command a hook can also
/// race against).
///
/// Deliberately skipped, unlike the confirmed path above: it never opens the
/// blocking admission call (`admit_purge_project`) at all — nothing was
/// decided yet, so there is nothing for a mirror to act on — plus wiki file
/// removal (nothing was deleted) and both checkpoints (the git tree does not
/// change). Because admission never runs, a `200` here is not a guarantee:
/// a `Reject`-policy or scope-guard admission webhook only runs on the
/// confirmed path and can still refuse the real purge afterward.
///
/// Because admission never runs here, a `200` preview is not a promise the
/// confirmed purge will succeed: a `Reject`-policy or scope-guard admission
/// webhook only runs on the confirmed path and can still refuse it after the
/// operator has already seen this preview.
///
/// Scope resolution and its 404 run exactly as the real purge's do, so an
/// unknown workspace/project answers identically either way.
async fn purge_project_preview(
state: &Arc<AdminState>,
req: &PurgeProjectRequest,
author_id: Option<ai_memory_core::UserId>,
) -> (StatusCode, Json<serde_json::Value>) {
let (ws_id, proj_id) = match lookup_ws_proj_no_create(state, &req.workspace, &req.project).await
{
Ok(ids) => ids,
Err(e) => return e,
};
let label = format!("{}/{}", req.workspace, req.project);
let summary = match state
.writer
.purge_project(
ws_id,
proj_id,
&label,
author_id,
req.force,
// A preview never deletes anything, so there is nothing to
// reclaim; `ops::purge_project` also forces `compacted: false`
// for `PurgeMode::Preview` regardless of this value.
ai_memory_store::Compaction::Skip,
ai_memory_store::PurgeMode::Preview,
)
.await
{
Ok(s) => s,
// Same conflict a confirmed purge would hit, reported the same way:
// the preview is a promise of what a real purge would do, and a real
// purge would refuse here too.
Err(e @ StoreError::ManagedRunActive { .. }) => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": e.to_string() })),
);
}
Err(e) => return internal_err(e.to_string()),
};
let report = PurgeProjectReport {
label: summary.label,
pages_deleted: summary.pages_deleted,
sessions_deleted: summary.sessions_deleted,
observations_deleted: summary.observations_deleted,
handoffs_deleted: summary.handoffs_deleted,
embeddings_deleted: summary.embeddings_deleted,
collateral_observations_deleted: summary.collateral_observations_deleted,
collateral_handoffs_denulled: summary.collateral_handoffs_denulled,
workstreams_deleted: summary.workstreams_deleted,
managed_runs_deleted: summary.managed_runs_deleted,
workstream_ids: summary.workstream_ids,
compacted: false,
files_deleted: Vec::new(),
files_failed: Vec::new(),
pre_checkpoint: None,
checkpoint: None,
dry_run: true,
};
(
@@ -6629,6 +6780,7 @@ async fn copy_purge_merge(
None,
false,
ai_memory_store::Compaction::Skip,
ai_memory_store::PurgeMode::Commit,
)
.await
{
@@ -12108,6 +12260,95 @@ mod tests {
}
}
/// The preview branch (no `confirm`, `dry_run: true`) sits behind the
/// exact same root-only gate as the rest of `/admin/purge-project` — it
/// is reached through the same handler and route, so there is no
/// separate check to forget. Mirrors
/// `multiuser_admin_routes_reject_db_user_tier` /
/// `multiuser_admin_routes_reject_anonymous` above, but with a payload
/// shaped like a preview request instead of `admin_route_samples()`'s
/// confirmed one. The root control case proves the gate — not the
/// route — is what is being tested: root reaches the handler (answered
/// with `404`, since this router's store has no `default/scratch`
/// project), while the DB user and anonymous requests below never get
/// that far.
#[tokio::test]
async fn multiuser_purge_project_dry_run_rejects_db_user_and_anonymous() {
let (_tmp, router) = user_admin_test_router("root-token");
post_create_user(
&router,
"root-token",
serde_json::json!({"username": "alice"}),
)
.await;
let preview_body = serde_json::json!({
"workspace": "default",
"project": "scratch",
"confirm": false,
"dry_run": true
});
// Root control case: reaches the handler (proven by getting past
// both auth statuses below into the route's own 404 for an unknown
// project), unlike the DB-user and anonymous requests.
let root_resp = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/purge-project")
.header("content-type", "application/json")
.header("authorization", "Bearer root-token")
.body(Body::from(serde_json::to_vec(&preview_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
root_resp.status(),
StatusCode::NOT_FOUND,
"root must reach the preview handler, not be blocked by the auth gate"
);
let db_user_resp = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/purge-project")
.header("content-type", "application/json")
.header("authorization", "Bearer db-user-token")
.body(Body::from(serde_json::to_vec(&preview_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
db_user_resp.status(),
StatusCode::FORBIDDEN,
"a dry-run preview must stay root-only for DB users in multi-user mode"
);
let anon_resp = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/purge-project")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&preview_body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
anon_resp.status(),
StatusCode::UNAUTHORIZED,
"a dry-run preview must require authentication in multi-user mode"
);
}
#[tokio::test]
async fn multiuser_operational_admin_routes_allow_root() {
let (_tmp, router) = user_admin_test_router("root-token");
@@ -803,3 +803,462 @@ async fn purge_project_idempotent_second_call_is_404() {
"second purge must 404 because project is already gone"
);
}
// ---------------------------------------------------------------------------
// Dry-run preview (no `confirm`, `dry_run: true`)
// ---------------------------------------------------------------------------
/// The preview must report the exact counts a confirmed purge would produce
/// — not an estimate — and it must not touch anything: the confirmed purge
/// run right after it must succeed with identical counts, and the project's
/// files and DB rows must still be intact in between.
#[tokio::test]
async fn purge_project_dry_run_reports_the_same_counts_the_confirmed_purge_will() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
let (ws, _keep, doomed) = seed_two_projects(&store, &state.wiki).await;
let proj_dir = state.wiki.project_root(ws, doomed);
let preview = post(
state.clone(),
"/admin/purge-project",
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
)
.await;
assert_eq!(preview.status(), StatusCode::OK, "a preview must succeed");
let preview_body = body_json(preview).await;
assert_eq!(preview_body["dry_run"], true);
assert_eq!(preview_body["pages_deleted"], 1);
assert_eq!(preview_body["sessions_deleted"], 1);
assert_eq!(preview_body["observations_deleted"], 3);
assert_eq!(preview_body["handoffs_deleted"], 1);
assert_eq!(preview_body["files_deleted"], json!([]));
assert_eq!(preview_body["files_failed"], json!([]));
assert_eq!(preview_body["compacted"], false);
assert!(
preview_body.get("pre_checkpoint").is_none(),
"a preview must not checkpoint the wiki tree"
);
assert!(
preview_body.get("checkpoint").is_none(),
"a preview must not checkpoint the wiki tree"
);
// Nothing was touched: the project directory and its row are still there.
assert!(
proj_dir.exists(),
"a preview must not remove the project directory"
);
assert!(
store
.reader
.find_project(ws, "doomed".to_string())
.await
.unwrap()
.is_some(),
"a preview must not delete the project row"
);
// The confirmed purge right after it must succeed with the same counts.
let confirmed = post(
state,
"/admin/purge-project",
json!({ "workspace": "default", "project": "doomed", "confirm": true }),
)
.await;
assert_eq!(confirmed.status(), StatusCode::OK);
let confirmed_body = body_json(confirmed).await;
assert_eq!(
confirmed_body.get("dry_run"),
None,
"a confirmed purge report has no dry_run key"
);
for field in [
"pages_deleted",
"sessions_deleted",
"observations_deleted",
"handoffs_deleted",
] {
assert_eq!(
confirmed_body[field], preview_body[field],
"the confirmed purge's {field} must match what the preview reported"
);
}
assert!(
!proj_dir.exists(),
"the confirmed purge must remove the project directory"
);
}
/// A dry run must not dispatch the admission webhook: nothing was decided
/// yet, so there is nothing for a mirror to act on. Uses the same
/// `Ignore`/non-blocking capture-hook pattern as
/// `purge_session_reports_file_cleanup_failure_after_db_commit` above, but
/// asserts the opposite — that no payload ever arrives — within a short
/// timeout.
#[tokio::test]
async fn purge_project_dry_run_does_not_dispatch_admission_webhook() {
let (url, rx) = spawn_capture_hook().await;
let tmp = TempDir::new().unwrap();
let chain = AdmissionChain::new(vec![WebhookConfig {
name: "async-mirror".into(),
url,
timeout_ms: 2_000,
failure_policy: FailurePolicy::Ignore,
events: vec![AdmissionOp::PurgeProject],
blocking: false,
}])
.unwrap();
let (state, store) = make_state_with_chain(&tmp, Some(chain)).await;
seed_two_projects(&store, &state.wiki).await;
let resp = post(
state,
"/admin/purge-project",
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let outcome = tokio::time::timeout(std::time::Duration::from_millis(300), rx).await;
assert!(
outcome.is_err(),
"a dry run must not dispatch the purge-project admission webhook"
);
}
/// A non-existent project must still 404 on a preview, exactly like the
/// confirmed purge does.
#[tokio::test]
async fn purge_project_dry_run_nonexistent_returns_404() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
store
.writer
.get_or_create_workspace("default")
.await
.unwrap();
let resp = post(
state,
"/admin/purge-project",
json!({
"workspace": "default",
"project": "nonexistent",
"confirm": false,
"dry_run": true
}),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body = body_json(resp).await;
assert!(body["error"].as_str().unwrap_or("").contains("not found"));
}
/// A non-existent workspace must also 404 on a preview.
#[tokio::test]
async fn purge_project_dry_run_nonexistent_workspace_returns_404() {
let tmp = TempDir::new().unwrap();
let (state, _store) = make_state(&tmp).await;
let resp = post(
state,
"/admin/purge-project",
json!({
"workspace": "ghost-workspace",
"project": "x",
"confirm": false,
"dry_run": true
}),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// The incident this feature guards against, exercised through the HTTP
/// route rather than the store function directly: a session lives in one
/// project but one of its observations is stamped into another (the
/// pre-#871 Windows path-casing split). The preview of the *other* project
/// must count that observation even though no session row lives there —
/// exactly the number a naive "0 sessions, 0 pages" glance would miss.
#[tokio::test]
async fn purge_project_dry_run_counts_observations_stamped_from_another_projects_session() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
let ws = store
.writer
.get_or_create_workspace("default")
.await
.unwrap();
let owner = store
.writer
.get_or_create_project(ws, "owner", None)
.await
.unwrap();
let doomed = store
.writer
.get_or_create_project(ws, "looks-empty", None)
.await
.unwrap();
let sid = SessionId::new();
store
.writer
.begin_session(NewSession {
id: sid,
workspace_id: ws,
project_id: owner,
agent_kind: AgentKind::ClaudeCode,
cwd: None,
actor_user: None,
})
.await
.unwrap();
// The stray observation: same session, different project_id.
store
.writer
.insert_observation(Sanitized::new(
NewObservation {
session_id: sid,
workspace_id: ws,
project_id: doomed,
kind: ObservationKind::UserPrompt,
extension: None,
source_event: None,
title: "stray".into(),
body: "stray body".into(),
importance: 5,
},
&Sanitizer::builtin(),
))
.await
.unwrap();
let resp = post(
state,
"/admin/purge-project",
json!({
"workspace": "default",
"project": "looks-empty",
"confirm": false,
"dry_run": true
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(
body["sessions_deleted"], 0,
"the session row itself lives in the owner project"
);
assert_eq!(
body["observations_deleted"], 1,
"the stray observation stamped into the previewed project must be counted"
);
}
/// BLOCKING fix: `dry_run` must always win over `confirm`. Before this test
/// existed, `{"confirm": true, "dry_run": true}` ran the real destructive
/// purge — `handle_purge_project` only checked `dry_run` inside the
/// `!confirm` branch, so a caller that (accidentally or not) sent both
/// `true` got the worst of both: a request that reads like a preview and
/// behaves like a purge. `reclaim-ledger-versions` never had this hole
/// because its handler passes `req.dry_run` straight into the op regardless
/// of `confirm`; `purge-project` now checks `dry_run` first, unconditionally,
/// exactly the same way.
#[tokio::test]
async fn purge_project_confirm_true_and_dry_run_true_still_only_previews() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
let (ws, _keep, doomed) = seed_two_projects(&store, &state.wiki).await;
let proj_dir = state.wiki.project_root(ws, doomed);
let resp = post(
state,
"/admin/purge-project",
json!({
"workspace": "default",
"project": "doomed",
"confirm": true,
"dry_run": true
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp).await;
assert_eq!(
body["dry_run"], true,
"confirm: true must not defeat dry_run: true"
);
assert_eq!(body["pages_deleted"], 1);
assert_eq!(body["sessions_deleted"], 1);
assert_eq!(body["observations_deleted"], 3);
// The only proof that matters: nothing was actually deleted.
assert!(
proj_dir.exists(),
"{{confirm: true, dry_run: true}} must not delete the project directory"
);
assert!(
store
.reader
.find_project(ws, "doomed".to_string())
.await
.unwrap()
.is_some(),
"{{confirm: true, dry_run: true}} must not delete the project row"
);
}
/// The mirror of the incident, exercised through the HTTP route: purging a
/// project P also collaterally deletes an observation stamped into a
/// *different* project Q (because `observations.session_id` cascades
/// regardless of the observation's own `project_id`), and orphans (nulls the
/// session reference of, without deleting) a handoff that lives in Q too.
/// Neither shows up in the plain `observations_deleted`/`handoffs_deleted`
/// counts, which is exactly why the two `collateral_*` fields exist.
#[tokio::test]
async fn purge_project_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project()
{
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
let ws = store
.writer
.get_or_create_workspace("default")
.await
.unwrap();
let doomed = store
.writer
.get_or_create_project(ws, "doomed", None)
.await
.unwrap();
let other = store
.writer
.get_or_create_project(ws, "other", None)
.await
.unwrap();
let sid = SessionId::new();
store
.writer
.begin_session(NewSession {
id: sid,
workspace_id: ws,
project_id: doomed,
agent_kind: AgentKind::ClaudeCode,
cwd: None,
actor_user: None,
})
.await
.unwrap();
// Collateral observation: session lives in `doomed`, observation is
// stamped into `other`.
store
.writer
.insert_observation(Sanitized::new(
NewObservation {
session_id: sid,
workspace_id: ws,
project_id: other,
kind: ObservationKind::UserPrompt,
extension: None,
source_event: None,
title: "collateral".into(),
body: "collateral body".into(),
importance: 5,
},
&Sanitizer::builtin(),
))
.await
.unwrap();
// Collateral handoff: lives in `other`, authored by the `doomed` session.
store
.writer
.insert_handoff(NewHandoff {
workspace_id: ws,
project_id: other,
from_session_id: Some(sid),
from_agent: AgentKind::ClaudeCode,
to_agent: None,
cwd: None,
summary: "collateral handoff".into(),
open_questions: vec![],
next_steps: vec![],
files_touched: vec![],
owner_user: None,
})
.await
.unwrap();
let preview = post(
state.clone(),
"/admin/purge-project",
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
)
.await;
assert_eq!(preview.status(), StatusCode::OK);
let preview_body = body_json(preview).await;
assert_eq!(preview_body["collateral_observations_deleted"], 1);
assert_eq!(preview_body["collateral_handoffs_denulled"], 1);
let confirmed = post(
state,
"/admin/purge-project",
json!({ "workspace": "default", "project": "doomed", "confirm": true }),
)
.await;
assert_eq!(confirmed.status(), StatusCode::OK);
let confirmed_body = body_json(confirmed).await;
assert_eq!(confirmed_body["collateral_observations_deleted"], 1);
assert_eq!(confirmed_body["collateral_handoffs_denulled"], 1);
// `other` survives as a project; only the collateral rows are affected.
assert_eq!(
store.reader.status_counts().await.unwrap().observations,
0,
"the collateral observation in `other` must actually be gone"
);
}
/// A live managed-run lease under the project must still refuse the preview
/// with the same `409` a confirmed purge would, unless `force` overrides it
/// — the preview promises to describe what a confirmed call would do, and a
/// confirmed call would refuse here too.
#[tokio::test]
async fn purge_project_dry_run_conflicts_on_a_live_managed_run_without_force() {
let tmp = TempDir::new().unwrap();
let (state, store) = make_state(&tmp).await;
let (workspace_id, _keep, project_id) = seed_two_projects(&store, &state.wiki).await;
store
.writer
.prepare_workstream_run(PrepareWorkstreamRun {
workspace_id,
project_id,
repo_fingerprint: "repo".into(),
worktree_fingerprint: "worktree".into(),
cwd: "/repo".into(),
agent: AgentKind::Codex,
automatic_harness: false,
available_agents: vec![AgentKind::Codex],
selection: WorkstreamSelection::Current,
lease_owner: "test".into(),
})
.await
.unwrap();
let resp = post(
state,
"/admin/purge-project",
json!({ "workspace": "default", "project": "doomed", "confirm": false, "dry_run": true }),
)
.await;
assert_eq!(
resp.status(),
StatusCode::CONFLICT,
"a live managed run must still 409 a preview without --force"
);
}
+1
View File
@@ -878,6 +878,7 @@ mod tests {
None,
false,
crate::Compaction::Skip,
crate::PurgeMode::Commit,
)
.await
.expect("a grant in force does not stand in a purge's way");
+2 -2
View File
@@ -61,8 +61,8 @@ pub use ops::{
EmbedOutcome, EmbeddingWrite, EntityBackfillSummary, HookSessionAdmission,
IngestObservationOutcome, LifecycleOnlyEndOutcome, MAX_PENDING_INBOX_MESSAGES,
MoveSessionSummary, MoveSummary, ObservationPruneOutcome, OkfMigratedPage,
PAGE_WINDOW_BACKFILL_BATCH, PageWindowBackfillSummary, PagesMode, PurgeSessionSummary,
PurgeSummary, ReorgSummary, backfill_entity_index, backfill_page_windows,
PAGE_WINDOW_BACKFILL_BATCH, PageWindowBackfillSummary, PagesMode, PurgeMode,
PurgeSessionSummary, PurgeSummary, ReorgSummary, backfill_entity_index, backfill_page_windows,
backfill_page_windows_in_batches, purge_session, record_embed_failure,
};
pub use project_authz::{
+424 -2
View File
@@ -139,6 +139,22 @@ pub struct PurgeSummary {
pub handoffs_deleted: u64,
/// Number of `page_embeddings` rows deleted (cascades through pages).
pub embeddings_deleted: u64,
/// `observations` rows belonging to a **different** project that are
/// deleted anyway, collaterally, because `observations.session_id`
/// carries `ON DELETE CASCADE` to `sessions` — a project's cascade does
/// not check the observation's own `project_id`. This is the mirror of
/// the incident this preview guards against: not "this scope holds more
/// than it looks like", but "purging this scope also reaches into
/// another one" (V01 `observations.session_id REFERENCES sessions(id)
/// ON DELETE CASCADE`).
pub collateral_observations_deleted: u64,
/// `handoffs` rows belonging to a different project whose
/// `from_session_id` or `accepted_by_session` is about to be set to
/// `NULL` — not deleted, just orphaned from the session that authored or
/// accepted them — because those columns are `ON DELETE SET NULL` to
/// `sessions` (V02). The row and its text survive; only the link back to
/// the purged project's session does not.
pub collateral_handoffs_denulled: u64,
/// Number of `workstreams` rows deleted. These cascade from `projects`,
/// so a project that looks empty by page/session/observation count can
/// still take a managed workstream — and its portable event ledger — down
@@ -4151,6 +4167,22 @@ pub enum Compaction {
Reclaim,
}
/// What [`purge_project`] does once it has finished counting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PurgeMode {
/// Run the delete for real and commit it.
Commit,
/// Stop right after the counts and roll the (read-only, so far)
/// transaction back. No `DELETE`, no cascade, no tombstone insert, no
/// audit row — the whole point is to never pay for a real purge's cost
/// just to undo it: on a project with a lot of rows, running and then
/// rolling back the actual `DELETE FROM projects` cascade would hold the
/// single writer actor (invariant #2) for as long as a real purge does,
/// and every hook capture queued behind it pays for that with a 429 or a
/// dropped observation. A preview only ever issues `SELECT`s.
Preview,
}
/// Every FTS5 index in the schema, which is what [`reclaim_freed_pages`]
/// rebuilds.
///
@@ -4928,10 +4960,23 @@ pub fn clear_bootstrap_progress(conn: &Connection, fingerprint: &str) -> StoreRe
/// cascades out of `projects`, so purging a scope whose lease is still live
/// would delete the lease row out from under a running agent.
///
/// `mode = `[`PurgeMode::Preview`] runs every count above — including the two
/// collateral ones — and then returns without ever issuing the `DELETE`, the
/// tombstone insert, or the audit row: the transaction so far has only ever
/// read, so rolling it back is free. This intentionally does *not* run the
/// delete and roll it back (as [`move_session`]'s dry run does): on a large
/// project that would hold the single writer actor (invariant #2) for as
/// long as a real purge takes, and every hook capture queued behind it pays
/// for that. The counts are what the confirmed call *would* delete, read the
/// same way the confirmed call itself decides what to delete — not a
/// separately-maintained estimate — but they are a snapshot, not a promise:
/// a write between the preview and a later `--confirm` can change them.
///
/// # Errors
/// Returns [`StoreError::ManagedRunActive`] when a managed run's lease is
/// still live and `force` is false, or [`StoreError`] if any SQL statement
/// fails. The transaction is rolled back automatically on error.
#[allow(clippy::too_many_arguments)]
pub fn purge_project(
conn: &mut Connection,
workspace_id: &WorkspaceId,
@@ -4940,6 +4985,7 @@ pub fn purge_project(
author_id: Option<ai_memory_core::UserId>,
force: bool,
compaction: Compaction,
mode: PurgeMode,
) -> StoreResult<PurgeSummary> {
let tx = conn.transaction()?;
@@ -5019,6 +5065,33 @@ pub fn purge_project(
&pid[..],
)?;
// Collateral damage in OTHER projects, via `sessions` cascading out of
// this one. `observations.session_id` is `ON DELETE CASCADE` (V01) with
// no regard for the observation's own `project_id`, so an observation
// stamped into a sibling project — the same split the incident this
// preview guards against turns on — is deleted right along with the
// session that wrote it, even though nothing in that sibling project's
// own row count says so.
let collateral_observations_deleted = count(
"SELECT COUNT(*) FROM observations \
WHERE project_id != ?1 \
AND session_id IN (SELECT id FROM sessions WHERE project_id = ?1)",
&pid[..],
)?;
// `handoffs.from_session_id` / `accepted_by_session` are `ON DELETE SET
// NULL` (V02): a handoff living in another project is not deleted, but
// loses the link back to whichever of this project's sessions authored
// or accepted it.
let collateral_handoffs_denulled = count(
"SELECT COUNT(*) FROM handoffs \
WHERE project_id != ?1 \
AND ( \
from_session_id IN (SELECT id FROM sessions WHERE project_id = ?1) \
OR accepted_by_session IN (SELECT id FROM sessions WHERE project_id = ?1) \
)",
&pid[..],
)?;
// Collect all distinct on-disk paths for the caller to clean up.
// We use DISTINCT because multiple versions of the same logical page
// share a path; the file only exists once. The statement must be
@@ -5046,6 +5119,29 @@ pub fn purge_project(
.collect::<Result<Vec<_>, _>>()?
};
if mode == PurgeMode::Preview {
// Nothing was written — every statement above was a `SELECT` — so
// rolling back here is immediate; it never had to pay for the
// `DELETE FROM projects` cascade this function's `Commit` mode runs
// below, which is the whole point (see `PurgeMode::Preview`'s doc).
tx.rollback()?;
return Ok(PurgeSummary {
label: workspace_project_label.to_string(),
page_paths,
pages_deleted,
sessions_deleted,
observations_deleted,
handoffs_deleted,
embeddings_deleted,
collateral_observations_deleted,
collateral_handoffs_denulled,
workstreams_deleted,
managed_runs_deleted,
workstream_ids,
compacted: false,
});
}
// Cascade handles pages / sessions / observations / handoffs /
// page_embeddings. The workspace row is intentionally left intact —
// other projects may still live there.
@@ -5080,7 +5176,8 @@ pub fn purge_project(
tx.commit()?;
if compaction == Compaction::Reclaim {
let compacted = compaction == Compaction::Reclaim;
if compacted {
reclaim_freed_pages(conn)?;
}
@@ -5092,10 +5189,12 @@ pub fn purge_project(
observations_deleted,
handoffs_deleted,
embeddings_deleted,
collateral_observations_deleted,
collateral_handoffs_denulled,
workstreams_deleted,
managed_runs_deleted,
workstream_ids,
compacted: compaction == Compaction::Reclaim,
compacted,
})
}
@@ -6440,6 +6539,7 @@ pub(crate) mod tests {
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.unwrap();
@@ -6482,6 +6582,7 @@ pub(crate) mod tests {
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.unwrap();
assert!(!summary.compacted, "the default does not VACUUM");
@@ -6516,6 +6617,7 @@ pub(crate) mod tests {
None,
false,
Compaction::Reclaim,
PurgeMode::Commit,
)
.unwrap();
assert!(summary.compacted, "the summary reports that VACUUM ran");
@@ -6532,6 +6634,320 @@ pub(crate) mod tests {
);
}
/// A preview's counts come from the same `SELECT`s the confirmed path
/// uses to decide what to delete, not a separately-maintained estimate,
/// so a preview and the confirmed run right after it must agree exactly
/// (barring a write landing in between, which neither this nor a real
/// `--confirm`-less-then-`--confirm` operator workflow can rule out).
#[test]
fn purge_project_dry_run_reports_the_same_counts_a_real_purge_would() {
let (_tmp, mut conn, ws, proj) = fresh_db();
seed_session(&mut conn, ws, proj, "canaryproj");
let preview = purge_project(
&mut conn,
&ws,
&proj,
"default/scratch",
None,
false,
Compaction::Skip,
PurgeMode::Preview,
)
.expect("a dry run must not error");
assert_eq!(preview.pages_deleted, 1);
assert_eq!(preview.sessions_deleted, 1);
assert_eq!(preview.observations_deleted, 1);
assert!(!preview.compacted, "a rolled-back run never reclaims bytes");
let real = purge_project(
&mut conn,
&ws,
&proj,
"default/scratch",
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.expect("the confirmed purge must still succeed after the preview");
assert_eq!(
(
real.pages_deleted,
real.sessions_deleted,
real.observations_deleted
),
(
preview.pages_deleted,
preview.sessions_deleted,
preview.observations_deleted
),
"the dry run's counts must match what the confirmed run actually deletes"
);
}
/// The incident this feature guards against: an operator purged a
/// project believing it held 0 sessions / 0 pages, and it actually held
/// over a thousand observations whose `project_id` pointed at the purged
/// project even though their `session_id` belonged to a session that
/// lived in a *different* project (a pre-#871 Windows path-casing
/// split). `purge_project` counts `observations` directly by
/// `project_id`, not by joining through `sessions`, so this must still
/// show up in a dry run's `observations_deleted` — the exact number a
/// naive "count sessions, look empty" check would miss.
#[test]
fn purge_project_dry_run_counts_observations_whose_session_lives_in_another_project() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let other = get_or_create_project(&mut conn, &ws, "elsewhere", None).unwrap();
// The session (and its own observation) live in `other`, not `proj`.
let (sid, _page) = seed_session(&mut conn, ws, other, "elsewhere-owner");
// A second observation of that same session is stamped into `proj` —
// the split this test pins.
let stray = NewObservation {
session_id: sid,
workspace_id: ws,
project_id: proj,
kind: ObservationKind::UserPrompt,
extension: None,
source_event: None,
title: "stray".into(),
body: "obs-stray-in-doomed-project".into(),
importance: 5,
};
insert_observation(&mut conn, &stray).unwrap();
let preview = purge_project(
&mut conn,
&ws,
&proj,
"default/scratch",
None,
false,
Compaction::Skip,
PurgeMode::Preview,
)
.expect("a dry run must not error");
assert_eq!(
preview.sessions_deleted, 0,
"the session row itself lives in `other`, not the previewed project"
);
assert_eq!(
preview.observations_deleted, 1,
"the stray observation stamped into the previewed project must still be counted"
);
// Nothing was actually touched: the session and its own observation
// in `other` are both still there, dry run or not.
let survived: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sessions WHERE id = ?1",
rusqlite::params![&sid.as_bytes()[..]],
|row| row.get(0),
)
.unwrap();
assert_eq!(
survived, 1,
"the session in the other project must survive the dry run"
);
}
/// Bite check: every project-scoped row count, the purge tombstone, and
/// the audit trail must all be identical before and after a
/// [`PurgeMode::Preview`] run. If `Preview` ever fell through to the
/// `Commit` path's `DELETE` / tombstone insert / audit insert, this is
/// the test that would catch it.
#[test]
fn purge_project_dry_run_changes_nothing() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let prepared = open_managed_run(&mut conn, &ws, &proj);
seed_workstream_event(&conn, &prepared.workstream_id, "canaryevt");
seed_session(&mut conn, ws, proj, "canaryproj");
let before = row_snapshot(&conn);
let preview = purge_project(
&mut conn,
&ws,
&proj,
"default/scratch",
None,
true, // force: a live managed run sits under this project
Compaction::Skip,
PurgeMode::Preview,
)
.expect("a dry run must not error even with a live managed run");
assert!(preview.pages_deleted >= 1);
assert!(preview.observations_deleted >= 1);
assert_eq!(preview.workstreams_deleted, 1);
assert_eq!(preview.managed_runs_deleted, 1);
let after = row_snapshot(&conn);
assert_eq!(
before, after,
"a dry run must leave every project-scoped table's row count unchanged"
);
}
/// The mirror of the incident this whole feature guards against: instead
/// of the purged project holding more than it looks like (rows counted
/// in `observations_deleted`), purging it reaches OUT and takes rows
/// from a project the operator never named. `observations.session_id`
/// is `ON DELETE CASCADE` (V01) with no regard for the observation's own
/// `project_id`, and `handoffs.from_session_id` /
/// `accepted_by_session` are `ON DELETE SET NULL` (V02) — neither of
/// which the plain `observations_deleted`/`handoffs_deleted` counts (by
/// `project_id = P`) can see, because these rows belong to a different
/// project.
#[test]
fn purge_project_counts_collateral_damage_in_another_project() {
let (_tmp, mut conn, ws, proj) = fresh_db();
let other = get_or_create_project(&mut conn, &ws, "other-project", None).unwrap();
// A session (and its own observation) rooted in the project being
// purged.
let (sid, _page) = seed_session(&mut conn, ws, proj, "owner");
// An observation in the OTHER project, stamped by the same session.
let stray_observation = NewObservation {
session_id: sid,
workspace_id: ws,
project_id: other,
kind: ObservationKind::UserPrompt,
extension: None,
source_event: None,
title: "stray".into(),
body: "obs-in-other-project".into(),
importance: 5,
};
insert_observation(&mut conn, &stray_observation).unwrap();
// A handoff in the OTHER project, authored by that same session.
insert_handoff(
&mut conn,
&NewHandoff {
workspace_id: ws,
project_id: other,
from_session_id: Some(sid),
from_agent: ai_memory_core::AgentKind::ClaudeCode,
to_agent: None,
cwd: None,
summary: "handoff in other project".into(),
open_questions: vec![],
next_steps: vec![],
files_touched: vec![],
owner_user: None,
},
)
.unwrap();
let preview = purge_project(
&mut conn,
&ws,
&proj,
"default/scratch",
None,
false,
Compaction::Skip,
PurgeMode::Preview,
)
.expect("a preview must not error");
assert_eq!(
preview.collateral_observations_deleted, 1,
"the observation in the other project must be counted as collateral"
);
assert_eq!(
preview.collateral_handoffs_denulled, 1,
"the handoff in the other project must be counted as collateral"
);
// The preview changed nothing: both rows are still exactly as seeded.
let obs_in_other: i64 = conn
.query_row(
"SELECT COUNT(*) FROM observations WHERE project_id = ?1",
rusqlite::params![&other.as_bytes()[..]],
|r| r.get(0),
)
.unwrap();
assert_eq!(obs_in_other, 1);
let real = purge_project(
&mut conn,
&ws,
&proj,
"default/scratch",
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.expect("the confirmed purge must succeed");
assert_eq!(real.collateral_observations_deleted, 1);
assert_eq!(real.collateral_handoffs_denulled, 1);
// The prediction must match what the cascade actually did: the
// collateral observation is really gone from the other project (its
// own project row was never touched)...
let obs_in_other_after: i64 = conn
.query_row(
"SELECT COUNT(*) FROM observations WHERE project_id = ?1",
rusqlite::params![&other.as_bytes()[..]],
|r| r.get(0),
)
.unwrap();
assert_eq!(
obs_in_other_after, 0,
"the collaterally-cascaded observation must actually be gone"
);
// ...and the handoff row itself survives (it belongs to `other`,
// which was never purged) but its session reference is nulled, not
// the row.
let (handoffs_in_other, from_session_id): (i64, Option<Vec<u8>>) = conn
.query_row(
"SELECT COUNT(*), MAX(from_session_id) FROM handoffs WHERE project_id = ?1",
rusqlite::params![&other.as_bytes()[..]],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(
handoffs_in_other, 1,
"the handoff row in the other project must survive"
);
assert!(
from_session_id.is_none(),
"the handoff's from_session_id must be nulled, not the row deleted"
);
}
/// Row counts of every table a real purge touches, used to prove a dry
/// run changed nothing. `purged_scopes` and `audit_log` are included
/// deliberately: both are written inside the same transaction as the
/// delete, so a rollback must take them back out too, not just the
/// cascade.
fn row_snapshot(conn: &Connection) -> Vec<(&'static str, i64)> {
[
"pages",
"sessions",
"observations",
"handoffs",
"page_embeddings",
"workstreams",
"managed_runs",
"workstream_events",
"projects",
"purged_scopes",
"audit_log",
]
.iter()
.map(|table| {
(
*table,
count(conn, &format!("SELECT COUNT(*) FROM {table}")),
)
})
.collect()
}
/// The reason `reclaim_freed_pages` rebuilds all three FTS indexes rather
/// than the two a session purge needs.
///
@@ -6569,6 +6985,7 @@ pub(crate) mod tests {
None,
true,
Compaction::Reclaim,
PurgeMode::Commit,
)
.unwrap();
@@ -11418,6 +11835,7 @@ pub(crate) mod tests {
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.expect("purge of fresh project should succeed");
// Now try to rename the project that no longer exists. The
@@ -11483,6 +11901,7 @@ pub(crate) mod tests {
Some(author),
false,
Compaction::Skip,
PurgeMode::Commit,
)
.expect("purge should succeed");
@@ -11538,6 +11957,7 @@ pub(crate) mod tests {
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.expect_err("an active managed run must block the purge");
@@ -11579,6 +11999,7 @@ pub(crate) mod tests {
None,
true,
Compaction::Skip,
PurgeMode::Commit,
)
.expect("force purges regardless of the live lease");
@@ -11615,6 +12036,7 @@ pub(crate) mod tests {
None,
false,
Compaction::Skip,
PurgeMode::Commit,
)
.expect("a lapsed lease is not a running agent");
+17
View File
@@ -394,6 +394,13 @@ pub(crate) enum WriteCmd {
force: bool,
/// Whether to reclaim the freed bytes afterwards (`VACUUM`).
compaction: crate::ops::Compaction,
/// [`crate::ops::PurgeMode::Preview`] stops right after counting and
/// never issues the delete — unlike [`WriteCmd::MoveSession`]'s dry
/// run, which runs the real write and rolls it back. See
/// [`crate::ops::PurgeMode`]'s doc for why: a rolled-back delete on a
/// large project would still hold the writer actor for as long as a
/// real purge does.
mode: crate::ops::PurgeMode,
reply: oneshot::Sender<StoreResult<PurgeSummary>>,
},
/// Delete one session and everything derived from it, inside a single
@@ -1844,9 +1851,15 @@ impl WriterHandle {
/// returned [`PurgeSummary`] includes pre-delete row counts and
/// the distinct page paths that the caller must remove from disk.
///
/// `mode = `[`ops::PurgeMode::Preview`] stops right after counting and
/// never issues the delete — see [`ops::purge_project`] for why, and for
/// the two collateral counts (`collateral_observations_deleted`,
/// `collateral_handoffs_denulled`) either mode reports.
///
/// # Errors
/// Returns [`StoreError::WriterClosed`] if the actor has shut down, or
/// propagates the SQL error from the purge transaction.
#[allow(clippy::too_many_arguments)]
pub async fn purge_project(
&self,
workspace_id: WorkspaceId,
@@ -1855,6 +1868,7 @@ impl WriterHandle {
author_id: Option<ai_memory_core::UserId>,
force: bool,
compaction: crate::ops::Compaction,
mode: crate::ops::PurgeMode,
) -> StoreResult<PurgeSummary> {
let (tx, rx) = oneshot::channel();
self.send(WriteCmd::PurgeProject {
@@ -1864,6 +1878,7 @@ impl WriterHandle {
author_id,
force,
compaction,
mode,
reply: tx,
})
.await?;
@@ -3523,6 +3538,7 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
author_id,
force,
compaction,
mode,
reply,
} => {
let result = ops::purge_project(
@@ -3533,6 +3549,7 @@ fn worker_loop(mut conn: Connection, mut rx: mpsc::Receiver<WriteCmd>) {
author_id,
force,
compaction,
mode,
);
send_or_warn(reply, result, "purge_project");
}
+1
View File
@@ -4890,6 +4890,7 @@ mod tests {
None,
false,
ai_memory_store::Compaction::Skip,
ai_memory_store::PurgeMode::Commit,
)
.await
.unwrap();
+47 -1
View File
@@ -8,7 +8,7 @@ on a homelab box where mistakes are harder to undo.
| Command | Safe with server **running**? | Wipes data? | Reversible? | Notes |
|---|---|---|---|---|
| `purge-project --confirm` | ✅ yes | the one project's data | no | Deletes the UUID-namespaced wiki root and raw workstream segments; sibling projects remain untouched. Refuses with `409` while a managed workstream under the project holds a live run lease — `--force` overrides. Logical delete by default; `--compact` additionally rebuilds the FTS indexes and `VACUUM`s (see below). |
| `purge-project --confirm` | ✅ yes | the one project's data, **plus** any observation stamped into a different project by one of this project's sessions (cascades regardless of the observation's own `project_id`), and it nulls (does not delete) the session reference on any handoff in a different project that this project's sessions authored or accepted | no | Deletes the UUID-namespaced wiki root and raw workstream segments. Refuses with `409` while a managed workstream under the project holds a live run lease — `--force` overrides. Logical delete by default; `--compact` additionally rebuilds the FTS indexes and `VACUUM`s (see below). Without `--confirm` it previews the same counts, including the cross-project ones, before refusing — see below. |
| `purge-session --session-id --confirm` | ✅ yes | the one session's data | no | Deletes one session by UUID: its row, its observations, the handoffs it **authored**, its `sessions/<id>.md` page and every superseded version, their embeddings, and its auto-improve runs. Strictly scoped — a session that does not belong to the named workspace/project is a `404` and nothing is deleted. Handoffs the session only *accepted* are kept: that text belongs to the session that wrote it. Logical delete by default; `--compact` additionally rebuilds the FTS indexes and `VACUUM`s (see below). |
| `handoffs --expire-all --confirm` | ✅ yes | no (state change only) | no (but nothing is destroyed) | Marks every **open** handoff in the scope `expired` so it stops being offered to an agent. Rows, summaries and provenance are kept and stay visible in the audit log. Unlike the automatic sweep it does **not** spare manual handoffs or ones from another directory — those exemptions are exactly what a leftover backlog is made of, so honouring them would clear nothing. `--older-than-days N` keeps recent batons. Owner-scoped: never touches another user's baton. |
| `rename-project --from --to` | ✅ yes | no | yes (rename back) | Column-only update on `projects.name`. The on-disk dir is keyed by `project_id` (UUID), so the rename never moves a file. |
@@ -209,6 +209,52 @@ raw segment directory is removed on the server and appears in
`files_deleted`; a failed removal appears in `files_failed` alongside wiki
cleanup failures.
#### Preview without `--confirm`
Without `--confirm`, the CLI first asks the server for a preview
(`"dry_run": true` in the request). `dry_run` always wins over `confirm` —
`{"confirm": true, "dry_run": true}` still only previews, the same way
`reclaim-ledger-versions` treats its own `dry_run` field — so a preview
request can never become destructive by accident.
The preview runs the same lookups and counts steps 1-3 above use to decide
what a confirmed purge would delete — same 404 on an unknown scope, same
`409` on a live managed-run lease without `--force` — and returns those
counts, including the two cross-project ones (an observation deleted, or a
handoff's session reference nulled, in a project other than the one named;
see the matrix row above), without ever issuing the `DELETE` in step 4. It
does not run the delete and roll it back: on a large project that would cost
as much writer-actor time as a real purge (every hook capture queued behind
it pays for that), for no benefit over just counting. Because step 4 never
runs, step 5's filesystem cleanup never runs either (`files_deleted` /
`files_failed` are always empty), and neither the `purged_scopes` tombstone
nor the `audit_log` row from step 4's transaction is written; neither
checkpoint is taken. The reply carries `"dry_run": true`. The CLI prints:
```
Would purge default/my-project: 3 pages, 1 sessions, 1063 observations, 0 handoffs, 3 embeddings, 0 workstreams, 0 managed runs.
```
(with a trailing "Plus N observations in other projects via their sessions"
/ "Plus N handoffs ..." clause when either cross-project count is non-zero),
then still refuses with the existing "destructive and irreversible" message
and a non-zero exit — the preview is information layered on top of the
refusal, never a substitute for `--confirm`.
A preview also skips the blocking admission call a confirmed purge makes
before deleting anything (`admit_purge_project`): nothing was decided yet,
so there is nothing for a `Reject`-policy or scope-guard webhook to act on.
This means a `200` preview is not a guarantee — that same webhook only runs
on the confirmed path and can still refuse the real purge afterward.
If the server is unreachable, times out (a few seconds, auth-token refresh
included), or predates this field (a plain `400`), the CLI falls back
silently to the plain refusal with no preview line, so no existing script's
exit code or error shape changes — only a confirmed purge is ever
destructive. A `404`/`409`/`403` (or any other unexpected status) prints the
server's own error before the refusal instead, since the operator asked what
would happen and the server has a real answer.
Failure modes:
- **Workspace or project name not found** → 404, no mutation.
+1 -1
View File
@@ -41,7 +41,7 @@ boundary not yet built.
| 8c | Messaging: pop-exactly-once | `ai-memory-store/src/ops.rs` `pop_message_in_transaction` atomic CAS `WHERE state='pending'` | `agent_messages.rs` — sequential **and** `tokio::join!` concurrent double-pop yields exactly one `Some` | STRONG |
| 8d | Messaging: inferred-scope read is diagnosed (#854) | `scope.rs` `is_inferred`; `server.rs` `inferred_scope_hint` on empty pop/list | `agent_messages_briefing.rs` `no_scope_pop_that_misses_the_mail_is_diagnosed_not_a_silent_null` | STRONG |
| 9 | Scope resolution fail-closed | `ai-memory-store/src/scope.rs` no-create `lookup_existing_*`; create only via `create_explicit_scope` | `scope.rs` no-auto-create + `unscoped_write_with_unresolvable_coordinate_errors` | STRONG |
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9) | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir | STRONG |
| 10 | Destructive-op live-process refusal + confirm flags (invariant #9); `purge-project`'s `dry_run` always wins over `confirm` so a preview request can never become destructive | `ai-memory-cli/src/commands/process_guard.rs` `sibling_processes` + confirm flags in `reset`/`restore`/`reindex`/`uninstall --purge-data`/`purge_project`; `admin.rs` `handle_purge_project` checks `req.dry_run` unconditionally, before `req.confirm`, mirroring `reclaim-ledger-versions` | `admin_purge.rs` confirm→400; `removal.rs` — injected live-sibling makes each destructive command bail before touching the data dir; `admin_purge.rs` `purge_project_confirm_true_and_dry_run_true_still_only_previews` — `{"confirm": true, "dry_run": true}` deletes nothing (the regression this row now also covers); `ops.rs` `purge_project_dry_run_changes_nothing` — every project-scoped row count, the `purged_scopes` tombstone, and the `audit_log` row are identical before/after a `PurgeMode::Preview` run; `ops.rs` `purge_project_counts_collateral_damage_in_another_project` and `admin_purge.rs` `purge_project_dry_run_and_confirmed_purge_both_report_collateral_damage_in_another_project` — a purge of project P collaterally deletes an observation, and orphans a handoff's session reference, in a *different* project Q (via `sessions` cascading out of P), and both the preview and the confirmed report count it | STRONG |
| 10b | Session purge is scope+owner-bound (no cross-session/project over-delete) | `ai-memory-store/src/ops.rs` `purge_session` — selection scoped to `(workspace_id, project_id)` and keyed on this session's own `summary_page_id` **or** `path='sessions/<sid>.md'` + `json_extract(frontmatter_json,'$.session_id')=<sid>` (frontmatter owner, not the recursive latest-chain); `in_scope==0 → NotFound` fail-closed; whole op in one transaction | `ops.rs` `purge_session_leaves_a_sibling_session_in_the_same_project_intact`, `…refuses_a_session_from_another_project_and_deletes_nothing`, `…refuses_a_session_from_another_workspace`, `…does_not_delete_an_identically_pathed_page_in_another_project`, `…removes_older_summary_versions_without_deleting_prior_manual_page` (#862) | STRONG |
| 10c | Ledger reclaim is content-gated, not filename-gated (invariant #16) | `ai-memory-store/src/ops.rs` `reclaim_ledger_versions` — a candidate row must pass `ai_memory_core::log_ledger::body_opens_with_log_ledger` on its own `body`, and only `is_latest=0 AND superseded_at IS NULL` rows are eligible, so decay-owned rows stay with `forget-sweep`; confirm gate in `admin.rs` `handle_reclaim_ledger_versions` | `reclaim_ledger_versions.rs` `a_prose_page_wearing_the_ledger_name_keeps_its_whole_version_chain`, `an_ordinary_page_chain_in_another_project_is_untouched`, `a_decay_tombstone_stays_with_the_sweep_that_owns_it`; `admin_reclaim_ledger_versions.rs` `deleting_without_confirm_is_refused_and_changes_nothing` (with dry-run and confirmed controls) | STRONG |
| 11a | Hook backpressure (202/429) + bounded fan-out (invariant #5) | `ai-memory-hooks/src/router.rs` semaphore→429, 202 immediately, `MAX_HOOK_BATCH_ITEMS`, bounded LRU limiter | `router.rs` `handle_hook_returns_429_when_ingest_saturated`, `ingest_rate_limiter_is_bounded` | STRONG |