diff --git a/crates/consolebook-server/src/packet_verify.rs b/crates/consolebook-server/src/packet_verify.rs index 6626bf9..817a897 100644 --- a/crates/consolebook-server/src/packet_verify.rs +++ b/crates/consolebook-server/src/packet_verify.rs @@ -536,12 +536,27 @@ fn check_signoffs(path: &str, signoffs: &[SignoffDoc]) -> Vec { /// The enrollment's pin history, from the manifest's current pin and the /// lifecycle events' version changes: every version the enrollment ever /// pinned, labelled as the packet labels it; the original pin; and the -/// version each version change reached, its epoch. +/// version each version change reached, with its epoch's time boundaries. struct PinHistory { labels: BTreeMap, - original: i64, - /// A version change's `event_id` → the version it reached. - epochs: BTreeMap, + original: PinEpoch, + /// A version change's `event_id` → the epoch it opened. + epochs: BTreeMap, +} + +struct PinEpoch { + version: i64, + opened_at: Option, + closed_at: Option, +} + +impl PinEpoch { + /// Unix seconds cannot order a change and an act within that second. + /// Both endpoints therefore belong to the epoch, even when equal. + fn includes(&self, instant: i64) -> bool { + self.opened_at.is_none_or(|opened| instant >= opened) + && self.closed_at.is_none_or(|closed| instant <= closed) + } } impl PinHistory { @@ -575,7 +590,7 @@ impl PinHistory { let mut epochs = BTreeMap::new(); let mut findings = Vec::new(); let mut pinned = original; - for (event, from, to) in &changes { + for (index, (event, from, to)) in changes.iter().enumerate() { let id = event.event_id; if from.version_number != pinned { findings.push(off_history( @@ -601,7 +616,23 @@ impl PinHistory { } } } - epochs.insert(id, to.version_number); + let closed_at = changes.get(index + 1).map(|(next, _, _)| next.occurred_at); + if closed_at.is_some_and(|closed| closed < event.occurred_at) { + findings.push(off_history( + path, + format!( + "version change {id} occurs after the next version change in recorded order" + ), + )); + } + epochs.insert( + id, + PinEpoch { + version: to.version_number, + opened_at: Some(event.occurred_at), + closed_at, + }, + ); pinned = to.version_number; } if pinned != current.version_number { @@ -616,7 +647,11 @@ impl PinHistory { ( Self { labels, - original, + original: PinEpoch { + version: original, + opened_at: None, + closed_at: changes.first().map(|(event, _, _)| event.occurred_at), + }, epochs, }, findings, @@ -649,18 +684,26 @@ impl PinHistory { signoffs .iter() .filter_map(|signoff| { - self.check_version( - path, - &format!("signoff {}", signoff.signoff_id), - &signoff.program_version, - ) + let who = format!("signoff {}", signoff.signoff_id); + if let Some(finding) = self.check_version(path, &who, &signoff.program_version) { + return Some(finding); + } + let named = signoff.program_version.version_number; + let pinned = std::iter::once(&self.original) + .chain(self.epochs.values()) + .any(|epoch| epoch.version == named && epoch.includes(signoff.signed_at)); + (!pinned).then(|| off_history(path, format!( + "{who} names program version {named}, which was not pinned at signed_at {}", + signoff.signed_at, + ))) }) .collect() } /// Every phase event names a pinned version, and the version its /// epoch reached: the original pin under `null`, otherwise the - /// version the named version change reached. + /// version the named version change reached. Effective and recorded times + /// cannot predate the opening; recording cannot postdate the closing. fn check_phase_events(&self, path: &str, enrollment: &EnrollmentDocument) -> Vec { enrollment .phase_events @@ -671,31 +714,52 @@ impl PinHistory { if let Some(finding) = self.check_version(path, &who, &event.program_version) { return Some(finding); } - match event.version_change_event_id { - None if named != self.original => Some(off_history( + let epoch = match event.version_change_event_id { + None if named != self.original.version => return Some(off_history( path, format!( "{who} is recorded under the original pin, but names version {named} rather than version {}", - self.original + self.original.version ), )), - None => None, + None => &self.original, Some(epoch) => match self.epochs.get(&epoch) { - None => Some(off_history( + None => return Some(off_history( path, format!( "{who} names version change {epoch} as its epoch, which the history does not record" ), )), - Some(reached) if *reached != named => Some(off_history( + Some(reached) if reached.version != named => return Some(off_history( path, format!( - "{who} names version {named} under the epoch that reached version {reached}" + "{who} names version {named} under the epoch that reached version {}", + reached.version, ), )), - Some(_) => None, + Some(epoch) => epoch, }, + }; + if let Some(opened) = epoch.opened_at { + if event.effective_at < opened { + return Some(off_history(path, format!( + "{who} takes effect at {}, before its epoch opened at {opened}", + event.effective_at, + ))); + } + if event.recorded_at < opened { + return Some(off_history(path, format!( + "{who} was recorded at {}, before its epoch opened at {opened}", + event.recorded_at, + ))); + } } + epoch.closed_at.filter(|&closed| event.recorded_at > closed).map(|closed| { + off_history(path, format!( + "{who} was recorded at {}, after its epoch closed at {closed}", + event.recorded_at, + )) + }) }) .collect() } diff --git a/crates/consolebook-server/tests/trainee_packet.rs b/crates/consolebook-server/tests/trainee_packet.rs index 99b9d58..368d318 100644 --- a/crates/consolebook-server/tests/trainee_packet.rs +++ b/crates/consolebook-server/tests/trainee_packet.rs @@ -37,6 +37,9 @@ use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use tower::ServiceExt; use zip::write::{SimpleFileOptions, ZipWriter}; +#[path = "trainee_packet/pin_history.rs"] +mod pin_history; + const PASSWORD: &str = "invented-passphrase-1"; /// 2026-09-01T19:00:00Z. @@ -1257,27 +1260,6 @@ fn phase_event( }) } -/// One version-change event as a forger would write it. -fn version_change(event_id: i64, from: (i64, &str), to: (i64, &str)) -> serde_json::Value { - serde_json::json!({ - "actor": null, - "event_id": event_id, - "from_version": {"label": from.1, "version_number": from.0}, - "kind": "version_change", - "occurred_at": 1_780_000_000, - "reason": "Invented version change.", - "to_version": {"label": to.1, "version_number": to.0}, - }) -} - -/// A phase event under a named epoch, naming a version. -fn epoch_phase_event(event_id: i64, epoch: Option, version: (i64, &str)) -> serde_json::Value { - let mut event = phase_event("advance", None, Some("Phase One"), 10, 10, event_id); - event["program_version"] = serde_json::json!({"label": version.1, "version_number": version.0}); - event["version_change_event_id"] = serde_json::json!(epoch); - event -} - /// The verifier holds every document to the order and the cross-member /// rules the format mandates — the stored tables' own constraints — not /// only to member types: a forger who keeps every member well-typed and @@ -1749,144 +1731,3 @@ async fn a_packet_never_holds_a_connection_while_waiting_for_one() { .expect("permitted"); assert_eq!(packet.bytes, reference.bytes); } - -/// The lifecycle events define the enrollment's pin history, and every -/// program version the packet names belongs to it: the verifier refuses -/// a version the enrollment never pinned, a label that disagrees, a -/// version change that leaves a version other than the one pinned, a -/// history ending elsewhere than the manifest's pin, and a phase event -/// naming a version its epoch did not reach. -#[tokio::test] -#[allow(clippy::too_many_lines)] -async fn packets_agree_with_their_pin_history() { - let fx = Fixture::new().await; - let s = seed(&fx, "pins").await; - let original = pack(&fx, s.casey_id, s.enrollment_id).await; - let listed = entries(&original); - let enrollment = DocumentKind::Enrollment; - let signoffs = DocumentKind::Signoffs; - let next_event_id = |doc: &serde_json::Value| -> i64 { - doc["events"] - .as_array() - .expect("events") - .iter() - .map(|event| event["event_id"].as_i64().expect("id")) - .max() - .unwrap_or(0) - + 1 - }; - let history = |findings: &[Finding], expected: &str| { - assert!( - findings.len() == 1 - && matches!( - &findings[0], - Finding::DocumentPinHistory { detail, .. } if detail.contains(expected) - ), - "expected one pin-history finding containing {expected:?}: {findings:?}" - ); - }; - - // Signoffs naming a version the enrollment never pinned, and ones - // labelling the pinned version differently from the packet. - let report = forged(&listed, signoffs, |doc| { - for row in doc.as_array_mut().expect("rows") { - row["program_version"]["version_number"] = serde_json::json!(7); - } - }); - assert_eq!(report.documents[3].findings.len(), 2, "{report:?}"); - assert!( - report.documents[3].findings.iter().all(|finding| matches!( - finding, - Finding::DocumentPinHistory { detail, .. } if detail.contains("never pinned") - )), - "{report:?}" - ); - let report = forged(&listed, signoffs, |doc| { - for row in doc.as_array_mut().expect("rows") { - row["program_version"]["label"] = serde_json::json!("2026 rev B"); - } - }); - assert!( - report.documents[3].findings.iter().all(|finding| matches!( - finding, - Finding::DocumentPinHistory { detail, .. } if detail.contains("labels version 1") - )), - "{report:?}" - ); - - // A version change ending elsewhere than the manifest's pin; a - // second change leaving a version other than the one pinned; an - // event labelling the manifest's version another way. - let report = forged(&listed, enrollment, |doc| { - let id = next_event_id(doc); - doc["events"] - .as_array_mut() - .expect("events") - .push(version_change(id, (1, "2026 rev A"), (2, "2026 rev B"))); - }); - history( - &report.documents[2].findings, - "end at version 2, but the manifest pins version 1", - ); - let report = forged(&listed, enrollment, |doc| { - let id = next_event_id(doc); - let events = doc["events"].as_array_mut().expect("events"); - events.push(version_change(id, (1, "2026 rev A"), (2, "2026 rev B"))); - events.push(version_change(id + 1, (3, "2026 rev C"), (1, "2026 rev A"))); - }); - history( - &report.documents[2].findings, - "leaves version 3, but the enrollment was pinned to version 2", - ); - let report = forged(&listed, enrollment, |doc| { - let id = next_event_id(doc); - doc["events"] - .as_array_mut() - .expect("events") - .push(version_change(id, (2, "2026 rev B"), (1, "Renamed"))); - doc["phase_events"] = serde_json::json!([]); - }); - history( - &report.documents[2].findings, - "labels version 1 \"Renamed\", but the packet labels it \"2026 rev A\"", - ); - - // Phase events: an epoch the history does not record; a version the - // named epoch did not reach; the original pin naming another version. - let report = forged(&listed, enrollment, |doc| { - doc["phase_events"] = - serde_json::json!([epoch_phase_event(1, Some(999), (1, "2026 rev A"))]); - }); - history( - &report.documents[2].findings, - "names version change 999 as its epoch, which the history does not record", - ); - let report = forged(&listed, enrollment, |doc| { - let id = next_event_id(doc); - doc["events"] - .as_array_mut() - .expect("events") - .push(version_change(id, (2, "2026 rev B"), (1, "2026 rev A"))); - doc["phase_events"] = - serde_json::json!([epoch_phase_event(1, Some(id), (2, "2026 rev B"))]); - }); - history( - &report.documents[2].findings, - "names version 2 under the epoch that reached version 1", - ); - let report = forged(&listed, enrollment, |doc| { - let id = next_event_id(doc); - doc["events"] - .as_array_mut() - .expect("events") - .push(version_change(id, (2, "2026 rev B"), (1, "2026 rev A"))); - doc["phase_events"] = serde_json::json!([epoch_phase_event(1, None, (1, "2026 rev A"))]); - }); - history( - &report.documents[2].findings, - "recorded under the original pin, but names version 1 rather than version 2", - ); - - // The genuine packet's history is coherent. - assert!(export_verify::verify_archive(&original).verified()); -} diff --git a/crates/consolebook-server/tests/trainee_packet/pin_history.rs b/crates/consolebook-server/tests/trainee_packet/pin_history.rs new file mode 100644 index 0000000..bea6383 --- /dev/null +++ b/crates/consolebook-server/tests/trainee_packet/pin_history.rs @@ -0,0 +1,473 @@ +//! Packet pin-history membership and timeline proof. + +use super::*; + +/// One version-change event as a forger would write it. +fn version_change(event_id: i64, from: (i64, &str), to: (i64, &str)) -> serde_json::Value { + serde_json::json!({ + "actor": null, + "event_id": event_id, + "from_version": {"label": from.1, "version_number": from.0}, + "kind": "version_change", + "occurred_at": 1_780_000_000, + "reason": "Invented version change.", + "to_version": {"label": to.1, "version_number": to.0}, + }) +} + +/// A phase event under a named epoch, naming a version. +fn epoch_phase_event(event_id: i64, epoch: Option, version: (i64, &str)) -> serde_json::Value { + let mut event = phase_event("advance", None, Some("Phase One"), 10, 10, event_id); + event["program_version"] = serde_json::json!({"label": version.1, "version_number": version.0}); + event["version_change_event_id"] = serde_json::json!(epoch); + event +} + +/// The lifecycle events define the enrollment's pin history, and every +/// program version the packet names belongs to it: the verifier refuses +/// a version the enrollment never pinned, a label that disagrees, a +/// version change that leaves a version other than the one pinned, a +/// history ending elsewhere than the manifest's pin, and a phase event +/// naming a version its epoch did not reach. +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn packets_agree_with_their_pin_history() { + let fx = Fixture::new().await; + let s = seed(&fx, "pins").await; + let original = pack(&fx, s.casey_id, s.enrollment_id).await; + let listed = entries(&original); + let enrollment = DocumentKind::Enrollment; + let signoffs = DocumentKind::Signoffs; + let next_event_id = |doc: &serde_json::Value| -> i64 { + doc["events"] + .as_array() + .expect("events") + .iter() + .map(|event| event["event_id"].as_i64().expect("id")) + .max() + .unwrap_or(0) + + 1 + }; + let history = |findings: &[Finding], expected: &str| { + assert!( + findings.len() == 1 + && matches!( + &findings[0], + Finding::DocumentPinHistory { detail, .. } if detail.contains(expected) + ), + "expected one pin-history finding containing {expected:?}: {findings:?}" + ); + }; + + // Signoffs naming a version the enrollment never pinned, and ones + // labelling the pinned version differently from the packet. + let report = forged(&listed, signoffs, |doc| { + for row in doc.as_array_mut().expect("rows") { + row["program_version"]["version_number"] = serde_json::json!(7); + } + }); + assert_eq!(report.documents[3].findings.len(), 2, "{report:?}"); + assert!( + report.documents[3].findings.iter().all(|finding| matches!( + finding, + Finding::DocumentPinHistory { detail, .. } if detail.contains("never pinned") + )), + "{report:?}" + ); + let report = forged(&listed, signoffs, |doc| { + for row in doc.as_array_mut().expect("rows") { + row["program_version"]["label"] = serde_json::json!("2026 rev B"); + } + }); + assert!( + report.documents[3].findings.iter().all(|finding| matches!( + finding, + Finding::DocumentPinHistory { detail, .. } if detail.contains("labels version 1") + )), + "{report:?}" + ); + + // A version change ending elsewhere than the manifest's pin; a + // second change leaving a version other than the one pinned; an + // event labelling the manifest's version another way. + let report = forged(&listed, enrollment, |doc| { + let id = next_event_id(doc); + doc["events"] + .as_array_mut() + .expect("events") + .push(version_change(id, (1, "2026 rev A"), (2, "2026 rev B"))); + doc["phase_events"] = serde_json::json!([]); + }); + history( + &report.documents[2].findings, + "end at version 2, but the manifest pins version 1", + ); + let report = forged(&listed, enrollment, |doc| { + let id = next_event_id(doc); + let events = doc["events"].as_array_mut().expect("events"); + events.push(version_change(id, (1, "2026 rev A"), (2, "2026 rev B"))); + events.push(version_change(id + 1, (3, "2026 rev C"), (1, "2026 rev A"))); + doc["phase_events"] = serde_json::json!([]); + }); + history( + &report.documents[2].findings, + "leaves version 3, but the enrollment was pinned to version 2", + ); + let report = forged(&listed, enrollment, |doc| { + let id = next_event_id(doc); + doc["events"] + .as_array_mut() + .expect("events") + .push(version_change(id, (2, "2026 rev B"), (1, "Renamed"))); + doc["phase_events"] = serde_json::json!([]); + }); + history( + &report.documents[2].findings, + "labels version 1 \"Renamed\", but the packet labels it \"2026 rev A\"", + ); + + // Phase events: an epoch the history does not record; a version the + // named epoch did not reach; the original pin naming another version. + let report = forged(&listed, enrollment, |doc| { + doc["phase_events"] = + serde_json::json!([epoch_phase_event(1, Some(999), (1, "2026 rev A"))]); + }); + history( + &report.documents[2].findings, + "names version change 999 as its epoch, which the history does not record", + ); + let report = forged(&listed, enrollment, |doc| { + let id = next_event_id(doc); + doc["events"] + .as_array_mut() + .expect("events") + .push(version_change(id, (2, "2026 rev B"), (1, "2026 rev A"))); + doc["phase_events"] = + serde_json::json!([epoch_phase_event(1, Some(id), (2, "2026 rev B"))]); + }); + history( + &report.documents[2].findings, + "names version 2 under the epoch that reached version 1", + ); + let report = forged(&listed, enrollment, |doc| { + let id = next_event_id(doc); + doc["events"] + .as_array_mut() + .expect("events") + .push(version_change(id, (2, "2026 rev B"), (1, "2026 rev A"))); + doc["phase_events"] = serde_json::json!([epoch_phase_event(1, None, (1, "2026 rev A"))]); + }); + history( + &report.documents[2].findings, + "recorded under the original pin, but names version 1 rather than version 2", + ); + + // The genuine packet's history is coherent. + assert!(export_verify::verify_archive(&original).verified()); +} + +const TIMELINE_START: i64 = EXPORTED_AT - 100; + +async fn published_timeline_versions(fx: &Fixture) -> Vec { + let mut content = program("Invented Pin Timeline Program"); + let program_id = programs::create_program(&fx.pool, fx.admin_id, &content.name) + .await + .expect("create") + .expect("accepted"); + let mut versions = Vec::new(); + for label in ["2026 rev A", "2026 rev B", "2026 rev C"] { + content.label = label.to_owned(); + let id = programs::create_version(&fx.pool, fx.admin_id, program_id, &content) + .await + .expect("version") + .expect("accepted"); + programs::publish_version(&fx.pool, fx.admin_id, id) + .await + .expect("publish") + .expect("accepted"); + versions.push(id); + } + versions +} + +/// The real producer exports constrained, append-only fixture rows. Explicit +/// timestamps make boundary cases deterministic without sleeping or rewriting +/// retained history. The pin visits 1 -> 2 -> 3 -> 1, including signoffs on +/// both sides of each change in the same second. +async fn timeline_packet(change_offsets: [i64; 3]) -> Vec { + let fx = Fixture::new().await; + let versions = published_timeline_versions(&fx).await; + let mut tx = storage::write_tx(&fx.pool) + .await + .expect("fixture transaction"); + let enrollment_id = sqlx::query( + "INSERT INTO enrollment (user_id, program_version_id, enrolled_at, enrolled_by) + VALUES (?1, ?2, ?3, ?1)", + ) + .bind(fx.admin_id) + .bind(versions[0]) + .bind(TIMELINE_START - 1) + .execute(&mut *tx) + .await + .expect("enroll fixture") + .last_insert_rowid(); + let changes = change_offsets.map(|offset| TIMELINE_START + offset); + let mut epoch_id = None; + for (index, version) in [versions[0], versions[1], versions[2], versions[0]] + .into_iter() + .enumerate() + { + let opened = if index == 0 { + TIMELINE_START + } else { + changes[index - 1] + }; + if index > 0 { + epoch_id = Some( + sqlx::query( + "INSERT INTO enrollment_event + (enrollment_id, kind, occurred_at, actor_user_id, reason, + from_program_version_id, to_program_version_id) + SELECT id, 'version_change', ?2, ?3, 'Invented timeline change.', + program_version_id, ?4 FROM enrollment WHERE id = ?1", + ) + .bind(enrollment_id) + .bind(opened) + .bind(fx.admin_id) + .bind(version) + .execute(&mut *tx) + .await + .expect("append version change") + .last_insert_rowid(), + ); + sqlx::query("UPDATE enrollment SET program_version_id = ?2 WHERE id = ?1") + .bind(enrollment_id) + .bind(version) + .execute(&mut *tx) + .await + .expect("repoint through event"); + } + sqlx::query( + "INSERT INTO phase_event + (enrollment_id, kind, to_phase_id, effective_at, recorded_at, + actor_user_id, reason, version_change_event_id) + SELECT ?1, 'advance', id, ?2, ?2, ?3, '', ?4 + FROM phase WHERE program_version_id = ?5", + ) + .bind(enrollment_id) + .bind(opened) + .bind(fx.admin_id) + .bind(epoch_id) + .bind(version) + .execute(&mut *tx) + .await + .expect("append phase entry"); + let closed = changes.get(index).copied().unwrap_or(TIMELINE_START + 40); + for signed_at in [opened, closed] { + sqlx::query( + "INSERT INTO task_signoff + (enrollment_id, task_id, kind, reason, signed_by, signed_by_display_name, signed_at) + SELECT ?1, id, 'observed', 'Invented repeated observation.', ?2, 'Avery Admin', ?3 + FROM task WHERE program_version_id = ?4", + ) + .bind(enrollment_id).bind(fx.admin_id).bind(signed_at).bind(version) + .execute(&mut *tx).await.expect("append signoff under current pin"); + } + } + tx.commit().await.expect("commit fixture"); + let packet = pack(&fx, fx.admin_id, enrollment_id).await; + fx.pool.close().await; + packet +} + +fn only_timeline_findings( + report: &export_verify::ArchiveReport, + kind: DocumentKind, + expected: &str, +) { + assert!(!report.verified(), "forgery verified"); + assert!(report.findings.is_empty(), "{report:?}"); + assert!( + report.units.iter().all(|unit| unit.findings.is_empty()), + "{report:?}" + ); + let document = report + .documents + .iter() + .find(|doc| doc.path == kind.path()) + .expect("document"); + assert!(!document.findings.is_empty(), "{report:?}"); + assert!( + document.findings.iter().all(|finding| matches!(finding, + Finding::DocumentPinHistory { detail, .. } if detail.contains(expected) + )), + "{report:?}" + ); + assert!( + report + .documents + .iter() + .filter(|doc| doc.path != kind.path()) + .all(|doc| doc.findings.is_empty()), + "{report:?}" + ); +} + +#[tokio::test] +async fn signoffs_follow_the_pin_at_the_signed_second() { + let packet = timeline_packet([10, 20, 30]).await; + assert!(export_verify::verify_archive(&packet).verified()); + let listed = entries(&packet); + // Relabel both original rows as the later version's actual task so + // task-description consistency holds, including the return to version 1. + // Version 2 is genuinely pinned later; it cannot explain the earlier act. + let report = forged(&listed, DocumentKind::Signoffs, |doc| { + let later_task = doc[2]["task_id"].clone(); + for row in &mut doc.as_array_mut().expect("rows")[..2] { + row["task_id"] = later_task.clone(); + row["program_version"] = + serde_json::json!({"version_number": 2, "label": "2026 rev B"}); + } + }); + only_timeline_findings(&report, DocumentKind::Signoffs, "not pinned at signed_at"); + // Version 1 is revisited, but its two epochs cannot cover the gap between. + for (row, offset) in [(0, 11), (2, 9), (3, 21), (4, 19), (5, 31), (6, 29)] { + let report = forged(&listed, DocumentKind::Signoffs, |doc| { + doc[row]["signed_at"] = serde_json::json!(TIMELINE_START + offset); + }); + only_timeline_findings(&report, DocumentKind::Signoffs, "not pinned at signed_at"); + } +} + +#[tokio::test] +async fn phase_events_stay_within_their_named_epoch() { + let packet = timeline_packet([10, 20, 30]).await; + let listed = entries(&packet); + // Keep effective order and effective <= recorded, so the failure is the + // epoch boundary, even when both instants predate the opening. + for recorded in [TIMELINE_START + 9, TIMELINE_START + 10] { + let report = forged(&listed, DocumentKind::Enrollment, |doc| { + doc["phase_events"][1]["effective_at"] = serde_json::json!(TIMELINE_START + 9); + doc["phase_events"][1]["recorded_at"] = serde_json::json!(recorded); + }); + only_timeline_findings(&report, DocumentKind::Enrollment, "before its epoch opened"); + } + // A backdated effective instant cannot rescue a record made after closing, + // under either the original pin or a subsequently opened epoch. + for (row, offset) in [(0, 11), (1, 21), (2, 31)] { + let report = forged(&listed, DocumentKind::Enrollment, |doc| { + doc["phase_events"][row]["recorded_at"] = serde_json::json!(TIMELINE_START + offset); + }); + only_timeline_findings(&report, DocumentKind::Enrollment, "after its epoch closed"); + } + // Inclusive closing boundaries and backdating inside an epoch are valid. + let report = forged(&listed, DocumentKind::Enrollment, |doc| { + for (row, offset) in [(0, 10), (1, 20), (2, 30), (3, 40)] { + doc["phase_events"][row]["recorded_at"] = serde_json::json!(TIMELINE_START + offset); + } + }); + assert!(report.verified(), "{report:?}"); +} + +#[tokio::test] +async fn multiple_changes_and_acts_in_one_second_verify() { + let packet = timeline_packet([10, 10, 30]).await; + let report = export_verify::verify_archive(&packet); + assert!(report.verified(), "{report:?}"); + let listed = entries(&packet); + // The intermediate pin exists only in the shared second. Neither adjacent + // second can borrow that version, while before/intermediate/after pins all + // appear legitimately in the unmodified packet at the boundary. + for offset in [9, 11] { + let report = forged(&listed, DocumentKind::Signoffs, |doc| { + doc[2]["signed_at"] = serde_json::json!(TIMELINE_START + offset); + }); + only_timeline_findings(&report, DocumentKind::Signoffs, "not pinned at signed_at"); + } +} + +#[tokio::test] +async fn backwards_version_change_times_are_an_incoherent_timeline() { + let packet = timeline_packet([10, 20, 30]).await; + let listed = entries(&packet); + let report = forged(&listed, DocumentKind::Enrollment, |doc| { + doc["events"][1]["occurred_at"] = serde_json::json!(TIMELINE_START + 9); + // Isolate the incoherent history from individual event-boundary checks. + doc["phase_events"] = serde_json::json!([]); + }); + assert!(report.documents[2].findings.iter().any(|finding| matches!(finding, + Finding::DocumentPinHistory { detail, .. } if detail.contains("after the next version change") + )), "{report:?}"); +} + +#[tokio::test] +async fn service_recorded_pin_changes_and_acts_verify() { + let fx = Fixture::new().await; + let coordinator = fx + .user_with_role( + "casey.timeline", + "Casey Coordinator", + RoleBundle::Coordinator, + ) + .await; + let versions = published_timeline_versions(&fx).await; + let enrollment_id = enrollments::enroll(&fx.pool, fx.admin_id, versions[0], fx.admin_id) + .await + .expect("enroll") + .expect("accepted"); + for (index, version) in [versions[0], versions[1], versions[2], versions[0]] + .into_iter() + .enumerate() + { + if index > 0 { + lifecycle::record_enrollment_event( + &fx.pool, + coordinator, + enrollment_id, + EnrollmentEventKind::VersionChange, + "Invented configuration update.", + Some(version), + ) + .await + .expect("change pin") + .expect("accepted"); + } + let phase: i64 = sqlx::query_scalar("SELECT id FROM phase WHERE program_version_id = ?1") + .bind(version) + .fetch_one(&fx.pool) + .await + .expect("phase"); + lifecycle::record_phase_event( + &fx.pool, + coordinator, + enrollment_id, + PhaseEventKind::Advance, + Some(phase), + None, + "", + ) + .await + .expect("phase entry") + .expect("accepted"); + let task: i64 = sqlx::query_scalar("SELECT id FROM task WHERE program_version_id = ?1") + .bind(version) + .fetch_one(&fx.pool) + .await + .expect("task"); + task_signoffs::record( + &fx.pool, + coordinator, + enrollment_id, + task, + SignoffKind::Observed, + "Invented observation after configuration update.", + ) + .await + .expect("signoff") + .expect("accepted"); + } + let packet = pack(&fx, fx.admin_id, enrollment_id).await; + let report = export_verify::verify_archive(&packet); + assert!(report.verified(), "{report:?}"); + fx.pool.close().await; +} diff --git a/docs/decisions/0015-trainee-packet.md b/docs/decisions/0015-trainee-packet.md index ecb95f7..562fb77 100644 --- a/docs/decisions/0015-trainee-packet.md +++ b/docs/decisions/0015-trainee-packet.md @@ -2,6 +2,8 @@ - **Status:** Accepted - **Date:** 2026-09-01 +- **Amended by:** [ADR 0017](0017-packet-pin-timeline-verification.md), which + binds signoffs and phase events to the pin timeline at Unix-second precision. ## Context diff --git a/docs/decisions/0017-packet-pin-timeline-verification.md b/docs/decisions/0017-packet-pin-timeline-verification.md new file mode 100644 index 0000000..cc15383 --- /dev/null +++ b/docs/decisions/0017-packet-pin-timeline-verification.md @@ -0,0 +1,75 @@ +# ADR 0017: Packet pin timeline verification + +- **Status:** Accepted +- **Date:** 2026-09-05 +- **Issue:** [#52](https://github.com/FieldmouseWorks/consolebook/issues/52) +- **Amends:** [ADR 0015](0015-trainee-packet.md) + +## Context + +Packet verification checked whether a document's program version appeared +anywhere in the enrollment's pin history. A rehashed packet could therefore +attribute an early signoff to a version pinned only later, or place phase +activity before the version-change event that opened its named epoch. + +The producer already carries all required instants: version changes have +`occurred_at`, signoffs have `signed_at`, and phase events have `effective_at`, +`recorded_at`, and an epoch identity. All are UTC Unix seconds. Separate tables +do not establish the order of a change and an act within the same second. + +## Decision + +`packet_verify::PinHistory` owns the temporal interpretation alongside its +existing version and label checks. Every epoch retains its pinned version, +opening instant, and the next version change's closing instant. The original +pin has no version-change opening boundary; the final epoch has no closing +boundary. Epochs remain distinct when the enrollment returns to an earlier +version. + +- A signoff must name a version pinned at `signed_at`. Each epoch includes + both boundary seconds. Equivalently, at second `t`, allow the pin after all + changes strictly before `t`, plus every target reached by a change at `t`. + Multiple changes within one second may therefore allow several versions; + a pin that exists only in that second cannot explain an adjacent second. +- A phase event must name the version its explicit epoch reached. Both + `effective_at` and `recorded_at` must be at or after its epoch's opening; + `recorded_at` must be at or before its closing. Under the original pin, only + the closing boundary applies. The existing `effective_at <= recorded_at` + shape rule still applies, and backdating within the epoch remains valid. +- Version-change instants must be nondecreasing in recorded event order, so + an epoch cannot close before it opens. A contradictory timestamp history + fails verification, even if its origin was a clock regression rather than + an edited archive. The verifier does not reorder or repair that history. +- All timeline contradictions use `DocumentPinHistory`. Existing shape, + canonical-byte, hash, label, and reference checks remain in force. + +These are stricter consistency checks on version-1 fields, not a new document +shape. The packet format remains version 1 under its existing versioning +rule. The producer and schema need no changes: they already preserve the +instants and epoch references verbatim. The normative packet specification +states the temporal rules and the same-second limitation. + +## Ownership and proof + +`tests/trainee_packet/pin_history.rs` owns membership and temporal packet +verification tests, extracted from the large packet integration-test module. +The parent retains shared fixtures and archive-editing helpers and registers +the child module. This test reorganization has no persisted or public impact. + +The real producer exports deterministic, constrained database fixtures that +visit versions 1, 2, 3, then 1 again. They include acts on both sides of changes +within the same second and an intermediate epoch opened and closed in one +second. Forged documents are canonicalized and their manifest hashes updated; +the negative assertions require pin-history findings, demonstrating that a +checksum or shape error is not doing the timeline check's work. +An additional round trip records pin changes, phase entries, and signoffs +through the domain services and verifies the resulting exported packet. + +## Limits + +The result still means consistency with the stated fingerprints and history, +not proof of provenance or of a timestamp's truth. An attacker who rewrites a +whole history consistently is outside this verifier's guarantee. The packet +cannot resolve subsecond ordering that was never stored. This change adds no +clock synchronization, signing, epoch column for signoffs, or live-write +concurrency policy; those require their own storage and service decisions. diff --git a/docs/development.md b/docs/development.md index dfcad4b..6ed5e46 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,7 +23,7 @@ tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. | Acknowledgments and amendments | `acknowledgments.rs`, `amendments.rs` | [Domain model](domain-model.md), [ADR 0012](decisions/0012-amendment-reopening-state-machine.md) | | 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), [Packet format](formats/trainee-packet.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) | | 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) | @@ -33,6 +33,10 @@ tests show what is implemented. [Roadmap](roadmap.md) owns milestone status. `crates/consolebook-server/migrations/` own schema, constraints, and triggers. Read both when changing a persisted contract. +Packet membership and timeline verification tests live in +`tests/trainee_packet/pin_history.rs`; the parent packet test module owns +shared fixtures and archive-editing helpers. + ## Runtime flow ```text diff --git a/docs/formats/trainee-packet.md b/docs/formats/trainee-packet.md index 3162244..2c7b795 100644 --- a/docs/formats/trainee-packet.md +++ b/docs/formats/trainee-packet.md @@ -4,9 +4,9 @@ Everything retained about one enrollment, as one verifiable archive (ADR 0015; #44 decision 3; `docs/records-integrity.md`). `consolebook-server/src/trainee_packet.rs` produces packets and `consolebook-server/src/export_verify.rs` verifies them; -`tests/trainee_packet.rs` proves the contents, determinism, and every -verification finding. This document is normative: the implementation -follows it, not the other way around. +`tests/trainee_packet.rs` and its `trainee_packet/pin_history.rs` module prove +the contents, determinism, and verification findings. This document is +normative: the implementation follows it, not the other way around. A packet is a record export (`docs/formats/record-export.md`) plus what the record bytes do not carry. Its units are byte-identical to record- @@ -144,6 +144,27 @@ a phase event's `program_version` is the version its epoch reached: the original pin under `null`, otherwise the version the named version change reached. +Pin history is also a **timeline** (ADR 0017). Version changes' `occurred_at` +instants are nondecreasing in recorded event order. Each change opens an epoch +at its instant and the next change closes that epoch. The original pin closes +at the first change, and the final epoch has no closing boundary. Returning to +an earlier version opens a separate epoch; it does not fill the intervening +gap with that version. + +Every instant is a UTC Unix second, so a change and an act in the same second +cannot be ordered across tables. Epoch boundaries are inclusive. A signoff's +version must be pinned at `signed_at`: allow the pin after all changes with +`occurred_at < signed_at`, plus the target of every change with +`occurred_at == signed_at`. This includes intermediate pins when several +changes share one second, but only for that second. + +A phase event under a version-change epoch must have both `effective_at` and +`recorded_at` at or after that epoch's opening instant, and `recorded_at` at or +before the next change's instant if there is one. Under the original (`null`) +epoch, `recorded_at` must be at or before the first change if there is one. +The existing `effective_at <= recorded_at` rule still applies. Backdating +inside an epoch is valid; a later recording cannot claim a closed epoch. + ### `packet/acknowledgments.json` An array, strictly ascending by (`record_id`, `version_number`) — one @@ -181,6 +202,7 @@ and overrides alike, so the history is complete (ADR 0013). `program_version` is the `{version_number, label}` of the pinned version whose task was signed, so a history that spans a version change keeps each signoff's configuration provenance without the installation. +The version must be pinned at `signed_at` under the timeline rules above. `prompt` and `competency_name` are non-empty; `competency_category` may be empty (uncategorized). `kind` is one of `observed`, `demonstrated`, `revoked`. Any signoff after the first for a task supersedes it and @@ -258,7 +280,11 @@ Document checks: version change leaving a version other than the one pinned, a history ending elsewhere than the manifest's pin, a version labelled two ways, a signoff or phase event naming a version never pinned, or a - phase event naming a version its epoch did not reach, is a finding; + phase event naming a version its epoch did not reach, is a finding. + Version-change times must not decrease in recorded order; signoffs must + name a version pinned at their signing second, and phase effective and + recorded instants must obey their named epoch's boundaries. Violations + are `DocumentPinHistory` findings, even when all hashes match; and 9. `enrollment.json` names the manifest's `enrollment.id`.