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. See Relation and denial attributes.
Older records don't gain these fields after an upgrade. For field names, limits, and availability, see Decision attributes.
Relation and denial attributes
Decision records carry two groups of attributes that help explain a decision. Each attribute is listed in Decision attributes.
The relation attributes (
referenced_relationsandreferenced_relations_total) describe the statement, and appear on allowed and denied decisions.The denial attributes (
denial_class,denial_funcname,denial_message, anddb.response.status_code) describe the PostgreSQL error, and appear only on a denial that PostgreSQL raises.
A denial that AIDB itself rejects before the statement reaches PostgreSQL (role_not_found, not_role_member, pre_execution_rejected, and an escalation_blocked found by AIDB's validator) carries neither group, because PostgreSQL never analyzed or executed the statement.
An allowed decision carries the relation attributes and no denial attributes, including when the call failed for a reason other than a privilege check.
Referenced relations
referenced_relations lists the relations the statement named, after PostgreSQL resolved the names for the agent's role and search_path. Each entry is a schema-qualified name, quoted where SQL requires it. Relations are listed once even if the statement uses them several times, and the order is not part of the contract.
The list includes tables, partitioned tables, views, materialized views, foreign tables, and sequences read as relations. It includes relations in subqueries, UNION branches, CTEs, EXPLAIN, and expressions such as WHERE x IN (SELECT ...). Temporary relations appear under their pg_temp_N schema. Relations in pg_catalog and information_schema are listed like any other relation.
Important
The list records only the relations named in the statement. It is not a list of everything the agent read during the statement, and it does not say which relation PostgreSQL denied access to. To see what was refused, read denial_message.
An empty array means the statement was analyzed and named no relations, for example SELECT 1. If the attribute is missing, no analysis exists for the statement, which is different from naming no relations. See Limitations.
On EDB Postgres Advanced Server, a synonym is recorded as its target table. Oracle compatibility objects appear under the sys schema, for example sys.dual.
The array is cut by whole names so that it stays valid JSON and fits in 5 KB. When the array has fewer entries than referenced_relations_total, the array is partial.
Limiting what an agent can submit
Use custom SQL tools instead of run_sql_query. A custom SQL tool runs a stored query with typed parameters, and the role needs no privileges on functions in the aidb schema.
Denial class
denial_class is the category of a denial, derived from the name of the PostgreSQL C function that raised the error. denial_funcname holds the function name itself.
denial_class | denial_funcname | Meaning |
|---|---|---|
object_privilege | aclcheck_error, aclcheck_error_col | An ACL or ownership check denied access. |
sequence_privilege | nextval_internal, currval_oid, lastval, do_setval, pg_sequence_parameters, pg_sequence_last_value | A sequence function denied access. pg_sequence_last_value raises on PostgreSQL 14 to 17 only. |
rls_policy | ExecWithCheckOptions | A row failed a row-level security policy expression. |
rls_setting | check_enable_rls | row_security is off while a policy applies. No policy refused anything. |
raised_by_user_code | exec_stmt_raise | A PL/pgSQL or SPL RAISE statement. A function the agent called wrote the message. |
rebuilt_by_pl | PLy_elog_impl | PL/Python rebuilt the error and the original site is lost. |
escalation | set_config_option, set_config_option_ext, set_config_with_handle, call_string_check_hook | An attempt to change a setting that the agent's role may not change. The decision's denial_reason is escalation_blocked for the set_config_* functions. A denial raised by call_string_check_hook keeps engine_privilege_denied. |
restricted_operation | CheckRestrictedOperation, PerformCursorOpen | AIDB's restricted execution context refused a command such as PREPARE, LISTEN, DISCARD, or DECLARE ... WITH HOLD. The decision's denial_reason stays engine_privilege_denied. |
other | any other function | Includes large-object, COPY, backend-signalling and DDL-only checks. |
A function name that is not in the table has class other. If PostgreSQL reports no function name, denial_funcname is absent and the class is other.
Note
The function that raises a set_config_option denial differs by version: set_config_option itself on PostgreSQL 14, set_config_option_ext on PostgreSQL 15 and 16, and set_config_with_handle on PostgreSQL 17 and 18. The class is the same.
Error message and SQLSTATE
denial_message is the primary message of the PostgreSQL error, as PostgreSQL wrote it, in the server's lc_messages language. It is cut from the end at 5 KB, like db.query.text. It does not include the [decision_id=...] marker that the caller sees. It names at most one object, without a schema.
db.response.status_code is 42501 for every denial that carries the denial attributes, including a 42501 that a user function itself raised.
When denial_class is raised_by_user_code, a function the agent called wrote the message, and denial_funcname is not a PostgreSQL privilege check.
Warning
denial_message is PostgreSQL's text and can contain row data if a user function or PostgreSQL extension includes row values in the message. Anyone who can read aidb_otel.traces can read it, such as a holder of the aidb_governance role or a read-only viewer role. The agent also receives the same message content.
Limitations
The relation and denial attributes describe what PostgreSQL reports. They don't always give a complete picture of what happened, for the reasons described in this section.
Permission errors in functions that are not recorded as denials
In the following two cases, the agent's statement can be refused access to an object but the decision record does not show a denial:
If a function catches the denial and continues, the error never reaches AIDB, so the decision is recorded as
allowed.PL/Perl and PL/Tcl convert a denial to SQLSTATE
38000. The call fails and the error does reach AIDB, but AIDB no longer sees a privilege error (42501). The decision isallowedwith a failed call.
Queried objects that are missing from referenced_relations
referenced_relations lists the relations the top-level statement named. It is not a record of everything the statement read, and it does not say which relation PostgreSQL refused. The following objects are never included in referenced_relations:
Relations used inside functions that the statement calls.
Relations read by SQL that is built and run while the statement executes, for example
query_to_xml('SELECT * FROM t', true, false, ''),ts_stat,ts_rewrite,EXECUTEin a PL/pgSQL function, and a call toaidb.run_sql_queryinside the statement.Relations passed to functions as arguments, for example
table_to_xmlanddatabase_to_xml.Relations behind a view. The list only includes the view named in the SQL provided by the agent.
Partitions and inheritance children. Only the parent the statement named appears.
Relations in row-level security policy expressions, rule actions, trigger bodies, and foreign-key targets.
Sequences used through
nextval,currval, and similar functions.Functions that the statement calls.
A governed tool call records one decision, so SQL that runs nested inside the statement records no decision of its own. db.query.text shows that SQL only if the statement contains it literally and the text fits in 5 KB.
The list is also partial when it is cut to 5 KB. See Referenced relations.
Missing attributes
Which attributes a decision record carries depends on how far the statement got.
The relation attributes (
referenced_relationsandreferenced_relations_total) appear once PostgreSQL has resolved the names in the statement.The denial attributes (
denial_class,denial_funcname,denial_message, anddb.response.status_code) appear when PostgreSQL raised a privilege error.
If referenced_relations is an empty array, the statement named no relations. If the attribute is missing, AIDB has no relation information for that decision.
The following table shows which attributes each outcome carries, in the order the statement moves through AIDB and PostgreSQL.
| How far the statement got | Outcome | Relation attributes | Denial attributes |
|---|---|---|---|
| Rejected before reaching PostgreSQL | Denied by AIDB | Absent | Absent |
| Failed while PostgreSQL resolved names | Denied, for example the role lacks USAGE on a schema named in the statement | Absent | Present |
| Failed while PostgreSQL resolved names | Allowed, with a failed call, for example the relation does not exist | Absent | Absent |
| Names resolved | Allowed | Present | Absent |
| Names resolved | Allowed, and the call failed for a reason other than a privilege check | Present | Absent, including the SQLSTATE |
| Names resolved | Denied by PostgreSQL | Present | Present |
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 what happened during a denied call
AIDB reports what happens during an operation as log records, not span events, so the events array of a span is empty. To see the log records for denied decisions, join aidb_otel.logs to aidb_otel.traces on trace_id and span_id:
SELECT l.recorded_at, l.payload_json FROM aidb_otel.logs l JOIN aidb_otel.traces t USING (trace_id, span_id) WHERE t.decision_status = 'denied' AND t.source = 'internal' ORDER BY l.recorded_at;
For why AIDB uses logs instead of span events, see Schemas.
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, recorded SQL keeps bound parameter placeholders ($1, $2, ...) without their values. Set aidb.otel_capture_query_parameters = on to include bound values in a comment before the SQL:
SET aidb.otel_capture_query_parameters = on;
This setting doesn't redact literal values already written in SQL or sensitive values in database error messages. Those can still be recorded when parameter capture is off.
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.