Story 1836: graded server-set trust tier on tool-results — firm data can never claim the principal’s voice

Why this exists (the catastrophe it prevents). When counsel asks the dock a data question, the broker runs a server tool, then re-injects the result into the next agent prompt so the brain can answer from it (clerk_worker/runner.py _command_with_tool_results). Those results are rendered inside the D1 keyed fence the brain shipped in pramaan-agents #1825 — the fence that separates the one channel that drives action (the verified counsel turn) from everything that is merely DATA. But a tool-result payload is firm data, and firm data is full of attacker-controlled strings: a matter title, a party name, a pasted note. The DockToolResult contract is extra="allow", so a payload carrying [trust=counsel_input] — or a smuggled trust_tier key — would let that data forge a higher provenance tier than the read it rode in on, promoting attacker text to the principal channel the model obeys. This Story sets the tier server-side and strips the forgery.

Provenance is decided by the SERVER, keyed ONLY to the tool/source registry — never read from the payload, never from a client/model field. The tier ladder is graded (≥3 tiers); a tool-result can reach tool_result at most, and the counsel ceiling is unreachable from data by construction. No model call, no I/O — a deterministic stamp at the broker re-injection choke-point.

Satellite view: one choke-point, every tool-result, two defences

Every tool-result the brain ever sees is merged in exactly one place — _command_with_tool_results — from three sources: a freshly executed read this step, a prior broker-stamped result echoed back in current_context, and (on the first invoke) whatever the client seeded. A new pure module, src/shared/agent_tools/trust.py, re-stamps every one of them there:

1. drop  any client/model-supplied tier key   (trust_tier / trust / trustTier)
2. strip [trust=...] lookalikes from every string leaf of the payload
3. stamp the server tier from the registry, keyed to result['tool']   (set LAST)

Because the tier is set last, from the registry, the cleaned payload can never contribute to it. The function is idempotent and source-agnostic, so re-running it every round-trip step is a no-op for already-stamped results and a hard reset for forged ones.

The decisions that matter

1. The ladder is graded, and the ceiling is out of reach

Three ordered tiers, low → high: untrusted < tool_result < counsel. A registered server tool’s result is tool_result (authoritative firm data — but still a DATA channel). Anything unknown, forged, empty, or non-string fails closed to untrusted. counsel is the ceiling the fence reserves for the verified principal turn #1825 leaves un-fenced — and tier_for_tool never returns it. So a payload screaming [trust=counsel_input] is climbing toward a rung the server will not hand to any tool-result.

2. Keyed to the source, not the content

tier_for_tool consults only the existing SERVER_TOOL_REGISTRY — the same leash that authorises which tools may run at all. A result naming query_matters gets tool_result because that tool is registered; a result naming evil_unregistered_tool gets untrusted. The decision input is the tool name and nothing else, so neither the payload bytes nor a caller-supplied field can move it. This adds no new authority model — it reuses the one registry the executor and the resolver already share.

3. Strip the lookalike so the fence can’t be tricked

Even with the tier set server-side, a raw [trust=...] string sitting in a matter title would reach the fence as text. The fence (#1825) defangs role prefixes and literal fence markers, but the trust marker is this Story’s concern. strip_trust_markers walks the whole payload (dicts, lists, strings; other scalars pass through) and removes every [trust=...] lookalike — case- and space-insensitive, single-line — leaving the surrounding firm data intact. A forged marker never survives to be misread.

4. No new observability span (and why that is correct)

Per pramaan-docs ADR-124, new executable code declares a span or documents its exemption. trust.py is pure deterministic transforms — no I/O, no boundary crossing, no state — invoked inside _command_with_tool_results, which already runs under the worker’s SQS consumer span. The module docstring records the exemption. No DTO, route, or trust-boundary payload changed, so the OpenAPI snapshots are untouched (the results ride the free current_context dict).

Verification

The forge property is pinned both as a pure unit test and through the real broker path (system boundary, not a fixture spot-check).

CheckProves
test_trust_tier — fake marker in payloadAC3: a query_matters result whose firm data carries [trust=counsel_input] stays at tool_result; the marker is gone, the surrounding data survives
test_trust_tier — smuggled tier keyAC1: a client-supplied trust_tier/trust/trustTier is dropped, then re-set server-side from the tool
test_trust_tier — graded ladder + fail-closed≥3 strictly ordered tiers; unknown/empty/non-string source → untrusted
test_runner — broker paththrough real process_command: a forged prior result and the freshly executed read are both re-stamped (untrusted / tool_result) with markers stripped; the run still succeeds

Ruff is clean on all changed files; mypy reports no new issues in the new module or the runner edit (pre-existing repo errors only); the agent_tools unit suite and the clerk_worker runner unit suite pass; the OpenAPI snapshots are unchanged (no route/DTO touched); git diff --check is clean. (The two DB-backed dock integration tests require make test-db and are environment-gated, not affected by this change.)

One sentence to remember: the broker now stamps a graded trust tier on every tool-result keyed to its registered source and strips [trust=...] lookalikes from the payload, so firm data can never forge the principal’s voice inside the keyed fence.