Skip to content

test(hooks): cross-backend adversarial parity suite + A3 closeout (durable backend PR 4/4) - #3937

Merged
zmanian merged 30 commits into
mainfrom
hooks-predicate-backend-parity-suite
Jun 6, 2026
Merged

zmanian merged 30 commits into
mainfrom
hooks-predicate-backend-parity-suite

Conversation

@zmanian

@zmanian zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator

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_parity that proves the three PredicateStateBackend
implementations are behaviorally interchangeable
by feeding the SAME scripted
input to all of them and cross-asserting identical observable output.

Builds on:

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-integration independently first; base here is
reborn-integration).

Parity matrix (tests/parity_matrix.rs) — load-bearing

One deterministic scripted sequence (fixed ids/timestamps/values, no wall
clock) is fed to in-memory, libSQL, and (under --features postgres + a DB
URL) Postgres. Each backend's per-step output — returned count/sum, error
variant, running evictions_observed() — is captured into an ObservationLog,
and the matrix asserts every backend reproduces the in-memory reference log
exactly. A divergence surfaces as a concrete assert_eq! diff naming the
diverging step.

Three scripts:

  • core: counts, sums (incl. fractional 1.25 to catch truncation), window
    trim, exact-cutoff retain (< cutoff), per-key replay dedup, tenant
    isolation, cross-map dedup isolation.
  • cap: fill to MAX_SAMPLES_PER_KEY (4096), assert next distinct id
    fails closed with WindowOverflow, replay at the cap is a dedup no-op.
  • lru: per-tenant quota flood — same eviction victim + same
    evictions_observed() across backends.

Multi-host adversarial (tests/multi_host_adversarial.rs, --features integration)

The cross-host correctness properties only the durable backends provide:

  1. N concurrent writers / 2 hosts — no count/sum desync, exactly-once.
  2. Cross-host replay — exactly-once counting (SQL PRIMARY KEY dedups across hosts).
  3. LRU eviction race — concurrent inserts past MAX_KEYS_PER_TENANT hold the quota.
  4. Per-key cap under attacker flood — fail-closed WindowOverflow, bounded.
  5. Clock-skew — two hosts pass different DateTime<Utc>; window follows the
    caller-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_once scenario.
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 no
fixed key ceiling; it's reaped via evict_older_than). All three share the
per-tenant MAX_KEYS_PER_TENANT quota, which is what the lru script asserts.

Test legs executed in this environment

No real Postgres was available, so:

  • Ran (green): in-memory + libSQL parity matrix (3 tests), libSQL
    multi-host adversarial (5 tests), Postgres legs skip-pass (4 tests). Plus
    regression: full ironclaw_hooks_libsql suite (16), ironclaw_hooks
    in-memory unit tests (258), ironclaw_hooks_postgres contract (env-skip).
  • Compiled + skip-passed (NOT exercised): the Postgres parity + multi-host
    legs. The parity guarantee is not fully exercised for Postgres without a
    real DB — a real-Postgres CI run (IRONCLAW_HOOKS_POSTGRES_URL /
    DATABASE_URL set) 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

zmanian and others added 4 commits May 23, 2026 06:02
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>
@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels May 23, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +393 to +397
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +378 to +381
if distinct as usize <= MAX_KEYS_PER_TENANT {
return Ok(0);
}
let to_evict = distinct as usize - MAX_KEYS_PER_TENANT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +126 to +131
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,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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()
})
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +103 to +105
conn.query("PRAGMA busy_timeout = 5000", ())
.await
.map_err(map_err)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)?;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +32 to +34
let len = u32::try_from(field.len()).unwrap_or(u32::MAX);
hasher.update(&len.to_be_bytes());
hasher.update(field);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

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
  1. 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +57
CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx
ON hook_predicate_counters (scope_hash, kind);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +58 to +59
CREATE INDEX IF NOT EXISTS idx_hooks_predicate_invocations_tenant
ON hooks_predicate_invocations (tenant_id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@henrypark133 @serrrfirat durable backend PR 4/4 — cross-backend parity suite (ironclaw_hooks_parity, test-only crate). Feeds one deterministic sequence to all three backends and asserts byte-identical outputs (count/sum/error-variant/eviction/LRU victim) + multi-host adversarial suite. No cross-backend discrepancy found (in-memory ↔ libSQL byte-identical). Closes the A3 multi-host-replay-bypass deferral from #3635. One intended divergence documented + worth your eye: in-memory has a global MAX_HISTORY_KEYS cap, durable backends reap via evict_older_than instead (all share the per-tenant quota) — is the global cap a real defense the durable backends now lack, or redundant given per-tenant quota? Postgres legs skip-passed (no real PG in env). Merge after #3933 + #3936.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strict maintainability/code-quality pass: I would not merge this as-is.

  1. 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.

  1. 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.

  1. 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.

  1. 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>
@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Codex review (advisory) — REQUEST CHANGES

The parity matrix is a useful differential test, but it overclaims and has gaps.

Critical

  1. Not enforced by CI — the workflow's cargo test runs only the root package; no cargo test -p ironclaw_hooks_parity job. A test-only PR whose whole proof is absent from required checks. Add a dedicated job (+ a real-PG integration leg).
  2. libSQL still enforces a global key cap; Postgres does not (parity_matrix.rs:453, ironclaw_hooks_libsql/src/backend.rs:427). The matrix excludes this divergence — so libSQL and Postgres can genuinely diverge for >8192 total scopes spread across tenants below the per-tenant cap, and the suite passes anyway. Resolve the backends, don't hide it: the documented durable behavior is reap-via-evict_older_than + per-tenant quota (no global cap), so libSQL should drop its global cap to match Postgres. Then the matrix can assert global-cap parity instead of excluding it. (Fix coordinated on the libSQL branch.)
  3. Concurrent writer test discards returned counts (multi_host_adversarial.rs:177) — only checks a final duplicate-id read. A backend could insert all rows but return stale counts mid-race, which is exactly what the evaluator gates on. Collect returned counts and assert 1..=N distinct.
  4. Cap-boundary race untested (:266) — fills to cap serially then races only overflow calls. Doesn't test the dangerous boundary: two hosts both observe cap-1 and both try a fresh id. Fill to cap-1, race two fresh ids, assert exactly one succeeds and the other fails closed.
  5. PG skip-pass = false confidence (:647, :504) — add IRONCLAW_REQUIRE_POSTGRES=1 for CI to turn missing PG into a hard failure.

Recommendation

In-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>
zmanian and others added 3 commits May 23, 2026 08:13
…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>
@github-actions github-actions Bot added the scope: ci CI/CD workflows label May 23, 2026
…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>
@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the test-quality REQUEST CHANGES (codex)

Re-merged the updated durable backend branches first (libSQL global-cap removal d61ec536b, Postgres LRU advisory-lock fix 806eacbb9) so the matrix runs against the current backends, then closed every flagged gap. Final SHA 17343e6a6.

1. CI enforcement — added a dedicated hooks-parity-tests job to .github/workflows/test.yml (modeled on the existing credential-store-postgres-tests job): runs cargo test -p ironclaw_hooks_parity --features postgres,integration against a pgvector/pgvector:pg16 service with IRONCLAW_REQUIRE_POSTGRES=1, and is wired into the run-tests roll-up's require_success gate. The crate is no longer invisible to CI.

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 parity_global_cap_script: many tenants each record a handful of scopes, exercising the under-quota branch that consults the in-memory global cap, and asserts all three backends AGREE (zero global evictions) in the regime below MAX_HISTORY_KEYS. The only remaining divergence is above 8192 total scopes, which stays documented in 03-persistent-counter.md as an intentional in-memory memory-bound difference (inserting 8192+ scopes through a per-op-connection durable backend is too slow for a unit test; the per-backend no_global_key_cap_only_per_tenant cases cover the durable side directly). The matrix now asserts what is actually uniform instead of silently skipping the dimension.

3. Concurrent-writer returned counts — scenario_concurrent_writers_no_desync now collects every spawned writer's returned count and asserts they form exactly 1..=N (no duplicate/stale counts mid-race), not just the final row count — catching a backend that inserts all rows but returns a stale snapshot count.

4. Cap-boundary race — new scenario_cap_boundary_race_admits_exactly_one: fills to MAX_SAMPLES_PER_KEY - 1, then races two fresh distinct ids from two hosts; asserts exactly one wins the last slot (count == cap) and the other fails closed with WindowOverflow (no TOCTOU breach, no double-reject). Driven for both libSQL and Postgres.

5. PG skip-pass false confidence — IRONCLAW_REQUIRE_POSTGRES=1 turns a missing/unreachable Postgres into a HARD failure in both the matrix and the multi-host suite; local runs without it still skip cleanly. Verified: with the env set and no DB, the matrix legs FAIL rather than green-skip.

6. Oracle independence — each parity script now carries an independent, 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 immediately earned its keep: it caught a hand-computation error of mine (re-inserting the evicted LRU victim triggers a 9th per-tenant eviction, not 8).

Also serialized the matrix's libSQL legs behind a process-global async mutex, because concurrent independent libSQL Database handles doing heavy 4096-step fills intermittently trip SQLITE_MISUSE under parallel test threads — the same driver limit the per-backend libSQL suite handles via harness=false.

Note on the two earlier inline P1 comments on ironclaw_hooks_postgres/src/backend.rs

  • "Enforce global key cap across tenants in Postgres backend" is now intentionally WONTFIX: the agreed direction is durable backends enforce per-tenant quota only (libSQL dropped its global cap in d61ec536b to match), with the global cap an in-memory memory-bound concern. parity_global_cap_script asserts the backends agree under that contract.
  • "Evict keys by oldest front timestamp" is addressed by the merged 806eacbb9 LRU eviction fix.

Verification

  • cargo fmt --all -- --check: clean
  • cargo clippy -p ironclaw_hooks_parity -p ironclaw_hooks_libsql -p ironclaw_hooks_postgres --tests --all-features -- -D warnings: clean
  • cargo test -p ironclaw_hooks_parity (in-memory + libSQL): 4/4 pass
  • cargo test -p ironclaw_hooks_parity --features integration --test multi_host_adversarial (libSQL multi-host incl. new boundary race): 6/6 pass, stable over 3 runs
  • cargo test -p ironclaw_hooks_libsql / -p ironclaw_hooks_postgres --features postgres: pass
  • Postgres legs skip-passed locally (no server); the new CI job runs them against a real Postgres with the hard-gate on.

@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@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.

zmanian and others added 4 commits May 23, 2026 08:42
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>
…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>
@zmanian
zmanian requested a review from henrypark133 May 27, 2026 00:47
@henrypark133

Copy link
Copy Markdown
Collaborator

Review of #3937: cross-backend adversarial parity suite

Test-only crate (ironclaw_hooks_parity). No production code changes. No critical bugs found.

Low — Postgres tests gated behind --features postgres + env var: easy to skip in CI without noticing

.github/workflows/test.yml — the Postgres parity tests only run when both --features postgres is set AND a DB URL env var is present. If CI is misconfigured or the Postgres service is unavailable, the tests are silently skipped rather than failing. The parity matrix is the load-bearing cross-backend correctness proof; a skipped run provides zero assurance.

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.

zmanian added a commit that referenced this pull request May 27, 2026
…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 serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 = ?1

and 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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 in multi_host_adversarial.rs) to assert_parity's libSQL leg when running the cap script, or
  • A brief note in the module doc explaining why the current parallelism is safe.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

zmanian and others added 3 commits June 4, 2026 10:05
…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>
@zmanian
zmanian requested a review from serrrfirat June 4, 2026 19:12
zmanian added a commit that referenced this pull request Jun 4, 2026
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>
@zmanian

zmanian commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@serrrfirat The two perf Mediums (composite (scope_hash, key_hash) indexes, both backends) and f-tests-3 are fixed on head 4b2910ea7; f-tests-1/f-tests-2/f-bugs-1 were already addressed on the branch (evict_older_than contract cases, record_value LRU parity script, divergence docs). The May 23 CHANGES_REQUESTED predates all of this — could you approve or dismiss it?

@zmanian

zmanian commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@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 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.rs lossy u32 length prefix — fixed in 8bd9419e5 (shared module, u64 big-endian).
  • hooks_postgres/backend.rs:174 Postgres cutoff math reimplemented — fixed in 8bd9419e5 (delegates to canonical window_cutoff).
  • hooks_libsql/src/lib.rs:36 tenant_scope_hash_bytes vs scope_hash_bytes naming — fixed in eb160f313.
  • hooks_libsql/src/hashing.rs:19 thin to_vec wrapper — author acknowledged: "thin Vec-adapting delegate" (intentional). Not re-blocking.
  • enforce_caps index coverage (gemini bot) — already discussed on the prior backend PRs.
  • hooks_postgres/src/backend.rs:523 global key cap across tenants (chatgpt-codex bot) — already addressed in #3933/#3936 review threads.

Note (raised on prior PRs in series)

  • Postgres evict_older_than two-DELETE atomicity gap (raised in my #3933 review). Still applies; not re-posting inline here since the file is owned by #3933.

Security

  1. Medium PredicateEventId has no length cap; arbitrarily large strings can be stored verbatim in both durable tables (predicate_state.rs:148, conf 75). See inline.

Tests

  1. Medium window_cutoff oversized-window Err(_) => now branch not unit-tested (predicate_state.rs:548, conf 75) — the very invariant the function was made pub to enforce. See inline.
  2. Medium predicate_hash length-prefix empty-string field handling not tested (predicate_hash.rs:72, conf 75). See inline.

Local patterns

  1. Low test_support::scope_hash_bytes return 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.
///

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from reborn-integration to main June 5, 2026 12:57
henrypark133
henrypark133 previously approved these changes Jun 6, 2026
zmanian added a commit that referenced this pull request Jun 6, 2026
…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>
zmanian added a commit that referenced this pull request Jun 6, 2026
…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
@zmanian
zmanian merged commit 57f70a4 into main Jun 6, 2026
10 checks passed
@zmanian
zmanian deleted the hooks-predicate-backend-parity-suite branch June 6, 2026 00:19
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: ci CI/CD workflows scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants