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

Architecture · Tier 5.2

Trust Membrane

Status: implemented and merged to main (2026-07-03). This is the architecture reference — how the shipped system works. The design rationale is docs/roadmap/TIER_5.2_TRUST_MEMBRANE.md; the per-artefact validator model is TIER_5.2_VALIDATORS.md; marketplace portability is TIER_5.2_PORTABILITY.md.


1. The idea in one paragraph

Trust is a visible, cryptographically-verifiable badge on an artefact, and the agent is the primary unit: a signed agent is trusted exactly when its whole composition is — its active ontology, its active skills, and the knowledge/planner repositories it consumes must each be signed 🟢. Trust is computed, never re-derived; sharing stays fully open (badge, not gate). Four states: ⚪ unsigned, 🟢 trusted (signed & intact), 🟠 changed (signed, but content drifted since), and ◒ deployment_unattested (publisher-signed and intact, but this instance's model, tool catalog and connector opt-ins have not been attested here — see §3b). Default (no attestations) = a byte-identical baseline — every artefact is ⚪.

2. Module map (src/trusted_data_agent/core/trust/)

Module Responsibility
attestation.py Pure core: TrustState, content_hash, id- & order-independent composition_hash, resolve_state, resolve_agent_trust, agent_signable (bottom-up rule), the validator registry, ValidationVerdict + SignBand (clean/partial/blocked). Zero I/O.
scanners.py Deterministic 0-token safety scanners: content_safety_scan (injection), code_safety_scan (AST), sql_safety_scan, markup_safety_scan (XSS), aggregate_verdict. ScanResult splits findings into blocking vs caveats.
validators.py The 11 per-kind validators (ontology · skill · agent [posture, Jul 2026] · extension · knowledge · planner · flow · dashboard · canvas · assurance_module [PROP-003] · data_product [OBDA, the earned signature]), each returning a ValidationVerdict, registered by kind. agent_pack remains the aggregate/composition path with no per-kind register_validator. 11 per-kind + 1 aggregate = the 12 signable categories in §3.
content.py Canonical per-kind content + version_hash resolver (skill/ontology/extension/knowledge/planner/flow/dashboard/canvas). One definition, shared by sign-off, badge state, and portability.
composition.py A real profile → its ingredients (active KG + active skills + the knowledge/planner repositories it consumes) → live AgentTrust. Owns agent_version_hash, the single definition of an agent's portable signable content (ingredients + posture + capability), and folds in the deployment half via compose_agent_state.
deployment.py The deployment half (§3b) — resolved LLM identity, reachable MCP tool/prompt catalog, per-profile connector opt-in. Never travels with a pack; describe_drift names what changed so re-attesting is informed.
pack.py Agent-pack composition resolver — a pack is "an agent whose ingredients are its members".
store.py The trust_attestations table (self-applying), Ed25519 sign/verify reusing the platform provenance key, content-bound signatures + the signer public key carried for cross-instance verification.
portability.py Marketplace capture / reinstate / claim_state + the honesty gate.
validators registry register_validator(kind, fn) — new kind = one function, no forks.

REST seam: api/trust_routes.py (GET /v1/trust/agent/<id>, GET/POST /v1/trust/artifact(s), POST /v1/trust/<kind>/<id>/sign-off, POST /v1/trust/agent/<id>/attest-deployment, POST /v1/trust/verify). UI: static/js/handlers/trustBadge.js.

3. The validator model (all 12 categories)

Every signable kind maps onto one closed archetype set — the two shipped poles (conformance, content-safety) plus code-safety, query-safety, provenance-integrity, and aggregate. Each validator is pure, deterministic, 0-token, and maps its result onto the harmonised band clean · partial · blocked.

Artefact kind Signable content Archetype
Ontology (KG) ontology entities + relationships conformance (Tier 4.3 gate)
Skill skill instruction text content-safety
Agent (profile) agent composition over ingredients + safety posture aggregate + posture
Extension extension handler source + manifest code + content-safety
Knowledge repo knowledge sorted(per-doc hash) + embedding integrity + content-safety
Planner repo planner sorted(per-case hash) integrity + query-safety
Agent pack agent_pack composition over members aggregate
Flow flow node/edge logic (positions excluded) code + query + content
Dashboard dashboard tile structure + data source query-safety
Canvas canvas markup markup/XSS + content
Assurance module assurance_module pack manifest + module content + capability honesty (a claimed tier must be provable) code-safety + tier proof (ASSURANCE_MODULE_PLANE_ARCHITECTURE.md)
Data product data_product {manifest_hash, validation_report_hash} (+ member_hashes lineage for composed) — the proof is inside the signature the earned signature: five machine-readable refusals, the V1–V7 gate incl. live probes (OBDA_ARCHITECTURE.md)

The ontology-hash product 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, fixed at the root — OBDA Phase A).

