Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3aac87a
feat(hooks): PostgresPredicateStateBackend (durable backend PR 2/4)
zmanian May 23, 2026
648d813
feat(hooks): LibSqlPredicateStateBackend in own crate (durable backen…
zmanian May 23, 2026
170955d
Merge remote-tracking branch 'origin/hooks-predicate-backend-libsql' …
zmanian May 23, 2026
4dd8434
test(hooks): cross-backend adversarial parity suite + A3 closeout (du…
zmanian May 23, 2026
806eacb
fix(hooks-postgres): take victim-key advisory lock during scope-LRU e…
zmanian May 23, 2026
d61ec53
fix(hooks-libsql): bound connection concurrency + connect retry to pr…
zmanian May 23, 2026
4178125
Merge remote-tracking branches 'origin/hooks-predicate-backend-postgr…
zmanian May 23, 2026
a784b0d
refactor(hooks-postgres): make migration SQL the single source via in…
zmanian May 23, 2026
17343e6
test(hooks): address codex parity-suite review (#3937)
zmanian May 23, 2026
0c102a6
fix(hooks-postgres): rank LRU eviction victims by MIN(ts), matching i…
zmanian May 23, 2026
ef93722
refactor(hooks-postgres): canonical typed two-table predicate schema
zmanian May 23, 2026
0b174b1
refactor(hooks-libsql): canonical typed schema columns + .sql migration
zmanian May 23, 2026
329a916
Merge commit 'ef937229b'; commit '0b174b133' into hooks-predicate-bac…
zmanian May 23, 2026
85de60f
test(hooks-parity): typed-schema re-merge + multi-sample LRU victim p…
zmanian May 23, 2026
80c6cf7
feat(hooks): LibSqlPredicateStateBackend in own crate (durable backen…
zmanian May 23, 2026
9e385e1
fix(hooks-libsql): bound connection concurrency + connect retry to pr…
zmanian May 23, 2026
235a205
refactor(hooks-libsql): canonical typed schema columns + .sql migration
zmanian May 23, 2026
17e7f1d
refactor(hooks-libsql): canonicalize contract list, DRY record state …
zmanian May 23, 2026
a8ab96f
Merge remote-tracking branch 'origin/hooks-predicate-backend-libsql' …
zmanian May 23, 2026
89f9aa1
Merge remote-tracking branch 'origin/reborn-integration' into hooks-p…
zmanian May 23, 2026
8bd9419
refactor(hooks): canonical predicate hashing + cutoff in shared crate…
zmanian May 25, 2026
bd90f4f
test(hooks-parity): own TempDir in a fixture instead of Box::leak (#3…
zmanian May 25, 2026
bed4cf3
test(hooks-parity): split 1102-line parity_matrix into support/script…
zmanian May 25, 2026
4e7a8bd
test(hooks-parity): loud Postgres setup failure + shared cross-backen…
zmanian May 26, 2026
eb160f3
test(hooks): address henrypark133 maintainability review on parity suite
zmanian May 27, 2026
295b415
test(hooks): close evict_older_than contract + value-path LRU parity …
zmanian Jun 4, 2026
ef4d96a
perf(hooks): composite (scope_hash, key_hash) indexes for per-tenant …
zmanian Jun 4, 2026
4b2910e
test(hooks-parity): note SQLITE_MISUSE serialization caveat in parity…
zmanian Jun 4, 2026
2701869
Merge main into hooks parity backend suite
zmanian Jun 5, 2026
1cc81a3
Merge remote-tracking branch 'origin/main' into HEAD
zmanian Jun 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,54 @@ jobs:
timeout --signal=INT --kill-after=30s 10m \
cargo test -p ironclaw_secrets --test secret_store_contract -- --nocapture

hooks-parity-tests:
name: Hooks Predicate-Backend Parity Tests
needs: changes
if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.has_core_code == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ironclaw_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_PROFILE_TEST_DEBUG: 0
DATABASE_URL: postgres://postgres:postgres@localhost/ironclaw_test
# Turn a missing/unreachable Postgres into a HARD failure so the full
# cross-backend matrix (in-memory + libSQL + Postgres) cannot skip-pass.
IRONCLAW_REQUIRE_POSTGRES: "1"
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: hooks-parity
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# Full parity matrix across all three backends: --features postgres
# compiles the Postgres leg, --features integration adds the multi-host
# adversarial suite, and IRONCLAW_REQUIRE_POSTGRES=1 (set above) makes the
# Postgres leg mandatory. The libSQL leg always runs (embedded temp-file).
- name: Run hooks parity matrix + multi-host adversarial suite (all backends)
run: |
timeout --signal=INT --kill-after=30s 15m \
cargo test -p ironclaw_hooks_parity --features postgres,integration -- --nocapture

heavy-integration-tests:
name: Heavy Integration Tests
needs: changes
Expand Down Expand Up @@ -580,6 +628,7 @@ jobs:
- changes
- matrix-config
- tests
- hooks-parity-tests
- heavy-integration-tests
- telegram-integration-tests
- replay-required
Expand Down Expand Up @@ -617,6 +666,7 @@ jobs:

if [[ "${{ needs.changes.outputs.has_core_code }}" == "true" ]]; then
require_success "tests" "${{ needs.tests.result }}"
require_success "hooks-parity-tests" "${{ needs.hooks-parity-tests.result }}"
fi

if [[ "$event" == "push" || "$event" == "workflow_call" || "${{ needs.changes.outputs.has_runtime_heavy_risk }}" == "true" ]]; then
Expand Down
19 changes: 19 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = [".", "crates/ironclaw_common", "crates/ironclaw_host_api", "crates/ironclaw_filesystem", "crates/ironclaw_memory", "crates/ironclaw_events", "crates/ironclaw_event_projections", "crates/ironclaw_event_streams", "crates/ironclaw_reborn_event_store", "crates/ironclaw_extensions", "crates/ironclaw_processes", "crates/ironclaw_dispatcher", "crates/ironclaw_scripts", "crates/ironclaw_process_sandbox", "crates/ironclaw_mcp", "crates/ironclaw_wasm", "crates/ironclaw_wasm_sandbox_core", "crates/ironclaw_wasm_limiter", "crates/ironclaw_capabilities", "crates/ironclaw_secrets", "crates/ironclaw_network", "crates/ironclaw_host_runtime", "crates/ironclaw_runtime_policy", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_auth", "crates/ironclaw_trust", "crates/ironclaw_turns", "crates/ironclaw_agent_loop", "crates/ironclaw_threads", "crates/ironclaw_prompt_envelope", "crates/ironclaw_hooks", "crates/ironclaw_hooks_postgres", "crates/ironclaw_hooks_libsql", "crates/ironclaw_loop_support", "crates/ironclaw_reborn", "crates/ironclaw_reborn_config", "crates/ironclaw_reborn_composition", "crates/ironclaw_first_party_extensions", "crates/ironclaw_reborn_cli", "crates/ironclaw_reborn_traces", "crates/ironclaw_reborn_webui_ingress", "crates/ironclaw_conversations", "crates/ironclaw_product_adapters", "crates/ironclaw_product_workflow", "crates/ironclaw_product_workflow_storage", "crates/ironclaw_product_adapter_registry", "crates/ironclaw_wasm_product_adapters", "crates/ironclaw_telegram_v2_adapter", "crates/ironclaw_slack_v2_adapter", "crates/ironclaw_outbound", "crates/ironclaw_triggers", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_oauth", "crates/ironclaw_llm", "crates/ironclaw_embeddings", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui", "crates/ironclaw_webui_v2", "crates/ironclaw_webui_v2_static"]
members = [".", "crates/ironclaw_common", "crates/ironclaw_host_api", "crates/ironclaw_filesystem", "crates/ironclaw_memory", "crates/ironclaw_events", "crates/ironclaw_event_projections", "crates/ironclaw_event_streams", "crates/ironclaw_reborn_event_store", "crates/ironclaw_extensions", "crates/ironclaw_processes", "crates/ironclaw_dispatcher", "crates/ironclaw_scripts", "crates/ironclaw_process_sandbox", "crates/ironclaw_mcp", "crates/ironclaw_wasm", "crates/ironclaw_wasm_sandbox_core", "crates/ironclaw_wasm_limiter", "crates/ironclaw_capabilities", "crates/ironclaw_secrets", "crates/ironclaw_network", "crates/ironclaw_host_runtime", "crates/ironclaw_runtime_policy", "crates/ironclaw_authorization", "crates/ironclaw_run_state", "crates/ironclaw_approvals", "crates/ironclaw_resources", "crates/ironclaw_auth", "crates/ironclaw_trust", "crates/ironclaw_turns", "crates/ironclaw_agent_loop", "crates/ironclaw_threads", "crates/ironclaw_prompt_envelope", "crates/ironclaw_hooks", "crates/ironclaw_hooks_postgres", "crates/ironclaw_hooks_libsql", "crates/ironclaw_hooks_parity", "crates/ironclaw_loop_support", "crates/ironclaw_reborn", "crates/ironclaw_reborn_config", "crates/ironclaw_reborn_composition", "crates/ironclaw_first_party_extensions", "crates/ironclaw_reborn_cli", "crates/ironclaw_reborn_traces", "crates/ironclaw_reborn_webui_ingress", "crates/ironclaw_conversations", "crates/ironclaw_product_adapters", "crates/ironclaw_product_workflow", "crates/ironclaw_product_workflow_storage", "crates/ironclaw_product_adapter_registry", "crates/ironclaw_wasm_product_adapters", "crates/ironclaw_telegram_v2_adapter", "crates/ironclaw_slack_v2_adapter", "crates/ironclaw_outbound", "crates/ironclaw_triggers", "crates/ironclaw_architecture", "crates/ironclaw_safety", "crates/ironclaw_skills", "crates/ironclaw_oauth", "crates/ironclaw_llm", "crates/ironclaw_embeddings", "crates/ironclaw_engine", "crates/ironclaw_gateway", "crates/ironclaw_tui", "crates/ironclaw_webui_v2", "crates/ironclaw_webui_v2_static"]
exclude = [
"channels-src/discord",
"channels-src/feishu",
Expand Down
175 changes: 175 additions & 0 deletions crates/ironclaw_hooks/docs/successors/03-persistent-counter.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,178 @@ id's uniqueness contract and was removed in this revision.
Medium-Large. Schema migration + backend impls are mechanical; the
performance-conscious read/write batching is the design-discussion
piece.

---

## Landed shape (durable-backend split PRs 1–4, final)

The plan above shipped across a four-PR split. This section records the
**final landed contract** — it is the source of truth where it differs
from the speculative "Likely surface" above.

### Public async trait

`ironclaw_hooks::predicate_state::PredicateStateBackend` is `pub`,
`#[async_trait]`, `Send + Sync`. The argument order settled differently
from the draft (`now` precedes `event_id` is NOT how it landed):

```rust
#[async_trait]
pub trait PredicateStateBackend: Send + Sync {
async fn record_invocation(
&self,
key: &InvocationKey, // (hook_id, tenant_id, capability)
event_id: &PredicateEventId, // host-assigned; dedup is per-key, not global
now: DateTime<Utc>, // caller-supplied clock basis (see below)
window: Duration,
) -> Result<u32, PredicateBackendError>;

async fn record_value(
&self,
key: &ValueKey, // InvocationKey + field
event_id: &PredicateEventId,
now: DateTime<Utc>,
value: Decimal,
window: Duration,
) -> Result<Decimal, PredicateBackendError>;

fn evictions_observed(&self) -> u64;

async fn evict_older_than(&self, cutoff: DateTime<Utc>)
-> Result<u64, PredicateBackendError>;
}
```

The whole surface is `DateTime<Utc>` (no `Instant` shim survived) — the
in-memory backend was rewritten to take a caller-supplied wall clock so
all three backends share one clock basis.

### Caller-supplied clock basis (clock-skew semantics)

All three backends trim the sliding window against the **caller-supplied
`now`**, NOT a server clock or the earliest stored entry. The window
cutoff for a call is `now - window` (saturating `chrono` arithmetic), and
the trim is strict `<` so an entry whose timestamp equals the cutoff is
**retained**. A host whose clock is skewed ahead therefore trims earlier
entries via *its own* `now`. This is verified in the parity matrix's
clock-skew scenario (PR 4/4): two hosts passing different `DateTime<Utc>`
against the same key behave deterministically per the clock each call was
given. The host runtime is responsible for supplying a sane `now`
(`Utc::now()` in production).

### Three backends

| Backend | Crate | Durable? | Multi-host dedup? |
|---------|-------|----------|-------------------|
| `InMemoryPredicateStateBackend` | `ironclaw_hooks` | no (process-local) | **no** — dedup is process-local |
| `PostgresPredicateStateBackend` | `ironclaw_hooks_postgres` | yes | yes (SQL `UNIQUE`/`PRIMARY KEY`) |
| `LibSqlPredicateStateBackend` | `ironclaw_hooks_libsql` | yes | yes (SQL `UNIQUE`/`PRIMARY KEY`) |

Dedup is scoped to the counter **key** — `(tenant_id, hook_id,
capability[, field], event_id)` — not global on `event_id`, so two
predicate-backed hooks observing the same capability invocation (sharing
a `caller_event_id`) do not undercount each other.

### Fail-closed cap semantics

The per-key sliding window has a sample cap, `MAX_SAMPLES_PER_KEY`
(4 096). Filling a key to the cap with distinct in-window ids succeeds;
the next **distinct** in-window id returns
`PredicateBackendError::WindowOverflow` — **fail closed**, never a silent
oldest-sample eviction (that would weaken cap enforcement and break replay
refusal). A **replay** of an already-recorded in-window id at the cap is
still a dedup no-op (returns the unchanged count), so replay refusal
survives the cap boundary. The evaluator maps `WindowOverflow` to the
restrictive `on_exceeded` action (DENY / PauseApproval), never a silent
Allow. This is the uniform contract across all three backends
(`record_invocation_overflow_is_fail_closed`, #3929) and is cross-asserted
identical in the parity matrix.

### LRU eviction — intended divergence

- All three backends enforce a **per-tenant / per-scope** LRU quota,
`MAX_KEYS_PER_TENANT` (2 048): a noisy tenant flooding distinct scopes
evicts *its own* oldest scopes (LRU victim = least-recently-active key)
and `evictions_observed()` advances; a quiet co-tenant's scope is never
evicted. This dimension is cross-asserted identical (same victim, same
eviction count) in the parity matrix's `lru` script.
- The in-memory backend **additionally** enforces a global
`MAX_HISTORY_KEYS` (8 192) cap across all tenants, because a process has
a bounded heap. The durable backends do **not** have a global key
ceiling (a database is the source of truth and is reaped by
`evict_older_than`, not by a fixed key count). This is an **intended**
divergence; the parity matrix deliberately stays under the per-tenant
quota so it compares apples-to-apples and does not exercise the global
cap.

#### Reaper requirement for accurate quota counts

The per-tenant quota count is `COUNT(DISTINCT key_hash) WHERE scope_hash = ?`
over **all stored rows** for the tenant, including expired rows from keys
that have not yet been reaped by `evict_older_than`. The `record_*` path
only trims the *current* key's out-of-window rows (`WHERE key_hash = ?`); it
never touches other keys' expired rows. Consequently a tenant with many
short-window keys that have gone idle can appear to be at
`MAX_KEYS_PER_TENANT` and trigger LRU eviction even though its *active* key
count is much lower. The evicted keys are already expired, so this is benign
for gate correctness, but `evictions_observed()` advances unexpectedly and
the effective quota for short-window workloads appears higher than the active
key count. This is an undocumented behavioral divergence from the in-memory
backend, whose buckets are process-local and bounded by `MAX_HISTORY_KEYS`.

Deployers MUST schedule a periodic `evict_older_than` reaper (typically
pointed at the slowest configured window) to keep the durable quota count
aligned with the active key count. Without a reaper, the per-tenant quota
degrades gracefully (it over-counts expired keys) rather than failing open,
but the eviction counter will be noisy.

### Multi-host guarantees (proven in PR 4/4)

The cross-backend adversarial parity suite (`ironclaw_hooks_parity`)
proves the durable backends provide, and the in-memory backend explicitly
does not:

1. **N concurrent writers across 2+ hosts** against one database — no
count/sum desync, exactly-once counting (`BEGIN IMMEDIATE` / row-lock
serializes the read-modify-write).
2. **Cross-host replay** — an id recorded on host A is a dedup no-op when
replayed on host B against the same key (the SQL uniqueness constraint
enforces dedup across every host pointing at the same DB).
3. **LRU eviction race** — concurrent inserts past `MAX_KEYS_PER_TENANT`
hold the quota deterministically; `evictions_observed()` advances.
4. **Per-key cap under attacker flood** — fail-closed `WindowOverflow`,
bounded; the count never exceeds the cap.
5. **Clock-skew** — see the clock-basis section above.

### A3 deferral — CLOSED

Threat-model finding **A3 (multi-host replay bypass)** from #3635 — that
the in-memory backend's process-local dedup lets a replayed `event_id`
double-count against the logical cap when two hosts share a tenant — is
**closed** by the durable backends and verified by PR 4/4's
`cross_host_replay_exactly_once` parity scenario. The SQL
`PRIMARY KEY (tenant_id, hook_id, capability[, field], event_id)`
constraint makes a cross-host replay a no-op `ON CONFLICT DO NOTHING`, so
exactly-once counting holds across every host pointing at the same
database. The in-memory backend's process-local limitation remains
documented on `PredicateStateBackend` (it is correct for single-process
deployments); production multi-host deployments MUST use a durable
backend, which is now the source of truth.

### Test layout

- **Trait + in-memory + contract harness** (`ironclaw_hooks`, PR 1/4):
the `predicate_state::contract` module + `predicate_backend_contract_test!`
macro behind the `contract-tests` feature.
- **Postgres backend + contract + adversarial** (`ironclaw_hooks_postgres`,
PR 2/4): env-gated on `IRONCLAW_HOOKS_POSTGRES_URL` / `DATABASE_URL`.
- **libSQL backend + contract + adversarial** (`ironclaw_hooks_libsql`,
PR 3/4): embedded temp-file db, runs anywhere.
- **Cross-backend parity matrix + multi-host adversarial**
(`ironclaw_hooks_parity`, PR 4/4): one scripted sequence fed to all
three backends with cross-assertion of identical observation logs;
multi-host scenarios behind `--features integration`. The in-memory and
libSQL legs run unconditionally; the Postgres leg compiles under
`--features postgres` and runs only with a reachable DB URL (a
real-Postgres CI run is required to fully exercise the Postgres parity
leg before merge).
1 change: 1 addition & 0 deletions crates/ironclaw_hooks/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod middleware;
pub mod ordering;
pub mod points;
pub mod predicate;
pub mod predicate_hash;
pub mod predicate_state;
pub mod registrar;
pub mod registry;
Expand Down
Loading
Loading