diff --git a/crates/consolebook-server/migrations/0015_retention_administration.sql b/crates/consolebook-server/migrations/0015_retention_administration.sql new file mode 100644 index 0000000..4b9213d --- /dev/null +++ b/crates/consolebook-server/migrations/0015_retention_administration.sql @@ -0,0 +1,145 @@ +-- ADR 0020; #65: retention administration, without a destruction path. +CREATE TABLE retention_authority_event ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES user(id), + granted INTEGER NOT NULL CHECK (granted IN (0, 1)), + actor_user_id INTEGER NOT NULL REFERENCES user(id), + reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000), + recorded_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE retention_policy ( + id INTEGER PRIMARY KEY, + record_class TEXT NOT NULL CHECK (record_class IN ('daily_report', 'weekly_summary', 'phase_evaluation', 'disposition_event')), + version_number INTEGER NOT NULL CHECK (version_number > 0), + supersedes_id INTEGER UNIQUE REFERENCES retention_policy(id), + authority TEXT NOT NULL CHECK (length(trim(authority, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 200), + retention_trigger TEXT NOT NULL CHECK (retention_trigger IN ('finalized_at', 'enrollment_closed_at', 'disposed_at')), + retention_days INTEGER NOT NULL CHECK (retention_days BETWEEN 0 AND 365250), + action TEXT NOT NULL CHECK (action IN ('retain', 'destroy')), + reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000), + created_by INTEGER NOT NULL REFERENCES user(id), + created_at INTEGER NOT NULL, + UNIQUE(record_class, version_number), + CHECK ((record_class = 'disposition_event') = (retention_trigger = 'disposed_at')), + CHECK (action = 'destroy' OR retention_days = 0), + CHECK ((version_number = 1) = (supersedes_id IS NULL)) +) STRICT; + +CREATE TRIGGER retention_policy_successor +BEFORE INSERT ON retention_policy +WHEN NEW.version_number != (SELECT COALESCE(MAX(version_number), 0) + 1 FROM retention_policy WHERE record_class = NEW.record_class) + OR NEW.supersedes_id IS NOT (SELECT id FROM retention_policy WHERE record_class = NEW.record_class ORDER BY version_number DESC LIMIT 1) +BEGIN + SELECT RAISE(ABORT, 'a retention policy supersedes the current version of its class'); +END; + +CREATE TABLE record_hold ( + id INTEGER PRIMARY KEY, + enrollment_id INTEGER REFERENCES enrollment(id), + evaluation_record_id INTEGER REFERENCES evaluation_record(id), + -- No ids means installation; enrollment only means enrollment scope; + -- record only means that record. No implicit name or text matching. + kind TEXT NOT NULL CHECK (kind IN ('litigation', 'anticipated_litigation', 'audit', 'investigation', 'public_records_request', 'other')), + authority TEXT NOT NULL CHECK (length(trim(authority, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 200), + reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000), + created_by INTEGER NOT NULL REFERENCES user(id), + created_at INTEGER NOT NULL, + replaces_id INTEGER UNIQUE REFERENCES record_hold(id), + CHECK (enrollment_id IS NULL OR evaluation_record_id IS NULL) +) STRICT; + +CREATE TABLE hold_release ( + hold_id INTEGER PRIMARY KEY REFERENCES record_hold(id), + released_by INTEGER NOT NULL REFERENCES user(id), + released_at INTEGER NOT NULL, + reason TEXT NOT NULL CHECK (length(trim(reason, char(9, 10, 11, 12, 13, 32, 133, 160, 5760, 8192, 8193, 8194, 8195, 8196, 8197, 8198, 8199, 8200, 8201, 8202, 8232, 8233, 8239, 8287, 12288))) BETWEEN 1 AND 1000), + replacement_id INTEGER UNIQUE REFERENCES record_hold(id) +) STRICT; + +CREATE TRIGGER record_hold_replace_active +BEFORE INSERT ON record_hold +WHEN NEW.replaces_id IS NOT NULL AND ( + NOT EXISTS (SELECT 1 FROM record_hold WHERE id = NEW.replaces_id) + OR EXISTS (SELECT 1 FROM hold_release WHERE hold_id = NEW.replaces_id) + OR NEW.created_at < (SELECT created_at FROM record_hold WHERE id = NEW.replaces_id) +) +BEGIN + SELECT RAISE(ABORT, 'a released hold cannot be replaced'); +END; + +CREATE TRIGGER hold_release_chronology +BEFORE INSERT ON hold_release +WHEN NEW.released_at < (SELECT created_at FROM record_hold WHERE id = NEW.hold_id) +BEGIN + SELECT RAISE(ABORT, 'a hold release cannot precede its creation'); +END; + +CREATE TRIGGER hold_release_matches_replacement +BEFORE INSERT ON hold_release +WHEN NEW.replacement_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM record_hold h WHERE h.id = NEW.replacement_id AND h.replaces_id = NEW.hold_id + AND h.created_by = NEW.released_by AND h.created_at = NEW.released_at AND h.reason = NEW.reason +) +BEGIN + SELECT RAISE(ABORT, 'a replacement release names its attributed successor'); +END; + +CREATE TRIGGER record_hold_replace_releases_previous +AFTER INSERT ON record_hold +WHEN NEW.replaces_id IS NOT NULL +BEGIN + INSERT INTO hold_release (hold_id, released_by, released_at, reason, replacement_id) + VALUES (NEW.replaces_id, NEW.created_by, NEW.created_at, NEW.reason, NEW.id); +END; + +CREATE INDEX record_hold_enrollment ON record_hold(enrollment_id); +CREATE INDEX record_hold_record ON record_hold(evaluation_record_id); + +CREATE TRIGGER retention_authority_event_no_update +BEFORE UPDATE ON retention_authority_event +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER retention_authority_event_no_delete +BEFORE DELETE ON retention_authority_event +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER retention_policy_no_update +BEFORE UPDATE ON retention_policy +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER retention_policy_no_delete +BEFORE DELETE ON retention_policy +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER record_hold_no_update +BEFORE UPDATE ON record_hold +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER record_hold_no_delete +BEFORE DELETE ON record_hold +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER hold_release_no_update +BEFORE UPDATE ON hold_release +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; + +CREATE TRIGGER hold_release_no_delete +BEFORE DELETE ON hold_release +BEGIN + SELECT RAISE(ABORT, 'retention administration history is append-only'); +END; diff --git a/crates/consolebook-server/src/audit.rs b/crates/consolebook-server/src/audit.rs index 873e50b..b61ea3e 100644 --- a/crates/consolebook-server/src/audit.rs +++ b/crates/consolebook-server/src/audit.rs @@ -12,6 +12,12 @@ use time::OffsetDateTime; /// milestones extend this with record-lifecycle kinds. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EventKind { + RetentionAuthorityGranted, + RetentionAuthorityRevoked, + RetentionPolicyCreated, + RecordHoldCreated, + RecordHoldReplaced, + RecordHoldReleased, SetupCompleted, LoginSucceeded, LoginFailed, @@ -56,6 +62,12 @@ impl EventKind { #[must_use] pub fn as_str(self) -> &'static str { match self { + Self::RetentionAuthorityGranted => "retention_authority_granted", + Self::RetentionAuthorityRevoked => "retention_authority_revoked", + Self::RetentionPolicyCreated => "retention_policy_created", + Self::RecordHoldCreated => "record_hold_created", + Self::RecordHoldReplaced => "record_hold_replaced", + Self::RecordHoldReleased => "record_hold_released", Self::SetupCompleted => "setup_completed", Self::LoginSucceeded => "login_succeeded", Self::LoginFailed => "login_failed", @@ -103,6 +115,8 @@ impl EventKind { /// append-only and must never block lawful disposition of its subjects. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Subject { + RetentionPolicy(i64), + RecordHold(i64), Program(i64), ProgramVersion(i64), Enrollment(i64), @@ -115,6 +129,8 @@ impl Subject { #[must_use] pub fn kind_str(self) -> &'static str { match self { + Self::RetentionPolicy(_) => "retention_policy", + Self::RecordHold(_) => "record_hold", Self::Program(_) => "program", Self::ProgramVersion(_) => "program_version", Self::Enrollment(_) => "enrollment", @@ -127,7 +143,9 @@ impl Subject { #[must_use] pub fn id(self) -> i64 { match self { - Self::Program(id) + Self::RetentionPolicy(id) + | Self::RecordHold(id) + | Self::Program(id) | Self::ProgramVersion(id) | Self::Enrollment(id) | Self::Assignment(id) diff --git a/crates/consolebook-server/src/capabilities.rs b/crates/consolebook-server/src/capabilities.rs index cb6ca88..51bab93 100644 --- a/crates/consolebook-server/src/capabilities.rs +++ b/crates/consolebook-server/src/capabilities.rs @@ -13,6 +13,7 @@ use time::OffsetDateTime; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Capability { ManageUsers, + ManageRetention, ManagePrograms, AssignTraining, ExportRecords, @@ -27,6 +28,7 @@ impl Capability { #[must_use] pub fn as_str(self) -> &'static str { match self { + Self::ManageRetention => "manage_retention", Self::ManageUsers => "manage_users", Self::ManagePrograms => "manage_programs", Self::AssignTraining => "assign_training", diff --git a/crates/consolebook-server/src/http.rs b/crates/consolebook-server/src/http.rs index 66da178..1569758 100644 --- a/crates/consolebook-server/src/http.rs +++ b/crates/consolebook-server/src/http.rs @@ -56,6 +56,7 @@ pub fn router(state: AppState) -> Router { .merge(crate::training_http::routes()) .merge(crate::drafts_http::routes()) .merge(crate::exports_http::routes()) + .merge(crate::retention_http::routes()) .fallback(crate::web_assets::serve) .with_state(state) } diff --git a/crates/consolebook-server/src/lib.rs b/crates/consolebook-server/src/lib.rs index 5208019..59ee108 100644 --- a/crates/consolebook-server/src/lib.rs +++ b/crates/consolebook-server/src/lib.rs @@ -50,3 +50,6 @@ pub mod zip_container; /// Version of the running build, as reported by `/api/health` and `doctor`. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub mod retention; +pub mod retention_http; diff --git a/crates/consolebook-server/src/retention.rs b/crates/consolebook-server/src/retention.rs new file mode 100644 index 0000000..21345fe --- /dev/null +++ b/crates/consolebook-server/src/retention.rs @@ -0,0 +1,76 @@ +//! Versioned policies, attributed holds, and explicit administration authority. +//! ADR 0020 defines this preparation stage; destruction is a separate workflow. +mod authority; +mod holds; +mod policies; +mod types; + +pub use authority::{authority_history, set_authority}; +pub use holds::{ + ScopeOption, ScopeOptions, active_holds_for_record, create_hold, list_holds, release_hold, + replace_hold, scope_options, +}; +pub use policies::{create_policy, list_policies}; +pub use types::*; + +use crate::{ + capabilities::{self, Capability}, + storage, +}; +use anyhow::Result; +use sqlx::{Sqlite, SqliteConnection, SqlitePool, Transaction}; + +async fn authorized_write( + pool: &SqlitePool, + actor: i64, + capability: Capability, +) -> Result, RetentionRefusal>> { + let mut tx = storage::write_tx(pool).await?; + if !capabilities::user_has_on(&mut tx, actor, capability).await? { + return storage::refuse(tx, RetentionRefusal::CapabilityRequired).await; + } + Ok(Ok(tx)) +} + +async fn authorized_read( + pool: &SqlitePool, + actor: i64, + capability: Capability, +) -> Result, RetentionRefusal>> { + let mut tx = pool.begin().await?; + if !capabilities::user_has_on(&mut tx, actor, capability).await? { + return storage::refuse(tx, RetentionRefusal::CapabilityRequired).await; + } + Ok(Ok(tx)) +} + +fn text_problem(value: &str, field: &str, maximum: usize, problems: &mut Vec) { + let length = value.trim().chars().count(); + if length == 0 || length > maximum { + problems.push(format!("{field} must contain 1–{maximum} characters")); + } +} + +async fn scope_exists( + conn: &mut SqliteConnection, + scope: &HoldScope, +) -> Result> { + let (query, id, refusal) = match scope { + HoldScope::Installation => return Ok(None), + HoldScope::Enrollment { enrollment_id } => ( + "SELECT 1 FROM enrollment WHERE id = ?1", + *enrollment_id, + RetentionRefusal::NoSuchEnrollment, + ), + HoldScope::Record { record_id } => ( + "SELECT 1 FROM evaluation_record WHERE id = ?1", + *record_id, + RetentionRefusal::NoSuchRecord, + ), + }; + let exists: Option = sqlx::query_scalar(query) + .bind(id) + .fetch_optional(conn) + .await?; + Ok(exists.is_none().then_some(refusal)) +} diff --git a/crates/consolebook-server/src/retention/authority.rs b/crates/consolebook-server/src/retention/authority.rs new file mode 100644 index 0000000..1e1a03b --- /dev/null +++ b/crates/consolebook-server/src/retention/authority.rs @@ -0,0 +1,91 @@ +//! Explicit retention authority, administered under `manage_users` with reasons. +use super::{AuthorityEvent, RetentionRefusal, authorized_read, authorized_write, text_problem}; +use crate::{ + audit::{self, EventKind}, + capabilities::{self, Capability}, + storage, +}; +use anyhow::{Context, Result}; +use sqlx::{Row, SqlitePool}; +use time::OffsetDateTime; + +pub async fn set_authority( + pool: &SqlitePool, + actor: i64, + user_id: i64, + granted: bool, + reason: &str, +) -> Result> { + let mut tx = match authorized_write(pool, actor, Capability::ManageUsers).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let mut problems = Vec::new(); + text_problem(reason, "reason", 1000, &mut problems); + if !problems.is_empty() { + return storage::refuse(tx, RetentionRefusal::Invalid(problems)).await; + } + let exists: Option = sqlx::query_scalar("SELECT 1 FROM user WHERE id = ?1") + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + if exists.is_none() { + return storage::refuse(tx, RetentionRefusal::NoSuchUser).await; + } + if capabilities::user_has_on(&mut tx, user_id, Capability::ManageRetention).await? == granted { + return storage::refuse(tx, RetentionRefusal::AuthorityUnchanged).await; + } + if granted { + capabilities::grant_bundle( + &mut tx, + user_id, + &[Capability::ManageRetention], + Some(actor), + ) + .await?; + } else { + sqlx::query( + "DELETE FROM capability_grant WHERE user_id = ?1 AND capability = 'manage_retention'", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + } + sqlx::query("INSERT INTO retention_authority_event (user_id, granted, actor_user_id, reason, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5)") + .bind(user_id).bind(granted).bind(actor).bind(reason.trim()).bind(OffsetDateTime::now_utc().unix_timestamp()) + .execute(&mut *tx).await.context("recording retention authority")?; + let kind = if granted { + EventKind::RetentionAuthorityGranted + } else { + EventKind::RetentionAuthorityRevoked + }; + audit::record(&mut *tx, kind, Some(actor), Some(user_id)).await?; + tx.commit().await?; + Ok(Ok(())) +} + +pub async fn authority_history( + pool: &SqlitePool, + actor: i64, +) -> Result, RetentionRefusal>> { + let mut tx = match authorized_read(pool, actor, Capability::ManageUsers).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let rows = sqlx::query("SELECT * FROM retention_authority_event ORDER BY id DESC") + .fetch_all(&mut *tx) + .await?; + let events = rows + .iter() + .map(|r| AuthorityEvent { + id: r.get("id"), + user_id: r.get("user_id"), + granted: r.get("granted"), + actor_user_id: r.get("actor_user_id"), + reason: r.get("reason"), + recorded_at: r.get("recorded_at"), + }) + .collect(); + tx.commit().await?; + Ok(Ok(events)) +} diff --git a/crates/consolebook-server/src/retention/holds.rs b/crates/consolebook-server/src/retention/holds.rs new file mode 100644 index 0000000..1b6628e --- /dev/null +++ b/crates/consolebook-server/src/retention/holds.rs @@ -0,0 +1,229 @@ +//! Attributed hold lifecycle and exact scope matching, independent of policy age. +use super::{ + Hold, HoldInput, HoldKind, HoldRelease, HoldScope, RetentionRefusal, authorized_read, + authorized_write, scope_exists, text_problem, +}; +use crate::{ + audit::{self, EventKind, Subject}, + capabilities::Capability, + storage, +}; +use anyhow::{Context, Result}; +use sqlx::{Row, SqliteConnection, SqlitePool}; +use time::OffsetDateTime; + +pub async fn create_hold( + pool: &SqlitePool, + actor: i64, + input: &HoldInput, +) -> Result> { + write_hold(pool, actor, input, None).await +} + +pub async fn replace_hold( + pool: &SqlitePool, + actor: i64, + hold_id: i64, + input: &HoldInput, +) -> Result> { + write_hold(pool, actor, input, Some(hold_id)).await +} + +async fn active_refusal(conn: &mut SqliteConnection, id: i64) -> Result> { + let exists: Option = sqlx::query_scalar("SELECT 1 FROM record_hold WHERE id = ?1") + .bind(id) + .fetch_optional(&mut *conn) + .await?; + if exists.is_none() { + return Ok(Some(RetentionRefusal::NoSuchHold)); + } + let released: Option = sqlx::query_scalar("SELECT 1 FROM hold_release WHERE hold_id = ?1") + .bind(id) + .fetch_optional(conn) + .await?; + Ok(released.is_some().then_some(RetentionRefusal::HoldReleased)) +} + +async fn write_hold( + pool: &SqlitePool, + actor: i64, + input: &HoldInput, + replaces_id: Option, +) -> Result> { + let mut tx = match authorized_write(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let mut problems = Vec::new(); + text_problem(&input.authority, "authority", 200, &mut problems); + text_problem(&input.reason, "reason", 1000, &mut problems); + if !problems.is_empty() { + return storage::refuse(tx, RetentionRefusal::Invalid(problems)).await; + } + if let Some(r) = scope_exists(&mut tx, &input.scope).await? { + return storage::refuse(tx, r).await; + } + if let Some(id) = replaces_id + && let Some(r) = active_refusal(&mut tx, id).await? + { + return storage::refuse(tx, r).await; + } + let (enrollment_id, record_id) = match input.scope { + HoldScope::Installation => (None, None), + HoldScope::Enrollment { enrollment_id } => (Some(enrollment_id), None), + HoldScope::Record { record_id } => (None, Some(record_id)), + }; + // The insert trigger atomically releases the predecessor with these same + // attributed fields. A failed successor insert cannot weaken the old hold. + let id = sqlx::query("INSERT INTO record_hold (enrollment_id, evaluation_record_id, kind, authority, reason, created_by, created_at, replaces_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)") + .bind(enrollment_id).bind(record_id).bind(input.kind.as_str()).bind(input.authority.trim()).bind(input.reason.trim()) + .bind(actor).bind(OffsetDateTime::now_utc().unix_timestamp()).bind(replaces_id) + .execute(&mut *tx).await.context("recording hold")?.last_insert_rowid(); + let kind = if replaces_id.is_some() { + EventKind::RecordHoldReplaced + } else { + EventKind::RecordHoldCreated + }; + audit::record_for_subject(&mut *tx, kind, Some(actor), None, Subject::RecordHold(id)).await?; + tx.commit().await?; + Ok(Ok(id)) +} + +pub async fn release_hold( + pool: &SqlitePool, + actor: i64, + hold_id: i64, + reason: &str, +) -> Result> { + let mut tx = match authorized_write(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let mut problems = Vec::new(); + text_problem(reason, "reason", 1000, &mut problems); + if !problems.is_empty() { + return storage::refuse(tx, RetentionRefusal::Invalid(problems)).await; + } + if let Some(r) = active_refusal(&mut tx, hold_id).await? { + return storage::refuse(tx, r).await; + } + sqlx::query("INSERT INTO hold_release (hold_id, released_by, released_at, reason) VALUES (?1, ?2, ?3, ?4)") + .bind(hold_id).bind(actor).bind(OffsetDateTime::now_utc().unix_timestamp()).bind(reason.trim()) + .execute(&mut *tx).await.context("releasing hold")?; + audit::record_for_subject( + &mut *tx, + EventKind::RecordHoldReleased, + Some(actor), + None, + Subject::RecordHold(hold_id), + ) + .await?; + tx.commit().await?; + Ok(Ok(())) +} + +fn hold_from_row(r: &sqlx::sqlite::SqliteRow) -> Result { + let scope = match ( + r.get::, _>("enrollment_id"), + r.get::, _>("evaluation_record_id"), + ) { + (None, None) => HoldScope::Installation, + (Some(enrollment_id), None) => HoldScope::Enrollment { enrollment_id }, + (None, Some(record_id)) => HoldScope::Record { record_id }, + _ => anyhow::bail!("invalid stored hold scope"), + }; + let release = r + .get::, _>("released_by") + .map(|released_by| HoldRelease { + released_by, + released_at: r.get("released_at"), + reason: r.get("release_reason"), + replacement_id: r.get("replacement_id"), + }); + Ok(Hold { + id: r.get("id"), + scope, + kind: HoldKind::from_db(r.get("kind"))?, + authority: r.get("authority"), + reason: r.get("reason"), + created_by: r.get("created_by"), + created_at: r.get("created_at"), + replaces_id: r.get("replaces_id"), + release, + }) +} + +const HOLD_QUERY: &str = "SELECT h.*, r.released_by, r.released_at, r.reason AS release_reason, r.replacement_id FROM record_hold h LEFT JOIN hold_release r ON r.hold_id = h.id"; + +pub async fn list_holds( + pool: &SqlitePool, + actor: i64, +) -> Result, RetentionRefusal>> { + let mut tx = match authorized_read(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let rows = sqlx::query(&format!("{HOLD_QUERY} ORDER BY h.id DESC")) + .fetch_all(&mut *tx) + .await?; + let holds = rows.iter().map(hold_from_row).collect::>>()?; + tx.commit().await?; + Ok(Ok(holds)) +} + +/// Resolve applicable active holds under the same snapshot as authorization. +/// This is a hold lookup, never a disposition-eligibility verdict. +pub async fn active_holds_for_record( + pool: &SqlitePool, + actor: i64, + record_id: i64, +) -> Result, RetentionRefusal>> { + let mut tx = match authorized_read(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let enrollment_id: Option = + sqlx::query_scalar("SELECT enrollment_id FROM evaluation_record WHERE id = ?1") + .bind(record_id) + .fetch_optional(&mut *tx) + .await?; + let Some(enrollment_id) = enrollment_id else { + return storage::refuse(tx, RetentionRefusal::NoSuchRecord).await; + }; + let rows = sqlx::query(&format!("{HOLD_QUERY} WHERE r.hold_id IS NULL AND ((h.enrollment_id IS NULL AND h.evaluation_record_id IS NULL) OR h.enrollment_id = ?1 OR h.evaluation_record_id = ?2) ORDER BY h.id")) + .bind(enrollment_id).bind(record_id).fetch_all(&mut *tx).await?; + let holds = rows.iter().map(hold_from_row).collect::>>()?; + tx.commit().await?; + Ok(Ok(holds)) +} + +#[derive(Debug, serde::Serialize)] +pub struct ScopeOption { + pub id: i64, + pub label: String, +} +#[derive(Debug, serde::Serialize)] +pub struct ScopeOptions { + pub enrollments: Vec, + pub records: Vec, +} + +/// Human-readable scope selection; ids, never labels, establish scope. +pub async fn scope_options( + pool: &SqlitePool, + actor: i64, +) -> Result> { + let mut tx = match authorized_read(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let enrollments = sqlx::query("SELECT e.id, u.display_name, p.name FROM enrollment e JOIN user u ON u.id = e.user_id JOIN program_version p ON p.id = e.program_version_id ORDER BY e.id") + .fetch_all(&mut *tx).await?.iter().map(|r| ScopeOption { id: r.get("id"), label: format!("{} — {} (enrollment {})", r.get::("display_name"), r.get::("name"), r.get::("id")) }).collect(); + let records = sqlx::query("SELECT r.id, u.display_name, f.name FROM evaluation_record r JOIN enrollment e ON e.id = r.enrollment_id JOIN user u ON u.id = e.user_id JOIN evaluation_form f ON f.id = r.evaluation_form_id ORDER BY r.id") + .fetch_all(&mut *tx).await?.iter().map(|r| ScopeOption { id: r.get("id"), label: format!("{} — {} (record {})", r.get::("display_name"), r.get::("name"), r.get::("id")) }).collect(); + tx.commit().await?; + Ok(Ok(ScopeOptions { + enrollments, + records, + })) +} diff --git a/crates/consolebook-server/src/retention/policies.rs b/crates/consolebook-server/src/retention/policies.rs new file mode 100644 index 0000000..33e9ca1 --- /dev/null +++ b/crates/consolebook-server/src/retention/policies.rs @@ -0,0 +1,95 @@ +//! Immutable policy revisions, selected by record class. +use super::{ + Policy, PolicyInput, RecordClass, RetentionAction, RetentionRefusal, RetentionTrigger, + authorized_read, authorized_write, text_problem, +}; +use crate::{ + audit::{self, EventKind, Subject}, + capabilities::Capability, + storage, +}; +use anyhow::{Context, Result}; +use sqlx::{Row, SqlitePool}; +use time::OffsetDateTime; + +pub async fn create_policy( + pool: &SqlitePool, + actor: i64, + input: &PolicyInput, +) -> Result> { + let mut tx = match authorized_write(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let mut problems = Vec::new(); + text_problem(&input.authority, "authority", 200, &mut problems); + text_problem(&input.reason, "reason", 1000, &mut problems); + if !(0..=365_250).contains(&input.retention_days) { + problems.push("retention days must be between 0 and 365250".into()); + } + if (input.record_class == RecordClass::DispositionEvent) + != (input.retention_trigger == RetentionTrigger::DisposedAt) + { + problems.push("disposition events use disposed_at; evaluations use finalized_at or enrollment_closed_at".into()); + } + if input.action == RetentionAction::Retain && input.retention_days != 0 { + problems.push("retain has no destruction period; use zero days".into()); + } + if !problems.is_empty() { + return storage::refuse(tx, RetentionRefusal::Invalid(problems)).await; + } + let current: Option<(i64, i64)> = sqlx::query_as("SELECT id, version_number FROM retention_policy WHERE record_class = ?1 ORDER BY version_number DESC LIMIT 1") + .bind(input.record_class.as_str()).fetch_optional(&mut *tx).await.context("reading current retention policy")?; + if current.map(|(id, _)| id) != input.expected_current_id { + return storage::refuse(tx, RetentionRefusal::StalePolicy).await; + } + let id = sqlx::query("INSERT INTO retention_policy (record_class, version_number, supersedes_id, authority, retention_trigger, retention_days, action, reason, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)") + .bind(input.record_class.as_str()).bind(current.map_or(1, |(_, n)| n + 1)).bind(input.expected_current_id) + .bind(input.authority.trim()).bind(input.retention_trigger.as_str()).bind(input.retention_days) + .bind(input.action.as_str()).bind(input.reason.trim()).bind(actor).bind(OffsetDateTime::now_utc().unix_timestamp()) + .execute(&mut *tx).await.context("creating retention policy")?.last_insert_rowid(); + audit::record_for_subject( + &mut *tx, + EventKind::RetentionPolicyCreated, + Some(actor), + None, + Subject::RetentionPolicy(id), + ) + .await?; + tx.commit().await?; + Ok(Ok(id)) +} + +pub async fn list_policies( + pool: &SqlitePool, + actor: i64, +) -> Result, RetentionRefusal>> { + let mut tx = match authorized_read(pool, actor, Capability::ManageRetention).await? { + Ok(tx) => tx, + Err(r) => return Ok(Err(r)), + }; + let rows = + sqlx::query("SELECT * FROM retention_policy ORDER BY record_class, version_number DESC") + .fetch_all(&mut *tx) + .await?; + let policies = rows + .iter() + .map(|row| { + Ok(Policy { + id: row.get("id"), + record_class: RecordClass::from_db(row.get("record_class"))?, + version_number: row.get("version_number"), + supersedes_id: row.get("supersedes_id"), + authority: row.get("authority"), + retention_trigger: RetentionTrigger::from_db(row.get("retention_trigger"))?, + retention_days: row.get("retention_days"), + action: RetentionAction::from_db(row.get("action"))?, + reason: row.get("reason"), + created_by: row.get("created_by"), + created_at: row.get("created_at"), + }) + }) + .collect::>>()?; + tx.commit().await?; + Ok(Ok(policies)) +} diff --git a/crates/consolebook-server/src/retention/types.rs b/crates/consolebook-server/src/retention/types.rs new file mode 100644 index 0000000..49cdf89 --- /dev/null +++ b/crates/consolebook-server/src/retention/types.rs @@ -0,0 +1,110 @@ +//! Closed retention-administration vocabulary. No policy here authorizes deletion. +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +macro_rules! vocabulary { + ($name:ident { $($variant:ident => $value:literal),+ $(,)? }) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum $name { $($variant),+ } + impl $name { + #[must_use] + pub fn as_str(self) -> &'static str { match self { $(Self::$variant => $value),+ } } + pub(super) fn from_db(value: &str) -> Result { + match value { $($value => Ok(Self::$variant)),+, _ => bail!("invalid stored retention vocabulary") } + } + } + }; +} +vocabulary!(RecordClass { DailyReport => "daily_report", WeeklySummary => "weekly_summary", PhaseEvaluation => "phase_evaluation", DispositionEvent => "disposition_event" }); +vocabulary!(RetentionTrigger { FinalizedAt => "finalized_at", EnrollmentClosedAt => "enrollment_closed_at", DisposedAt => "disposed_at" }); +vocabulary!(RetentionAction { Retain => "retain", Destroy => "destroy" }); +vocabulary!(HoldKind { Litigation => "litigation", AnticipatedLitigation => "anticipated_litigation", Audit => "audit", Investigation => "investigation", PublicRecordsRequest => "public_records_request", Other => "other" }); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum HoldScope { + Installation, + Enrollment { enrollment_id: i64 }, + Record { record_id: i64 }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PolicyInput { + pub record_class: RecordClass, + pub expected_current_id: Option, + pub authority: String, + pub retention_trigger: RetentionTrigger, + pub retention_days: i64, + pub action: RetentionAction, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Policy { + pub id: i64, + pub record_class: RecordClass, + pub version_number: i64, + pub supersedes_id: Option, + pub authority: String, + pub retention_trigger: RetentionTrigger, + pub retention_days: i64, + pub action: RetentionAction, + pub reason: String, + pub created_by: i64, + pub created_at: i64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HoldInput { + pub scope: HoldScope, + pub kind: HoldKind, + pub authority: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Hold { + pub id: i64, + pub scope: HoldScope, + pub kind: HoldKind, + pub authority: String, + pub reason: String, + pub created_by: i64, + pub created_at: i64, + pub replaces_id: Option, + pub release: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HoldRelease { + pub released_by: i64, + pub released_at: i64, + pub reason: String, + pub replacement_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AuthorityEvent { + pub id: i64, + pub user_id: i64, + pub granted: bool, + pub actor_user_id: i64, + pub reason: String, + pub recorded_at: i64, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum RetentionRefusal { + CapabilityRequired, + Invalid(Vec), + StalePolicy, + NoSuchEnrollment, + NoSuchRecord, + NoSuchHold, + HoldReleased, + NoSuchUser, + AuthorityUnchanged, +} diff --git a/crates/consolebook-server/src/retention_http.rs b/crates/consolebook-server/src/retention_http.rs new file mode 100644 index 0000000..14a710c --- /dev/null +++ b/crates/consolebook-server/src/retention_http.rs @@ -0,0 +1,171 @@ +//! Thin retention HTTP adapter; services own authority and scope decisions. +use crate::{ + http::{ApiError, AppState, CurrentUser}, + retention::{self, RetentionRefusal}, +}; +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::Json, + routing::{get, post}, +}; +use serde::Deserialize; + +pub fn routes() -> Router { + Router::new() + .route("/api/retention/scopes", get(scopes)) + .route("/api/retention/policies", get(policies).post(create_policy)) + .route("/api/retention/holds", get(holds).post(create_hold)) + .route("/api/retention/holds/{id}/replace", post(replace_hold)) + .route("/api/retention/holds/{id}/release", post(release_hold)) + .route("/api/retention/records/{id}/holds", get(record_holds)) + .route( + "/api/retention/authority", + get(authority).post(set_authority), + ) +} + +impl From for ApiError { + fn from(value: RetentionRefusal) -> Self { + let (status, code, message) = match value { + RetentionRefusal::CapabilityRequired => ( + StatusCode::FORBIDDEN, + "capability_required", + "explicit authority is required", + ), + RetentionRefusal::Invalid(problems) => { + return Self::with_problems( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_retention_input", + "check the retention fields", + problems, + ); + } + RetentionRefusal::StalePolicy => ( + StatusCode::CONFLICT, + "stale_policy", + "the policy changed; reload and review the current version", + ), + RetentionRefusal::NoSuchEnrollment => ( + StatusCode::NOT_FOUND, + "no_such_enrollment", + "no such enrollment", + ), + RetentionRefusal::NoSuchRecord => { + (StatusCode::NOT_FOUND, "no_such_record", "no such record") + } + RetentionRefusal::NoSuchHold => (StatusCode::NOT_FOUND, "no_such_hold", "no such hold"), + RetentionRefusal::HoldReleased => ( + StatusCode::CONFLICT, + "hold_released", + "the hold is already released; reload its history", + ), + RetentionRefusal::NoSuchUser => (StatusCode::NOT_FOUND, "no_such_user", "no such user"), + RetentionRefusal::AuthorityUnchanged => ( + StatusCode::CONFLICT, + "authority_unchanged", + "the user already has this authority state", + ), + }; + Self::new(status, code, message) + } +} + +async fn policies( + State(s): State, + u: CurrentUser, +) -> Result>, ApiError> { + Ok(Json(retention::list_policies(&s.pool, u.user.id).await??)) +} +async fn create_policy( + State(s): State, + u: CurrentUser, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + let id = retention::create_policy(&s.pool, u.user.id, &input).await??; + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id})))) +} +async fn holds( + State(s): State, + u: CurrentUser, +) -> Result>, ApiError> { + Ok(Json(retention::list_holds(&s.pool, u.user.id).await??)) +} +async fn record_holds( + State(s): State, + u: CurrentUser, + Path(id): Path, +) -> Result>, ApiError> { + Ok(Json( + retention::active_holds_for_record(&s.pool, u.user.id, id).await??, + )) +} +async fn create_hold( + State(s): State, + u: CurrentUser, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + let id = retention::create_hold(&s.pool, u.user.id, &input).await??; + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id})))) +} +async fn replace_hold( + State(s): State, + u: CurrentUser, + Path(id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), ApiError> { + let id = retention::replace_hold(&s.pool, u.user.id, id, &input).await??; + Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id})))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Reason { + reason: String, +} +async fn release_hold( + State(s): State, + u: CurrentUser, + Path(id): Path, + Json(input): Json, +) -> Result { + retention::release_hold(&s.pool, u.user.id, id, &input.reason).await??; + Ok(StatusCode::NO_CONTENT) +} +async fn authority( + State(s): State, + u: CurrentUser, +) -> Result>, ApiError> { + Ok(Json( + retention::authority_history(&s.pool, u.user.id).await??, + )) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthorityInput { + user_id: i64, + granted: bool, + reason: String, +} +async fn set_authority( + State(s): State, + u: CurrentUser, + Json(input): Json, +) -> Result { + retention::set_authority( + &s.pool, + u.user.id, + input.user_id, + input.granted, + &input.reason, + ) + .await??; + Ok(StatusCode::NO_CONTENT) +} + +async fn scopes( + State(s): State, + u: CurrentUser, +) -> Result, ApiError> { + Ok(Json(retention::scope_options(&s.pool, u.user.id).await??)) +} diff --git a/crates/consolebook-server/tests/enrollment_event_schema.rs b/crates/consolebook-server/tests/enrollment_event_schema.rs index ad9a391..0f62bea 100644 --- a/crates/consolebook-server/tests/enrollment_event_schema.rs +++ b/crates/consolebook-server/tests/enrollment_event_schema.rs @@ -270,13 +270,18 @@ async fn upgrade_preserves_history_references_schema_objects_and_packet_bytes() let mut connection = pool.acquire().await.expect("inspect upgraded storage"); assert_eq!(before, history(&mut connection).await); let after_schema = schema(&mut connection).await; - let retained_schema: Vec<_> = after_schema - .into_iter() - .filter(|(_, name, _, _)| name != "enrollment_event_version_reference_shape") - .collect(); - assert_eq!( - before_schema, retained_schema, - "existing tables, indexes, and triggers stay intact" + // Later forward migrations may add owners. Every pre-upgrade schema + // object must still be present with exactly the same definition. + for object in before_schema { + assert!( + after_schema.contains(&object), + "existing schema object changed: {object:?}" + ); + } + assert!( + after_schema + .iter() + .any(|(_, name, _, _)| name == "enrollment_event_version_reference_shape") ); let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys") .fetch_one(&mut *connection) diff --git a/crates/consolebook-server/tests/retention.rs b/crates/consolebook-server/tests/retention.rs new file mode 100644 index 0000000..27a4031 --- /dev/null +++ b/crates/consolebook-server/tests/retention.rs @@ -0,0 +1,734 @@ +//! Retention administration: explicit authority, versioned policy, and holds. +//! All names, schedules, authorities, and reasons are invented. +use consolebook_server::{ + capabilities::{self, Capability}, + retention::{ + self, HoldInput, HoldKind, HoldScope, PolicyInput, RecordClass, RetentionAction, + RetentionRefusal, RetentionTrigger, + }, + storage, users, +}; +use sqlx::{ConnectOptions, Connection, SqlitePool}; + +struct Fixture { + tmp: tempfile::TempDir, + pool: SqlitePool, +} +impl Fixture { + async fn new() -> Self { + let tmp = tempfile::tempdir().expect("scratch"); + let pool = storage::open(&tmp.path().join("consolebook.db")) + .await + .expect("migrate"); + let mut tx = storage::write_tx(&pool).await.expect("seed"); + for (username, bundle) in [ + ("avery.admin", capabilities::ADMINISTRATOR_BUNDLE.as_slice()), + ("jordan.trainer", capabilities::TRAINER_BUNDLE.as_slice()), + ("taylor.trainee", capabilities::TRAINEE_BUNDLE.as_slice()), + ] { + let id = users::create(&mut tx, username, username, "", "", "invented-unused-hash") + .await + .expect("user"); + capabilities::grant_bundle(&mut tx, id, bundle, None) + .await + .expect("grants"); + } + capabilities::grant_bundle(&mut tx, 1, &[Capability::ReviewEvaluation], None) + .await + .expect("review grant"); + tx.commit().await.expect("commit"); + Self { tmp, pool } + } + async fn authorize(&self) { + retention::set_authority(&self.pool, 1, 2, true, "Invented delegation") + .await + .expect("call") + .expect("grant"); + } + async fn record(&self) -> (i64, i64) { + use consolebook_server::{ + enrollments, evaluation_drafts, finalization, programs, training_sessions, + }; + let content: programs::VersionContent = serde_json::from_value(serde_json::json!({ + "name":"Invented County Training", "label":"A", "description":"", "phases":[], "phase_transitions":[], "competencies":[], "rating_scales":[], "rating_modifiers":[], "citations":[], + "evaluation_forms":[{"record_type":"daily_report", "name":"Invented Daily", "instructions":"", "competencies":[], "narratives":[{"prompt":"Invented observations", "required":false}]}], + "finalization_policy":{"review_approved":false,"required_narratives":false,"ratings_complete":false} + })).expect("content"); + let program = programs::create_program(&self.pool, 1, &content.name) + .await + .expect("call") + .expect("program"); + let version = programs::create_version(&self.pool, 1, program, &content) + .await + .expect("call") + .expect("version"); + programs::publish_version(&self.pool, 1, version) + .await + .expect("call") + .expect("publish"); + let enrollment = enrollments::enroll(&self.pool, 1, version, 3) + .await + .expect("call") + .expect("enrollment"); + let session = training_sessions::create( + &self.pool, + 1, + enrollment, + &training_sessions::SessionInput { + business_date: "2026-06-02".into(), + timezone: "UTC".into(), + local_start: "2026-06-02T08:00".into(), + local_end: Some("2026-06-02T16:00".into()), + disposition: Some(training_sessions::Disposition::Completed), + phase_id: None, + trainer_user_ids: vec![2], + }, + ) + .await + .expect("call") + .expect("session"); + let record = evaluation_drafts::create(&self.pool, 2, session, None) + .await + .expect("call") + .expect("draft"); + finalization::finalize(&self.pool, 1, record, 0) + .await + .expect("call") + .expect("finalized"); + (enrollment, record) + } + async fn probe(&self) { + let mut c = sqlx::sqlite::SqliteConnectOptions::new() + .filename(self.tmp.path().join("consolebook.db")) + .busy_timeout(std::time::Duration::ZERO) + .connect() + .await + .expect("probe"); + sqlx::query("BEGIN IMMEDIATE") + .execute(&mut c) + .await + .expect("released lock"); + sqlx::query("ROLLBACK") + .execute(&mut c) + .await + .expect("rollback"); + c.close().await.expect("close"); + } +} +fn policy() -> PolicyInput { + PolicyInput { + record_class: RecordClass::DailyReport, + expected_current_id: None, + authority: "INVENTED-SCHEDULE-1".into(), + retention_trigger: RetentionTrigger::FinalizedAt, + retention_days: 365, + action: RetentionAction::Destroy, + reason: "Invented schedule approval".into(), + } +} +fn hold(scope: HoldScope) -> HoldInput { + HoldInput { + scope, + kind: HoldKind::Litigation, + authority: "INVENTED-HOLD-1".into(), + reason: "Invented preservation instruction".into(), + } +} + +#[tokio::test] +async fn authority_is_explicit_revocable_and_audited() { + let fx = Fixture::new().await; + for bundle in [ + capabilities::ADMINISTRATOR_BUNDLE.as_slice(), + capabilities::COORDINATOR_BUNDLE.as_slice(), + capabilities::TRAINER_BUNDLE.as_slice(), + capabilities::TRAINEE_BUNDLE.as_slice(), + ] { + assert!(!bundle.contains(&Capability::ManageRetention)); + } + for actor in [1, 2, 3] { + assert_eq!( + retention::list_policies(&fx.pool, actor) + .await + .expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + assert_eq!( + retention::create_policy(&fx.pool, actor, &policy()) + .await + .expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + assert_eq!( + retention::list_holds(&fx.pool, actor).await.expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + } + assert_eq!( + retention::set_authority(&fx.pool, 2, 2, true, "Invented self-grant") + .await + .expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + fx.authorize().await; + retention::create_policy(&fx.pool, 2, &policy()) + .await + .expect("call") + .expect("policy"); + assert_eq!( + retention::authority_history(&fx.pool, 2) + .await + .expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + retention::set_authority(&fx.pool, 1, 2, false, "Invented reassignment") + .await + .expect("call") + .expect("revoke"); + assert_eq!( + retention::list_policies(&fx.pool, 2).await.expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + let events = retention::authority_history(&fx.pool, 1) + .await + .expect("call") + .expect("history"); + assert_eq!(events.len(), 2); + assert!(!events[0].granted); + assert!(events[1].granted); + assert_eq!(events[0].actor_user_id, 1); + let audits: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_event WHERE kind IN ('retention_authority_granted', 'retention_authority_revoked') AND actor_user_id = 1 AND subject_user_id = 2").fetch_one(&fx.pool).await.expect("audit"); + assert_eq!(audits, 2); + fx.probe().await; +} + +#[tokio::test] +async fn policies_preserve_history_and_refuse_stale_replacement() { + let fx = Fixture::new().await; + fx.authorize().await; + let first = retention::create_policy(&fx.pool, 2, &policy()) + .await + .expect("call") + .expect("policy"); + let before = retention::list_policies(&fx.pool, 2) + .await + .expect("call") + .expect("history")[0] + .clone(); + let next = PolicyInput { + expected_current_id: Some(first), + retention_days: 730, + reason: "Invented schedule revision".into(), + ..policy() + }; + let (a, b) = tokio::join!( + retention::create_policy(&fx.pool, 2, &next), + retention::create_policy(&fx.pool, 2, &next) + ); + let (a, b) = (a.expect("a"), b.expect("b")); + assert!(matches!( + (a, b), + (Ok(_), Err(RetentionRefusal::StalePolicy)) | (Err(RetentionRefusal::StalePolicy), Ok(_)) + )); + let after = retention::list_policies(&fx.pool, 2) + .await + .expect("call") + .expect("history"); + assert_eq!(after.len(), 2); + assert_eq!(after[1], before); + assert_eq!(after[0].version_number, 2); + assert_eq!(after[0].supersedes_id, Some(first)); + fx.probe().await; +} + +#[tokio::test] +async fn policy_class_trigger_duration_and_text_contracts_hold() { + let fx = Fixture::new().await; + fx.authorize().await; + for class in [ + RecordClass::DailyReport, + RecordClass::WeeklySummary, + RecordClass::PhaseEvaluation, + RecordClass::DispositionEvent, + ] { + for trigger in [ + RetentionTrigger::FinalizedAt, + RetentionTrigger::EnrollmentClosedAt, + RetentionTrigger::DisposedAt, + ] { + let mut input = policy(); + input.record_class = class; + input.retention_trigger = trigger; + input.expected_current_id = retention::list_policies(&fx.pool, 2) + .await + .expect("call") + .expect("list") + .iter() + .find(|p| p.record_class == class) + .map(|p| p.id); + let result = retention::create_policy(&fx.pool, 2, &input) + .await + .expect("call"); + assert_eq!( + result.is_ok(), + (class == RecordClass::DispositionEvent) + == (trigger == RetentionTrigger::DisposedAt) + ); + } + } + for input in [ + PolicyInput { + retention_days: -1, + ..policy() + }, + PolicyInput { + retention_days: 365_251, + ..policy() + }, + PolicyInput { + action: RetentionAction::Retain, + ..policy() + }, + PolicyInput { + authority: "\u{2003}".into(), + ..policy() + }, + PolicyInput { + reason: "x".repeat(1001), + ..policy() + }, + ] { + assert!(matches!( + retention::create_policy(&fx.pool, 2, &input) + .await + .expect("call"), + Err(RetentionRefusal::Invalid(_)) + )); + } + let mut retain = policy(); + retain.record_class = RecordClass::DailyReport; + retain.retention_days = 0; + retain.action = RetentionAction::Retain; + retain.expected_current_id = retention::list_policies(&fx.pool, 2) + .await + .expect("call") + .expect("list") + .iter() + .find(|p| p.record_class == RecordClass::DailyReport) + .map(|p| p.id); + retention::create_policy(&fx.pool, 2, &retain) + .await + .expect("call") + .expect("retain policy"); +} + +#[tokio::test] +async fn all_hold_kinds_match_only_their_exact_active_scopes() { + let fx = Fixture::new().await; + fx.authorize().await; + let (enrollment, record) = fx.record().await; + let scopes = [ + HoldScope::Installation, + HoldScope::Enrollment { + enrollment_id: enrollment, + }, + HoldScope::Record { record_id: record }, + ]; + for (i, kind) in [ + HoldKind::Litigation, + HoldKind::AnticipatedLitigation, + HoldKind::Audit, + HoldKind::Investigation, + HoldKind::PublicRecordsRequest, + HoldKind::Other, + ] + .into_iter() + .enumerate() + { + let mut input = hold(scopes[i % 3].clone()); + input.kind = kind; + retention::create_hold(&fx.pool, 2, &input) + .await + .expect("call") + .expect("hold"); + } + let matched = retention::active_holds_for_record(&fx.pool, 2, record) + .await + .expect("call") + .expect("matches"); + assert_eq!(matched.len(), 6); + retention::release_hold(&fx.pool, 2, matched[0].id, "Invented release") + .await + .expect("call") + .expect("release"); + assert_eq!( + retention::active_holds_for_record(&fx.pool, 2, record) + .await + .expect("call") + .expect("matches") + .len(), + 5 + ); + // Same user and program names never expand scope to another enrollment. + let mut tx = storage::write_tx(&fx.pool).await.expect("tx"); + sqlx::query("INSERT INTO enrollment (id,user_id,program_version_id,enrolled_at) SELECT 99,2,program_version_id,1 FROM enrollment WHERE id=?1").bind(enrollment).execute(&mut *tx).await.expect("other enrollment"); + tx.commit().await.expect("commit"); + let other = retention::create_hold( + &fx.pool, + 2, + &hold(HoldScope::Enrollment { enrollment_id: 99 }), + ) + .await + .expect("call") + .expect("other hold"); + assert!( + !retention::active_holds_for_record(&fx.pool, 2, record) + .await + .expect("call") + .expect("matches") + .iter() + .any(|h| h.id == other) + ); + assert_eq!( + retention::active_holds_for_record(&fx.pool, 3, record) + .await + .expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + assert_eq!( + retention::create_hold(&fx.pool, 2, &hold(HoldScope::Record { record_id: 999 })) + .await + .expect("call"), + Err(RetentionRefusal::NoSuchRecord) + ); + assert!( + sqlx::query("DELETE FROM evaluation_version") + .execute(&fx.pool) + .await + .is_err(), + "holds do not open deletion guards" + ); +} + +#[tokio::test] +async fn hold_replacement_is_atomic_attributed_and_not_repeatable() { + let fx = Fixture::new().await; + fx.authorize().await; + let original = retention::create_hold(&fx.pool, 2, &hold(HoldScope::Installation)) + .await + .expect("call") + .expect("hold"); + let invalid = hold(HoldScope::Enrollment { enrollment_id: 999 }); + assert_eq!( + retention::replace_hold(&fx.pool, 2, original, &invalid) + .await + .expect("call"), + Err(RetentionRefusal::NoSuchEnrollment) + ); + assert!( + retention::list_holds(&fx.pool, 2) + .await + .expect("call") + .expect("holds")[0] + .release + .is_none() + ); + // Inject failure after the replacement trigger would have released the + // predecessor; the service must roll back the entire operation. + sqlx::raw_sql("CREATE TRIGGER fail_retention_audit BEFORE INSERT ON audit_event WHEN NEW.kind = 'record_hold_replaced' BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;").execute(&fx.pool).await.expect("failure injection"); + assert!( + retention::replace_hold(&fx.pool, 2, original, &hold(HoldScope::Installation)) + .await + .is_err() + ); + let history = retention::list_holds(&fx.pool, 2) + .await + .expect("call") + .expect("holds"); + assert_eq!(history.len(), 1); + assert!(history[0].release.is_none()); + sqlx::query("DROP TRIGGER fail_retention_audit") + .execute(&fx.pool) + .await + .expect("remove injection"); + let input = HoldInput { + kind: HoldKind::Investigation, + reason: "Invented successor authority".into(), + ..hold(HoldScope::Installation) + }; + let (a, b) = tokio::join!( + retention::replace_hold(&fx.pool, 2, original, &input), + retention::release_hold(&fx.pool, 2, original, "Concurrent invented release") + ); + match (a.expect("replace"), b.expect("release")) { + (Ok(id), Err(RetentionRefusal::HoldReleased)) => { + let rows = retention::list_holds(&fx.pool, 2) + .await + .expect("call") + .expect("holds"); + assert_eq!(rows[0].id, id); + assert_eq!(rows[0].replaces_id, Some(original)); + assert!(rows[0].release.is_none()); + let release = rows[1].release.as_ref().expect("released predecessor"); + assert_eq!(release.replacement_id, Some(id)); + assert_eq!(release.reason, input.reason); + assert_eq!(release.released_by, 2); + } + (Err(RetentionRefusal::HoldReleased), Ok(())) => assert_eq!( + retention::list_holds(&fx.pool, 2) + .await + .expect("call") + .expect("holds") + .len(), + 1 + ), + pair => panic!("one change wins: {pair:?}"), + } + assert_eq!( + retention::release_hold(&fx.pool, 2, original, "Repeated release") + .await + .expect("call"), + Err(RetentionRefusal::HoldReleased) + ); + fx.probe().await; +} + +#[tokio::test] +async fn authorization_is_rechecked_after_waiting_for_a_writer() { + let fx = Fixture::new().await; + fx.authorize().await; + let mut blocker = storage::write_tx(&fx.pool).await.expect("blocker"); + sqlx::query("DELETE FROM capability_grant WHERE user_id=2 AND capability='manage_retention'") + .execute(&mut *blocker) + .await + .expect("pending revocation"); + let input = policy(); + let attempt = retention::create_policy(&fx.pool, 2, &input); + tokio::pin!(attempt); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut attempt) + .await + .is_err() + ); + blocker.commit().await.expect("commit revocation"); + assert_eq!( + attempt.await.expect("call"), + Err(RetentionRefusal::CapabilityRequired) + ); + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM retention_policy") + .fetch_one(&fx.pool) + .await + .expect("count"); + assert_eq!(count, 0); + fx.probe().await; +} + +#[tokio::test] +async fn storage_refuses_mutated_history_and_invalid_shapes() { + let fx = Fixture::new().await; + fx.authorize().await; + let policy_id = retention::create_policy(&fx.pool, 2, &policy()) + .await + .expect("call") + .expect("policy"); + let hold_id = retention::create_hold(&fx.pool, 2, &hold(HoldScope::Installation)) + .await + .expect("call") + .expect("hold"); + retention::release_hold(&fx.pool, 2, hold_id, "Invented release") + .await + .expect("call") + .expect("release"); + for table in [ + "retention_authority_event", + "retention_policy", + "record_hold", + "hold_release", + ] { + for sql in [ + format!("UPDATE {table} SET reason='changed'"), + format!("DELETE FROM {table}"), + ] { + let error = sqlx::query(&sql) + .execute(&fx.pool) + .await + .expect_err("append-only") + .to_string(); + assert!(error.contains("append-only"), "{error}"); + } + } + for (class, trigger, days, action, authority) in [ + ("daily_report", "disposed_at", 1, "destroy", "X"), + ("disposition_event", "finalized_at", 1, "destroy", "X"), + ("weekly_summary", "finalized_at", -1, "destroy", "X"), + ("weekly_summary", "finalized_at", 365_251, "destroy", "X"), + ("weekly_summary", "finalized_at", 1, "retain", "X"), + ("weekly_summary", "finalized_at", 1, "destroy", "\u{2003}"), + ] { + assert!(sqlx::query("INSERT INTO retention_policy (record_class,version_number,authority,retention_trigger,retention_days,action,reason,created_by,created_at) VALUES (?1,1,?2,?3,?4,?5,'Invented raw test',2,1)").bind(class).bind(authority).bind(trigger).bind(days).bind(action).execute(&fx.pool).await.is_err()); + } + assert!(sqlx::query("INSERT INTO retention_policy (record_class,version_number,supersedes_id,authority,retention_trigger,retention_days,action,reason,created_by,created_at) VALUES ('weekly_summary',2,?1,'X','finalized_at',1,'destroy','Invented wrong predecessor',2,1)").bind(policy_id).execute(&fx.pool).await.is_err()); + assert!(sqlx::query("INSERT INTO record_hold (kind,authority,reason,created_by,created_at,replaces_id) VALUES ('audit','X','Invented released predecessor',2,1,?1)").bind(hold_id).execute(&fx.pool).await.is_err()); + assert!( + sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&fx.pool) + .await + .expect("fk check") + .is_empty() + ); +} + +#[tokio::test] +async fn authority_and_policy_failures_leave_no_partial_state() { + let fx = Fixture::new().await; + sqlx::raw_sql("CREATE TRIGGER fail_retention_audit BEFORE INSERT ON audit_event WHEN NEW.kind LIKE 'retention_%' BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;").execute(&fx.pool).await.expect("inject"); + assert!( + retention::set_authority(&fx.pool, 1, 2, true, "Invented grant") + .await + .is_err() + ); + assert!( + !capabilities::user_has(&fx.pool, 2, Capability::ManageRetention) + .await + .expect("grant rolled back") + ); + assert!( + retention::authority_history(&fx.pool, 1) + .await + .expect("call") + .expect("history") + .is_empty() + ); + sqlx::query("DROP TRIGGER fail_retention_audit") + .execute(&fx.pool) + .await + .expect("remove"); + fx.authorize().await; + sqlx::raw_sql("CREATE TRIGGER fail_retention_audit BEFORE INSERT ON audit_event WHEN NEW.kind = 'retention_policy_created' BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;").execute(&fx.pool).await.expect("inject"); + assert!( + retention::create_policy(&fx.pool, 2, &policy()) + .await + .is_err() + ); + assert!( + retention::list_policies(&fx.pool, 2) + .await + .expect("call") + .expect("policies") + .is_empty() + ); +} + +#[tokio::test] +async fn http_refusals_are_typed_and_private() { + use axum::{ + body::Body, + http::{ + Request, StatusCode, + header::{CONTENT_TYPE, COOKIE}, + }, + }; + use http_body_util::BodyExt; + use tower::ServiceExt; + let fx = Fixture::new().await; + let token = consolebook_server::sessions::create(&fx.pool, 3) + .await + .expect("token") + .0; + let app = consolebook_server::http::router(consolebook_server::http::AppState { + pool: fx.pool.clone(), + }); + for (method, path, body) in [ + ("GET", "/api/retention/policies", serde_json::Value::Null), + ("GET", "/api/retention/holds", serde_json::Value::Null), + ("GET", "/api/retention/scopes", serde_json::Value::Null), + ( + "GET", + "/api/retention/records/999/holds", + serde_json::Value::Null, + ), + ( + "POST", + "/api/retention/authority", + serde_json::json!({"user_id":3,"granted":true,"reason":"Invented self grant"}), + ), + ( + "POST", + "/api/retention/holds/999/release", + serde_json::json!({"reason":"Invented release"}), + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header( + COOKIE, + format!("{}={}", consolebook_server::http::SESSION_COOKIE, token.raw), + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + let bytes = response + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + assert_eq!(json["error"], "capability_required"); + } +} + +#[tokio::test] +async fn hold_storage_refuses_ambiguous_scope_self_replacement_and_early_release() { + let fx = Fixture::new().await; + fx.authorize().await; + let (enrollment, record) = fx.record().await; + assert!(sqlx::query("INSERT INTO record_hold (enrollment_id,evaluation_record_id,kind,authority,reason,created_by,created_at) VALUES (?1,?2,'audit','X','Invented ambiguous scope',2,1)").bind(enrollment).bind(record).execute(&fx.pool).await.is_err()); + assert!(sqlx::query("INSERT INTO record_hold (id,replaces_id,kind,authority,reason,created_by,created_at) VALUES (500,500,'audit','X','Invented self replacement',2,1)").execute(&fx.pool).await.is_err()); + for (kind, reason) in [("unknown", "Invented unknown kind"), ("audit", "\u{2003}")] { + assert!(sqlx::query("INSERT INTO record_hold (kind,authority,reason,created_by,created_at) VALUES (?1,'X',?2,2,1)").bind(kind).bind(reason).execute(&fx.pool).await.is_err()); + } + let id = retention::create_hold(&fx.pool, 2, &hold(HoldScope::Record { record_id: record })) + .await + .expect("call") + .expect("hold"); + assert!(sqlx::query("INSERT INTO hold_release (hold_id,released_by,released_at,reason) VALUES (?1,2,0,'Invented early release')").bind(id).execute(&fx.pool).await.is_err()); + let replacement = retention::replace_hold( + &fx.pool, + 2, + id, + &HoldInput { + kind: HoldKind::Audit, + reason: "Invented replacement".into(), + ..hold(HoldScope::Installation) + }, + ) + .await + .expect("call") + .expect("replace"); + let rows = retention::list_holds(&fx.pool, 2) + .await + .expect("call") + .expect("holds"); + assert_eq!(rows[0].id, replacement); + assert_eq!(rows[0].replaces_id, Some(id)); + assert!(rows[0].release.is_none()); + let release = rows[1].release.as_ref().expect("released predecessor"); + assert_eq!(release.replacement_id, Some(replacement)); + assert_eq!(release.released_at, rows[0].created_at); + assert_eq!(release.reason, rows[0].reason); + let applicable = retention::active_holds_for_record(&fx.pool, 2, record) + .await + .expect("call") + .expect("lookup"); + assert_eq!(applicable.len(), 1); + assert_eq!(applicable[0].id, replacement); +} diff --git a/docs/architecture.md b/docs/architecture.md index a6b6278..4cb4027 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,7 @@ HTTP API and application services +-- sessions and evaluation workflow +-- immutable record versions +-- acknowledgments and amendments - +-- holds, retention, and lawful disposition (planned) + +-- retention policy and holds (disposition execution planned) +-- authorization and audit +-- in-app notifications +-- exports and recovery @@ -99,7 +99,8 @@ data/ `DataDir` owns these paths. SQLite and application state live under this one root; runtime services do not require an external database, queue, cache, or -object store. Retention policies for records remain Milestone 5 work. +object store. Retention policies and holds are configurable; disposition execution remains +Milestone 5 work. ## Backups @@ -159,12 +160,11 @@ SMTP may be added later as an optional delivery adapter. It mirrors an in-app no ## Retention and disposition -Retention policy, record holds, and lawful disposition are the next Milestone 5 -slice. They belong to application services with explicit capabilities and -audit events. Normal service methods and database triggers already reject -mutation or deletion of finalized content. The separate disposition path will -check applicable policy and holds, preview scope, record authority, and remove -only the approved material. +Versioned retention policies and attributed holds are administered through +explicit `manage_retention` grants and the operator interface (ADR 0020). +The separate confirmed-disposition workflow remains #64 work. Normal service +methods and database triggers still reject mutation or deletion of finalized +content. Policy configuration and hold lookup do not authorize destruction. Disposition records have retention rules of their own. The architecture must not keep personal metadata forever merely to make an integrity chain convenient. diff --git a/docs/decisions/0020-retention-policy-and-hold-administration.md b/docs/decisions/0020-retention-policy-and-hold-administration.md new file mode 100644 index 0000000..07f6f1c --- /dev/null +++ b/docs/decisions/0020-retention-policy-and-hold-administration.md @@ -0,0 +1,121 @@ +# ADR 0020: Retention policy and hold administration + +- **Status:** Accepted +- **Date:** 2026-09-05 +- **Issues:** [#65](https://github.com/FieldmouseWorks/consolebook/issues/65), + [#64](https://github.com/FieldmouseWorks/consolebook/issues/64) +- **Implements part of:** [Milestone 5 decisions](https://github.com/FieldmouseWorks/consolebook/issues/44) + +## Context and delivery boundary + +The approved retention design requires versioned policy, typed holds, +explicit authority, exact-scope confirmation, destruction, and independently +retained disposition evidence. These are separate contracts. Existing data +also lives in working copies, review snapshots, acknowledgments, amendments, +linked summaries, exported archives, and backups. A deletion implementation +must account for those copies and restore behavior before claiming completion. + +This first vertical stage delivers policy and hold administration. It adds no +record deletion exception, disposition execution, eligibility verdict, or +export-format change. #64 remains open for execution, copy/recovery scope, +partial failure and retry, tombstones, and policy-boundary verification. + +## Decision + +### Explicit administration authority + +`manage_retention` is a new capability for policy/hold administration and +reading its installation-wide metadata. No existing or future role bundle +includes it. A `manage_users` holder explicitly grants or revokes it for an +existing user with a reason. This uses the existing authority for managing +users; it does not give administrators implicit policy or hold access. +The grant/revoke and its attributed authority event and audit event commit +in one immediate transaction. Duplicate state changes refuse typed. + +Destruction will require a separate explicit capability in #64. This +administration capability alone cannot authorize destruction. + +Every retention service checks the required capability on the same connection +and transaction as the governed reads/writes. A writer waiting for reservation +checks the committed grant state after it acquires the reservation. Read-only +snapshots remain deferred. Typed refusals await rollback (ADR 0019). + +### Versioned policy + +Each immutable policy revision names one of four classes: daily reports, +weekly summaries, phase evaluations, or disposition events. There is one +ordered policy history per class. A new revision takes the id of the current +version it supersedes; stale replacement is refused, including concurrent +attempts against the same version. Missing policy never authorizes destruction. + +A policy carries an agency-supplied authority reference, a typed trigger, +minimum retention days, scheduled action, reason, actor id, and UTC timestamp. +Evaluation triggers are finalization or enrollment closure; disposition-event +policy uses disposition time. These are explicit elapsed days of 24 hours, +not calendar months or years. Periods range from 0 to 365250 days. The `retain` +action has zero days and authorizes no destruction; `destroy` records the +schedule intent, subject to the later workflow's complete checks. + +No jurisdictional schedule, recommended duration, default destruction policy, +or assumed legal authority ships with the application. A policy is stored +configuration, not a claim that the application can execute that schedule yet. +Disposition-event policy is independently versioned in preparation for #64; +there are no disposition events to prune in this stage. + +### Holds + +A hold has exactly one scope: installation, enrollment, or evaluation record. +Installation scope covers every record; enrollment scope covers records of +that enrollment; record scope covers only that record, including its lineage. +Scope is established by typed identifiers and relationships, never names, +authority text, date heuristics, or keyword matching. Scope selectors display +human-readable labels but submit the identifiers. Records may be held before +finalization. + +The closed hold-kind set is litigation, anticipated litigation, audit, +investigation, public-records request, and other authority. Every kind carries +an authority reference and reason; `other` uses those fields to name the +agency's configured authority. There is no automatic expiry. + +Holds and releases are append-only. A release records its actor, timestamp, +and reason; it never changes or deletes the original hold. Replacing a hold +creates an attributed successor, and an insert trigger releases its active +predecessor with matching attribution in the same transaction. A failed insert, +audit failure, or competing release cannot leave the predecessor silently +released. Replacement/release of an already released hold is refused. + +The lookup returns applicable active holds for one existing record. An empty +list is only a hold result. The UI never presents it as permission to destroy. + +### Storage and ownership + +Migration 0015 adds policy, hold, release, and authority-event tables and their +constraints and append-only guards. Existing record immutability and historical +migration checksums are unchanged. Service validation adds bounded, nonblank +authority/reason checks; schema checks enforce vocabulary, field combinations, +policy succession, valid scope references, and release/replacement pairing. +Audit rows carry only typed event and subject identifiers, not these reasons. + +`retention/types.rs` owns vocabulary, `policies.rs` owns policy revisions, +`holds.rs` owns hold lifecycle and scope resolution, and `authority.rs` owns +explicit grants. The parent owns shared transaction/authorization boundaries; +`retention_http.rs` only adapts HTTP. The operator page has separate policy, +hold-editor, and authority components. Shared request/error transport moves to +`web/src/lib/api/transport.ts` with compatible legacy API imports; the new +retention client has its own module. Remaining web decomposition stays in #59. + +## Proof and remaining work + +Integration tests cover explicit grants and revocation while a write waits, +policy succession and stale conflicts, all hold kinds and scope matching, +replacement/release races, injected failures with rollback, append-only/raw +shape restrictions, and typed unauthorized HTTP responses. A browser scenario +uses the operator controls for explicit delegation, policy revision, hold +replacement/release, and revoked access. Existing repository gates cover the +unchanged record/export behavior and API transport imports. + +Policy interpretation at execution, exact-scope preview and confirmation, +record-copy removal, backup/restore interaction, independent tombstone expiry, +and portable policy-boundary evidence remain #64 work. Administration history +is retained in this stage; no claim of a complete retention implementation or +lawful-disposition workflow follows from these tables or tests alone. diff --git a/docs/development.md b/docs/development.md index e7758e7..068f1f3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,7 +24,7 @@ tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. | Summaries and signoffs | `summaries.rs`, `task_signoffs.rs` | [ADR 0013](decisions/0013-weekly-summaries-and-task-signoffs.md) | | Record exports | `record_export.rs`, `export_verify.rs`, `zip_container.rs` | [ADR 0014](decisions/0014-record-export-format.md), [Export format](formats/record-export.md) | | Trainee packets | `trainee_packet.rs`, `packet_verify.rs` | [ADR 0015](decisions/0015-trainee-packet.md), [ADR 0017](decisions/0017-packet-pin-timeline-verification.md), [Packet format](formats/trainee-packet.md) | -| Retention, holds, disposition (planned) | No implemented service yet | [Integrity](records-integrity.md), [Milestone 5 decisions](https://github.com/FieldmouseWorks/consolebook/issues/44) | +| Retention policy and holds | `retention.rs`, `retention/`, `retention_http.rs` | [ADR 0020](decisions/0020-retention-policy-and-hold-administration.md), [Operator guide](retention.md); disposition execution remains [#64](https://github.com/FieldmouseWorks/consolebook/issues/64) | | Web shell and HTTP | `http.rs`, `web_assets.rs`, `notices.rs`, domain `*_http.rs` modules | [ADR 0005](decisions/0005-embedded-web-interface.md), web map below | | Preview operations | Separate host installation | [Preview runbook](preview.md) | @@ -77,7 +77,10 @@ snapshot; check where the decision is evaluated. See the The UI is a client-routed SPA. `web/src/routes/+layout.ts` guards setup and authentication; `+layout.svelte` owns navigation and shared styling. -`web/src/lib/api.ts` owns typed same-origin HTTP calls. +`web/src/lib/api/transport.ts` owns shared same-origin requests and typed errors. +`web/src/lib/api.ts` keeps compatible legacy imports and domain calls; +`web/src/lib/api/retention.ts` owns retention contracts. +`web/src/lib/retention/` owns policy editing, hold editing, and authority controls. `web/src/lib/editor/` contains program-authoring components. `web/e2e/fixtures.ts` supplies each scenario's server, base URL, and setup code. @@ -93,6 +96,7 @@ and assertions in their own specs. | `/enrollments/[id]` | Lifecycle, assignments, sessions, summaries, signoffs, exports | | `/drafts/[id]` | Authoring, review, finalized presentation, acknowledgment, amendments | | `/records` | Trainee's own timeline and packet downloads | +| `/retention` | Explicit authority, versioned policies, and attributed holds; no disposition execution | ## Local workflow diff --git a/docs/domain-model.md b/docs/domain-model.md index c4873f2..6d7cce5 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -1,7 +1,7 @@ # Domain Model Consolebook uses a training domain with versioned agency configuration. These -are domain concepts, not a table inventory. Retention/disposition, attachments, +are domain concepts, not a table inventory. Disposition execution, attachments, and PDF presentation remain design targets; see [roadmap.md](roadmap.md). ## Configuration @@ -154,17 +154,19 @@ A RecordExport is an archive of finalized EvaluationVersions as stored: each ver A TraineePacket is everything retained about one enrollment as one archive (`docs/formats/trainee-packet.md`, ADR 0015): the record export's units for every retained version of every record, plus typed documents for the enrollment's lifecycle and phase history, every acknowledgment, every amendment, and the full task signoff history, named with hashes by one packet manifest. The trainee may produce their own; so may whoever reads the enrollment's training history and `export_records` holders. It verifies with the same verifier as a record export. -## Retention and disposition (planned) +## Retention and disposition ### RetentionPolicy +Policy and hold administration is implemented under [ADR 0020](decisions/0020-retention-policy-and-hold-administration.md). Disposition execution remains #64 work. + A versioned RetentionPolicy maps record classes to an approved disposition authority, trigger, minimum retention period, action, and rules for any destruction log. Installations configure policy; Consolebook does not pretend one jurisdiction's schedule is universal. ### RecordHold A RecordHold suspends disposition for an explicit scope. Holds may represent litigation, anticipated litigation, audit, investigation, public-records request, or another configured authority. Creating, changing, and releasing a hold requires attribution and a reason. -### DispositionEvent +### DispositionEvent (planned) A DispositionEvent records an authorized disposition attempt and its result. Where policy permits or requires a retained tombstone, it may contain only the minimum approved fields, such as: @@ -185,9 +187,9 @@ The event does not preserve destroyed narratives, attachments, presentation snap Security- and record-sensitive actions produce append-only audit events. The implemented vocabulary covers authentication and recovery, assignments and enrollment lifecycle, draft and review workflow, finalization, -acknowledgments, amendments, exports, and backup or restore operations. Hold -and disposition events join that vocabulary with the Milestone 5 retention -slice. +acknowledgments, amendments, exports, backup or restore operations, retention +authority and policy changes, and hold creation/replacement/release. Disposition +events remain part of the later execution stage. An audit event supplements the immutable domain record. It is not a substitute for version history. diff --git a/docs/records-integrity.md b/docs/records-integrity.md index fb40bb2..72e4ffc 100644 --- a/docs/records-integrity.md +++ b/docs/records-integrity.md @@ -95,7 +95,8 @@ An amendment never inherits acknowledgment silently. Immutability governs records while they are retained. It does not overrule an approved records-retention schedule or authorize keeping personal data forever. -The next Milestone 5 slice implements retention as this explicit workflow: +Policy and hold administration is implemented in ADR 0020. The complete +disposition workflow remains [#64](https://github.com/FieldmouseWorks/consolebook/issues/64): 1. a versioned policy identifies the record class, disposition authority, trigger, retention period, and action; 2. the service checks for litigation, anticipated-litigation, audit, investigation, public-records-request, and other configured holds; diff --git a/docs/retention.md b/docs/retention.md new file mode 100644 index 0000000..c92012e --- /dev/null +++ b/docs/retention.md @@ -0,0 +1,63 @@ +# Retention administration + +Consolebook currently supports versioned retention policy configuration and +attributed record holds. **Disposition execution is not available yet.** Saving +a policy, releasing a hold, or checking holds does not delete records or prove +that destruction is authorized. The remaining workflow is tracked in +[#64](https://github.com/FieldmouseWorks/consolebook/issues/64). + +## Assign an operator + +A user with `manage_users` opens **Retention → Retention authority**, selects +an existing user, records a reason, and explicitly grants retention +administration. No role receives this permission automatically. The same +control revokes it and preserves the attribution history. The permission +allows installation-wide policy and hold metadata access; grant it accordingly. +It does not grant destruction authority. + +## Record the approved schedule + +A retention administrator selects the record class, enters the agency's +approved authority reference, chooses the trigger and action, and records +why the version is being adopted. Consolebook supplies no default schedule. + +- Evaluation policies support finalization or enrollment closure as triggers. +- Disposition-event policies use disposition time, independently of evaluation + policy. Their execution and event expiry arrive with the disposition work. +- Periods are elapsed 24-hour days, not calendar-month/year arithmetic. +- **Retain** authorizes no destruction; **Destroy** records schedule intent + subject to holds and the future confirmed-disposition workflow. + +Saving creates an immutable version. Revisions preserve earlier versions. If +someone else changes the current policy while it is being edited, the save +refuses with a conflict. Use **Load current policy**, review its values, and +enter a new reason before submitting again. Loading current values replaces +unsaved policy fields. + +## Place, change, and release holds + +Choose the entire installation, a named enrollment, or a named record as the +scope. Choose the hold kind, enter its authority reference, and explain the +preservation instruction. Use invented data in development; authority and +reason fields in a real installation should contain only the metadata needed +to administer the hold, not copied record narratives. + +Holds never expire automatically. **Replace hold** records a new scope/kind +and reason and releases the previous hold in the same operation; if replacement +fails, the old hold stays active. **Release hold** displays the scope again and +requires a reason and explicit confirmation. Prior holds and releases remain +visible in history with actor ids and timestamps. + +**Check a record's holds** lists the active installation, enrollment, and +record holds that apply to it. A result of zero is not a disposition approval: +policy eligibility, related records and copies, authority, and exact-scope +confirmation have not been evaluated by this lookup. + +## Remaining disposition work + +Finalized records retain their existing immutability guards. There is no +supported command or web control to bypass them. The next stage must define +and prove the complete destruction scope, including duplicate content, +summary dependencies, backups and restore, failure/retry handling, minimal +tombstones with independent retention, and honest export verification of +unavailable records and policy boundaries. See [ADR 0020](decisions/0020-retention-policy-and-hold-administration.md). diff --git a/docs/roadmap.md b/docs/roadmap.md index 90b33f1..161a237 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,11 @@ and the trainee timeline, amendments and successor versions schema 2). Milestone 5 is in progress under #44. Its first two slices are complete: file-verifiable structured record exports (#46, ADR 0014) and complete trainee packets (#50, ADR 0015). Slice 3 — retention policy, -holds, lawful disposition, tombstones, and explicit authority — is next. +holds, lawful disposition, tombstones, and explicit authority — is tracked in +[#64](https://github.com/FieldmouseWorks/consolebook/issues/64). Its administration +stage (#65, ADR 0020) provides policy versions, holds, and explicit administration +authority. Confirmed disposition, backup/restore scope, tombstones, and portable +policy-boundary evidence remain next. For continuation, start with the approved design in [#44](https://github.com/FieldmouseWorks/consolebook/issues/44) and check the diff --git a/web/e2e/retention.spec.ts b/web/e2e/retention.spec.ts new file mode 100644 index 0000000..3650452 --- /dev/null +++ b/web/e2e/retention.spec.ts @@ -0,0 +1,60 @@ +// Invented policy/hold administration, with explicit authority and no deletion. +import { expect, test } from './fixtures'; + +test('administer retention authority, policy versions, and hold history', async ({ page, setupCode }) => { + await page.goto('/'); + await page.getByLabel('Setup code').fill(setupCode); + await page.getByLabel('Agency name').fill('Invented Retention County'); + await page.getByLabel('Administrator username').fill('avery.admin'); + await page.getByLabel('Administrator display name').fill('Avery Admin'); + await page.getByLabel('Administrator password').fill('invented-passphrase-1'); + await page.getByRole('button', { name: 'Initialize installation' }).click(); + await expect(page).toHaveURL(/\/login$/); + await page.getByLabel('Username', { exact: true }).fill('avery.admin'); + await page.getByLabel('Password', { exact: true }).fill('invented-passphrase-1'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await page.getByRole('link', { name: 'Retention', exact: true }).click(); + await expect(page.getByText('Explicit retention administration authority is required to view policies and holds.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Policy version', exact: true })).toHaveCount(0); + + await page.getByLabel('User', { exact: true }).selectOption({ label: 'Avery Admin (avery.admin)' }); + await page.getByLabel('Reason for authority change').fill('Invented records custodian appointment'); + await page.getByRole('button', { name: 'Save authority change' }).click(); + await expect(page.getByRole('heading', { name: 'Policy version', exact: true })).toBeVisible(); + await page.getByLabel('Disposition authority reference').fill('INVENTED-SCHEDULE-2026'); + await page.getByLabel('Scheduled action').selectOption('destroy'); + await page.getByLabel('Minimum retention (elapsed days of 24 hours)').fill('365'); + await page.getByLabel('Reason for this version').fill('Invented approved schedule'); + await page.getByRole('button', { name: 'Save policy version' }).click(); + await expect(page.getByRole('heading', { name: 'Daily reports · version 1 (current)' })).toBeVisible(); + await page.getByLabel('Minimum retention (elapsed days of 24 hours)').fill('730'); + await page.getByLabel('Reason for this version').fill('Invented revised schedule'); + await page.getByRole('button', { name: 'Save policy version' }).click(); + await expect(page.getByRole('heading', { name: 'Daily reports · version 2 (current)' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Daily reports · version 1 (superseded)' })).toBeVisible(); + + await page.getByRole('button', { name: 'New hold', exact: true }).click(); + await page.getByLabel('Hold kind').selectOption('public_records_request'); + await page.getByLabel('Hold authority reference').fill('INVENTED-REQUEST-1'); + await page.getByLabel('Reason for hold').fill('Invented preservation request'); + await page.getByRole('button', { name: 'Place hold', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Hold 1 · Active · Public records request' })).toBeVisible(); + await page.getByRole('button', { name: 'Replace hold 1', exact: true }).click(); + await page.getByLabel('Hold kind').selectOption('investigation'); + await page.getByLabel('Reason for replacement').fill('Invented updated authority'); + await page.getByRole('button', { name: 'Save replacement hold' }).click(); + await expect(page.getByRole('heading', { name: 'Hold 1 · Released · Public records request' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Hold 2 · Active · Investigation' })).toBeVisible(); + await page.getByRole('button', { name: 'Release hold 2', exact: true }).click(); + await page.getByLabel('Reason for release').fill('Invented investigation complete'); + await page.getByRole('button', { name: 'Confirm hold release' }).click(); + await expect(page.getByRole('heading', { name: 'Hold 2 · Released · Investigation' })).toBeVisible(); + + await page.getByLabel('Authority change', { exact: true }).selectOption({ label: 'Revoke retention administration' }); + await page.getByLabel('Reason for authority change').fill('Invented reassignment'); + await page.getByRole('button', { name: 'Save authority change' }).click(); + await expect(page.getByRole('heading', { name: 'Policy version', exact: true })).toHaveCount(0); + const response = await page.request.get('/api/retention/holds'); + expect(response.status()).toBe(403); + expect((await response.json()).error).toBe('capability_required'); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index df375d1..9a8e557 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,7 @@ +import { ApiError, request, type ApiErrorBody } from './api/transport'; +export { ApiError } from './api/transport'; +export type { ApiErrorBody } from './api/transport'; + // Typed client for the Consolebook HTTP API. Every call goes to the same // origin; sessions ride the HttpOnly cookie, never JavaScript-visible state. @@ -25,47 +29,6 @@ export interface Health { database: string; } -/** Error body shape the API guarantees for non-2xx responses. */ -export interface ApiErrorBody { - error: string; - message: string; - /** Itemized refusal reasons, present on validation refusals. */ - problems?: string[]; -} - -export class ApiError extends Error { - readonly status: number; - readonly code: string; - readonly problems: string[]; - - constructor(status: number, body: ApiErrorBody) { - super(body.message); - this.status = status; - this.code = body.error; - this.problems = body.problems ?? []; - } -} - -async function request(path: string, init?: RequestInit): Promise { - const response = await fetch(path, { - headers: init?.body ? { 'Content-Type': 'application/json' } : undefined, - ...init - }); - if (response.status === 204) { - return undefined as T; - } - if (!response.ok) { - let body: ApiErrorBody; - try { - body = (await response.json()) as ApiErrorBody; - } catch { - body = { error: 'unreachable', message: `server returned ${response.status}` }; - } - throw new ApiError(response.status, body); - } - return (await response.json()) as T; -} - export function getInstance(): Promise { return request('/api/instance'); } diff --git a/web/src/lib/api/retention.ts b/web/src/lib/api/retention.ts new file mode 100644 index 0000000..293e51a --- /dev/null +++ b/web/src/lib/api/retention.ts @@ -0,0 +1,39 @@ +import { request } from './transport'; + +export type RecordClass = 'daily_report' | 'weekly_summary' | 'phase_evaluation' | 'disposition_event'; +export type RetentionTrigger = 'finalized_at' | 'enrollment_closed_at' | 'disposed_at'; +export type RetentionAction = 'retain' | 'destroy'; +export type HoldKind = 'litigation' | 'anticipated_litigation' | 'audit' | 'investigation' | 'public_records_request' | 'other'; +export type HoldScope = { kind: 'installation' } | { kind: 'enrollment'; enrollment_id: number } | { kind: 'record'; record_id: number }; +export interface PolicyInput { + record_class: RecordClass; expected_current_id: number | null; authority: string; + retention_trigger: RetentionTrigger; retention_days: number; action: RetentionAction; reason: string; +} +export interface Policy extends Omit { + id: number; version_number: number; supersedes_id: number | null; created_by: number; created_at: number; +} +export interface HoldInput { scope: HoldScope; kind: HoldKind; authority: string; reason: string } +export interface Hold extends HoldInput { + id: number; created_by: number; created_at: number; replaces_id: number | null; + release: { released_by: number; released_at: number; reason: string; replacement_id: number | null } | null; +} +export interface AuthorityEvent { id: number; user_id: number; granted: boolean; actor_user_id: number; reason: string; recorded_at: number } +export interface ScopeOption { id: number; label: string } +export interface ScopeOptions { enrollments: ScopeOption[]; records: ScopeOption[] } +export const listPolicies = () => request('/api/retention/policies'); +export const savePolicy = (input: PolicyInput) => request<{ id: number }>('/api/retention/policies', { method: 'POST', body: JSON.stringify(input) }); +export const listHolds = () => request('/api/retention/holds'); +export const listScopes = () => request('/api/retention/scopes'); +export const recordHolds = (id: number) => request(`/api/retention/records/${id}/holds`); +export const saveHold = (input: HoldInput, replaces: number | null) => request<{ id: number }>(replaces === null ? '/api/retention/holds' : `/api/retention/holds/${replaces}/replace`, { method: 'POST', body: JSON.stringify(input) }); +export const releaseHold = (id: number, reason: string) => request(`/api/retention/holds/${id}/release`, { method: 'POST', body: JSON.stringify({ reason }) }); +export const authorityHistory = () => request('/api/retention/authority'); +export const setAuthority = (user_id: number, granted: boolean, reason: string) => request('/api/retention/authority', { method: 'POST', body: JSON.stringify({ user_id, granted, reason }) }); +export const classLabels: Record = { daily_report: 'Daily reports', weekly_summary: 'Weekly summaries', phase_evaluation: 'Phase evaluations', disposition_event: 'Disposition events' }; +export const triggerLabels: Record = { finalized_at: 'Finalization', enrollment_closed_at: 'Enrollment closure', disposed_at: 'Disposition' }; +export const holdLabels: Record = { litigation: 'Litigation', anticipated_litigation: 'Anticipated litigation', audit: 'Audit', investigation: 'Investigation', public_records_request: 'Public records request', other: 'Other authority' }; +export function scopeLabel(scope: HoldScope, options: ScopeOptions): string { + if (scope.kind === 'installation') return 'Entire installation'; + if (scope.kind === 'enrollment') return options.enrollments.find(e => e.id === scope.enrollment_id)?.label ?? `Enrollment ${scope.enrollment_id}`; + return options.records.find(r => r.id === scope.record_id)?.label ?? `Record ${scope.record_id}`; +} diff --git a/web/src/lib/api/transport.ts b/web/src/lib/api/transport.ts new file mode 100644 index 0000000..9e9fd64 --- /dev/null +++ b/web/src/lib/api/transport.ts @@ -0,0 +1,42 @@ +// Shared same-origin request and typed-error boundary. + +/** Error body shape the API guarantees for non-2xx responses. */ +export interface ApiErrorBody { + error: string; + message: string; + /** Itemized refusal reasons, present on validation refusals. */ + problems?: string[]; +} + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly problems: string[]; + + constructor(status: number, body: ApiErrorBody) { + super(body.message); + this.status = status; + this.code = body.error; + this.problems = body.problems ?? []; + } +} + +export async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + headers: init?.body ? { 'Content-Type': 'application/json' } : undefined, + ...init + }); + if (response.status === 204) { + return undefined as T; + } + if (!response.ok) { + let body: ApiErrorBody; + try { + body = (await response.json()) as ApiErrorBody; + } catch { + body = { error: 'unreachable', message: `server returned ${response.status}` }; + } + throw new ApiError(response.status, body); + } + return (await response.json()) as T; +} diff --git a/web/src/lib/retention/AuthorityPanel.svelte b/web/src/lib/retention/AuthorityPanel.svelte new file mode 100644 index 0000000..13a3ae7 --- /dev/null +++ b/web/src/lib/retention/AuthorityPanel.svelte @@ -0,0 +1,42 @@ + +
+

