Decision records v7

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:

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 Postgres identity that made the call — current_user at the moment of the check.
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_deniedThe execution was blocked by Postgres — an ordinary object-ACL denial (a table, column, or function the resolved role has no grant on).
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 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;