Commit 5f9abdb
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
- src
- intercept
- tier1
- tests
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
7 | | - | |
8 | | - | |
9 | | - | |
10 | | - | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
0 commit comments