Retention authority

+

Grant or revoke permission to administer policies and holds. This is a separate grant; no role receives it automatically. It does not grant permission to destroy records.

+
+ + + + + + + {#if error}{/if} + {#if message}

{message}

{/if} + +
+
Authority history ({events.length}) + {#each events as event}

{instant(event.recorded_at)} · {name(event.actor_user_id)} {event.granted ? 'granted' : 'revoked'} authority for {name(event.user_id)}. {event.reason}

{/each} +
+
diff --git a/web/src/lib/retention/HoldEditor.svelte b/web/src/lib/retention/HoldEditor.svelte new file mode 100644 index 0000000..1285d4b --- /dev/null +++ b/web/src/lib/retention/HoldEditor.svelte @@ -0,0 +1,48 @@ + +
+

{previous ? `Replace hold ${previous.id}` : 'New hold'}

+

{previous ? 'Saving releases the previous scope and activates this replacement together. The previous history remains available.' : 'Holds remain active until explicitly released. There is no automatic expiry.'}

+ + + {#if scopeKind !== 'installation'} + + + {/if} + + + + + + + {#if error}{/if} +
+
diff --git a/web/src/lib/retention/PolicyEditor.svelte b/web/src/lib/retention/PolicyEditor.svelte new file mode 100644 index 0000000..d897519 --- /dev/null +++ b/web/src/lib/retention/PolicyEditor.svelte @@ -0,0 +1,70 @@ + +
+

Policy version

+

Enter your agency’s approved schedule. A new version preserves every earlier version.

+ + +

{expected === null ? 'No current policy for this class.' : `Replaces policy ${expected}.`}

+ + + + + + + {#if action === 'destroy'} + + + {/if} + + + {#if error}{/if} + {#if message}

{message}

{/if} +
+
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d63e2e9..a4caac1 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -15,6 +15,9 @@