Skip to content

feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4, replaces #3932) - #3933

Merged
zmanian merged 12 commits into
mainfrom
hooks-predicate-backend-postgres
Jun 6, 2026
Merged

zmanian merged 12 commits into
mainfrom
hooks-predicate-backend-postgres

Conversation

@zmanian

@zmanian zmanian commented May 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Durable, cross-host-consistent PostgresPredicateStateBackend implementing the PredicateStateBackend contract 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 onto reborn-integration (which now contains the merged trait widening, #3927) and adds the fail-closed alignment.

Contract base (now merged)

The public async PredicateStateBackend contract — async fn, now: DateTime<Utc>, Result<_, PredicateBackendError>, plus PredicateBackendError::WindowOverflow — landed in reborn-integration via #3927. This PR's Postgres impl satisfies that contract exactly and is exercised against the shared contract harness (ironclaw_hooks::predicate_state::contract, contract-tests feature).

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

  • When a key's in-window sample count reaches MAX_SAMPLES_PER_KEY and a new distinct id arrives, record_invocation / record_value return PredicateBackendError::WindowOverflow without inserting — instead of the previous drop-oldest eviction.
  • Replay-dedup short-circuits before the cap check: an already-recorded in-window id is a no-op (returns the unchanged aggregate), so replay refusal survives the cap boundary.
  • The evaluator maps WindowOverflow to 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 one READ COMMITTED transaction 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 via run_migrations(). The PRIMARY KEY (key_hash, id) + ON CONFLICT DO NOTHING is the clock-independent cross-host replay-dedup constraint.

Test plan

  • Contract harness (predicate_state_postgres_contract.rs): all 9 contract functions wired, including the new record_invocation_overflow_is_fail_closed. Same suite the in-memory backend runs — proven by construction.
  • Adversarial (predicate_state_postgres_adversarial.rs): two-host write storm, cross-host replay (count + value), per-scope LRU eviction, and per_key_sample_cap_fails_closed_under_flood (rewritten from the old drop-oldest assertion to assert WindowOverflow + replay-dedup-at-cap).
  • Integration tests are env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL; they skip-pass without a DB (no testcontainers dep), matching the ironclaw_filesystem / ironclaw_reborn_event_store pattern.

Verification

  • cargo fmt --all — clean
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings — zero warnings
  • cargo check -p ironclaw_hooks_postgres --features postgres — clean
  • cargo 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.
  • In-memory ironclaw_hooks predicate_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):

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 (Postgres TIMESTAMPTZ + NUMERIC vs. libSQL epoch-ms INTEGER + TEXT). The dependency is also recorded in the crate-level doc comment in lib.rs.

🤖 Generated with Claude Code

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>

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces 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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The hook_predicate_counters_scope_idx index currently only covers (scope_hash, kind). 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.

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

Comment on lines +220 to +247
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)
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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.