Full detail: TIER_5.2_VALIDATORS.md §6. Config artefacts (MCP servers, LLM configs, vector/graph backends) are not-attestable by design — their content is credentials, and trust ≠ access.

3a. What counts as an ingredient

An agent's ingredients are its active ontology, its active skills, and the knowledge/planner repositories it consumes.

Repositories earn their place because the compositional promise — a signed agent contains only signed ingredients — is otherwise false for two whole agent classes. A Focus agent is its corpus and an Optimize agent is steered by its champion cases; while repositories sat outside the composition, an agent with a bound corpus and no KG resolved with ingredients: [], so the bottom-up gate had nothing to gate on and signed instantly over an unscreened corpus, a corpus edit could not flip the badge, and re-pointing the agent at a different corpus disturbed nothing.

Applicability mirrors the runtime rather than adding a rule — attesting an artefact the agent never consults would make the badge claim more than it verifies:

Ingredient Counts when
ontology it is the profile's active KG (kg_profile_assignments)
skill the skillsConfig entry is active
knowledge the collection is bound and the runtime would retrieve it — per class: rag_focused always (Focus retrieval is unconditional; enabled is ignored by the engine), every other class only when knowledgeConfig.enabled is truthy (runtime default false, mirroring get("enabled", False) in the engines)
planner the collection is bound and the profile is a planning class (tool_enabled/genie), matching planner.py, agent_pack_manager and the ontology projection

Kind is decided by each collection's own repository_type, never by which field named it — knowledgeConfig.collections and ragCollections overlap.

Empty and unreadable are deliberately different outcomes. A repository with no content has nothing to attest and is omitted, so an empty default planner repo cannot make every agent unsignable. A repository that demonstrably holds content whose hash could not be computed is emitted unresolved and blocks: silently omitting it would reopen exactly the gap repositories close, this time under a transient read error. This holds for both repo kinds: planner_cases returns a None hash when any case file is unparseable (a corrupt case must not silently shrink the signed set) and planner_has_files is the independent existence probe, so a populated-but-unreadable planner repo blocks rather than vanishing. A per-collection failure emits that one repo as unresolved and continues — it can neither silently drop the repo nor abort the loop and drop every repo after it. IngredientRef carries the flag explicitly and one shared ingredient_state() resolves it for both the badge and the sign gate, so what you see and what blocks can never disagree. A binding to a deleted collection is warned and skipped — a configuration fault, not an ingredient.

3b. Two signable halves — portable and deployment

An agent is signed in two halves, because two different questions are being asked and one hash cannot answer both:

Half Question Contents Travels? Authority
portable "is this agent the artefact that was validated?" composition (ingredients) + posture (what it will say) + capability (what it can do) ✅ reinstates on install approver tier (_authorize_signer)
deployment "is its resolved capability surface still what was attested here?" resolved LLM identity · reachable MCP tool/prompt catalog · per-profile connector opt-in ❌ never travels ownership (_authorize_deployment_attester)

