From e85e8efe792175215b554f8b148f2d1c79bd3559 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 14 May 2026 16:39:49 +0100 Subject: [PATCH] feat(retention): [retention] manifest section + `verisimiser gc` subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #50. Sidecar tables grew unboundedly. README mentioned retention; the data model didn't. Manifest: - New `[retention]` section with three `*-days` fields (`provenance-days`, `temporal-days`, `lineage-days`). `0` means "keep forever" — the default. Modelled as `RetentionConfig` with serde renames so the TOML field names stay kebab-case. - `init` template emits the section with explanatory comments, pulling defaults from `RetentionConfig::default()` (continues the V-L2-O1 "no string drift between code defaults and template" invariant). New module `src/gc.rs`: - `run_gc(&Manifest, dry_run: bool) -> Result` opens the SQLite sidecar via rusqlite and DELETEs rows whose timestamp is older than `now - retention_days` per dimension. - `dry_run = true` only counts candidates (no writes), so users can audit before applying. - `temporal_versions` purge is scoped to `valid_to IS NOT NULL` so the *current* version is never deleted regardless of age. - Non-SQLite sidecar backends fail loudly with a typed error ("only supports the SQLite sidecar backend") rather than silently no-op'ing. - `GcReport` is `Serialize` so `--json` can emit a structured report (sidecar path, dry-run flag, per-dimension counts, total). New CLI: `verisimiser gc [--manifest ] [--dry-run] [--json]`. New dep: `rusqlite = { version = "0.32", features = ["bundled"] }`. The `bundled` feature compiles SQLite from source so users don't need a system libsqlite3 install. Adds ~1MB to the binary; outweighed by deployment simplicity. Tests in `gc::tests` (require rusqlite at compile time but use only in-tempdir SQLite files): - `gc_dry_run_counts_but_does_not_delete` — dry-run reports correct counts; row count in DB is unchanged. - `gc_apply_deletes_old_rows` — real purge deletes the right rows; crucially, the current (`valid_to IS NULL`) temporal version survives even though its `valid_from` is old. - `gc_retention_zero_is_forever` — every retention=0 means total=0 deletions. - `gc_rejects_non_sqlite_backend` — non-SQLite storage returns the typed error. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 106 +++++++++++++++ Cargo.toml | 1 + src/gc.rs | 315 ++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 34 ++++- src/manifest/mod.rs | 49 +++++++ 6 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 src/gc.rs diff --git a/Cargo.lock b/Cargo.lock index aff589e..69d1efd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -221,6 +233,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -262,6 +286,15 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -277,6 +310,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -359,6 +401,17 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -398,6 +451,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "prettyplease" version = "0.2.37" @@ -432,6 +491,20 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustix" version = "1.1.4" @@ -526,6 +599,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "strsim" version = "0.11.1" @@ -641,6 +720,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "verisimiser" version = "0.1.0" @@ -648,6 +733,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "rusqlite", "serde", "serde_json", "sha2", @@ -924,6 +1010,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index a90ffd3..2c51aab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ anyhow = "1" thiserror = "2" chrono = { version = "0.4", features = ["serde"] } sha2 = "0.10" +rusqlite = { version = "0.32", features = ["bundled"] } [build-dependencies] chrono = "0.4" diff --git a/src/gc.rs b/src/gc.rs new file mode 100644 index 0000000..2d3c5f5 --- /dev/null +++ b/src/gc.rs @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// `verisimiser gc` — purge sidecar rows older than the retention bound +// declared in `[retention]`. Closes #50 (V-L2-P1). +// +// Only the SQLite sidecar backend is implemented in this initial cut; +// other backends return a typed error so users see the limitation +// explicitly instead of silently no-op'ing. + +use anyhow::{Context, Result, bail}; +use chrono::{Duration, Utc}; +use rusqlite::Connection; +use serde::Serialize; + +use crate::manifest::{Manifest, RetentionConfig}; + +/// Number of rows purged per dimension by [`run_gc`]. +#[derive(Debug, Clone, Serialize, Default)] +pub struct GcReport { + /// Resolved sidecar path that was operated on. + pub sidecar: String, + /// `true` if no changes were applied (`--dry-run`). + pub dry_run: bool, + /// Rows deleted from `verisimdb_provenance_log` (or "would delete" in dry-run). + pub provenance_deleted: usize, + /// Rows deleted from `verisimdb_temporal_versions` (superseded rows only; + /// `valid_to IS NULL` is always kept). + pub temporal_deleted: usize, + /// Rows deleted from `verisimdb_lineage_graph`. + pub lineage_deleted: usize, +} + +impl GcReport { + /// Total rows purged across all dimensions. + pub fn total(&self) -> usize { + self.provenance_deleted + self.temporal_deleted + self.lineage_deleted + } +} + +/// Purge sidecar rows older than the retention bound. `dry_run = true` +/// reports what would be deleted without changing the DB. +/// +/// Returns `Err` if the sidecar storage is not SQLite (unsupported in +/// this cut) or if the file is unreachable. +pub fn run_gc(manifest: &Manifest, dry_run: bool) -> Result { + if manifest.sidecar.storage != "sqlite" { + bail!( + "verisimiser gc currently only supports the SQLite sidecar backend; \ + [sidecar].storage is {:?}", + manifest.sidecar.storage + ); + } + + let sidecar_path = &manifest.sidecar.path; + let conn = Connection::open(sidecar_path) + .with_context(|| format!("opening sidecar at {}", sidecar_path))?; + + let retention = &manifest.retention; + let mut report = GcReport { + sidecar: sidecar_path.clone(), + dry_run, + ..Default::default() + }; + + if retention.provenance_days > 0 { + report.provenance_deleted = purge_by_age( + &conn, + "verisimdb_provenance_log", + "timestamp", + retention.provenance_days, + dry_run, + None, + )?; + } + if retention.temporal_days > 0 { + // Only purge superseded versions — never the current one. + report.temporal_deleted = purge_by_age( + &conn, + "verisimdb_temporal_versions", + "valid_from", + retention.temporal_days, + dry_run, + Some("valid_to IS NOT NULL"), + )?; + } + if retention.lineage_days > 0 { + report.lineage_deleted = purge_by_age( + &conn, + "verisimdb_lineage_graph", + "created_at", + retention.lineage_days, + dry_run, + None, + )?; + } + + Ok(report) +} + +/// Delete rows where ` < cutoff`. When `dry_run` is true, +/// counts matching rows but does not delete. `extra_where` is appended +/// with `AND (...)` so callers can scope the purge (e.g. exclude the +/// current temporal version). +/// +/// `table`, `ts_column`, and `extra_where` are *trusted* — they come +/// from the codegen layer's identifier set, not user input. They are +/// inlined into the SQL because rusqlite cannot bind identifiers. +fn purge_by_age( + conn: &Connection, + table: &str, + ts_column: &str, + days: u32, + dry_run: bool, + extra_where: Option<&str>, +) -> Result { + let cutoff = (Utc::now() - Duration::days(days as i64)).to_rfc3339(); + let extra = extra_where + .map(|w| format!(" AND ({})", w)) + .unwrap_or_default(); + if dry_run { + let sql = format!("SELECT COUNT(*) FROM {table} WHERE {ts_column} < ?{extra}"); + let count: i64 = conn + .query_row(&sql, [&cutoff], |row| row.get(0)) + .with_context(|| format!("counting purge candidates in {table}"))?; + Ok(count as usize) + } else { + let sql = format!("DELETE FROM {table} WHERE {ts_column} < ?{extra}"); + let n = conn + .execute(&sql, [&cutoff]) + .with_context(|| format!("deleting old rows from {table}"))?; + Ok(n) + } +} + +#[cfg(test)] +mod tests { + use super::{RetentionConfig, run_gc}; + use crate::manifest::{Manifest, SidecarConfig}; + use rusqlite::Connection; + + /// Build a Manifest with a temp SQLite sidecar, retention set as given. + fn fixture( + sidecar_path: &str, + retention: RetentionConfig, + storage: &str, + ) -> Manifest { + let mut m: Manifest = toml::from_str( + "[database]\n\ + backend = \"sqlite\"\n", + ) + .unwrap(); + m.sidecar = SidecarConfig { + storage: storage.to_string(), + path: sidecar_path.to_string(), + }; + m.retention = retention; + m + } + + /// Create the three sidecar tables and seed rows of varying ages. + fn seed_db(path: &str) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "CREATE TABLE verisimdb_provenance_log ( + hash TEXT PRIMARY KEY, + timestamp TEXT NOT NULL + ); + CREATE TABLE verisimdb_temporal_versions ( + entity_id TEXT NOT NULL, + table_name TEXT NOT NULL, + version INTEGER NOT NULL, + valid_from TEXT NOT NULL, + valid_to TEXT, + PRIMARY KEY (entity_id, table_name, version) + ); + CREATE TABLE verisimdb_lineage_graph ( + edge_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL + );", + ) + .unwrap(); + + // Provenance: 1 old, 1 fresh + conn.execute( + "INSERT INTO verisimdb_provenance_log VALUES (?, ?)", + ["old", "2020-01-01T00:00:00+00:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO verisimdb_provenance_log VALUES (?, ?)", + ["new", "9999-01-01T00:00:00+00:00"], + ) + .unwrap(); + + // Temporal: 1 old superseded, 1 old current, 1 fresh superseded + conn.execute( + "INSERT INTO verisimdb_temporal_versions VALUES ('e1','t',1,'2020-01-01T00:00:00+00:00','2020-06-01T00:00:00+00:00')", + [], + ).unwrap(); + conn.execute( + "INSERT INTO verisimdb_temporal_versions VALUES ('e2','t',1,'2020-01-01T00:00:00+00:00',NULL)", + [], + ).unwrap(); + conn.execute( + "INSERT INTO verisimdb_temporal_versions VALUES ('e3','t',1,'9999-01-01T00:00:00+00:00','9999-06-01T00:00:00+00:00')", + [], + ).unwrap(); + + // Lineage: 1 old, 1 fresh + conn.execute( + "INSERT INTO verisimdb_lineage_graph VALUES (?, ?)", + ["old", "2020-01-01T00:00:00+00:00"], + ) + .unwrap(); + conn.execute( + "INSERT INTO verisimdb_lineage_graph VALUES (?, ?)", + ["new", "9999-01-01T00:00:00+00:00"], + ) + .unwrap(); + } + + #[test] + fn gc_dry_run_counts_but_does_not_delete() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = dir.path().join("sidecar.db"); + let sidecar_str = sidecar.to_str().unwrap(); + seed_db(sidecar_str); + let m = fixture( + sidecar_str, + RetentionConfig { + provenance_days: 30, + temporal_days: 30, + lineage_days: 30, + }, + "sqlite", + ); + let report = run_gc(&m, true).unwrap(); + assert!(report.dry_run); + assert_eq!(report.provenance_deleted, 1, "old provenance row"); + // Only superseded ("valid_to NOT NULL") + old should be purged. + // e1 qualifies (old + superseded). e2 is old but current. e3 is fresh. + assert_eq!(report.temporal_deleted, 1, "old superseded temporal row"); + assert_eq!(report.lineage_deleted, 1, "old lineage row"); + + // Verify nothing was actually deleted. + let conn = Connection::open(sidecar_str).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM verisimdb_provenance_log", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 2, "dry-run must not delete"); + } + + #[test] + fn gc_apply_deletes_old_rows() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = dir.path().join("sidecar.db"); + let sidecar_str = sidecar.to_str().unwrap(); + seed_db(sidecar_str); + let m = fixture( + sidecar_str, + RetentionConfig { + provenance_days: 30, + temporal_days: 30, + lineage_days: 30, + }, + "sqlite", + ); + let report = run_gc(&m, false).unwrap(); + assert!(!report.dry_run); + assert_eq!(report.total(), 3); + + let conn = Connection::open(sidecar_str).unwrap(); + let provenance_count: i64 = conn + .query_row("SELECT COUNT(*) FROM verisimdb_provenance_log", [], |r| r.get(0)) + .unwrap(); + assert_eq!(provenance_count, 1, "fresh provenance kept"); + + // The current temporal version (e2, valid_to IS NULL) must survive + // even though it is old enough to qualify on valid_from. + let temporal_count: i64 = conn + .query_row("SELECT COUNT(*) FROM verisimdb_temporal_versions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(temporal_count, 2); + let current_survived: i64 = conn + .query_row( + "SELECT COUNT(*) FROM verisimdb_temporal_versions WHERE entity_id='e2'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(current_survived, 1, "current temporal version must survive"); + } + + #[test] + fn gc_retention_zero_is_forever() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = dir.path().join("sidecar.db"); + let sidecar_str = sidecar.to_str().unwrap(); + seed_db(sidecar_str); + let m = fixture(sidecar_str, RetentionConfig::default(), "sqlite"); + let report = run_gc(&m, false).unwrap(); + assert_eq!(report.total(), 0, "retention=0 should purge nothing"); + } + + #[test] + fn gc_rejects_non_sqlite_backend() { + let m = fixture("/dev/null", RetentionConfig::default(), "json"); + let err = run_gc(&m, true).unwrap_err(); + assert!( + err.to_string().contains("only supports the SQLite sidecar"), + "expected explicit unsupported-backend error; got: {err}" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index cad426a..e24fb37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod abi; pub mod codegen; pub mod doctor; +pub mod gc; pub mod intercept; pub mod manifest; pub mod tier1; diff --git a/src/main.rs b/src/main.rs index 92741e9..ec91985 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; -use verisimiser::{abi, codegen, doctor, manifest}; +use verisimiser::{abi, codegen, doctor, gc, manifest}; /// Long version string: ` (, built )`. const LONG_VERSION: &str = concat!( @@ -125,6 +125,17 @@ enum Commands { #[arg(long)] json: bool, }, + /// Purge sidecar rows older than the bounds in `[retention]`. + Gc { + #[arg(short, long, default_value = "verisimiser.toml")] + manifest: String, + /// Report what would be deleted without actually deleting. + #[arg(long)] + dry_run: bool, + /// Emit the structured GcReport as JSON instead of text. + #[arg(long)] + json: bool, + }, } fn main() -> Result<()> { @@ -254,6 +265,27 @@ fn main() -> Result<()> { emit_report(&report, json, "doctor") } + Commands::Gc { + manifest, + dry_run, + json, + } => { + let m = manifest::load_manifest(&manifest)?; + let report = gc::run_gc(&m, dry_run)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + let action = if report.dry_run { "would delete" } else { "deleted" }; + println!("verisimiser gc ({}):", if report.dry_run { "dry-run" } else { "apply" }); + println!(" sidecar: {}", report.sidecar); + println!(" provenance: {action} {} rows", report.provenance_deleted); + println!(" temporal: {action} {} rows", report.temporal_deleted); + println!(" lineage: {action} {} rows", report.lineage_deleted); + println!(" total: {} rows", report.total()); + } + Ok(()) + } + Commands::Version { json } => { if json { let report = serde_json::json!({ diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index f28830d..31d5ff6 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -31,6 +31,11 @@ pub struct Manifest { #[serde(default)] pub sidecar: SidecarConfig, + /// Retention policy — how long each octad dimension keeps history. + /// See [`RetentionConfig`]; `verisimiser gc` enforces these bounds. + #[serde(default)] + pub retention: RetentionConfig, + // --- Legacy fields for backward compatibility --- /// Legacy top-level [verisimiser] section. #[serde(default)] @@ -269,6 +274,40 @@ pub struct SidecarConfig { pub path: String, } +/// [retention] section — bounds on how long each octad dimension's history +/// is kept in the sidecar. A field of `0` means "keep forever". The actual +/// purging is performed by `verisimiser gc`. Closes #50 (V-L2-P1). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RetentionConfig { + /// Maximum age (in days) of provenance log entries. `0` means forever. + #[serde(rename = "provenance-days", default = "default_retention_forever")] + pub provenance_days: u32, + + /// Maximum age (in days) of temporal version entries. `0` means forever. + /// Only superseded versions (`valid_to IS NOT NULL`) are eligible; the + /// current version is always retained. + #[serde(rename = "temporal-days", default = "default_retention_forever")] + pub temporal_days: u32, + + /// Maximum age (in days) of lineage graph edges. `0` means forever. + #[serde(rename = "lineage-days", default = "default_retention_forever")] + pub lineage_days: u32, +} + +impl Default for RetentionConfig { + fn default() -> Self { + Self { + provenance_days: default_retention_forever(), + temporal_days: default_retention_forever(), + lineage_days: default_retention_forever(), + } + } +} + +fn default_retention_forever() -> u32 { + 0 +} + impl Default for SidecarConfig { fn default() -> Self { Self { @@ -551,6 +590,7 @@ pub(crate) fn render_manifest_template(database: &str, name: Option<&str>) -> St let project = ProjectConfig::default(); let octad = OctadConfig::default(); let sidecar = SidecarConfig::default(); + let retention = RetentionConfig::default(); let project_name = name.unwrap_or(&project.name); format!( r#"# SPDX-License-Identifier: PMPL-1.0-or-later @@ -577,6 +617,12 @@ enable-simulation = {enable_simulation} [sidecar] storage = "{sidecar_storage}" path = "{sidecar_path}" + +[retention] +# Days to keep per dimension. 0 = keep forever. +provenance-days = {provenance_days} +temporal-days = {temporal_days} +lineage-days = {lineage_days} "#, project_version = project.version, conn_env = default_connection_env(), @@ -588,6 +634,9 @@ path = "{sidecar_path}" enable_simulation = octad.enable_simulation, sidecar_storage = sidecar.storage, sidecar_path = sidecar.path, + provenance_days = retention.provenance_days, + temporal_days = retention.temporal_days, + lineage_days = retention.lineage_days, ) }