Agent Trust & Assurance / Agent Authority ← The Life of a Uderia Agent

Architecture · P3 · the agent principal

Agent Authority

Status: implemented on main (2026-07-03). Delivers assessment requirement P3 (agent identity & permissions — only Salesforce/Microsoft were GREEN). Built on the Trust Membrane (Tier 5.2) foundation: the agent is a first-class governed principal. Design: LEAPFROG_ROADMAP.md Epic 3.3, TIER_5.2_TRUST_MEMBRANE.md §1.5.

Renamed 2026-07-04 — this document was formerly "Agent Identity Architecture" and the module was core/agent_identity.py. It was renamed to Agent Authority (core/agent_authority.py) because in Uderia an agent's identity is its Profile; this subsystem governs the agent's authority. See §0. The stored config key moved identityConfig → authorityConfig (the legacy key is still read). The internal class AgentPrincipal keeps its name — it is the correct IAM term.


0. What "Agent Identity" means in Uderia — read this first

"Agent identity" in the market is an umbrella term. In Uderia it is not a single feature — it is a composite realised across four subsystems on two orthogonal axes. Keeping them distinct is the whole point of this rename.

Concept The question it answers Where it lives in Uderia Axis
Agent identity Who is this agent? The Profile — its IFOC class, its LLM, its MCP "senses", its Memory, its Components. The @TAG profile is the agent. who
Agent assurance Is this agent verified / trustworthy? The Trust Membrane (Tier 5.2) — signed, compositional attestation. trust
Agent authoritythis document What may it do, on whose behalf, when it acts autonomously? core/agent_authority.py — a delegated, subset-of-owner scope. access
Agent audit What did it actually do? The Execution Provenance Chain — every autonomous action attributed to the agent. audit

Visual companion: The Uderia Agent — An Anatomy of Trust — the whole-agent diagram this taxonomy comes from (the five faculties inside the Trust Membrane, on the Grounding & Guardrails foundations). Its center — Agent Identity = who it is = its Profile — is exactly the "identity" row above.

Two rules follow, and they are load-bearing:

  1. Identity ≠ authority. The Profile says who the agent is; Agent Authority says what it is permitted to do. A trusted, well-defined agent (identity + assurance) can still be given a deliberately narrow authority. This is the platform's phrasing of charter #8, trust ≠ access.
  2. Authority is delegated and can only shrink. An agent has no authority of its own — when it acts autonomously it borrows a strict subset of its owner's permissions. The model can only remove; it can never grant or elevate.

If you came here looking for "the agent's identity," you want the Profile and, for how it's surfaced as an estate-wide map, the Platform Ontology. This document is specifically the access/authority face.

1. The idea

When an agent acts autonomously — a genie expert delegated by a coordinator, a scheduled task firing on a timer, a flow node, an inbound MCP ask_profile call — it is not a human issuing that request in the moment. Until now such actions ran under the owner's raw authority (the parent's auth_token, the task's user_uuid). P3 makes each autonomous action run as an Agent Principal derived from the owner whose authority is a strict subset of the owner's — and attributes every autonomous action to the agent in the audit trail.

Scope (updated Aug 2026). The original delivery was the attainable form of P3: a scoped principal + audit attribution + fail-closed enforcement over capabilities (tools, writes, connectors, budget). The fuller vision — an agent confined to a data slice ("cannot query outside its slice") — shipped with OBDA Phase C: the data surface (§2a below) makes the set of signed data products on an agent's surface its entire data authority, enforced through this same principal at the same chokepoints. See OBDA_ARCHITECTURE.md §6.

2. The model — pure, deterministic, subset-only

core/agent_authority.py is pure: the model + the enforcement decision. It never grants — a scope only ever removes the owner's authority (subset invariant by construction, so an agent with no config behaves exactly as today — byte-identical baseline, charter #15).