The split is forced by the marketplace honesty gate (§8): reinstate only re-applies a signature when the copy's live hash equals what was signed, so anything in the portable hash must reproduce byte-identically after an id remap. A subscriber uses their LLM, their MCP server and has opted into nothing — yet a model swap, a server quietly gaining a write tool, and a connector opt-in are exactly the changes most worth catching. One hash cannot both survive installation and cover them; two hashes answer both honestly.

Two halves, two authorities (the provenance-vs-policy split every trust system makes — a build system signs the artefact, the deploying org attests its own admission policy). The portable half can be vouched for by a third party, so it keeps the approver-tier gate. The deployment half is a statement of intent about a surface only its owner controls, so its authority is ownership — the profile is resolved under the caller's own uuid, and a non-owner gets 404 (never 403, which would confirm another user's profile id exists).

Routing attestation through the approver-tier gate was wrong for three reasons, all pinned by test_deployment_attest_authority.py so it is not reinstated: (1) it could never enable a reviewer, because that same user-scoped lookup means an approver reaches only their OWN profiles — the gate could only lock the owner out; (2) it disabled the drift detector it appeared to protect, since with no one able to take the baseline a model swap or a grown tool catalog never flips the badge — a control that cannot be exercised is not a control; (3) under require_approval the separation-of-duties rule refuses ALL self-signing, and attesting is always self, so deployment attestation became impossible platform-wide for every tier including admin. Ownership is the authority model for a per-user artifact — the same conclusion TC-30 reached for leaf sign-offs. The record still carries signer_uuid + signer_fingerprint, so a surface can distinguish an owner-attestation from an admin one rather than flattening both to 🟢.

Enablement is content. A disabled→enabled flip changes what the agent can do while changing no artefact, so opt-in state is hashed alongside the catalog itself. Conversely absent must equal inert: a disabled session primer, an opted-out connector and a component's intensity dial hash as though they were not there, because a badge that flips on tuning is a badge people learn to ignore.

Portability normalisation. Coordinator routing stores raw profile ids that an install remaps, so genieConfig is hashed in tag space via an injected resolver; hashing ids would make every coordinator pack permanently 🟠. Instance-local pointers (llmConfigurationId, mcpServerId) never enter the portable hash at all — what matters is the resolved identity, which lives in the deployment half.

The catalog is read from the persisted classification snapshot, not a live MCP connection, so badge resolution works offline. Honest limit: an upstream server change is caught when the profile next re-classifies, not instantaneously. The timestamp is excluded, so re-classifying an unchanged server does not flip the badge.

Drift is described, not merely detected (deployment.describe_drift) — "tools appeared: base_writeQuery(sql)", "connectors enabled: uderia-shell". An unexplained amber trains operators to click through it.

skillsConfig is deliberately not signed: active skills are already ingredients, so signing the block would double-count and flip on inert edits.

4. The bottom-up sign gate

agent_signable(ingredients) refuses to sign an agent unless every ingredient is already 🟢; the blocking list drives the guided "sign these first" flow. The same rule composes for agent packs (pack.member_refsagent_signable). Verdicts are Ed25519-signed and bound to the exact content version (store.sign).

For a Focus agent the chain therefore runs two levels deep: the agent is blocked by its corpus, and the corpus is blocked by its own earned-signature rule (unscreened documents, or injection findings neither quarantined nor acknowledged). That is the intended shape — the badge cannot outrun the screening that justifies it.

5. Badges + sign-off UX

