Harnesses
There is one master agent. For every run it wears one harness: the configuration that gives it a workbench
role’s prompt, toolset, skills, memory lenses, rubric, plan templates, refusal rules and status template. The
harness is chosen when the run is created and enforced by the tool server from the run row, never from the
prompt; it never changes inside a run. Names are fixed in PRODUCT-CONTRACT.md section 12.5 and the design is
ADR 0031.
Everything here is designed until the R4 gates pass. R4 ships two harness
prompts, medicinal_chemist and dmpk_tox_scientist; the other thirteen workbench keys are full harness
entries whose prompt resolves to the neutral prompt of the persona held until their own prompt ships (R5;
ip_analyst R6). Lens reviews are R5 and are described here only so that no earlier surface is mistaken for
one. The word “harness” is plumbing: it appears in this reference and in the audit envelope, never on a
workbench screen.
What a harness is
HarnessSpec(key, personas, prompt_ref, toolset, skills, memory_lenses, rubric_key,
plan_templates, refusal_rules, status_template, record_types)
HARNESS_VERSION = "harness_v1"; PROMPT_VERSION = "console_v2"toolset is the only relation between a tool and a harness; a tool never lists the harnesses that may use
it. Prompts live at prompts/console_v2/harness/<key>.txt in the agent repository with the shared
refusals.txt and lenses.txt. The run roles (console, evidence, plans, review, progress, reader)
are unchanged; the reader alone receives untrusted text, and every harness delegates retrieval to it.
The twenty keys
HARNESSES holds 20 entries and HARNESS_KEYS 20 members (EXPECTED_HARNESS_COUNT = 20, test
test_harness_keys_sync in api/tests/test_harness.py; the agent’s config.HARNESS_KEYS mirrors the 20): the 15 workbench role keys
plus five neutral persona harnesses keyed by persona key, scientist, quality_reviewer, program_lead,
program_owner, manufacturing_specialist, whose toolset is that persona’s tools today and whose only skill
is evidence.assertion_lint. agent_runs.harness_key is always one of the 20, or NULL for a viewer’s run
(read-only console, no harness). The agent container takes its tools/list snapshot per key.
The run row is the authority
- When a run is created the API sets
agent_runs.harness_key(withharness_versionandrubric_version) fromusers.settings.workbench_role[program_id], or from the persona fallback when no workbench role is set (owner > lead > quality reviewer > manufacturing specialist > scientist; viewer ->NULL). - The worker copies the key into a
harnessclaim on all six run tokens. No allow-list travels in a token; the allow-list hash stays the empty value it is today. - On every tool call the server compares the claim with the row and refuses
401 harness_mismatchon a difference;tools/listandcheck_allowedreadharness_keyfrom the row, never from the claim. When the run row is missing,tools/listrefuses401 run_not_foundand never falls back to the claim (contract 12.5, recorded 2026-09-16). Fact (verified inapi/app/services/tool_server.py:147-151, 547-553):run_not_foundanswers401withdata.code = run_not_found, and_harness_for_listraises it when the run row is absent, never falling back to the claim | Consequence: the refusal is built. The worker’s bootstrap envelope carriesharness_key,harness_version,harness_personasand, for twin runs,twin.workbench_role_key, so the container applies the row’s harness prompt and reports it back (verified inapi/app/services/agent_runtime.py:224-267andagent/trovensa_agent/server.py:154-167). - A harness never changes inside a run and no token is re-minted. Choosing another workbench role mid-conversation applies to the next run; “look at this as a toxicologist” is a lens request (R5), not a switch.
Twin runs keep persona_key as the authority for duties; role-keyed duty templates apply only when the bound
person wears the role.
Enforcement at the tool server
The tools a run may see are an intersection:
tools/list = tools_for_role(run role) ∩ HARNESSES[run.harness_key].toolsetand what it may call is filtered again by the person’s live permissions (allowlist_for). check_allowed(tool_key, role, plan_json, plan_approved, harness) adds one denial to the three that exist (unknown_tool,
policy_denied, plan_violation): harness_denied, for a tool outside the toolset, audited as
agent_run.harness_denied in the same way as policy_denied. The agent treats it as a refusal and the web shows
it in the message feed as the existing question item. Three tools are never a plan violation because every
harness needs them (ALWAYS_ALLOWED_TOOLS): memory.recall, memory.propose_note, lens.list_rubrics (R5, by
name), plus the run’s harness skills. memory.propose_promotion is not in the set in R4; the tool is
designed (contract 12.4, recorded 2026-09-16). Fact (verified in
api/app/tools/allowlists.py:37): the set holds memory.recall, memory.propose_note and lens.list_rubrics
only | Consequence: the set matches the contract.
Two rules about what an agent may read: any read that returns prose is flagged untrusted and offered to the
reader only (the container drops it for every other agent), and every structured read a non-reader may hold
carries a closed output schema (no unknown keys, strings capped at 300 characters, no key named text,
description, abstract, summary, body, function or caption); values outside it are dropped, truncated
and counted on the call row. Titles from tickets, registers and datasets are admitted at 120 characters under the
untrusted: label. No external tool server is proxied in any release; the structural test “no external server
mounted” stays.
Refusal rules, in plain words
Every harness prompt carries the same refusals. The agent:
- never approves, signs, notifies or sends anything; those verbs do not exist in its tools;
- gives no number without a skill result or a cited source;
- makes no compliance or regulatory-acceptance claim;
- makes no timing claim;
- brings no fact from another program into a program run;
- puts no vendor or model name in an artefact title;
- gives no legal advice (patents) and no medical advice (clinical);
- presents every dose output as a projection for a decision, never a starting dose;
- lets memory change nothing about its rubric, its refusals or its citations; the memory brief is data,
wrapped with the fixed prefix
<<<memory:...>>>.
The container’s contract tests (make verify in the agent repository) check the tools/list snapshot per
harness, that every flagged untrusted tool is absent from every non-reader agent, that harness lists are
subsets of the persona bundle, that the injection fixture through memory.propose_note is refused or
neutralised, and that the refusal lines are present.
Rubrics
Rubrics live in api/app/skills/rubrics.py, shared by the server and the agent. Each criterion states
evidence_required; a criterion without a citation is no_evidence, rendered hollow with assertion kind
assumption. Scores are stored 0 to 3 only for the “Changed since scored” diff and rendered as words:
no_evidence, weak, moderate, strong. No verdict word exists anywhere.
What the person sees
Every proposal reads “Proposed by <Name>’s twin (agent, working as <workbench role>); decided by <Name>”
(“working as Medicinal chemist”; a run under a neutral persona harness reads “working as Scientist”). The
harness key, version, rubric key, rubric version, run id and acting user are recorded in the audit envelope of
every agent write, beside the fields the envelope already carries. AgentRunOut gains harness_key?,
harness_version?, rubric_version?; ToolCallOut gains check_logic_version?, inputs_hash?,
result_artifact_id?, output_violations.
Lens reviews (R5, reserved)
A lens is a harness applied read-only to one record on a person’s explicit request; a lens review is one
lens_review run of up to four lenses in sequence under a read-only approved plan; its scorecard is the
lens_scorecard artifact; the disagreement view shows criteria in rows and lenses in columns with “Lenses
disagree here” first, and has no aggregate, no ranking and no verdict word. Its fixed phrase, on every
scorecard: “A lens is not a review. Only a <role> reviews.” The column for a role the requester does not hold
reads “<role> rubric (no <role> involved)”; a lens requested by a proposer is labelled “Requested by the
proposer” and cannot attach to the proposer’s own decision record. A scorecard can never be a stage-gate
required record, a check input or an approvals attachment; no duty template schedules a lens review. Route
POST /programs/{program_id}/lens-reviews, LensScoreOut, the lens_ready notification and the tone word
they bring arrive with R5 (0007_lenses).
Related
- Workbench roles, Skills, Memory.
- Agent versions for how a prompt version becomes an active agent version.
- About agent actions and records.
Source: PRODUCT-CONTRACT.md sections 12.2, 12.4, 12.5 and 12.7; PRODUCT-PLAN.md Part 9 section 5; api/app/tools/catalogue.py (RUN_ROLES, FORBIDDEN_TOOL_VERBS); api/app/tools/allowlists.py (check_allowed denials today: unknown_tool, policy_denied, plan_violation); api/app/auth/run_tokens.py; docs/lib/content.ts (NEUTRAL_HARNESS_KEYS, HARNESS_COUNT, RUBRIC_LEVELS); decisions/0031-harness-from-the-run-row.md