Piece Meaning
AgentScope The restriction. autonomous (may it act at all?) · deny_writes (block mutating tools) · denied_tools (deny-list) · allowed_connectors (None = all; else an allow-list) · max_cost_per_run (USD cap) · dataSurface (§2a — products / exclusive / advisory). All defaults = unrestricted.
AgentPrincipal owner_uuid + profile_id + scope. principal_id = agent:<profile_id>. actor() = the audit stamp attributing an action to the agent, not the user. (Kept its name — "principal" is the correct IAM term for an entity that holds authority.)
parse_scope(authorityConfig) Build a scope from a config dict (absent/empty → baseline).
derive_principal(profile, owner_uuid) The principal for a profile, read from its stored authorityConfigfalling back to the legacy identityConfig key for profiles saved before the rename (same mechanism as every other *Config block).
is_tool_allowed(scope, tool, …) The decision: (allowed, reason). Autonomous-off → deny all; denied-list → deny; connector not in allow-list → deny; write under deny_writes → deny. The write signal is the tool-name heuristic OR (when a sql arg is present) the classified statement (classify_sql_write) — so a write via a read-named tool is caught. TDA_* framework tools are never write-gated (they are the agent's own reporting/orchestration).
is_run_within_budget(scope, cost) Per-run USD cap. Enforced in-flight (check_budget_before_call, before each model call) on every autonomous engine AND post-hoc at end of turn — one predicate, two moments.

2a. The data surface (OBDA Phase C — E1–E5, per-agent tri-state)

authorityConfig.dataSurface = {products, exclusive, advisory}, read through ONE resolveragent/obda.py:profile_obda(profile) → (mode, products) — which is what the enrichment render/seed/event, both agent actions, the governed_execution planner pass, the lane scan and the champion filter all consult (the tri-state, operator model 2026-08-17):

Visibility vs enforcement — two seams, deliberately split. Planning visibility (which contracts the planner is shown, which champions are offered) follows the PROFILE configuration on every turn, interactive included; raw-SQL enforcement stays with the ambient PRINCIPAL (autonomous turns, plus the explicit enforce_interactive opt-in). An empty product list is Option A by construction — the profile editor never shows a mode that does not act, and saves an empty list as a cleared surface.

The one exception — the turn-scoped governed-SQL registry. query_product registers its compiled statement (register_governed_sql), so the only raw SQL a surface-only agent can execute is a statement the surface itself produced this turn; an edited statement forfeits the contract. The registry is a mutable set in a contextvar, created at set_current_principal and mutated by reference — a .set() in a child task is invisible to sibling tasks (contextvars propagate parent→child only, and Optimize runs parallel phases).

Recovery-shaped denial. A surface denial spells out the NEXT STEP (call query_product, execute the returned statement verbatim), keyed on the ONE SURFACE_DENIAL_MARKER; the blanket "do not retry; report this limitation" tail is reserved for categorical denials (denied tool, deny_writes) where nothing the agent does can help.

E5 — the empirical monitor. Free-formed SQL executed by a surface-only agent means the confinement leaked: a DANGER surface_breach incident (Assurance Inbox, BOUNDED station) whose summary says "a hole report, not agent misbehaviour". It fires only on the breach — advisory, additive, governed and interactive turns are all silent.

3. Enforcement — a per-turn security context, fail-closed at every chokepoint

The design problem: one gate misses most engines, and threading a principal through every call site is fragile. The solution is a per-turn security context via contextvars + layered enforcement at the execution chokepoints.

The scope is a declared choice, not a hardcoded constant (Aug 2026). An agent may opt its INTERACTIVE turns into enforcement too: authorityConfig.enforce_interactive: true (agent_authority.enforces_interactive, consulted at the same one gate — AUTONOMOUS_ORIGINS ∨ enforces_interactive(profile)). Why: an operator ticked Surface-only (structural confinement) and then watched the agent hand-write raw SQL in chat, because a principal was never derived interactively — a switch labelled structural confinement that silently does not apply to the mode the operator is using is the wrong default, and what the human "control" actually sees is prose, not SQL. Rules, each mutation-tested: it WIDENS the scope, never replaces it (an autonomous origin still derives a principal whatever the flag says); only an explicit True opts in (a migrated "true" string must never start denying tools); it governs the WHOLE authorityConfig, not the data surface alone (a partially applied principal would be a second, subtler notion of authority); turning it OFF is a pack-import downgrade (flag_paths). Editor: Agent Authority card → Apply to interactive turns too. Default absent ⇒ byte-identical baseline.

Chokepoint File Scoped by
MCP data-source tools (Optimize / genie-slave / FASTPATH) mcp_adapter/adapter.py:invoke_mcp_tool denied_tools, deny_writes (tool-name heuristic plus SQL-statement classification — a write smuggled through a read-named tool, base_readQuery(sql="DELETE …"), is denied by classifying the statement via tool_enforcement.classify_sql_writepreflight.catalog.sql_is_write (sqlglot, regex fallback), not just the name). Genuine TDA_* framework tools (final report, logging, orchestration) exempt — but component tools are not (enforced in the component-routing branch, see below).
MCP data-source tools (Conversation / ReAct) agent/tool_enforcement.py:wrap_langchain_mcp_tools Same decision as invoke_mcp_tool, at full parity. The ReAct engine executes MCP tools through the LangChain session, NOT invoke_mcp_tool, so each tool's coroutine is wrapped at ConversationAgentExecutor.__init__ (the one seam where the tools + session_id are both known) to reach the same fail-closed chokepoint. The wrapper runs the full battery the adapter runs: authority (incl. SQL-write classification), preflight SQL grounding, the in-flight loop guard (loop_guard_block), and the tool-result firewall (scan_result) — closing the gap where the ReAct engine, the one most prone to tool loops and whose output re-enters context, escaped the two newest controls.
Component tools components/manager.py:_run_component (LangChain path) + mcp_adapter/adapter.py component-routing branch (Optimize path) denied_tools by component id OR TDA_ tool name (enforce_tool(..., aliases=…)), so denied_tools:["scheduler"] and denied_tools:["TDA_Scheduler"] both work on both paths. Capabilities, not write-gated.
Platform connectors core/platform_connector_registry.py:invoke_connector_tool allowed_connectors allow-list. Connector output (web pages, browser, files, Google/Slack content — the canonical indirect-injection lane) is additionally screened by the tool-result firewall at the adapter's connector-routing return (invoke_mcp_tooltool_enforcement.scan_result); observe-only, never altered, labels-only inbox event.

The single shared decision module agent/tool_enforcement.py backs both MCP paths so the "fail-closed at every tool chokepoint" guarantee holds on every engine, including the ReAct engine that bypasses invoke_mcp_tool.

4. Persistence & UX

4a. Reporting a scope — presence is not constraint

Any surface that reports what an agent may do must resolve the scope, never inspect the raw config. authorityConfig being a non-empty dict says nothing about whether the agent is constrained: {autonomous: true, denied_tools: [], deny_writes: false, max_cost_per_run: null} is a fully-populated block that restricts nothing, and is byte-for-byte equivalent at runtime to no block at all. A presence check over that key once rendered the least-bounded agent in an estate as green under the headline "a strict subset of yours", while agents with no block — behaving identically — read as honest n/a. It rewarded touching authority in the direction of more autonomy.

Two helpers make the resolved answer the only reachable one:

Helper Answers
scope_for_profile(profile) The scope a profile actually runs under, honouring the legacy identityConfig fallback — one place, so a reader can never disagree with the enforcer.
describe_scope(scope) The constraints in operator-reading order. Empty not scope.is_restricted, asserted in CI, so the wording can never drift from the verdict.

GET /v1/profiles ships the result as a computed, read-only authorityScope {restricted, autonomous, constraints[]}; the Trust Circle's BOUNDED station and the posture matrix render that field rather than re-deriving it. The station reads pass only when every agent in scope genuinely restricts itself, and warn — with a count of how many would act with the owner's full authority if run unattended — otherwise.

The asymmetries is_restricted already encodes are exactly the ones a naive reader inverts: allowed_connectors: [] is deny-all, not "no restriction", and max_cost_per_run: 0 is a real (deny-all) cap, not an absent one.

5. Guarantees & proof

5a. The trust-budget circuit breaker writes here (Jul 2026)

The Behavioral Trust Score's opt-in circuit breaker (trust_behavior.breaker_mode = "freeze_autonomy" — see AGENT_OPS_EVAL_ARCHITECTURE.md §6c) acts through this model, not around it: when an agent's trailing conduct enters at_risk, the breaker sets the profile's authorityConfig.autonomous = False and stamps frozen_by_breaker: true + frozen_reason — suspending only the agent's unattended use via the exact same scope the Agent Authority card edits (interactive use untouched; visible and reversible by clearing the flag / re-enabling autonomous in the card). The stamps are plain authorityConfig keys, so export/import and the editor treat them like any other scope field. Implementation: eval/online.py:_run_trust_breaker (the freeze_autonomy branch calls config_manager.update_profile).

6. What's deferred (charter #19)

7. Files

File Role
src/trusted_data_agent/core/agent_authority.py The model + decision + contextvar context + enforce_tool + denied_tool_result (pure).
src/trusted_data_agent/agent/execution_service.py Derive/set the principal at the autonomous-turn boundary; reset + actor stamp on convergence; source=scheduler → origin.
src/trusted_data_agent/mcp_adapter/adapter.py MCP-tool chokepoint enforcement.
src/trusted_data_agent/components/manager.py Component-tool chokepoint enforcement.
src/trusted_data_agent/core/platform_connector_registry.py Connector chokepoint enforcement.
static/js/handlers/configurationHandler.js + templates/index.html The Agent Authority profile-editor card.
test/test_agent_authority.py 46 CI assertions incl. the async architectural proof and config back-compat.