trustBadge.js provides the reusable primitives: - mountArtifactBadges(root) — fills [data-trust-artifact-*] slots with a signer-aware seal (green intact · amber changed · faint "sign off" for signers on unsigned; nothing for non-signers). Wired across Skills, Extensions, KGs, Repos, Agent Packs, and Workspace instances (flow/dashboard/canvas). - mountAgentBadges(root) — the compositional agent shield on the profiles list. - openArtifactTrust(kind, id) — the trust popover: state · offline Verify · signer-only Sign off / Re-sign (surfaces an agent-pack's blocking members). - mountMarketplaceTrust(root) — read-only "publisher signed" seal on marketplace cards.

6. Marketplace portability

Trust travels with content, not ids: signatures are content-bound (kind|version_hash), so an attestation survives an id remap. Every marketplace surface uses the same two calls — capture(kind, id) at publish, reinstate(kind, new_id, claim) at materialize — and the honesty gate re-establishes a signature only if the copy's live hash equals what was signed. The claim carries the signer's public key, so a cross-instance consumer verifies with the publisher's key. Wired into KG · agent-pack (+ per-member) · skill · extension · component-instance; collections inherit trust free via the reference model. Full detail: TIER_5.2_PORTABILITY.md.

7. Packaging + isolation (the Ontology Suite)

The Trust Membrane ships bundled with the Platform Ontology UX as one physically-isolatable enterprise feature — the Ontology Suite — governed by a single switch (core/enterprise.py, ON for all tiers today; a one-line flip makes it Enterprise-only). Removal manifest + absence-tolerance contract: src/trusted_data_agent/core/trust/README.md. Canonical suite description: PLATFORM_ONTOLOGY_ARCHITECTURE.md §11.

7a. What an agent's signature covers (Jul 2026)

An agent's signable content is its composition + its safety posture — the grounding, guardrail and honesty modes that decide what it will and will not say. Before this, two agents with identical ontology and skills but opposite enforcement hashed identically and both resolved 🟢: the seal attested to something other than the thing protecting the user.

authorityConfig is deliberately excluded. It governs what an agent may do on whose behalf, is a strict subset of the deploying owner's permissions, and legitimately differs per deployment — hashing it would make 🟠 fire as often for a correct adaptation as for tampering. Trust ≠ access (§8) is the boundary. Authority is still portable, downgrade-guarded, and enforced fail-closed at every tool chokepoint.

An agent whose posture sits entirely at the runtime defaults hashes exactly as before, so existing attestations stay 🟢 and nothing re-signs; the posture dimension engages only once there is real posture to protect. The agent kind also gained its first validator: signing bands clean (at least one gate acts) or partial (every gate observe-only, with a caveat naming them) — it never blocks. Full detail: TIER_5.2_POSTURE.md.

7b. Assessment hardening (Aug 2026) — the seal must not lie

A four-lens adversarial audit (TRUST_CIRCLE_DEEP_ASSESSMENT_2026-08.md) closed a family of fail-open defects. The standing invariants it established:

Deliberate, documented limits kept as-is: content_hash's type-domain collision and the catalog entry encoding (any change invalidates every existing attestation — a fix belongs to an explicit hash-format version bump); the sign-time TOCTOU (fail-safe direction); the license gate's fail-open (search key TRUST_LICENSE_FAIL_OPEN — must flip with the Enterprise gate).

8. Guarantees & scope

Downstream consumers (Jul 2026): the membrane's integrity state now feeds two live surfaces beyond the badge — (1) Behavioral Trust Score (AGENT_OPS_EVAL_ARCHITECTURE.md §6c): the composite per-agent trust state = live conduct capped by this integrity reading (unsigned and deployment_unattested both cap at "proven", changed caps at "watch" — a signature never promotes bad conduct, and a deployment never attested here can never out-score a portable-signed one), rendered as a seal+ring glyph on the Agents roster; (2) trust-aware routing (coordinate_engine.py: genieConfig.requireTrustedExperts) — a coordinator can require its experts to resolve 🟢 at routing time, making compositional trust operative, not just visible. Both resolve via composition.resolve_profile_trust defensively (suite absent → unknown / loud fail-open warning), preserving the removability contract.

Remaining (minor): a cross-instance trusted-publisher policy (verification works; the allow-list is a config choice); Model B grounding (opt-in runtime enforcement that a turn only executes signed ingredients).

Tests: test/test_trust_membrane.py (132 assertions — three-state truth, hashing, compositional truth table, all 10 validators, scanners, bands, portability incl. cross-instance pubkey round-trip).