feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4, replaces #3932) - #3933
Conversation
Implements the durable PostgreSQL backend for predicate sliding-window state, satisfying the `PredicateStateBackend` contract widened to a public async trait in PR #3927. New crate `ironclaw_hooks_postgres` (out-of-crate durable backend, the pattern the `contract-tests` feature on `ironclaw_hooks` was designed for; mirrors `ironclaw_reborn_event_store`). Keeps the Postgres dependency surface out of the hook framework itself. Correctness: - Atomic record-and-read: each `record_*` runs in one READ COMMITTED tx guarded by a transaction-scoped advisory lock on the bucket. The advisory lock (not REPEATABLE READ) serializes same-key writers by BLOCKING the second writer rather than aborting it with a serialization failure — no caller retry loop. Trim, insert, cap-evict, aggregate all share the one tx. - Cross-host replay dedup via PRIMARY KEY (key_hash, id) + INSERT ON CONFLICT DO NOTHING — exact regardless of clock skew. - Running-sum consistency under eviction: cap eviction re-aggregates inside the same tx so the returned sum reflects dropped rows. - Per-key sample cap (MAX_SAMPLES_PER_KEY, drop-oldest) and per-scope distinct-key LRU quota (MAX_KEYS_PER_TENANT) match the in-memory backend. Scope-quota enforcement takes a second advisory lock in the disjoint (int8) lock space so concurrent new-key inserts in a scope don't under-evict. Schema: single `hook_predicate_counters` table, BYTEA blake3 hash keys, TEXT id column (NOT uuid — Postgres uuid rejects the 64-char blake3 hex digest; resolves the #3635 docs/schema contradiction in favor of TEXT). Idempotent CREATE ... IF NOT EXISTS applied via run_migrations(); the per-crate pattern, not the legacy main-binary refinery migrations. DB-clock decision: window comparison basis is the caller's `now` (the same Utc::now() the in-memory backend trusts), making this a drop-in under the deterministic-clock contract harness. Replay dedup and atomicity do NOT depend on clock agreement. Tests (env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL, skip when absent; schema-isolated so the two test binaries can share one DB): - All 8 shared contract functions via the `contract-tests` harness. - Adversarial: two-host write storm (no count desync), cross-host replay (count + value), per-key cap flood, per-scope LRU under concurrent insert pressure across two hosts. - 4 hashing unit tests (injective length-prefixed serialization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the ironclaw_hooks_postgres crate, which implements a durable PostgreSQL backend for the hook framework's predicate state. The implementation ensures cross-host consistency and atomicity through the use of Postgres advisory locks and READ COMMITTED transactions. Key feedback includes optimizing the scope-level index to support index-only scans for quota enforcement, removing a redundant database query in the replay detection path, and standardizing binary encoding by using 64-bit length prefixes for canonical hashing.
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | ||
| ON hook_predicate_counters (scope_hash, kind); |
There was a problem hiding this comment.
The hook_predicate_counters_scope_idx index currently only covers (scope_hash, kind). To optimize the enforce_scope_quota operation, which performs a COUNT(DISTINCT key_hash) and an LRU victim selection using MAX(ts), the index should be expanded to include key_hash and ts. This would enable index-only scans for these operations, significantly improving performance for tenants with a large number of recorded events.
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | |
| ON hook_predicate_counters (scope_hash, kind); | |
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | |
| ON hook_predicate_counters (scope_hash, kind, key_hash, ts); |
| let pre_count: i64 = pre_row.get("cnt"); | ||
| // BOOL_OR over an empty set is NULL; treat NULL as "no duplicate". | ||
| let is_replay: bool = pre_row.get::<_, Option<bool>>("dup").unwrap_or(false); | ||
|
|
||
| if is_replay { | ||
| // Replay refusal: the id is already in-window for this key, so | ||
| // this is a no-op against the count/sum. Short-circuit BEFORE | ||
| // the cap check so a replay at the cap dedups rather than | ||
| // overflowing — matching the in-memory contract. Aggregate and | ||
| // return the unchanged state. | ||
| let agg = tx | ||
| .query_one( | ||
| "SELECT COUNT(*)::BIGINT AS cnt, | ||
| COALESCE(SUM(value), 0)::NUMERIC AS total | ||
| FROM hook_predicate_counters | ||
| WHERE key_hash = $1 AND ts >= $2", | ||
| &[&key_ref, &cutoff], | ||
| ) | ||
| .await | ||
| .map_err(map_pg)?; | ||
| let count: i64 = agg.get("cnt"); | ||
| let total: Decimal = agg.get("total"); | ||
| tx.commit().await.map_err(map_pg)?; | ||
| return Ok(if value.is_some() { | ||
| total | ||
| } else { | ||
| Decimal::from(count.max(0) as u64) | ||
| }); |
There was a problem hiding this comment.
The record function performs a redundant database query when a replay is detected. The pre_row query already computes the in-window count and sum. Since a replay is a no-op that doesn't modify the table, these values can be returned directly, saving a round-trip to the database.
let pre_count: i64 = pre_row.get("cnt");
let pre_total: Decimal = pre_row.get("total");
// BOOL_OR over an empty set is NULL; treat NULL as "no duplicate".
let is_replay: bool = pre_row.get::<_, Option<bool>>("dup").unwrap_or(false);
if is_replay {
// Replay refusal: the id is already in-window for this key, so
// this is a no-op against the count/sum. Short-circuit BEFORE
// the cap check so a replay at the cap dedups rather than
// overflowing — matching the in-memory contract.
tx.commit().await.map_err(map_pg)?;
return Ok(if value.is_some() {
pre_total
} else {
Decimal::from(pre_count.max(0) as u64)
});
}There was a problem hiding this comment.
The replay short-circuit still issues one aggregate(..) call on the no-op path. Kept deliberately after the typed-table rewrite: the pre-row cnt is a COUNT(*) valid only for the invocation table, whereas the value path must return SUM(value). Returning cnt directly would be wrong for values, so the replay path runs the same typed aggregate() the insert path uses (one extra in-window read under the advisory lock already held). It keeps the count/sum return correct per table rather than smuggling a count back through the value path.
| // Silently dropping the oldest sample to make room would weaken cap | ||
| // enforcement and break replay refusal — so we fail closed, | ||
| // matching the in-memory backend's `if !dedup && len >= cap { Err }`. | ||
| if pre_count as usize >= MAX_SAMPLES_PER_KEY { |
There was a problem hiding this comment.
While pre_count is expected to be non-negative, using .max(0) before casting to usize is a safer practice in this codebase to prevent potential wrapping issues if the database were to return a negative value.
| if pre_count as usize >= MAX_SAMPLES_PER_KEY { | |
| if pre_count.max(0) as usize >= MAX_SAMPLES_PER_KEY { |
There was a problem hiding this comment.
Done in 7547ebf — the fail-closed cap check now clamps pre_count.max(0) as usize, matching the codebase convention against negative-row wrap.
| //! discriminant so an invocation key and a value key that share | ||
| //! `(hook, tenant, capability)` never collide. | ||
| //! | ||
| //! Length-prefixing every field (4-byte big-endian length ++ bytes) |
There was a problem hiding this comment.
Update the documentation to reflect the change to an 8-byte length prefix for binary encoding consistency.
| //! Length-prefixing every field (4-byte big-endian length ++ bytes) | |
| //! Length-prefixing every field (8-byte big-endian length ++ bytes) |
References
- Prefer using
u64for length prefixes in binary encodings to ensure that conversions fromusizeare infallible on all supported platforms, avoiding panics and potential DoS vectors caused by.expect()on overflow.
There was a problem hiding this comment.
Done in 7547ebf — doc updated to "8-byte big-endian length ++ bytes".
| // 4-byte length prefix makes the field boundary unambiguous; u32 is | ||
| // ample for any tenant/capability/field string we will ever see. | ||
| let len = u32::try_from(field.len()).unwrap_or(u32::MAX); | ||
| hasher.update(&len.to_be_bytes()); |
There was a problem hiding this comment.
Prefer using u64 for length prefixes in binary encodings to ensure that conversions from usize are infallible and to maintain injectivity on all supported platforms. While the current implementation uses u32 with saturation, u64 is the standard for the project's binary encodings to avoid potential collisions if string lengths were to exceed u32::MAX on 64-bit platforms.
| // 4-byte length prefix makes the field boundary unambiguous; u32 is | |
| // ample for any tenant/capability/field string we will ever see. | |
| let len = u32::try_from(field.len()).unwrap_or(u32::MAX); | |
| hasher.update(&len.to_be_bytes()); | |
| // 8-byte length prefix makes the field boundary unambiguous; u64 is | |
| // ample for any tenant/capability/field string we will ever see. | |
| let len = field.len() as u64; | |
| hasher.update(&len.to_be_bytes()); |
References
- Prefer using
u64for length prefixes in binary encodings to ensure that conversions fromusizeare infallible on all supported platforms, avoiding panics and potential DoS vectors caused by.expect()on overflow.
There was a problem hiding this comment.
Done in 7547ebf. The length prefix is now field.len() as u64 (8-byte big-endian), making the usize->len conversion infallible on all platforms and removing the prior u32::try_from(..).unwrap_or(u32::MAX) saturation corner that could (in principle) alias two fields differing only beyond u32::MAX. Serialization stays strictly injective.
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | ||
| ON hook_predicate_counters (scope_hash, kind); |
There was a problem hiding this comment.
The hook_predicate_counters_scope_idx index currently only covers (scope_hash, kind). To optimize the enforce_scope_quota operation, which performs a COUNT(DISTINCT key_hash) and an LRU victim selection using MAX(ts), the index should be expanded to include key_hash and ts. This would enable index-only scans for these operations, significantly improving performance for tenants with a large number of recorded events.
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | |
| ON hook_predicate_counters (scope_hash, kind); | |
| CREATE INDEX IF NOT EXISTS hook_predicate_counters_scope_idx | |
| ON hook_predicate_counters (scope_hash, kind, key_hash, ts); |
There was a problem hiding this comment.
Applied in 7547ebf, adapted to the post-rewrite two-table typed schema (the old single hook_predicate_counters table is gone). The per-scope index on BOTH typed tables is now (scope_hash, key_hash, occurred_at) instead of (scope_hash), so enforce_scope_quota's COUNT(DISTINCT key_hash) and MIN(occurred_at)-per-key victim ranking run as index-only scans for tenants with many recorded keys.
|
@henrypark133 @serrrfirat durable backend PR 2/4 — |
Codex review (advisory) — REQUEST CHANGESThe per-key trim/dedup/cap/insert/aggregate flow is mostly right (parameterized SQL, Critical1. Scope-LRU deletes victim buckets without the victim key's advisory lock — 2. No CI validates this crate against real Postgres — root Recommendations
|
serrrfirat
left a comment
There was a problem hiding this comment.
Thermo-nuclear maintainability pass: I think this needs restructuring before merge. The backend compiles, and the contract harness is wired, but there are a few structural issues that make the implementation harder to reason about than it needs to be, especially around quota locking and the hash-only schema shape.
| kind: &str, | ||
| current_key: &[u8], | ||
| ) -> Result<u64, PredicateBackendError> { | ||
| // Serialize quota enforcement within the scope. Concurrent inserts |
There was a problem hiding this comment.
This lock model is not as clean as the comment claims. enforce_scope_quota takes the scope advisory lock after this transaction already holds the current key lock, then deletes arbitrary victim keys without taking their per-key advisory locks. A concurrent transaction for a victim key can hold row locks from trim/insert while waiting for the same scope lock, while this transaction holds the scope lock and waits on those rows. This is a structural problem, not just a race edge. Can we make quota enforcement own the scope mutation boundary explicitly, e.g. scope-first for new-key materialization or deterministic victim-key locking before delete?
There was a problem hiding this comment.
Fixed in 806eacb by making victim eviction obey the same per-bucket serialization a recorder does — your "deterministic victim-key locking before delete" suggestion.
enforce_scope_quota no longer issues a blind set-based DELETE under the scope lock alone. It now:
- Takes the scope advisory lock and computes the over-quota count (unchanged — this keeps the distinct-key count exact across concurrent new-key materializations).
- Selects victim candidates ordered by
MAX(ts) ASC, over-fetching beyond the evict count. - For each candidate, acquires that victim key's per-key advisory lock with the non-blocking
pg_try_advisory_xact_lock(int4,int4)— the same lock key the recording path derives for that bucket. If the try-lock succeeds it deletes exactly that key's rows; if it fails (a recorder is mid-flight on that bucket under its own key lock) it skips to the next-staleest candidate.
This closes both halves of what you flagged:
- Deadlock cycle eliminated. The cycle required the LRU txn to block on a victim's row locks while a victim recorder blocks on the scope lock. With a try-lock the LRU txn never waits on an in-flight victim — it observes the held key lock, returns immediately, and skips. No edge where both wait on each other.
- Torn aggregate eliminated. Eviction now holds the victim's per-key lock before deleting its rows, so it can never delete rows out from under a recorder that is aggregating that same bucket. Victim deletes are serialized with recorders of that bucket exactly like two recorders are.
Over-fetching candidates means skips don't leave the scope above quota; if every candidate happened to be in-flight the scope simply stays at-or-near quota until the next newly-material insert reruns the pass (bounded, self-correcting). I chose deterministic victim-key try-locking over a global scope-first ordering because it doesn't block the hot recording path and needs no reordering of the existing key-then-scope acquisition.
Fail-closed WindowOverflow and the dedup/atomicity invariants are untouched.
Tests added (crates/ironclaw_hooks_postgres/):
eviction_and_record_derive_identical_per_key_lock(lib unit, no DB) — pins the eviction try-lock key derivation equal to the recorder's lock key, so the two lock spaces can never silently diverge.eviction_try_lock_does_not_block_on_in_flight_victim(adversarial, real PG) — deterministic raw-SQL reproduction: while txn V holds victim key B's lock, txn L's try-lock on B returnsfalsepromptly (under a hard timeout) instead of blocking. This is the load-bearing invariant; pre-fix the blocking DELETE would stall.scope_lru_eviction_serializes_against_victim_writes_no_deadlock(adversarial, real PG) — concurrency stress: fresh-key flood (eviction passes) concurrent with a flood against a hot eviction-candidate key; asserts no deadlock/hang under timeout and that the hot key's reported aggregate matches its surviving in-window row count.
Verified against a local Postgres 14: full crate suite green (7 lib unit, 7 adversarial, 9 contract). The integration tests gate on IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL and skip (passing) without a DB.
One CI note: this crate currently has no real-Postgres job, so these gated tests are skipped in CI today. Recommend adding a Postgres service job that sets IRONCLAW_HOOKS_POSTGRES_URL so the adversarial/contract suites actually execute on PRs — happy to do that as a follow-up.
| /// Idempotent schema applied by `run_migrations()`. Kept byte-compatible | ||
| /// with `migrations/V1__predicate_counters.sql`. | ||
| pub const POSTGRES_PREDICATE_SCHEMA: &str = "\ | ||
| CREATE TABLE IF NOT EXISTS hook_predicate_counters ( |
There was a problem hiding this comment.
This schema hides a simple two-shape domain model behind a generic hash table. The successor spec described explicit invocation/value key columns; here all operator/debug/migration semantics are reconstructed through scope_hash, key_hash, and kind. That makes the durable state harder to inspect and reason about. Unless secrecy of tenant/capability names is an explicit requirement, can we use typed columns/tables and keep hashes as indexes/lock keys rather than making them the only persisted identity?
There was a problem hiding this comment.
Treating the typed-columns-vs-hash-table question itself as a design call for the humans (not changing it here). But the cross-PR maintainability angle surfaced a real divergence worth flagging: the libSQL sibling (#3936) did NOT make the same shape choice.
- Postgres (this PR): one generic table
hook_predicate_counters(scope_hash, key_hash, kind CHAR(1), id, ts, value NUMERIC)— both predicate kinds folded behindkind+ a nullablevalue, keyedPRIMARY KEY (key_hash, id). - libSQL (feat(hooks): LibSqlPredicateStateBackend in own crate (durable backend PR 3/4, replaces #3930) #3936): two typed tables —
hooks_predicate_invocations(scope_hash, event_id, occurred_at, tenant_id)andhooks_predicate_values(scope_hash, event_id, occurred_at, value TEXT, tenant_id), each keyedPRIMARY KEY (scope_hash, event_id).
So the two durable backends disagree on (a) one-generic-table vs two-typed-tables, (b) column names (key_hash/id/ts vs scope_hash/event_id/occurred_at), (c) timestamp encoding (TIMESTAMPTZ vs epoch-ms INTEGER), (d) value encoding (NUMERIC vs exact-decimal TEXT), and (e) the dedup primary key (key_hash vs scope_hash). That's exactly the shape critique you raised, now confirmed to be inconsistent across siblings — surfacing for a reconciliation decision rather than fixing unilaterally.
Separately, comparing the two also caught a genuine correctness divergence in the LRU victim selection, which I did fix on this branch (0c102a6): Postgres ranked eviction victims by MAX(ts) while in-memory + libSQL evict oldest-front (MIN(ts) per key). See the dedicated thread.
There was a problem hiding this comment.
Resolved in ef93722 on this branch — Zaki signed off on the typed-columns shape as the canonical decision, coordinated across all three durable-backend branches so both backends end with the same logical schema.
The generic hook_predicate_counters(kind CHAR(1), …) table is gone. It's now two explicit typed tables matching the successor spec and the libSQL sibling's two-table model:
hooks_predicate_invocations(scope_hash, key_hash, event_id, occurred_at)— in-windowCOUNT(*)is the invocation count.hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at, value NUMERIC NOT NULL)— in-windowSUM(value)is the running sum.
The kind discriminator column is eliminated (table identity carries it), and id/ts are renamed to the canonical event_id/occurred_at. Operator/debug/migration semantics are now directly inspectable per table rather than reconstructed through a kind filter. Postgres keeps its native types (BYTEA / TIMESTAMPTZ / NUMERIC); the libSQL sibling (#3936, hooks-predicate-backend-libsql) was reconciled to the IDENTICAL column set + PK in 0b174b1 (native libSQL types: BLOB / epoch-ms INTEGER / TEXT). The cross-backend parity suite (#3937) re-merges both and proves they stay behaviorally interchangeable.
| } | ||
| } | ||
|
|
||
| /// Shared transaction body for both record paths. `value` is `None` |
There was a problem hiding this comment.
This shared record path is doing too much through value: Option<Decimal>, returning Decimal for both count and sum, then having the invocation path cast it back to u32. That feels like a thin abstraction that saves a few lines by making the main control flow less typed. Can we keep the common lock/trim/dedup/cap pieces as helpers and let record_invocation / record_value remain typed flows with their own aggregation?
There was a problem hiding this comment.
Resolved in ef93722 on this branch. The value: Option<Decimal> double-duty is gone now that the two typed tables make the count-vs-sum distinction structural rather than smuggled through a nullable NUMERIC.
The shared record body still serializes the genuinely common steps (advisory lock, window trim, replay-dedup check, fail-closed cap, ON CONFLICT insert, per-tenant LRU quota) — those are identical across both tables — but the kind-specific parts are now explicitly typed:
- A
RecordKind { Invocation, Value }enum selects the table and folds a per-table tag into the scope advisory lock. - A dedicated
aggregate(kind, …)helper runsCOUNT(*)for the invocation table andSUM(value)for the value table, so the invocation table — which no longer has avaluecolumn at all — is never asked toSUM(value). - The INSERT column list is matched per table (no
valuecolumn for invocations;value NOT NULLfor values).
record_invocation still narrows the count to u32, but it now does so off an explicit COUNT(*) aggregate rather than a Decimal that was standing in for a count. The control flow is typed end-to-end; nothing is reconstructed by casting a sum back into a count.
| //! `READ COMMITTED` transaction guarded by a per-key advisory lock, also | ||
| //! clock-independent. | ||
|
|
||
| /// Idempotent schema applied by `run_migrations()`. Kept byte-compatible |
There was a problem hiding this comment.
The migration file is documented as canonical, but the code executes this separate embedded string. That creates drift risk in the durable schema path. Can we make one source executable, or add a small test that proves migrations/V1__predicate_counters.sql and this constant stay equivalent after normalization?
There was a problem hiding this comment.
Fixed in 0c102a6 (this branch). Removed the hand-copied embedded const entirely — POSTGRES_PREDICATE_SCHEMA is now include_str!("../migrations/V1__predicate_counters.sql"), so the .sql file is the only copy and there is nothing left to drift against. batch_execute tolerates the file's leading -- comment block, so no statement-splitting was needed, and run_migrations() is exercised by the contract + adversarial suites.
Reusable pattern note for the libSQL sibling (#3936): the same embedded-vs-file shape exists there (LIBSQL_PREDICATE_STATE_SCHEMA is a hand-maintained const). Recommend the same include_str! collapse so both durable crates have a single schema source of truth.
…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>
…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>
…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>
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>
|
@serrrfirat @henrypark133 re-review please — major redesign per the agreed direction (SHA ef93722): the generic hash table is gone, replaced by two typed tables ( |
|
@serrrfirat @henrypark133 friendly ping — every item from your review is addressed as of |
serrrfirat
left a comment
There was a problem hiding this comment.
Thermo-nuclear maintainability review: I found two blocker-level issues and one structural cleanup that should be addressed before this lands.
| let backend = Arc::clone(&hs[i % 2]); | ||
| let k = inv_key(tenant, &format!("fresh.{i}")); | ||
| let ts = base() + chrono::Duration::seconds(10_000 + i as i64); | ||
| handles.push(tokio::spawn(async move { |
There was a problem hiding this comment.
This test says Unavailable/deadlock-style failures must not happen, but the spawned task discards the record_invocation result. That means a deadlock-detected DB error, serialization error, or other backend failure still passes as long as the task returns before the timeout. Please have each task return the backend result and assert the allowed outcomes explicitly, so this regression test actually fails on the production failure mode it documents.
There was a problem hiding this comment.
Fixed in 741e0ec. Each spawned task now RETURNS its record_invocation result, and the join collects them into a Vec that is asserted explicitly after the timeout.
Per result the allowed outcomes are: Ok(_) (recorded or replayed), or Err(WindowOverflow {..}) (a fresh/hot key legitimately hitting the per-key sample cap), or a non-deadlock Err(Unavailable(..)) (the new explicit fail-closed quota outcome when every stale victim is momentarily locked). Crucially, an Unavailable whose message contains deadlock or serialize now fails the test — that is exactly the production failure mode (deadlock-detected / serialization-failure DB error) this regression test documents, and previously a discarded let _ = would let it pass as long as the task returned before the 60s timeout.
There was a problem hiding this comment.
Confirmed addressed at the current tip (7547ebf; the fix landed in 741e0ec). Each spawned task now RETURNS its record_invocation result; the join collects them into a Vec asserted explicitly after the timeout. A Unavailable(..) whose message contains deadlock or serialize now FAILS the test — the exact production failure mode this regression documents — instead of passing silently via a discarded let _ =. Allowed outcomes per result: Ok(_), Err(WindowOverflow), or a non-deadlock Err(Unavailable) (the explicit fail-closed quota outcome). Verified: scope_lru_eviction_serializes_against_victim_writes_no_deadlock green against local PG and skip-pass without a DB.
| // fails, see below) we can fall through to the next-oldest key and | ||
| // still meet the quota. Bound the over-fetch so a pathological scope | ||
| // can't pull an unbounded candidate set into memory. | ||
| const CANDIDATE_OVERFETCH: i64 = 64; |
There was a problem hiding this comment.
This makes quota enforcement silently best-effort: we over-fetch a fixed 64 candidates, skip locked victims, and then commit Ok(evicted) even if the scope is still above MAX_KEYS_PER_TENANT. That is a structural problem because the caller never rechecks, so the strong per-scope bound becomes an implementation accident under enough in-flight victims. Can we reframe this as an explicit quota outcome, or loop/requery until the cap is actually met or enforcement fails closed?
There was a problem hiding this comment.
Fixed in 741e0ec. Quota enforcement is now an explicit outcome that either meets the cap or fails closed — never a silent best-effort Ok.
enforce_scope_quota no longer over-fetches a fixed 64 and commits whatever it managed to evict. Under the scope advisory lock it now runs a bounded loop:
- Requery the live
COUNT(DISTINCT key_hash)for the scope. If<= MAX_KEYS_PER_TENANT, returnOk(evicted)— the cap is genuinely met (the recount reflects rows this loop already deleted). - Otherwise compute the exact deficit
to_evict = distinct - MAX_KEYS_PER_TENANT, fetch a fresh oldest-front (MIN(occurred_at)) victim batch, and evict victims one at a time under each victim's per-keypg_try_advisory_xact_lock(deterministic lock order, deadlock-free) — stopping at exactlyto_evictso it can never over-evict below the cap. - If a whole pass makes zero progress (every stale candidate is locked by an in-flight recorder) while still over cap, it fails closed with
PredicateBackendError::Unavailablerather than committing an over-quota scope. The caller's transaction rolls back (the record is not applied) and the evaluator maps the error restrictively — the same posture the per-key cap uses forWindowOverflow. A retry or a concurrent recorder finishing clears the contention.
So the strong per-scope bound is no longer an accident of how many victims happened to be in-flight: a committed record always leaves the scope at-or-under the cap, by construction.
Stateful coverage: the new scope_quota_is_enforced_exactly_not_best_effort test drives a single uncontended writer 200 keys past the cap and asserts the scope holds at EXACTLY MAX_KEYS_PER_TENANT, with the oldest-front keys evicted and the newest surviving. (This test earned its keep — against a live Postgres it caught an over-eviction bug in my first cut, where the inner loop drained the whole 64-candidate batch instead of just the per-pass deficit; the to_evict stop fixes it.)
There was a problem hiding this comment.
Confirmed addressed at the current tip (7547ebf; fix landed in 741e0ec). enforce_scope_quota is now an explicit quota OUTCOME, never silent best-effort. Under the scope advisory lock it loops: requery live COUNT(DISTINCT key_hash); if <= MAX_KEYS_PER_TENANT return Ok(evicted); otherwise compute the exact to_evict deficit and evict precisely that many oldest-front victims (each under its own pg_try_advisory_xact_lock, deadlock-free), stopping at the deficit so it never over-evicts below the cap. 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 — the caller transaction rolls back and the evaluator maps it restrictively, same posture as the per-key WindowOverflow. A committed record always leaves the scope at-or-under cap by construction. scope_quota_is_enforced_exactly_not_best_effort drives a single uncontended writer 200 keys past the cap and asserts the scope holds at EXACTLY the cap.
| /// column), and for the value table it is the real recorded numeric. The | ||
| /// caller-facing `record_invocation`/`record_value` methods map the | ||
| /// returned aggregate to their typed return. | ||
| async fn record( |
There was a problem hiding this comment.
The schema moved to typed tables to delete the old nullable value abstraction, but this shared record entry point reintroduces the same hidden mode through value: Option<Decimal> plus RecordKind, with the invariant guarded only by debug_assert_eq. This is a maintainability regression: the public shape says typed paths, while the core transaction still has a nullable mode flag. I think there is a cleaner code-judo move here: use a typed RecordPlan/enum that carries the value only for the value path, or split the two record methods and share smaller helpers for lock/trim/dedup/quota instead of rebuilding a generic record path.
There was a problem hiding this comment.
Fixed in 741e0ec. The shared record no longer carries a nullable mode flag.
The value: Option<Decimal> + RecordKind + debug_assert_eq triad is gone. The core transaction now dispatches on a typed RecordPlan enum:
RecordPlan::Invocation(PlanCommon)— the count path; it structurally cannot carry a value (the invocation table has novaluecolumn).RecordPlan::Value { common: PlanCommon, value: Decimal }— the sum path; the recorded numeric lives inside the variant, not in a side-channel.
The genuinely common steps (advisory lock, window trim, replay-dedup, fail-closed cap, ON CONFLICT insert, per-tenant LRU quota) run off the shared PlanCommon via RecordPlan::common(), while the only variant-specific bits — the INSERT column list and the final COUNT(*) vs SUM(value) aggregate — match on the enum. There is no nullable NUMERIC double-duty and no debug-only invariant tying a separate value arg to a separate kind; the type makes "value present iff value table" non-representable otherwise. record_invocation / record_value build the typed plan and map the typed return.
There was a problem hiding this comment.
Confirmed addressed at the current tip (7547ebf; fix landed in 741e0ec). The value: Option<Decimal> + RecordKind + debug_assert_eq triad is gone. The shared record now dispatches on a typed RecordPlan enum: RecordPlan::Invocation(PlanCommon) (count path, structurally cannot carry a value) and RecordPlan::Value { common, value: Decimal } (sum path, the numeric lives in the variant). Common steps run off RecordPlan::common(); the only variant-specific bits — INSERT column list and the COUNT(*) vs SUM(value) aggregate — match on the enum. No nullable NUMERIC double-duty, no debug-only invariant; the type makes "value present iff value table" non-representable otherwise.
…erting 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>
Addressed the post-typed-schema maintainability review (741e0ec)All three follow-up items from the latest review are fixed; the two earlier BLOCKER items (typed columns, migration single-source) had already landed earlier on the branch and the new line refs pointed at the post-refactor file. 1. Quota enforcement — explicit outcome, never silently best-effort (backend.rs:525). 2. Nullable-mode anti-pattern removed (backend.rs:185). The shared 3. Adversarial LRU test asserts outcomes (adversarial.rs:407). Each spawned task RETURNS its result; the join asserts the allowed-outcome set and fails the test on any deadlock/serialization Plus a new stateful regression test VerificationAgainst a local Postgres 14: 7 lib unit + 8 adversarial + 9 contract tests green. (CI note from the earlier thread still stands: this crate has no real-Postgres CI job, so the DB-gated legs skip-pass on PRs until a Postgres service job sets |
|
@serrrfirat all six items from the thermo-nuclear review are addressed in 741e0ec (replies posted inline). Highlights: quota enforcement now loops-until-cap-met-or-fails-closed with deterministic oldest-front per-key locking (no more silent best-effort Ok); the nullable |
henrypark133
left a comment
There was a problem hiding this comment.
Code Review (multi-agent)
Intent: Add PostgresPredicateStateBackend for durable cross-host predicate state
Stats: 4 findings (from 4 raw, 4 after dedup) across 1 file. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability. Reviewers failed: none. Body-only: 0
Performance
- High Redundant COUNT queries on invocation hot path add 2 extra round trips per record() (
backend.rs:361-389, confidence 95) — three COUNT queries where one suffices. - Low format! per SQL statement allocates 5-6 Strings per hot-path record() call (
backend.rs:258-621, confidence 70)
Tests
- Medium Postgres evict_older_than has no integration test (
backend.rs:709-727, confidence 75)
Security
- Low Raw Postgres error messages exposed through PredicateBackendError::Unavailable (
backend.rs:773-778, confidence 75)
Bugs, Conventions, Local Patterns, Maintainability
No findings.
| advisory_lock_key_from_bytes(key) | ||
| } | ||
|
|
||
| /// Same derivation as [`advisory_lock_key`] but over a raw byte slice — used |
There was a problem hiding this comment.
Low — Raw Postgres error messages exposed through PredicateBackendError::Unavailable.
map_pg() and map_pool() convert errors via e.to_string(), passing raw DB error messages to callers without redaction.
Fix: Log the full error with tracing::warn! and return a sanitized message.
There was a problem hiding this comment.
Fixed in 2ee6f0f. map_pg/map_pool now tracing::warn!(error = %e, ...) the full raw error for operators and return a fixed sanitized message (predicate state backend unavailable (database error)) through PredicateBackendError::Unavailable, so raw Postgres error text (which can embed connection/schema details) no longer reaches callers. The evaluator only needs the unavailable signal to fail closed.
| //! re-insert of the same id regardless of clock skew) AND the property | ||
| //! that lets a replay survive the cap boundary. | ||
| //! 3. **Caps fail-closed**: if the `event_id` is new (not a replay) and the | ||
| //! in-window count is already at [`MAX_SAMPLES_PER_KEY`], the call |
There was a problem hiding this comment.
High — Redundant COUNT queries on invocation hot path add 2 extra round trips per record().
Every non-replay invocation issues three COUNT queries where one suffices. (1) pre_count needed for dedup+cap check. (2) in_window_count: under per-key advisory lock, in_window_count = pre_count + 1 provably. (3) aggregate() issues another COUNT, redundant since in_window_count already holds the value.
Fix: Derive in_window_count from pre_count + 1. Use if pre_count == 0 for scope-quota gate. Use derived count as return aggregate for invocations.
There was a problem hiding this comment.
Fixed in 2ee6f0f. The post-insert in-window count is now derived as pre_count + 1 instead of a second COUNT, and for the invocation table that derived value is returned directly as the aggregate (no third COUNT). This is provable under the per-key advisory lock: the dedup check proved the id absent, the cap gate passed, and INSERT ... ON CONFLICT DO NOTHING therefore added exactly one in-window row, and no concurrent writer can touch this key while we hold its lock. The quota gate now keys on pre_count == 0 (equivalent to the old in_window_count == 1). The value aggregate stays a SUM(value) query since a sum is not derivable from the sample count. Net: 3 COUNTs -> 1 on the invocation hot path.
| // in-window records fresh — matching the in-memory backend, whose | ||
| // dedup memory is exactly the in-window entry set. | ||
| tx.execute( | ||
| &format!("DELETE FROM {table} WHERE key_hash = $1 AND occurred_at < $2"), |
There was a problem hiding this comment.
Low — format! per SQL statement allocates 5-6 Strings per hot-path record() call.
Every SQL statement in record() uses format! to interpolate the table name, allocating a new String on every call.
Fix: Pre-build statement strings at construction time.
There was a problem hiding this comment.
Fixed in 2ee6f0f. Added a TableStatements struct that pre-formats every record()/reaper SQL statement once per typed table at backend construction; record() selects the matching set via the plan variant. No more per-call format! allocations on the hot path.
| @@ -0,0 +1,833 @@ | |||
| //! [`PostgresPredicateStateBackend`] — durable, cross-host-consistent | |||
There was a problem hiding this comment.
Medium — Postgres evict_older_than has no integration test.
The Postgres override performs two DELETE statements but no test exercises the Postgres implementation.
Fix: Add evict_older_than_removes_stale_rows_from_both_tables adversarial test.
There was a problem hiding this comment.
Fixed in 2ee6f0f. Added evict_older_than_removes_stale_rows_from_both_tables to the adversarial suite: it records stale + fresh rows on BOTH typed tables, calls evict_older_than(cutoff), asserts the returned count is exactly the 3 stale rows, and verifies via a direct row count (invocation table) and a replay-read (value table) that only the fresh rows survive. Same env-gate pattern as the rest of the suite (skips passing without a live DB).
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>
|
Addressed @henrypark133's latest CHANGES_REQUESTED review (all 4 findings) in 2ee6f0f:
Preserved: atomic record-and-read transaction + advisory-lock serialization, tenant-scoped keying, fail-closed cap/quota posture, shared identity hashing, durable-backend contract suite, libSQL-sibling parity. Removed the now-unused |
|
Review of #3933: PostgresPredicateStateBackend No critical bugs found. Two notes: Low —
The victim bucket's per-key advisory lock is taken with Nit — advisory lock key is a 64-bit hash split into two i32s Hash collision between two different bucket keys maps them to the same advisory lock key → unnecessary serialization of unrelated writers. Not a correctness issue (just throughput), and 64-bit collision space makes it rare. No action needed, just worth knowing. |
serrrfirat
left a comment
There was a problem hiding this comment.
Code Review — nearai/ironclaw #3933
PostgresPredicateStateBackend (durable backend PR 2/4)
| Category | Findings | Severity |
|---|---|---|
| Security | 0 | — |
| Bugs | 0 | — |
| Performance | 2 | Low |
| Tests | 2 | Low |
| Conventions | 2 | Medium / Low |
Reviewers: security · bugs · performance · tests · conventions
Head: 2ee6f0f5 | Base: 16bb43d5
The implementation is solid — the advisory-lock discipline, typed RecordPlan enum, fail-closed cap, and replay-dedup logic are correct. The adversarial test suite (scope_quota_is_enforced_exactly_not_best_effort, scope_lru_eviction_serializes_against_victim_writes_no_deadlock, per_key_sample_cap_fails_closed_under_flood) is comprehensive. No Critical or High findings.
🟡 Conventions — Medium
C-1 · tier-1-#1 · libSQL backend absent: AGENTS.md must support both PostgreSQL and libSQL
AGENTS.md: "New persistence behavior must support both PostgreSQL and libSQL. Add new DB operations to the shared DB trait first, then implement both backends."
This PR adds PostgresPredicateStateBackend + schema + migrations with no libSQL counterpart. The rule is a hard MUST. The PR body names the staged 4-PR series, which partially justifies the approach, but the repo rule requires both backends before the new persistence surface is considered complete.
Recommendation: Before merging, either (a) link the libSQL PR (PR 3 or 4 in the series) and block merge on it landing first, or (b) open a tracking issue and add a TODO comment in lib.rs that the libSQL backend is required. The PR series approach is reasonable — just make the dependency explicit.
🔵 Conventions — Low
C-2 · tier-1-#1 · enforce_scope_quota Unavailable messages bypass DB_UNAVAILABLE_MSG sanitization
map_pg / map_pool use DB_UNAVAILABLE_MSG ("predicate state backend unavailable (database error)") precisely because "the raw error... can reach the evaluator/caller" (backend.rs:843). The quota enforcement path constructs rich Unavailable(format!(...)) messages that expose MAX_KEYS_PER_TENANT and internal lock-contention state. These messages are not credentials, but they break the established sanitization invariant. Inline comment attached.
🔵 Performance — Low (×2)
Inline comments attached at the relevant lines.
🔵 Tests — Low (×2)
Inline comments attached at the relevant lines.
| // evaluator maps the error restrictively — same posture as the | ||
| // per-key cap's `WindowOverflow`. A retry (or a concurrent | ||
| // recorder finishing) clears the contention. | ||
| return Err(PredicateBackendError::Unavailable(format!( |
There was a problem hiding this comment.
[C-2 · Conventions/Low] enforce_scope_quota constructs detailed Unavailable(format!(...)) messages that expose MAX_KEYS_PER_TENANT and lock-contention state, bypassing the DB_UNAVAILABLE_MSG sanitization pattern established at line 843 ("whose payload can reach the evaluator/caller"). The quota constants are not sensitive, but the inconsistency means the evaluator/caller could observe different message shapes from the same error type depending on the failure path.
Fix: Extract quota-enforcement failure messages into constants (like DB_UNAVAILABLE_MSG) or reuse a single sanitized message. The evaluator should only need the error type, not the message payload, to map to a restrictive outcome.
const QUOTA_CONTENDED_MSG: &str = "predicate state backend unavailable (quota enforcement contended)";
const QUOTA_BUDGET_MSG: &str = "predicate state backend unavailable (quota enforcement budget exhausted)";There was a problem hiding this comment.
Correct — enforce_scope_quota builds Unavailable(format!(...)) payloads at backend.rs:670 and :684 that leak MAX_KEYS_PER_TENANT and contention state, while map_pg/map_pool route every DB failure through the sanitized DB_UNAVAILABLE_MSG (backend.rs:805, henrypark finding). Will fix: hoist both quota-failure messages into const QUOTA_CONTENDED_MSG/QUOTA_BUDGET_MSG so the evaluator only sees the error type, matching the sanitization contract.
There was a problem hiding this comment.
Fixed in 34d1d60: hoisted both quota fail-closed messages into sanitized constants QUOTA_CONTENDED_MSG and QUOTA_BUDGET_MSG (mirroring DB_UNAVAILABLE_MSG). The operational detail (MAX_KEYS_PER_TENANT, MAX_PASSES, contention state) now goes to tracing::debug! and the Unavailable payload returned to the evaluator/caller is the sanitized constant only.
| // space, disjoint from the per-key `(int4,int4)` lock space. Hot-path | ||
| // same-key writes never reach here (only newly-material keys do), so | ||
| // this does not serialize steady-state traffic. | ||
| let scope_lock = scope_advisory_lock_key(scope_ref, plan.lock_tag()); |
There was a problem hiding this comment.
[P-1 · Performance/Low] scope_advisory_lock_key(scope_ref, plan.lock_tag()) recomputes a blake3 hash on every enforce_scope_quota call (every count == 1 new-key write). The scope bytes are already an artifact of hashing, and the kind tag is one byte — this is a second blake3 invocation per new-key path.
Fix: Precompute and cache the scope lock key in PlanCommon (alongside scope and key) during record_invocation/record_value setup, so it's derived once not twice:
struct PlanCommon {
scope: Digest,
key: Digest,
scope_lock_key: i64, // precomputed
label: String,
}Not blocking — blake3 is fast and this path only runs on new-key inserts.
There was a problem hiding this comment.
Accurate — scope_advisory_lock_key(scope_ref, plan.lock_tag()) (backend.rs:562) recomputes a blake3 over the scope digest on each new-key write, and PlanCommon already carries scope/key so it could be precomputed once. Deferring: this only runs on the in_window_count == 1 new-key path (backend.rs:437), not steady-state same-key writes, and adding a field to PlanCommon touches both record_invocation/record_value constructors — a follow-up since you flagged it non-blocking and blake3 is cheap here.
There was a problem hiding this comment.
Agreed-deferred per the posted reply; not touched in this pass.
| // Roll back so the trim above (which freed aged-out dedup ids) | ||
| // is not committed independently of a rejected record; the | ||
| // caller observes a clean no-write overflow. | ||
| drop(tx); |
There was a problem hiding this comment.
[P-2 · Performance/Low] The drop(tx) rollback here is documented as intentional (trim not committed independently of a rejected record). However, there is a subtle repeated-work case: if a key has exactly MAX_SAMPLES_PER_KEY in-window rows and additional stale (out-of-window) rows, each overflow write attempt will: (1) trim the stale rows, (2) read pre_count = MAX_SAMPLES_PER_KEY, (3) fail the cap check, (4) rollback — restoring the stale rows. The next overflow attempt repeats the same cycle.
In practice this requires the key to be simultaneously at full in-window capacity and have unreaped stale rows (an unusual but possible state). The stale rows are eventually cleaned by evict_older_than, but until then, each overflow write generates a trim-and-rollback cycle unnecessarily.
Fix (optional): Commit the trim unconditionally before the cap check, then start a fresh transaction for the cap check + insert. This avoids repeated stale-row churn. Or add a comment noting the wasted-trim scenario as a known minor cost of the fail-closed rollback design.
There was a problem hiding this comment.
The trim-and-rollback churn is real for the simultaneous full-in-window + unreaped-stale state, and drop(tx) at backend.rs:383 does discard the trim. Deferring to a comment rather than the two-transaction restructure: splitting the trim commit from the cap check would break the fail-closed atomicity guarantee the rollback exists to provide (caller observes a clean no-write overflow), and the stale rows are bounded and reaped by evict_older_than. Will add a comment at backend.rs:383 noting the wasted-trim cost as a known minor cost of the fail-closed design.
There was a problem hiding this comment.
Agreed-deferred per the posted reply; not touched in this pass.
| // every candidate is momentarily locked, after which we fail closed | ||
| // rather than spin. This bound keeps the transaction from looping | ||
| // unboundedly under sustained contention. | ||
| const MAX_PASSES: usize = 1_024; |
There was a problem hiding this comment.
[T-1 · Tests/Low] The MAX_PASSES budget exhaustion path (the Err(...) at line 684) and — separately — a scenario where every candidate key in a pass is locked (!progressed at line 662 → Err) are the two critical fail-closed branches of enforce_scope_quota. While scope_lru_eviction_serializes_against_victim_writes_no_deadlock permits Unavailable as a pass-through outcome, no test asserts that either of these specific code paths fires.
Suggested test: In the adversarial suite, set up a scope at MAX_KEYS_PER_TENANT + 1 with all candidate keys mid-write (holding their per-key advisory locks), then verify that enforce_scope_quota returns Err(PredicateBackendError::Unavailable) — confirming the !progressed fail-closed path is exercised, not just silently skipped.
tests::adversarial::scope_quota_fails_closed_when_all_candidates_locked covering !progressed path
There was a problem hiding this comment.
Confirmed — scope_lru_eviction_serializes_against_victim_writes_no_deadlock (predicate_state_postgres_adversarial.rs:370) only permits Unavailable as a pass-through (asserts it isn't a deadlock) and never forces the !progressed branch at backend.rs:662. Deferring: the adversarial suite is gated on a live Postgres (db_url()), and deterministically pinning every candidate key mid-write to drive !progressed is a non-trivial harness addition. Will add scope_quota_fails_closed_when_all_candidates_locked in the durable-backend test follow-up.
There was a problem hiding this comment.
Agreed-deferred per the posted reply; not touched in this pass.
| match chrono::Duration::from_std(window) { | ||
| Ok(d) => now.checked_sub_signed(d).unwrap_or(now), | ||
| Err(_) => now, | ||
| } |
There was a problem hiding this comment.
[T-2 · Tests/Low] The Err(_) => now branch (lines 274–275) fires when window exceeds chrono's maximum Duration — e.g., Duration::MAX. In that case cutoff saturates to now, meaning the entire window is treated as in-scope (nothing trimmed). This is the conservative/correct behavior, but it's never exercised by any unit test.
Suggested test:
#[test]
fn cutoff_with_overflow_window_saturates_to_now() {
let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
// Duration::MAX exceeds chrono's max i64 nanoseconds
assert_eq!(PostgresPredicateStateBackend::cutoff(now, Duration::MAX), now);
}This belongs in tests inside backend.rs (where eviction_and_record_derive_identical_per_key_lock lives) and requires making cutoff accessible from tests (it's already a fn on the struct, accessible via use super::*).
There was a problem hiding this comment.
Correct — the Err(_) => now saturation arm of cutoff (backend.rs:274) is unexercised. It's a pure fn cutoff(now, window) on the struct, testable via use super::* with no Postgres. Will fix: add cutoff_with_overflow_window_saturates_to_now asserting cutoff(now, Duration::MAX) == now to the in-module tests block alongside eviction_and_record_derive_identical_per_key_lock.
There was a problem hiding this comment.
Fixed in 34d1d60: added cutoff_with_overflow_window_saturates_to_now to the in-module tests block, asserting cutoff(now, Duration::MAX) == now (the Err(_) => now saturation arm trims nothing). Pure fn, runs without live Postgres; passes under --features postgres.
…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>
…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>
Address serrrfirat C-1 (Medium): the repo rule requiring both PostgreSQL and libSQL persistence backends is satisfied across the staged durable-backend series, not in this crate alone. Document the libSQL counterpart (ironclaw_hooks_libsql, PR #3936) and the cross-backend parity suite (ironclaw_hooks_parity, PR #3937) in the crate-level doc, including merge ordering, so the dependency is explicit per the reviewer's recommendation (b). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@serrrfirat C-1 is addressed: the dual-backend rule is now explicit in the PR body and crate docs ( |
|
@henrypark133 This one is ready for your re-review as well — all findings from the June 3 review pass are addressed (see the summary comment above for commits), and your review request is still pending on it. |
henrypark133
left a comment
There was a problem hiding this comment.
Code Review (multi-agent, re-review)
Intent: PostgresPredicateStateBackend — durable backend PR 2/4.
Stats: 8 new findings (1 High, 3 Medium, 4 Low) after deduping against 52 prior comments. 8 reviewers run.
Previously raised — author has sound reply, no re-block
backend.rsevict_older_than untested in Postgres — fixed in2ee6f0f55(test added).backend.rs:775map_pg/map_pool raw error exposure — fixed in2ee6f0f55(sanitized return + raw at warn for operators).backend.rsvalue: Option shared record path — refactored to typed tables inef937229b/2ee6f0f55(TableStatements).backend.rs:552lock-ordering / deadlock concern (serrrfirat) — addressed in741e0ecac(explicit quota outcome, try-lock semantics).backend.rs:27COUNT-on-hot-path round-trip — addressed in741e0ecac.backend.rs:562scope_advisory_lock_key recomputes blake3 — acknowledged by author as small per-new-key cost.backend.rs:383drop(tx) trim-rollback churn — acknowledged as intentional trade-off.schema.rsPOSTGRES_PREDICATE_SCHEMA hand-copied const — replaced with include_str! in0c102a631.predicate_state_postgres_adversarial.rs:405discarded JoinHandle results — fixed in741e0ecac.
Tests
- High
record_valueWindowOverflow fail-closed has no Postgres test (tests/predicate_state_postgres_adversarial.rs:245, conf 90) — value table cap untested; only invocation is. See inline. - Medium
scope_advisory_lock_keyinvocation-vs-value distinctness has no unit test (backend.rs:798, conf 75) — pin the b"i"/b"v" disjointness invariant. See inline. - Medium evictions counter must not advance on rolled-back WindowOverflow — no test (
backend.rs:583, conf 75). See inline.
Bugs
- Medium
evict_older_thanruns two DELETEs without a transaction; partial delete on second failure (backend.rs:751, conf 75) — atomicity gap separate from the test added in2ee6f0f55. See inline.
Local patterns
- Low New crate missing
AGENTS.mdrequired by every workspace crate (Cargo.toml, conf 100). See inline.
Maintainability
- Low
advisory_lock_keyis a trivial pass-through wrapper overadvisory_lock_key_from_bytes(backend.rs:771, conf 85). See inline. - Low
TableStatements::new(table, with_value_column: bool)uses a mode flag bool (backend.rs:165, conf 75) — prefer two named constructors. See inline.
Conventions
- Low Crate accepts raw
deadpool_postgres::Poolrather than routing throughRootFilesystemper.claude/rules/database.md(backend.rs:222, conf 55) — may have valid rationale; record it. See inline.
| let id = ev("shared-value-X"); | ||
|
|
||
| let s_a = hs[0] | ||
| .record_value(&key, &id, now, Decimal::from(50), window) |
There was a problem hiding this comment.
High — per_key_sample_cap_fails_closed_under_flood exercises the invocation path only. record_value runs through the same record() body but the cap-reject path on hooks_predicate_values is untested. A regression in the value-table INSERT or aggregate_sum would silently allow over-cap values rather than failing closed.
Fix: Add per_value_key_sample_cap_fails_closed_under_flood mirroring this test against record_value; assert WindowOverflow at MAX_SAMPLES_PER_KEY+1; assert in-window replay still dedups.
New finding — not raised in prior reviews.
| self.evictions.load(Ordering::Relaxed) | ||
| } | ||
|
|
||
| async fn evict_older_than(&self, cutoff: DateTime<Utc>) -> Result<u64, PredicateBackendError> { |
There was a problem hiding this comment.
Medium — evict_older_than runs two DELETE statements on a non-transactional client. If the invocation DELETE commits and the value DELETE fails, invocation rows are permanently gone while value rows remain, and the function returns Err — the caller has no indication of partial work. A retry would delete 0 additional invocation rows but delete the remaining value rows.
Fix: Wrap both DELETEs in a transaction (begin → execute both → commit), rolling back on any error so either both tables are reaped or neither is.
New finding — separate from the test added in 2ee6f0f55; not raised in prior reviews.
| // space, disjoint from the per-key `(int4,int4)` lock space. Hot-path | ||
| // same-key writes never reach here (only newly-material keys do), so | ||
| // this does not serialize steady-state traffic. | ||
| let scope_lock = scope_advisory_lock_key(scope_ref, plan.lock_tag()); |
There was a problem hiding this comment.
Medium — lock_tag (b"i" vs b"v") is what keeps invocation and value scope-quota passes from serializing against each other. A collision would make every scope-quota pass on hooks_predicate_values serialize behind passes on hooks_predicate_invocations for the same tenant. There is no unit test pinning the disjointness invariant, and it is a pure function testable without Postgres.
Fix: Add invocation_and_value_scope_lock_keys_are_distinct asserting scope_advisory_lock_key(scope, b"i") != scope_advisory_lock_key(scope, b"v") for the same scope bytes.
New finding — not raised in prior reviews.
| /// behind an `Arc`. | ||
| pub struct PostgresPredicateStateBackend { | ||
| pool: Pool, | ||
| /// Local mirror of LRU evictions performed by THIS process instance, |
There was a problem hiding this comment.
Medium — evictions_observed() increments only after tx.commit(). When a per-key cap overflow rolls back via drop(tx), the counter must not advance. No test drives a key to overflow then asserts the counter is unchanged. A bug crediting evictions on rollback would produce spurious telemetry without corrupting data — still a correctness gap.
Fix: Add evictions_counter_unchanged_on_window_overflow driving a key to MAX_SAMPLES_PER_KEY+1, then asserting evictions_observed() is unchanged from the pre-overflow snapshot.
New finding — not raised in prior reviews.
|
|
||
| [features] | ||
| default = [] | ||
| # The Postgres backend itself. Mirrors the per-crate `postgres` feature gate |
There was a problem hiding this comment.
Low — crates/AGENTS.md states "Every crate with Cargo.toml has a crate-local AGENTS.md" and directs agents to treat it as the first file to load. Sibling durable-backend crates ironclaw_reborn_event_store and ironclaw_filesystem ship one; ironclaw_hooks_postgres does not.
Fix: Add crates/ironclaw_hooks_postgres/AGENTS.md following the shape used by ironclaw_reborn_event_store/AGENTS.md (What This Crate Owns / Do Not Move In Here / Validation / Agent Notes).
New finding — not raised in prior reviews.
| // released automatically at commit/rollback. Collisions across | ||
| // distinct keys (same 64-bit lock key) only cost extra | ||
| // serialization, never correctness. | ||
| let lock_key = advisory_lock_key(&key); |
There was a problem hiding this comment.
Low — advisory_lock_key(&Digest) exists only to call advisory_lock_key_from_bytes(&[u8]). Digest is [u8; 32]; slice coercion makes &digest[..] (or just &digest) a zero-cost deref. The wrapper introduces a named concept without adding any safety or clarity.
Fix: Delete advisory_lock_key. Update the two call sites (here and in enforce_scope_quota) plus the per-key test to call advisory_lock_key_from_bytes directly.
New finding — not raised in prior reviews.
| Self { | ||
| pool, | ||
| evictions: AtomicU64::new(0), | ||
| invocation_sql: TableStatements::new(INVOCATIONS_TABLE, false), |
There was a problem hiding this comment.
Low — TableStatements::new(table, with_value_column: bool) selects between two insert-SQL shapes via a boolean. Always called with literals (false/true) paired with RecordPlan::Invocation/Value. Repo types convention prefers enums or named constructors over boolean mode flags.
Fix: Replace with TableStatements::for_invocations(table) and TableStatements::for_values(table); each builds its insert SQL directly with no branch.
New finding — not raised in prior reviews.
| use std::sync::atomic::{AtomicU64, Ordering}; | ||
| use tokio_postgres::IsolationLevel; | ||
|
|
||
| use crate::hashing::{Digest, invocation_key_hash, scope_hash, value_key_hash}; |
There was a problem hiding this comment.
Low (confidence 55) — .claude/rules/database.md directs new crates to mount through RootFilesystem and let the wiring layer pick the backend. This crate accepts a raw deadpool_postgres::Pool. The sibling crate cited as the structural reference (ironclaw_reborn_event_store) routes through RootFilesystem/ScopedFilesystem. The advisory-lock and sliding-window aggregate requirements may genuinely justify the bypass, but no rationale is recorded.
Fix: Either route through Arc<dyn RootFilesystem> matching the event-store pattern, or add an inline rationale comment at pub fn new(pool: Pool) and a sentence in the PR body explaining why the abstraction cannot serve here.
New finding — not raised in prior reviews.
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>
…rable backend PR 4/4) (#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 #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> * 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> * 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 #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> * 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 #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 #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 (#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 #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> * 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 #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 #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): canonical predicate hashing + cutoff in shared crate (#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 (#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 (#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 (#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 (#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 #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> * test(hooks-parity): note SQLITE_MISUSE serialization caveat in parity_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> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ED flag (#3934) (#3938) * feat(hooks): extension-declared hook section on ExtensionManifestV2 (#3934) Add a `[[hooks]]` declaration surface to the production v2 extension manifest (`ironclaw_extensions::ExtensionManifestV2` and its projected `ExtensionManifest`). Each entry is carried as a structurally-typed `HookSectionEntryV2` DTO — a `local_id` plus the entry's body re-serialized to canonical TOML — so `ironclaw_extensions` (substrate) never imports the `ironclaw_hooks` predicate vocabulary. The composition layer, which depends on both crates, is the single seam that projects these payloads into typed `ironclaw_hooks::HookManifestEntry` values (a later commit). Parse-time structural bounds: `MAX_MANIFEST_HOOKS` (32, matching the downstream per-extension registration cap) and `MAX_HOOK_ENTRY_BYTES` (8 KiB per entry). Entries must be tables carrying a non-empty `id`; ids must be unique within the manifest. `#[serde(default)]` keeps every existing manifest valid (empty `hooks` vec). The DTO holds canonical TOML as a `String` rather than a `toml::Value` so the enclosing `ExtensionManifestV2` keeps its `Eq` derive (`toml::Value` is not `Eq`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): composition-layer activation module (loader, first-party hook, flag) (#3934) Add `ironclaw_reborn_composition::hooks` — the single seam that activates the hook framework in production. Implements four numbered pieces of #3934: - Feature flag (item 7): `HooksActivationConfig`, default OFF, resolved from `HOOKS_ENABLED` (only `1`/`true`/`yes`/`on` enable; unset/anything else = OFF). Flag OFF ⇒ `build_hook_dispatcher_builder_factory` returns `None` and the runtime composes no dispatcher — exact pre-hooks behavior. Hard rollout-safety contract. - Manifest → registry loader (item 2): `install_extension_hooks` projects each `ExtensionManifestV2` `HookSectionEntryV2` (canonical TOML) into a typed `HookManifestEntry` and installs it via `HookRegistrar::install` at the `Installed` trust tier. This is the clean-boundary projection: the hook vocabulary lives only here, never in `ironclaw_extensions`. Trust attenuation is enforced by construction (registrar only calls `install_installed_*`). Fail-closed: any projection/install error fails the build loudly. - First-party builtin hooks (item 3): a single illustrative no-op observer (`NoOpObserverHook`), installed regardless of extensions. Ships dark (zero driver-visible effect even with the flag ON). Production catalog is TBD by design — this PR does not invent a first-party hook. - Dispatcher composition (item 5): builds a per-tenant `PredicateEvaluator` over the in-memory state backend (swappable via the new public `PredicateEvaluator::with_state_backend` for durable #3933), validates the full install set once fail-closed, and returns a per-run builder-factory closure. Per-run construction (fresh registry/dispatcher per host build) + per-tenant evaluator give full isolation; the host factory attaches the run-scoped milestone sink internally. Per-tenant scoping is by construction: `build_reborn_runtime` runs once per identity, so everything here is tenant-local — no global registry. The router-backed gate-ref factory (PauseApproval/PauseAuth) and the security-audit sink (#3922, not yet on this branch) are deferred follow-ups; their absence is fail-closed (PauseApproval surfaces as Denied) and noted for the PR body. Not yet wired into the runtime — next commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): wire dispatcher builder factory into build_default_planned_runtime (#3934) Item 6 of #3934. Add an optional `hook_dispatcher_builder_factory` to `DefaultPlannedRuntimeParts` and, in `build_default_planned_runtime`, call `.with_hook_dispatcher_builder_factory(...)` on the production `RebornLoopDriverHostFactory` when it is present. `None` (the default) means no dispatcher is composed — behavior identical to the pre-hooks runtime (rollout-safety contract). The composition layer (`build_reborn_runtime`) resolves the flag via `HooksActivationConfig::from_env()` and builds the factory against this tenant's extension registry (per-tenant by construction — the function runs once per identity). Fail-closed: a malformed manifest hook fails the build here rather than composing a broken dispatcher. A per-run builder factory (not a captured dispatcher instance) is used so the host attaches a run-scoped milestone sink internally per build — per-run telemetry attribution, the #3573 capture-and-stick lesson. All `DefaultPlannedRuntimeParts` construction sites (8 test sites across ironclaw_reborn, ironclaw_product_workflow, ironclaw_reborn_composition) updated with the new field defaulting to `None`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): e2e activation tests through build_default_planned_runtime (#3934) Item 8 of #3934. Add four end-to-end tests in crates/ironclaw_reborn/tests/loop_driver_host.rs that drive the *production* composition function `build_default_planned_runtime` with a per-run hook dispatcher builder factory shaped exactly like the composition layer's output (first-party builtin no-op observer + extension-declared `Installed`-tier hooks projected from a manifest entry through `HookRegistrar::install`), then build a host via the composed `host_factory` and invoke a capability: - flag OFF (no factory): allowed capability completes unaffected and reaches the inner host runtime port — the pre-hooks behavior / rollout-safety contract. - flag ON, first-party-only no-op observer: outcome unchanged, inner port reached — the builtin ships dark. - flag ON, extension-declared deny hook: capability denied through the composed runtime and the inner port is never reached (installed at the Installed tier via the registrar; OwnCapabilities scope keyed to the capability provider). - per-tenant isolation: tenant A's deny hook fires; tenant B (separate build_default_planned_runtime composition, no hooks) completes the same capability — proving no cross-tenant leakage. Security-audit-on-deny assertion is intentionally deferred: #3922's SecurityAuditSink is not yet on reborn-integration. It lands with the audit-sink wiring follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): thread hook_dispatcher_builder_factory through shared reborn harness (#3934) The root-crate `tests/support/reborn/harness.rs` constructs `DefaultPlannedRuntimeParts` directly; add the new `hook_dispatcher_builder_factory: None` field so the parity-test harness compiles. Default `None` keeps the harness on the no-hooks path (unchanged behavior). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks): proper error handling / safety annotations for activation production paths (#3938 CI) The per-run dispatcher factory closure used `.expect()` on the first-party and extension hook installs, tripping the no-panics CI gate. These installs are pure replays of the install set already validated fail-closed (`?`) against a scratch builder at composition time, so they are genuine invariants. The factory type returns a non-Result `HookDispatcherBuilder` and is invoked deep in the run loop, so the documented `// safety:` suppression is the correct fix here. Hoisted the expect messages into `let` bindings so the `.expect(msg)` call fits on one line, keeping the scanner-required `// safety:` comment on the same line as the call after rustfmt. The malformed-manifest path (TOML projection) already uses real error propagation via map_err/`?` and is unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): direct composition-loader coverage + activation-scope docs Address Codex non-blocking follow-ups on #3938. Add three direct tests for the composition-layer hook loader (`install_extension_hooks` via `build_hook_dispatcher_builder_factory`), driving a real `ExtensionRegistry` with `[[hooks]]` declarations rather than mimicking the loader: - valid `own_capabilities` predicate hook installs at the Installed trust tier; the dispatcher carries the derived binding at BeforeCapability alongside the first-party no-op observer - malformed typed hook body (unknown `mode`) fails CLOSED with `RebornBuildError::InvalidConfig`, never a panic (the load-bearing degradation contract for untrusted external manifests) - a hook claiming `scope = same_tenant` without a verified grant is rejected by trust attenuation (fail-closed) No loader bug surfaced: `HookRegistrar::install` already returns `Result` on every malformed/over-scoped path and the loader maps it to `InvalidConfig` via `?`. Document activation scope at both the loader rustdoc and the `build_reborn_runtime` call site: production currently passes only `builtin_extension_registry()`, so third-party installed-extension hooks are not yet surfaced into the runtime path — only first-party-builtin and builtin-package-declared hooks activate today. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): thread HooksActivationConfig through input; empty production catalog Two maintainability cleanups on #3938 (firat review): Item 1 — config hygiene: stop reading HOOKS_ENABLED via std::env::var deep inside build_reborn_runtime. Add a typed `hooks: HooksActivationConfig` field to RebornRuntimeInput (default OFF) plus a `with_hooks_config` builder. The composition root now consumes the typed config; the env var is resolved ONCE at the edge (the reborn CLI's build_runtime_input) via HooksActivationConfig::from_env and threaded down. Testable without env mutation; matches the project's env → typed config → composition pattern. Item 2 — empty production first-party catalog: stop shipping NoOpObserverHook as a first-party builtin. install_first_party_hooks is now a no-op (empty catalog); the production type/install/export for a hook that does nothing is gone (removed from lib.rs exports). The activation machinery is still tested end-to-end through the real composition path via a new `build_hook_dispatcher_builder_factory_with` seam that takes a first-party installer; tests pass a `#[cfg(test)]` NoOpObserverHook through it. Pinned the empty-catalog-is-valid contract: flag ON + empty first-party set + no extension hooks composes a valid zero-binding dispatcher (not a panic/error). Reconciled the sibling loader tests/docs (f6c79c0): the in-module tests now drive the test-only seam; the reborn e2e tests already used a test-local no-op and are untouched. Updated activation-scope docs (loader rustdoc + build_reborn_runtime call site) to reflect the now-single live source (builtin-package-declared hooks). Deferred (not touched): switching to the canonical extension registry for third-party installed-extension hooks (#3934 follow-on). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): canonical registry + infallible plan + tenant-scoped counter docs/tests (#3938) Addresses serrrfirat's thermo-nuclear re-review on 1e618d0. #1 (runtime.rs:839, canonical registry): make the extension registry a shared composition artifact. `build_local_dev` builds one `Arc<ExtensionRegistry>`, hands it to `HostRuntimeServices::new` AND stores it in `RebornLocalRuntimeServices.extension_registry`. Hook activation in `build_reborn_runtime` now consumes that same `Arc` instead of rebuilding a builtin-only sidecar, so capability dispatch and hook activation cannot drift. Third-party activation stays a follow-up, but it now follows the canonical registry rather than a separate path. #3 (hooks.rs factory machinery): replace the parse/validate/replay duplication + two prose-justified `.expect()` calls with a typed `HookInstallPlan`. TOML is projected once into typed entries, the full install set is validated once against a fresh builder (fail-closed via `?`), and `HookInstallPlan::rebuild` mints a fresh builder per run. The per-run path is infallible by construction: a plan only exists for an install set that already composed cleanly, so a deterministic replay from the identical fresh-empty start cannot fail. One extension-install code path (`project_extension_install_sets` + `install_extension_sets`) is shared by validation and rebuild. #4 (hooks.rs:295, predicate counter scoping): the evaluator/backend is intentionally tenant-scoped and shared across runs (rate/value caps keyed `(hook, tenant, capability)` with no run_id; a run-scoped limit would reset every run and enforce nothing). Document the split explicitly — per-run-fresh dispatcher, tenant-scoped predicate counters — in the module docs and fix the misleading "per-run isolation of hook state" wording in `ironclaw_reborn` (loop_driver_host.rs / runtime.rs). Add `predicate_counter_state_is_tenant_scoped_across_rebuilds`, which drives a real rate-cap predicate through two dispatchers from one factory and proves the second run sees the first run's recorded count. Rename `factory_mints_independent_dispatchers_per_call` -> `rebuild_mints_independent_dispatchers_per_call` and scope it to proving dispatcher freshness only. #6 (loop_driver_host tests): clarify that the hand-built builder factories cover host PLUMBING, not composition activation. Add `build_reborn_runtime_activates_hooks_through_real_composition_path`, which drives the real `build_reborn_runtime` with `HooksActivationConfig` threaded through `RebornRuntimeInput` (env-free) and the canonical registry, proving the production activation wiring composes. #2 (env boundary) and #5 (empty production catalog) were already fixed in 1e618d0; docs touched here for consistency. Known follow-up (not one of the six items, not introduced here): with the flag ON the standalone local-dev runtime does not yet reach `Completed` for a capability turn even with a zero-binding dispatcher — the composition root wires the dispatcher but not the companion hooked-prompt dependencies. The new runtime test asserts `is_terminal()` + the capability path and documents the gap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): cover hook-entry rejection branches; document InvocationCount cap semantics (#3938) Address henrypark133 review (review 4367870023): - Add extension-manifest tests for the three previously-uncovered hook-entry validation branches: non-table `[[hooks]]` element, whitespace-only `id`, and oversized entry (HookEntryTooLarge). - Document the InvocationCount inclusive-allow / deny-on-overflow semantics inline at the comparison site; behavior unchanged and still pinned by the cap test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(reborn-cli): assert hooks config threaded in caller test (#3938) Addresses the review finding that `build_runtime_input_maps_configured_cli_identity` exercised `build_runtime_input` but never asserted the `hooks` field, so a regression silently dropping `with_hooks_config(HooksActivationConfig::from_env())` or flipping the default-OFF rollout-safety contract would pass. Per `.claude/rules/testing.md` ("Test Through the Caller"): add two assertions to the existing caller-level test: - threading: `runtime_input.hooks == HooksActivationConfig::from_env()`, proving the env-resolved config is actually threaded through and not dropped. Verified via TDD that dropping the wiring fails this assertion (run under HOOKS_ENABLED=1). - default-OFF: when `HOOKS_ENABLED` is unset, `!runtime_input.hooks.is_enabled()`, guarded to skip if the CI environment exports the flag so it only pins the contract it claims to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): correct in-memory backend warning text; delegate test ctor (#3938) Address serrrfirat review (2026-06-03): - Low: the in-memory backend warning claimed the LRU cap is shared across tenants, but the Reborn composition constructs a fresh InMemoryPredicateStateBackend per tenant. Rewrite the warn! text and doc comment so the real limitation (process-local replay dedup for multi-host deployments) is accurate, and note the backend is per-tenant in this composition. - Nit: PredicateEvaluator::with_backend (test-only) and with_state_backend had identical bodies; delegate with_backend to with_state_backend so they stay in lockstep. The Medium finding (hooks_config assertion in build_runtime_input caller test) was already addressed in 218a1de. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ection (HOOKS_THIRD_PARTY_ENABLED, default OFF) (#3951) * feat(hooks): extension-declared hook section on ExtensionManifestV2 (#3934) Add a `[[hooks]]` declaration surface to the production v2 extension manifest (`ironclaw_extensions::ExtensionManifestV2` and its projected `ExtensionManifest`). Each entry is carried as a structurally-typed `HookSectionEntryV2` DTO — a `local_id` plus the entry's body re-serialized to canonical TOML — so `ironclaw_extensions` (substrate) never imports the `ironclaw_hooks` predicate vocabulary. The composition layer, which depends on both crates, is the single seam that projects these payloads into typed `ironclaw_hooks::HookManifestEntry` values (a later commit). Parse-time structural bounds: `MAX_MANIFEST_HOOKS` (32, matching the downstream per-extension registration cap) and `MAX_HOOK_ENTRY_BYTES` (8 KiB per entry). Entries must be tables carrying a non-empty `id`; ids must be unique within the manifest. `#[serde(default)]` keeps every existing manifest valid (empty `hooks` vec). The DTO holds canonical TOML as a `String` rather than a `toml::Value` so the enclosing `ExtensionManifestV2` keeps its `Eq` derive (`toml::Value` is not `Eq`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): composition-layer activation module (loader, first-party hook, flag) (#3934) Add `ironclaw_reborn_composition::hooks` — the single seam that activates the hook framework in production. Implements four numbered pieces of #3934: - Feature flag (item 7): `HooksActivationConfig`, default OFF, resolved from `HOOKS_ENABLED` (only `1`/`true`/`yes`/`on` enable; unset/anything else = OFF). Flag OFF ⇒ `build_hook_dispatcher_builder_factory` returns `None` and the runtime composes no dispatcher — exact pre-hooks behavior. Hard rollout-safety contract. - Manifest → registry loader (item 2): `install_extension_hooks` projects each `ExtensionManifestV2` `HookSectionEntryV2` (canonical TOML) into a typed `HookManifestEntry` and installs it via `HookRegistrar::install` at the `Installed` trust tier. This is the clean-boundary projection: the hook vocabulary lives only here, never in `ironclaw_extensions`. Trust attenuation is enforced by construction (registrar only calls `install_installed_*`). Fail-closed: any projection/install error fails the build loudly. - First-party builtin hooks (item 3): a single illustrative no-op observer (`NoOpObserverHook`), installed regardless of extensions. Ships dark (zero driver-visible effect even with the flag ON). Production catalog is TBD by design — this PR does not invent a first-party hook. - Dispatcher composition (item 5): builds a per-tenant `PredicateEvaluator` over the in-memory state backend (swappable via the new public `PredicateEvaluator::with_state_backend` for durable #3933), validates the full install set once fail-closed, and returns a per-run builder-factory closure. Per-run construction (fresh registry/dispatcher per host build) + per-tenant evaluator give full isolation; the host factory attaches the run-scoped milestone sink internally. Per-tenant scoping is by construction: `build_reborn_runtime` runs once per identity, so everything here is tenant-local — no global registry. The router-backed gate-ref factory (PauseApproval/PauseAuth) and the security-audit sink (#3922, not yet on this branch) are deferred follow-ups; their absence is fail-closed (PauseApproval surfaces as Denied) and noted for the PR body. Not yet wired into the runtime — next commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): wire dispatcher builder factory into build_default_planned_runtime (#3934) Item 6 of #3934. Add an optional `hook_dispatcher_builder_factory` to `DefaultPlannedRuntimeParts` and, in `build_default_planned_runtime`, call `.with_hook_dispatcher_builder_factory(...)` on the production `RebornLoopDriverHostFactory` when it is present. `None` (the default) means no dispatcher is composed — behavior identical to the pre-hooks runtime (rollout-safety contract). The composition layer (`build_reborn_runtime`) resolves the flag via `HooksActivationConfig::from_env()` and builds the factory against this tenant's extension registry (per-tenant by construction — the function runs once per identity). Fail-closed: a malformed manifest hook fails the build here rather than composing a broken dispatcher. A per-run builder factory (not a captured dispatcher instance) is used so the host attaches a run-scoped milestone sink internally per build — per-run telemetry attribution, the #3573 capture-and-stick lesson. All `DefaultPlannedRuntimeParts` construction sites (8 test sites across ironclaw_reborn, ironclaw_product_workflow, ironclaw_reborn_composition) updated with the new field defaulting to `None`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): e2e activation tests through build_default_planned_runtime (#3934) Item 8 of #3934. Add four end-to-end tests in crates/ironclaw_reborn/tests/loop_driver_host.rs that drive the *production* composition function `build_default_planned_runtime` with a per-run hook dispatcher builder factory shaped exactly like the composition layer's output (first-party builtin no-op observer + extension-declared `Installed`-tier hooks projected from a manifest entry through `HookRegistrar::install`), then build a host via the composed `host_factory` and invoke a capability: - flag OFF (no factory): allowed capability completes unaffected and reaches the inner host runtime port — the pre-hooks behavior / rollout-safety contract. - flag ON, first-party-only no-op observer: outcome unchanged, inner port reached — the builtin ships dark. - flag ON, extension-declared deny hook: capability denied through the composed runtime and the inner port is never reached (installed at the Installed tier via the registrar; OwnCapabilities scope keyed to the capability provider). - per-tenant isolation: tenant A's deny hook fires; tenant B (separate build_default_planned_runtime composition, no hooks) completes the same capability — proving no cross-tenant leakage. Security-audit-on-deny assertion is intentionally deferred: #3922's SecurityAuditSink is not yet on reborn-integration. It lands with the audit-sink wiring follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): thread hook_dispatcher_builder_factory through shared reborn harness (#3934) The root-crate `tests/support/reborn/harness.rs` constructs `DefaultPlannedRuntimeParts` directly; add the new `hook_dispatcher_builder_factory: None` field so the parity-test harness compiles. Default `None` keeps the harness on the no-hooks path (unchanged behavior). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks): proper error handling / safety annotations for activation production paths (#3938 CI) The per-run dispatcher factory closure used `.expect()` on the first-party and extension hook installs, tripping the no-panics CI gate. These installs are pure replays of the install set already validated fail-closed (`?`) against a scratch builder at composition time, so they are genuine invariants. The factory type returns a non-Result `HookDispatcherBuilder` and is invoked deep in the run loop, so the documented `// safety:` suppression is the correct fix here. Hoisted the expect messages into `let` bindings so the `.expect(msg)` call fits on one line, keeping the scanner-required `// safety:` comment on the same line as the call after rustfmt. The malformed-manifest path (TOML projection) already uses real error propagation via map_err/`?` and is unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): direct composition-loader coverage + activation-scope docs Address Codex non-blocking follow-ups on #3938. Add three direct tests for the composition-layer hook loader (`install_extension_hooks` via `build_hook_dispatcher_builder_factory`), driving a real `ExtensionRegistry` with `[[hooks]]` declarations rather than mimicking the loader: - valid `own_capabilities` predicate hook installs at the Installed trust tier; the dispatcher carries the derived binding at BeforeCapability alongside the first-party no-op observer - malformed typed hook body (unknown `mode`) fails CLOSED with `RebornBuildError::InvalidConfig`, never a panic (the load-bearing degradation contract for untrusted external manifests) - a hook claiming `scope = same_tenant` without a verified grant is rejected by trust attenuation (fail-closed) No loader bug surfaced: `HookRegistrar::install` already returns `Result` on every malformed/over-scoped path and the loader maps it to `InvalidConfig` via `?`. Document activation scope at both the loader rustdoc and the `build_reborn_runtime` call site: production currently passes only `builtin_extension_registry()`, so third-party installed-extension hooks are not yet surfaced into the runtime path — only first-party-builtin and builtin-package-declared hooks activate today. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): thread HooksActivationConfig through input; empty production catalog Two maintainability cleanups on #3938 (firat review): Item 1 — config hygiene: stop reading HOOKS_ENABLED via std::env::var deep inside build_reborn_runtime. Add a typed `hooks: HooksActivationConfig` field to RebornRuntimeInput (default OFF) plus a `with_hooks_config` builder. The composition root now consumes the typed config; the env var is resolved ONCE at the edge (the reborn CLI's build_runtime_input) via HooksActivationConfig::from_env and threaded down. Testable without env mutation; matches the project's env → typed config → composition pattern. Item 2 — empty production first-party catalog: stop shipping NoOpObserverHook as a first-party builtin. install_first_party_hooks is now a no-op (empty catalog); the production type/install/export for a hook that does nothing is gone (removed from lib.rs exports). The activation machinery is still tested end-to-end through the real composition path via a new `build_hook_dispatcher_builder_factory_with` seam that takes a first-party installer; tests pass a `#[cfg(test)]` NoOpObserverHook through it. Pinned the empty-catalog-is-valid contract: flag ON + empty first-party set + no extension hooks composes a valid zero-binding dispatcher (not a panic/error). Reconciled the sibling loader tests/docs (f6c79c0): the in-module tests now drive the test-only seam; the reborn e2e tests already used a test-local no-op and are untouched. Updated activation-scope docs (loader rustdoc + build_reborn_runtime call site) to reflect the now-single live source (builtin-package-declared hooks). Deferred (not touched): switching to the canonical extension registry for third-party installed-extension hooks (#3934 follow-on). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): canonical registry + infallible plan + tenant-scoped counter docs/tests (#3938) Addresses serrrfirat's thermo-nuclear re-review on 1e618d0. #1 (runtime.rs:839, canonical registry): make the extension registry a shared composition artifact. `build_local_dev` builds one `Arc<ExtensionRegistry>`, hands it to `HostRuntimeServices::new` AND stores it in `RebornLocalRuntimeServices.extension_registry`. Hook activation in `build_reborn_runtime` now consumes that same `Arc` instead of rebuilding a builtin-only sidecar, so capability dispatch and hook activation cannot drift. Third-party activation stays a follow-up, but it now follows the canonical registry rather than a separate path. #3 (hooks.rs factory machinery): replace the parse/validate/replay duplication + two prose-justified `.expect()` calls with a typed `HookInstallPlan`. TOML is projected once into typed entries, the full install set is validated once against a fresh builder (fail-closed via `?`), and `HookInstallPlan::rebuild` mints a fresh builder per run. The per-run path is infallible by construction: a plan only exists for an install set that already composed cleanly, so a deterministic replay from the identical fresh-empty start cannot fail. One extension-install code path (`project_extension_install_sets` + `install_extension_sets`) is shared by validation and rebuild. #4 (hooks.rs:295, predicate counter scoping): the evaluator/backend is intentionally tenant-scoped and shared across runs (rate/value caps keyed `(hook, tenant, capability)` with no run_id; a run-scoped limit would reset every run and enforce nothing). Document the split explicitly — per-run-fresh dispatcher, tenant-scoped predicate counters — in the module docs and fix the misleading "per-run isolation of hook state" wording in `ironclaw_reborn` (loop_driver_host.rs / runtime.rs). Add `predicate_counter_state_is_tenant_scoped_across_rebuilds`, which drives a real rate-cap predicate through two dispatchers from one factory and proves the second run sees the first run's recorded count. Rename `factory_mints_independent_dispatchers_per_call` -> `rebuild_mints_independent_dispatchers_per_call` and scope it to proving dispatcher freshness only. #6 (loop_driver_host tests): clarify that the hand-built builder factories cover host PLUMBING, not composition activation. Add `build_reborn_runtime_activates_hooks_through_real_composition_path`, which drives the real `build_reborn_runtime` with `HooksActivationConfig` threaded through `RebornRuntimeInput` (env-free) and the canonical registry, proving the production activation wiring composes. #2 (env boundary) and #5 (empty production catalog) were already fixed in 1e618d0; docs touched here for consistency. Known follow-up (not one of the six items, not introduced here): with the flag ON the standalone local-dev runtime does not yet reach `Completed` for a capability turn even with a zero-binding dispatcher — the composition root wires the dispatcher but not the companion hooked-prompt dependencies. The new runtime test asserts `is_terminal()` + the capability path and documents the gap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): third-party hook-only projection core (flag, newtype, quarantine, caps) Steps 1-6 of third-party extension hook activation via hook-only projection: - Step 1: HOOKS_THIRD_PARTY_ENABLED sub-flag on HooksActivationConfig (default OFF; is_third_party_enabled() requires master flag too). Resolved at the CLI edge via from_env(). - Step 2: tenant_extension_root(&TenantId) derives the fixed /system/extensions/<tenant> root from identity (never caller-supplied); projection-layer strict-child / no-`..` containment check. - Step 3: build_hook_projection_registry assembles a HookProjectionRegistry (type-enforced hook-only newtype: no Deref / conversion back to ExtensionRegistry, so it can never reach HostRuntimeServices::new / the capability path). Sub-flag OFF => builtin-only, byte-identical to #3938. - Step 4/4a: atomic per-extension quarantine — untrusted (InstalledLocal) sets validated whole against a scratch builder, committed only if the whole set passes; any failure drops the extension's hooks entirely, emits a hook.quarantined security_audit tracing event (warn!, not info!), and continues. Trusted (HostBundled) sources stay fail-closed-whole-build. - Step 5: MAX_INSTALLED_EXTENSIONS_CONSIDERED / MAX_TOTAL_HOOKS_PER_TENANT DoS caps; count_total_bindings() accessor on HookDispatcher(Builder); pre-read MAX_MANIFEST_BYTES bound via read_file_bounded in discovery. - Step 6: third-party WASM stays out (loader registrar has no wasm_runtime) => WASM-bodied hook quarantines + build continues. Registrar-only invariant: projection installs go exclusively through HookRegistrar::install (ceiling + spoof-blocked owning_extension), never the direct builder installer API. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): FS-scoped tenant isolation (Option 1), trust matrix, registrar-only assertion Resolve the discovery/path conflict: the discovery layer hardcodes package roots to /system/extensions/<id> because the per-tenant RootFilesystem is the scope boundary (as with every other tenant-scoped resource), not a tenant path segment. So: - tenant_extension_root -> fixed /system/extensions (no tenant segment). The per-tenant RootFilesystem handed to discovery IS the isolation boundary. Documented as load-bearing; the openat2(RESOLVE_BENEATH)/O_NOFOLLOW backend hardening follow-up is what protects it (gating note kept prominent). - build_local_dev mounts /system/extensions to a per-owner host subtree under the storage root (per-identity by construction, not a process-global mount); exposed via RebornLocalRuntimeServices.extension_filesystem. - enforce_root_containment retained as defense-in-depth. Tests: - Integration (real build_hook_projection_registry + build_hook_dispatcher_ builder_factory through a fake RootFilesystem, not a loader look-alike): containment (hook present / capability absent by construction), FS-as-boundary tenant isolation proof (two distinct per-tenant filesystems; A can't see B), bad dir name skipped, id mismatch not a panic, surplus-extensions DoS cap, sub-flag OFF discovers nothing. - Per-hook-point trust matrix: BeforeCapability installed deny IS allowed and fires (Gate reachable); before_prompt predicate quarantined + build continues; after_model/after_capability/after_checkpoint/event_triggered WASM-only => quarantined + build continues; owning_extension derived (not spoofable). - Discovery pre-read bound: oversized manifest rejected via stat WITHOUT reading the body (fake fs panics on get); within-bound proceeds to read. - ironclaw_architecture source assertion: the hooks.rs projection path never calls install_installed_* directly (registrar-only invariant). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(hooks): correct Option-1 path-shape references in test comments Update the third-party projection integration-test module docs to reflect the FS-scoped isolation model (fixed /system/extensions root; per-tenant filesystem is the boundary), not the abandoned /system/extensions/<tenant> path segment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks): tolerant+bounded third-party discovery, structural hook-only containment Addresses Codex P1/P2 + serrrfirat P1 on #3951. Critical 1 (discovery-stage DoS): add `ExtensionDiscovery::discover_with_manifest_contracts_tolerant_bounded` (+ `discover_extensions_tolerant_bounded` host-runtime wrapper). It lists+sorts the root once, then reads/parses at most `max_extensions` manifests, recording the surplus as quarantines WITHOUT reading them. The hook projection calls this with `MAX_INSTALLED_EXTENSIONS_CONSIDERED`, so the count cap fires before the per-manifest read storm. New all-or-nothing path delegates to a shared `load_package_entry` so per-package semantics are identical. Critical 2 (fail-open): tolerant discovery quarantines a single malformed/oversized/id-mismatched package and CONTINUES; valid siblings still load. The builtin-only fallback is now reserved solely for failure to LIST THE ROOT (directory unreadable). One bad manifest can no longer drop a tenant's entire legitimate third-party hook set. Refinement 3: the per-tenant hook budget is consumed only AFTER a successful merge, so a quarantined/duplicate package no longer burns budget. Refinement 4: the registrar-only arch assertion now scans the WHOLE composition crate (every non-test source) and forbids all installed-tier-minting primitives crate-wide (`install_installed_*`, `install_observer(`, `insert_binding(`, `HookTrustClass::Installed`) — not just a hooks.rs substring scan. Installed-tier bindings can only be minted via `HookRegistrar::install`. serrrfirat P1 (structural containment): `HookProjectionRegistry` no longer wraps `ExtensionRegistry`. It carries `Vec<HookProjection>` — hook metadata only (id/version/source/root/[[hooks]]). The projection literally cannot reach capabilities because it does not hold them; containment is by data shape, not a withheld conversion. Removes the `ExtensionPackageView` ceremony. Tests: bounded read-storm cap (read-counting fs panics on surplus), tolerant per-package quarantine, root-unreadable fallback, quarantined-package-does-not- consume-budget, malformed-sibling-survives at the projection layer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): address serrrfirat review — tenant-attributed install audits + hooks decomposition Addresses the maintainability review on #3951. Findings #1 (narrow hook-only boundary), #2 (per-extension discovery quarantine + mixed-batch test), and #5 (behavioral arch-test invariant) were already satisfied by the head commit (2b62597); this commit closes the two remaining items and hardens the arch test against the decomposition: - #3 (tenant attribution): add `build_hook_dispatcher_builder_factory_for_tenant`, threading the authenticated `tenant_id` (and its derived extension root) into the install-time quarantine-audit seam. `build_reborn_runtime` now calls it, so install-time quarantine audits carry the real tenant instead of the synthetic `reborn-hook-projection` fallback (closing the split where only discovery-time audits were attributed). New caller-driven test `for_tenant_entry_point_attributes_install_time_quarantine_to_real_tenant` asserts attribution via a deterministic thread-local audit capture (immune to tracing's process-wide max-level filter under parallel tests). - #4 (decomposition): split the 1.7k-line `hooks.rs` into a focused `hooks/` module — `mod.rs` (flag/config + public surface), `projection.rs` (hook-only `HookProjection`/`HookProjectionRegistry` containment + discovery/admission), `factory.rs` (first-party install, per-extension quarantine validation, fresh-per-build replay), `audit.rs` (`hook.quarantined` emission), and `tests.rs` (the test matrix). Behavior-preserving; no logic change. - arch test: skip dedicated test-module files in the registrar-only scan so the #4 decomposition cannot break it; the whole-crate behavioral invariant is preserved. - audit emission uses `debug!` (not `warn!`) per the background/hook-path logging rule, on the stable filterable `security_audit` target. - gemini #353: add the documented no-empty-segment guard to `enforce_root_containment` (defense-in-depth, not relying on VirtualPath canonicalization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): cover hook-entry rejection branches; document InvocationCount cap semantics (#3938) Address henrypark133 review (review 4367870023): - Add extension-manifest tests for the three previously-uncovered hook-entry validation branches: non-table `[[hooks]]` element, whitespace-only `id`, and oversized entry (HookEntryTooLarge). - Document the InvocationCount inclusive-allow / deny-on-overflow semantics inline at the comparison site; behavior unchanged and still pinned by the cap test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(deps): pin kuchikikiki to 0.9.1 (0.9.2 yanked) cargo-deny failed on the yanked kuchikikiki 0.9.2 pulled in transitively via readabilityrs. Downgrade to 0.9.1 at the lockfile level. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(reborn-cli): assert hooks config threaded in caller test (#3938) Addresses the review finding that `build_runtime_input_maps_configured_cli_identity` exercised `build_runtime_input` but never asserted the `hooks` field, so a regression silently dropping `with_hooks_config(HooksActivationConfig::from_env())` or flipping the default-OFF rollout-safety contract would pass. Per `.claude/rules/testing.md` ("Test Through the Caller"): add two assertions to the existing caller-level test: - threading: `runtime_input.hooks == HooksActivationConfig::from_env()`, proving the env-resolved config is actually threaded through and not dropped. Verified via TDD that dropping the wiring fails this assertion (run under HOOKS_ENABLED=1). - default-OFF: when `HOOKS_ENABLED` is unset, `!runtime_input.hooks.is_enabled()`, guarded to skip if the CI environment exports the flag so it only pins the contract it claims to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): cover build_reborn_runtime third-party wiring + async dir create (#3951) Address serrrfirat review findings M1 and L2. M1: add an integration test in tests/runtime.rs that drives build_reborn_runtime with HooksActivationConfig::enabled().with_third_party_enabled(true), a real /system/extensions manifest tree on the local-dev host filesystem, and tenant attribution. Asserts the runtime builds, starts a conversation turn, and shuts down cleanly — exercising the runtime.rs third-party discovery input + projection registry + tenant-threading wiring that was previously uncovered (the projection tests call build_hook_projection_registry / the dispatcher factory directly, and every other build_reborn_runtime call used the default disabled config). Verified the test fails when the wiring is broken. L2: switch the new factory.rs blocking std::fs::create_dir_all for the extensions host root to tokio::fs::create_dir_all(...).await with the same error mapping, so it no longer blocks the tokio executor thread inside the async build_local_dev. The two pre-existing std::fs calls (lines 132/136) are out of this PR's diff per the posted promise and are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): L1 quarantine-surfacing gate doc, L3 robust test-mod strip, M1 coverage-gap TODO Address serrrfirat 2026-06-03 review (M1/L2 already landed in 9866793). L1 (security observability): hook.quarantined audit events are emitted only via tracing at the security_audit target / debug! level, which production typically disables. Document durable quarantine surfacing as a hard production-enablement prerequisite for HOOKS_THIRD_PARTY_ENABLED, alongside the existing openat2(RESOLVE_BENEATH)/O_NOFOLLOW FS-hardening note, at all three gate doc sites: HooksActivationConfig (hooks/mod.rs), the runtime.rs composition -root gate comment, and the audit.rs module doc. L3 (robustness): strip_test_module matched #[cfg(test)]\nmod tests specifically and only the first occurrence. Generalize the anchor to #[cfg(test)]\nmod (any module name) so a refactor that renames the test module or adds a second #[cfg(test)] mod block is still fully stripped, preventing false positives in the FORBIDDEN_INSTALLED_PRIMITIVES architecture scan. M1 (test coverage): the build_reborn_runtime third-party wiring test already landed in tests/runtime.rs (9866793). Add the reviewer-requested TODO preserving the removed test's Cancelled-outcome coverage gap: the stub local-dev gateway cancels the turn before any capability dispatches, so the test exercises discovery + projection + tenant-threading at build/start but not end-to-end hook enforcement. NOTE: third-party discovery is intentionally tolerant (skips unparseable manifests), so this test catches compile-time field/arg regressions and build-path failures but not a silent manifest-read drop; documented for the reviewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): correct in-memory backend warning text; delegate test ctor (#3938) Address serrrfirat review (2026-06-03): - Low: the in-memory backend warning claimed the LRU cap is shared across tenants, but the Reborn composition constructs a fresh InMemoryPredicateStateBackend per tenant. Rewrite the warn! text and doc comment so the real limitation (process-local replay dedup for multi-host deployments) is accurate, and note the backend is per-tenant in this composition. - Nit: PredicateEvaluator::with_backend (test-only) and with_state_backend had identical bodies; delegate with_backend to with_state_backend so they stay in lockstep. The Medium finding (hooks_config assertion in build_runtime_input caller test) was already addressed in 218a1de. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…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>
…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>
…ED flag (nearai#3934) (nearai#3938) * feat(hooks): extension-declared hook section on ExtensionManifestV2 (nearai#3934) Add a `[[hooks]]` declaration surface to the production v2 extension manifest (`ironclaw_extensions::ExtensionManifestV2` and its projected `ExtensionManifest`). Each entry is carried as a structurally-typed `HookSectionEntryV2` DTO — a `local_id` plus the entry's body re-serialized to canonical TOML — so `ironclaw_extensions` (substrate) never imports the `ironclaw_hooks` predicate vocabulary. The composition layer, which depends on both crates, is the single seam that projects these payloads into typed `ironclaw_hooks::HookManifestEntry` values (a later commit). Parse-time structural bounds: `MAX_MANIFEST_HOOKS` (32, matching the downstream per-extension registration cap) and `MAX_HOOK_ENTRY_BYTES` (8 KiB per entry). Entries must be tables carrying a non-empty `id`; ids must be unique within the manifest. `#[serde(default)]` keeps every existing manifest valid (empty `hooks` vec). The DTO holds canonical TOML as a `String` rather than a `toml::Value` so the enclosing `ExtensionManifestV2` keeps its `Eq` derive (`toml::Value` is not `Eq`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): composition-layer activation module (loader, first-party hook, flag) (nearai#3934) Add `ironclaw_reborn_composition::hooks` — the single seam that activates the hook framework in production. Implements four numbered pieces of nearai#3934: - Feature flag (item 7): `HooksActivationConfig`, default OFF, resolved from `HOOKS_ENABLED` (only `1`/`true`/`yes`/`on` enable; unset/anything else = OFF). Flag OFF ⇒ `build_hook_dispatcher_builder_factory` returns `None` and the runtime composes no dispatcher — exact pre-hooks behavior. Hard rollout-safety contract. - Manifest → registry loader (item 2): `install_extension_hooks` projects each `ExtensionManifestV2` `HookSectionEntryV2` (canonical TOML) into a typed `HookManifestEntry` and installs it via `HookRegistrar::install` at the `Installed` trust tier. This is the clean-boundary projection: the hook vocabulary lives only here, never in `ironclaw_extensions`. Trust attenuation is enforced by construction (registrar only calls `install_installed_*`). Fail-closed: any projection/install error fails the build loudly. - First-party builtin hooks (item 3): a single illustrative no-op observer (`NoOpObserverHook`), installed regardless of extensions. Ships dark (zero driver-visible effect even with the flag ON). Production catalog is TBD by design — this PR does not invent a first-party hook. - Dispatcher composition (item 5): builds a per-tenant `PredicateEvaluator` over the in-memory state backend (swappable via the new public `PredicateEvaluator::with_state_backend` for durable nearai#3933), validates the full install set once fail-closed, and returns a per-run builder-factory closure. Per-run construction (fresh registry/dispatcher per host build) + per-tenant evaluator give full isolation; the host factory attaches the run-scoped milestone sink internally. Per-tenant scoping is by construction: `build_reborn_runtime` runs once per identity, so everything here is tenant-local — no global registry. The router-backed gate-ref factory (PauseApproval/PauseAuth) and the security-audit sink (nearai#3922, not yet on this branch) are deferred follow-ups; their absence is fail-closed (PauseApproval surfaces as Denied) and noted for the PR body. Not yet wired into the runtime — next commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): wire dispatcher builder factory into build_default_planned_runtime (nearai#3934) Item 6 of nearai#3934. Add an optional `hook_dispatcher_builder_factory` to `DefaultPlannedRuntimeParts` and, in `build_default_planned_runtime`, call `.with_hook_dispatcher_builder_factory(...)` on the production `RebornLoopDriverHostFactory` when it is present. `None` (the default) means no dispatcher is composed — behavior identical to the pre-hooks runtime (rollout-safety contract). The composition layer (`build_reborn_runtime`) resolves the flag via `HooksActivationConfig::from_env()` and builds the factory against this tenant's extension registry (per-tenant by construction — the function runs once per identity). Fail-closed: a malformed manifest hook fails the build here rather than composing a broken dispatcher. A per-run builder factory (not a captured dispatcher instance) is used so the host attaches a run-scoped milestone sink internally per build — per-run telemetry attribution, the nearai#3573 capture-and-stick lesson. All `DefaultPlannedRuntimeParts` construction sites (8 test sites across ironclaw_reborn, ironclaw_product_workflow, ironclaw_reborn_composition) updated with the new field defaulting to `None`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): e2e activation tests through build_default_planned_runtime (nearai#3934) Item 8 of nearai#3934. Add four end-to-end tests in crates/ironclaw_reborn/tests/loop_driver_host.rs that drive the *production* composition function `build_default_planned_runtime` with a per-run hook dispatcher builder factory shaped exactly like the composition layer's output (first-party builtin no-op observer + extension-declared `Installed`-tier hooks projected from a manifest entry through `HookRegistrar::install`), then build a host via the composed `host_factory` and invoke a capability: - flag OFF (no factory): allowed capability completes unaffected and reaches the inner host runtime port — the pre-hooks behavior / rollout-safety contract. - flag ON, first-party-only no-op observer: outcome unchanged, inner port reached — the builtin ships dark. - flag ON, extension-declared deny hook: capability denied through the composed runtime and the inner port is never reached (installed at the Installed tier via the registrar; OwnCapabilities scope keyed to the capability provider). - per-tenant isolation: tenant A's deny hook fires; tenant B (separate build_default_planned_runtime composition, no hooks) completes the same capability — proving no cross-tenant leakage. Security-audit-on-deny assertion is intentionally deferred: nearai#3922's SecurityAuditSink is not yet on reborn-integration. It lands with the audit-sink wiring follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): thread hook_dispatcher_builder_factory through shared reborn harness (nearai#3934) The root-crate `tests/support/reborn/harness.rs` constructs `DefaultPlannedRuntimeParts` directly; add the new `hook_dispatcher_builder_factory: None` field so the parity-test harness compiles. Default `None` keeps the harness on the no-hooks path (unchanged behavior). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks): proper error handling / safety annotations for activation production paths (nearai#3938 CI) The per-run dispatcher factory closure used `.expect()` on the first-party and extension hook installs, tripping the no-panics CI gate. These installs are pure replays of the install set already validated fail-closed (`?`) against a scratch builder at composition time, so they are genuine invariants. The factory type returns a non-Result `HookDispatcherBuilder` and is invoked deep in the run loop, so the documented `// safety:` suppression is the correct fix here. Hoisted the expect messages into `let` bindings so the `.expect(msg)` call fits on one line, keeping the scanner-required `// safety:` comment on the same line as the call after rustfmt. The malformed-manifest path (TOML projection) already uses real error propagation via map_err/`?` and is unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): direct composition-loader coverage + activation-scope docs Address Codex non-blocking follow-ups on nearai#3938. Add three direct tests for the composition-layer hook loader (`install_extension_hooks` via `build_hook_dispatcher_builder_factory`), driving a real `ExtensionRegistry` with `[[hooks]]` declarations rather than mimicking the loader: - valid `own_capabilities` predicate hook installs at the Installed trust tier; the dispatcher carries the derived binding at BeforeCapability alongside the first-party no-op observer - malformed typed hook body (unknown `mode`) fails CLOSED with `RebornBuildError::InvalidConfig`, never a panic (the load-bearing degradation contract for untrusted external manifests) - a hook claiming `scope = same_tenant` without a verified grant is rejected by trust attenuation (fail-closed) No loader bug surfaced: `HookRegistrar::install` already returns `Result` on every malformed/over-scoped path and the loader maps it to `InvalidConfig` via `?`. Document activation scope at both the loader rustdoc and the `build_reborn_runtime` call site: production currently passes only `builtin_extension_registry()`, so third-party installed-extension hooks are not yet surfaced into the runtime path — only first-party-builtin and builtin-package-declared hooks activate today. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): thread HooksActivationConfig through input; empty production catalog Two maintainability cleanups on nearai#3938 (firat review): Item 1 — config hygiene: stop reading HOOKS_ENABLED via std::env::var deep inside build_reborn_runtime. Add a typed `hooks: HooksActivationConfig` field to RebornRuntimeInput (default OFF) plus a `with_hooks_config` builder. The composition root now consumes the typed config; the env var is resolved ONCE at the edge (the reborn CLI's build_runtime_input) via HooksActivationConfig::from_env and threaded down. Testable without env mutation; matches the project's env → typed config → composition pattern. Item 2 — empty production first-party catalog: stop shipping NoOpObserverHook as a first-party builtin. install_first_party_hooks is now a no-op (empty catalog); the production type/install/export for a hook that does nothing is gone (removed from lib.rs exports). The activation machinery is still tested end-to-end through the real composition path via a new `build_hook_dispatcher_builder_factory_with` seam that takes a first-party installer; tests pass a `#[cfg(test)]` NoOpObserverHook through it. Pinned the empty-catalog-is-valid contract: flag ON + empty first-party set + no extension hooks composes a valid zero-binding dispatcher (not a panic/error). Reconciled the sibling loader tests/docs (f6c79c0): the in-module tests now drive the test-only seam; the reborn e2e tests already used a test-local no-op and are untouched. Updated activation-scope docs (loader rustdoc + build_reborn_runtime call site) to reflect the now-single live source (builtin-package-declared hooks). Deferred (not touched): switching to the canonical extension registry for third-party installed-extension hooks (nearai#3934 follow-on). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): canonical registry + infallible plan + tenant-scoped counter docs/tests (nearai#3938) Addresses serrrfirat's thermo-nuclear re-review on 1e618d0. #1 (runtime.rs:839, canonical registry): make the extension registry a shared composition artifact. `build_local_dev` builds one `Arc<ExtensionRegistry>`, hands it to `HostRuntimeServices::new` AND stores it in `RebornLocalRuntimeServices.extension_registry`. Hook activation in `build_reborn_runtime` now consumes that same `Arc` instead of rebuilding a builtin-only sidecar, so capability dispatch and hook activation cannot drift. Third-party activation stays a follow-up, but it now follows the canonical registry rather than a separate path. #3 (hooks.rs factory machinery): replace the parse/validate/replay duplication + two prose-justified `.expect()` calls with a typed `HookInstallPlan`. TOML is projected once into typed entries, the full install set is validated once against a fresh builder (fail-closed via `?`), and `HookInstallPlan::rebuild` mints a fresh builder per run. The per-run path is infallible by construction: a plan only exists for an install set that already composed cleanly, so a deterministic replay from the identical fresh-empty start cannot fail. One extension-install code path (`project_extension_install_sets` + `install_extension_sets`) is shared by validation and rebuild. #4 (hooks.rs:295, predicate counter scoping): the evaluator/backend is intentionally tenant-scoped and shared across runs (rate/value caps keyed `(hook, tenant, capability)` with no run_id; a run-scoped limit would reset every run and enforce nothing). Document the split explicitly — per-run-fresh dispatcher, tenant-scoped predicate counters — in the module docs and fix the misleading "per-run isolation of hook state" wording in `ironclaw_reborn` (loop_driver_host.rs / runtime.rs). Add `predicate_counter_state_is_tenant_scoped_across_rebuilds`, which drives a real rate-cap predicate through two dispatchers from one factory and proves the second run sees the first run's recorded count. Rename `factory_mints_independent_dispatchers_per_call` -> `rebuild_mints_independent_dispatchers_per_call` and scope it to proving dispatcher freshness only. #6 (loop_driver_host tests): clarify that the hand-built builder factories cover host PLUMBING, not composition activation. Add `build_reborn_runtime_activates_hooks_through_real_composition_path`, which drives the real `build_reborn_runtime` with `HooksActivationConfig` threaded through `RebornRuntimeInput` (env-free) and the canonical registry, proving the production activation wiring composes. #2 (env boundary) and #5 (empty production catalog) were already fixed in 1e618d0; docs touched here for consistency. Known follow-up (not one of the six items, not introduced here): with the flag ON the standalone local-dev runtime does not yet reach `Completed` for a capability turn even with a zero-binding dispatcher — the composition root wires the dispatcher but not the companion hooked-prompt dependencies. The new runtime test asserts `is_terminal()` + the capability path and documents the gap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): cover hook-entry rejection branches; document InvocationCount cap semantics (nearai#3938) Address henrypark133 review (review 4367870023): - Add extension-manifest tests for the three previously-uncovered hook-entry validation branches: non-table `[[hooks]]` element, whitespace-only `id`, and oversized entry (HookEntryTooLarge). - Document the InvocationCount inclusive-allow / deny-on-overflow semantics inline at the comparison site; behavior unchanged and still pinned by the cap test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(reborn-cli): assert hooks config threaded in caller test (nearai#3938) Addresses the review finding that `build_runtime_input_maps_configured_cli_identity` exercised `build_runtime_input` but never asserted the `hooks` field, so a regression silently dropping `with_hooks_config(HooksActivationConfig::from_env())` or flipping the default-OFF rollout-safety contract would pass. Per `.claude/rules/testing.md` ("Test Through the Caller"): add two assertions to the existing caller-level test: - threading: `runtime_input.hooks == HooksActivationConfig::from_env()`, proving the env-resolved config is actually threaded through and not dropped. Verified via TDD that dropping the wiring fails this assertion (run under HOOKS_ENABLED=1). - default-OFF: when `HOOKS_ENABLED` is unset, `!runtime_input.hooks.is_enabled()`, guarded to skip if the CI environment exports the flag so it only pins the contract it claims to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): correct in-memory backend warning text; delegate test ctor (nearai#3938) Address serrrfirat review (2026-06-03): - Low: the in-memory backend warning claimed the LRU cap is shared across tenants, but the Reborn composition constructs a fresh InMemoryPredicateStateBackend per tenant. Rewrite the warn! text and doc comment so the real limitation (process-local replay dedup for multi-host deployments) is accurate, and note the backend is per-tenant in this composition. - Nit: PredicateEvaluator::with_backend (test-only) and with_state_backend had identical bodies; delegate with_backend to with_state_backend so they stay in lockstep. The Medium finding (hooks_config assertion in build_runtime_input caller test) was already addressed in 218a1de. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ection (HOOKS_THIRD_PARTY_ENABLED, default OFF) (nearai#3951) * feat(hooks): extension-declared hook section on ExtensionManifestV2 (nearai#3934) Add a `[[hooks]]` declaration surface to the production v2 extension manifest (`ironclaw_extensions::ExtensionManifestV2` and its projected `ExtensionManifest`). Each entry is carried as a structurally-typed `HookSectionEntryV2` DTO — a `local_id` plus the entry's body re-serialized to canonical TOML — so `ironclaw_extensions` (substrate) never imports the `ironclaw_hooks` predicate vocabulary. The composition layer, which depends on both crates, is the single seam that projects these payloads into typed `ironclaw_hooks::HookManifestEntry` values (a later commit). Parse-time structural bounds: `MAX_MANIFEST_HOOKS` (32, matching the downstream per-extension registration cap) and `MAX_HOOK_ENTRY_BYTES` (8 KiB per entry). Entries must be tables carrying a non-empty `id`; ids must be unique within the manifest. `#[serde(default)]` keeps every existing manifest valid (empty `hooks` vec). The DTO holds canonical TOML as a `String` rather than a `toml::Value` so the enclosing `ExtensionManifestV2` keeps its `Eq` derive (`toml::Value` is not `Eq`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): composition-layer activation module (loader, first-party hook, flag) (nearai#3934) Add `ironclaw_reborn_composition::hooks` — the single seam that activates the hook framework in production. Implements four numbered pieces of nearai#3934: - Feature flag (item 7): `HooksActivationConfig`, default OFF, resolved from `HOOKS_ENABLED` (only `1`/`true`/`yes`/`on` enable; unset/anything else = OFF). Flag OFF ⇒ `build_hook_dispatcher_builder_factory` returns `None` and the runtime composes no dispatcher — exact pre-hooks behavior. Hard rollout-safety contract. - Manifest → registry loader (item 2): `install_extension_hooks` projects each `ExtensionManifestV2` `HookSectionEntryV2` (canonical TOML) into a typed `HookManifestEntry` and installs it via `HookRegistrar::install` at the `Installed` trust tier. This is the clean-boundary projection: the hook vocabulary lives only here, never in `ironclaw_extensions`. Trust attenuation is enforced by construction (registrar only calls `install_installed_*`). Fail-closed: any projection/install error fails the build loudly. - First-party builtin hooks (item 3): a single illustrative no-op observer (`NoOpObserverHook`), installed regardless of extensions. Ships dark (zero driver-visible effect even with the flag ON). Production catalog is TBD by design — this PR does not invent a first-party hook. - Dispatcher composition (item 5): builds a per-tenant `PredicateEvaluator` over the in-memory state backend (swappable via the new public `PredicateEvaluator::with_state_backend` for durable nearai#3933), validates the full install set once fail-closed, and returns a per-run builder-factory closure. Per-run construction (fresh registry/dispatcher per host build) + per-tenant evaluator give full isolation; the host factory attaches the run-scoped milestone sink internally. Per-tenant scoping is by construction: `build_reborn_runtime` runs once per identity, so everything here is tenant-local — no global registry. The router-backed gate-ref factory (PauseApproval/PauseAuth) and the security-audit sink (nearai#3922, not yet on this branch) are deferred follow-ups; their absence is fail-closed (PauseApproval surfaces as Denied) and noted for the PR body. Not yet wired into the runtime — next commit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): wire dispatcher builder factory into build_default_planned_runtime (nearai#3934) Item 6 of nearai#3934. Add an optional `hook_dispatcher_builder_factory` to `DefaultPlannedRuntimeParts` and, in `build_default_planned_runtime`, call `.with_hook_dispatcher_builder_factory(...)` on the production `RebornLoopDriverHostFactory` when it is present. `None` (the default) means no dispatcher is composed — behavior identical to the pre-hooks runtime (rollout-safety contract). The composition layer (`build_reborn_runtime`) resolves the flag via `HooksActivationConfig::from_env()` and builds the factory against this tenant's extension registry (per-tenant by construction — the function runs once per identity). Fail-closed: a malformed manifest hook fails the build here rather than composing a broken dispatcher. A per-run builder factory (not a captured dispatcher instance) is used so the host attaches a run-scoped milestone sink internally per build — per-run telemetry attribution, the nearai#3573 capture-and-stick lesson. All `DefaultPlannedRuntimeParts` construction sites (8 test sites across ironclaw_reborn, ironclaw_product_workflow, ironclaw_reborn_composition) updated with the new field defaulting to `None`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): e2e activation tests through build_default_planned_runtime (nearai#3934) Item 8 of nearai#3934. Add four end-to-end tests in crates/ironclaw_reborn/tests/loop_driver_host.rs that drive the *production* composition function `build_default_planned_runtime` with a per-run hook dispatcher builder factory shaped exactly like the composition layer's output (first-party builtin no-op observer + extension-declared `Installed`-tier hooks projected from a manifest entry through `HookRegistrar::install`), then build a host via the composed `host_factory` and invoke a capability: - flag OFF (no factory): allowed capability completes unaffected and reaches the inner host runtime port — the pre-hooks behavior / rollout-safety contract. - flag ON, first-party-only no-op observer: outcome unchanged, inner port reached — the builtin ships dark. - flag ON, extension-declared deny hook: capability denied through the composed runtime and the inner port is never reached (installed at the Installed tier via the registrar; OwnCapabilities scope keyed to the capability provider). - per-tenant isolation: tenant A's deny hook fires; tenant B (separate build_default_planned_runtime composition, no hooks) completes the same capability — proving no cross-tenant leakage. Security-audit-on-deny assertion is intentionally deferred: nearai#3922's SecurityAuditSink is not yet on reborn-integration. It lands with the audit-sink wiring follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): thread hook_dispatcher_builder_factory through shared reborn harness (nearai#3934) The root-crate `tests/support/reborn/harness.rs` constructs `DefaultPlannedRuntimeParts` directly; add the new `hook_dispatcher_builder_factory: None` field so the parity-test harness compiles. Default `None` keeps the harness on the no-hooks path (unchanged behavior). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks): proper error handling / safety annotations for activation production paths (nearai#3938 CI) The per-run dispatcher factory closure used `.expect()` on the first-party and extension hook installs, tripping the no-panics CI gate. These installs are pure replays of the install set already validated fail-closed (`?`) against a scratch builder at composition time, so they are genuine invariants. The factory type returns a non-Result `HookDispatcherBuilder` and is invoked deep in the run loop, so the documented `// safety:` suppression is the correct fix here. Hoisted the expect messages into `let` bindings so the `.expect(msg)` call fits on one line, keeping the scanner-required `// safety:` comment on the same line as the call after rustfmt. The malformed-manifest path (TOML projection) already uses real error propagation via map_err/`?` and is unaffected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): direct composition-loader coverage + activation-scope docs Address Codex non-blocking follow-ups on nearai#3938. Add three direct tests for the composition-layer hook loader (`install_extension_hooks` via `build_hook_dispatcher_builder_factory`), driving a real `ExtensionRegistry` with `[[hooks]]` declarations rather than mimicking the loader: - valid `own_capabilities` predicate hook installs at the Installed trust tier; the dispatcher carries the derived binding at BeforeCapability alongside the first-party no-op observer - malformed typed hook body (unknown `mode`) fails CLOSED with `RebornBuildError::InvalidConfig`, never a panic (the load-bearing degradation contract for untrusted external manifests) - a hook claiming `scope = same_tenant` without a verified grant is rejected by trust attenuation (fail-closed) No loader bug surfaced: `HookRegistrar::install` already returns `Result` on every malformed/over-scoped path and the loader maps it to `InvalidConfig` via `?`. Document activation scope at both the loader rustdoc and the `build_reborn_runtime` call site: production currently passes only `builtin_extension_registry()`, so third-party installed-extension hooks are not yet surfaced into the runtime path — only first-party-builtin and builtin-package-declared hooks activate today. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): thread HooksActivationConfig through input; empty production catalog Two maintainability cleanups on nearai#3938 (firat review): Item 1 — config hygiene: stop reading HOOKS_ENABLED via std::env::var deep inside build_reborn_runtime. Add a typed `hooks: HooksActivationConfig` field to RebornRuntimeInput (default OFF) plus a `with_hooks_config` builder. The composition root now consumes the typed config; the env var is resolved ONCE at the edge (the reborn CLI's build_runtime_input) via HooksActivationConfig::from_env and threaded down. Testable without env mutation; matches the project's env → typed config → composition pattern. Item 2 — empty production first-party catalog: stop shipping NoOpObserverHook as a first-party builtin. install_first_party_hooks is now a no-op (empty catalog); the production type/install/export for a hook that does nothing is gone (removed from lib.rs exports). The activation machinery is still tested end-to-end through the real composition path via a new `build_hook_dispatcher_builder_factory_with` seam that takes a first-party installer; tests pass a `#[cfg(test)]` NoOpObserverHook through it. Pinned the empty-catalog-is-valid contract: flag ON + empty first-party set + no extension hooks composes a valid zero-binding dispatcher (not a panic/error). Reconciled the sibling loader tests/docs (f6c79c0): the in-module tests now drive the test-only seam; the reborn e2e tests already used a test-local no-op and are untouched. Updated activation-scope docs (loader rustdoc + build_reborn_runtime call site) to reflect the now-single live source (builtin-package-declared hooks). Deferred (not touched): switching to the canonical extension registry for third-party installed-extension hooks (nearai#3934 follow-on). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): canonical registry + infallible plan + tenant-scoped counter docs/tests (nearai#3938) Addresses serrrfirat's thermo-nuclear re-review on 1e618d0. #1 (runtime.rs:839, canonical registry): make the extension registry a shared composition artifact. `build_local_dev` builds one `Arc<ExtensionRegistry>`, hands it to `HostRuntimeServices::new` AND stores it in `RebornLocalRuntimeServices.extension_registry`. Hook activation in `build_reborn_runtime` now consumes that same `Arc` instead of rebuilding a builtin-only sidecar, so capability dispatch and hook activation cannot drift. Third-party activation stays a follow-up, but it now follows the canonical registry rather than a separate path. #3 (hooks.rs factory machinery): replace the parse/validate/replay duplication + two prose-justified `.expect()` calls with a typed `HookInstallPlan`. TOML is projected once into typed entries, the full install set is validated once against a fresh builder (fail-closed via `?`), and `HookInstallPlan::rebuild` mints a fresh builder per run. The per-run path is infallible by construction: a plan only exists for an install set that already composed cleanly, so a deterministic replay from the identical fresh-empty start cannot fail. One extension-install code path (`project_extension_install_sets` + `install_extension_sets`) is shared by validation and rebuild. #4 (hooks.rs:295, predicate counter scoping): the evaluator/backend is intentionally tenant-scoped and shared across runs (rate/value caps keyed `(hook, tenant, capability)` with no run_id; a run-scoped limit would reset every run and enforce nothing). Document the split explicitly — per-run-fresh dispatcher, tenant-scoped predicate counters — in the module docs and fix the misleading "per-run isolation of hook state" wording in `ironclaw_reborn` (loop_driver_host.rs / runtime.rs). Add `predicate_counter_state_is_tenant_scoped_across_rebuilds`, which drives a real rate-cap predicate through two dispatchers from one factory and proves the second run sees the first run's recorded count. Rename `factory_mints_independent_dispatchers_per_call` -> `rebuild_mints_independent_dispatchers_per_call` and scope it to proving dispatcher freshness only. #6 (loop_driver_host tests): clarify that the hand-built builder factories cover host PLUMBING, not composition activation. Add `build_reborn_runtime_activates_hooks_through_real_composition_path`, which drives the real `build_reborn_runtime` with `HooksActivationConfig` threaded through `RebornRuntimeInput` (env-free) and the canonical registry, proving the production activation wiring composes. #2 (env boundary) and #5 (empty production catalog) were already fixed in 1e618d0; docs touched here for consistency. Known follow-up (not one of the six items, not introduced here): with the flag ON the standalone local-dev runtime does not yet reach `Completed` for a capability turn even with a zero-binding dispatcher — the composition root wires the dispatcher but not the companion hooked-prompt dependencies. The new runtime test asserts `is_terminal()` + the capability path and documents the gap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): third-party hook-only projection core (flag, newtype, quarantine, caps) Steps 1-6 of third-party extension hook activation via hook-only projection: - Step 1: HOOKS_THIRD_PARTY_ENABLED sub-flag on HooksActivationConfig (default OFF; is_third_party_enabled() requires master flag too). Resolved at the CLI edge via from_env(). - Step 2: tenant_extension_root(&TenantId) derives the fixed /system/extensions/<tenant> root from identity (never caller-supplied); projection-layer strict-child / no-`..` containment check. - Step 3: build_hook_projection_registry assembles a HookProjectionRegistry (type-enforced hook-only newtype: no Deref / conversion back to ExtensionRegistry, so it can never reach HostRuntimeServices::new / the capability path). Sub-flag OFF => builtin-only, byte-identical to nearai#3938. - Step 4/4a: atomic per-extension quarantine — untrusted (InstalledLocal) sets validated whole against a scratch builder, committed only if the whole set passes; any failure drops the extension's hooks entirely, emits a hook.quarantined security_audit tracing event (warn!, not info!), and continues. Trusted (HostBundled) sources stay fail-closed-whole-build. - Step 5: MAX_INSTALLED_EXTENSIONS_CONSIDERED / MAX_TOTAL_HOOKS_PER_TENANT DoS caps; count_total_bindings() accessor on HookDispatcher(Builder); pre-read MAX_MANIFEST_BYTES bound via read_file_bounded in discovery. - Step 6: third-party WASM stays out (loader registrar has no wasm_runtime) => WASM-bodied hook quarantines + build continues. Registrar-only invariant: projection installs go exclusively through HookRegistrar::install (ceiling + spoof-blocked owning_extension), never the direct builder installer API. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(hooks): FS-scoped tenant isolation (Option 1), trust matrix, registrar-only assertion Resolve the discovery/path conflict: the discovery layer hardcodes package roots to /system/extensions/<id> because the per-tenant RootFilesystem is the scope boundary (as with every other tenant-scoped resource), not a tenant path segment. So: - tenant_extension_root -> fixed /system/extensions (no tenant segment). The per-tenant RootFilesystem handed to discovery IS the isolation boundary. Documented as load-bearing; the openat2(RESOLVE_BENEATH)/O_NOFOLLOW backend hardening follow-up is what protects it (gating note kept prominent). - build_local_dev mounts /system/extensions to a per-owner host subtree under the storage root (per-identity by construction, not a process-global mount); exposed via RebornLocalRuntimeServices.extension_filesystem. - enforce_root_containment retained as defense-in-depth. Tests: - Integration (real build_hook_projection_registry + build_hook_dispatcher_ builder_factory through a fake RootFilesystem, not a loader look-alike): containment (hook present / capability absent by construction), FS-as-boundary tenant isolation proof (two distinct per-tenant filesystems; A can't see B), bad dir name skipped, id mismatch not a panic, surplus-extensions DoS cap, sub-flag OFF discovers nothing. - Per-hook-point trust matrix: BeforeCapability installed deny IS allowed and fires (Gate reachable); before_prompt predicate quarantined + build continues; after_model/after_capability/after_checkpoint/event_triggered WASM-only => quarantined + build continues; owning_extension derived (not spoofable). - Discovery pre-read bound: oversized manifest rejected via stat WITHOUT reading the body (fake fs panics on get); within-bound proceeds to read. - ironclaw_architecture source assertion: the hooks.rs projection path never calls install_installed_* directly (registrar-only invariant). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(hooks): correct Option-1 path-shape references in test comments Update the third-party projection integration-test module docs to reflect the FS-scoped isolation model (fixed /system/extensions root; per-tenant filesystem is the boundary), not the abandoned /system/extensions/<tenant> path segment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(hooks): tolerant+bounded third-party discovery, structural hook-only containment Addresses Codex P1/P2 + serrrfirat P1 on nearai#3951. Critical 1 (discovery-stage DoS): add `ExtensionDiscovery::discover_with_manifest_contracts_tolerant_bounded` (+ `discover_extensions_tolerant_bounded` host-runtime wrapper). It lists+sorts the root once, then reads/parses at most `max_extensions` manifests, recording the surplus as quarantines WITHOUT reading them. The hook projection calls this with `MAX_INSTALLED_EXTENSIONS_CONSIDERED`, so the count cap fires before the per-manifest read storm. New all-or-nothing path delegates to a shared `load_package_entry` so per-package semantics are identical. Critical 2 (fail-open): tolerant discovery quarantines a single malformed/oversized/id-mismatched package and CONTINUES; valid siblings still load. The builtin-only fallback is now reserved solely for failure to LIST THE ROOT (directory unreadable). One bad manifest can no longer drop a tenant's entire legitimate third-party hook set. Refinement 3: the per-tenant hook budget is consumed only AFTER a successful merge, so a quarantined/duplicate package no longer burns budget. Refinement 4: the registrar-only arch assertion now scans the WHOLE composition crate (every non-test source) and forbids all installed-tier-minting primitives crate-wide (`install_installed_*`, `install_observer(`, `insert_binding(`, `HookTrustClass::Installed`) — not just a hooks.rs substring scan. Installed-tier bindings can only be minted via `HookRegistrar::install`. serrrfirat P1 (structural containment): `HookProjectionRegistry` no longer wraps `ExtensionRegistry`. It carries `Vec<HookProjection>` — hook metadata only (id/version/source/root/[[hooks]]). The projection literally cannot reach capabilities because it does not hold them; containment is by data shape, not a withheld conversion. Removes the `ExtensionPackageView` ceremony. Tests: bounded read-storm cap (read-counting fs panics on surplus), tolerant per-package quarantine, root-unreadable fallback, quarantined-package-does-not- consume-budget, malformed-sibling-survives at the projection layer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(hooks): address serrrfirat review — tenant-attributed install audits + hooks decomposition Addresses the maintainability review on nearai#3951. Findings #1 (narrow hook-only boundary), #2 (per-extension discovery quarantine + mixed-batch test), and #5 (behavioral arch-test invariant) were already satisfied by the head commit (2b62597); this commit closes the two remaining items and hardens the arch test against the decomposition: - #3 (tenant attribution): add `build_hook_dispatcher_builder_factory_for_tenant`, threading the authenticated `tenant_id` (and its derived extension root) into the install-time quarantine-audit seam. `build_reborn_runtime` now calls it, so install-time quarantine audits carry the real tenant instead of the synthetic `reborn-hook-projection` fallback (closing the split where only discovery-time audits were attributed). New caller-driven test `for_tenant_entry_point_attributes_install_time_quarantine_to_real_tenant` asserts attribution via a deterministic thread-local audit capture (immune to tracing's process-wide max-level filter under parallel tests). - #4 (decomposition): split the 1.7k-line `hooks.rs` into a focused `hooks/` module — `mod.rs` (flag/config + public surface), `projection.rs` (hook-only `HookProjection`/`HookProjectionRegistry` containment + discovery/admission), `factory.rs` (first-party install, per-extension quarantine validation, fresh-per-build replay), `audit.rs` (`hook.quarantined` emission), and `tests.rs` (the test matrix). Behavior-preserving; no logic change. - arch test: skip dedicated test-module files in the registrar-only scan so the #4 decomposition cannot break it; the whole-crate behavioral invariant is preserved. - audit emission uses `debug!` (not `warn!`) per the background/hook-path logging rule, on the stable filterable `security_audit` target. - gemini nearai#353: add the documented no-empty-segment guard to `enforce_root_containment` (defense-in-depth, not relying on VirtualPath canonicalization). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(hooks): cover hook-entry rejection branches; document InvocationCount cap semantics (nearai#3938) Address henrypark133 review (review 4367870023): - Add extension-manifest tests for the three previously-uncovered hook-entry validation branches: non-table `[[hooks]]` element, whitespace-only `id`, and oversized entry (HookEntryTooLarge). - Document the InvocationCount inclusive-allow / deny-on-overflow semantics inline at the comparison site; behavior unchanged and still pinned by the cap test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(deps): pin kuchikikiki to 0.9.1 (0.9.2 yanked) cargo-deny failed on the yanked kuchikikiki 0.9.2 pulled in transitively via readabilityrs. Downgrade to 0.9.1 at the lockfile level. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(reborn-cli): assert hooks config threaded in caller test (nearai#3938) Addresses the review finding that `build_runtime_input_maps_configured_cli_identity` exercised `build_runtime_input` but never asserted the `hooks` field, so a regression silently dropping `with_hooks_config(HooksActivationConfig::from_env())` or flipping the default-OFF rollout-safety contract would pass. Per `.claude/rules/testing.md` ("Test Through the Caller"): add two assertions to the existing caller-level test: - threading: `runtime_input.hooks == HooksActivationConfig::from_env()`, proving the env-resolved config is actually threaded through and not dropped. Verified via TDD that dropping the wiring fails this assertion (run under HOOKS_ENABLED=1). - default-OFF: when `HOOKS_ENABLED` is unset, `!runtime_input.hooks.is_enabled()`, guarded to skip if the CI environment exports the flag so it only pins the contract it claims to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): cover build_reborn_runtime third-party wiring + async dir create (nearai#3951) Address serrrfirat review findings M1 and L2. M1: add an integration test in tests/runtime.rs that drives build_reborn_runtime with HooksActivationConfig::enabled().with_third_party_enabled(true), a real /system/extensions manifest tree on the local-dev host filesystem, and tenant attribution. Asserts the runtime builds, starts a conversation turn, and shuts down cleanly — exercising the runtime.rs third-party discovery input + projection registry + tenant-threading wiring that was previously uncovered (the projection tests call build_hook_projection_registry / the dispatcher factory directly, and every other build_reborn_runtime call used the default disabled config). Verified the test fails when the wiring is broken. L2: switch the new factory.rs blocking std::fs::create_dir_all for the extensions host root to tokio::fs::create_dir_all(...).await with the same error mapping, so it no longer blocks the tokio executor thread inside the async build_local_dev. The two pre-existing std::fs calls (lines 132/136) are out of this PR's diff per the posted promise and are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): L1 quarantine-surfacing gate doc, L3 robust test-mod strip, M1 coverage-gap TODO Address serrrfirat 2026-06-03 review (M1/L2 already landed in 9866793). L1 (security observability): hook.quarantined audit events are emitted only via tracing at the security_audit target / debug! level, which production typically disables. Document durable quarantine surfacing as a hard production-enablement prerequisite for HOOKS_THIRD_PARTY_ENABLED, alongside the existing openat2(RESOLVE_BENEATH)/O_NOFOLLOW FS-hardening note, at all three gate doc sites: HooksActivationConfig (hooks/mod.rs), the runtime.rs composition -root gate comment, and the audit.rs module doc. L3 (robustness): strip_test_module matched #[cfg(test)]\nmod tests specifically and only the first occurrence. Generalize the anchor to #[cfg(test)]\nmod (any module name) so a refactor that renames the test module or adds a second #[cfg(test)] mod block is still fully stripped, preventing false positives in the FORBIDDEN_INSTALLED_PRIMITIVES architecture scan. M1 (test coverage): the build_reborn_runtime third-party wiring test already landed in tests/runtime.rs (9866793). Add the reviewer-requested TODO preserving the removed test's Cancelled-outcome coverage gap: the stub local-dev gateway cancels the turn before any capability dispatches, so the test exercises discovery + projection + tenant-threading at build/start but not end-to-end hook enforcement. NOTE: third-party discovery is intentionally tolerant (skips unparseable manifests), so this test catches compile-time field/arg regressions and build-path failures but not a silent manifest-read drop; documented for the reviewer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): correct in-memory backend warning text; delegate test ctor (nearai#3938) Address serrrfirat review (2026-06-03): - Low: the in-memory backend warning claimed the LRU cap is shared across tenants, but the Reborn composition constructs a fresh InMemoryPredicateStateBackend per tenant. Rewrite the warn! text and doc comment so the real limitation (process-local replay dedup for multi-host deployments) is accurate, and note the backend is per-tenant in this composition. - Nit: PredicateEvaluator::with_backend (test-only) and with_state_backend had identical bodies; delegate with_backend to with_state_backend so they stay in lockstep. The Medium finding (hooks_config assertion in build_runtime_input caller test) was already addressed in 218a1de. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Durable, cross-host-consistent
PostgresPredicateStateBackendimplementing thePredicateStateBackendcontract for the reborn hook framework. This is PR 2 of the 4-PR durable-backend split.This replaces #3932, which was auto-closed when its base branch (
hooks-predicate-backend-trait-widening) was deleted on the squash-merge of the trait-widening work. This PR re-homes the Postgres-specific commit ontoreborn-integration(which now contains the merged trait widening, #3927) and adds the fail-closed alignment.Contract base (now merged)
The public async
PredicateStateBackendcontract —async fn,now: DateTime<Utc>,Result<_, PredicateBackendError>, plusPredicateBackendError::WindowOverflow— landed inreborn-integrationvia #3927. This PR's Postgres impl satisfies that contract exactly and is exercised against the shared contract harness (ironclaw_hooks::predicate_state::contract,contract-testsfeature).Fail-closed cap alignment
The per-key sample cap is now fail-closed, matching the in-memory backend and the uniform trait contract (#3929 / PR #3635 followup):
MAX_SAMPLES_PER_KEYand a new distinct id arrives,record_invocation/record_valuereturnPredicateBackendError::WindowOverflowwithout inserting — instead of the previous drop-oldest eviction.WindowOverflowto a restrictive DENY/PauseApproval, so overflow surfaces as a refusal, never a silent Allow.Implementation: the shared transaction body now does trim → atomic dedup-check + pre-insert count (
BOOL_OR(id = $id)under the per-key advisory lock) → fail-closed cap check → insert → aggregate → per-scope LRU quota, all inside oneREAD COMMITTEDtransaction guarded by a transaction-scoped advisory lock.Schema
hook_predicate_counters (scope_hash BYTEA, key_hash BYTEA, kind CHAR(1), id TEXT, ts TIMESTAMPTZ, value NUMERIC, PRIMARY KEY (key_hash, id))plus supporting indexes. Idempotent (CREATE … IF NOT EXISTS), applied viarun_migrations(). ThePRIMARY KEY (key_hash, id)+ON CONFLICT DO NOTHINGis the clock-independent cross-host replay-dedup constraint.Test plan
predicate_state_postgres_contract.rs): all 9 contract functions wired, including the newrecord_invocation_overflow_is_fail_closed. Same suite the in-memory backend runs — proven by construction.predicate_state_postgres_adversarial.rs): two-host write storm, cross-host replay (count + value), per-scope LRU eviction, andper_key_sample_cap_fails_closed_under_flood(rewritten from the old drop-oldest assertion to assertWindowOverflow+ replay-dedup-at-cap).IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL; they skip-pass without a DB (no testcontainers dep), matching theironclaw_filesystem/ironclaw_reborn_event_storepattern.Verification
cargo fmt --all— cleancargo clippy --all --benches --tests --examples --all-features -- -D warnings— zero warningscargo check -p ironclaw_hooks_postgres --features postgres— cleancargo test -p ironclaw_hooks_postgres --features postgres— 9 contract + 5 adversarial tests compile and skip-pass (no DB in this env); a real Postgres run is required to exercise the SQL paths.ironclaw_hookspredicate_state suite (27 tests) — green.Crate placement: Postgres is its own crate (
ironclaw_hooks_postgres); the libSQL backend moves to its own crate in a sibling task.Dual-backend compliance (libSQL counterpart + parity)
Per the repo rule "new persistence must support both PostgreSQL and libSQL," the requirement is satisfied across this staged durable-backend series, not in this PR alone (addresses serrrfirat C-1, Medium):
ironclaw_hooks_postgres.ironclaw_hooks_libsql— PR feat(hooks): LibSqlPredicateStateBackend in own crate (durable backend PR 3/4, replaces #3930) #3936 (hooks-predicate-backend-libsql).ironclaw_hooks_parity— PR test(hooks): cross-backend adversarial parity suite + A3 closeout (durable backend PR 4/4) #3937, which runs the identical contract assertions against both durable backends and proves they are drop-in interchangeable.Merge ordering: this PR (#3933) lands first, then the libSQL crate (#3936), then the parity suite (#3937) gates that the two backends are behaviorally equivalent. Both backends share ONE logical two-table typed schema (
migrations/V1__predicate_state.sql); only the storage column types differ (PostgresTIMESTAMPTZ+NUMERICvs. libSQL epoch-msINTEGER+TEXT). The dependency is also recorded in the crate-level doc comment inlib.rs.🤖 Generated with Claude Code