Agent Trust & Assurance / OBDA-MODEL & OBDA-ACT ← The Life of a Uderia Agent

Architecture · OBDA-MODEL · OBDA-ACT · PROP-008

OBDA-MODEL & OBDA-ACT

Status: built (Oct 2026) on branch feature/act-business-operating-model, increments A1–A5, B1–B4d, C1–C3; rehearsed end to end as Act II of the policing demo. This is the live contract. The design record — the options considered and rejected, the rollout plan, the decisions taken on the open questions — stays in PROP-008; where the two differ, this document describes what was built.

Related: ONTOLOGY_ARCHITECTURE.md (the layers OBDA-MODEL writes into), OBDA_ARCHITECTURE.md (the read-side twin: signed data products), TRUST_MEMBRANE_ARCHITECTURE.md (the two new trust kinds), AGENT_AUTHORITY_ARCHITECTURE.md (grants and the action-only denial), INDUSTRY_MODEL_PDM_DEPLOYMENT_PLAN.md (the deployment backends OBDA-MODEL reuses), restAPI.md (the endpoints).


0. What OBDA-MODEL and OBDA-ACT are

Until OBDA-MODEL and OBDA-ACT the platform could only read (OBDA-READ): an agent answered from the ontology and the warehouse, and anything that had to change — a concept missing from the model, a record that had to be written — went to a person outside the platform. OBDA-MODEL and OBDA-ACT close that loop on two planes that share one road (propose → validate → land → publish → sign → use):

Plane What changes Who decides What guarantees it
OBDA-MODEL the ontology and the warehouse schema — a new entity, a new attribute, a performance structure a person publishes; an agent may only draft the extension's own signature, the design standard read back from the warehouse's dictionary
OBDA-ACT rows — record a lead, close a ticket, set a tier the action type's posture; a person confirms or approves the action's earned signature, one transaction with checks, a sealed receipt

The family: OBDA-MODEL defines and extends the model, OBDA-READ reads through it (OBDA_ARCHITECTURE.md: signed data products), OBDA-ACT writes through it — one ontology, one signature discipline, three directions. (Before Oct 2026 the two planes here were called Model ACT and Instance ACT, and OBDA-READ plain OBDA; earlier changelog entries, proposals and assessments keep the names they were written with.)

Three properties hold everywhere, and every section below is how one of them is kept:

  1. Nothing writes outside a signed contract. Model changes land only through the delta deployer from a published extension; row changes only through a signed, granted action type. A raw INSERT/UPDATE from an agent is refused at the SQL gate.
  2. The base is never touched. An extension, an action type, a landing on another warehouse and a tuning artefact are stacked on the graph: none moves the base ontology's seal, none counts as a base mutation (§2.5).
  3. Every act is accountable. Each performed write has a receipt naming who, which contract, which statement, how many rows and through which door; every act through any door lands in one append-only log the audit surfaces read (§3.7).

0a. OBDA-ACT and OBDA-READ — the write half and the read half

OBDA-READ governs what an agent may read: a signed data product is the only shape of question it may ask of the warehouse. OBDA-ACT governs what it may write: a signed action type is the only shape of change it may make. The two were built on the same pattern, and where they share a mechanism it is one implementation, not two copies:

Concern OBDA-READ — reading OBDA-ACT — writing
The contract a data product manifest (dbdeploy/product_model.py) an action type manifest (dbdeploy/action_model.py)
The gate V1–V7, with live probes (dbdeploy/product_validation.py) A1–A6, with live probes (dbdeploy/action_validation.py)
The earned signature trust kind data_product, signed over {manifest_hash, validation_report_hash} trust kind action_type, the same shape — the proof is inside the signature in both
Bottom-up rule a product needs a signed ontology (unsigned_ingredient) an action needs a signed ontology (unsigned_ingredient)
On the graph data_product node + member edges action_type node + acts_on edge
Never moves the ontology's seal both are stacked contracts — one rule, kgstore.types.is_stacked_contract, read by trust composition, governance and extension signing
Who executes the platform compiles, the agent executes through its governed data tool, so the read keeps the SQL gate the platform executes (dbdeploy/action_runner.py): a write needs pre-checks, statement, post-checks and ledger in one transaction, which a data tool cannot hold
Agent Authority dataSurface with exclusive → raw SQL refused as surface-only actions with actions_exclusive → raw writes refused as action-only — the same chokepoint (agent_authority.is_tool_allowed)
The lane scanner (agent/obda.py) governed · free-formed · confined the acted lane: acted_count / refused_count in the same obda stamp (only where the agent's OBDA-READ plane is on; OBDA-ACT's own actions stamp and act log do not depend on it)
Receipts obda.consumption steps sealed in the turn's Ed25519 chain action.performed steps sealed in the same chain; an act outside a sealed turn carries a receipt signed on its own
Faces agent · REST (/v1/data-products/*) · MCP the same three, plus flow · scheduler · dashboard tile · Assurance Inbox
Eval governed_data_ratio, surface_violation, the surface-escape probe action_safety, the write-escape probe
Availability the obda_read component (global · per user · per agent) — core/obda_planes the action component, the same chain (OBDA-MODEL: obda_model)

An agent can be confined on both sides at once — surface-only for reads and action-only for writes — and the Trust Circle's BOUNDED station reads both.

Naming: the eval model also has a turn stage called ACT (Plan · Retrieve · Act · Compose · Deliver — e.g. "surface_violation (BOUNDED, ACT moment)"). That is the moment in a turn where the agent uses tools; it predates PROP-008 and is unrelated to OBDA-ACT.


1. Switches and the baseline

Switch Where Off means
Availability — the components (no master switch since Oct 2026) OBDA-MODEL is the builtin component obda_model (tool TDA_ModelProposal), OBDA-ACT the component action (tool TDA_Action): each plane exists for a deployment, a user and an agent the way every component does — Administration → Components (global gate, per-user override), the agent's Capabilities toggle, and relevance (a graph bound). ONE reader: core/obda_planes.plane_available / plane_enabled. The former ACT_ENABLED (TDA_ACT) switch was migrated once at startup (auth/database._migrate_obda_masters: a persisted off disabled both components; a model-authoring grant switched obda_model on for the agent, an action grant action) the plane is absent: no authoring or action text in any prompt (the [[gate:act]] blocks open per agent on the plane), the grants are dropped at parse (planes=), TDA_Action / TDA_ModelProposal are not offered, the REST faces answer 403 obda_act_off / obda_model_off naming the admin pane, the scope reads pre-OBDA
The OBDA policy — "contracts only" per plane (APP_CONFIG.OBDA_POLICY, Administration → App Config → Trust → OBDA policy; off · observe · enforce) applied once at agent_authority.derive_principal into every agent's scope, on every turn; observe records policy_observed events instead of refusing (see OBDA_ARCHITECTURE §0) model enforce: an agent's DIRECT graph edits (add_entity · add_relationship · declare_concept · restore) are refused — the model changes through a proposal a person publishes; act enforce: every raw write and every command tool is refused — rows change only through the agent's granted signed actions (none granted ⇒ none at all)
modelAuthoring grant authorityConfig.modelAuthoring on a profile the agent cannot call propose_entity / propose_attribute
Action grant authorityConfig.actions.types (list of action names) the agent cannot perform that action — not_granted, whatever the face
actions_exclusive authorityConfig.actions when set, every raw write from the agent is refused as action-only (ACTION_DENIAL_MARKER), naming the granted actions
action component componentConfig.action (and the platform's component governance) — one face only: switching the component off removes TDA_Action from conversations and leaves authoring, the grants, REST/MCP, flow, scheduler and dashboard acts as they are; availability is the component chain's — Administration → Components (global gate, per-user override) and the agent's Capabilities toggle, read by core/obda_planes.plane_enabled; there is no master switch (Oct 2026). is_profile_relevant offers the tool only where it can act: a knowledge graph bound to the agent enabled_for: [] — no profile has TDA_Action until it is switched on; packs keep it off (agent_packs/common/component_config.py:ACTION_COMPONENTS)

A grant is access, not trust: changing it moves no signature. The posture of an action (§3.5) is part of the action's signed contract, not of the agent.

1a. Access in the warehouse — stated and checked before every act

OBDA-MODEL and OBDA-ACT act on a warehouse through two logins (§3.2): the model login lands tables and creates ledgers; the write login performs an action's rows under the action's role. What each needs is stated in one place (dbdeploy/access.py: landing_requirements, ledger_requirements, action_requirements), judged against the dictionary (TeradataDeploymentBackend.read_access: DBC.AllRightsV, DBC.AllRoleRightsV, DBC.RoleMembersV; a database-level grant covers its tables; an unreadable dictionary holds nothing — never a pass) and shown before anything runs — each requirement held or missing, why, and the exact GRANT for what is missing (judge). Govern's preview carries it (deploy.access) and Land is refused while it is missing; the Actions Designer shows it in the Define form (re-read as the connection, columns or role change) and in each action's details (POST /v1/actions/access). Gate: test/test_act_access.py.

Act Who Must hold Why When it is checked
OBDA-MODEL — land a new sibling database UDERIA_EXT_<root> the model connection's login CREATE DATABASE on the parent (the login's own space by default — a login is not granted this on itself automatically) the landing creates the database; the creator then holds every right inside it Govern Preview; Land is disabled and refused (409 access_missing, nothing changed) while missing
OBDA-MODEL — land a new table in an existing database the model connection's login CREATE TABLE on that database creates the table as above
OBDA-MODEL — land a new column on an existing table the model connection's login DROP TABLE on that table Teradata's ALTER TABLE needs it as above
OBDA-MODEL — land a reference to a base table the model connection's login REFERENCES on the base table the soft-RI FOREIGN KEY … REFERENCES WITH NO CHECK OPTION as above
OBDA-MODEL — the action ledger the model connection's login CREATE TABLE on the target's database the ledger <db>.UDERIA_ACT_<table> is created once, at the first validation the Designer's Access in the warehouse (Define form and details); a refusal is named at validation
OBDA-ACT — perform the write connection's login the action's role (GRANT <role> TO <login>) every write session acts under the role (SET ROLE) Define form + details before anything runs; validation's probes run under the role; perform refuses role_not_held
OBDA-ACT — perform the action's role exactly the operation's rights on the target — insert INSERT, SELECT · update UPDATE, SELECT · upsert INSERT, UPDATE, SELECT — and INSERT on the ledger; nothing wider (A6 refuses a wider role) the write, its pre/post checks and the before-image; the ledger entry Define form + details; A6 at validation

The statements, with the platform's names filled in, are what the surfaces print; the templates:

-- OBDA-MODEL (the model connection's login, e.g. uderia_ext_admin)
GRANT CREATE DATABASE ON <login> TO <login>;          -- a new sibling database under the login's own space
GRANT CREATE TABLE ON <landing_db> TO <login>;          -- a new table in an existing database
GRANT DROP TABLE ON <landing_db>.<table> TO <login>;    -- a new column on an existing table
GRANT REFERENCES ON <base_db>.<base_table> TO <login>;  -- a reference to a base table
-- OBDA-ACT (the write connection's login, e.g. policing_investigator, and the action's role)
GRANT <role> TO <write_login>;
GRANT INSERT, SELECT ON <db>.<table> TO <role>;          -- insert   (update: UPDATE, SELECT · upsert: INSERT, UPDATE, SELECT)
GRANT INSERT ON <db>.UDERIA_ACT_<table> TO <role>;       -- the ledger, append-only

2. OBDA-MODEL — the graph authors, the warehouse materialises

2.1 The authored layer and extensions

A proposed concept is written to the graph on the authored layer (properties.layer = "authored"), beside the logical and physical layers, and belongs to an extension keyed extension_id = ext-<user uuid>. Its lifecycle is draft → validated → deployed → published, and kgstore.types.advance_status only ever moves it forward (a published node that lands on a second warehouse stays published). A landable node (an entity or an attribute) is published only after it has landed — kgstore.types.awaits_landing is the one rule: publish_extension refuses not_landed (naming the nodes, stamping nothing) and Govern counts it per extension (unlanded), saying "land … first, then publish" instead of offering Publish; published early, the released model would carry a column the warehouse does not have. A draft attribute on a published entity is its own row (an attribute folds into its owner only at the same stage) and is outside the extension's signed content, its edge included.

2.2 The normalisation advisor

agent/kg_constructors/normalisation.py decides, deterministically, whether a proposed concept is an entity or an attribute, from probes over the rows (when there are any): functional dependency (must), multiplicity, hidden identity, shared reference, independent lifecycle, and closed enumeration (never an entity). A declared choice stands unless a rule fires; with no rows the verdict is provisional and says so; with every probe failed it is unknown. An attribute's type is inferred from its name when not declared, and the preview says it was inferred.

The verdict is the body of the agent's answer row with one act — Draft it — offered only when a draft is owed.

2.2a The model gap — decided and delivered in code

Before a concept can be proposed, someone has to notice the model lacks it. A question whose subject nothing in the graph matches ("which leads are open?" asked of a model with no Lead) is a model gap, and both halves of it belong to code:

Seam Owner What it does
Decide the knowledge-graph component (handler.py:_model_gap) the question's subject from its own sentence shape (_question_subject), unmatched in the whole graph by name, label, description, declared value or meaning, in its own and its singular form. Stamps model_gap = {term, singular, may_author} on the turn's kg_enrichment_event. may_author is the same gate propose_entity applies (_act_refusal), so the offer and the act cannot disagree. Only while OBDA-MODEL · OBDA-ACT is on.
Deliver agent/model_gap.py, called by every engine before the turn persists (executor._apply_model_gap for Optimize · Ideate · Ideate+tools · Focus, incl. Focus's nothing-retrieved answer; Coordinate calls the module on its coordinator's own enrichment, before its history write) one platform note under the answer (answer_surface.note("model_gap"), kind word Not in the model), placed above the evidence rail. When the agent may author (and the graph is known) it carries the act Propose a ‹Concept›, which opens a small form in place (ui.js _onGapProposeAct): the name, what it records in plain words, one per line, what it belongs to (the model's own entities, read by the deciding seam from the graph, bounded, carried on the act — metadata, never prompt text) and an optional definition. Opening it asks for one suggestion (POST /v1/knowledge-graph/propose/suggest, agent/proposal_suggest.py): one small call on the answering agent's own model for what such a concept records and which entity it belongs to, booked to the note's turn; it fills only what the person has not written, and says whose suggestion it is. Check the proposal posts it to POST /v1/knowledge-graph/propose — the same handler path a turn runs, with no model in between — and the verdict row with its Draft it takes the form's place. When the agent may not author, the note says extending the model needs an agent allowed to author it, and offers no button. The text form of the answer carries the same line.
Model asked only for the prose (and, in a conversation, for propose_entity when a person asks it there) the enrichment tells it the subject is not in the model, never to present another entity in its place, that the offer is the platform's (not to repeat it), and to call propose_entity only when asked.

Business terms in, the model's terms out (agent/kg_constructors/business_terms.py, run by propose_entity for every face — a conversation, the form, REST). The person who extends a model knows the business, not the warehouse, so code supplies everything physical: each attribute becomes an identifier in the model's own naming style (assigned_investigator beside incident_number; AssignedInvestigator beside IncidentNumber), the person's words kept as its label; the concept gets an identifier of its own as its key unless one was named; and belonging to an entity adds the reference the landing turns into a foreign key — named exactly like that entity's declared key and typed like its deployed column. The platform's additions carry added_by_platform (key · reference) and the proposal row names them in words. The planner declares a foreign key only to a parent that is a table: a parent realised as a view (every Policing_Core entity) keeps the reference in the model and lands the column, with the reason stated — the warehouse is never asked to reference a view.

Why both halves are code: on production (12 Oct 2026) the gap was decided correctly and the agent, with a session primer ahead of the question, stated the gap but left out the offer, so there was nothing for the person to say yes to. The first fix put a written request into the composer; asked explicitly to propose, the agent then drew its own table of invented attribute names and asked for permission instead of calling the advisor. Neither whether an owed act appears nor whether an explicit request is carried out is left to the model: the person states the concept in the form, and code asks the advisor. No gap ⇒ the answer is byte-identical. Gates: test_model_gap.py (delivery, generality across terms, every engine before it persists), test_model_gap_act.mjs (the form, executed: in place, the advisor called directly, the verdict row in its place, a refusal kept for correction).

2.3 The delta deployer

dbdeploy/delta.py:plan_delta(authored, facts, dictionary, landing) is pure: a function of what the graph says and what the warehouse dictionary already holds. A new entity becomes a CREATE under the design standard; a new attribute on a base entity a nullable ADD; a reference on the attribute that names the parent's key; statistics on key and foreign keys. An object the dictionary already holds is exists, so a re-run is a no-op and a half-landed delta resumes. A realisation whose authored node is gone is refused unless forced, and only ever over the graph's own objects.

Where a table lands is policy, not a guess (landing_for): the model's own database when the deploy login owns it, otherwise a platform sibling UDERIA_EXT_<root> (provisioned first, named from the deployed root). Unknown ownership never lands in the root.

agent/kg_constructors/authored_deploy.py orchestrates a landing: it stamps physical nodes that implements the authored ones, marks the authored node deployed, and seals a model_change receipt (Ed25519). The preview is the validation act.

One graph, several warehouses. The graph's own physical layer is the primary infrastructure; a landing elsewhere is recorded on the authored node as realisations[<infrastructure>] (table, standard, readback, receipt), never as a second physical layer (ONTOLOGY_ARCHITECTURE §2.4b).

Where a sibling database is created, and how large. When the landing needs UDERIA_EXT_<root> and it does not exist yet, TeradataDeploymentBackend.provisioning() decides both and says why: the parent is the one given for this landing, else the connection's configured database_parent, else its default database, else the landing login's own space (a login can hold CREATE DATABASE on itself; dbc only works for a superuser); the size is the one given, else configured, else half of what that parent has free (DBC.DiskSpaceV, capped at 10 GB). The preview carries provisioning ({sql, parent, perm_bytes, basis}); the Govern dialog states it and lets the person change both, and Land sends exactly what the last preview showed. The landing database itself is never a request field — ownership decides it.

2.4 The design standard, probes and readback

2.5 Signing an extension, and what never moves the base

The extension is the trust kind ontology_extension (core/trust/extensions.py), signed over {extension_hash, base_attested_hash} — the base's signature is inside it, so a base re-sign reads the extension changed · base moved while the base stays green. Refusals: not_found · nothing_published · base_mutation · nonconformant · unsigned_ingredient. An agent grounded on the graph carries each published extension as an ingredient.

Stacked contracts (kgstore.types.is_stacked_contract, one rule read by trust composition, governance and extension signing): an authored node, a realisation, an action type and its acts_on edge, a data product, and a ledger or tuning ddl_artifact are excluded from the ontology's content hash and are never a base mutation. A graph with none of them hashes byte-identically.

2.5a Withdrawing an extension — the inverse of publish (Oct 2026)

A person takes an extension off a graph in one act: DELETE /v1/knowledge-graph/authored/<extension_id>?kg_id= (core/authored_withdraw.py). What goes: every authored node of the extension and its design decisions; the physical nodes a landing wrote for those nodes (realises names the authored node) and the landing's schema node once nothing else realises into it, with whatever the platform stamped under it (the action ledger artefact); their retrieval telemetry; the extension's signature (the trust history keeps both the signing and the revocation). What stays: the warehouse — the platform lands, a person owns what was landed (§1a), so the table is dropped there by hand and on purpose — and the act log, which has no delete face. Agents grounded on the graph read changed afterwards (their composition lost an ingredient) until their owner re-signs them, exactly as after a publish. The rule that picks the nodes is a pure function over the graph's entities (select_withdrawal), the same reading the authored listing uses; another extender's layer is never touched. Gated like publish. Gate: test/test_withdraw_rest.py. The Act II rehearsal reset (act2_reset.py) is this endpoint plus four small deletes (pending Inbox items, undecided tuning proposals, snapshots, a signature) — all through the API, so no rehearsal ever opens the server's database.

2.6 Workload tuning

The tuning review proposes performance structures from what the agents actually ran — never applies them.


3. OBDA-ACT — the signed action type

3.1 The manifest

dbdeploy/action_model.py parses an action: name, version, backend; a target (entity, the frozen deployed identifier, the key); an operation (insert · update · upsert; delete is future); an attribute allowlist with types, value domains and defaults; author pre/post conditions; a required row bound (max_rows ≤ 1000); an authority (required_role, posture); and a ledger — on by default (<target db>.UDERIA_ACT_<table>, append-only, the before-image as JSON). Rule violations are a closed set of REJECT codes with near-miss hints. The default conditions follow from the operation and cannot be redeclared (update: row_exists · one_row_affected; insert: row_absent · one_row_present; upsert: one_row_affected).

3.2 The gate (A1–A6) and the earned signature

dbdeploy/action_validation.py (pure, evidence injected):

Check What it proves
A1 the frozen target is what the resolver answers today
A2 columns exist with compatible types; value domains agree with the graph's closed domains
A3 the grain is unique — measured live; a probe that cannot run fails
A4 conditions are boolean, hold no subquery, read only the target's columns
A5 one parameterised statement on the target only; live EXPLAIN shows no product join and no full scan of the target
A6 the role holds exactly the rights the operation needs, and the ledger is provisioned

The trust kind action_type (core/trust/actions.py) signs {manifest_hash, validation_report_hash}: the proof is inside the signature. Refusals: invalid_manifest · unvalidated · stale_report · gate_blocked · unsigned_ingredient (a write contract stands on a signed ontology). Live evidence signs clean (verified_write), structural evidence partial (verified_declared). An edited manifest drops its stored report and reads changed.

The model under the action is in the signature — exactly the part the proof read. The report records model_facts — the facts A1/A2 read from the graph (the target's resolution, its columns, the writable attributes' closed value domains), computed by ONE pure function action_types.model_facts for both validation (through the store) and the trust reading (through the graph rows). The signature covers the report, so it covers the facts; trust/actions.live_hash substitutes TODAY's facts into the stored report, so the live hash equals the signed one exactly while those facts hold. A change to one reads changed with drift in words (the allowed values of LoyaltyTier changed · new attribute Email · Lead now resolves to …); signing the stale proof is refused stale_report; perform's refusal names it; the Designer's Next is Validate again. A change elsewhere in the graph, or a base re-sign, moves nothing — the action binds the facts it depends on, not the model's signature (no churn on unrelated edits, no cycle between two signatures). A report from before the facts were recorded proves nothing about today's model: it reads changed and re-validates once. The other direction is deliberate: action nodes stay OUT of the ontology hash — the graph does not depend on its actions, and an action's author may not own the model. Facts are read from the PUBLISHED model only (kgstore.types.unpublished_extension_names): a draft on the target moves nothing until it is published.

Signing stands on the whole model the target lives in. The bottom-up ingredient (trust/actions._model_ingredient) is the base ontology AND — when the target is an authored entity — the extension that holds it; the worse of the two blocks with its name (unsigned_ingredient: the action's extension by ann is changed … sign the extension by ann first). The trust reading of an unsigned action carries stands_on {state, label}, so the Designer's Next is Open Govern (the graph's Govern dialog, via the uderia:open-govern event) rather than a Sign the gate refuses, and it re-reads itself when Govern signs.

core/action_types.py is the authoring seam (author_action, gather_evidence, validate, provision_ledger); the Action Designer (static/js/handlers/actionDesigner.js) shows the journey validated · signed · granted · in use and offers only the act that is owed.

Two logins, never confused (Act II rehearsal, Oct 2026). A model has a MODEL login (it lands tables, creates the ledger — CREATE TABLE in the extension's database) and a WRITE login (an action's rows, under the action's role). action_types.default_connection(…, purpose=) keeps them apart: model → the connection the model was landed through (landed_via on its physical tables and columns); write → the user's only warehouse, else the person chooses — never the landed one (the model login holds no action's role) and never anything on an agent. The action owns the connection it writes through: the one it was validated through (validated_with; a structural validation owns none). Perform writes through it (action_perform.action_connection → resolve_connection, on every path: agent turn, Confirm, REST, unattended, undo); to change it, validate through another connection — the proof follows the login. There is no second place: nothing on the agent chooses a write login — not a profile's legacy componentConfig.action.connection_id (a profile save once dropped it and the agent could no longer write) and not its Database Navigator source (a READ connection, often an administrator's). Validation provisions a missing ledger through the MODEL connection — a refusal is a named result (ledger.missing + the warehouse's own sentence, dbcatalog.sql_runner.friendly_db_error), never a 500, and the gate still runs. The Designer validates and tries an action through the WRITE connection (the action's own validated_with, then defined_with, then the catalog's writes_through), says on each row which connection it writes through, and never starts on "the first connection listed". Every Teradata deployment backend holds its own teradatasql session for its own login: teradataml's create_context is process-global, and with two logins in one process a statement ran as whichever had connected last (a PAT-only configuration is the one case still served through it).

A6 names the fix. When the role lacks a right, the check says exactly what to run (action_validation.grant_statements: GRANT INSERT, SELECT ON db.t TO role; — never wider), also when the role holds nothing yet; and a blocked action's owed act is Validate again (the fix is in the warehouse, not the contract), with the blocker named in its Next box.

Defining an action is a person's act in the Designer (Oct 2026; before, only POST /v1/actions could). The person decides what only a person can — the concept, insert or update, the writable columns, the bound, the role, the posture; the platform derives the rest (action_types.describe_target, GET /v1/action-targets + POST /v1/action-targets/describe): the deployed TABLE (the resolver; a view is never offered, and refused by name), the key (the warehouse's unique index, else the model's), every column's contract type (the live dictionary through action_validation.contract_type_of, the inverse of A2's rule — so a derived contract cannot fail A2, the 7 Oct lead_id integer-over-text refusal), the closed value domains, and the warehouse's roles for the table ranked per operation (exact rights from action_model.TARGET_RIGHTS first, then a name that says the operation). The form opens prepared — the concept defined last (else an extension's first), its table read, the operation it has no action for yet, the role the warehouse suggests — and with a warehouse chosen one click defines AND validates (validation never signs). The table is read through the connection the person chooses (action_types.default_connection(…, purpose="write"): the user's only warehouse, else a choice — said in words beside it; nothing is guessed between several, and the connection the model was landed through is never offered for a write). What is not offered is named with its reason (deployed as a view — read-only to an action; a draft; not landed). It opens from the graph card, the Workspace's Actions section (+, and inline in its empty state) and the Actions panel; every list re-reads on uderia:actions-changed.

3.3 The compiler

dbdeploy/action_compiler.py:compile_action(manifest, {key, values}) (pure, deterministic):

Refusals are recovery-shaped (unknown_attribute with the closest name, key_not_settable, missing_required, outside_domain, …).

3.4 The runner

dbdeploy/action_runner.py:run_compiled(conn, compiled, principal, posture, commit) runs one compiled write in one transaction: pre-checks → before-image → statement → post-checks → ledger → commit only if every check held, otherwise rollback. A driver error fails closed. It decides nothing about authority or posture.

3.5 Performing: authority, posture, connection

core/action_perform.py is the one perform seam.

Refused before anything is touched: OBDA-MODEL · OBDA-ACT off · unknown action · an action that is not signed and intact (not_trusted) · an agent whose grant does not name it (not_granted; a person with no agent acts with their own authority). On Teradata the connection's login must hold the manifest's role, and the session acts under it (SET ROLE) — the platform never widens what A6 proved.

The posture (signed, part of the contract):

Posture Effect
observe records what would be written; writes nothing
review runs the write in an always-rolled-back transaction (so the diff is the database's answer) and parks it in the Assurance Inbox; nothing written until a person approves
annotate / enforce writes in one transaction through the runner

Interactive vs unattended. On an interactive turn a perform prepares (prepare_action): the consequence row shows the change and a Confirm; confirm_action performs only the invocation whose hash the person saw (a different value set is refused, nothing written). An unattended face — a flow node, a scheduled task, a dashboard tile — calls perform_unattended, which never prepares: the posture decides, and it always acts as an agent (no agent ⇒ refused — nobody is present to lend their authority).

Approval performs. Approving a parked item calls perform_parked, which writes only if the manifest hash, the re-compiled statement hash and the invocation hash equal the parked ones and the agent's grant still names the action; otherwise 409, the item stays open.

Four eyes — a designated approver, never the owner (PROP-008 §5.4a). An agent's authorityConfig.actions.approvers (account usernames, set with POST /v1/actions/approvers or the Approvers of parked writes field on the profile editor's Agent Authority card — which calls that same endpoint before anything is saved, so the one server rule refuses an unknown account and the owner on both doors) moves the decision off the agent's owner:

The approval's write and its disposition run as one unit shielded from cancellation, so a client that disconnects mid-approval can never leave a performed write on an item still pending.

Undo (undo_invocation): the ledger's before-image becomes an invocation of the same signed contract, returned prepared, confirmed like any act. An insert's undo would be a delete — refused in v1.

The connection is decided, never guessed (resolve_connection): explicit → the connection the ACTION was validated through (it owns it) → the user's only Teradata or SQLite connection → otherwise refused naming the candidates and saying how to give the action one (validate it through that connection). Nothing on the agent decides.

The Trust Circle counts a parking action. A granted action in review posture can park work exactly as a review-mode gate can, so REVIEWED's configuration reads it: /v1/profiles serves the granted review-posture actions as actionsParking (action_perform.parking_actions, read from the agent's own graph), and the station reads "can park" and names them. Before this, an agent whose gates all observed read "cannot park" on the day it had parked five writes.

3.6 Receipts

3.7 The act log

Acts were first recorded only where a face kept state (a turn, a flow node run, a scheduled run, the review queue), so an act through REST, MCP or a tile with no session left no platform record. core/action_act_log.py is one append-only table (action_act_log) written at every seam where a final record exists — perform_unattended, perform_parked (face review), the REST perform (with its face), and the conversation's own acts. Writes are fail-soft (the act's own record and the warehouse ledger still hold it). Read by Trust History (kind: "act", the face in words, the approver by account) and /v1/actions/activity (by_face, unattended).

3.8 The SQL gate's part

The SQL gate (agent/tool_enforcement.py → agent_authority.is_tool_allowed) refuses the raw write: with actions_exclusive every INSERT/UPDATE/MERGE/DELETE an agent sends through its data tool is action-only, and the refusal names the granted actions. A signed action never goes through the agent's data tool — a data tool runs one statement per call under autocommit and cannot hold pre-checks, write, post-checks and ledger together.


4. Faces — one core, every door

Face Surface face on the receipt
Conversation builtin component TDA_Action (list_actions · perform · undo · pending_approvals — the writes awaiting the asking person as designated approver, each with Approve / Reject), companion skill obda-act-expert; answer row with Confirm / outcome / Undo; Actions split panel; pin to dashboard conversation
REST /v1/actions (catalog, author), /<name> (definition, delete + revoke), /validate, /preview, /perform, /undo, /grants, /grant, /confirmations, /activity; /v1/trust/action/<kg>/<name>/sign-off rest (or the body's face, closed set)
MCP list_actions · perform_action at developer tier — two-step: without invocation_hash it previews, with it it performs mcp
Flow node type task_action ($name / {name} resolved from upstream outputs); refused at save if the node's agent is not granted flow
Scheduler a task with action_spec instead of a prompt — no session, no LLM, no tokens scheduler
Dashboard a pinned action tile offers Prepare again once its row has settled dashboard
Assurance Inbox a parked write shown as the database's diff; Approve — perform it / Reject review

How the conversation face resolves the action (ActionComponentHandler._resolve_action): a granted action named is used; otherwise, when exactly one granted action acts on the named entity it is used without asking. The entity may be named where the action belongs (action_type: "Lead" reads as the entity). When several act on the entity, the invocation's own attributes pick the one action that can write them all; ambiguity is refused, never guessed.

OBDA-MODEL faces: the obda_model component — TDA_ModelProposal propose_entity / propose_attribute (the TDA_KnowledgeGraph actions of the same names remain unadvertised aliases; behind the modelAuthoring grant), POST /v1/knowledge-graph/propose, GET …/authored, POST …/authored/deploy, POST …/authored/publish, POST /v1/trust/ontology-extension/<kg>/<ext>/sign-off, the tuning endpoints /v1/knowledge-graph/tuning/*, and the KG panel's Govern dialog (the road, the landing preview and readback, extensions, and the Workload tuning segment).


5. Observability and evaluation


6. Honest limits


7. File and gate map

Area Files Gates
Authored layer, extensions kgstore/types.py, components/builtin/knowledge_graph/{handler,graph_store}.py, core/{ontology_governance,kg_release}.py, core/trust/extensions.py test_kg_extension_trust, test_kg_act_ux.mjs
Advisor agent/kg_constructors/normalisation.py test_kg_normalisation
Delta deployer, landings dbdeploy/{delta,backend,teradata_backend,postgresql_backend}.py, dbdeploy/dialects/, agent/kg_constructors/authored_deploy.py test_pdm_delta
Design standard, probes, readback dbdeploy/{design_standard,pg_standard,probes,readback}.py, scripts/design_rules_verify.py test_pdm_design_rules
Tuning dbdeploy/tuning.py, core/{tuning_evidence,tuning_proposals}.py, static/js/handlers/rag/kgUpdateVersions.js test_pdm_tuning, test_tuning_ui.mjs
Action model, gate, trust dbdeploy/{action_model,action_validation}.py, core/action_types.py, core/trust/actions.py test_action_model, test_action_validation, test_action_trust
Compiler, runner dbdeploy/{action_compiler,action_runner}.py test_action_compiler
Perform, receipts, act log core/{action_perform,action_act_log,agent_authority}.py test_action_enforcement, test_act2_rehearsal
Four eyes (designated approvers) core/{review_queue,action_perform,agent_authority}.py, api/{rest_routes,action_routes}.py, components/builtin/action/{handler.py,renderer.js}, static/js/handlers/agentPerformanceHandler.js test_action_four_eyes
Model gap (decide + deliver) components/builtin/knowledge_graph/handler.py (_model_gap, _render_gap_lead), agent/model_gap.py, agent/proposal_suggest.py, agent/kg_constructors/business_terms.py, agent/answer_surface.py (model_gap note kind), agent/executor.py (_apply_model_gap), every engine, static/js/ui.js (_onGapProposeAct) test_model_gap, test_model_gap_act.mjs, test_business_terms
Faces components/builtin/action/*, skills/builtin/obda-act-expert/, api/action_routes.py, mcp_server/{client,server}.py, core/{flow_executor,task_scheduler}.py, components/builtin/dashboard/renderer.js, static/js/handlers/actionDesigner.js test_action_agent_face, test_action_rest, test_action_renderer.mjs, test_flow_actions, test_scheduler_actions, test_dashboard_act_again.mjs, test_action_surfaces
Baseline the planes off (component availability) test_act_baseline, test_act_admin_switch
Eval eval/scorers/reliability.py (action_safety), eval/live_redteam.py test_eval_reliability, test_live_redteam, test_act_friction.mjs

All of these run in test/trust_battery.sh.