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 movedidentityConfig → authorityConfig(the legacy key is still read). The internal classAgentPrincipalkeeps 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 authority ← this 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:
- 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.
- 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 authorityConfig — falling 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
resolver — agent/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):
- Option A — Disable OBDA (
mode="off"; the DEFAULT):dataSurfaceabsent,null, or an empty product list — and always the answer when theOBDA_ENABLEDmaster is off, becauseparse_scopedrops the surface at the parse seam (OBDA is additive, charter #15: master off ⇒ the platform behaves as if OBDA never shipped; gating each consumer separately is how the enrichment and the agent face were missed — found live, session d51cf073). No products are visible to the planner,query_product/list_productsrefuse (obda_not_enabled_for_agent), no lane stamp, no confinement. - Option B — Surface Full (
mode="exclusive",{products, exclusive: true}): surface-only (E1). When the agent runs autonomously, raw SQL is denied fail-closed at all three chokepoints below (thesqlargument is threaded throughis_tool_allowed); only signed-and-intact products are served (E2/E4, enforced in the product face). Counts as restricted forauthorityScope; downgrade-guarded in the config contract (a pack republish cannot silently relax it). Deliberately noTDA_exemption — canvas SQL is the same escape hatch.advisoryis B's observe-only development sub-toggle (E4): nothing denied, violations observed and scored. - Option C — Products + regular paths (
mode="mixed",{products, exclusive: false}): the listed products render as consumption contracts BESIDE the full KG paths — the LLM decides per question. Nothing is denied, but the list is still the grant:query_productrefuses products not on it (not_on_surface, naming the granted surface). That check is config-driven, so it holds on interactive turns that carry no ambient principal.
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.
-
contextvars.ContextVar(async-task-local, non-racy). The principal is set once at the autonomous-turn boundary and read at every tool chokepoint.contextvarspropagate into awaited coroutines and child tasks (asyncio copies the context at task creation), so concurrent sessions stay isolated and no signature plumbing is needed. An interactive user turn leaves it unset → baseline (no gating). (A CI test proves the propagation acrossawaitand the concurrent-turn isolation.) -
Set/reset at the turn boundary —
agent/execution_service.py:run_agent_execution. When_effective_origin ∈ AUTONOMOUS_ORIGINS(genie_parent·genie_child·flow·scheduler·mcp) it derives the principal from the active profile and sets the ambient context; on convergence it resets and stamps the turn payload with the actor (agent vs user). A scheduled task'ssource="scheduler"is mapped to theschedulerorigin so the principal applies. Genie slaves run as separate HTTP requests (their own task/context) → each gets its own principal, no cross-task leak.
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.
-
A SQL argument that is not a STRING is refused before any round trip —
tool_enforcement.nonstring_sql_block, ONE implementation on BOTH data-tool paths (the MCP adapter and the ReAct wrapper; the first version lived inline in the adapter alone and a cross-class audit found the ReAct path uncovered). The refusal's marker is inDEFINITIVE_TOOL_ERRORS, so no self-correction rounds fire: retrying cannot change what the upstream step produced.TDA_*exempt; never raises. -
Four chokepoints read the ambient principal and deny before executing (all-engine, because every tool call from any engine funnels through one of these):
| 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_write → preflight.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_tool → tool_enforcement.scan_result); observe-only, never altered, labels-only inbox event. |
The single shared decision module
agent/tool_enforcement.pybacks both MCP paths so the "fail-closed at every tool chokepoint" guarantee holds on every engine, including the ReAct engine that bypassesinvoke_mcp_tool.
- Fail-closed (charter #8):
enforce_tool()returns deny on any enforcement error; a denial returns a terminal, non-retryable result (denied_tool_result) stampedagent_deniedso it is auditable and the LLM incorporates it as a definitive outcome (no self-correction loop). No principal (interactive) → allowed (baseline).
4. Persistence & UX
- Storage: the profile's
authorityConfigblock (inuser_preferencesprofile JSON, same asguardrailConfig/groundingConfig— no schema change). The legacyidentityConfigkey is still read on load, so profiles saved before the rename keep their scope; the next save rewrites them underauthorityConfig. - Profile editor: an Agent Authority card (
configurationHandler.js+index.html) — process-focused: it frames the model ("when this agent runs autonomously it acts as a scoped identity derived from you — its identity is the Profile itself; leave untouched to inherit your full authority") then exposes allow-autonomous · read-only · denied-tools · allowed-connectors · per-run budget. Theme-aware, monochrome icon, collapsed by default.
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
- Byte-identical baseline — no
authorityConfig/identityConfig(or all-default) → unrestricted scope,is_restrictedFalse, nothing gated. - Subset-only — the scope model has no grant/elevate field; it can only remove.
- Backward-compatible — legacy
identityConfigis still honored;authorityConfigwins when both are present (asserted in CI). - Proof —
test/test_agent_authority.py(58 assertions: decision · subset invariant · reported-scope agrees with enforced scope (a present-but-empty block is not restricted; description non-empty ⇔ restricted;$0cap and empty connector allow-list read as real constraints) · config back-compat · contextvar set/get/reset · async propagation-across-awaits + concurrent-turn isolation · fail-closed enforce · denial shape) + a live integration proof through the realinvoke_mcp_toolchokepoint (owner/no-principal passes; the autonomous agent is deniedbase_writeQuerybefore touching MCP; a permitted read tool passes) + the real stored-profile → scope loop (config_manager load of@OPTIM→derive_principal→ denial).
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)
- Structural data-scoping (OBDA) — delivered, Aug 2026: the Agent Principal now carries the data surface (§2a), exactly as this row predicted — extending, never forking, this model. Kept here as the record of the prediction.
- Per-run budget is enforced in two places, on ONE predicate (
is_run_within_budget): - In-flight (
check_budget_before_call, the same predicate): asked BEFORE each model call is paid for, on every engine that spends autonomously. Routed throughPlanExecutor(Optimize/Ideate/Focus helpers), the ReAct engine (conversation_agentat eachon_chat_model_start, accumulatingself._run_cost_usd, halting with a named, graceful stop that keeps the partial answer +budget_stopped), and the genie coordinator (same seam). No ambient principal (interactive turn) → the check is a no-op → baseline. This closes the earlier gap where only thePlanExecutorpath checked in flight, so ReAct + genie got post-hoc flagging only — "an audit record wearing a control's name". - Post-hoc (
execution_service.run_agent_executionend-of-turn seam): a final check after the turn's cost is resolved, stampingauthority_budget_exceeded+ emitting anagent_budget_exceededaudit event so the caller (scheduler, coordinator) can react. - (
max_cost_per_run: 0is honored as a real deny-all cap, not "no cap".) - MCP origin is in
AUTONOMOUS_ORIGINSbut an inbound MCPask_profileis not yet stamped with a distinct origin at the execution layer (it currently arrives as a user REST call); genie/flow/scheduler are the live autonomous triggers today. - Frontier "agent identity" facets the platform does not yet claim (see §0): the agent authenticating to external systems as itself (its own workload credential), its own vaulted short-lived secrets distinct from the owner's, and formal OAuth token-exchange / on-behalf-of delegation downstream. Authority (this doc), assurance (Trust Membrane), and audit (provenance) are in place; those three are the open edge of the identity umbrella.
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. |