Skip to content

Commit 5f9abdb

Browse files
feat(tier1,intercept): SQLite Tier 1 e2e — provenance writer + update_hook (V-L1-C1) (#100)
Closes #46. End-to-end SQLite Tier 1: target SQLite → `sqlite3_update_hook` → provenance sidecar (separate SQLite file) → verifiable hash chain. ### `tier1::provenance` rewrite The module previously held only the `ProvenanceRecord` struct with a deprecated string-based hash. This commit makes it the canonical home for the Provenance concern's SQLite backend: * **`SIDECAR_DDL`** — schema text for both `verisimdb_provenance_log` (the append-only entries table) and the new `verisimdb_provenance_chain_head` table (per-entity tip-of-chain pointer used to look up `previous_hash` in O(1) per append, without scanning the log). * **`init_sidecar_schema(conn)`** — idempotent DDL applier. * **`append_provenance(conn, entity_id, table_name, op, actor, before, transformation) -> Result<hash>`** — opens a `BEGIN IMMEDIATE` transaction, reads the chain head (or empty for genesis), computes the canonical domain-tagged hash via `abi::ProvenanceEntry::compute_hash` (#27 / V-L2-C1), inserts the log row, updates the chain head, commits. Returns the new hash. * **`verify_chain(conn, entity_id)`** — walks the log in timestamp order, recomputing each entry's hash and checking the `previous_hash` links. Returns `Ok(true)` iff every link is intact. * The legacy `ProvenanceRecord::compute_hash` is preserved as a shim that forwards to `abi::ProvenanceEntry::compute_hash` so any external callers see no behaviour change. ### `intercept::sqlite::SqliteInterceptor` New module wiring `sqlite3_update_hook` (via rusqlite's `Connection::update_hook`) on a target connection. Each INSERT, UPDATE, and DELETE on the target produces a `Decl::Extern`-style record in the sidecar: * Constructor: `SqliteInterceptor::new(sidecar, actor)`. * Optional `.with_resolver(...)` to override the default rowid-stringifying entity-id resolver — production usage typically routes rowid through a `SELECT` to fetch a logical PK column. * `.install(&target)` registers the update_hook closure; the hook is `FnMut + Send + 'static` (rusqlite's bound) and shares the sidecar via `Arc<Mutex<Connection>>`. The hook NEVER writes back to the target — that's the V-L1-C1 isolation invariant. An integration test enforces it directly (`target_database_is_not_modified_by_the_hook`). ### Cargo dependencies * `rusqlite` gains the `hooks` feature flag (required for `update_hook`). * `proptest` added to `[dev-dependencies]` for the property-style tests to come in phase 2. ### Tests 10 new tests: * 6 unit tests in `tier1::provenance::tests`: - `schema_is_idempotent` - `genesis_entry_chains_from_empty` - `sequential_appends_chain_correctly` - `verify_chain_detects_tampered_hash` - `verify_chain_detects_broken_chain_link` - `distinct_entities_have_independent_chains` * 4 unit tests in `intercept::sqlite::tests`: - `target_insert_produces_sidecar_provenance_entry` - `update_and_delete_produce_chained_entries` - `target_database_is_not_modified_by_the_hook` (the isolation invariant) - `custom_resolver_overrides_rowid_default` * 2 integration tests in `tests/sqlite_intercept_e2e.rs` (tempfile-backed, so the real on-disk path is exercised): - `e2e_mixed_workload_verifies_all_chains` — 5 accounts × 5 ops each (insert / 3 updates / delete), every chain verifies, the entry count matches the workload, the target has no leaked `verisimdb_*` tables. - `e2e_chain_survives_reopen_of_sidecar` — drop the interceptor + reopen the sidecar file from a fresh Connection; chain still verifies and the chain-head table still points at the latest entry. `cargo test --workspace` → 87 passed, 0 failed. ### Pre-existing test fix `tests/integration_test.rs` referenced 5 table names from before the `verisim_*` → `verisimdb_*` migration. Renamed in-place so the file runs green again. Unrelated to V-L1-C1 in spirit, but the failures blocked the suite — folded in here rather than carried as a separate trivial PR. ### Out of scope * **Multi-threaded property test** (the issue's "N threads × M updates" line item) — the integration test exercises the e2e path with a non-trivial mixed workload, but doesn't actually concurrent-spawn. A follow-up can wire proptest + std::thread once the sidecar's `Arc<Mutex<Connection>>` access pattern is verified safe under contention. Tracked separately. * **Logical PK resolution** beyond the rowid default — the `EntityIdResolver` plumbing is in place but no production resolver ships in this PR. * **before_snapshot capture** — the update_hook fires after the row mutation, so reading the "before" state requires either a preupdate_hook (rusqlite has a `preupdate_hook` feature) or caching reads. Filed for V-L1-C2 (#47), which the temporal versioning writer needs anyway. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fdcd5a0 commit 5f9abdb

7 files changed

Lines changed: 1011 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 147 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ anyhow = "1"
2121
thiserror = "2"
2222
chrono = { version = "0.4", features = ["serde"] }
2323
sha2 = "0.10"
24-
rusqlite = { version = "0.32", features = ["bundled"] }
24+
rusqlite = { version = "0.32", features = ["bundled", "hooks"] }
2525

2626
[build-dependencies]
2727
chrono = "0.4"
2828

2929
[dev-dependencies]
3030
tempfile = "3"
31+
proptest = "1"

src/intercept/mod.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
//
44
// Database interception layer.
55
// Configurable per target database:
6-
// PostgreSQL: logical replication / pg_notify / triggers
7-
// MySQL: binlog CDC / triggers
8-
// SQLite: sqlite3_update_hook / WAL monitoring
9-
// MongoDB: change streams
10-
// App-level: middleware / ORM hooks
6+
// PostgreSQL: logical replication / pg_notify / triggers (TODO)
7+
// MySQL: binlog CDC / triggers (TODO)
8+
// SQLite: sqlite3_update_hook / WAL monitoring (V-L1-C1, this module)
9+
// MongoDB: change streams (TODO)
10+
// App-level: middleware / ORM hooks (TODO)
1111

12-
// TODO: implement per-database interception strategies
12+
pub mod sqlite;

0 commit comments

Comments
 (0)