With telemetry enabled, AIDB records whether an agent's purpose-scoped SQL action was allowed or denied. Tool calls made by agents with no purpose don't 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 or an explicit grant:
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.
For EDB Agent Governance, use a dedicated read-only role with explicit grants instead. The aidb_governance role also allows purpose management and retention cleanup, which the dashboard doesn't need. See Creating the viewer role.
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 calling user's Postgres identity, captured before switching to the purpose's role. |
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 | Postgres blocked execution with a privilege error (42501), such as a missing object grant or a row-level security restriction. |
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, use the recorded SQL in db.query.text to inspect what the agent attempted.
Version-specific decision details
AIDB 7.8.0 and later record the effective purpose used for the decision. This can differ from the agent's configured purpose when a delegated agent runs under its caller's purpose.
AIDB 7.9.0 adds the relations referenced by the SQL statement and more detail about PostgreSQL privilege errors. These details help explain a decision:
- Referenced relations identify the tables, views, or other relations named by the statement. They don't identify which object caused a denial or list every object accessed indirectly.
- The denial class groups PostgreSQL privilege errors, including object permissions and row-level security. The database message provides the error text.
- These error details are recorded for PostgreSQL privilege errors (
42501). Decisions rejected before SQL reaches the database don't have them.
Older records don't gain these fields after an upgrade. For field names, limits, and availability, see Decision attributes.
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 one row per span, with a JSON payload, a protobuf payload, or both. The following query uses payload_json and therefore requires aidb.otel_client_storage_format = 'json' (the default) or 'both' when the record is written. See Storage format.
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;
Starting in AIDB 7.9.0, add source = 'internal' to decision queries to exclude decisions reported by external telemetry senders. Older rows retained during upgrade are also labeled internal, but their original source wasn't recorded. See Common columns.