diff --git a/README.md b/README.md index 43a1f5f1..7023ee98 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,7 @@ milestone. | **Every Ubuntu LTS validated** โ€” 22.04, 24.04 and 26.04 all at 79/79, each with a replay twin that reproduces it | โœ… | | Telegram approval interface | ๐Ÿ“‹ roadmap | -**1,841 Rust tests and 72 frontend tests** form the current deterministic +**1,843 Rust tests and 72 frontend tests** form the current deterministic release baseline. ## Configure your LLM diff --git a/crates/sysknife-daemon/src/store/postgres.rs b/crates/sysknife-daemon/src/store/postgres.rs index a6540a75..8e252fbe 100644 --- a/crates/sysknife-daemon/src/store/postgres.rs +++ b/crates/sysknife-daemon/src/store/postgres.rs @@ -377,6 +377,47 @@ impl PostgresStore { tx.commit().await.map_err(map_sqlx_err) } + + async fn revoke_unconsumed_approval_in_tx( + tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>, + key: &AuditKey, + transaction_id: &str, + ) -> Result { + // Read the digest before the DELETE: the event names which receipt was + // retracted, and after the delete there is nothing left to name. + let digest: Option = sqlx_core::query_scalar::query_scalar( + "SELECT receipt_digest FROM transaction_approvals \ + WHERE transaction_id = $1 AND consumed_at IS NULL", + ) + .bind(transaction_id) + .fetch_optional(&mut **tx) + .await + .map_err(map_sqlx_err)?; + let result = sqlx_core::query::query( + "DELETE FROM transaction_approvals \ + WHERE transaction_id = $1 AND consumed_at IS NULL", + ) + .bind(transaction_id) + .execute(&mut **tx) + .await + .map_err(map_sqlx_err)?; + if result.rows_affected() > 0 { + let digest = digest.ok_or_else(|| { + TransactionStoreError::DatabaseInvariant(format!( + "revoked an approval for {transaction_id} that had no receipt digest" + )) + })?; + append_event( + tx, + key, + AuditEventKind::ApprovalRevoked, + transaction_id, + &digest, + ) + .await?; + } + Ok(result.rows_affected() > 0) + } } // --------------------------------------------------------------------------- @@ -615,41 +656,11 @@ impl AuditStore for PostgresStore { transaction_id: &str, ) -> Result { let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?; - // Read the digest before the DELETE: the event names which receipt was - // retracted, and after the delete there is nothing left to name. - let digest: Option = sqlx_core::query_scalar::query_scalar( - "SELECT receipt_digest FROM transaction_approvals \ - WHERE transaction_id = $1 AND consumed_at IS NULL", - ) - .bind(transaction_id) - .fetch_optional(&mut *tx) - .await - .map_err(map_sqlx_err)?; - let result = sqlx_core::query::query( - "DELETE FROM transaction_approvals \ - WHERE transaction_id = $1 AND consumed_at IS NULL", - ) - .bind(transaction_id) - .execute(&mut *tx) - .await - .map_err(map_sqlx_err)?; - if result.rows_affected() > 0 { - let digest = digest.ok_or_else(|| { - TransactionStoreError::DatabaseInvariant(format!( - "revoked an approval for {transaction_id} that had no receipt digest" - )) - })?; - append_event( - &mut tx, - &self.audit_key, - AuditEventKind::ApprovalRevoked, - transaction_id, - &digest, - ) - .await?; - } + let revoked = + Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, transaction_id) + .await?; tx.commit().await.map_err(map_sqlx_err)?; - Ok(result.rows_affected() > 0) + Ok(revoked) } async fn claim_approved_for_execution( @@ -707,19 +718,38 @@ impl AuditStore for PostgresStore { async fn cleanup_stale_queued(&self) -> Result { let queued = serialize(&JobState::Queued)?; let canceled = serialize(&JobState::Canceled)?; - let result = sqlx_core::query::query( + let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?; + let stale_ids: Vec = sqlx_core::query_scalar::query_scalar( sqlx_core::sql_str::AssertSqlSafe(format!( - "UPDATE transactions SET status = $1 \ - WHERE status = $2 \ + "SELECT transaction_id FROM transactions \ + WHERE status = $1 \ AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'" )), ) - .bind(&canceled) .bind(&queued) - .execute(&self.pool) + .fetch_all(&mut *tx) .await .map_err(map_sqlx_err)?; - Ok(result.rows_affected()) + let mut canceled_count = 0; + for transaction_id in stale_ids { + let result = sqlx_core::query::query( + "UPDATE transactions SET status = $1 \ + WHERE transaction_id = $2 AND status = $3", + ) + .bind(&canceled) + .bind(&transaction_id) + .bind(&queued) + .execute(&mut *tx) + .await + .map_err(map_sqlx_err)?; + if result.rows_affected() > 0 { + Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, &transaction_id) + .await?; + canceled_count += result.rows_affected(); + } + } + tx.commit().await.map_err(map_sqlx_err)?; + Ok(canceled_count) } async fn cancel_queued(&self, transaction_id: &str) -> Result { @@ -727,6 +757,7 @@ impl AuditStore for PostgresStore { // transaction is never cancelled. let queued = serialize(&JobState::Queued)?; let canceled = serialize(&JobState::Canceled)?; + let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?; let result = sqlx_core::query::query( "UPDATE transactions SET status = $1 \ WHERE transaction_id = $2 AND status = $3", @@ -734,9 +765,14 @@ impl AuditStore for PostgresStore { .bind(&canceled) .bind(transaction_id) .bind(&queued) - .execute(&self.pool) + .execute(&mut *tx) .await .map_err(map_sqlx_err)?; + if result.rows_affected() > 0 { + Self::revoke_unconsumed_approval_in_tx(&mut tx, &self.audit_key, transaction_id) + .await?; + } + tx.commit().await.map_err(map_sqlx_err)?; Ok(result.rows_affected() > 0) } diff --git a/crates/sysknife-daemon/src/transactions.rs b/crates/sysknife-daemon/src/transactions.rs index 32364d90..ddf606db 100644 --- a/crates/sysknife-daemon/src/transactions.rs +++ b/crates/sysknife-daemon/src/transactions.rs @@ -616,16 +616,26 @@ impl TransactionStore { ))?; let mut conn = self.connection()?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let revoked = Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?; + tx.commit()?; + Ok(revoked) + } + + fn revoke_unconsumed_approval_in_tx( + conn: &Connection, + key: &AuditKey, + transaction_id: &str, + ) -> Result { // Capture the digest before the DELETE: the event has to name which // receipt was retracted, and after the delete there is nothing to name. let digest: Option = query_optional( - &tx, + conn, "SELECT receipt_digest FROM transaction_approvals \ WHERE transaction_id = ?1 AND consumed_at IS NULL", params![transaction_id], |row| row.get(0), )?; - let rows_affected = tx.execute( + let rows_affected = conn.execute( "DELETE FROM transaction_approvals \ WHERE transaction_id = ?1 AND consumed_at IS NULL", params![transaction_id], @@ -637,14 +647,13 @@ impl TransactionStore { )) })?; Self::append_event( - &tx, + conn, key, AuditEventKind::ApprovalRevoked, transaction_id, &digest, )?; } - tx.commit()?; Ok(rows_affected > 0) } @@ -713,18 +722,41 @@ impl TransactionStore { /// `cleanup_stale_queued_does_not_clobber_running_rows` regression test /// in `tests/coverage_gaps.rs` pins this guarantee. pub fn cleanup_stale_queued(&self) -> Result { - let conn = self.connection()?; + let key = self + .audit_key + .as_ref() + .ok_or(TransactionStoreError::AuditChainMissing( + "this TransactionStore was opened read-only; cannot clean up", + ))?; + let mut conn = self.connection()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let canceled_json = serialize_field(&JobState::Canceled)?; let queued_json = serialize_field(&JobState::Queued)?; - let rows_affected = conn.execute( - &format!( - "UPDATE transactions SET status = ?1 \ - WHERE status = ?2 \ + let stale_ids: Vec = { + let mut statement = tx.prepare(&format!( + "SELECT transaction_id FROM transactions \ + WHERE status = ?1 \ AND julianday(created_at) <= julianday('now', '-{APPROVAL_RECEIPT_TTL_MINUTES} minutes')" - ), - params![canceled_json, queued_json], - )?; - Ok(rows_affected as u64) + ))?; + let stale_ids = statement + .query_map(params![queued_json], |row| row.get(0))? + .collect::>()?; + stale_ids + }; + let mut canceled = 0; + for transaction_id in stale_ids { + let rows_affected = tx.execute( + "UPDATE transactions SET status = ?1 \ + WHERE transaction_id = ?2 AND status = ?3", + params![canceled_json, transaction_id, queued_json], + )?; + if rows_affected > 0 { + Self::revoke_unconsumed_approval_in_tx(&tx, key, &transaction_id)?; + canceled += rows_affected; + } + } + tx.commit()?; + Ok(canceled as u64) } /// Cancel one still-`Queued` transaction (`Queued โ†’ Canceled`). Returns @@ -736,14 +768,25 @@ impl TransactionStore { /// a `Canceled` record. Missing or already-terminal transactions return /// `false`. pub fn cancel_queued(&self, transaction_id: &str) -> Result { - let conn = self.connection()?; + let key = self + .audit_key + .as_ref() + .ok_or(TransactionStoreError::AuditChainMissing( + "this TransactionStore was opened read-only; cannot cancel", + ))?; + let mut conn = self.connection()?; + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let canceled_json = serialize_field(&JobState::Canceled)?; let queued_json = serialize_field(&JobState::Queued)?; - let rows_affected = conn.execute( + let rows_affected = tx.execute( "UPDATE transactions SET status = ?1 \ WHERE transaction_id = ?2 AND status = ?3", params![canceled_json, transaction_id, queued_json], )?; + if rows_affected > 0 { + Self::revoke_unconsumed_approval_in_tx(&tx, key, transaction_id)?; + } + tx.commit()?; Ok(rows_affected > 0) } @@ -2436,6 +2479,43 @@ mod tests { !store.cancel_queued("no-such-transaction").unwrap(), "a missing transaction is not cancelable" ); + assert!( + store.fetch_event_rows().unwrap().is_empty(), + "canceling an unapproved transaction must not append an approval event" + ); + } + + #[test] + fn cancel_queued_revokes_an_unconsumed_approval_and_appends_event() { + let dir = tempdir().unwrap(); + let store = test_store(dir.path().join("tx.db")); + let tx = store.record(queued_transaction()).unwrap(); + let receipt = store + .approve_transaction(&tx.transaction_id) + .unwrap() + .expect("queued transaction is approvable"); + let digest = audit_chain::approval_receipt_digest(&receipt); + + assert!(store.cancel_queued(&tx.transaction_id).unwrap()); + assert_eq!( + store.get(&tx.transaction_id).unwrap().unwrap().status, + JobState::Canceled + ); + assert!( + !store + .claim_approved_for_execution(&tx.transaction_id, &digest) + .unwrap(), + "canceling must revoke the unconsumed receipt" + ); + assert_eq!( + store + .fetch_event_rows() + .unwrap() + .iter() + .map(|event| event.kind.as_str()) + .collect::>(), + vec!["approval_granted", "approval_revoked"] + ); } #[test] @@ -2652,6 +2732,64 @@ mod tests { assert_eq!(fresh_record.status, JobState::Queued); } + #[test] + fn cleanup_stale_queued_revokes_every_unconsumed_approval() { + let dir = tempdir().unwrap(); + let store = test_store(dir.path().join("tx.db")); + let transactions: Vec<_> = (0..3) + .map(|_| { + let transaction = store.record(queued_transaction()).unwrap(); + store + .approve_transaction(&transaction.transaction_id) + .unwrap() + .expect("stale transaction is approvable"); + transaction + }) + .collect(); + + let conn = store.connection().unwrap(); + for transaction in &transactions { + conn.execute( + "UPDATE transactions \ + SET created_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-20 minutes') \ + WHERE transaction_id = ?1", + params![transaction.transaction_id], + ) + .unwrap(); + } + + assert_eq!(store.cleanup_stale_queued().unwrap(), 3); + for transaction in &transactions { + assert_eq!( + store + .get(&transaction.transaction_id) + .unwrap() + .unwrap() + .status, + JobState::Canceled + ); + assert!(!store + .revoke_unconsumed_approval(&transaction.transaction_id) + .unwrap()); + } + assert_eq!( + store + .fetch_event_rows() + .unwrap() + .iter() + .map(|event| event.kind.as_str()) + .collect::>(), + vec![ + "approval_granted", + "approval_granted", + "approval_granted", + "approval_revoked", + "approval_revoked", + "approval_revoked" + ] + ); + } + // โ”€โ”€ State-machine validation tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ #[test] diff --git a/crates/sysknife-daemon/tests/postgres_store.rs b/crates/sysknife-daemon/tests/postgres_store.rs index 87ea9337..21443b78 100644 --- a/crates/sysknife-daemon/tests/postgres_store.rs +++ b/crates/sysknife-daemon/tests/postgres_store.rs @@ -376,6 +376,43 @@ async fn migrates_legacy_schema_and_enforces_store_contract() { .status, JobState::Canceled ); + assert_eq!( + store.fetch_event_rows().await.expect("fetch events").len(), + 2, + "canceling an unapproved transaction must not append an event" + ); + + let approved = store + .record(new_transaction()) + .await + .expect("record approved"); + let receipt = store + .approve_transaction(&approved.transaction_id) + .await + .expect("approve fresh transaction") + .expect("fresh transaction is approvable"); + assert!(store + .cancel_queued(&approved.transaction_id) + .await + .expect("cancel approved transaction")); + let receipt_digest = sysknife_daemon::audit_chain::approval_receipt_digest(&receipt); + assert!(!store + .claim_approved_for_execution(&approved.transaction_id, &receipt_digest) + .await + .expect("revoked receipt must not execute")); + let events = store.fetch_event_rows().await.expect("fetch events"); + assert_eq!( + events + .iter() + .map(|event| event.kind.as_str()) + .collect::>(), + vec![ + "approval_granted", + "approval_consumed", + "approval_granted", + "approval_revoked" + ] + ); let _reconnected = PostgresStore::connect(&config, Arc::clone(&key)) .await diff --git a/docs/distro-support.md b/docs/distro-support.md index 6abaf99c..e2db1db2 100644 --- a/docs/distro-support.md +++ b/docs/distro-support.md @@ -82,7 +82,7 @@ family and the atomic story family are implemented and covered by the workspace suite. What is missing is a way to put the helpers somewhere the daemon's own grants already point. -The deterministic workspace baseline is 1,841 Rust tests plus 72 frontend +The deterministic workspace baseline is 1,843 Rust tests plus 72 frontend tests. Those tests verify action construction, policy, approval, storage, and UI behavior, but they do not replace a real distribution VM run. diff --git a/docs/introduction.md b/docs/introduction.md index 7edcd97f..b7106963 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -141,7 +141,7 @@ flow. ## Status -190 typed actions ยท 1,841 Rust tests + 72 frontend tests ยท MIT +190 typed actions ยท 1,843 Rust tests + 72 frontend tests ยท MIT SysKnife is the reference implementation of the [LACS specification](https://github.com/lacs-project/specification) โ€” a diff --git a/tests/evidence/workspace-tests.json b/tests/evidence/workspace-tests.json index 05bdebc8..710c27da 100644 --- a/tests/evidence/workspace-tests.json +++ b/tests/evidence/workspace-tests.json @@ -5,13 +5,13 @@ }, "commit": { "frontend_tests": "656b399035ee5fc81bd949e871daee65b4a1f3c4", - "tests": "f3de71615cb8436372b18e4faed78e626263b956" + "tests": "019bf4761addcdf265ba9e5df55920a0202f38bd" }, "frontend_tests": 72, "measured_at": { "frontend_tests": "2026-08-05T09:23:15-06:00", - "tests": "2026-09-07T12:31:15-06:00" + "tests": "2026-09-07T14:09:57-06:00" }, - "tests": 1841, + "tests": 1843, "version": 1 }