cortex/backend/sandbox.rs
1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Sandbox corpora (Arm 5) — carve a working subset out of a parent corpus by a **message-condition
9//! filter**, as a first-class corpus an agent (or human) can iterate conversion campaigns on.
10//!
11//! A sandbox is an ordinary `corpora` row with two extra columns set: `parent_corpus_id` (the
12//! corpus it was carved from) and `selection` (the filter predicate). The selection IS the
13//! provenance — the predicate applied over the parent — so no per-task origin link is kept (owner
14//! decision 2026-06-15). Sources are referenced **in place** (the sandbox shares the parent's entry
15//! paths; nothing is copied) and the carved set is a **one-time snapshot** evaluated at creation.
16//! See `docs/archive/SANDBOX_CORPORA.md`.
17
18use diesel::result::Error;
19use diesel::sql_types::Text;
20use diesel::*;
21use serde::{Deserialize, Serialize};
22
23use crate::concerns::CortexInsertable;
24use crate::helpers::TaskStatus;
25use crate::models::{Corpus, NewSandboxCorpus};
26
27/// The filter that defines a sandbox: a slice of the parent corpus addressed by independent,
28/// **intersected** task-status and message (`severity`/`category`/`what`) dimensions (Model C),
29/// plus optional entry/limit narrowing. Serialized verbatim into the sandbox corpus's `selection`
30/// JSON.
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct SandboxSelection {
33 /// the service whose conversion results are being filtered
34 pub service_id: i32,
35 /// optional **task-status** filter (`todo` | `no_problem` | `warning` | `error` | `fatal` |
36 /// `invalid`) — the task's status (`todo` = not yet run). Intersected with the message filter
37 /// below.
38 #[serde(default)]
39 pub status: Option<String>,
40 /// optional **message-severity** filter (`info` | `warning` | `error` | `fatal` | `invalid`) —
41 /// the carve keeps tasks that *emitted* a message of this severity (any task status).
42 /// `category`/`what` narrow within it.
43 #[serde(default)]
44 pub message_severity: Option<String>,
45 /// optional message-category narrowing (needs `message_severity`)
46 #[serde(default)]
47 pub category: Option<String>,
48 /// optional `what` narrowing within the category (needs `category`)
49 #[serde(default)]
50 pub what: Option<String>,
51 /// optional substring the parent `entry` must contain, matched at any position (`entry LIKE
52 /// '%…%'`). Include slashes for precision — `/2605/` carves exactly the arXiv month (a bare
53 /// `2605.` also matches mid-id paths like `…0302605.zip`). `None`/empty = no narrowing.
54 #[serde(default)]
55 pub entry: Option<String>,
56 /// optional hard cap on how many entries the carve captures — the first `n` by `entry` order
57 /// (deterministic). `None`/non-positive = no cap.
58 #[serde(default)]
59 pub max_entries: Option<i64>,
60 /// **Legacy** (pre-Model-C) single overloaded severity — kept only so selections stored before
61 /// the status/message split still render their provenance. Never set by new carves.
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub severity: Option<String>,
64}
65
66/// The message-severity → `log_*` table map (includes `info`, which has no [`TaskStatus`]). The
67/// only thing the carve interpolates into SQL, and it comes from this fixed map — never user input.
68fn message_log_table(message_severity: &str) -> Option<&'static str> {
69 match message_severity {
70 "info" => Some("log_infos"),
71 "warning" => Some("log_warnings"),
72 "error" => Some("log_errors"),
73 "fatal" => Some("log_fatals"),
74 "invalid" => Some("log_invalids"),
75 _ => None,
76 }
77}
78
79/// A validated, resolved sandbox filter: the task-status raw int to match (if any) and the message
80/// `log_*` table to look in (if any). The carve uses both; the pre-flight just checks for `Err`.
81pub struct ResolvedFilter {
82 /// raw task-status int to match `t.status` against, or `None` for "any status"
83 pub status_raw: Option<i32>,
84 /// the `log_*` table a message filter looks in, or `None` for "no message filter"
85 pub message_table: Option<&'static str>,
86}
87
88impl SandboxSelection {
89 /// A compact, human-readable summary of the carve filter — e.g.
90 /// `status=no_problem, message=error/missing_file, entry~2506., limit=100`. The single source of
91 /// truth for both the sandbox corpus's stored `description` and the provenance surfaced on its
92 /// corpus page / detail API, so the two never drift. Falls back to the legacy `severity` for
93 /// selections stored before the status/message split.
94 pub fn filter_summary(&self) -> String {
95 let mut parts: Vec<String> = Vec::new();
96 if let Some(status) = &self.status {
97 parts.push(format!("status={status}"));
98 }
99 if let Some(message_severity) = &self.message_severity {
100 let mut message = format!("message={message_severity}");
101 if let Some(category) = &self.category {
102 message.push_str(&format!("/{category}"));
103 }
104 if let Some(what) = &self.what {
105 message.push_str(&format!("/{what}"));
106 }
107 parts.push(message);
108 }
109 // Legacy stored selections only had the overloaded `severity` (+ its category/what).
110 if parts.is_empty()
111 && let Some(severity) = &self.severity
112 {
113 parts.push(format!("severity={severity}"));
114 if let Some(category) = &self.category {
115 parts.push(format!("category={category}"));
116 }
117 if let Some(what) = &self.what {
118 parts.push(format!("what={what}"));
119 }
120 }
121 if let Some(entry) = self
122 .entry
123 .as_deref()
124 .map(str::trim)
125 .filter(|e| !e.is_empty())
126 {
127 parts.push(format!("entry~{entry}"));
128 }
129 if let Some(n) = self.max_entries.filter(|n| *n > 0) {
130 parts.push(format!("limit={n}"));
131 }
132 parts.join(", ")
133 }
134
135 /// Validates the **intersecting** task-status and message filters and resolves them for the
136 /// carve, or returns a human-readable reason. They are independent dimensions (Model C):
137 /// `status` matches the task's outcome; `message_severity` (+ `category`/`what`) matches a log
138 /// message the task emitted, **at any task status** — so `info`+category collects every task
139 /// that emitted that info message. `category` needs a `message_severity`; `what` needs a
140 /// `category`; at least one of the two dimensions must be present. The **pre-flight twin** of
141 /// the carve (`start_sandbox` calls it for an immediate `422`).
142 pub fn validate(&self) -> Result<ResolvedFilter, String> {
143 let status_raw = match self.status.as_deref() {
144 Some(key) => Some(
145 TaskStatus::from_key(key)
146 .ok_or_else(|| {
147 format!(
148 "unknown task status '{key}' (use todo, no_problem, warning, error, fatal, invalid)"
149 )
150 })?
151 .raw(),
152 ),
153 None => None,
154 };
155 let message_table = match self.message_severity.as_deref() {
156 Some(key) => Some(message_log_table(key).ok_or_else(|| {
157 format!("unknown message severity '{key}' (use info, warning, error, fatal, invalid)")
158 })?),
159 None => None,
160 };
161 if message_table.is_none() && (self.category.is_some() || self.what.is_some()) {
162 return Err("category/what need a message_severity to filter on".to_string());
163 }
164 if self.what.is_some() && self.category.is_none() {
165 return Err("what needs a category".to_string());
166 }
167 if status_raw.is_none() && message_table.is_none() {
168 return Err(
169 "a sandbox needs at least a task-status or a message-severity filter".to_string(),
170 );
171 }
172 Ok(ResolvedFilter {
173 status_raw,
174 message_table,
175 })
176 }
177}
178
179/// The result of carving a sandbox: the new corpus plus how many entries it captured.
180pub struct SandboxOutcome {
181 /// the freshly-created sandbox corpus
182 pub sandbox: Corpus,
183 /// number of parent entries that matched the selection (= number of `TODO` tasks created)
184 pub entry_count: usize,
185}
186
187/// Carves a **sandbox corpus** from `parent` using `selection`, **entirely server-side**: it
188/// inserts the sandbox `corpora` row, then a single `INSERT INTO tasks (...) SELECT ... FROM tasks
189/// ...` materializes a `TODO` task per matched parent entry **without ever loading the entries into
190/// the application**. A 100k-entry carve therefore costs no client RAM and no per-row bind
191/// parameters (it sidesteps the 65535-parameter cap a client-side batch insert would hit); the
192/// whole carve is one transaction, so it is atomic (no half-built sandbox). Because the matching
193/// `SELECT` over a large parent can take minutes to an hour, this is meant to run as a **background
194/// job** (`corpus_sandbox`).
195///
196/// A severity-only selection reads `tasks` directly; a `category`/`what` narrowing joins the
197/// severity's `log_*` table (the table name comes from the fixed [`TaskStatus::to_table`] map, so
198/// it is never user-controlled; ids/`category`/`what` are bound parameters). `SELECT DISTINCT`
199/// collapses a parent task carrying several matching messages to a single carved entry — which also
200/// satisfies the `tasks` `UNIQUE(entry, service_id, corpus_id)` constraint.
201///
202/// **Output-isolation note:** the sandbox is its own `corpus_id` (own tasks, runs, reports).
203/// Running a *conversion* on it would, today, write result archives to the shared
204/// `<entry-dir>/<service>.zip` path it inherits from the parent — so isolating a sandbox's **rerun
205/// outputs** needs a follow-up (a sink output-path change), tracked in
206/// `docs/archive/SANDBOX_CORPORA.md` + `docs/KNOWN_ISSUES.md`.
207pub fn create_sandbox(
208 connection: &mut PgConnection,
209 parent: &Corpus,
210 name: &str,
211 selection: &SandboxSelection,
212) -> Result<SandboxOutcome, Error> {
213 // Validation (the intersecting status + message filters) lives in `validate`, so the carve and
214 // the `start_sandbox` pre-flight reject the identical set.
215 let ResolvedFilter {
216 status_raw,
217 message_table,
218 } = selection
219 .validate()
220 .map_err(|message| Error::QueryBuilderError(message.into()))?;
221 let selection_json = serde_json::to_value(selection).ok();
222
223 // Optional entry-substring narrowing, matched at ANY position so a filter can target any path
224 // slot (`entry LIKE '%…%'`). Include slashes for precision: `/2605/` carves exactly the arXiv
225 // month directory, whereas a bare `2605.` also matches mid-id paths like
226 // `.../cond-mat0302605/cond-mat0302605.zip` (`0302605.zip` literally contains `2605.`). Always
227 // bound (default `%` = match every entry) so the three SQL branches share one extra bind slot.
228 // Trimmed; blank = no narrowing.
229 let entry_pattern = selection
230 .entry
231 .as_deref()
232 .map(str::trim)
233 .filter(|e| !e.is_empty())
234 .map_or_else(|| "%".to_string(), |e| format!("%{e}%"));
235
236 // Optional deterministic size cap: the first `n` entries by `entry` order. `n` is a validated
237 // i64, so it is safe to inline (no bind needed; an integer has no injection surface).
238 // Non-positive caps are ignored. Appended after the WHERE clause of each branch.
239 let limit_clause = match selection.max_entries {
240 Some(n) if n > 0 => format!(" ORDER BY t.entry LIMIT {n}"),
241 _ => String::new(),
242 };
243
244 let description = format!(
245 "Sandbox of '{}' (filter: {})",
246 parent.name,
247 selection.filter_summary()
248 );
249
250 let new_sandbox = NewSandboxCorpus {
251 path: parent.path.clone(),
252 name: name.to_string(),
253 complex: parent.complex,
254 description,
255 parent_corpus_id: Some(parent.id),
256 selection: selection_json,
257 };
258
259 let todo = TaskStatus::TODO.raw();
260 let service_id = selection.service_id;
261 let parent_id = parent.id;
262
263 connection.transaction(|t_connection| {
264 new_sandbox.create(t_connection)?;
265 let sandbox = Corpus::find_by_name(name, t_connection)?;
266
267 // Server-side carve as ONE `INSERT … SELECT` (no rows cross into the app). The two filters are
268 // INTERSECTED: an optional `t.status = <status>` and, independently, an optional EXISTS over
269 // the message `log_*` table — so a message filter matches a task **at any status**.
270 // Validated ints (ids, status, limit) are inlined (no injection surface); the only user
271 // strings (category / what / entry) are bound; the only interpolated identifier is the
272 // fixed-map `log_*` table name.
273 let sandbox_id = sandbox.id;
274 let status_clause = match status_raw {
275 Some(status) => format!(" AND t.status = {status}"),
276 None => String::new(),
277 };
278 let base = format!(
279 "INSERT INTO tasks (service_id, corpus_id, status, entry) \
280 SELECT {service_id}, {sandbox_id}, {todo}, t.entry FROM tasks t \
281 WHERE t.corpus_id = {parent_id} AND t.service_id = {service_id}{status_clause}"
282 );
283 let entry_count = match (
284 message_table,
285 selection.category.as_deref(),
286 selection.what.as_deref(),
287 ) {
288 // No message filter (status-only / entry-only): a plain status + entry scan.
289 (None, _, _) => sql_query(format!("{base} AND t.entry LIKE $1{limit_clause}"))
290 .bind::<Text, _>(&entry_pattern)
291 .execute(t_connection)?,
292 // Message severity, no category: tasks that emitted ANY message of that severity.
293 (Some(table), None, _) => sql_query(format!(
294 "{base} AND EXISTS (SELECT 1 FROM {table} l WHERE l.task_id = t.id) \
295 AND t.entry LIKE $1{limit_clause}"
296 ))
297 .bind::<Text, _>(&entry_pattern)
298 .execute(t_connection)?,
299 // Message severity + category.
300 (Some(table), Some(category), None) => sql_query(format!(
301 "{base} AND EXISTS (SELECT 1 FROM {table} l WHERE l.task_id = t.id AND l.category = $1) \
302 AND t.entry LIKE $2{limit_clause}"
303 ))
304 .bind::<Text, _>(category)
305 .bind::<Text, _>(&entry_pattern)
306 .execute(t_connection)?,
307 // Message severity + category + what.
308 (Some(table), Some(category), Some(what)) => sql_query(format!(
309 "{base} AND EXISTS (SELECT 1 FROM {table} l WHERE l.task_id = t.id AND l.category = $1 \
310 AND l.what = $2) AND t.entry LIKE $3{limit_clause}"
311 ))
312 .bind::<Text, _>(category)
313 .bind::<Text, _>(what)
314 .bind::<Text, _>(&entry_pattern)
315 .execute(t_connection)?,
316 };
317
318 Ok(SandboxOutcome {
319 sandbox,
320 entry_count,
321 })
322 })
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn sel(
330 status: Option<&str>,
331 message_severity: Option<&str>,
332 category: Option<&str>,
333 what: Option<&str>,
334 ) -> SandboxSelection {
335 SandboxSelection {
336 service_id: 1,
337 status: status.map(String::from),
338 message_severity: message_severity.map(String::from),
339 category: category.map(String::from),
340 what: what.map(String::from),
341 entry: None,
342 max_entries: None,
343 severity: None,
344 }
345 }
346
347 #[test]
348 fn model_c_validation_is_status_and_message_intersected() {
349 // The F-7 fix: `info`+category is now VALID (info is a real message severity, `log_infos`;
350 // `conversion` is a real info category — unlike `missing_file`, which is a *warning* category).
351 assert!(
352 sel(None, Some("info"), Some("conversion"), None)
353 .validate()
354 .is_ok()
355 );
356 // status + message intersect; status-only; message-only (any status) — all valid.
357 assert!(
358 sel(
359 Some("no_problem"),
360 Some("warning"),
361 Some("missing_file"),
362 None
363 )
364 .validate()
365 .is_ok()
366 );
367 assert!(sel(Some("warning"), None, None, None).validate().is_ok());
368 assert!(sel(None, Some("error"), None, None).validate().is_ok());
369
370 // category needs a message_severity; what needs a category.
371 assert!(
372 sel(Some("no_problem"), None, Some("x"), None)
373 .validate()
374 .is_err()
375 );
376 assert!(
377 sel(None, Some("warning"), None, Some("x"))
378 .validate()
379 .is_err()
380 );
381 // `info` is a message severity, NOT a task status — rejected in the status slot.
382 assert!(sel(Some("info"), None, None, None).validate().is_err());
383 // an empty selection (no dimension) is rejected.
384 assert!(sel(None, None, None, None).validate().is_err());
385 }
386
387 #[test]
388 fn legacy_selection_still_renders_its_provenance() {
389 // A pre-Model-C stored selection (only `severity`) must still summarise for the provenance UI.
390 let mut legacy = sel(None, None, Some("missing_file"), None);
391 legacy.severity = Some("warning".to_string());
392 let summary = legacy.filter_summary();
393 assert!(summary.contains("severity=warning"), "got {summary:?}");
394 assert!(summary.contains("category=missing_file"), "got {summary:?}");
395 }
396}