Decision records v7

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:

AttributeDescription
aidb.agent.decision.decision_idA UUID minted for this decision. Appears in a denial's error message.
aidb.agent.decision.action_statusallowed or denied.
aidb.agent.decision.interaction_pointAlways sql_exec — the only interaction point AIDB records against today.
aidb.agent.decision.principalThe calling user's Postgres identity, captured before switching to the purpose's role.
aidb.agent.decision.agent_roleThe role the purpose resolved to.
aidb.agent.decision.denial_reasonPresent only when action_status = denied.
db.query.textThe 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:

ValueMeaning
role_not_foundThe purpose's role doesn't exist — for example, it was dropped after the purpose was registered against it.
not_role_memberThe caller of agent_converse isn't a member of the resolved role.
engine_privilege_deniedPostgres blocked execution with a privilege error (42501), such as a missing object grant or a row-level security restriction.
escalation_blockedA 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_rejectedThe 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 42501 from an ordinary object-ACL check, the decision is recorded as engine_privilege_denied, not escalation_blocked.
  • If the earlier failure is a pre-execution rejection, the decision is recorded as pre_execution_rejected unless the rejected statement contains an identity-switch attempt at the top level, in which case the decision is recorded as escalation_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_relations and referenced_relations_total) describe the statement, and appear on allowed and denied decisions.

  • The denial attributes (denial_class, denial_funcname, denial_message, and db.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_classdenial_funcnameMeaning
object_privilegeaclcheck_error, aclcheck_error_colAn ACL or ownership check denied access.
sequence_privilegenextval_internal, currval_oid, lastval, do_setval, pg_sequence_parameters, pg_sequence_last_valueA sequence function denied access. pg_sequence_last_value raises on PostgreSQL 14 to 17 only.
rls_policyExecWithCheckOptionsA row failed a row-level security policy expression.
rls_settingcheck_enable_rlsrow_security is off while a policy applies. No policy refused anything.
raised_by_user_codeexec_stmt_raiseA PL/pgSQL or SPL RAISE statement. A function the agent called wrote the message.
rebuilt_by_plPLy_elog_implPL/Python rebuilt the error and the original site is lost.
escalationset_config_option, set_config_option_ext, set_config_with_handle, call_string_check_hookAn 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_operationCheckRestrictedOperation, PerformCursorOpenAIDB'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.
otherany other functionIncludes 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 is allowed with 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, EXECUTE in a PL/pgSQL function, and a call to aidb.run_sql_query inside the statement.

  • Relations passed to functions as arguments, for example table_to_xml and database_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_relations and referenced_relations_total) appear once PostgreSQL has resolved the names in the statement.

  • The denial attributes (denial_class, denial_funcname, denial_message, and db.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 gotOutcomeRelation attributesDenial attributes
Rejected before reaching PostgreSQLDenied by AIDBAbsentAbsent
Failed while PostgreSQL resolved namesDenied, for example the role lacks USAGE on a schema named in the statementAbsentPresent
Failed while PostgreSQL resolved namesAllowed, with a failed call, for example the relation does not existAbsentAbsent
Names resolvedAllowedPresentAbsent
Names resolvedAllowed, and the call failed for a reason other than a privilege checkPresentAbsent, including the SQLSTATE
Names resolvedDenied by PostgreSQLPresentPresent

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.