test(hooks): cross-backend adversarial parity suite + A3 closeout (durable backend PR 4/4) - #3937
Conversation
Implements the durable PostgreSQL backend for predicate sliding-window state, satisfying the `PredicateStateBackend` contract widened to a public async trait in PR #3927. New crate `ironclaw_hooks_postgres` (out-of-crate durable backend, the pattern the `contract-tests` feature on `ironclaw_hooks` was designed for; mirrors `ironclaw_reborn_event_store`). Keeps the Postgres dependency surface out of the hook framework itself. Correctness: - Atomic record-and-read: each `record_*` runs in one READ COMMITTED tx guarded by a transaction-scoped advisory lock on the bucket. The advisory lock (not REPEATABLE READ) serializes same-key writers by BLOCKING the second writer rather than aborting it with a serialization failure — no caller retry loop. Trim, insert, cap-evict, aggregate all share the one tx. - Cross-host replay dedup via PRIMARY KEY (key_hash, id) + INSERT ON CONFLICT DO NOTHING — exact regardless of clock skew. - Running-sum consistency under eviction: cap eviction re-aggregates inside the same tx so the returned sum reflects dropped rows. - Per-key sample cap (MAX_SAMPLES_PER_KEY, drop-oldest) and per-scope distinct-key LRU quota (MAX_KEYS_PER_TENANT) match the in-memory backend. Scope-quota enforcement takes a second advisory lock in the disjoint (int8) lock space so concurrent new-key inserts in a scope don't under-evict. Schema: single `hook_predicate_counters` table, BYTEA blake3 hash keys, TEXT id column (NOT uuid — Postgres uuid rejects the 64-char blake3 hex digest; resolves the #3635 docs/schema contradiction in favor of TEXT). Idempotent CREATE ... IF NOT EXISTS applied via run_migrations(); the per-crate pattern, not the legacy main-binary refinery migrations. DB-clock decision: window comparison basis is the caller's `now` (the same Utc::now() the in-memory backend trusts), making this a drop-in under the deterministic-clock contract harness. Replay dedup and atomicity do NOT depend on clock agreement. Tests (env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL, skip when absent; schema-isolated so the two test binaries can share one DB): - All 8 shared contract functions via the `contract-tests` harness. - Adversarial: two-host write storm (no count desync), cross-host replay (count + value), per-key cap flood, per-scope LRU under concurrent insert pressure across two hosts. - 4 hashing unit tests (injective length-prefixed serialization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…d PR 3/4) Durable libSQL-backed PredicateStateBackend satisfying the trait widened in #3927, with the same invariants as the in-memory backend: MAX_SAMPLES_PER_KEY per-key cap, MAX_HISTORY_KEYS / MAX_KEYS_PER_TENANT LRU, and replay-dedup. Crate move (vs the original #3930): the backend now lives in its own crate `ironclaw_hooks_libsql` rather than as an optional `libsql` feature on the framework crate. This keeps DB deps out of `ironclaw_hooks`, matching the intended `ironclaw_hooks_postgres` pattern (backend crate -> framework crate; no inversion). Structure: src/{lib,backend,hashing, schema}.rs. The `libsql` feature + dep and the `predicate_state::libsql` module were removed from `ironclaw_hooks`; the unused `tempfile` dev-dep was dropped too. `ironclaw_hooks` now builds with no libsql feature at all. Fail-closed alignment (#3929, now the public contract): the per-key cap returns PredicateBackendError::WindowOverflow when a scope is at MAX_SAMPLES_PER_KEY and a NEW distinct id arrives, instead of the prior drop-oldest. Dedup short-circuits BEFORE the overflow check, so a replay at the cap is a no-op (replay refusal survives the cap boundary). This matches the in-memory backend exactly and passes the record_invocation_overflow_is_fail_closed contract from #3927. - Atomic record-and-read via a single BEGIN IMMEDIATE transaction per record_* call so concurrent writers serialise on SQLite's write lock. - Value-sum recomputed from surviving rows inside the same transaction. - Clock basis: stores host-supplied now: DateTime<Utc> as epoch millis. - Replay-dedup via INSERT ... ON CONFLICT (scope_hash, event_id) DO NOTHING; event_id is TEXT (64-char blake3 hex, Codex #3635 finding). Test plan: shared contract harness (all 9 contracts incl. fail-closed) plus adversarial tests (two-host concurrent writes, cross-host replay dedup invocation+value, per-key cap fail-closed invocation+value, per-tenant LRU under bounded concurrent pressure, restart survival). The suite runs with harness=false and a small serial runner: each heavy case fills a key to MAX_SAMPLES_PER_KEY (4096 connect/BEGIN IMMEDIATE/ COMMIT cycles), and running several concurrently against the replication-enabled libSQL build intermittently trips SQLITE_MISUSE. Serial execution (concurrency cases get their own multi-thread runtime) makes it deterministic; the per-tenant flood keeps its bounded semaphore. 16 cases, all green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…into hooks-predicate-backend-parity-suite # Conflicts: # Cargo.lock # Cargo.toml
…rable backend PR 4/4) Add `ironclaw_hooks_parity`, a test-only crate that feeds ONE deterministic scripted sequence of record_invocation/record_value calls to all three PredicateStateBackend implementations (in-memory, Postgres, libSQL) and cross-asserts identical observation logs — proving the backends are behaviorally interchangeable. Parity matrix (tests/parity_matrix.rs), always runs in-memory + libSQL, Postgres compiled under --features postgres and run with a DB URL: - core behavioral script: counts, sums (incl. fractional), window trim, exact-cutoff retain, per-key replay dedup, tenant isolation, cross-map dedup isolation - fail-closed cap script: WindowOverflow at MAX_SAMPLES_PER_KEY, replay no-op at the cap - per-tenant LRU script: same eviction victim + evictions_observed() across backends Multi-host adversarial (tests/multi_host_adversarial.rs, --features integration): N concurrent writers/2 hosts no desync, cross-host replay exactly-once, LRU eviction race holds quota, per-key cap fail-closed under flood, clock-skew follows caller-supplied DateTime<Utc> basis. libSQL legs run unconditionally (embedded temp-file db); Postgres legs env-gated. Documents the final landed shape and closes the A3 multi-host-replay-bypass deferral from #3635 in 03-persistent-counter.md. Merges origin/hooks-predicate-backend-postgres (#3933) and origin/hooks-predicate-backend-libsql (#3936) into one tree so all three backends are available together. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4dd8434dc6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| SELECT key_hash, MAX(ts) AS last_ts | ||
| FROM hook_predicate_counters | ||
| WHERE scope_hash = $1 AND kind = $2 | ||
| AND key_hash <> $3 | ||
| GROUP BY key_hash |
There was a problem hiding this comment.
Evict keys by oldest front timestamp, not newest activity
The eviction query ranks victims by MAX(ts) (ORDER BY last_ts ASC), which picks the key whose most recent sample is oldest. The in-memory and libSQL backends evict by the oldest retained sample (min/front timestamp), so a key with an old front but a recent write can survive in Postgres while being evicted elsewhere. When quotas are hit, this changes which key is dropped and can produce different predicate outcomes across backends for the same event sequence.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already fixed (commit 0c102a6). The scope-LRU eviction ranks victims by MIN(occurred_at) (oldest retained sample / front), ORDER BY oldest_ts ASC — not MAX(ts) — matching the in-memory backend's entries.front() + min_by_key and the libSQL backend. Confirmed: no MAX(ts) eviction remains in the Postgres backend, and the multi-sample LRU parity test exercises it.
| if distinct as usize <= MAX_KEYS_PER_TENANT { | ||
| return Ok(0); | ||
| } | ||
| let to_evict = distinct as usize - MAX_KEYS_PER_TENANT; |
There was a problem hiding this comment.
Enforce global key cap across tenants in Postgres backend
This quota check only enforces MAX_KEYS_PER_TENANT within one scope_hash (tenant) and never applies the map-wide MAX_HISTORY_KEYS cap that the in-memory and libSQL implementations enforce. With many tenants each under their per-tenant cap, Postgres can retain far more keys than the contract expects, causing cross-backend divergence and unbounded growth until a separate reaper runs.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Resolved as a deliberate design decision rather than adding the cap to Postgres: the global MAX_HISTORY_KEYS cap is an in-memory-only memory-footprint bound (threat-model D5). Durable backends are disk-backed, not memory-bound, so they intentionally do NOT enforce it inline — they reap via time-based evict_older_than plus the per-tenant MAX_KEYS_PER_TENANT quota. libSQL was aligned to Postgres (its earlier global-cap enforcement was removed) so the two durable backends match; the divergence is now in-memory-vs-durable by design, documented at ironclaw_hooks_libsql backend.rs and predicate_state.rs.
There was a problem hiding this comment.
Code Review
This pull request introduces durable database-backed implementations for the PredicateStateBackend trait, adding the ironclaw_hooks_libsql and ironclaw_hooks_postgres crates along with a comprehensive parity testing suite. Feedback identifies a logic error in the Postgres window cutoff calculation that could lead to incorrect entry trimming for large windows. Additionally, improvements are suggested for database index coverage to optimize LRU eviction performance, the use of more idiomatic libSQL execution methods, and the standardization of length prefixes to u64 for binary encodings.
| fn cutoff(now: DateTime<Utc>, window: Duration) -> DateTime<Utc> { | ||
| match chrono::Duration::from_std(window) { | ||
| Ok(d) => now.checked_sub_signed(d).unwrap_or(now), | ||
| Err(_) => now, | ||
| } | ||
| } |
There was a problem hiding this comment.
The current cutoff logic is incorrect for extremely large windows that cause an underflow when subtracted from now. If chrono::Duration::from_std fails or checked_sub_signed returns None, the function currently returns now, which would cause the backend to trim all past entries. For a conservative rate/value cap, a large window should result in a cutoff far in the past so that nothing is trimmed.
| fn cutoff(now: DateTime<Utc>, window: Duration) -> DateTime<Utc> { | |
| match chrono::Duration::from_std(window) { | |
| Ok(d) => now.checked_sub_signed(d).unwrap_or(now), | |
| Err(_) => now, | |
| } | |
| } | |
| fn cutoff(now: DateTime<Utc>, window: Duration) -> DateTime<Utc> { | |
| let d = chrono::Duration::from_std(window).ok(); | |
| d.and_then(|d| now.checked_sub_signed(d)).unwrap_or_else(|| { | |
| // For extremely large windows, return a timestamp far in the past | |
| // to ensure nothing is trimmed (conservative). | |
| DateTime::<Utc>::from_timestamp(0, 0).unwrap() | |
| }) | |
| } |
There was a problem hiding this comment.
Fixed in 8bd9419. The Postgres cutoff no longer reimplements the Duration→cutoff math; it delegates to the canonical ironclaw_hooks::predicate_state::window_cutoff (the same function libSQL uses), which saturates to now for windows beyond chrono's range and on checked_sub_signed underflow. Byte-for-byte identical across backends, no drift surface.
| conn.query("PRAGMA busy_timeout = 5000", ()) | ||
| .await | ||
| .map_err(map_err)?; |
There was a problem hiding this comment.
The PRAGMA busy_timeout statement does not return any rows. Using Connection::execute is more appropriate and idiomatic than Connection::query for this operation. Additionally, consider making the timeout value (5000ms) a named constant to improve maintainability.
conn.execute("PRAGMA busy_timeout = 5000", ())
.await
.map_err(map_err)?;There was a problem hiding this comment.
PRAGMA busy_timeout = N returns a single row (the applied value), so Connection::query is valid here — execute would discard that row but isn't more correct. The 5000ms is documented inline as the cross-process serialisation knob. Leaving as-is; happy to switch to execute if you'd prefer the idiom, but it's not a correctness issue.
| let len = u32::try_from(field.len()).unwrap_or(u32::MAX); | ||
| hasher.update(&len.to_be_bytes()); | ||
| hasher.update(field); |
There was a problem hiding this comment.
Per the general rules for this repository, length prefixes in binary encodings should prefer u64 to ensure conversions from usize are infallible on all supported platforms and to maintain consistency across backend implementations (e.g., the libSQL backend already uses u64).
References
- Prefer using u64 for length prefixes in binary encodings to ensure that conversions from usize are infallible on all supported platforms, avoiding panics and potential DoS vectors caused by .expect() on overflow.
There was a problem hiding this comment.
Fixed in 8bd9419. The hashing was extracted to the shared ironclaw_hooks::predicate_hash module, which uses an 8-byte big-endian u64 length prefix — infallible from usize, removing the lossy u32::try_from(len).unwrap_or(u32::MAX) saturation. Both backends now delegate to it.
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | ||
| ON hook_predicate_counters (scope_hash, kind); |
There was a problem hiding this comment.
The hook_predicate_counters_scope_idx index currently only covers (scope_hash, kind). The enforce_scope_quota logic performs a COUNT(DISTINCT key_hash) and an LRU victim selection using MAX(ts) grouped by key_hash. With up to 8.4M rows per tenant (2048 keys * 4096 samples), these operations will be very slow. Expanding this index to include key_hash and ts would allow for much more efficient index-only scans.
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | |
| ON hook_predicate_counters (scope_hash, kind); | |
| -- Per-scope (tenant) distinct-key LRU eviction scans by scope. | |
| -- Including key_hash and ts allows index-only scans for COUNT(DISTINCT) and LRU victim selection. | |
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | |
| ON hook_predicate_counters (scope_hash, kind, key_hash, ts); |
There was a problem hiding this comment.
Addressed by the typed-schema redesign (ef93722): the generic single-table-with-kind shape is gone, replaced by two typed tables (hooks_predicate_invocations / hooks_predicate_values). So the original (scope_hash, kind) suggestion is now structural — each table carries its own (scope_hash) index plus (key_hash, occurred_at) for the per-key window aggregate, covering the COUNT(DISTINCT key_hash) WHERE scope_hash quota scan and the MIN(occurred_at) GROUP BY key_hash LRU.
| CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_tenant | ||
| ON hooks_predicate_invocations (tenant_id); |
There was a problem hiding this comment.
The idx_hooks_predicate_invocations_tenant index only covers tenant_id. The enforce_caps and evict_oldest_scope functions perform COUNT(DISTINCT scope_hash) and LRU victim selection across all rows for a tenant. For a large number of samples, this will result in expensive table scans. Expanding the index to include scope_hash and occurred_at would significantly improve performance.
| CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_tenant | |
| ON hooks_predicate_invocations (tenant_id); | |
| CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_tenant | |
| ON hooks_predicate_invocations (tenant_id, scope_hash, occurred_at); |
There was a problem hiding this comment.
Same as the Postgres sibling: the libSQL schema was canonicalized to typed two-table columns with the matching .sql migration (235a205), so per-table scope_hash / (key_hash, occurred_at) coverage replaces the old single tenant_id index the comment referenced.
|
@henrypark133 @serrrfirat durable backend PR 4/4 — cross-backend parity suite ( |
serrrfirat
left a comment
There was a problem hiding this comment.
Strict maintainability/code-quality pass: I would not merge this as-is.
- libSQL violates the documented durable-backend model.
crates/ironclaw_hooks/docs/successors/03-persistent-counter.md says the in-memory backend alone has the global MAX_HISTORY_KEYS ceiling and durable backends do not. But crates/ironclaw_hooks_libsql/src/backend.rs still imports and enforces MAX_HISTORY_KEYS, including global cross-tenant eviction in enforce_caps. That is not just docs drift; it makes libSQL and Postgres production semantics diverge while the parity matrix deliberately stays below this path. Please either remove the libSQL global cap or make it an explicit durable-backend contract and test it across both SQL backends.
- Postgres LRU victim selection does not match in-memory/libSQL.
The in-memory backend evicts by the oldest retained/front sample. libSQL mirrors that with min(occurred_at). Postgres ranks victims by MAX(ts) even though the surrounding comments say it matches oldest-front semantics. The current parity LRU script inserts only one sample per key, so MIN == MAX and this semantic divergence is invisible. Please define the victim rule once and add a multi-sample LRU parity case that would fail if one backend uses oldest-front and another uses newest/last activity.
- Postgres setup failures are converted into skip-pass tests.
The parity Postgres factory returns Option and uses .ok()? for connect, schema creation, pool build, migration, and truncate. assert_parity() then treats None as “DB URL not set.” That means CI with a configured DB can silently skip Postgres parity if migrations or setup fail. The multi-host harness has the same shape. Missing env should skip; setup errors after env discovery should fail loudly, e.g. with Result<Option<_>, _> or a small setup enum.
- The multi-host harness structure already let a promised Postgres scenario disappear.
The multi-host suite advertises the LRU eviction race as a durable backend scenario, but the Postgres driver block only wires concurrent writes, replay, cap overflow, and clock skew. The LRU race runs only for libSQL. This is the maintainability smell from hand-wired backend-specific test drivers. A cleaner shape is a shared scenario list plus per-backend cluster adapters so every backend runs every scenario unless explicitly marked unsupported.
The code is close in spirit, but the current structure makes backend parity too easy to claim while important branches remain untested or semantically divergent.
…viction (deadlock + race)
enforce_scope_quota deleted victim buckets' rows while holding only the
per-scope advisory lock, never the victims' per-key advisory lock. That
broke the per-bucket serialization guarantee in two ways:
* Torn aggregate: the LRU pass could delete key B's rows while another
transaction was recording B under B's own per-key lock, so the
recorder's COUNT/SUM straddled a delete it never serialized against.
* Deadlock: a recorder of victim key B holds B's per-key lock and then
waits on the scope lock inside its own quota pass, while the LRU
transaction holds the scope lock and waits on B's row locks to delete
them -> cycle.
Fix: evict victims one at a time, each guarded by that victim key's
per-key advisory lock acquired with the NON-blocking
pg_try_advisory_xact_lock. An in-flight victim (lock already held by a
concurrent recorder) is skipped, not waited on, so the deadlock cycle
cannot form and eviction never deletes rows out from under an
unserialized transaction. Candidates are over-fetched beyond the evict
count so skips still meet the per-scope quota. Fail-closed WindowOverflow
semantics and the dedup/atomicity invariants are unchanged.
Adds a lib unit test pinning the eviction try-lock key derivation equal
to the recorder's lock key (lock-acquisition invariant, no DB needed), a
deterministic raw-SQL regression test proving the try-lock on an in-flight
victim returns immediately rather than blocking, and a concurrency stress
test asserting no deadlock and no torn aggregate. The integration tests
gate on IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL and were verified against
a real Postgres 14.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex review (advisory) — REQUEST CHANGESThe parity matrix is a useful differential test, but it overclaims and has gaps. Critical
RecommendationIn-memory is a reference impl, not an independent oracle — two backends sharing a semantic bug pass. Add explicit expected-observation assertions for the core/cap/LRU scripts, or soften the "proof" wording. Also: the suite tests the backend trait directly, not the evaluator/caller path — doesn't fully satisfy "test through the caller" for actual predicate decisions. |
…event fail-closed under load
The libSQL predicate-state backend opened a fresh connection per op with no
in-process write serialization and no connect retry. Under production
concurrency (e.g. a heartbeat tick evaluating many hooks at once) concurrent
writers on a shared backend instance raced on the single SQLite write lock; in
the replication-enabled libSQL build a raw BEGIN IMMEDIATE can return
SQLITE_BUSY immediately instead of honouring busy_timeout, surfacing as
Unavailable("database is locked") -> fail-closed predicate evaluation (a hook
that should pass gets denied).
Fix, mirroring the canonical libSQL pattern in src/db/libsql/ (LibSqlBackend /
LibSqlWorkspaceStore):
- Add a per-backend write_lock: Arc<Mutex<()>> held across the whole
BEGIN IMMEDIATE..COMMIT for every mutating op (record_invocation,
record_value, evict_older_than, run_migrations). Serialises in-process
writers so they never contend at the SQLite layer.
- Add connect retry with exponential backoff for transient open failures
(SQLITE_CANTOPEN during concurrent opens), matching LibSqlBackend::connect.
- Cross-instance contention still falls back to PRAGMA busy_timeout = 5000.
Preserves BEGIN IMMEDIATE serialization and fail-closed WindowOverflow / the
per-key cap exactly.
Also (folded in from codex review of the parity PR #3937): remove the global
MAX_HISTORY_KEYS cap from the libSQL backend. That cap is an in-memory-only
memory-footprint bound (threat-model D5); durable backends are not memory-bound
and reap via evict_older_than + the per-tenant MAX_KEYS_PER_TENANT quota. The
Postgres sibling has no global cap, so enforcing one in libSQL diverged the two
backends once total scopes exceeded 8192 under the per-tenant quota. Dropping it
restores parity; evict_oldest_scope is now always tenant-scoped.
Tests (serial-runner suite):
- two_independent_db_handles_flood_no_handle_exhaustion: two independently
created libsql::Database handles on the same temp file flood concurrently
with NO test-side admission semaphore.
- no_global_key_cap_only_per_tenant: many tenants under quota retain all scopes
with zero evictions (would fail if a global cap still evicted cross-tenant).
- Removed the now-unnecessary test-side semaphore from
per_tenant_quota_isolates_under_concurrent_pressure (backend self-serialises).
The harness = false serial runner is still required: 6 concurrent heavy fills
as default-harness tests reproduce a DRIVER-LEVEL SQLITE_MISUSE across
independent Database handles every run even with the write_lock (verified
empirically). That is distinct from the production fail-closed risk this fixes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…es' and 'origin/hooks-predicate-backend-libsql' into pr3937-fixes
…clude_str! firat (PR #3933 maintainability audit): schema.rs embedded a hand-copied DDL const documented as "byte-compatible" with migrations/V1__predicate_counters.sql, with nothing enforcing equivalence — silent drift risk. Pull the DDL directly from the .sql file via include_str! so there is exactly one copy. batch_execute tolerates the file's leading -- comment block, so no statement splitting is needed. run_migrations is exercised by the contract/adversarial suites, confirming the included SQL applies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-merge the updated durable backend branches (libSQL global-cap removal d61ec53, Postgres LRU advisory-lock fix 806eacb) and close the test-quality gaps codex flagged: - CI enforcement: add a dedicated `hooks-parity-tests` job to test.yml that runs `cargo test -p ironclaw_hooks_parity --features postgres,integration` against a real Postgres service, with IRONCLAW_REQUIRE_POSTGRES=1 so the full three-backend matrix cannot skip-pass. Wired into the run-tests roll-up. - Global-cap parity: now that libSQL dropped its global cap (matching Postgres), add `parity_global_cap_script` asserting all three backends AGREE in the regime below MAX_HISTORY_KEYS (no global eviction fires). The above-8192 divergence stays documented as an intentional in-memory memory-bound difference. No longer silently excluded. - Concurrent-writer counts: collect every spawned writer's returned count and assert they form exactly 1..=N (no duplicate/stale counts mid-race), not just the final row count. - Cap-boundary race: new scenario fills to MAX_SAMPLES_PER_KEY-1 then races two fresh distinct ids from two hosts; asserts exactly one wins the last slot and the other fails closed with WindowOverflow (no TOCTOU breach or double-reject). - PG skip-pass false confidence: IRONCLAW_REQUIRE_POSTGRES=1 turns a missing/unreachable Postgres into a HARD failure (CI), while local runs still skip cleanly. - Oracle independence: each parity script now carries a hand-computed expected-observation log; every backend (including the in-memory reference) is asserted against it, so two backends sharing a semantic bug can no longer both pass. The oracle already caught one hand-computation error (the LRU victim-reinsert triggers a 9th eviction, not 8). Also serialize the parity matrix's libSQL legs (process-global async mutex) so concurrent independent libSQL Database handles don't trip SQLITE_MISUSE under parallel test threads — same driver limit the per-backend libSQL suite handles via its harness=false serial runner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n-memory/libSQL serrrfirat (PR #3933): the Postgres scope-LRU victim query ranked keys by MAX(ts) (most-recent activity) while the in-memory backend evicts by each bucket's OLDEST retained sample (entries.front() + min_by_key) and libSQL does the same (oldest-front). The doc comment claimed oldest-front but the SQL did MAX(ts). Under multi-sample keys this diverges: a key with one ancient + one fresh sample is spared by MAX(ts) but evicted by the in-memory/libSQL MIN(ts) ranking, so the three backends evict different keys. The single-sample- per-key parity matrix masks it (MIN == MAX). Fix: rank by MIN(ts) per key and update the module/fn docs to match the actual behavior. Follow-up (routed separately): the #3937 parity suite needs a multi-sample LRU case to lock this in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addressed the test-quality REQUEST CHANGES (codex)Re-merged the updated durable backend branches first (libSQL global-cap removal 1. CI enforcement — added a dedicated 2. Global-cap parity (no longer excluded) — since libSQL dropped its global cap to match Postgres (Postgres only ever had the per-tenant quota), I added 3. Concurrent-writer returned counts — 4. Cap-boundary race — new 5. PG skip-pass false confidence — 6. Oracle independence — each parity script now carries an independent, hand-computed Also serialized the matrix's libSQL legs behind a process-global async mutex, because concurrent independent libSQL Note on the two earlier inline P1 comments on
|
|
@henrypark133 @serrrfirat re-review please — all 4 maintainability items + the codex test-quality gaps addressed (SHA 17343e6): added a hooks-parity CI job with a real Postgres service + IRONCLAW_REQUIRE_POSTGRES=1 hard-gate (setup failures fail loudly, only missing env skips); concurrent-writer counts now asserted 1..=N; cap-boundary race added (fill to cap-1, race two ids, one wins one fails closed); oracle-checked expected logs per script (caught a real hand-comp error); global-cap parity asserted not excluded. libSQL global cap removed at source; Postgres MIN-victim fix landed. A multi-sample LRU case is being finalized in the typed-columns redesign that owns this branch. |
Replace the single generic hook_predicate_counters(kind CHAR(1), id, ts, value NUMERIC) table with two explicit typed tables matching the libSQL backend's two-table model, so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Canonical schema (both backends): - hooks_predicate_invocations(scope_hash, key_hash, event_id, occurred_at), PK (key_hash, event_id); in-window COUNT(*) is the invocation count. - hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at, value NOT NULL), PK (key_hash, event_id); in-window SUM(value) is the sum. Postgres native types unchanged where right: BYTEA hashes, TIMESTAMPTZ occurred_at, NUMERIC value. Column renames id->event_id, ts->occurred_at to the canonical names. The kind discriminator column is gone (table identity carries it); the value: Option<Decimal> double-duty smuggling serrrfirat flagged (backend.rs:138) is eliminated — the invocation table has no value column and the value table's value is NOT NULL, with a per-table aggregate() helper so COUNT vs SUM is explicit. Migration file renamed V1__predicate_counters.sql -> V1__predicate_state.sql, still the include_str! single source of truth. Invariants preserved: MIN(occurred_at) oldest-front LRU victim rule (the 0c102a6 fix), fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK + ON CONFLICT DO NOTHING, per-tenant distinct-key quota (COUNT(DISTINCT key_hash) WHERE scope_hash, no global cap), per-key advisory lock + non-blocking victim try-lock, scope advisory lock now folds a per-table tag instead of the kind column. evict_older_than reaps both tables. SQL paths compile + skip-pass without a reachable Postgres; the typed schema needs the #3937 PG CI leg for a live run before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Align the libSQL backend's column set with the Postgres sibling so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Column changes per table: - Add `key_hash` (full bucket digest) as the dedup/count grain + PRIMARY KEY (key_hash, event_id). The column previously called `scope_hash` actually held the full bucket key; it is renamed `key_hash`. - Add a real `scope_hash` = blake3(tenant_id) (the tenant grain) and DROP the raw `tenant_id` TEXT column. The per-tenant LRU quota now counts COUNT(DISTINCT key_hash) WHERE scope_hash = ?, matching Postgres. - `event_id` / `occurred_at` keep the canonical names. libSQL native types unchanged where right: BLOB hashes, INTEGER epoch-ms occurred_at, TEXT exact-decimal value. Schema is now sourced from migrations/V1__predicate_state.sql via include_str! (kills the hand-maintained LIBSQL_PREDICATE_STATE_SCHEMA const body, per the batchb-3933 note). hashing.rs renamed invocation_scope_hash/ value_scope_hash -> invocation_key_hash/value_key_hash and adds tenant_scope_hash, with a folded map discriminant so invocation/value keys never collide. Added a doc(hidden) test_support::tenant_scope_hash_bytes so the contract test can query by the tenant digest now that tenant_id is gone. Invariants preserved: oldest-front MIN(occurred_at) per-key LRU victim, fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK (key_hash, event_id) + ON CONFLICT DO NOTHING, per-tenant quota (no global cap), BEGIN IMMEDIATE + in-process write_lock serialization, connect retry. evict_older_than reaps both tables by occurred_at. Full libSQL contract + adversarial suite green (embedded temp-file db). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…kend-parity-suite
…arity Re-merge the typed-column redesign of both durable backends (Postgres two-table + libSQL canonical columns) into the parity suite and update the schema-coupled raw SQL the matrix uses for direct row probes: - TRUNCATE the two typed tables (hooks_predicate_invocations, hooks_predicate_values) instead of the dropped hook_predicate_counters, in both parity_matrix.rs and multi_host_adversarial.rs. - distinct_invocation_scopes() now counts COUNT(DISTINCT key_hash) WHERE scope_hash = <tenant digest> (via ironclaw_hooks_libsql::test_support), since the raw tenant_id column is gone in the canonical schema. Add an oracle-checked MULTI-SAMPLE per-tenant LRU victim-rule parity case (parity_multisample_lru_victim_rule). The existing run_lru_script puts one sample per key so MIN(ts) == MAX(ts) and a newest-activity victim rule is invisible. The new script gives the oldest-front key a second far-recent sample so its MIN(ts) (oldest) and MAX(ts) (newest) point at DIFFERENT victims, then forces eviction and probes the oldest-front key. Under the correct oldest-front MIN(occurred_at) rule (all three backends, after the 0c102a6 Postgres fix) the probe returns count 1 with a second eviction; a backend regressed to MAX(ts) victim selection would spare that key and return count 3, diverging from the hand-computed oracle. Behavioral parity is unchanged — same observable counts/sums/eviction across backends. Verified: in-memory + libSQL legs green against the new typed schemas (parity_matrix 5/5; multi_host_adversarial 6/6 single-threaded). The multi_host binary uses the default harness; run it with --test-threads=1 to avoid the documented cross-instance libSQL driver SQLITE_MISUSE under concurrent independent Database handles (pre-existing, not schema-related). Postgres legs skip-pass without a reachable DB; the #3937 PG CI leg must run the typed-schema matrix before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Review of #3937: cross-backend adversarial parity suite Test-only crate ( Low — Postgres tests gated behind
Fix: Add a CI step that fails if the Postgres service is unavailable, or gate the workflow job on a service health check so a missing DB is a CI failure, not a silent skip. |
…event fail-closed under load
The libSQL predicate-state backend opened a fresh connection per op with no
in-process write serialization and no connect retry. Under production
concurrency (e.g. a heartbeat tick evaluating many hooks at once) concurrent
writers on a shared backend instance raced on the single SQLite write lock; in
the replication-enabled libSQL build a raw BEGIN IMMEDIATE can return
SQLITE_BUSY immediately instead of honouring busy_timeout, surfacing as
Unavailable("database is locked") -> fail-closed predicate evaluation (a hook
that should pass gets denied).
Fix, mirroring the canonical libSQL pattern in src/db/libsql/ (LibSqlBackend /
LibSqlWorkspaceStore):
- Add a per-backend write_lock: Arc<Mutex<()>> held across the whole
BEGIN IMMEDIATE..COMMIT for every mutating op (record_invocation,
record_value, evict_older_than, run_migrations). Serialises in-process
writers so they never contend at the SQLite layer.
- Add connect retry with exponential backoff for transient open failures
(SQLITE_CANTOPEN during concurrent opens), matching LibSqlBackend::connect.
- Cross-instance contention still falls back to PRAGMA busy_timeout = 5000.
Preserves BEGIN IMMEDIATE serialization and fail-closed WindowOverflow / the
per-key cap exactly.
Also (folded in from codex review of the parity PR #3937): remove the global
MAX_HISTORY_KEYS cap from the libSQL backend. That cap is an in-memory-only
memory-footprint bound (threat-model D5); durable backends are not memory-bound
and reap via evict_older_than + the per-tenant MAX_KEYS_PER_TENANT quota. The
Postgres sibling has no global cap, so enforcing one in libSQL diverged the two
backends once total scopes exceeded 8192 under the per-tenant quota. Dropping it
restores parity; evict_oldest_scope is now always tenant-scoped.
Tests (serial-runner suite):
- two_independent_db_handles_flood_no_handle_exhaustion: two independently
created libsql::Database handles on the same temp file flood concurrently
with NO test-side admission semaphore.
- no_global_key_cap_only_per_tenant: many tenants under quota retain all scopes
with zero evictions (would fail if a global cap still evicted cross-tenant).
- Removed the now-unnecessary test-side semaphore from
per_tenant_quota_isolates_under_concurrent_pressure (backend self-serialises).
The harness = false serial runner is still required: 6 concurrent heavy fills
as default-harness tests reproduce a DRIVER-LEVEL SQLITE_MISUSE across
independent Database handles every run even with the write_lock (verified
empirically). That is distinct from the production fail-closed risk this fixes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
serrrfirat
left a comment
There was a problem hiding this comment.
Code Review — #3937
PR: test(hooks): cross-backend adversarial parity suite + A3 closeout (durable backend PR 4/4)
Summary
| Category | Findings |
|---|---|
| 🔴 Critical/High | 0 |
| 🟡 Medium | 3 |
| 🟢 Low | 3 |
| ℹ️ Nit | 0 |
Reviewers: security ✅ · bugs ✅ · performance ✅ · tests ✅ · conventions ✅
🟡 Medium — Performance
[f-perf-1 + f-perf-2] Both the libSQL and Postgres migration files create a scope_hash-only index for the per-tenant LRU quota path. The quota enforcement query SELECT count(DISTINCT key_hash) FROM {table} WHERE scope_hash = ?1 and the victim-selection GROUP BY key_hash ORDER BY min(occurred_at) both require scanning every row for the tenant to compute their result — O(total rows for tenant) per new-key insert. A composite (scope_hash, key_hash) index would allow an index-only scan for the distinct-key count (O(distinct keys)), dramatically reducing cost for high-churn tenants at or near MAX_KEYS_PER_TENANT.
[f-tests-1] evict_older_than is a public trait method but is absent from predicate_backend_contract_cases! and the parity matrix. A backend that implements evict_older_than with wrong cutoff semantics (e.g., <= vs <) would pass all current tests. See inline comment on the contract cases macro.
🟢 Low — Bugs / Tests
[f-bugs-1] enforce_caps counts DISTINCT key_hash WHERE scope_hash = ? across all rows, including rows from other keys whose windows have expired but were never reaped by evict_older_than. A tenant whose keys all expired (but rows linger) still appears at MAX_KEYS_PER_TENANT, triggering LRU evictions of already-expired keys. The evicted keys are harmless victims, so this is not a correctness failure — but it's an undocumented divergence from the in-memory backend's behavior and produces surprising evictions_observed() metrics without a periodic reaper.
[f-tests-2] The parity matrix lru and multisample-lru scripts only cover record_invocation. enforce_caps is called from both record_invocation and record_value paths; a regression in the value-table branch would go undetected.
[f-tests-3] parity_fail_closed_cap_script fills MAX_SAMPLES_PER_KEY (4 096) entries to a fresh libSQL Database handle. The parity matrix runs under the default libtest harness with no serialization guard. The libSQL contract suite documents that concurrent independent handles on heavy fills trigger SQLITE_MISUSE — which is why that suite uses harness = false. Only one parity test is cap-heavy today, so the risk is low, but worth noting for future scripts.
| $emit!([$($ctx)*] duplicate_event_id_is_noop_for_invocations); | ||
| $emit!([$($ctx)*] duplicate_event_id_is_noop_for_values); | ||
| $emit!([$($ctx)*] invocation_retains_entry_at_exact_window_cutoff); | ||
| $emit!([$($ctx)*] event_id_dedup_isolated_across_maps); |
There was a problem hiding this comment.
[f-tests-1] Medium — evict_older_than missing from contract inventory
predicate_backend_contract_cases! drives every backend's contract suite, but evict_older_than (a public trait method) is absent. A backend that evicts with <= instead of < on the cutoff, or that touches only one table, would pass all current tests.
Suggested additions:
$emit!([$($ctx)*] evict_older_than_reaps_strictly_older_rows);
$emit!([$($ctx)*] evict_older_than_retains_entry_at_exact_cutoff);With corresponding pub async fns in the contract module that verify: rows strictly before the cutoff are deleted, the row exactly AT the cutoff is retained, and the count returned equals rows deleted.
There was a problem hiding this comment.
Confirmed: predicate_backend_contract_cases! enumerates 9 cases (predicate_state.rs:1228-1238) and evict_older_than is not among them. There is an in-memory unit test (evict_older_than_drops_expired_entries_and_empty_buckets, predicate_state.rs:1545), but it lives in the in-memory mod tests and never runs against the durable overrides (ironclaw_hooks_libsql/src/backend.rs:720, ironclaw_hooks_postgres/src/backend.rs), so the <=-vs-< cutoff and single-table-only failure modes are uncovered cross-backend. Will fix: add evict_older_than_reaps_strictly_older_rows and evict_older_than_retains_entry_at_exact_cutoff to the canonical inventory, asserting strictly-older rows are deleted, the exact-cutoff row is retained, both tables are reaped, and the returned count equals rows deleted.
There was a problem hiding this comment.
Fixed in 295b415: added evict_older_than_reaps_strictly_older_rows and evict_older_than_retains_entry_at_exact_cutoff to the canonical predicate_backend_contract_cases! inventory in predicate_state.rs. They assert strictly-older rows are reaped from BOTH the invocation and value tables, the row exactly at the cutoff is retained (< vs <=), and the returned count equals rows deleted. The cases auto-run for in-memory and libSQL via the canonical macro; I also added them to the hand-maintained Postgres pg_contract! list so the durable backends are covered.
There was a problem hiding this comment.
Fixed in 295b415. Added the two contract cases evict_older_than_reaps_strictly_older_rows and evict_older_than_retains_entry_at_exact_cutoff to the canonical predicate_backend_contract_cases! macro (auto-runs for in-memory + libSQL) plus the hand-maintained pg_contract! list. They pin the documented < (not <=) semantics: rows strictly before the cutoff are reaped from BOTH the invocation and value tables, the row exactly at the cutoff is retained, and the returned count equals rows deleted. All pass against in-memory and libSQL; Postgres runs the same cases under a live DB.
| ON hooks_predicate_invocations (key_hash, occurred_at); | ||
| CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_scope | ||
| ON hooks_predicate_invocations (scope_hash); | ||
| CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_ts |
There was a problem hiding this comment.
[f-perf-1] Medium — Missing (scope_hash, key_hash) composite index
The quota enforcement query:
SELECT count(DISTINCT key_hash) FROM hooks_predicate_invocations WHERE scope_hash = ?1and the LRU victim selection:
SELECT key_hash FROM ... WHERE scope_hash = ?1 GROUP BY key_hash ORDER BY min(occurred_at)both scan every row for the tenant. The single-column scope_hash index finds the partition but cannot satisfy the DISTINCT or GROUP BY without fetching each row.
A composite (scope_hash, key_hash) index allows SQLite to answer the distinct-key count via an index-only scan. For a tenant at MAX_KEYS_PER_TENANT (2 048) with many samples per key, the current path is O(total rows for tenant) per new-key insert.
CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_scope_key
ON hooks_predicate_invocations (scope_hash, key_hash);
CREATE INDEX IF NOT EXISTS idx_hooks_predicate_values_scope_key
ON hooks_predicate_values (scope_hash, key_hash);There was a problem hiding this comment.
Confirmed against the schema at head (V1__predicate_state.sql:54-58): only idx_hooks_predicate_invocations_scope ON (scope_hash) and idx_hooks_predicate_values_scope exist, both single-column. enforce_caps runs COUNT(DISTINCT key_hash) WHERE scope_hash = ?1 and evict_oldest_key runs ... WHERE scope_hash = ?1 GROUP BY key_hash ORDER BY min(occurred_at) (backend.rs:617,640), neither of which the single-column index can satisfy index-only. Will fix: add the (scope_hash, key_hash) composite indexes to both tables in this migration. This PR introduces the migration, so the index belongs here, not a follow-up.
There was a problem hiding this comment.
The (scope_hash, key_hash) composite indexes land in the owning backend PR #3936 (which introduces this libSQL migration), not here, to avoid stacked-branch merge conflicts with the migration file. This PR (4/4) only adds the parity/contract suite.
There was a problem hiding this comment.
Fixed in ef4d96a. Added idx_hooks_predicate_invocations_scope_key and idx_hooks_predicate_values_scope_key on (scope_hash, key_hash) to the libSQL V1 schema so the count(DISTINCT key_hash) WHERE scope_hash = ? quota count and the GROUP BY key_hash ORDER BY min(occurred_at) victim scan stay off a full per-tenant row scan. The same indexes are being added to the owning PR #3933; the duplicate copy here will dedupe on rebase once that merges.
| CREATE INDEX IF NOT EXISTS hooks_predicate_invocations_key_ts_idx | ||
| ON hooks_predicate_invocations (key_hash, occurred_at); | ||
| -- Per-scope (tenant) distinct-key LRU eviction scans by scope. | ||
| CREATE INDEX IF NOT EXISTS hooks_predicate_invocations_scope_idx |
There was a problem hiding this comment.
[f-perf-2] Medium — Same composite index gap in Postgres schema
Same COUNT(DISTINCT key_hash) WHERE scope_hash = ? and victim-selection queries run in the Postgres backend. The hooks_predicate_invocations_scope_idx ON (scope_hash) alone doesn't enable an index-only scan for distinct key counts.
CREATE INDEX IF NOT EXISTS hooks_predicate_invocations_scope_key_idx
ON hooks_predicate_invocations (scope_hash, key_hash);
CREATE INDEX IF NOT EXISTS hooks_predicate_values_scope_key_idx
ON hooks_predicate_values (scope_hash, key_hash);There was a problem hiding this comment.
Same gap in the Postgres schema at head (V1__predicate_state.sql:60-66): hooks_predicate_invocations_scope_idx ON (scope_hash) only, with the identical COUNT(DISTINCT key_hash) / victim-selection queries in ironclaw_hooks_postgres/src/backend.rs. Will fix: add the (scope_hash, key_hash) composite indexes to both Postgres tables in this migration alongside the libSQL change in f-perf-1.
There was a problem hiding this comment.
On inspection the Postgres schema at #3933's head already carries wider covering indexes — (scope_hash, key_hash, occurred_at) on both the invocations and values tables (added in 7547ebf), which serve the COUNT(DISTINCT key_hash) quota query and LRU victim selection as index-only scans. The proposed bare (scope_hash, key_hash) would be a redundant left-prefix, so no change was needed there. The libSQL twin did lack the index and gained it in #3936 (a06caee).
There was a problem hiding this comment.
Fixed in ef4d96a. Added hooks_predicate_invocations_scope_key_idx and hooks_predicate_values_scope_key_idx on (scope_hash, key_hash) to the Postgres V1 schema, matching the libSQL sibling, so the COUNT(DISTINCT key_hash) WHERE scope_hash = ? and victim-selection queries are covered by a composite rather than the scope-only index. The same indexes are being added to the owning PR #3936; the duplicate copy here will dedupe on rebase once that merges.
| // values under it. | ||
| let tenant_keys = scalar_u32( | ||
| conn, | ||
| &format!("SELECT count(DISTINCT key_hash) FROM {table} WHERE scope_hash = ?1"), |
There was a problem hiding this comment.
[f-bugs-1] Low — Quota count includes un-reaped expired rows from other keys
Step 1 trims only the current key (WHERE key_hash = ?1). This COUNT(DISTINCT key_hash) counts ALL rows for the tenant scope, including rows from OTHER keys whose windows have expired but whose rows haven't been reaped by evict_older_than. A tenant with many expired keys (never reaped) can appear at MAX_KEYS_PER_TENANT and trigger LRU eviction even when its active key count is much lower. The evicted keys are already expired (benign), but the evictions_observed() counter advances unexpectedly.
This is an undocumented behavioral divergence from the in-memory backend (which also doesn't proactively clean expired keys, but those are process-local and bounded by MAX_HISTORY_KEYS). Suggest adding to 03-persistent-counter.md:
The per-tenant quota count includes all stored rows for the tenant, including expired rows from keys not yet reaped by
evict_older_than. Deployers must schedule a periodic reaper to keep quota counts accurate; without it, the effective quota for tenants with short-window workloads may appear higher than their active key count.
There was a problem hiding this comment.
Confirmed: the per-key window trim runs WHERE key_hash = ?1 while the quota count is COUNT(DISTINCT key_hash) WHERE scope_hash = ?1 over all stored rows (backend.rs enforce_caps), so expired-but-unreaped rows from other keys do inflate the distinct-key count and can trip LRU eviction (and advance evictions_observed()) below the active-key count. The evicted keys are already expired so gate correctness is unaffected, but the counter divergence from the in-memory backend is undocumented. Will fix: document this in 03-persistent-counter.md — the per-tenant quota counts all stored rows including un-reaped expired keys, so deployers must schedule a periodic evict_older_than reaper to keep quota counts aligned with active key count for short-window workloads.
There was a problem hiding this comment.
Fixed in 295b415 (documentation, no behavior change): documented that the per-tenant quota's COUNT(DISTINCT key_hash) counts all stored rows including expired-but-unreaped rows from other keys, so deployers must schedule a periodic evict_older_than reaper to keep quota counts aligned with the active key count. Added the note at the PredicateStateBackend::evict_older_than trait method, at both backends' quota-enforcement sites (libSQL enforce_caps, Postgres enforce_scope_quota), and in 03-persistent-counter.md.
There was a problem hiding this comment.
Addressed on this branch (HEAD 4b2910e), documentation only — no behavior change. The expired-but-unreaped-rows quota divergence is now documented at the evict_older_than trait method, both backends quota-enforcement sites, and docs/successors/03-persistent-counter.md: the per-tenant COUNT(DISTINCT key_hash) counts all stored rows for the scope including expired rows from idle keys, so deployers must schedule a periodic evict_older_than reaper to keep quota counts aligned with the active key count.
| /// 8192 total scopes is an intentional memory-bound difference documented in | ||
| /// 03-persistent-counter.md. This per-tenant LRU script stays under both caps | ||
| /// so a divergence here localizes to the per-tenant dimension. | ||
| pub(crate) async fn run_lru_script(backend: &dyn PredicateStateBackend) -> ObservationLog { |
There was a problem hiding this comment.
[f-tests-2] Low — record_value LRU path not exercised in parity matrix
All LRU parity scripts (run_lru_script, run_multisample_lru_script) drive record_invocation only. enforce_caps is called from both the invocation and value paths in both backends. A regression in enforce_caps that only surfaces through record_value (e.g., wrong table constant) would not be caught.
Suggested: add a run_lru_value_script (or extend run_lru_script) that exercises per-tenant LRU eviction through record_value and cross-asserts the same oracle log.
There was a problem hiding this comment.
Confirmed: both run_lru_script and run_multisample_lru_script drive only record_invocation (scripts.rs:316,473), so the enforce_caps call on the value path (shared helper, table-parameterized) never exercises per-tenant LRU through record_value in the matrix — a wrong-table-constant regression on that path would slip the oracle. Will fix: add a record_value-driven LRU parity script cross-asserting the same oracle log.
There was a problem hiding this comment.
Fixed in 295b415: added run_lru_value_script + expected_lru_value_log + the parity_per_tenant_lru_value_script test. It mirrors run_lru_script but drives per-tenant LRU eviction through record_value, cross-asserting the same eviction trajectory against the oracle. A value-path-only enforce_caps regression (e.g. wrong table constant) now fails the matrix.
There was a problem hiding this comment.
Fixed on this branch (HEAD 4b2910e). Added run_lru_value_script + expected_lru_value_log and the parity_per_tenant_lru_value_script test, which drives per-tenant LRU through record_value and cross-asserts the same eviction trajectory as the invocation path against an independent oracle. A value-path-only enforce_caps regression (e.g. wrong table constant) is now caught. Verified passing locally (parity_per_tenant_lru_value_script ... ok).
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn parity_fail_closed_cap_script() { |
There was a problem hiding this comment.
[f-tests-3] Low — Cap-fill parity test runs without a SQLITE_MISUSE serialization guard
parity_fail_closed_cap_script fills MAX_SAMPLES_PER_KEY (4 096) entries through a fresh libSQL Database handle. All parity matrix tests run under the default libtest harness concurrently. The libSQL contract suite documents that concurrent independent Database handles on heavy fills can trigger SQLITE_MISUSE — which is why that suite uses harness = false and a serial runner.
Only one parity test is cap-heavy today, so the risk is lower than in the contract suite. But if future parity scripts add more heavy fills, the same intermittent failure will appear. Consider either:
- Adding the singleton
libsql_serial_guard()pattern (as inmulti_host_adversarial.rs) toassert_parity's libSQL leg when running the cap script, or - A brief note in the module doc explaining why the current parallelism is safe.
There was a problem hiding this comment.
This is already in place at head. assert_parity acquires libsql_serial_guard().lock().await and holds it across the entire libSQL leg (parity_matrix/support.rs:383), with the guard defined at support.rs:281 and the rationale documented inline at support.rs:373-382 (same SQLITE_MISUSE concurrent-handles limit, same singleton pattern as multi_host_adversarial.rs:554). Both options you suggested — the serial guard and the explaining module doc — are present, so the cap-fill script (parity_fail_closed_cap_script) and any future heavy fills are already serialized.
There was a problem hiding this comment.
Addressed in 4b2910e (comment only, authored on this branch). Added a caveat at the top of parity_matrix.rs documenting the SQLITE_MISUSE serialization hazard and pointing future cap-heavy / statement-heavy parity script authors at the process-global libsql_serial_guard in support.rs, so an unguarded fresh libSQL handle pushing thousands of rows under the concurrent libtest harness will not intermittently trip SQLITE_MISUSE.
…gaps (#3937) Addresses serrrfirat review on the cross-backend parity suite (PR 4/4). f-tests-1: add evict_older_than to the canonical predicate contract inventory. New cases evict_older_than_reaps_strictly_older_rows and evict_older_than_retains_entry_at_exact_cutoff assert strictly-older rows are reaped from BOTH the invocation and value tables, the row exactly at the cutoff is retained (< vs <=), and the returned count equals rows deleted. Wired into the canonical predicate_backend_contract_cases! list (auto-runs for in-memory + libSQL) and the hand-maintained Postgres pg_contract! list. f-tests-2: add run_lru_value_script + expected_lru_value_log + the parity_per_tenant_lru_value_script test. enforce_caps is shared and table-parameterized across both record paths; the existing LRU scripts drove record_invocation only, so a value-path-only enforce_caps regression (e.g. wrong table constant) could slip the oracle. The new script exercises per-tenant LRU through record_value and cross-asserts the same eviction trajectory. f-bugs-1: document (no behavior change) that the per-tenant quota counts all stored rows including expired-but-unreaped rows from other keys, so deployers must schedule a periodic evict_older_than reaper to keep quota counts aligned with the active key count. Documented at the trait method, both backends' quota-enforcement sites, and 03-persistent-counter.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…LRU quota f-perf-1 + f-perf-2 (serrrfirat review on #3937): the per-tenant LRU quota path runs `SELECT count(DISTINCT key_hash) ... WHERE scope_hash = ?` and an LRU victim scan `GROUP BY key_hash ORDER BY min(occurred_at)`. With only a single-column scope_hash index, both filter by scope_hash but then scan every matching tenant row to group/aggregate by key_hash. Add a composite (scope_hash, key_hash) index on both the invocations and values tables in both durable backends so the quota count and victim selection stay off a full per-tenant row scan. Schema is the single canonical V1 source (include_str! into schema.rs, applied via idempotent CREATE INDEX IF NOT EXISTS), so editing V1 in place is the convention for this unreleased schema; no second Rust copy to sync. The same indexes are being added to the owning PRs (#3933 libsql, #3936 postgres) and will dedupe on rebase once those merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…_matrix.rs f-tests-3 (serrrfirat review on #3937, Low): add a comment at the top of parity_matrix.rs pointing future cap-heavy / statement-heavy parity script authors at the process-global `libsql_serial_guard` in support.rs, so an unguarded fresh libSQL handle pushing thousands of rows under the concurrent libtest harness does not intermittently trip SQLITE_MISUSE. Comment only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address serrrfirat C-1 (Medium): the repo rule requiring both PostgreSQL and libSQL persistence backends is satisfied across the staged durable-backend series, not in this crate alone. Document the libSQL counterpart (ironclaw_hooks_libsql, PR #3936) and the cross-backend parity suite (ironclaw_hooks_parity, PR #3937) in the crate-level doc, including merge ordering, so the dependency is explicit per the reviewer's recommendation (b). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@serrrfirat The two perf Mediums (composite |
|
@henrypark133 This one is ready for your re-review as well — all findings from the June 3 review pass are addressed (see the summary comment above for commits), and your review request is still pending on it. |
henrypark133
left a comment
There was a problem hiding this comment.
Code Review (multi-agent, re-review)
Intent: Cross-backend adversarial parity suite + A3 closeout (durable backend PR 4/4).
Stats: 4 new findings (1 Medium security, 2 Medium tests, 1 Low) after deduping against 64 prior comments. 8 reviewers run. Diff was truncated at 40 KB for review (full diff 287 KB / 30 files).
Previously raised — author has sound reply, no re-block
hooks_postgres/hashing.rslossy u32 length prefix — fixed in8bd9419e5(shared module, u64 big-endian).hooks_postgres/backend.rs:174Postgres cutoff math reimplemented — fixed in8bd9419e5(delegates to canonicalwindow_cutoff).hooks_libsql/src/lib.rs:36tenant_scope_hash_bytesvsscope_hash_bytesnaming — fixed ineb160f313.hooks_libsql/src/hashing.rs:19thinto_vecwrapper — author acknowledged: "thin Vec-adapting delegate" (intentional). Not re-blocking.enforce_capsindex coverage (gemini bot) — already discussed on the prior backend PRs.hooks_postgres/src/backend.rs:523global key cap across tenants (chatgpt-codex bot) — already addressed in#3933/#3936review threads.
Note (raised on prior PRs in series)
- Postgres
evict_older_thantwo-DELETE atomicity gap (raised in my #3933 review). Still applies; not re-posting inline here since the file is owned by #3933.
Security
- Medium
PredicateEventIdhas no length cap; arbitrarily large strings can be stored verbatim in both durable tables (predicate_state.rs:148, conf 75). See inline.
Tests
- Medium
window_cutoffoversized-windowErr(_) => nowbranch not unit-tested (predicate_state.rs:548, conf 75) — the very invariant the function was madepubto enforce. See inline. - Medium
predicate_hashlength-prefix empty-string field handling not tested (predicate_hash.rs:72, conf 75). See inline.
Local patterns
- Low
test_support::scope_hash_bytesreturn type asymmetric across sibling backends —Vec<u8>vs[u8; 32](hooks_libsql/src/lib.rs:40, conf 75) — separate from the prior naming fix. See inline.
| /// Lockable into the trait signature now (rather than at the durable | ||
| /// backend PR) so trait-object callers don't break when the durable | ||
| /// impl lands — henrypark133 important #5 on PR #3635. | ||
| /// |
There was a problem hiding this comment.
Medium — PredicateEventId::new validates non-empty and NUL-free but applies no length cap. The value is stored verbatim in hooks_predicate_invocations.event_id and hooks_predicate_values.event_id (TEXT, no DB-level length constraint). An attacker who can drive event_id through any path calling record_invocation/record_value can insert arbitrarily large strings — up to PostgreSQLs 1 GiB row limit — into durable storage. With MAX_SAMPLES_PER_KEY = 4096, a single tenant can store 4096 × multi-MB event_ids per key. The WindowOverflow error also embeds the key label in its message, amplifying oversized strings into error-message memory.
Fix: Add a max-length check (e.g. MAX_EVENT_ID_LEN = 512, ~4× a blake3 hex digest) and a PredicateEventIdError::TooLong { len, max } variant.
New finding — not raised in prior reviews.
| /// shortcut a backend might write independently is **not** equivalent — on an | ||
| /// oversized window it trims nothing, whereas this canonical rule trims to | ||
| /// `now`. The cross-backend parity suite (#3937) covers this boundary. | ||
| pub fn window_cutoff(now: DateTime<Utc>, window: Duration) -> DateTime<Utc> { |
There was a problem hiding this comment.
Medium — The Err(_) => now branch fires when chrono::Duration::from_std(window) overflows (a std::time::Duration larger than chrono::Duration::MAX ≈ 9.2e18 ns). The doc explicitly warns that an independent saturating_sub shortcut would diverge here by trimming nothing, whereas this canonical rule trims to now. This is the load-bearing reason window_cutoff was promoted to pub so backends could delegate to it. All existing tests use small Duration::from_secs(N) values; no test exercises the overflow branch. A future refactor to unwrap_or(MIN) would silently regress without failing any test.
Fix: Add window_cutoff_oversized_window_trims_to_now asserting window_cutoff(now, std::time::Duration::MAX) == now.
New finding — not raised in prior reviews.
| //! apart, closing the classic concatenation-collision hole. `u64` makes the | ||
| //! `usize → prefix` conversion infallible on every supported platform — no | ||
| //! lossy `u32::try_from(len).unwrap_or(u32::MAX)` saturation that could alias | ||
| //! two pathologically large fields. |
There was a problem hiding this comment.
Medium — The length-prefix scheme is explicitly motivated as closing the concatenation-collision hole. Existing tests cover ("ab","c") vs ("a","bc") and ("t","abc") vs ("tabc",""), but never empty-string inputs (scope_hash(""), invocation_key_hash with empty capability, value_key_hash with empty field). An empty-string input writes only the 8-byte zero prefix — a real boundary. A refactor that skips zero-length fields would be undetected by current tests.
Fix: Add scope_hash_empty_tenant_is_stable, invocation_key_hash_empty_capability_is_distinct_from_nonempty, and a value-key variant covering all three public hash functions with empty-string field inputs.
New finding — not raised in prior reviews.
| //! | ||
| //! This crate is the libSQL sibling of the in-memory backend that ships in | ||
| //! `ironclaw_hooks`. The framework crate (`ironclaw_hooks`) owns the public | ||
| //! [`PredicateStateBackend`] trait and its supporting types; this crate |
There was a problem hiding this comment.
Low — After the rename in eb160f313, both crates expose scope_hash_bytes — but libSQL returns Vec<u8> and Postgres returns [u8; 32]. Callers in the parity tests apply .to_vec() on the Postgres side (multi_host_adversarial.rs:683) but not on libSQL, making the supposedly-symmetric accessor asymmetric. Additionally, Postgres exposes invocation_key_hash_bytes; libSQL does not, so any future parity test querying by key hash has no parallel accessor.
Fix: Align both test_support::scope_hash_bytes to return [u8; 32] (the canonical Digest). libSQL can delegate directly to ironclaw_hooks::predicate_hash::scope_hash. Add invocation_key_hash_bytes to libSQL test_support to match the Postgres surface.
New finding — separate from the prior naming fix; not raised in prior reviews.
…eplaces #3932) (#3933) * feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4) Implements the durable PostgreSQL backend for predicate sliding-window state, satisfying the `PredicateStateBackend` contract widened to a public async trait in PR #3927. New crate `ironclaw_hooks_postgres` (out-of-crate durable backend, the pattern the `contract-tests` feature on `ironclaw_hooks` was designed for; mirrors `ironclaw_reborn_event_store`). Keeps the Postgres dependency surface out of the hook framework itself. Correctness: - Atomic record-and-read: each `record_*` runs in one READ COMMITTED tx guarded by a transaction-scoped advisory lock on the bucket. The advisory lock (not REPEATABLE READ) serializes same-key writers by BLOCKING the second writer rather than aborting it with a serialization failure — no caller retry loop. Trim, insert, cap-evict, aggregate all share the one tx. - Cross-host replay dedup via PRIMARY KEY (key_hash, id) + INSERT ON CONFLICT DO NOTHING — exact regardless of clock skew. - Running-sum consistency under eviction: cap eviction re-aggregates inside the same tx so the returned sum reflects dropped rows. - Per-key sample cap (MAX_SAMPLES_PER_KEY, drop-oldest) and per-scope distinct-key LRU quota (MAX_KEYS_PER_TENANT) match the in-memory backend. Scope-quota enforcement takes a second advisory lock in the disjoint (int8) lock space so concurrent new-key inserts in a scope don't under-evict. Schema: single `hook_predicate_counters` table, BYTEA blake3 hash keys, TEXT id column (NOT uuid — Postgres uuid rejects the 64-char blake3 hex digest; resolves the #3635 docs/schema contradiction in favor of TEXT). Idempotent CREATE ... IF NOT EXISTS applied via run_migrations(); the per-crate pattern, not the legacy main-binary refinery migrations. DB-clock decision: window comparison basis is the caller's `now` (the same Utc::now() the in-memory backend trusts), making this a drop-in under the deterministic-clock contract harness. Replay dedup and atomicity do NOT depend on clock agreement. Tests (env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL, skip when absent; schema-isolated so the two test binaries can share one DB): - All 8 shared contract functions via the `contract-tests` harness. - Adversarial: two-host write storm (no count desync), cross-host replay (count + value), per-key cap flood, per-scope LRU under concurrent insert pressure across two hosts. - 4 hashing unit tests (injective length-prefixed serialization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): take victim-key advisory lock during scope-LRU eviction (deadlock + race) enforce_scope_quota deleted victim buckets' rows while holding only the per-scope advisory lock, never the victims' per-key advisory lock. That broke the per-bucket serialization guarantee in two ways: * Torn aggregate: the LRU pass could delete key B's rows while another transaction was recording B under B's own per-key lock, so the recorder's COUNT/SUM straddled a delete it never serialized against. * Deadlock: a recorder of victim key B holds B's per-key lock and then waits on the scope lock inside its own quota pass, while the LRU transaction holds the scope lock and waits on B's row locks to delete them -> cycle. Fix: evict victims one at a time, each guarded by that victim key's per-key advisory lock acquired with the NON-blocking pg_try_advisory_xact_lock. An in-flight victim (lock already held by a concurrent recorder) is skipped, not waited on, so the deadlock cycle cannot form and eviction never deletes rows out from under an unserialized transaction. Candidates are over-fetched beyond the evict count so skips still meet the per-scope quota. Fail-closed WindowOverflow semantics and the dedup/atomicity invariants are unchanged. Adds a lib unit test pinning the eviction try-lock key derivation equal to the recorder's lock key (lock-acquisition invariant, no DB needed), a deterministic raw-SQL regression test proving the try-lock on an in-flight victim returns immediately rather than blocking, and a concurrency stress test asserting no deadlock and no torn aggregate. The integration tests gate on IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL and were verified against a real Postgres 14. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-postgres): make migration SQL the single source via include_str! firat (PR #3933 maintainability audit): schema.rs embedded a hand-copied DDL const documented as "byte-compatible" with migrations/V1__predicate_counters.sql, with nothing enforcing equivalence — silent drift risk. Pull the DDL directly from the .sql file via include_str! so there is exactly one copy. batch_execute tolerates the file's leading -- comment block, so no statement splitting is needed. run_migrations is exercised by the contract/adversarial suites, confirming the included SQL applies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): rank LRU eviction victims by MIN(ts), matching in-memory/libSQL serrrfirat (PR #3933): the Postgres scope-LRU victim query ranked keys by MAX(ts) (most-recent activity) while the in-memory backend evicts by each bucket's OLDEST retained sample (entries.front() + min_by_key) and libSQL does the same (oldest-front). The doc comment claimed oldest-front but the SQL did MAX(ts). Under multi-sample keys this diverges: a key with one ancient + one fresh sample is spared by MAX(ts) but evicted by the in-memory/libSQL MIN(ts) ranking, so the three backends evict different keys. The single-sample- per-key parity matrix masks it (MIN == MAX). Fix: rank by MIN(ts) per key and update the module/fn docs to match the actual behavior. Follow-up (routed separately): the #3937 parity suite needs a multi-sample LRU case to lock this in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-postgres): canonical typed two-table predicate schema Replace the single generic hook_predicate_counters(kind CHAR(1), id, ts, value NUMERIC) table with two explicit typed tables matching the libSQL backend's two-table model, so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Canonical schema (both backends): - hooks_predicate_invocations(scope_hash, key_hash, event_id, occurred_at), PK (key_hash, event_id); in-window COUNT(*) is the invocation count. - hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at, value NOT NULL), PK (key_hash, event_id); in-window SUM(value) is the sum. Postgres native types unchanged where right: BYTEA hashes, TIMESTAMPTZ occurred_at, NUMERIC value. Column renames id->event_id, ts->occurred_at to the canonical names. The kind discriminator column is gone (table identity carries it); the value: Option<Decimal> double-duty smuggling serrrfirat flagged (backend.rs:138) is eliminated — the invocation table has no value column and the value table's value is NOT NULL, with a per-table aggregate() helper so COUNT vs SUM is explicit. Migration file renamed V1__predicate_counters.sql -> V1__predicate_state.sql, still the include_str! single source of truth. Invariants preserved: MIN(occurred_at) oldest-front LRU victim rule (the 0c102a6 fix), fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK + ON CONFLICT DO NOTHING, per-tenant distinct-key quota (COUNT(DISTINCT key_hash) WHERE scope_hash, no global cap), per-key advisory lock + non-blocking victim try-lock, scope advisory lock now folds a per-table tag instead of the kind column. evict_older_than reaps both tables. SQL paths compile + skip-pass without a reachable Postgres; the typed schema needs the #3937 PG CI leg for a live run before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): typed RecordPlan + fail-closed scope quota + asserting LRU test Addresses serrrfirat's post-typed-schema maintainability review on #3933. 1. Quota enforcement is now an explicit OUTCOME, not silently best-effort (backend.rs). `enforce_scope_quota` previously over-fetched a fixed 64 candidates, skipped locked victims, and committed `Ok(evicted)` even if the scope was still above MAX_KEYS_PER_TENANT — making the per-scope bound an accident of how many victims happened to be in-flight. It now requeries the distinct count + a fresh victim batch each pass and keeps evicting (deterministic oldest-front victim, per-key try-lock before delete, consistent lock order) until the cap is actually met, evicting EXACTLY the per-pass deficit so it can never over-evict. If a whole pass makes zero progress while still over cap (every stale candidate locked by an in-flight recorder) it FAILS CLOSED with Unavailable rather than committing an over-quota scope; the caller's txn rolls back and the evaluator maps it restrictively. 2. Eliminate the nullable-mode anti-pattern in the shared record path (backend.rs). The `value: Option<Decimal>` + `RecordKind` pair guarded by `debug_assert_eq` is replaced by a typed `RecordPlan` enum whose `Value` variant carries the Decimal and whose `Invocation` variant cannot. The common lock/trim/dedup/cap/quota steps run off a shared `PlanCommon`; only the INSERT column list and final aggregate dispatch on the variant. No nullable side-channel, no debug_assert invariant. 3. Adversarial LRU test now RETURNS each spawned task's backend result and asserts the allowed-outcome set explicitly, so a deadlock-detected / serialization DB error fails the test instead of passing silently as long as the task returns before the timeout. 4. New `scope_quota_is_enforced_exactly_not_best_effort` stateful test drives a sequential flood past the cap and asserts the scope holds at EXACTLY MAX_KEYS_PER_TENANT with oldest-front victims evicted — the regression guard for the best-effort BLOCKER. (This test caught an over-eviction bug in the first cut of the fix against a live Postgres.) Schema typed-columns (#2) and migration include_str! single-source (#4) were already landed in ef93722 / a784b0d on this branch; line refs in the new review pointed at the post-refactor file. Verified against a local Postgres 14: 7 lib unit + 8 adversarial + 9 contract tests green. fmt + clippy -D warnings clean crate-wide and workspace-wide. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): index-only scope-quota cover, u64 hash length prefix, defensive count clamp Address remaining review items on #3933 after the three blocker fixes landed in 741e0ec: - Expand the per-scope index on both typed tables to (scope_hash, key_hash, occurred_at) so enforce_scope_quota's COUNT(DISTINCT key_hash) and MIN(occurred_at) victim ranking run as index-only scans (gemini perf item, re-applied to the post-rewrite two-table schema). - Canonical hashing length prefix widened from 4-byte u32 (saturating) to 8-byte u64, making the usize->len conversion infallible and removing the saturation aliasing corner; keeps the serialization strictly injective. Doc updated to match. - Clamp pre_count with .max(0) before the usize cast in the fail-closed cap check, matching the codebase convention against negative-row wrap. Crate suite green (7 lib unit, 8 adversarial, 9 contract; PG-gated tests skip-pass without a live DB). fmt clean, clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-pg): address henrypark133 review on #3933 Performance (High): derive the post-insert in-window count as `pre_count + 1` instead of issuing a second COUNT, and return it directly as the invocation aggregate — removing 2 of the 3 COUNT round trips on the record() hot path. Provable under the per-key advisory lock: the dedup check proved the id absent, the cap gate passed, and the ON CONFLICT insert added exactly one in-window row, so no concurrent writer can perturb this key's count. The value aggregate stays a SUM query (not derivable from sample count). Quota gate now keys on `pre_count == 0` (equivalently the old `in_window_count == 1`). Performance (Low): pre-format every record()/reaper SQL statement once at construction (TableStatements per typed table) instead of `format!` allocating 5-6 throwaway Strings per call. Security (Low): map_pg/map_pool now log the raw DB error at warn and return a sanitized "backend unavailable" message through PredicateBackendError::Unavailable rather than leaking raw Postgres error text (which can embed connection/schema details) to callers. Tests (Medium): add evict_older_than_removes_stale_rows_from_both_tables adversarial test proving the time-based reaper deletes stale rows from both typed tables, spares in-window rows, and returns the correct count. Preserves the atomic record-and-read transaction, advisory-lock serialization, tenant-scoped keying, fail-closed quota posture, and shared identity hashing. Removed now-unused RecordPlan::table(). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-pg): sanitize quota fail-closed messages + cutoff overflow test (#3933) C-2: enforce_scope_quota built detailed Unavailable(format!(...)) payloads exposing MAX_KEYS_PER_TENANT and lock-contention state, bypassing the DB_UNAVAILABLE_MSG sanitization contract. Hoist both fail-closed messages into sanitized constants (QUOTA_CONTENDED_MSG / QUOTA_BUDGET_MSG); the operational detail now goes to tracing::debug! and the caller/evaluator only observes the error type, not the payload. T-2: add cutoff_with_overflow_window_saturates_to_now unit test asserting the Err(_) => now saturation arm of cutoff (window beyond chrono's max Duration trims nothing — conservative). Pure fn, no live Postgres. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(hooks-pg): make dual-backend compliance explicit (C-1) Address serrrfirat C-1 (Medium): the repo rule requiring both PostgreSQL and libSQL persistence backends is satisfied across the staged durable-backend series, not in this crate alone. Document the libSQL counterpart (ironclaw_hooks_libsql, PR #3936) and the cross-backend parity suite (ironclaw_hooks_parity, PR #3937) in the crate-level doc, including merge ordering, so the dependency is explicit per the reviewer's recommendation (b). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks-pg): round-2 review fixes for predicate state backend (#3933) Address henrypark133 round-2 inline findings: - High: add per_value_key_sample_cap_fails_closed_under_flood mirroring the invocation flood test against record_value — fills to MAX_SAMPLES_PER_KEY, asserts WindowOverflow at cap+1, and asserts an in-window replay at the cap dedups (sum unchanged), covering the value-table INSERT and aggregate_sum cap-reject path the invocation test did not exercise. - Medium: wrap evict_older_than's two table DELETEs in one READ COMMITTED transaction so the reaper is all-or-nothing (no partial reap leaving invocation rows gone and value rows present). - Medium: add invocation_and_value_scope_lock_keys_are_distinct pinning that b"i" and b"v" lock tags derive disjoint scope advisory keys for the same tenant (pure fn, no live Postgres). - Medium: add evictions_counter_unchanged_on_window_overflow asserting evictions_observed() does not advance when a per-key cap overflow rolls back. - Low: add crates/ironclaw_hooks_postgres/AGENTS.md modeled on the sibling durable-backend crates' files. - Low: delete the advisory_lock_key(&Digest) wrapper; call advisory_lock_key_from_bytes directly at both record/eviction call sites and in the unit tests (Digest slice-coerces at zero cost). - Low: replace TableStatements::new(table, with_value_column: bool) with named constructors for_invocations / for_values per the no-boolean-mode-flags convention. Postgres-backed adversarial tests are env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL and skip (passing) with no DB reachable; the two new ones could not be executed against a live Postgres locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…d PR 3/4, replaces #3930) (#3936) * feat(hooks): LibSqlPredicateStateBackend in own crate (durable backend PR 3/4) Durable libSQL-backed PredicateStateBackend satisfying the trait widened in #3927, with the same invariants as the in-memory backend: MAX_SAMPLES_PER_KEY per-key cap, MAX_HISTORY_KEYS / MAX_KEYS_PER_TENANT LRU, and replay-dedup. Crate move (vs the original #3930): the backend now lives in its own crate `ironclaw_hooks_libsql` rather than as an optional `libsql` feature on the framework crate. This keeps DB deps out of `ironclaw_hooks`, matching the intended `ironclaw_hooks_postgres` pattern (backend crate -> framework crate; no inversion). Structure: src/{lib,backend,hashing, schema}.rs. The `libsql` feature + dep and the `predicate_state::libsql` module were removed from `ironclaw_hooks`; the unused `tempfile` dev-dep was dropped too. `ironclaw_hooks` now builds with no libsql feature at all. Fail-closed alignment (#3929, now the public contract): the per-key cap returns PredicateBackendError::WindowOverflow when a scope is at MAX_SAMPLES_PER_KEY and a NEW distinct id arrives, instead of the prior drop-oldest. Dedup short-circuits BEFORE the overflow check, so a replay at the cap is a no-op (replay refusal survives the cap boundary). This matches the in-memory backend exactly and passes the record_invocation_overflow_is_fail_closed contract from #3927. - Atomic record-and-read via a single BEGIN IMMEDIATE transaction per record_* call so concurrent writers serialise on SQLite's write lock. - Value-sum recomputed from surviving rows inside the same transaction. - Clock basis: stores host-supplied now: DateTime<Utc> as epoch millis. - Replay-dedup via INSERT ... ON CONFLICT (scope_hash, event_id) DO NOTHING; event_id is TEXT (64-char blake3 hex, Codex #3635 finding). Test plan: shared contract harness (all 9 contracts incl. fail-closed) plus adversarial tests (two-host concurrent writes, cross-host replay dedup invocation+value, per-key cap fail-closed invocation+value, per-tenant LRU under bounded concurrent pressure, restart survival). The suite runs with harness=false and a small serial runner: each heavy case fills a key to MAX_SAMPLES_PER_KEY (4096 connect/BEGIN IMMEDIATE/ COMMIT cycles), and running several concurrently against the replication-enabled libSQL build intermittently trips SQLITE_MISUSE. Serial execution (concurrency cases get their own multi-thread runtime) makes it deterministic; the per-tenant flood keeps its bounded semaphore. 16 cases, all green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-libsql): bound connection concurrency + connect retry to prevent fail-closed under load The libSQL predicate-state backend opened a fresh connection per op with no in-process write serialization and no connect retry. Under production concurrency (e.g. a heartbeat tick evaluating many hooks at once) concurrent writers on a shared backend instance raced on the single SQLite write lock; in the replication-enabled libSQL build a raw BEGIN IMMEDIATE can return SQLITE_BUSY immediately instead of honouring busy_timeout, surfacing as Unavailable("database is locked") -> fail-closed predicate evaluation (a hook that should pass gets denied). Fix, mirroring the canonical libSQL pattern in src/db/libsql/ (LibSqlBackend / LibSqlWorkspaceStore): - Add a per-backend write_lock: Arc<Mutex<()>> held across the whole BEGIN IMMEDIATE..COMMIT for every mutating op (record_invocation, record_value, evict_older_than, run_migrations). Serialises in-process writers so they never contend at the SQLite layer. - Add connect retry with exponential backoff for transient open failures (SQLITE_CANTOPEN during concurrent opens), matching LibSqlBackend::connect. - Cross-instance contention still falls back to PRAGMA busy_timeout = 5000. Preserves BEGIN IMMEDIATE serialization and fail-closed WindowOverflow / the per-key cap exactly. Also (folded in from codex review of the parity PR #3937): remove the global MAX_HISTORY_KEYS cap from the libSQL backend. That cap is an in-memory-only memory-footprint bound (threat-model D5); durable backends are not memory-bound and reap via evict_older_than + the per-tenant MAX_KEYS_PER_TENANT quota. The Postgres sibling has no global cap, so enforcing one in libSQL diverged the two backends once total scopes exceeded 8192 under the per-tenant quota. Dropping it restores parity; evict_oldest_scope is now always tenant-scoped. Tests (serial-runner suite): - two_independent_db_handles_flood_no_handle_exhaustion: two independently created libsql::Database handles on the same temp file flood concurrently with NO test-side admission semaphore. - no_global_key_cap_only_per_tenant: many tenants under quota retain all scopes with zero evictions (would fail if a global cap still evicted cross-tenant). - Removed the now-unnecessary test-side semaphore from per_tenant_quota_isolates_under_concurrent_pressure (backend self-serialises). The harness = false serial runner is still required: 6 concurrent heavy fills as default-harness tests reproduce a DRIVER-LEVEL SQLITE_MISUSE across independent Database handles every run even with the write_lock (verified empirically). That is distinct from the production fail-closed risk this fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonical typed schema columns + .sql migration Align the libSQL backend's column set with the Postgres sibling so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Column changes per table: - Add `key_hash` (full bucket digest) as the dedup/count grain + PRIMARY KEY (key_hash, event_id). The column previously called `scope_hash` actually held the full bucket key; it is renamed `key_hash`. - Add a real `scope_hash` = blake3(tenant_id) (the tenant grain) and DROP the raw `tenant_id` TEXT column. The per-tenant LRU quota now counts COUNT(DISTINCT key_hash) WHERE scope_hash = ?, matching Postgres. - `event_id` / `occurred_at` keep the canonical names. libSQL native types unchanged where right: BLOB hashes, INTEGER epoch-ms occurred_at, TEXT exact-decimal value. Schema is now sourced from migrations/V1__predicate_state.sql via include_str! (kills the hand-maintained LIBSQL_PREDICATE_STATE_SCHEMA const body, per the batchb-3933 note). hashing.rs renamed invocation_scope_hash/ value_scope_hash -> invocation_key_hash/value_key_hash and adds tenant_scope_hash, with a folded map discriminant so invocation/value keys never collide. Added a doc(hidden) test_support::tenant_scope_hash_bytes so the contract test can query by the tenant digest now that tenant_id is gone. Invariants preserved: oldest-front MIN(occurred_at) per-key LRU victim, fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK (key_hash, event_id) + ON CONFLICT DO NOTHING, per-tenant quota (no global cap), BEGIN IMMEDIATE + in-process write_lock serialization, connect retry. evict_older_than reaps both tables by occurred_at. Full libSQL contract + adversarial suite green (embedded temp-file db). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonicalize contract list, DRY record state machine, align window-cutoff Address serrrfirat maintainability review on PR #3936: - P1: drive the libSQL serial-runner case inventory from a new canonical `predicate_backend_contract_cases!` macro in `ironclaw_hooks`. Both the default-harness `predicate_backend_contract_test!` and the libSQL `harness=false` serial runner now expand the same single source-of-truth case list, so a contract added upstream auto-runs against libSQL with no hand-maintained second list to drift. - P2: extract the duplicated record transaction state machine (trim -> replay check -> overflow check -> quota eviction -> insert -> read -> commit) into one `LibSqlPredicateStateBackend::record` parameterized on a per-table `RecordSpec` (table, hashes, overflow key, insert shape, result read). Behavior, atomicity, fail-closed, and BEGIN IMMEDIATE / write-lock guarantees unchanged. - P2: make `ironclaw_hooks::predicate_state::window_cutoff` public as the canonical cross-backend cutoff and have libSQL's `window_cutoff_millis` delegate to it, then project onto epoch-millis. Removes the divergent i64::MAX-saturating reimplementation that trimmed nothing on oversized windows (canonical trims to `now`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): index-served LRU victim selection; document PRAGMA query choice Address gemini review on #3936: - evict_oldest_key: replace the `GROUP BY key_hash ORDER BY min(occurred_at)` aggregation with `ORDER BY occurred_at ASC, key_hash ASC LIMIT 1`. The key owning the globally-oldest row in a scope is exactly the key with the smallest per-key front timestamp, so this picks the same deterministic victim without a global grouping. Extend the per-scope indexes to `(scope_hash, occurred_at)` so the new ordering is index-served. - connect(): keep `query` (not `execute`) for `PRAGMA busy_timeout = N` and document why — this libSQL build echoes the new value back as a result row, so `execute` fails with `Execute returned rows`. Verified against the contract suite. Contract suite (all 9 canonical cases + durable-specific cases incl. per-tenant quota / LRU eviction) passes; fmt + clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-libsql): rollback on COMMIT failure, covering index, LIMIT 1 existence, cross-process test (#3936) Address review findings on the durable libSQL predicate-state backend: - M1: explicit best-effort ROLLBACK before returning the mapped error when COMMIT fails, in finish_txn, evict_older_than, and run_migrations. A failed COMMIT could leave libSQL replication state indeterminate without it. - M2: add covering composite index (scope_hash, key_hash) on both the invocations and values tables so the per-tenant COUNT(DISTINCT key_hash) WHERE scope_hash = ? in enforce_caps is answered index-only. The existing (scope_hash, occurred_at) index stays for LRU victim selection. - L1: switch key_exists and event_id_exists from COUNT(*) to SELECT 1 ... LIMIT 1 via a shared row_exists helper (O(1) vs O(N) for the multi-row key_exists; event_id_exists is a PK point lookup, converted for consistency). - L2: remove the unused tracing dependency (no tracing:: call in the crate). - L3: add separate_handle_backends helper that opens two independent Builder::new_local(path) handles to the same file plus a separate_handles_share_durable_state case exercising the true cross-process OS-file-lock path, and document the in-process vs cross-process distinction on shared_db_backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts: # Cargo.lock # Cargo.toml # crates/ironclaw_hooks/src/predicate_state.rs # crates/ironclaw_hooks_libsql/Cargo.toml # crates/ironclaw_hooks_libsql/migrations/V1__predicate_state.sql # crates/ironclaw_hooks_libsql/src/backend.rs # crates/ironclaw_hooks_libsql/src/hashing.rs # crates/ironclaw_hooks_libsql/src/lib.rs # crates/ironclaw_hooks_libsql/tests/predicate_state_contract.rs # crates/ironclaw_hooks_postgres/migrations/V1__predicate_state.sql # crates/ironclaw_hooks_postgres/src/backend.rs # crates/ironclaw_hooks_postgres/src/hashing.rs # crates/ironclaw_hooks_postgres/src/lib.rs # crates/ironclaw_hooks_postgres/src/schema.rs # crates/ironclaw_hooks_postgres/tests/predicate_state_postgres_adversarial.rs # crates/ironclaw_hooks_postgres/tests/predicate_state_postgres_contract.rs
…eplaces nearai#3932) (nearai#3933) * feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4) Implements the durable PostgreSQL backend for predicate sliding-window state, satisfying the `PredicateStateBackend` contract widened to a public async trait in PR nearai#3927. New crate `ironclaw_hooks_postgres` (out-of-crate durable backend, the pattern the `contract-tests` feature on `ironclaw_hooks` was designed for; mirrors `ironclaw_reborn_event_store`). Keeps the Postgres dependency surface out of the hook framework itself. Correctness: - Atomic record-and-read: each `record_*` runs in one READ COMMITTED tx guarded by a transaction-scoped advisory lock on the bucket. The advisory lock (not REPEATABLE READ) serializes same-key writers by BLOCKING the second writer rather than aborting it with a serialization failure — no caller retry loop. Trim, insert, cap-evict, aggregate all share the one tx. - Cross-host replay dedup via PRIMARY KEY (key_hash, id) + INSERT ON CONFLICT DO NOTHING — exact regardless of clock skew. - Running-sum consistency under eviction: cap eviction re-aggregates inside the same tx so the returned sum reflects dropped rows. - Per-key sample cap (MAX_SAMPLES_PER_KEY, drop-oldest) and per-scope distinct-key LRU quota (MAX_KEYS_PER_TENANT) match the in-memory backend. Scope-quota enforcement takes a second advisory lock in the disjoint (int8) lock space so concurrent new-key inserts in a scope don't under-evict. Schema: single `hook_predicate_counters` table, BYTEA blake3 hash keys, TEXT id column (NOT uuid — Postgres uuid rejects the 64-char blake3 hex digest; resolves the nearai#3635 docs/schema contradiction in favor of TEXT). Idempotent CREATE ... IF NOT EXISTS applied via run_migrations(); the per-crate pattern, not the legacy main-binary refinery migrations. DB-clock decision: window comparison basis is the caller's `now` (the same Utc::now() the in-memory backend trusts), making this a drop-in under the deterministic-clock contract harness. Replay dedup and atomicity do NOT depend on clock agreement. Tests (env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL, skip when absent; schema-isolated so the two test binaries can share one DB): - All 8 shared contract functions via the `contract-tests` harness. - Adversarial: two-host write storm (no count desync), cross-host replay (count + value), per-key cap flood, per-scope LRU under concurrent insert pressure across two hosts. - 4 hashing unit tests (injective length-prefixed serialization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): take victim-key advisory lock during scope-LRU eviction (deadlock + race) enforce_scope_quota deleted victim buckets' rows while holding only the per-scope advisory lock, never the victims' per-key advisory lock. That broke the per-bucket serialization guarantee in two ways: * Torn aggregate: the LRU pass could delete key B's rows while another transaction was recording B under B's own per-key lock, so the recorder's COUNT/SUM straddled a delete it never serialized against. * Deadlock: a recorder of victim key B holds B's per-key lock and then waits on the scope lock inside its own quota pass, while the LRU transaction holds the scope lock and waits on B's row locks to delete them -> cycle. Fix: evict victims one at a time, each guarded by that victim key's per-key advisory lock acquired with the NON-blocking pg_try_advisory_xact_lock. An in-flight victim (lock already held by a concurrent recorder) is skipped, not waited on, so the deadlock cycle cannot form and eviction never deletes rows out from under an unserialized transaction. Candidates are over-fetched beyond the evict count so skips still meet the per-scope quota. Fail-closed WindowOverflow semantics and the dedup/atomicity invariants are unchanged. Adds a lib unit test pinning the eviction try-lock key derivation equal to the recorder's lock key (lock-acquisition invariant, no DB needed), a deterministic raw-SQL regression test proving the try-lock on an in-flight victim returns immediately rather than blocking, and a concurrency stress test asserting no deadlock and no torn aggregate. The integration tests gate on IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL and were verified against a real Postgres 14. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-postgres): make migration SQL the single source via include_str! firat (PR nearai#3933 maintainability audit): schema.rs embedded a hand-copied DDL const documented as "byte-compatible" with migrations/V1__predicate_counters.sql, with nothing enforcing equivalence — silent drift risk. Pull the DDL directly from the .sql file via include_str! so there is exactly one copy. batch_execute tolerates the file's leading -- comment block, so no statement splitting is needed. run_migrations is exercised by the contract/adversarial suites, confirming the included SQL applies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): rank LRU eviction victims by MIN(ts), matching in-memory/libSQL serrrfirat (PR nearai#3933): the Postgres scope-LRU victim query ranked keys by MAX(ts) (most-recent activity) while the in-memory backend evicts by each bucket's OLDEST retained sample (entries.front() + min_by_key) and libSQL does the same (oldest-front). The doc comment claimed oldest-front but the SQL did MAX(ts). Under multi-sample keys this diverges: a key with one ancient + one fresh sample is spared by MAX(ts) but evicted by the in-memory/libSQL MIN(ts) ranking, so the three backends evict different keys. The single-sample- per-key parity matrix masks it (MIN == MAX). Fix: rank by MIN(ts) per key and update the module/fn docs to match the actual behavior. Follow-up (routed separately): the nearai#3937 parity suite needs a multi-sample LRU case to lock this in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-postgres): canonical typed two-table predicate schema Replace the single generic hook_predicate_counters(kind CHAR(1), id, ts, value NUMERIC) table with two explicit typed tables matching the libSQL backend's two-table model, so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Canonical schema (both backends): - hooks_predicate_invocations(scope_hash, key_hash, event_id, occurred_at), PK (key_hash, event_id); in-window COUNT(*) is the invocation count. - hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at, value NOT NULL), PK (key_hash, event_id); in-window SUM(value) is the sum. Postgres native types unchanged where right: BYTEA hashes, TIMESTAMPTZ occurred_at, NUMERIC value. Column renames id->event_id, ts->occurred_at to the canonical names. The kind discriminator column is gone (table identity carries it); the value: Option<Decimal> double-duty smuggling serrrfirat flagged (backend.rs:138) is eliminated — the invocation table has no value column and the value table's value is NOT NULL, with a per-table aggregate() helper so COUNT vs SUM is explicit. Migration file renamed V1__predicate_counters.sql -> V1__predicate_state.sql, still the include_str! single source of truth. Invariants preserved: MIN(occurred_at) oldest-front LRU victim rule (the 0c102a6 fix), fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK + ON CONFLICT DO NOTHING, per-tenant distinct-key quota (COUNT(DISTINCT key_hash) WHERE scope_hash, no global cap), per-key advisory lock + non-blocking victim try-lock, scope advisory lock now folds a per-table tag instead of the kind column. evict_older_than reaps both tables. SQL paths compile + skip-pass without a reachable Postgres; the typed schema needs the nearai#3937 PG CI leg for a live run before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): typed RecordPlan + fail-closed scope quota + asserting LRU test Addresses serrrfirat's post-typed-schema maintainability review on nearai#3933. 1. Quota enforcement is now an explicit OUTCOME, not silently best-effort (backend.rs). `enforce_scope_quota` previously over-fetched a fixed 64 candidates, skipped locked victims, and committed `Ok(evicted)` even if the scope was still above MAX_KEYS_PER_TENANT — making the per-scope bound an accident of how many victims happened to be in-flight. It now requeries the distinct count + a fresh victim batch each pass and keeps evicting (deterministic oldest-front victim, per-key try-lock before delete, consistent lock order) until the cap is actually met, evicting EXACTLY the per-pass deficit so it can never over-evict. If a whole pass makes zero progress while still over cap (every stale candidate locked by an in-flight recorder) it FAILS CLOSED with Unavailable rather than committing an over-quota scope; the caller's txn rolls back and the evaluator maps it restrictively. 2. Eliminate the nullable-mode anti-pattern in the shared record path (backend.rs). The `value: Option<Decimal>` + `RecordKind` pair guarded by `debug_assert_eq` is replaced by a typed `RecordPlan` enum whose `Value` variant carries the Decimal and whose `Invocation` variant cannot. The common lock/trim/dedup/cap/quota steps run off a shared `PlanCommon`; only the INSERT column list and final aggregate dispatch on the variant. No nullable side-channel, no debug_assert invariant. 3. Adversarial LRU test now RETURNS each spawned task's backend result and asserts the allowed-outcome set explicitly, so a deadlock-detected / serialization DB error fails the test instead of passing silently as long as the task returns before the timeout. 4. New `scope_quota_is_enforced_exactly_not_best_effort` stateful test drives a sequential flood past the cap and asserts the scope holds at EXACTLY MAX_KEYS_PER_TENANT with oldest-front victims evicted — the regression guard for the best-effort BLOCKER. (This test caught an over-eviction bug in the first cut of the fix against a live Postgres.) Schema typed-columns (#2) and migration include_str! single-source (#4) were already landed in ef93722 / a784b0d on this branch; line refs in the new review pointed at the post-refactor file. Verified against a local Postgres 14: 7 lib unit + 8 adversarial + 9 contract tests green. fmt + clippy -D warnings clean crate-wide and workspace-wide. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): index-only scope-quota cover, u64 hash length prefix, defensive count clamp Address remaining review items on nearai#3933 after the three blocker fixes landed in 741e0ec: - Expand the per-scope index on both typed tables to (scope_hash, key_hash, occurred_at) so enforce_scope_quota's COUNT(DISTINCT key_hash) and MIN(occurred_at) victim ranking run as index-only scans (gemini perf item, re-applied to the post-rewrite two-table schema). - Canonical hashing length prefix widened from 4-byte u32 (saturating) to 8-byte u64, making the usize->len conversion infallible and removing the saturation aliasing corner; keeps the serialization strictly injective. Doc updated to match. - Clamp pre_count with .max(0) before the usize cast in the fail-closed cap check, matching the codebase convention against negative-row wrap. Crate suite green (7 lib unit, 8 adversarial, 9 contract; PG-gated tests skip-pass without a live DB). fmt clean, clippy -D warnings clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-pg): address henrypark133 review on nearai#3933 Performance (High): derive the post-insert in-window count as `pre_count + 1` instead of issuing a second COUNT, and return it directly as the invocation aggregate — removing 2 of the 3 COUNT round trips on the record() hot path. Provable under the per-key advisory lock: the dedup check proved the id absent, the cap gate passed, and the ON CONFLICT insert added exactly one in-window row, so no concurrent writer can perturb this key's count. The value aggregate stays a SUM query (not derivable from sample count). Quota gate now keys on `pre_count == 0` (equivalently the old `in_window_count == 1`). Performance (Low): pre-format every record()/reaper SQL statement once at construction (TableStatements per typed table) instead of `format!` allocating 5-6 throwaway Strings per call. Security (Low): map_pg/map_pool now log the raw DB error at warn and return a sanitized "backend unavailable" message through PredicateBackendError::Unavailable rather than leaking raw Postgres error text (which can embed connection/schema details) to callers. Tests (Medium): add evict_older_than_removes_stale_rows_from_both_tables adversarial test proving the time-based reaper deletes stale rows from both typed tables, spares in-window rows, and returns the correct count. Preserves the atomic record-and-read transaction, advisory-lock serialization, tenant-scoped keying, fail-closed quota posture, and shared identity hashing. Removed now-unused RecordPlan::table(). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-pg): sanitize quota fail-closed messages + cutoff overflow test (nearai#3933) C-2: enforce_scope_quota built detailed Unavailable(format!(...)) payloads exposing MAX_KEYS_PER_TENANT and lock-contention state, bypassing the DB_UNAVAILABLE_MSG sanitization contract. Hoist both fail-closed messages into sanitized constants (QUOTA_CONTENDED_MSG / QUOTA_BUDGET_MSG); the operational detail now goes to tracing::debug! and the caller/evaluator only observes the error type, not the payload. T-2: add cutoff_with_overflow_window_saturates_to_now unit test asserting the Err(_) => now saturation arm of cutoff (window beyond chrono's max Duration trims nothing — conservative). Pure fn, no live Postgres. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(hooks-pg): make dual-backend compliance explicit (C-1) Address serrrfirat C-1 (Medium): the repo rule requiring both PostgreSQL and libSQL persistence backends is satisfied across the staged durable-backend series, not in this crate alone. Document the libSQL counterpart (ironclaw_hooks_libsql, PR nearai#3936) and the cross-backend parity suite (ironclaw_hooks_parity, PR nearai#3937) in the crate-level doc, including merge ordering, so the dependency is explicit per the reviewer's recommendation (b). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks-pg): round-2 review fixes for predicate state backend (nearai#3933) Address henrypark133 round-2 inline findings: - High: add per_value_key_sample_cap_fails_closed_under_flood mirroring the invocation flood test against record_value — fills to MAX_SAMPLES_PER_KEY, asserts WindowOverflow at cap+1, and asserts an in-window replay at the cap dedups (sum unchanged), covering the value-table INSERT and aggregate_sum cap-reject path the invocation test did not exercise. - Medium: wrap evict_older_than's two table DELETEs in one READ COMMITTED transaction so the reaper is all-or-nothing (no partial reap leaving invocation rows gone and value rows present). - Medium: add invocation_and_value_scope_lock_keys_are_distinct pinning that b"i" and b"v" lock tags derive disjoint scope advisory keys for the same tenant (pure fn, no live Postgres). - Medium: add evictions_counter_unchanged_on_window_overflow asserting evictions_observed() does not advance when a per-key cap overflow rolls back. - Low: add crates/ironclaw_hooks_postgres/AGENTS.md modeled on the sibling durable-backend crates' files. - Low: delete the advisory_lock_key(&Digest) wrapper; call advisory_lock_key_from_bytes directly at both record/eviction call sites and in the unit tests (Digest slice-coerces at zero cost). - Low: replace TableStatements::new(table, with_value_column: bool) with named constructors for_invocations / for_values per the no-boolean-mode-flags convention. Postgres-backed adversarial tests are env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL and skip (passing) with no DB reachable; the two new ones could not be executed against a live Postgres locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…d PR 3/4, replaces nearai#3930) (nearai#3936) * feat(hooks): LibSqlPredicateStateBackend in own crate (durable backend PR 3/4) Durable libSQL-backed PredicateStateBackend satisfying the trait widened in nearai#3927, with the same invariants as the in-memory backend: MAX_SAMPLES_PER_KEY per-key cap, MAX_HISTORY_KEYS / MAX_KEYS_PER_TENANT LRU, and replay-dedup. Crate move (vs the original nearai#3930): the backend now lives in its own crate `ironclaw_hooks_libsql` rather than as an optional `libsql` feature on the framework crate. This keeps DB deps out of `ironclaw_hooks`, matching the intended `ironclaw_hooks_postgres` pattern (backend crate -> framework crate; no inversion). Structure: src/{lib,backend,hashing, schema}.rs. The `libsql` feature + dep and the `predicate_state::libsql` module were removed from `ironclaw_hooks`; the unused `tempfile` dev-dep was dropped too. `ironclaw_hooks` now builds with no libsql feature at all. Fail-closed alignment (nearai#3929, now the public contract): the per-key cap returns PredicateBackendError::WindowOverflow when a scope is at MAX_SAMPLES_PER_KEY and a NEW distinct id arrives, instead of the prior drop-oldest. Dedup short-circuits BEFORE the overflow check, so a replay at the cap is a no-op (replay refusal survives the cap boundary). This matches the in-memory backend exactly and passes the record_invocation_overflow_is_fail_closed contract from nearai#3927. - Atomic record-and-read via a single BEGIN IMMEDIATE transaction per record_* call so concurrent writers serialise on SQLite's write lock. - Value-sum recomputed from surviving rows inside the same transaction. - Clock basis: stores host-supplied now: DateTime<Utc> as epoch millis. - Replay-dedup via INSERT ... ON CONFLICT (scope_hash, event_id) DO NOTHING; event_id is TEXT (64-char blake3 hex, Codex nearai#3635 finding). Test plan: shared contract harness (all 9 contracts incl. fail-closed) plus adversarial tests (two-host concurrent writes, cross-host replay dedup invocation+value, per-key cap fail-closed invocation+value, per-tenant LRU under bounded concurrent pressure, restart survival). The suite runs with harness=false and a small serial runner: each heavy case fills a key to MAX_SAMPLES_PER_KEY (4096 connect/BEGIN IMMEDIATE/ COMMIT cycles), and running several concurrently against the replication-enabled libSQL build intermittently trips SQLITE_MISUSE. Serial execution (concurrency cases get their own multi-thread runtime) makes it deterministic; the per-tenant flood keeps its bounded semaphore. 16 cases, all green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-libsql): bound connection concurrency + connect retry to prevent fail-closed under load The libSQL predicate-state backend opened a fresh connection per op with no in-process write serialization and no connect retry. Under production concurrency (e.g. a heartbeat tick evaluating many hooks at once) concurrent writers on a shared backend instance raced on the single SQLite write lock; in the replication-enabled libSQL build a raw BEGIN IMMEDIATE can return SQLITE_BUSY immediately instead of honouring busy_timeout, surfacing as Unavailable("database is locked") -> fail-closed predicate evaluation (a hook that should pass gets denied). Fix, mirroring the canonical libSQL pattern in src/db/libsql/ (LibSqlBackend / LibSqlWorkspaceStore): - Add a per-backend write_lock: Arc<Mutex<()>> held across the whole BEGIN IMMEDIATE..COMMIT for every mutating op (record_invocation, record_value, evict_older_than, run_migrations). Serialises in-process writers so they never contend at the SQLite layer. - Add connect retry with exponential backoff for transient open failures (SQLITE_CANTOPEN during concurrent opens), matching LibSqlBackend::connect. - Cross-instance contention still falls back to PRAGMA busy_timeout = 5000. Preserves BEGIN IMMEDIATE serialization and fail-closed WindowOverflow / the per-key cap exactly. Also (folded in from codex review of the parity PR nearai#3937): remove the global MAX_HISTORY_KEYS cap from the libSQL backend. That cap is an in-memory-only memory-footprint bound (threat-model D5); durable backends are not memory-bound and reap via evict_older_than + the per-tenant MAX_KEYS_PER_TENANT quota. The Postgres sibling has no global cap, so enforcing one in libSQL diverged the two backends once total scopes exceeded 8192 under the per-tenant quota. Dropping it restores parity; evict_oldest_scope is now always tenant-scoped. Tests (serial-runner suite): - two_independent_db_handles_flood_no_handle_exhaustion: two independently created libsql::Database handles on the same temp file flood concurrently with NO test-side admission semaphore. - no_global_key_cap_only_per_tenant: many tenants under quota retain all scopes with zero evictions (would fail if a global cap still evicted cross-tenant). - Removed the now-unnecessary test-side semaphore from per_tenant_quota_isolates_under_concurrent_pressure (backend self-serialises). The harness = false serial runner is still required: 6 concurrent heavy fills as default-harness tests reproduce a DRIVER-LEVEL SQLITE_MISUSE across independent Database handles every run even with the write_lock (verified empirically). That is distinct from the production fail-closed risk this fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonical typed schema columns + .sql migration Align the libSQL backend's column set with the Postgres sibling so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Column changes per table: - Add `key_hash` (full bucket digest) as the dedup/count grain + PRIMARY KEY (key_hash, event_id). The column previously called `scope_hash` actually held the full bucket key; it is renamed `key_hash`. - Add a real `scope_hash` = blake3(tenant_id) (the tenant grain) and DROP the raw `tenant_id` TEXT column. The per-tenant LRU quota now counts COUNT(DISTINCT key_hash) WHERE scope_hash = ?, matching Postgres. - `event_id` / `occurred_at` keep the canonical names. libSQL native types unchanged where right: BLOB hashes, INTEGER epoch-ms occurred_at, TEXT exact-decimal value. Schema is now sourced from migrations/V1__predicate_state.sql via include_str! (kills the hand-maintained LIBSQL_PREDICATE_STATE_SCHEMA const body, per the batchb-3933 note). hashing.rs renamed invocation_scope_hash/ value_scope_hash -> invocation_key_hash/value_key_hash and adds tenant_scope_hash, with a folded map discriminant so invocation/value keys never collide. Added a doc(hidden) test_support::tenant_scope_hash_bytes so the contract test can query by the tenant digest now that tenant_id is gone. Invariants preserved: oldest-front MIN(occurred_at) per-key LRU victim, fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK (key_hash, event_id) + ON CONFLICT DO NOTHING, per-tenant quota (no global cap), BEGIN IMMEDIATE + in-process write_lock serialization, connect retry. evict_older_than reaps both tables by occurred_at. Full libSQL contract + adversarial suite green (embedded temp-file db). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonicalize contract list, DRY record state machine, align window-cutoff Address serrrfirat maintainability review on PR nearai#3936: - P1: drive the libSQL serial-runner case inventory from a new canonical `predicate_backend_contract_cases!` macro in `ironclaw_hooks`. Both the default-harness `predicate_backend_contract_test!` and the libSQL `harness=false` serial runner now expand the same single source-of-truth case list, so a contract added upstream auto-runs against libSQL with no hand-maintained second list to drift. - P2: extract the duplicated record transaction state machine (trim -> replay check -> overflow check -> quota eviction -> insert -> read -> commit) into one `LibSqlPredicateStateBackend::record` parameterized on a per-table `RecordSpec` (table, hashes, overflow key, insert shape, result read). Behavior, atomicity, fail-closed, and BEGIN IMMEDIATE / write-lock guarantees unchanged. - P2: make `ironclaw_hooks::predicate_state::window_cutoff` public as the canonical cross-backend cutoff and have libSQL's `window_cutoff_millis` delegate to it, then project onto epoch-millis. Removes the divergent i64::MAX-saturating reimplementation that trimmed nothing on oversized windows (canonical trims to `now`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): index-served LRU victim selection; document PRAGMA query choice Address gemini review on nearai#3936: - evict_oldest_key: replace the `GROUP BY key_hash ORDER BY min(occurred_at)` aggregation with `ORDER BY occurred_at ASC, key_hash ASC LIMIT 1`. The key owning the globally-oldest row in a scope is exactly the key with the smallest per-key front timestamp, so this picks the same deterministic victim without a global grouping. Extend the per-scope indexes to `(scope_hash, occurred_at)` so the new ordering is index-served. - connect(): keep `query` (not `execute`) for `PRAGMA busy_timeout = N` and document why — this libSQL build echoes the new value back as a result row, so `execute` fails with `Execute returned rows`. Verified against the contract suite. Contract suite (all 9 canonical cases + durable-specific cases incl. per-tenant quota / LRU eviction) passes; fmt + clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-libsql): rollback on COMMIT failure, covering index, LIMIT 1 existence, cross-process test (nearai#3936) Address review findings on the durable libSQL predicate-state backend: - M1: explicit best-effort ROLLBACK before returning the mapped error when COMMIT fails, in finish_txn, evict_older_than, and run_migrations. A failed COMMIT could leave libSQL replication state indeterminate without it. - M2: add covering composite index (scope_hash, key_hash) on both the invocations and values tables so the per-tenant COUNT(DISTINCT key_hash) WHERE scope_hash = ? in enforce_caps is answered index-only. The existing (scope_hash, occurred_at) index stays for LRU victim selection. - L1: switch key_exists and event_id_exists from COUNT(*) to SELECT 1 ... LIMIT 1 via a shared row_exists helper (O(1) vs O(N) for the multi-row key_exists; event_id_exists is a PK point lookup, converted for consistency). - L2: remove the unused tracing dependency (no tracing:: call in the crate). - L3: add separate_handle_backends helper that opens two independent Builder::new_local(path) handles to the same file plus a separate_handles_share_durable_state case exercising the true cross-process OS-file-lock path, and document the in-process vs cross-process distinction on shared_db_backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…rable backend PR 4/4) (nearai#3937) * feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4) Implements the durable PostgreSQL backend for predicate sliding-window state, satisfying the `PredicateStateBackend` contract widened to a public async trait in PR nearai#3927. New crate `ironclaw_hooks_postgres` (out-of-crate durable backend, the pattern the `contract-tests` feature on `ironclaw_hooks` was designed for; mirrors `ironclaw_reborn_event_store`). Keeps the Postgres dependency surface out of the hook framework itself. Correctness: - Atomic record-and-read: each `record_*` runs in one READ COMMITTED tx guarded by a transaction-scoped advisory lock on the bucket. The advisory lock (not REPEATABLE READ) serializes same-key writers by BLOCKING the second writer rather than aborting it with a serialization failure — no caller retry loop. Trim, insert, cap-evict, aggregate all share the one tx. - Cross-host replay dedup via PRIMARY KEY (key_hash, id) + INSERT ON CONFLICT DO NOTHING — exact regardless of clock skew. - Running-sum consistency under eviction: cap eviction re-aggregates inside the same tx so the returned sum reflects dropped rows. - Per-key sample cap (MAX_SAMPLES_PER_KEY, drop-oldest) and per-scope distinct-key LRU quota (MAX_KEYS_PER_TENANT) match the in-memory backend. Scope-quota enforcement takes a second advisory lock in the disjoint (int8) lock space so concurrent new-key inserts in a scope don't under-evict. Schema: single `hook_predicate_counters` table, BYTEA blake3 hash keys, TEXT id column (NOT uuid — Postgres uuid rejects the 64-char blake3 hex digest; resolves the nearai#3635 docs/schema contradiction in favor of TEXT). Idempotent CREATE ... IF NOT EXISTS applied via run_migrations(); the per-crate pattern, not the legacy main-binary refinery migrations. DB-clock decision: window comparison basis is the caller's `now` (the same Utc::now() the in-memory backend trusts), making this a drop-in under the deterministic-clock contract harness. Replay dedup and atomicity do NOT depend on clock agreement. Tests (env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL, skip when absent; schema-isolated so the two test binaries can share one DB): - All 8 shared contract functions via the `contract-tests` harness. - Adversarial: two-host write storm (no count desync), cross-host replay (count + value), per-key cap flood, per-scope LRU under concurrent insert pressure across two hosts. - 4 hashing unit tests (injective length-prefixed serialization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): LibSqlPredicateStateBackend in own crate (durable backend PR 3/4) Durable libSQL-backed PredicateStateBackend satisfying the trait widened in nearai#3927, with the same invariants as the in-memory backend: MAX_SAMPLES_PER_KEY per-key cap, MAX_HISTORY_KEYS / MAX_KEYS_PER_TENANT LRU, and replay-dedup. Crate move (vs the original nearai#3930): the backend now lives in its own crate `ironclaw_hooks_libsql` rather than as an optional `libsql` feature on the framework crate. This keeps DB deps out of `ironclaw_hooks`, matching the intended `ironclaw_hooks_postgres` pattern (backend crate -> framework crate; no inversion). Structure: src/{lib,backend,hashing, schema}.rs. The `libsql` feature + dep and the `predicate_state::libsql` module were removed from `ironclaw_hooks`; the unused `tempfile` dev-dep was dropped too. `ironclaw_hooks` now builds with no libsql feature at all. Fail-closed alignment (nearai#3929, now the public contract): the per-key cap returns PredicateBackendError::WindowOverflow when a scope is at MAX_SAMPLES_PER_KEY and a NEW distinct id arrives, instead of the prior drop-oldest. Dedup short-circuits BEFORE the overflow check, so a replay at the cap is a no-op (replay refusal survives the cap boundary). This matches the in-memory backend exactly and passes the record_invocation_overflow_is_fail_closed contract from nearai#3927. - Atomic record-and-read via a single BEGIN IMMEDIATE transaction per record_* call so concurrent writers serialise on SQLite's write lock. - Value-sum recomputed from surviving rows inside the same transaction. - Clock basis: stores host-supplied now: DateTime<Utc> as epoch millis. - Replay-dedup via INSERT ... ON CONFLICT (scope_hash, event_id) DO NOTHING; event_id is TEXT (64-char blake3 hex, Codex nearai#3635 finding). Test plan: shared contract harness (all 9 contracts incl. fail-closed) plus adversarial tests (two-host concurrent writes, cross-host replay dedup invocation+value, per-key cap fail-closed invocation+value, per-tenant LRU under bounded concurrent pressure, restart survival). The suite runs with harness=false and a small serial runner: each heavy case fills a key to MAX_SAMPLES_PER_KEY (4096 connect/BEGIN IMMEDIATE/ COMMIT cycles), and running several concurrently against the replication-enabled libSQL build intermittently trips SQLITE_MISUSE. Serial execution (concurrency cases get their own multi-thread runtime) makes it deterministic; the per-tenant flood keeps its bounded semaphore. 16 cases, all green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): cross-backend adversarial parity suite + A3 closeout (durable backend PR 4/4) Add `ironclaw_hooks_parity`, a test-only crate that feeds ONE deterministic scripted sequence of record_invocation/record_value calls to all three PredicateStateBackend implementations (in-memory, Postgres, libSQL) and cross-asserts identical observation logs — proving the backends are behaviorally interchangeable. Parity matrix (tests/parity_matrix.rs), always runs in-memory + libSQL, Postgres compiled under --features postgres and run with a DB URL: - core behavioral script: counts, sums (incl. fractional), window trim, exact-cutoff retain, per-key replay dedup, tenant isolation, cross-map dedup isolation - fail-closed cap script: WindowOverflow at MAX_SAMPLES_PER_KEY, replay no-op at the cap - per-tenant LRU script: same eviction victim + evictions_observed() across backends Multi-host adversarial (tests/multi_host_adversarial.rs, --features integration): N concurrent writers/2 hosts no desync, cross-host replay exactly-once, LRU eviction race holds quota, per-key cap fail-closed under flood, clock-skew follows caller-supplied DateTime<Utc> basis. libSQL legs run unconditionally (embedded temp-file db); Postgres legs env-gated. Documents the final landed shape and closes the A3 multi-host-replay-bypass deferral from nearai#3635 in 03-persistent-counter.md. Merges origin/hooks-predicate-backend-postgres (nearai#3933) and origin/hooks-predicate-backend-libsql (nearai#3936) into one tree so all three backends are available together. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): take victim-key advisory lock during scope-LRU eviction (deadlock + race) enforce_scope_quota deleted victim buckets' rows while holding only the per-scope advisory lock, never the victims' per-key advisory lock. That broke the per-bucket serialization guarantee in two ways: * Torn aggregate: the LRU pass could delete key B's rows while another transaction was recording B under B's own per-key lock, so the recorder's COUNT/SUM straddled a delete it never serialized against. * Deadlock: a recorder of victim key B holds B's per-key lock and then waits on the scope lock inside its own quota pass, while the LRU transaction holds the scope lock and waits on B's row locks to delete them -> cycle. Fix: evict victims one at a time, each guarded by that victim key's per-key advisory lock acquired with the NON-blocking pg_try_advisory_xact_lock. An in-flight victim (lock already held by a concurrent recorder) is skipped, not waited on, so the deadlock cycle cannot form and eviction never deletes rows out from under an unserialized transaction. Candidates are over-fetched beyond the evict count so skips still meet the per-scope quota. Fail-closed WindowOverflow semantics and the dedup/atomicity invariants are unchanged. Adds a lib unit test pinning the eviction try-lock key derivation equal to the recorder's lock key (lock-acquisition invariant, no DB needed), a deterministic raw-SQL regression test proving the try-lock on an in-flight victim returns immediately rather than blocking, and a concurrency stress test asserting no deadlock and no torn aggregate. The integration tests gate on IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL and were verified against a real Postgres 14. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-libsql): bound connection concurrency + connect retry to prevent fail-closed under load The libSQL predicate-state backend opened a fresh connection per op with no in-process write serialization and no connect retry. Under production concurrency (e.g. a heartbeat tick evaluating many hooks at once) concurrent writers on a shared backend instance raced on the single SQLite write lock; in the replication-enabled libSQL build a raw BEGIN IMMEDIATE can return SQLITE_BUSY immediately instead of honouring busy_timeout, surfacing as Unavailable("database is locked") -> fail-closed predicate evaluation (a hook that should pass gets denied). Fix, mirroring the canonical libSQL pattern in src/db/libsql/ (LibSqlBackend / LibSqlWorkspaceStore): - Add a per-backend write_lock: Arc<Mutex<()>> held across the whole BEGIN IMMEDIATE..COMMIT for every mutating op (record_invocation, record_value, evict_older_than, run_migrations). Serialises in-process writers so they never contend at the SQLite layer. - Add connect retry with exponential backoff for transient open failures (SQLITE_CANTOPEN during concurrent opens), matching LibSqlBackend::connect. - Cross-instance contention still falls back to PRAGMA busy_timeout = 5000. Preserves BEGIN IMMEDIATE serialization and fail-closed WindowOverflow / the per-key cap exactly. Also (folded in from codex review of the parity PR nearai#3937): remove the global MAX_HISTORY_KEYS cap from the libSQL backend. That cap is an in-memory-only memory-footprint bound (threat-model D5); durable backends are not memory-bound and reap via evict_older_than + the per-tenant MAX_KEYS_PER_TENANT quota. The Postgres sibling has no global cap, so enforcing one in libSQL diverged the two backends once total scopes exceeded 8192 under the per-tenant quota. Dropping it restores parity; evict_oldest_scope is now always tenant-scoped. Tests (serial-runner suite): - two_independent_db_handles_flood_no_handle_exhaustion: two independently created libsql::Database handles on the same temp file flood concurrently with NO test-side admission semaphore. - no_global_key_cap_only_per_tenant: many tenants under quota retain all scopes with zero evictions (would fail if a global cap still evicted cross-tenant). - Removed the now-unnecessary test-side semaphore from per_tenant_quota_isolates_under_concurrent_pressure (backend self-serialises). The harness = false serial runner is still required: 6 concurrent heavy fills as default-harness tests reproduce a DRIVER-LEVEL SQLITE_MISUSE across independent Database handles every run even with the write_lock (verified empirically). That is distinct from the production fail-closed risk this fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-postgres): make migration SQL the single source via include_str! firat (PR nearai#3933 maintainability audit): schema.rs embedded a hand-copied DDL const documented as "byte-compatible" with migrations/V1__predicate_counters.sql, with nothing enforcing equivalence — silent drift risk. Pull the DDL directly from the .sql file via include_str! so there is exactly one copy. batch_execute tolerates the file's leading -- comment block, so no statement splitting is needed. run_migrations is exercised by the contract/adversarial suites, confirming the included SQL applies. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): address codex parity-suite review (nearai#3937) Re-merge the updated durable backend branches (libSQL global-cap removal d61ec53, Postgres LRU advisory-lock fix 806eacb) and close the test-quality gaps codex flagged: - CI enforcement: add a dedicated `hooks-parity-tests` job to test.yml that runs `cargo test -p ironclaw_hooks_parity --features postgres,integration` against a real Postgres service, with IRONCLAW_REQUIRE_POSTGRES=1 so the full three-backend matrix cannot skip-pass. Wired into the run-tests roll-up. - Global-cap parity: now that libSQL dropped its global cap (matching Postgres), add `parity_global_cap_script` asserting all three backends AGREE in the regime below MAX_HISTORY_KEYS (no global eviction fires). The above-8192 divergence stays documented as an intentional in-memory memory-bound difference. No longer silently excluded. - Concurrent-writer counts: collect every spawned writer's returned count and assert they form exactly 1..=N (no duplicate/stale counts mid-race), not just the final row count. - Cap-boundary race: new scenario fills to MAX_SAMPLES_PER_KEY-1 then races two fresh distinct ids from two hosts; asserts exactly one wins the last slot and the other fails closed with WindowOverflow (no TOCTOU breach or double-reject). - PG skip-pass false confidence: IRONCLAW_REQUIRE_POSTGRES=1 turns a missing/unreachable Postgres into a HARD failure (CI), while local runs still skip cleanly. - Oracle independence: each parity script now carries a hand-computed expected-observation log; every backend (including the in-memory reference) is asserted against it, so two backends sharing a semantic bug can no longer both pass. The oracle already caught one hand-computation error (the LRU victim-reinsert triggers a 9th eviction, not 8). Also serialize the parity matrix's libSQL legs (process-global async mutex) so concurrent independent libSQL Database handles don't trip SQLITE_MISUSE under parallel test threads — same driver limit the per-backend libSQL suite handles via its harness=false serial runner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-postgres): rank LRU eviction victims by MIN(ts), matching in-memory/libSQL serrrfirat (PR nearai#3933): the Postgres scope-LRU victim query ranked keys by MAX(ts) (most-recent activity) while the in-memory backend evicts by each bucket's OLDEST retained sample (entries.front() + min_by_key) and libSQL does the same (oldest-front). The doc comment claimed oldest-front but the SQL did MAX(ts). Under multi-sample keys this diverges: a key with one ancient + one fresh sample is spared by MAX(ts) but evicted by the in-memory/libSQL MIN(ts) ranking, so the three backends evict different keys. The single-sample- per-key parity matrix masks it (MIN == MAX). Fix: rank by MIN(ts) per key and update the module/fn docs to match the actual behavior. Follow-up (routed separately): the nearai#3937 parity suite needs a multi-sample LRU case to lock this in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-postgres): canonical typed two-table predicate schema Replace the single generic hook_predicate_counters(kind CHAR(1), id, ts, value NUMERIC) table with two explicit typed tables matching the libSQL backend's two-table model, so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Canonical schema (both backends): - hooks_predicate_invocations(scope_hash, key_hash, event_id, occurred_at), PK (key_hash, event_id); in-window COUNT(*) is the invocation count. - hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at, value NOT NULL), PK (key_hash, event_id); in-window SUM(value) is the sum. Postgres native types unchanged where right: BYTEA hashes, TIMESTAMPTZ occurred_at, NUMERIC value. Column renames id->event_id, ts->occurred_at to the canonical names. The kind discriminator column is gone (table identity carries it); the value: Option<Decimal> double-duty smuggling serrrfirat flagged (backend.rs:138) is eliminated — the invocation table has no value column and the value table's value is NOT NULL, with a per-table aggregate() helper so COUNT vs SUM is explicit. Migration file renamed V1__predicate_counters.sql -> V1__predicate_state.sql, still the include_str! single source of truth. Invariants preserved: MIN(occurred_at) oldest-front LRU victim rule (the 0c102a6 fix), fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK + ON CONFLICT DO NOTHING, per-tenant distinct-key quota (COUNT(DISTINCT key_hash) WHERE scope_hash, no global cap), per-key advisory lock + non-blocking victim try-lock, scope advisory lock now folds a per-table tag instead of the kind column. evict_older_than reaps both tables. SQL paths compile + skip-pass without a reachable Postgres; the typed schema needs the nearai#3937 PG CI leg for a live run before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonical typed schema columns + .sql migration Align the libSQL backend's column set with the Postgres sibling so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Column changes per table: - Add `key_hash` (full bucket digest) as the dedup/count grain + PRIMARY KEY (key_hash, event_id). The column previously called `scope_hash` actually held the full bucket key; it is renamed `key_hash`. - Add a real `scope_hash` = blake3(tenant_id) (the tenant grain) and DROP the raw `tenant_id` TEXT column. The per-tenant LRU quota now counts COUNT(DISTINCT key_hash) WHERE scope_hash = ?, matching Postgres. - `event_id` / `occurred_at` keep the canonical names. libSQL native types unchanged where right: BLOB hashes, INTEGER epoch-ms occurred_at, TEXT exact-decimal value. Schema is now sourced from migrations/V1__predicate_state.sql via include_str! (kills the hand-maintained LIBSQL_PREDICATE_STATE_SCHEMA const body, per the batchb-3933 note). hashing.rs renamed invocation_scope_hash/ value_scope_hash -> invocation_key_hash/value_key_hash and adds tenant_scope_hash, with a folded map discriminant so invocation/value keys never collide. Added a doc(hidden) test_support::tenant_scope_hash_bytes so the contract test can query by the tenant digest now that tenant_id is gone. Invariants preserved: oldest-front MIN(occurred_at) per-key LRU victim, fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK (key_hash, event_id) + ON CONFLICT DO NOTHING, per-tenant quota (no global cap), BEGIN IMMEDIATE + in-process write_lock serialization, connect retry. evict_older_than reaps both tables by occurred_at. Full libSQL contract + adversarial suite green (embedded temp-file db). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks-parity): typed-schema re-merge + multi-sample LRU victim parity Re-merge the typed-column redesign of both durable backends (Postgres two-table + libSQL canonical columns) into the parity suite and update the schema-coupled raw SQL the matrix uses for direct row probes: - TRUNCATE the two typed tables (hooks_predicate_invocations, hooks_predicate_values) instead of the dropped hook_predicate_counters, in both parity_matrix.rs and multi_host_adversarial.rs. - distinct_invocation_scopes() now counts COUNT(DISTINCT key_hash) WHERE scope_hash = <tenant digest> (via ironclaw_hooks_libsql::test_support), since the raw tenant_id column is gone in the canonical schema. Add an oracle-checked MULTI-SAMPLE per-tenant LRU victim-rule parity case (parity_multisample_lru_victim_rule). The existing run_lru_script puts one sample per key so MIN(ts) == MAX(ts) and a newest-activity victim rule is invisible. The new script gives the oldest-front key a second far-recent sample so its MIN(ts) (oldest) and MAX(ts) (newest) point at DIFFERENT victims, then forces eviction and probes the oldest-front key. Under the correct oldest-front MIN(occurred_at) rule (all three backends, after the 0c102a6 Postgres fix) the probe returns count 1 with a second eviction; a backend regressed to MAX(ts) victim selection would spare that key and return count 3, diverging from the hand-computed oracle. Behavioral parity is unchanged — same observable counts/sums/eviction across backends. Verified: in-memory + libSQL legs green against the new typed schemas (parity_matrix 5/5; multi_host_adversarial 6/6 single-threaded). The multi_host binary uses the default harness; run it with --test-threads=1 to avoid the documented cross-instance libSQL driver SQLITE_MISUSE under concurrent independent Database handles (pre-existing, not schema-related). Postgres legs skip-pass without a reachable DB; the nearai#3937 PG CI leg must run the typed-schema matrix before merge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): LibSqlPredicateStateBackend in own crate (durable backend PR 3/4) Durable libSQL-backed PredicateStateBackend satisfying the trait widened in nearai#3927, with the same invariants as the in-memory backend: MAX_SAMPLES_PER_KEY per-key cap, MAX_HISTORY_KEYS / MAX_KEYS_PER_TENANT LRU, and replay-dedup. Crate move (vs the original nearai#3930): the backend now lives in its own crate `ironclaw_hooks_libsql` rather than as an optional `libsql` feature on the framework crate. This keeps DB deps out of `ironclaw_hooks`, matching the intended `ironclaw_hooks_postgres` pattern (backend crate -> framework crate; no inversion). Structure: src/{lib,backend,hashing, schema}.rs. The `libsql` feature + dep and the `predicate_state::libsql` module were removed from `ironclaw_hooks`; the unused `tempfile` dev-dep was dropped too. `ironclaw_hooks` now builds with no libsql feature at all. Fail-closed alignment (nearai#3929, now the public contract): the per-key cap returns PredicateBackendError::WindowOverflow when a scope is at MAX_SAMPLES_PER_KEY and a NEW distinct id arrives, instead of the prior drop-oldest. Dedup short-circuits BEFORE the overflow check, so a replay at the cap is a no-op (replay refusal survives the cap boundary). This matches the in-memory backend exactly and passes the record_invocation_overflow_is_fail_closed contract from nearai#3927. - Atomic record-and-read via a single BEGIN IMMEDIATE transaction per record_* call so concurrent writers serialise on SQLite's write lock. - Value-sum recomputed from surviving rows inside the same transaction. - Clock basis: stores host-supplied now: DateTime<Utc> as epoch millis. - Replay-dedup via INSERT ... ON CONFLICT (scope_hash, event_id) DO NOTHING; event_id is TEXT (64-char blake3 hex, Codex nearai#3635 finding). Test plan: shared contract harness (all 9 contracts incl. fail-closed) plus adversarial tests (two-host concurrent writes, cross-host replay dedup invocation+value, per-key cap fail-closed invocation+value, per-tenant LRU under bounded concurrent pressure, restart survival). The suite runs with harness=false and a small serial runner: each heavy case fills a key to MAX_SAMPLES_PER_KEY (4096 connect/BEGIN IMMEDIATE/ COMMIT cycles), and running several concurrently against the replication-enabled libSQL build intermittently trips SQLITE_MISUSE. Serial execution (concurrency cases get their own multi-thread runtime) makes it deterministic; the per-tenant flood keeps its bounded semaphore. 16 cases, all green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks-libsql): bound connection concurrency + connect retry to prevent fail-closed under load The libSQL predicate-state backend opened a fresh connection per op with no in-process write serialization and no connect retry. Under production concurrency (e.g. a heartbeat tick evaluating many hooks at once) concurrent writers on a shared backend instance raced on the single SQLite write lock; in the replication-enabled libSQL build a raw BEGIN IMMEDIATE can return SQLITE_BUSY immediately instead of honouring busy_timeout, surfacing as Unavailable("database is locked") -> fail-closed predicate evaluation (a hook that should pass gets denied). Fix, mirroring the canonical libSQL pattern in src/db/libsql/ (LibSqlBackend / LibSqlWorkspaceStore): - Add a per-backend write_lock: Arc<Mutex<()>> held across the whole BEGIN IMMEDIATE..COMMIT for every mutating op (record_invocation, record_value, evict_older_than, run_migrations). Serialises in-process writers so they never contend at the SQLite layer. - Add connect retry with exponential backoff for transient open failures (SQLITE_CANTOPEN during concurrent opens), matching LibSqlBackend::connect. - Cross-instance contention still falls back to PRAGMA busy_timeout = 5000. Preserves BEGIN IMMEDIATE serialization and fail-closed WindowOverflow / the per-key cap exactly. Also (folded in from codex review of the parity PR nearai#3937): remove the global MAX_HISTORY_KEYS cap from the libSQL backend. That cap is an in-memory-only memory-footprint bound (threat-model D5); durable backends are not memory-bound and reap via evict_older_than + the per-tenant MAX_KEYS_PER_TENANT quota. The Postgres sibling has no global cap, so enforcing one in libSQL diverged the two backends once total scopes exceeded 8192 under the per-tenant quota. Dropping it restores parity; evict_oldest_scope is now always tenant-scoped. Tests (serial-runner suite): - two_independent_db_handles_flood_no_handle_exhaustion: two independently created libsql::Database handles on the same temp file flood concurrently with NO test-side admission semaphore. - no_global_key_cap_only_per_tenant: many tenants under quota retain all scopes with zero evictions (would fail if a global cap still evicted cross-tenant). - Removed the now-unnecessary test-side semaphore from per_tenant_quota_isolates_under_concurrent_pressure (backend self-serialises). The harness = false serial runner is still required: 6 concurrent heavy fills as default-harness tests reproduce a DRIVER-LEVEL SQLITE_MISUSE across independent Database handles every run even with the write_lock (verified empirically). That is distinct from the production fail-closed risk this fixes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonical typed schema columns + .sql migration Align the libSQL backend's column set with the Postgres sibling so both durable backends share ONE logical schema (table count, column names, semantics identical; native storage types differ per backend). Column changes per table: - Add `key_hash` (full bucket digest) as the dedup/count grain + PRIMARY KEY (key_hash, event_id). The column previously called `scope_hash` actually held the full bucket key; it is renamed `key_hash`. - Add a real `scope_hash` = blake3(tenant_id) (the tenant grain) and DROP the raw `tenant_id` TEXT column. The per-tenant LRU quota now counts COUNT(DISTINCT key_hash) WHERE scope_hash = ?, matching Postgres. - `event_id` / `occurred_at` keep the canonical names. libSQL native types unchanged where right: BLOB hashes, INTEGER epoch-ms occurred_at, TEXT exact-decimal value. Schema is now sourced from migrations/V1__predicate_state.sql via include_str! (kills the hand-maintained LIBSQL_PREDICATE_STATE_SCHEMA const body, per the batchb-3933 note). hashing.rs renamed invocation_scope_hash/ value_scope_hash -> invocation_key_hash/value_key_hash and adds tenant_scope_hash, with a folded map discriminant so invocation/value keys never collide. Added a doc(hidden) test_support::tenant_scope_hash_bytes so the contract test can query by the tenant digest now that tenant_id is gone. Invariants preserved: oldest-front MIN(occurred_at) per-key LRU victim, fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay dedup via PK (key_hash, event_id) + ON CONFLICT DO NOTHING, per-tenant quota (no global cap), BEGIN IMMEDIATE + in-process write_lock serialization, connect retry. evict_older_than reaps both tables by occurred_at. Full libSQL contract + adversarial suite green (embedded temp-file db). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks-libsql): canonicalize contract list, DRY record state machine, align window-cutoff Address serrrfirat maintainability review on PR nearai#3936: - P1: drive the libSQL serial-runner case inventory from a new canonical `predicate_backend_contract_cases!` macro in `ironclaw_hooks`. Both the default-harness `predicate_backend_contract_test!` and the libSQL `harness=false` serial runner now expand the same single source-of-truth case list, so a contract added upstream auto-runs against libSQL with no hand-maintained second list to drift. - P2: extract the duplicated record transaction state machine (trim -> replay check -> overflow check -> quota eviction -> insert -> read -> commit) into one `LibSqlPredicateStateBackend::record` parameterized on a per-table `RecordSpec` (table, hashes, overflow key, insert shape, result read). Behavior, atomicity, fail-closed, and BEGIN IMMEDIATE / write-lock guarantees unchanged. - P2: make `ironclaw_hooks::predicate_state::window_cutoff` public as the canonical cross-backend cutoff and have libSQL's `window_cutoff_millis` delegate to it, then project onto epoch-millis. Removes the divergent i64::MAX-saturating reimplementation that trimmed nothing on oversized windows (canonical trims to `now`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): canonical predicate hashing + cutoff in shared crate (nearai#3937) Address serrrfirat's drift concern (and the gemini u64 finding) on the durable backends' bucket-identity hashing, which had actually diverged: Postgres used a 4-byte big-endian u32 length prefix while libSQL used an 8-byte little-endian u64, so the same logical key produced different digests per backend. - Extract the canonical scope_hash/invocation_key_hash/value_key_hash into ironclaw_hooks::predicate_hash with a single u64 big-endian length prefix (infallible from usize — no lossy u32 saturation). Both backend hashing modules now delegate; the DB identity contract has one source of truth. - Likewise route the Postgres window cutoff through the canonical predicate_state::window_cutoff instead of a byte-identical local copy, matching libSQL and removing another drift surface. Behavior preserved (libSQL parity suite green). Note: changes the digest bytes for both durable backends, which is safe pre-merge (no persisted production data yet). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(hooks-parity): own TempDir in a fixture instead of Box::leak (nearai#3937) The parity libSQL leg leaked its TempDir (Box::leak) because the factory returned a bare Arc<dyn PredicateStateBackend> with nowhere to hang the dir. Wrap it in a LibSqlFixture that owns the TempDir; assert_parity holds the fixture across the script run and drops it after (backend field before _dir, so the db handle closes before the dir is removed), giving RAII cleanup. No script-signature change needed — the Arc is cloned out for the run. libSQL parity suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(hooks-parity): split 1102-line parity_matrix into support/scripts/oracle (nearai#3937) serrrfirat flagged the single 1102-line parity_matrix.rs as past the 1k-line smell threshold. Split along the natural seams it called out: - tests/parity_matrix/support.rs — observation types, deterministic fixtures, per-step drivers, the three backend factories (incl. the TempDir-owning LibSqlFixture), and the assert_parity oracle runner. - tests/parity_matrix/scripts.rs — the run_* scripted scenarios. - tests/parity_matrix/oracle.rs — the hand-computed expected_* logs. - tests/parity_matrix.rs — now just the module doc, #[path] mod wiring, and the #[tokio::test] entrypoints. #[path] keeps the submodules in the subdirectory (files directly under tests/ each become their own test binary; subdir files don't). Pure test-only reorg, no behavior change — parity suite green (5 tests), postgres feature compiles, clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(hooks-parity): loud Postgres setup failure + shared cross-backend LRU race (nearai#3937) Addresses the remaining structural review items on the parity suite without dropping any adversarial coverage: - Postgres parity factory now returns `Result<Option<_>, String>`: `Ok(None)` for missing env (skip-eligible), `Err` for any failure AFTER the DB URL is found (connect/schema/pool/migrate/truncate). `assert_parity` turns that `Err` into a hard panic, so a configured-but-misconfigured CI DB can no longer silently skip-pass the Postgres leg. - Extracted the multi-host LRU eviction race into a shared `scenario_lru_eviction_race_holds_quota`, parameterized over a per-backend distinct-scope counter, and wired a Postgres driver (`postgres_lru_eviction_race_holds_quota`) so the LRU race now runs on EVERY durable backend rather than only libSQL — the harness can no longer advertise a scenario that silently runs on one backend. - Added a process-global async libSQL serial guard to the multi-host legs, mirroring the parity_matrix and per-backend libSQL contract suites, so the added heavy libSQL fill cannot intermittently trip the libSQL driver's concurrent-independent-handle SQLITE_MISUSE limit. The four thermo-nuclear blockers (1k+ parity matrix, duplicated identity hashing, duplicated libSQL record state machine, leaked TempDir) were resolved in prior commits on this branch; this commit closes the remaining first-review structural gaps. Full parity + multi-host suite green; fmt + clippy clean; no temp-dir leaks on a passing run. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): address henrypark133 maintainability review on parity suite - libSQL backend: open/retry the connection BEFORE taking write_lock in record/run_migrations/evict_older_than. connect()'s backoff sleep no longer stalls other in-process writers; the lock spans only the BEGIN IMMEDIATE..COMMIT transaction. (review: write_lock held during connect retry sleep) - libSQL test_support: rename tenant_scope_hash_bytes -> scope_hash_bytes to match the Postgres sibling's accessor, so parity tests reference one symbol name across both backends. Updated both call sites. - Postgres: POSTGRES_PREDICATE_SCHEMA is crate-internal (pub -> pub(crate)) and the pub re-export from lib.rs is removed; it had no external consumers and the libSQL sibling already keeps its schema crate-private. Declined with rationale (replies on PR): the Postgres enforce_scope_quota per-candidate try_lock+DELETE loop is the documented deadlock-avoidance design (pg_try_advisory_xact_lock per victim, breaks at evicted>=to_evict); bulk DELETE would reintroduce the lock-cycle. libSQL sum_decimal sums rows in Rust deliberately to preserve rust_decimal exactness (SQLite total() returns float); both bounded by MAX_SAMPLES_PER_KEY. Cross-backend parity suite (libSQL + embedded Postgres clusters) and the libSQL contract+adversarial harness pass green; no behavioural/adversarial case dropped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): close evict_older_than contract + value-path LRU parity gaps (nearai#3937) Addresses serrrfirat review on the cross-backend parity suite (PR 4/4). f-tests-1: add evict_older_than to the canonical predicate contract inventory. New cases evict_older_than_reaps_strictly_older_rows and evict_older_than_retains_entry_at_exact_cutoff assert strictly-older rows are reaped from BOTH the invocation and value tables, the row exactly at the cutoff is retained (< vs <=), and the returned count equals rows deleted. Wired into the canonical predicate_backend_contract_cases! list (auto-runs for in-memory + libSQL) and the hand-maintained Postgres pg_contract! list. f-tests-2: add run_lru_value_script + expected_lru_value_log + the parity_per_tenant_lru_value_script test. enforce_caps is shared and table-parameterized across both record paths; the existing LRU scripts drove record_invocation only, so a value-path-only enforce_caps regression (e.g. wrong table constant) could slip the oracle. The new script exercises per-tenant LRU through record_value and cross-asserts the same eviction trajectory. f-bugs-1: document (no behavior change) that the per-tenant quota counts all stored rows including expired-but-unreaped rows from other keys, so deployers must schedule a periodic evict_older_than reaper to keep quota counts aligned with the active key count. Documented at the trait method, both backends' quota-enforcement sites, and 03-persistent-counter.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(hooks): composite (scope_hash, key_hash) indexes for per-tenant LRU quota f-perf-1 + f-perf-2 (serrrfirat review on nearai#3937): the per-tenant LRU quota path runs `SELECT count(DISTINCT key_hash) ... WHERE scope_hash = ?` and an LRU victim scan `GROUP BY key_hash ORDER BY min(occurred_at)`. With only a single-column scope_hash index, both filter by scope_hash but then scan every matching tenant row to group/aggregate by key_hash. Add a composite (scope_hash, key_hash) index on both the invocations and values tables in both durable backends so the quota count and victim selection stay off a full per-tenant row scan. Schema is the single canonical V1 source (include_str! into schema.rs, applied via idempotent CREATE INDEX IF NOT EXISTS), so editing V1 in place is the convention for this unreleased schema; no second Rust copy to sync. The same indexes are being added to the owning PRs (nearai#3933 libsql, nearai#3936 postgres) and will dedupe on rebase once those merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(hooks-parity): note SQLITE_MISUSE serialization caveat in parity_matrix.rs f-tests-3 (serrrfirat review on nearai#3937, Low): add a comment at the top of parity_matrix.rs pointing future cap-heavy / statement-heavy parity script authors at the process-global `libsql_serial_guard` in support.rs, so an unguarded fresh libSQL handle pushing thousands of rows under the concurrent libtest harness does not intermittently trip SQLITE_MISUSE. Comment only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Cross-backend adversarial parity suite (durable-backend PR 4/4)
The final PR of the durable-predicate-backend split. Lands a new test-only
crate
ironclaw_hooks_paritythat proves the threePredicateStateBackendimplementations are behaviorally interchangeable by feeding the SAME scripted
input to all of them and cross-asserting identical observable output.
Builds on:
reborn-integration,d5e6434ec)ironclaw_hooks_postgres)ironclaw_hooks_libsql)Merge order: this PR merges both backend branches into its working tree so
all three backends compile together, and should land after #3933 and #3936
(both will merge into
reborn-integrationindependently first; base here isreborn-integration).Parity matrix (
tests/parity_matrix.rs) — load-bearingOne deterministic scripted sequence (fixed ids/timestamps/values, no wall
clock) is fed to in-memory, libSQL, and (under
--features postgres+ a DBURL) Postgres. Each backend's per-step output — returned count/sum, error
variant, running
evictions_observed()— is captured into anObservationLog,and the matrix asserts every backend reproduces the in-memory reference log
exactly. A divergence surfaces as a concrete
assert_eq!diff naming thediverging step.
Three scripts:
1.25to catch truncation), windowtrim, exact-cutoff retain (
< cutoff), per-key replay dedup, tenantisolation, cross-map dedup isolation.
MAX_SAMPLES_PER_KEY(4096), assert next distinct idfails closed with
WindowOverflow, replay at the cap is a dedup no-op.evictions_observed()across backends.Multi-host adversarial (
tests/multi_host_adversarial.rs,--features integration)The cross-host correctness properties only the durable backends provide:
PRIMARY KEYdedups across hosts).MAX_KEYS_PER_TENANThold the quota.WindowOverflow, bounded.DateTime<Utc>; window follows thecaller-supplied clock basis (both durable backends chose caller
now).A3 deferral — CLOSED
This closes threat-model finding A3 (multi-host replay bypass) from #3635:
the durable backends' SQL uniqueness constraint makes a cross-host replay a
no-op, so exactly-once counting holds across every host pointing at one
database. Verified by the
cross_host_replay_exactly_oncescenario.Documented in
crates/ironclaw_hooks/docs/successors/03-persistent-counter.md,which now also records the final landed trait/clock/fail-closed/LRU shape.
Behavioral findings
No discrepancies found — in-memory and libSQL produce byte-identical parity
logs across all three scripts. One intended divergence is documented and
deliberately not exercised by the matrix: the in-memory backend has a global
MAX_HISTORY_KEYS(8192) cap that the durable backends do not (a DB has nofixed key ceiling; it's reaped via
evict_older_than). All three share theper-tenant
MAX_KEYS_PER_TENANTquota, which is what thelruscript asserts.Test legs executed in this environment
No real Postgres was available, so:
multi-host adversarial (5 tests), Postgres legs skip-pass (4 tests). Plus
regression: full
ironclaw_hooks_libsqlsuite (16),ironclaw_hooksin-memory unit tests (258),
ironclaw_hooks_postgrescontract (env-skip).legs. The parity guarantee is not fully exercised for Postgres without a
real DB — a real-Postgres CI run (
IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URLset) is required before merge (same caveat as feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4, replaces #3932) #3933).Verification:
cargo fmt --all,cargo clippy -p ironclaw_hooks -p ironclaw_hooks_postgres -p ironclaw_hooks_libsql -p ironclaw_hooks_parity --benches --tests --examples --all-features -- -D warnings(clean).🤖 Generated with Claude Code