Agent Trust & Assurance / Signed Data Products ← The Life of a Uderia Agent

Architecture · OBDA · Tier 5.5

Signed Data Products

The live contract for the OBDA subsystem — what is built, by which rules, and where the honest boundaries sit. The binding design is TIER_5.5_OBDA_SCOPE.md (normative MUST/REJECT language; its §13 stop-line governs); the per-increment build record is OBDA_STATUS_ASSESSMENT_2026-08.md. Status: v1 complete (branch feature/obda-phase-a, Aug 2026) — Phases A–D, V7 live probes, O2 receipts, E1–E5 enforcement, K5, and the P1–P3 compositional ratchet.

One sentence: a data product is a producer-declared, machine-validated, Ed25519-signed subgraph of the ontology (entities + frozen deployed mappings + declared joins + measures), and OBDA is the single deterministic surface — agent, BI, and MCP — through which it is consumed; consumption is a pure function of (product version, request), so a signed product cannot answer differently, drift, or invent.


0. What "signed" MEANS for a data product

The Trust Membrane makes "signed" mean attested + intact. A data product's signature is stronger (scope §2.4): "the producer approved this definition AND the platform mechanically verified that every request in its admissible space compiles to deterministic, aggregation-safe SQL." Three readings coincide in one act:

Reading What the signature asserts
Cryptographic attested by an authorized signer, byte-intact since (Ed25519, the shared provenance key)
Semantic same request → same governed SQL → same rows — deterministic by machine proof, not by promise
Trust reproducible ⇒ governable: the signature is earned through the V-gate, never a rubber stamp (S1)

Because the proof travels inside the signature (§2), an edited manifest, a stale report, or a drifted member cannot hide behind an intact-looking seal.

The master switch. OBDA_ENABLED (admin → App Config → Trust) defaults OFF: off = byte-identical baseline — the platform behaves as if OBDA never shipped (operator-restated design imperative). Every face honours it: no scan, no stamp, no REST surface, no Designer button, no scorer banding, no product contracts in the KG enrichment, no product seeding, query_product/list_products refuse (error_code: obda_disabled, recovery-shaped — "use your ordinary data tools"), and the data surface does not confine — gated ONCE at agent_authority.parse_scope (the surface is dropped at parse, so the tool chokepoints, the enrichment cut, the champion filter and the catalog marking all inherit from one seam; gating each consumer separately is how the enrichment and the agent face were missed — found live, session d51cf073: with the master off a planner was still told "you MUST use query_product", queried a product, was REJECTed, and substituted a nonsense grain). A deployment that adopts governed data opts in. Mirrors the grounding and guardrail masters exactly — master off = the whole lane off, including enforcement (the least- finished lane must never be the only always-on one).

The per-agent tri-state (operator model, 2026-08-17; resolved by ONE rule, agent/obda.py:profile_obda(profile) → (mode, products), read by the enrichment render/seed/event, both agent actions, the governed_execution planner pass, the lane scan and the champion filter):

Option Stored shape (authorityConfig.dataSurface) Behaviour
A — Disable OBDA (the DEFAULT: every unconfigured profile, an empty product list, and always the answer with the master off) absent / null no products visible to the planner (subgraph, contracts, seed, event all clean), query_product/list_products refuse (obda_not_enabled_for_agent), no OBDA planner behaviour, no lane stamp (the ring reads pre-OBDA), OBDA champions not offered
B — Surface Full {products, exclusive: true, advisory} only the listed products; contract-only enrichment; raw SQL denied fail-closed (Agent Authority); advisory is the observe-only dev sub-toggle
C — Products + regular paths {products, exclusive: false} the listed products render as contracts BESIDE the full KG paths — the LLM decides per question. The list is still the grant: query_product refuses products not on it (config-driven, so it holds on interactive turns that carry no principal)

Profile editor: Agent Authority → Data surface (OBDA) — a three-option radio; an empty product list saves as A (the editor never shows a mode that does not act); switching to A genuinely clears a stored surface. Enforcement (raw-SQL denial) stays with the ambient principal; planning visibility follows the profile's configuration on every turn.


1. The product model

