| @@ -29,7 +29,10 @@ | ||
| identity mismatches, malformed manifest JSON, invalid manifest signatures, | ||
| and manifest/file ID divergence before operators declare migration burn-in | ||
| complete. It also validates event/corpus JSON sidecars and tlog index | ||
| - | uniqueness so corrupted migrated evidence cannot look clean. | |
| + | uniqueness so corrupted migrated evidence cannot look clean. Rust registry | |
| + | writes now fail closed if the local transparency log cannot append, and | |
| + | validation checks missing or out-of-range event tlog indexes against the | |
| + | on-disk tlog size. | |
| - **Rust policy test parity.** Fixed the `oversight-policy` crate's manifest | ||
| fixture after the v0.4.11 `Recipient.p256_pub` schema addition so the full | ||
| Rust workspace test suite compiles again. |
| @@ -97,6 +97,8 @@ migration tooling (`--migrate-from`, `--migrate-dry-run`) and a native | ||
| `--validate-db` integrity report so operators can preflight, copy, and verify | ||
| attribution rows, event metadata, corpus metadata, and tlog indexes without | ||
| treating the Python reference as a permanent production dependency. | ||
| + | Rust registry writes now fail closed if the local transparency log cannot | |
| + | append, so new evidence rows cannot silently lose their audit trail. | |
| The next Rust-registry gate is operational burn-in: longer-running deployment | ||
| tests against real operator databases and a final wire-format stability |
| @@ -135,7 +135,8 @@ oversight-registry \ | ||
| The validation command prints JSON counts plus integrity failures for orphaned | ||
| beacons, watermarks, events, corpus rows, identity mismatches, malformed | ||
| event `extra` JSON, malformed corpus metadata JSON, duplicate or negative | ||
| - | tlog indexes, malformed manifest JSON, invalid manifest signatures, and | |
| + | tlog indexes, missing event tlog indexes, event tlog indexes outside the | |
| + | on-disk tlog size, malformed manifest JSON, invalid manifest signatures, and | |
| manifest/file ID divergence. Keep the Python database as a rollback artifact | ||
| until validation, live conformance, and evidence-bundle checks pass against | ||
| the Rust service. |
| @@ -244,6 +244,8 @@ preflight. As of 2026-05-20, `--validate-db` checks the copied Rust database | ||
| for orphan rows, identity mismatches, malformed manifest JSON, invalid | ||
| manifest signatures, and manifest/file ID divergence. As of 2026-05-21, that | ||
| validation also covers event/corpus JSON sidecars and tlog index uniqueness. | ||
| + | As of 2026-05-22, registry writes fail closed when tlog append fails and | |
| + | `--validate-db` compares event tlog indexes against the on-disk tlog size. | |
| Remaining work: longer-running deployment tests and a wire-format stability | ||
| declaration before declaring v1.0 ready. | ||
| @@ -42,6 +42,9 @@ pub struct RegistryIntegrityReport { | ||
| pub malformed_corpus_metadata_json: i64, | ||
| pub duplicate_event_tlog_indexes: i64, | ||
| pub negative_event_tlog_indexes: i64, | ||
| + | pub events_without_tlog_index: i64, | |
| + | pub event_tlog_indexes_out_of_range: i64, | |
| + | pub tlog_size: Option<usize>, | |
| pub malformed_manifest_json: i64, | ||
| pub invalid_manifest_signatures: i64, | ||
| pub mismatched_manifest_file_ids: i64, | ||
| @@ -227,7 +230,10 @@ pub async fn migrate_from_sqlite( | ||
| result | ||
| } | ||
| - | pub async fn validate_registry_integrity(pool: &SqlitePool) -> Result<RegistryIntegrityReport> { | |
| + | pub async fn validate_registry_integrity( | |
| + | pool: &SqlitePool, | |
| + | tlog_size: Option<usize>, | |
| + | ) -> Result<RegistryIntegrityReport> { | |
| let counts = registry_counts(pool).await?; | ||
| let orphan_beacons = count_query( | ||
| pool, | ||
| @@ -274,6 +280,20 @@ pub async fn validate_registry_integrity(pool: &SqlitePool) -> Result<RegistryIn | ||
| "SELECT COUNT(*) FROM events WHERE tlog_index IS NOT NULL AND tlog_index < 0", | ||
| ) | ||
| .await?; | ||
| + | let events_without_tlog_index = | |
| + | count_query(pool, "SELECT COUNT(*) FROM events WHERE tlog_index IS NULL").await?; | |
| + | let event_tlog_indexes_out_of_range = match tlog_size { | |
| + | Some(size) => { | |
| + | let (count,): (i64,) = sqlx::query_as( | |
| + | "SELECT COUNT(*) FROM events WHERE tlog_index IS NOT NULL AND tlog_index >= ?", | |
| + | ) | |
| + | .bind(size as i64) | |
| + | .fetch_one(pool) | |
| + | .await?; | |
| + | count | |
| + | } | |
| + | None => 0, | |
| + | }; | |
| let event_extra_rows: Vec<String> = sqlx::query_scalar( | ||
| "SELECT extra FROM events WHERE extra IS NOT NULL AND TRIM(extra) != ''", | ||
| @@ -330,6 +350,8 @@ pub async fn validate_registry_integrity(pool: &SqlitePool) -> Result<RegistryIn | ||
| && malformed_corpus_metadata_json == 0 | ||
| && duplicate_event_tlog_indexes == 0 | ||
| && negative_event_tlog_indexes == 0 | ||
| + | && events_without_tlog_index == 0 | |
| + | && event_tlog_indexes_out_of_range == 0 | |
| && malformed_manifest_json == 0 | ||
| && invalid_manifest_signatures == 0 | ||
| && mismatched_manifest_file_ids == 0; | ||
| @@ -348,6 +370,9 @@ pub async fn validate_registry_integrity(pool: &SqlitePool) -> Result<RegistryIn | ||
| malformed_corpus_metadata_json, | ||
| duplicate_event_tlog_indexes, | ||
| negative_event_tlog_indexes, | ||
| + | events_without_tlog_index, | |
| + | event_tlog_indexes_out_of_range, | |
| + | tlog_size, | |
| malformed_manifest_json, | ||
| invalid_manifest_signatures, | ||
| mismatched_manifest_file_ids, | ||
| @@ -941,7 +966,7 @@ mod tests { | ||
| run_migrations(&pool).await.unwrap(); | ||
| seed_source(&pool).await; | ||
| - | let report = validate_registry_integrity(&pool).await.unwrap(); | |
| + | let report = validate_registry_integrity(&pool, None).await.unwrap(); | |
| assert!(report.ok); | ||
| assert_eq!(report.counts.manifests, 1); | ||
| assert_eq!(report.counts.beacons, 1); | ||
| @@ -951,6 +976,9 @@ mod tests { | ||
| assert_eq!(report.malformed_corpus_metadata_json, 0); | ||
| assert_eq!(report.duplicate_event_tlog_indexes, 0); | ||
| assert_eq!(report.negative_event_tlog_indexes, 0); | ||
| + | assert_eq!(report.events_without_tlog_index, 0); | |
| + | assert_eq!(report.event_tlog_indexes_out_of_range, 0); | |
| + | assert_eq!(report.tlog_size, None); | |
| pool.close().await; | ||
| let _ = std::fs::remove_dir_all(dir); | ||
| @@ -1015,6 +1043,22 @@ mod tests { | ||
| ) | ||
| .await | ||
| .unwrap(); | ||
| + | insert_event( | |
| + | &pool, | |
| + | "token-no-tlog", | |
| + | Some("file-1"), | |
| + | Some("recipient-1"), | |
| + | Some("issuer-1"), | |
| + | "dns", | |
| + | None, | |
| + | None, | |
| + | Some(r#"{"ok":true}"#), | |
| + | 23, | |
| + | None, | |
| + | None, | |
| + | ) | |
| + | .await | |
| + | .unwrap(); | |
| sqlx::query( | ||
| "INSERT INTO corpus (file_id, hash_kind, hash_value, metadata, registered_at) VALUES (?, ?, ?, ?, ?)", | ||
| ) | ||
| @@ -1027,7 +1071,7 @@ mod tests { | ||
| .await | ||
| .unwrap(); | ||
| - | let report = validate_registry_integrity(&pool).await.unwrap(); | |
| + | let report = validate_registry_integrity(&pool, Some(1)).await.unwrap(); | |
| assert!(!report.ok); | ||
| assert_eq!(report.orphan_beacons, 1); | ||
| assert_eq!(report.orphan_watermarks, 1); | ||
| @@ -1038,6 +1082,9 @@ mod tests { | ||
| assert_eq!(report.malformed_corpus_metadata_json, 1); | ||
| assert_eq!(report.duplicate_event_tlog_indexes, 1); | ||
| assert_eq!(report.negative_event_tlog_indexes, 1); | ||
| + | assert_eq!(report.events_without_tlog_index, 1); | |
| + | assert_eq!(report.event_tlog_indexes_out_of_range, 2); | |
| + | assert_eq!(report.tlog_size, Some(1)); | |
| pool.close().await; | ||
| let _ = std::fs::remove_dir_all(dir); |
| @@ -385,7 +385,9 @@ async fn main() -> anyhow::Result<()> { | ||
| } | ||
| if args.validate_db { | ||
| - | let report = db::validate_registry_integrity(&pool) | |
| + | let tlog = TransparencyLog::open(data_dir.join("tlog")) | |
| + | .map_err(|e| anyhow::anyhow!("tlog validation init: {e}"))?; | |
| + | let report = db::validate_registry_integrity(&pool, Some(tlog.size())) | |
| .await | ||
| .map_err(|e| anyhow::anyhow!("registry integrity validation failed: {e}"))?; | ||
| println!("{}", serde_json::to_string_pretty(&report)?); |
| @@ -89,7 +89,7 @@ async fn record_event( | ||
| .tlog | ||
| .append_event(&tlog_event) | ||
| .map(|idx| idx as i64) | ||
| - | .unwrap_or(-1); | |
| + | .map_err(|e| RegistryError::Internal(format!("tlog append failed: {e}")))?; | |
| let now = SystemTime::now() | ||
| .duration_since(UNIX_EPOCH) |
| @@ -53,7 +53,7 @@ pub async fn dns_event( | ||
| .tlog | ||
| .append_event(&tlog_event) | ||
| .map(|idx| idx as i64) | ||
| - | .unwrap_or(-1); | |
| + | .map_err(|e| RegistryError::Internal(format!("tlog append failed: {e}")))?; | |
| let now = SystemTime::now() | ||
| .duration_since(UNIX_EPOCH) |
| @@ -98,17 +98,7 @@ pub async fn register( | ||
| .unwrap_or_default() | ||
| .as_secs() as i64; | ||
| - | db::upsert_manifest( | |
| - | &state.db, | |
| - | file_id, | |
| - | recipient_id, | |
| - | issuer_id, | |
| - | &issuer_pub, | |
| - | &manifest_json, | |
| - | now, | |
| - | ) | |
| - | .await?; | |
| - | ||
| + | let mut beacon_rows = Vec::with_capacity(signed_beacons.len()); | |
| for beacon in &signed_beacons { | ||
| let token_id = beacon | ||
| .get("token_id") | ||
| @@ -128,18 +118,10 @@ pub async fn register( | ||
| "signed beacon has invalid kind".into(), | ||
| )); | ||
| } | ||
| - | db::upsert_beacon( | |
| - | &state.db, | |
| - | token_id, | |
| - | file_id, | |
| - | recipient_id, | |
| - | issuer_id, | |
| - | kind, | |
| - | now, | |
| - | ) | |
| - | .await?; | |
| + | beacon_rows.push((token_id, kind)); | |
| } | ||
| + | let mut watermark_rows = Vec::with_capacity(signed_watermarks.len()); | |
| for watermark in &signed_watermarks { | ||
| let mark_id = watermark | ||
| .get("mark_id") | ||
| @@ -159,16 +141,7 @@ pub async fn register( | ||
| "signed watermark has invalid layer".into(), | ||
| )); | ||
| } | ||
| - | db::upsert_watermark( | |
| - | &state.db, | |
| - | mark_id, | |
| - | layer, | |
| - | file_id, | |
| - | recipient_id, | |
| - | issuer_id, | |
| - | now, | |
| - | ) | |
| - | .await?; | |
| + | watermark_rows.push((mark_id, layer)); | |
| } | ||
| if let Some(ref corpus) = req.corpus { | ||
| @@ -178,13 +151,6 @@ pub async fn register( | ||
| MAX_CORPUS_ENTRIES | ||
| ))); | ||
| } | ||
| - | for (hash_kind, hash_value) in corpus { | |
| - | if let Some(hv) = hash_value.as_str() { | |
| - | if !hv.is_empty() && hash_kind.len() <= MAX_ID_LEN && hv.len() <= MAX_ID_LEN { | |
| - | db::upsert_corpus(&state.db, file_id, hash_kind, hv, now).await?; | |
| - | } | |
| - | } | |
| - | } | |
| } | ||
| let timestamp_str = crate::timestamp_stub(); | ||
| @@ -202,7 +168,54 @@ pub async fn register( | ||
| .tlog | ||
| .append_event(&tlog_event) | ||
| .map(|idx| idx as i64) | ||
| - | .unwrap_or(-1); | |
| + | .map_err(|e| RegistryError::Internal(format!("tlog append failed: {e}")))?; | |
| + | ||
| + | db::upsert_manifest( | |
| + | &state.db, | |
| + | file_id, | |
| + | recipient_id, | |
| + | issuer_id, | |
| + | &issuer_pub, | |
| + | &manifest_json, | |
| + | now, | |
| + | ) | |
| + | .await?; | |
| + | ||
| + | for (token_id, kind) in beacon_rows { | |
| + | db::upsert_beacon( | |
| + | &state.db, | |
| + | token_id, | |
| + | file_id, | |
| + | recipient_id, | |
| + | issuer_id, | |
| + | kind, | |
| + | now, | |
| + | ) | |
| + | .await?; | |
| + | } | |
| + | ||
| + | for (mark_id, layer) in watermark_rows { | |
| + | db::upsert_watermark( | |
| + | &state.db, | |
| + | mark_id, | |
| + | layer, | |
| + | file_id, | |
| + | recipient_id, | |
| + | issuer_id, | |
| + | now, | |
| + | ) | |
| + | .await?; | |
| + | } | |
| + | ||
| + | if let Some(ref corpus) = req.corpus { | |
| + | for (hash_kind, hash_value) in corpus { | |
| + | if let Some(hv) = hash_value.as_str() { | |
| + | if !hv.is_empty() && hash_kind.len() <= MAX_ID_LEN && hv.len() <= MAX_ID_LEN { | |
| + | db::upsert_corpus(&state.db, file_id, hash_kind, hv, now).await?; | |
| + | } | |
| + | } | |
| + | } | |
| + | } | |
| let rekor_result = if state.rekor_enabled { | ||
| attest_to_rekor( |