Every tool SQL execution made by an agent under a purpose-resolved role produces one decision record, whether it's allowed or denied. Tool calls made by agents with no purpose do not produce decision records.
Turning records on
Decision records are emitted as OpenTelemetry spans, and by default no exporter is active. Set aidb.otel_client to database before the first aidb.* call in the session:
SET aidb.otel_client = 'database';
The exporter choice is read once, at first use, and stays fixed for the rest of that backend — setting it after your first aidb.* call has no effect for that session. To make it stick across sessions:
ALTER DATABASE your_database SET aidb.otel_client = 'database';
Only database writes records into aidb_otel.traces, where you can query them in SQL. The other exporter choices — noop (the default, discards everything), stdout, and log — send the same spans elsewhere or nowhere.
The same aidb.otel_client setting also controls the other OpenTelemetry spans AIDB emits for agent and tool activity. This page covers only purpose decision records.
Reading records
Reading aidb_otel.traces requires the aidb_governance role:
GRANT aidb_governance TO your_admin;
aidb_users has USAGE on the aidb_otel schema (so it can write telemetry) but no SELECT on any table in it.
What's in a record
Each record is a span named purpose_decision, carrying these attributes:
| Attribute | Description |
|---|---|
aidb.agent.decision.decision_id | A UUID minted for this decision. Appears in a denial's error message. |
aidb.agent.decision.action_status | allowed or denied. |
aidb.agent.decision.interaction_point | Always sql_exec — the only interaction point AIDB records against today. |
aidb.agent.decision.principal | The Postgres identity that made the call — current_user at the moment of the check. |
aidb.agent.decision.agent_role | The role the purpose resolved to. |
aidb.agent.decision.denial_reason | Present only when action_status = denied. |
db.query.text | The SQL text the agent's tool call submitted, present whenever that text is known — including on a denial recorded before the statement reached Postgres. See Seeing the attempted SQL. |
denial_reason is one of:
| Value | Meaning |
|---|---|
role_not_found | The purpose's role doesn't exist — for example, it was dropped after the purpose was registered against it. |
not_role_member | The caller of agent_converse isn't a member of the resolved role. |
engine_privilege_denied | The execution was blocked by Postgres — an ordinary object-ACL denial (a table, column, or function the resolved role has no grant on). |
escalation_blocked | A privilege-escalation attempt was blocked — either by the engine after the role was assumed (RESET ROLE, SET ROLE, SET SESSION AUTHORIZATION, or any other privilege-gated GUC without a pg_parameter_acl grant), or pre-execution, before the statement ever reached Postgres (a bare RESET ROLE, RESET ALL, or DISCARD ALL tool body). |
pre_execution_rejected | The SQL provided to the tool failed pre-execution validation. |
The denial reason is set based on whichever error actually stopped the statement, not everything the statement attempted. Postgres executes a statement single-pass and halts at the first error it raises, so an escalation attempt nested inside a function call, subquery, or DO block is never reached if something else in the same statement fails first:
- If the earlier failure is itself SQLSTATE
42501from an ordinary object-ACL check, the decision is recorded asengine_privilege_denied, notescalation_blocked. - If the earlier failure is a pre-execution rejection, the decision is recorded as
pre_execution_rejectedunless the rejected statement contains an identity-switch attempt at the top level, in which case the decision is recorded asescalation_blocked.
In both cases db.query.text still captures the full SQL text regardless, so the query itself can be inspected to verify the intent of the agent's query.
Finding the record from a denial
A denied caller's error carries the decision id inline for cross-referencing against governance logs:
Tool error: current user is not a member of role 'sales_tickets_rw'; refusing to switch [decision_id=3fa85f64-5717-4562-b3fc-2c963f66afa6]
Seeing the attempted SQL
A purpose_decision record carries the attempted SQL directly, in its own db.query.text attribute — including on a denial rejected before execution, such as an escalation_blocked or pre_execution_rejected case. That text is truncated from the end at 5 KB.
By default, bound parameter values are not substituted into that text — placeholders ($1, $2, ...) stay as placeholders. Set aidb.otel_capture_query_parameters = on to have the actual bound values substituted in instead:
SET aidb.otel_capture_query_parameters = on;
Querying aidb_otel.traces
aidb_otel.traces stores raw OTLP JSON, one row per exported span, in (id, recorded_at, payload_json). Drill into the span's name and attributes like this:
SELECT payload_json->'resourceSpans'->0->'scopeSpans'->0->'spans'->0->>'name' AS span_name, ( SELECT attr->'value'->>'stringValue' FROM jsonb_array_elements(payload_json->'resourceSpans'->0->'scopeSpans'->0->'spans'->0->'attributes') attr WHERE attr->>'key' = 'aidb.agent.decision.denial_reason' ) AS denial_reason FROM aidb_otel.traces WHERE payload_json->'resourceSpans'->0->'scopeSpans'->0->'spans'->0->>'name' = 'purpose_decision' ORDER BY id DESC LIMIT 10;