A product is a data_product node on the ontology (gold #eab308 in the inspector; member edges to the entities it exposes; excluded from the ontology's own content hash — see §2). Its properties.manifest is the contract:

product / version / backend / description
entities:  name · deployed (frozen resolver output, M1) · key (the grain, M2) · attributes (the allowlist, M4)
joins:     from (the MANY side) → to · on-pairs · cardinality N:1|1:1 (M3 — REQUIRED, never inferred) · role
measures:  name · anchor entity · expr (dialect-checked)

dbdeploy/product_model.py is the pure model: frozen dataclasses, parse_manifest enforcing every manifest-decidable M-rule with a closed REJECT-code set (ERROR_CODES) and difflib near-miss hints — a rejection is self-correction food, never a guess. Canonicalization (canonical_jsonmanifest_hash) is byte-identical to the Trust Membrane's discipline, so the two formulas can never drift.

Composed products (§8) declare members + bridges only — their entities and measures come from the flatten.


2. The earned signature (trust kind #11)

core/trust/products.py + the data_product validator in core/trust/validators.py. Five refusal conditions, each machine-readable, checked at every sign-off:

  1. invalid_manifest — the manifest (or, composed, its flat form) fails an M-rule;
  2. unvalidated — no stored V-gate report exists (S1: the signature is earned);
  3. stale_report — the stored report validated a different manifest (report.manifest_hash mismatch — the ratchet: edit ⇒ re-validate ⇒ re-sign);
  4. gate_blocked — the report exists but says not signable (V-gate blockers);
  5. unsigned_ingredient / unsigned_member — the ontology (or, composed, a member product) is not signed-and-intact — the bottom-up rule, extended compositionally.

Signable content = hash{manifest_hash, validation_report_hash} (+ member_hashes for composed products, §8) — the proof is inside the signature. live_hash recomputes the same formula from the node's current state, so an edited manifest or refreshed report reads 🟠 CHANGED, never silently intact. An unresolvable attested product reads CHANGED (fail-safe), an unattested one UNSIGNED.

The ontology-hash exclusion (load-bearing). kg_content_hash excludes data_product nodes and member edges: the ontology's signable content is the knowledge model; a product is a contract stacked on it, protected by its own attestation. Without this rule, authoring a product flipped its own ontology CHANGED and forced a rubber-stamp re-sign loop (found live in Phase A, fixed at the root).

Products are removable: DELETE /v1/data-products/<name> deletes the node and revokes the attestation — a dangling signature over a deleted contract attests nothing; the ontology's seal is untouched by construction.


3. The validation gate — V1–V7

dbdeploy/product_validation.py:validate_product — deterministic (two runs hash byte-identically; no timestamps in the report), evidence-mode-honest (S2):

Check What it proves Evidence mode
V1 every entity resolves to exactly its frozen deployed table (drift → "re-validate + re-sign") schema with a live resolver, else structural, stated honestly
V2 join columns exist schema / structural
V3 join graph connected; every request-restrictable subgraph resolves to a unique tree (parallel role edges are alternatives, not cycles) structural
V4 every expression parses + transpiles; anchor-column rule structural
V5 the grain-safety matrix — every measure × dimension pair classified safe or rejected:<the 1:N hop, named> (G3: published, so a query-time grain_conflict can only mean the consumer ignored it) structural
V6 the determinism battery — built FROM the admissible map (per-measure safe set · the multi-anchor common set · grand total · entity reads), compiled twice, byte-identical; an in-battery REJECT is a FAIL naming the map/compiler divergence, never a skip structural
V7 live probes (opt-in, needs a DB connection): every battery query executes with limit 1 at the REAL target (TOP form on Teradata); every declared N:1/1:1 spot-checked empirically — the one-side must be UNIQUE on the join's to-columns (the composite-safe GROUP BY … HAVING COUNT(*) > 1 → zero groups form; a 1:1 also checks the from-side); every entity's declared key checked unique live

V7 verdicts: a probe contradicting a declaration fails V7 naming the declaration → not signable. A probe that cannot execute also fails — live was attempted, and signing a half-probed report as "live" would be dishonest (re-validate without a connection to sign structurally: an explicit choice). No connection ⇒ V7 skips and the report says mode: "structural" — and the mode is attested content (it changes the report hash), so an empirically-verified signature is distinguishable from a declaration-trusting one.

Two injection-safety layers, deliberately separate: the compiler path quotes and double-escapes identifiers (a hostile deployed stays inert inside a quoted identifier); the hand-built dup-groups probes use bare identifiers and therefore refuse any identifier failing a shape check rather than execute it.

The REST seam (POST /v1/data-products/<name>/validate, connection_id or live:true) pre-executes the probe plan through the same read-only executor as product queries and serves the pure gate from a cache; live with no usable connection falls back to structural with an honest live_note, never N failed probes.


4. The compiler — fan-out-safe by construction

dbdeploy/product_compiler.py:compile_request(manifest, request) — the §3/§4 rules:


5. Three faces, one semantic core

Every face compiles through the ONE compiler — byte-identical SQL by construction:

Face Surface Notes
Agent TDA_KnowledgeGraph actions list_products / query_product Compiles, never executes (the probe_bridge doctrine): in-component SQL would bypass the Agent-Authority chokepoint. The agent runs the returned statement through its own governed data tool; the OBDA scanner recognises that execution as governed (§7). A REJECT reads as a FAILED tool call (spec.error) carrying the verbatim code + admissible members — correction food, never a silent success. Products render in the KG enrichment as consumption contracts (measures, entities, validation state, "plan query_product, never hand-written SQL").
BI / REST GET/POST/DELETE /v1/data-products*, POST …/compile, POST …/query Query executes read-only under the caller's own DB connection (single-SELECT recheck; no connection → honest 409 with the SQL). Every query carries the governed provenance stamp {product, version, request_hash, trust, lane:"governed", row_count}. REJECTs are 422 with code + closest names.
MCP list_data_products / query_data_product on the Uderia MCP server, user tier An external agent queries a cryptographically signed product through MCP; REJECTs and the disabled switch return as structured error dicts, never bare exceptions.

Composed products resolve through one loader (product_model.load_flat_manifest) at every face — base passes through, composed flattens — so composition is transparent to every consumer.


6. Enforcement — the data surface (E-rules) and the knowledge twin (K5)

6.1 authorityConfig.dataSurface (Agent Authority)

"dataSurface": { "products": ["fitness_sales"], "exclusive": true, "advisory": false }

6.1a The enforcement scope is a declared choice (enforce_interactive)

By default a principal is derived only for AUTONOMOUS_ORIGINS — an interactive turn has none, on the reasoning that a present human is the control. A live walkthrough showed that reasoning is thinner than it looks: the operator ticked Surface-only (structural confinement) and then watched the agent hand-write raw SQL four times, because what the human sees is prose, not SQL. So the SCOPE is now a declared choice: authorityConfig.enforce_interactive: true applies the agent's WHOLE authority to interactive turns too (widens the scope, never replaces it — autonomous origins still derive a principal regardless). Absent/false is byte-identical to the historic behaviour; only an explicit True opts in (a migrated "true" string must never start denying tools); turning it OFF is a pack-import downgrade (flag_paths). Editor: Agent Authority card → Apply to interactive turns too.

6.1b Surface-aware enrichment — the contract ALONE (the operator's cut)

Under an exclusive surface the KG enrichment renders the consumption contract and NOTHING else. Observed before this: filtering the product contracts removed three of four product doors but left every table door standing — TABLE SCHEMAS, joinable columns, entity listings exist solely to help an agent WRITE SQL, and a surface-only agent cannot execute SQL it writes. With the schemas present the planner kept reaching for SELECT COUNT(*) FROM Customers even with a one-door contract in front of it (proven twice on identical context, once each way — raw-SQL patterns outweigh any instruction). The confined enrichment is therefore:

Advisory / non-exclusive surfaces keep the full enrichment (they can write SQL — for them the schemas are information, not bait). The grounding gate's trusted scope (scope_databases/scope_entities) is computed from the FULL subgraph and is deliberately untouched by the text cut. The enrichment EVENT carries data_products (the contracts actually shown — ONE shared rule with the renderer, so event and prompt cannot disagree), data_products_off_surface, and surface_only; the Live-Status title distinguishes ANALYSED from INJECTED ("surface-only — injected 1 data product contract: …; schemas withheld, 51 entities analysed for scope"). Signed products are ALWAYS seeded into the enrichment regardless of the question's wording — a product is named for its subject area, a question names entities, so the lexical match loses essentially every time, and an absent product is a missing OPTION, not a missing fact. list_products mirrors the same fact mid-turn: rows carry on_surface, surface rows sort first, and a surface_note states the rule.

6.1c Deterministic consumption pipeline (no LLM where nothing is left to decide)

The walkthrough hardened the Optimize path end-to-end so the governed lane is deterministic once the model has chosen it:

6.1d The learning loop is authority-aware

Pre-governance turns seed champions teaching literal raw-SQL plans; once the agent is surface-only those champions guide the planner into plans the runtime denies with certainty — and can never self-demote, because a confined turn ends status=success (an honest refusal). Two durable rules: retrieval — a champion whose plan the ambient principal would deny is not OFFERED (champion_blocked_by_surface; not demoted, not deleted — unconfine the agent and it is valid again; fails OPEN, the trust-aware-routing precedent); seeding — a turn whose OBDA lane is confined never seeds (its plan proved nothing; defence-in-depth beside the generic success criteria, pinned against criteria drift).

6.2 K5 — requireSignedKnowledge (the knowledge-modality twin)

knowledgeConfig.requireSignedKnowledge: only collections whose integrity signature is signed-and-intact back the agent's answers — changed and unsigned corpora are excluded at the ONE retrieval seam (rag_retriever.retrieve_examples, which Focus, Ideate, and the knowledge_context module all route through), exactly like the maxSensitivity ceiling. Pure core filter_signed_collections; rules that matter: signed means intact; unattributable chunks (no collection id) cannot be signed; the trust suite physically absent fails open with a loud warning (the trust-aware-routing precedent — silently emptying the knowledge lane would be worse than not enforcing); one unresolvable collection beside real states is excluded, never a fail-open trigger. The knob is signed content and downgrade-guarded; the editor toggle is modeled in carryForward so unchecking actually takes.

K3/O5 (source-provenance stamps on retrieval + the capsule) and K6 (bound collections as agent-badge ingredients) are delivered by KNOWLEDGE_RETRIEVAL_ARCHITECTURE.md / TRUST_MEMBRANE_ARCHITECTURE.md's earlier waves.


7. Observability, receipts & eval — two ends of one signal

7.1 The lane scanner (agent/obda.py)

scan_obda classifies a turn's data operations, consumed by BOTH the Live-Status card and the eval scorer (one source):

Lane Meaning
governed a compile_metric/query_product op — or a raw-SQL call whose statement matches a governed compile's output from the same turn (execute_governed, whitespace/semicolon-tolerant): the component compiles, the agent executes, and the scanner knows the truth of that split
free-formed the agent wrote raw SQL itself — advisory, judgement in the loop
confined every raw-SQL attempt was DENIED by the surface — neither lane: nothing executed. Counting a denial as free-formed would record the confinement working as the escape it prevented, and governed_data_ratio would punish the agent for being confined (found live in C4, fixed at the root: denied_count, its own card, GROUNDED reads pass)

Beside the lanes, the scan carries rejected_count — requests the CONTRACT refused (an unknown_member, a grain conflict, …). A REJECT moved no data: it is the governance working, not a data operation, so it is reported, never folded into a lane (found live, session 2fcbb51f: an M4 allowlist REJECT counted beside the real compile+execute pair read "3 governed data operations"). Same rule as denied_count, one layer up: the surface denies at the door, the contract rejects inside it. A rejected compile also never seeds the governed-SQL allowlist (a REJECT produced no statement to pair an execution against). The capsule's lane chip and the GROUNDED "Governed data" reading both name the count (… · 1 request rejected by the contract).

The REJECT tail splits by REJECT kind (handler._handle_query_product): a typo (the closest declared: hint fired) or a non-member reject (empty_request, grain_conflict, …) keeps the mechanical retry tail — the fix is a reshape. A genuinely undeclared member (unknown_member + "does not expose", no closest hint — the allowlist working, M4) instead instructs: answer with an admissible dimension and state explicitly that the requested member is not exposed, or report that the question cannot be answered at that grain — never silently substitute a coarser dimension and present it as the asked one. The one-size retry tail had literally commanded the substitution observed live ("which customer has the most tickets?" answered at state grain over a 100-way per-customer tie). The safety net behind the instruction is deterministic: the honesty gate's grain_substitution verdict (agent/honesty/gate.assess_grain_substitution, 0-token) detects an un-disclosed substitution post-hoc — observe flags it; annotate/enforce APPEND the disclosure naming the refused member and the grain actually used (never a replacement, never parked: the figure itself is correctly computed governed data — what was missing is the admission of which question it answers).

The substitution can also happen at PLAN time, with no REJECT at all (session 7b9b5a25: "which customer has the most tickets?" was planned straight onto customer_count × Customers.State — admissible, governed, fluent, answering a different question — so every REJECT-keyed layer was blind). Three layers close it: (1) the rendered contract itself now states the rule where the decision is made ("These are the ONLY members … NEVER silently answer at a different grain") — the REJECT tail's doctrine moved to plan time; (2) a REJECT-free honesty-gate trigger (primitives.entity_grain_mismatch, deterministic, deliberately narrow: a ranking-shaped question naming a queried product ENTITY but none of that entity's queried dimensions — "which customer …" grouped by Customers.State trips, "which state has the most tickets" names the used grain and is clean) feeding the same append-only verdict, tagged trigger: entity vs the precise trigger: reject; (3) because (2) is a heuristic, the agent can opt into the grain_faithfulness LLM judge (honestyConfig.grainJudge, OFF by default, configurable in the profile editor's Honesty card): the heuristic escalates to the judge before acting — a judge PASS withdraws the verdict (recorded, never silent), a FAIL/timeout/absence keeps the deterministic append (fail-safe keeps the protection, the grounding gate's own rule). The judge is also a registered post-hoc quality dimension (the 54th), owned by the honesty gate at GROUNDED and declared in POINT_JUDGES — the Assurance Guide renders it gate-consulted. The precise reject trigger is never judge-gated: an observed REJECT-then-retry is not a heuristic.

7.2 O2 — the consumption receipt, sealed

Every governed consumption seals an obda.consumption step into the turn's Ed25519 provenance chain: {product, version, request_hash, sql_hash, row_count, trust_state}row_count supplied by the paired execution call; a REJECTed compile carries none (nothing was consumed). The sealed content is obda.receipt_content(op) — a closed field tuple (a stray presentation field can never enter the seal), canonical JSON — and provenance.verify_content re-derives it from the turn's stamped obda.operations with the SAME function, so the sealed receipt and the displayed record are checked against each other; a receipt matching no stamped operation is a named mismatch, never a silent skip. The answer capsule renders a Data products row (fitness_sales v1 · trusted · 32 rows, hashes + seal note in the tooltip), and the existing "Export receipt" carries the consumption inside the signed chain — offline-verifiable with the public key alone.

7.3 Eval + red-team


8. The compositional ratchet (P1–P3)

A composed product declares members (≥ 2 base products, same ontology, same backend) + bridges (the stitching joins — cardinality + on-pairs, as always). Then:

Flatten refusals are precise, never silent: nested_composition (v1 stop-line), member_backend_mismatch, member_entity_collision / member_measure_collision (naming BOTH members), bridge_unknown_entity (naming what IS exposed), unknown_member_product.


9. Lifecycle, portability, governance


10. Honest boundaries (the stop-line, held)

Refused by name, never silently (scope §13): no marketplace listings of standalone products (v1 ships them inside ontologies/packs); no non-SQL backends; no cross-ontology members; no nested composition (nested_composition); row-level policy stays the pinned minimal v1 form. The grounding gate stays ON for surface-only agents (E5's monitor — prevention subsumes detection, but detection is kept as the audit of prevention).


11. File & gate map

Area Files Gate (assertions)
Model + composition dbdeploy/product_model.py test_product_model.py (98)
Validation V1–V7 dbdeploy/product_validation.py test_product_validation.py (66)
Compiler dbdeploy/product_compiler.py test_product_compiler.py (74)
Trust kind + ratchet core/trust/{products,validators}.py test_product_trust.py (45)
REST faces api/{rest_routes,trust_routes}.py test_product_rest.py (29, live)
Agent face components/builtin/knowledge_graph/handler.py test_product_agent_face.py (19)
Scanner + receipts + E5 agent/obda.py, agent/executor.py, core/provenance.py test_obda.py (74)
Surface enforcement core/agent_authority.py, mcp_adapter/adapter.py, components/manager.py, agent/tool_enforcement.py test_agent_authority.py (77)
K5 agent/rag_retriever.py, core/profile_config_contract.py test_signed_knowledge.py (19)
Eval + red-team eval/scorers/reliability.py, eval/live_redteam.py test_eval_reliability.py (69), test_live_redteam.py (38)
MCP face mcp_server/{client,server}.py test_mcp_server.py
Designer + capsule static/js/handlers/{knowledgeGraphPanelHandler,answerCapsule,configurationHandler}.js browser-verified per increment

All Python gates run in test/trust_battery.sh (a gate the battery does not run is a gate nobody runs). Every security-bearing rule above is mutation-tested — ~40 mutations across the subsystem, each failing by name.