Persistent HITL Approval Queue¶
Reference ADR: ADR-024 — Persistent HITL Approval Queue (JDBC) (
docs/adr/)
Since: 0.23.0
Module:agenor-adapters-persistence
Overview¶
The default InMemoryApprovalGate loses all pending approval requests when the JVM restarts.
For production HITL workflows with human-scale timeouts (minutes, hours, days), this is
unacceptable.
JdbcApprovalGate persists every approval request in the agenor_hitl_requests table.
It reuses the same agenor-adapters-persistence module used by the JDBC agent directory
(ADR-023), so no new Maven dependency is required if you already have the module on your
classpath.
Quick start¶
1. Add the dependency¶
<dependency>
<groupId>dev.agenor</groupId>
<artifactId>agenor-adapters-persistence</artifactId>
<version>0.33.0</version>
</dependency>
<!-- PostgreSQL driver (or H2 for testing) -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.5</version>
<scope>runtime</scope>
</dependency>
2. Wire the gate manually (without Spring Boot)¶
var cfg = new HikariConfig();
cfg.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
cfg.setUsername("agenor");
cfg.setPassword("secret");
var dataSource = new HikariDataSource(cfg);
// Run the Flyway migration (idempotent)
new HitlSchemaManager(dataSource, "classpath:db/migration/agenor-hitl").migrate();
// Create and initialise the gate
var gate = new JdbcApprovalGate(dataSource, cfg.getJdbcUrl());
gate.recoverExpired(); // mark stale rows EXPIRED on startup
// Wire into the runtime
var runtime = AgenorRuntime.builder()
.withDefaultConfig()
.approvalGate(gate)
.build();
3. Wire via Spring Boot¶
agenor:
hitl:
provider: jdbc
jdbc:
url: jdbc:postgresql://localhost:5432/mydb
username: agenor
password: ${DB_PASSWORD}
pool-size: 5 # default: 5
When agenor.hitl.provider=jdbc and agenor-adapters-persistence is on the classpath,
the auto-configuration creates the JdbcApprovalGate bean and wires it into AgenorRuntime
automatically.
If agenor.hitl.jdbc.url is not set, the auto-configuration falls back to
agenor.directory.jdbc.url (so both features can share a single connection pool config).
Schema¶
The schema is managed by Flyway from location classpath:db/migration/agenor-hitl.
CREATE TABLE agenor_hitl_requests (
request_id VARCHAR(255) NOT NULL,
agent_id VARCHAR(255) NOT NULL,
action VARCHAR(255) NOT NULL,
payload TEXT, -- serialised action payload
metadata TEXT, -- JSON key-value pairs
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
status VARCHAR(50) NOT NULL, -- see below
decision_type VARCHAR(50),
decision_data TEXT, -- rejection reason or modified payload
decided_at TIMESTAMP,
decided_by VARCHAR(255),
CONSTRAINT pk_agenor_hitl_requests PRIMARY KEY (request_id)
);
Status values¶
| Status | Meaning |
|---|---|
PENDING |
Awaiting a human decision |
APPROVED |
Approved; agent proceeded with original payload |
REJECTED |
Rejected; agent did not execute the action |
MODIFIED |
Approved with modified payload |
EXPIRED |
Timed out before a decision was submitted |
Recovery on restart¶
Call gate.recoverExpired() once after construction. It scans PENDING rows whose
expires_at has passed and marks them EXPIRED. This is safe to call on every startup
(idempotent).
var gate = new JdbcApprovalGate(dataSource, jdbcUrl);
gate.recoverExpired(); // must be called before handing the gate to agents
The Spring Boot auto-configuration calls this automatically.
Cross-node behaviour¶
getPendingRequests() queries the database — it returns requests from all JVMs, not just
the current node. This means a dashboard or operator UI on any node can see every pending
request and submit decisions.
Postgres LISTEN/NOTIFY (automatic)¶
When the JDBC URL contains postgresql, JdbcApprovalGate activates
PostgresNotificationListener. It opens a dedicated connection and listens on the
agenor_hitl channel. When another JVM submits a decision, it emits
NOTIFY agenor_hitl, '<requestId>:<decisionJson>'. The listener picks this up and completes
the local future on the receiving JVM.
This provides push-based cross-node propagation without polling.
Non-Postgres databases¶
For H2, MySQL, and other databases, cross-node propagation is polling-based. A node that
does not hold the local future will not receive push notifications, but the decision is
always stored in the DB and is visible via getPendingRequests().
Known constraints¶
Local-future constraint¶
The CompletableFuture returned by requestApproval() lives only in the JVM that called
it. If that JVM crashes before the decision arrives:
- The agent's future is lost — the agent will not receive the decision.
- The DB row remains with
PENDINGstatus until it expires. - Any operator can still submit a decision from another node (the row is visible via
getPendingRequests()), but the originating agent will not see it after restart.
This is a documented trade-off. Full event-sourced replay (agent re-registers a future for
an existing requestId after restart) is deferred to the Enterprise tier.
Configuration reference¶
| Property | Default | Description |
|---|---|---|
agenor.hitl.provider |
inmemory |
HITL backend: inmemory or jdbc |
agenor.hitl.jdbc.url |
— | JDBC URL (falls back to agenor.directory.jdbc.url) |
agenor.hitl.jdbc.username |
"" |
Database username |
agenor.hitl.jdbc.password |
"" |
Database password |
agenor.hitl.jdbc.pool-size |
5 |
HikariCP pool size |
Running the example¶
mvn exec:java -pl agenor-examples \
-Dexec.mainClass="dev.agenor.examples.hitl.PersistentHitlExample"
The example uses an in-process H2 database — no Docker or external database required. For a production scenario, replace the H2 URL with a real Postgres connection string.
See also¶
- HITL Checkpoint guide — core HITL concepts and
InMemoryApprovalGate - ADR-015 — Human-in-the-Loop Checkpoint design (
docs/adr/) - ADR-024 — Persistent HITL design decisions (
docs/adr/) - JDBC Directory — the sibling persistence feature