Suggested change
if pre_count as usize >= MAX_SAMPLES_PER_KEY {
if pre_count.max(0) as usize >= MAX_SAMPLES_PER_KEY {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Update the documentation to reflect the change to an 8-byte length prefix for binary encoding consistency.

Suggested change
//! Length-prefixing every field (4-byte big-endian length ++ bytes)
//! Length-prefixing every field (8-byte big-endian length ++ bytes)
References
  1. Prefer using u64 for length prefixes in binary encodings to ensure that conversions from usize are infallible on all supported platforms, avoiding panics and potential DoS vectors caused by .expect() on overflow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 7547ebf — doc updated to "8-byte big-endian length ++ bytes".

Comment on lines +30 to +33
// 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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

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.

Suggested change
// 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
  1. Prefer using u64 for length prefixes in binary encodings to ensure that conversions from usize are infallible on all supported platforms, avoiding panics and potential DoS vectors caused by .expect() on overflow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The hook_predicate_counters_scope_idx index currently only covers (scope_hash, kind). 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.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@henrypark133 @serrrfirat durable backend PR 2/4 — PostgresPredicateStateBackend (own crate ironclaw_hooks_postgres). Implements the public async DateTime<Utc> contract from #3927 with fail-closed WindowOverflow cap semantics matching #3929. Atomicity via per-key advisory lock; replay dedup via PRIMARY KEY + ON CONFLICT DO NOTHING (clock-independent). id column TEXT (64-char blake3 hex doesn't fit uuid). Merge caveat: my env had no Postgres, so the SQL fail-closed/dedup paths compiled + skip-passed — needs a real-Postgres CI run before merge. Replaces closed #3932.

@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

Codex review (advisory) — REQUEST CHANGES

The per-key trim/dedup/cap/insert/aggregate flow is mostly right (parameterized SQL, WindowOverflow after replay detection, correct BOOL_OR(id=$3) dedup, (key_hash, ts) index supports the hot path). One correctness blocker + a CI gap.

Critical

1. Scope-LRU deletes victim buckets without the victim key's advisory lock — crates/ironclaw_hooks_postgres/src/backend.rs:316 / :387. The per-key lock only protects current_key; enforce_scope_quota can delete rows for another key while a concurrent transaction is actively recording that victim key under its own advisory lock. Breaks the "all writers to the same bucket are serialized" claim and can return aggregates that include concurrently-deleted rows. Real deadlock shape: victim-key txn trims rows then waits for the scope lock; LRU txn holds the scope lock then waits to delete those victim rows. Fix: make victim eviction participate in the same lock discipline (acquire/try-acquire victim key locks before deleting, skip in-flight victims), or impose one global scope/key/row lock order.

2. No CI validates this crate against real Postgres — root cargo test doesn't run this workspace member, and the PG tests skip-pass without IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL. Highest-risk unexercised: advisory-lock semantics, READ COMMITTED visibility, ON CONFLICT dedup with BYTEA/TEXT, NUMERIC/TIMESTAMPTZ codecs, and the LRU concurrency path above. Add a PG service job running cargo test -p ironclaw_hooks_postgres --features postgres.

Recommendations

  • Clock basis: trim-before-dedup means a fast host can free an id early, so replay dedup isn't fully clock-independent under arbitrary skew — tighten the docs (bounded-skew/NTP assumption) or add skew tests.
  • LRU quota query scans/groups all scope rows — consider a covering (scope_hash, kind, key_hash, ts) index or per-key summary for high-cardinality tenants.
  • Add a record_value overflow/Decimal-sum adversarial case (flood test only covers invocation overflow).

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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:

  1. Takes the scope advisory lock and computes the over-quota count (unchanged — this keeps the distinct-key count exact across concurrent new-key materializations).
  2. Selects victim candidates ordered by MAX(ts) ASC, over-fetching beyond the evict count.
  3. 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 returns false promptly (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 (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 behind kind + a nullable value, keyed PRIMARY 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) and hooks_predicate_values(scope_hash, event_id, occurred_at, value TEXT, tenant_id), each keyed PRIMARY 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved 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-window COUNT(*) is the invocation count.
  • hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at, value NUMERIC NOT NULL) — in-window SUM(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`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Resolved 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 runs COUNT(*) for the invocation table and SUM(value) for the value table, so the invocation table — which no longer has a value column at all — is never asked to SUM(value).
  • The INSERT column list is matched per table (no value column for invocations; value NOT NULL for 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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>
@github-actions github-actions Bot added the scope: dependencies Dependency updates label May 23, 2026
zmanian and others added 3 commits May 23, 2026 08:28
…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>
@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@serrrfirat @henrypark133 re-review please — major redesign per the agreed direction (SHA ef93722): the generic hash table is gone, replaced by two typed tables (hooks_predicate_invocations + hooks_predicate_values) shared canonically with the libSQL backend (#3936). This resolves three of your findings at once: (1) typed columns instead of the (scope_hash, key_hash, kind) hash shape — operator/migration-inspectable; (2) the value: Option<Decimal> double-duty is eliminated via a RecordKind enum + per-table aggregate() (no SUM on the value-less invocation table); (3) SQL drift killed via include_str! of the migration. All invariants preserved (MIN oldest-front victim, fail-closed WindowOverflow, dedup, per-tenant quota, advisory locks). Replied on both threads. Caveat: needs the real-PG CI leg (from #3937) before merge — no PG in the dev env.

@zmanian

zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

@serrrfirat @henrypark133 friendly ping — every item from your review is addressed as of ef937229b (details in the comment above) and CI is green. Ready for another pass to clear the CHANGES_REQUESTED when you have a moment. Thanks!

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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:

  1. Requery the live COUNT(DISTINCT key_hash) for the scope. If <= MAX_KEYS_PER_TENANT, return Ok(evicted) — the cap is genuinely met (the recount reflects rows this loop already deleted).
  2. 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-key pg_try_advisory_xact_lock (deterministic lock order, deadlock-free) — stopping at exactly to_evict so it can never over-evict below the cap.
  3. 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::Unavailable rather 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 for WindowOverflow. 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.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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 no value column).
  • 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed 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>
@zmanian

zmanian commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

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). enforce_scope_quota now loops: requery distinct count → evict EXACTLY the per-pass deficit via deterministic oldest-front + per-key try-lock → repeat until the cap is met, or fail closed (Unavailable) if a pass makes zero progress while still over cap. A committed record always leaves the scope at-or-under MAX_KEYS_PER_TENANT, by construction. No more Ok(evicted) over a still-over-quota scope.

2. Nullable-mode anti-pattern removed (backend.rs:185). The shared record path no longer takes value: Option<Decimal> + RecordKind guarded by debug_assert_eq. It dispatches on a typed RecordPlan enum (Invocation cannot carry a value; Value { value } carries it in the variant). Common steps run off shared PlanCommon; only the INSERT columns + final aggregate are variant-specific.

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 Unavailable — the production failure mode it documents.

Plus a new stateful regression test scope_quota_is_enforced_exactly_not_best_effort proving the cap holds at EXACTLY the bound after a flood. It caught an over-eviction bug in my first cut against a live Postgres (inner loop drained the whole candidate batch instead of the per-pass deficit) — now fixed.

Verification

Against a local Postgres 14: 7 lib unit + 8 adversarial + 9 contract tests green. cargo fmt --all --check, cargo clippy -p ironclaw_hooks_postgres --all-features --tests -- -D warnings, and cargo clippy --all --all-features --tests -- -D warnings all clean. No .unwrap()/.expect() added to non-test code; the fail-closed path uses thiserror's existing PredicateBackendError::Unavailable.

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

@zmanian

zmanian commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

@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 value: Option<Decimal> core replaced with a typed RecordPlan enum; adversarial test now returns + asserts task outcomes so deadlock/serialization failures fail the test; new scope_quota_is_enforced_exactly_not_best_effort stateful test (it caught a real over-eviction bug in my first cut). Typed-columns + migration include_str! were already on-branch (your line refs pointed post-refactor). Verified against a live Postgres: 7 unit + 8 adversarial + 9 contract green; workspace clippy clean. Ready for a re-pass.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review (multi-agent)

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

  1. 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.
  2. Low format! per SQL statement allocates 5-6 Strings per hot-path record() call (backend.rs:258-621, confidence 70)

Tests

  1. Medium Postgres evict_older_than has no integration test (backend.rs:709-727, confidence 75)

Security

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low — 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low — 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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>
@zmanian

zmanian commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed @henrypark133's latest CHANGES_REQUESTED review (all 4 findings) in 2ee6f0f:

  • High (perf) — redundant COUNT queries: derive post-insert in-window count as pre_count + 1 and return it directly as the invocation aggregate. 3 COUNTs → 1 on the invocation record() hot path. Correctness rests on the per-key advisory lock (dedup proved absent + cap passed + ON CONFLICT inserted exactly one row, no concurrent same-key writer). Value path keeps its SUM(value) query.
  • Low (perf) — format! per statement: new TableStatements struct pre-formats all SQL once per typed table at construction.
  • Low (security) — raw PG errors exposed: map_pg/map_pool log the raw error at warn and return a sanitized "backend unavailable" message.
  • Medium (tests) — evict_older_than untested: added evict_older_than_removes_stale_rows_from_both_tables to the adversarial suite.

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 RecordPlan::table(). fmt/clippy clean; cargo test -p ironclaw_hooks_postgres --all-features green (PG-gated tests skip without a live DB).

@zmanian
zmanian requested a review from henrypark133 May 27, 2026 00:41
@henrypark133

Copy link
Copy Markdown
Collaborator

Review of #3933: PostgresPredicateStateBackend

No critical bugs found. Two notes:

Low — pg_try_advisory_xact_lock (non-blocking) for victim eviction: silent skip on lock contention

crates/ironclaw_hooks_postgres/src/backend.rs — victim eviction path:

The victim bucket's per-key advisory lock is taken with pg_try_advisory_xact_lock (non-blocking). If a concurrent writer holds that victim's lock, try_advisory returns false and the victim is skipped. The doc comment says "keeps evicting until the per-scope cap is met, or FAILS CLOSED" — but if all victim candidates are locked, the scope quota may remain over-cap and the call returns WindowOverflow rather than succeeding. This is the correct fail-closed outcome (documented), but worth verifying the test suite covers the case where ALL candidate victims are concurrently locked and the call correctly surfaces WindowOverflow rather than silently accepting over-quota.

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 serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review — 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!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed — 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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,
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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::*).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 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>
zmanian added a commit that referenced this pull request Jun 4, 2026
…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>
@zmanian
zmanian requested a review from serrrfirat June 4, 2026 20:12
@zmanian

zmanian commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@serrrfirat C-1 is addressed: the dual-backend rule is now explicit in the PR body and crate docs (da1aaaff5) — libSQL counterpart is #3936, parity suite is #3937. C-2/T-2 were already fixed on head (34d1d6081); P-1/P-2/T-1 deferrals are reasoned in the inline replies. No Critical/High in your June 3 pass — could you approve or dismiss the May 23 CHANGES_REQUESTED?

@zmanian

zmanian commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@henrypark133 This one is ready for your re-review as well — all findings from the June 3 review pass are addressed (see the summary comment above for commits), and your review request is still pending on it.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review (multi-agent, re-review)

Intent: 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.rs evict_older_than untested in Postgres — fixed in 2ee6f0f55 (test added).
  • backend.rs:775 map_pg/map_pool raw error exposure — fixed in 2ee6f0f55 (sanitized return + raw at warn for operators).
  • backend.rs value: Option shared record path — refactored to typed tables in ef937229b / 2ee6f0f55 (TableStatements).
  • backend.rs:552 lock-ordering / deadlock concern (serrrfirat) — addressed in 741e0ecac (explicit quota outcome, try-lock semantics).
  • backend.rs:27 COUNT-on-hot-path round-trip — addressed in 741e0ecac.
  • backend.rs:562 scope_advisory_lock_key recomputes blake3 — acknowledged by author as small per-new-key cost.
  • backend.rs:383 drop(tx) trim-rollback churn — acknowledged as intentional trade-off.
  • schema.rs POSTGRES_PREDICATE_SCHEMA hand-copied const — replaced with include_str! in 0c102a631.
  • predicate_state_postgres_adversarial.rs:405 discarded JoinHandle results — fixed in 741e0ecac.

Tests

  1. High record_value WindowOverflow fail-closed has no Postgres test (tests/predicate_state_postgres_adversarial.rs:245, conf 90) — value table cap untested; only invocation is. See inline.
  2. Medium scope_advisory_lock_key invocation-vs-value distinctness has no unit test (backend.rs:798, conf 75) — pin the b"i"/b"v" disjointness invariant. See inline.
  3. Medium evictions counter must not advance on rolled-back WindowOverflow — no test (backend.rs:583, conf 75). See inline.

Bugs

  1. Medium evict_older_than runs two DELETEs without a transaction; partial delete on second failure (backend.rs:751, conf 75) — atomicity gap separate from the test added in 2ee6f0f55. See inline.

Local patterns

  1. Low New crate missing AGENTS.md required by every workspace crate (Cargo.toml, conf 100). See inline.

Maintainability

  1. Low advisory_lock_key is a trivial pass-through wrapper over advisory_lock_key_from_bytes (backend.rs:771, conf 85). See inline.
  2. 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

  1. Low Crate accepts raw deadpool_postgres::Pool rather than routing through RootFilesystem per .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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — 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());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — 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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low — 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),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low — 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};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low (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.

Base automatically changed from reborn-integration to main June 5, 2026 12:57
zmanian and others added 2 commits June 5, 2026 10:39
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>
@github-actions github-actions Bot added the scope: docs Documentation label Jun 5, 2026
@zmanian
zmanian merged commit 3d91f32 into main Jun 6, 2026
35 of 37 checks passed
@zmanian
zmanian deleted the hooks-predicate-backend-postgres branch June 6, 2026 00:12
zmanian added a commit that referenced this pull request Jun 6, 2026
…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>
zmanian added a commit that referenced this pull request Jun 6, 2026
…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>
zmanian added a commit that referenced this pull request Jun 6, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…eplaces nearai#3932) (nearai#3933)

* feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4)

Implements the durable PostgreSQL backend for predicate sliding-window
state, satisfying the `PredicateStateBackend` contract widened to a
public async trait in PR nearai#3927.

New crate `ironclaw_hooks_postgres` (out-of-crate durable backend, the
pattern the `contract-tests` feature on `ironclaw_hooks` was designed
for; mirrors `ironclaw_reborn_event_store`). Keeps the Postgres
dependency surface out of the hook framework itself.

Correctness:
- Atomic record-and-read: each `record_*` runs in one READ COMMITTED tx
  guarded by a transaction-scoped advisory lock on the bucket. The
  advisory lock (not REPEATABLE READ) serializes same-key writers by
  BLOCKING the second writer rather than aborting it with a
  serialization failure — no caller retry loop. Trim, insert, cap-evict,
  aggregate all share the one tx.
- Cross-host replay dedup via PRIMARY KEY (key_hash, id) +
  INSERT ON CONFLICT DO NOTHING — exact regardless of clock skew.
- Running-sum consistency under eviction: cap eviction re-aggregates
  inside the same tx so the returned sum reflects dropped rows.
- Per-key sample cap (MAX_SAMPLES_PER_KEY, drop-oldest) and per-scope
  distinct-key LRU quota (MAX_KEYS_PER_TENANT) match the in-memory
  backend. Scope-quota enforcement takes a second advisory lock in the
  disjoint (int8) lock space so concurrent new-key inserts in a scope
  don't under-evict.

Schema: single `hook_predicate_counters` table, BYTEA blake3 hash keys,
TEXT id column (NOT uuid — Postgres uuid rejects the 64-char blake3 hex
digest; resolves the nearai#3635 docs/schema contradiction in favor of TEXT).
Idempotent CREATE ... IF NOT EXISTS applied via run_migrations(); the
per-crate pattern, not the legacy main-binary refinery migrations.

DB-clock decision: window comparison basis is the caller's `now` (the
same Utc::now() the in-memory backend trusts), making this a drop-in
under the deterministic-clock contract harness. Replay dedup and
atomicity do NOT depend on clock agreement.

Tests (env-gated on IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL, skip
when absent; schema-isolated so the two test binaries can share one DB):
- All 8 shared contract functions via the `contract-tests` harness.
- Adversarial: two-host write storm (no count desync), cross-host
  replay (count + value), per-key cap flood, per-scope LRU under
  concurrent insert pressure across two hosts.
- 4 hashing unit tests (injective length-prefixed serialization).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks-postgres): take victim-key advisory lock during scope-LRU eviction (deadlock + race)

enforce_scope_quota deleted victim buckets' rows while holding only the
per-scope advisory lock, never the victims' per-key advisory lock. That
broke the per-bucket serialization guarantee in two ways:

  * Torn aggregate: the LRU pass could delete key B's rows while another
    transaction was recording B under B's own per-key lock, so the
    recorder's COUNT/SUM straddled a delete it never serialized against.
  * Deadlock: a recorder of victim key B holds B's per-key lock and then
    waits on the scope lock inside its own quota pass, while the LRU
    transaction holds the scope lock and waits on B's row locks to delete
    them -> cycle.

Fix: evict victims one at a time, each guarded by that victim key's
per-key advisory lock acquired with the NON-blocking
pg_try_advisory_xact_lock. An in-flight victim (lock already held by a
concurrent recorder) is skipped, not waited on, so the deadlock cycle
cannot form and eviction never deletes rows out from under an
unserialized transaction. Candidates are over-fetched beyond the evict
count so skips still meet the per-scope quota. Fail-closed WindowOverflow
semantics and the dedup/atomicity invariants are unchanged.

Adds a lib unit test pinning the eviction try-lock key derivation equal
to the recorder's lock key (lock-acquisition invariant, no DB needed), a
deterministic raw-SQL regression test proving the try-lock on an in-flight
victim returns immediately rather than blocking, and a concurrency stress
test asserting no deadlock and no torn aggregate. The integration tests
gate on IRONCLAW_HOOKS_POSTGRES_URL/DATABASE_URL and were verified against
a real Postgres 14.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks-postgres): make migration SQL the single source via include_str!

firat (PR nearai#3933 maintainability audit): schema.rs embedded a hand-copied
DDL const documented as "byte-compatible" with
migrations/V1__predicate_counters.sql, with nothing enforcing equivalence
— silent drift risk.

Pull the DDL directly from the .sql file via include_str! so there is
exactly one copy. batch_execute tolerates the file's leading -- comment
block, so no statement splitting is needed. run_migrations is exercised
by the contract/adversarial suites, confirming the included SQL applies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks-postgres): rank LRU eviction victims by MIN(ts), matching in-memory/libSQL

serrrfirat (PR nearai#3933): the Postgres scope-LRU victim query ranked keys by
MAX(ts) (most-recent activity) while the in-memory backend evicts by each
bucket's OLDEST retained sample (entries.front() + min_by_key) and libSQL
does the same (oldest-front). The doc comment claimed oldest-front but the
SQL did MAX(ts).

Under multi-sample keys this diverges: a key with one ancient + one fresh
sample is spared by MAX(ts) but evicted by the in-memory/libSQL MIN(ts)
ranking, so the three backends evict different keys. The single-sample-
per-key parity matrix masks it (MIN == MAX). Fix: rank by MIN(ts) per key
and update the module/fn docs to match the actual behavior.

Follow-up (routed separately): the nearai#3937 parity suite needs a multi-sample
LRU case to lock this in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks-postgres): canonical typed two-table predicate schema

Replace the single generic hook_predicate_counters(kind CHAR(1), id, ts,
value NUMERIC) table with two explicit typed tables matching the libSQL
backend's two-table model, so both durable backends share ONE logical
schema (table count, column names, semantics identical; native storage
types differ per backend).

Canonical schema (both backends):
- hooks_predicate_invocations(scope_hash, key_hash, event_id, occurred_at),
  PK (key_hash, event_id); in-window COUNT(*) is the invocation count.
- hooks_predicate_values(scope_hash, key_hash, event_id, occurred_at,
  value NOT NULL), PK (key_hash, event_id); in-window SUM(value) is the sum.

Postgres native types unchanged where right: BYTEA hashes, TIMESTAMPTZ
occurred_at, NUMERIC value. Column renames id->event_id, ts->occurred_at
to the canonical names. The kind discriminator column is gone (table
identity carries it); the value: Option<Decimal> double-duty smuggling
serrrfirat flagged (backend.rs:138) is eliminated — the invocation table
has no value column and the value table's value is NOT NULL, with a
per-table aggregate() helper so COUNT vs SUM is explicit.

Migration file renamed V1__predicate_counters.sql -> V1__predicate_state.sql,
still the include_str! single source of truth.

Invariants preserved: MIN(occurred_at) oldest-front LRU victim rule (the
0c102a6 fix), fail-closed WindowOverflow at MAX_SAMPLES_PER_KEY, replay
dedup via PK + ON CONFLICT DO NOTHING, per-tenant distinct-key quota
(COUNT(DISTINCT key_hash) WHERE scope_hash, no global cap), per-key
advisory lock + non-blocking victim try-lock, scope advisory lock now
folds a per-table tag instead of the kind column. evict_older_than reaps
both tables.

SQL paths compile + skip-pass without a reachable Postgres; the typed
schema needs the nearai#3937 PG CI leg for a live run before merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks-postgres): typed RecordPlan + fail-closed scope quota + asserting LRU test

Addresses serrrfirat's post-typed-schema maintainability review on nearai#3933.

1. Quota enforcement is now an explicit OUTCOME, not silently best-effort
   (backend.rs). `enforce_scope_quota` previously over-fetched a fixed 64
   candidates, skipped locked victims, and committed `Ok(evicted)` even if
   the scope was still above MAX_KEYS_PER_TENANT — making the per-scope
   bound an accident of how many victims happened to be in-flight. It now
   requeries the distinct count + a fresh victim batch each pass and keeps
   evicting (deterministic oldest-front victim, per-key try-lock before
   delete, consistent lock order) until the cap is actually met, evicting
   EXACTLY the per-pass deficit so it can never over-evict. If a whole pass
   makes zero progress while still over cap (every stale candidate locked by
   an in-flight recorder) it FAILS CLOSED with Unavailable rather than
   committing an over-quota scope; the caller's txn rolls back and the
   evaluator maps it restrictively.

2. Eliminate the nullable-mode anti-pattern in the shared record path
   (backend.rs). The `value: Option<Decimal>` + `RecordKind` pair guarded by
   `debug_assert_eq` is replaced by a typed `RecordPlan` enum whose `Value`
   variant carries the Decimal and whose `Invocation` variant cannot. The
   common lock/trim/dedup/cap/quota steps run off a shared `PlanCommon`; only
   the INSERT column list and final aggregate dispatch on the variant. No
   nullable side-channel, no debug_assert invariant.

3. Adversarial LRU test now RETURNS each spawned task's backend result and
   asserts the allowed-outcome set explicitly, so a deadlock-detected /
   serialization DB error fails the test instead of passing silently as long
   as the task returns before the timeout.

4. New `scope_quota_is_enforced_exactly_not_best_effort` stateful test drives
   a sequential flood past the cap and asserts the scope holds at EXACTLY
   MAX_KEYS_PER_TENANT with oldest-front victims evicted — the regression
   guard for the best-effort BLOCKER. (This test caught an over-eviction bug
   in the first cut of the fix against a live Postgres.)

Schema typed-columns (#2) and migration include_str! single-source (#4) were
already landed in ef93722 / a784b0d on this branch; line refs in the new
review pointed at the post-refactor file.

Verified against a local Postgres 14: 7 lib unit + 8 adversarial + 9 contract
tests green. fmt + clippy -D warnings clean crate-wide and workspace-wide.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks-postgres): index-only scope-quota cover, u64 hash length prefix, defensive count clamp

Address remaining review items on nearai#3933 after the three blocker fixes
landed in 741e0ec:

- Expand the per-scope index on both typed tables to
  (scope_hash, key_hash, occurred_at) so enforce_scope_quota's
  COUNT(DISTINCT key_hash) and MIN(occurred_at) victim ranking run as
  index-only scans (gemini perf item, re-applied to the post-rewrite
  two-table schema).
- Canonical hashing length prefix widened from 4-byte u32 (saturating)
  to 8-byte u64, making the usize->len conversion infallible and removing
  the saturation aliasing corner; keeps the serialization strictly
  injective. Doc updated to match.
- Clamp pre_count with .max(0) before the usize cast in the fail-closed
  cap check, matching the codebase convention against negative-row wrap.

Crate suite green (7 lib unit, 8 adversarial, 9 contract; PG-gated tests
skip-pass without a live DB). fmt clean, clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks-pg): address henrypark133 review on nearai#3933

Performance (High): derive the post-insert in-window count as
`pre_count + 1` instead of issuing a second COUNT, and return it
directly as the invocation aggregate — removing 2 of the 3 COUNT round
trips on the record() hot path. Provable under the per-key advisory
lock: the dedup check proved the id absent, the cap gate passed, and the
ON CONFLICT insert added exactly one in-window row, so no concurrent
writer can perturb this key's count. The value aggregate stays a SUM
query (not derivable from sample count). Quota gate now keys on
`pre_count == 0` (equivalently the old `in_window_count == 1`).

Performance (Low): pre-format every record()/reaper SQL statement once
at construction (TableStatements per typed table) instead of `format!`
allocating 5-6 throwaway Strings per call.

Security (Low): map_pg/map_pool now log the raw DB error at warn and
return a sanitized "backend unavailable" message through
PredicateBackendError::Unavailable rather than leaking raw Postgres
error text (which can embed connection/schema details) to callers.

Tests (Medium): add evict_older_than_removes_stale_rows_from_both_tables
adversarial test proving the time-based reaper deletes stale rows from
both typed tables, spares in-window rows, and returns the correct count.

Preserves the atomic record-and-read transaction, advisory-lock
serialization, tenant-scoped keying, fail-closed quota posture, and
shared identity hashing. Removed now-unused RecordPlan::table().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks-pg): sanitize quota fail-closed messages + cutoff overflow test (nearai#3933)

C-2: enforce_scope_quota built detailed Unavailable(format!(...)) payloads
exposing MAX_KEYS_PER_TENANT and lock-contention state, bypassing the
DB_UNAVAILABLE_MSG sanitization contract. Hoist both fail-closed messages
into sanitized constants (QUOTA_CONTENDED_MSG / QUOTA_BUDGET_MSG); the
operational detail now goes to tracing::debug! and the caller/evaluator only
observes the error type, not the payload.

T-2: add cutoff_with_overflow_window_saturates_to_now unit test asserting the
Err(_) => now saturation arm of cutoff (window beyond chrono's max Duration
trims nothing — conservative). Pure fn, no live Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(hooks-pg): make dual-backend compliance explicit (C-1)

Address serrrfirat C-1 (Medium): the repo rule requiring both PostgreSQL
and libSQL persistence backends is satisfied across the staged
durable-backend series, not in this crate alone. Document the libSQL
counterpart (ironclaw_hooks_libsql, PR nearai#3936) and the cross-backend
parity suite (ironclaw_hooks_parity, PR nearai#3937) in the crate-level doc,
including merge ordering, so the dependency is explicit per the
reviewer's recommendation (b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks-pg): round-2 review fixes for predicate state backend (nearai#3933)

Address henrypark133 round-2 inline findings:

- High: add per_value_key_sample_cap_fails_closed_under_flood mirroring the
  invocation flood test against record_value — fills to MAX_SAMPLES_PER_KEY,
  asserts WindowOverflow at cap+1, and asserts an in-window replay at the cap
  dedups (sum unchanged), covering the value-table INSERT and aggregate_sum
  cap-reject path the invocation test did not exercise.
- Medium: wrap evict_older_than's two table DELETEs in one READ COMMITTED
  transaction so the reaper is all-or-nothing (no partial reap leaving
  invocation rows gone and value rows present).
- Medium: add invocation_and_value_scope_lock_keys_are_distinct pinning that
  b"i" and b"v" lock tags derive disjoint scope advisory keys for the same
  tenant (pure fn, no live Postgres).
- Medium: add evictions_counter_unchanged_on_window_overflow asserting
  evictions_observed() does not advance when a per-key cap overflow rolls back.
- Low: add crates/ironclaw_hooks_postgres/AGENTS.md modeled on the sibling
  durable-backend crates' files.
- Low: delete the advisory_lock_key(&Digest) wrapper; call
  advisory_lock_key_from_bytes directly at both record/eviction call sites and
  in the unit tests (Digest slice-coerces at zero cost).
- Low: replace TableStatements::new(table, with_value_column: bool) with named
  constructors for_invocations / for_values per the no-boolean-mode-flags
  convention.

Postgres-backed adversarial tests are env-gated on
IRONCLAW_HOOKS_POSTGRES_URL / DATABASE_URL and skip (passing) with no DB
reachable; the two new ones could not be executed against a live Postgres
locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants