Skip to Content

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

  1. When a run is created the API sets agent_runs.harness_key (with harness_version and rubric_version) from users.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).
  2. The worker copies the key into a harness claim on all six run tokens. No allow-list travels in a token; the allow-list hash stays the empty value it is today.
  3. On every tool call the server compares the claim with the row and refuses 401 harness_mismatch on a difference; tools/list and check_allowed read harness_key from the row, never from the claim. When the run row is missing, tools/list refuses 401 run_not_found and never falls back to the claim (contract 12.5, recorded 2026-09-16). Fact (verified in api/app/services/tool_server.py:147-151, 547-553): run_not_found answers 401 with data.code = run_not_found, and _harness_for_list raises it when the run row is absent, never falling back to the claim | Consequence: the refusal is built. The worker’s bootstrap envelope carries harness_key, harness_version, harness_personas and, for twin runs, twin.workbench_role_key, so the container applies the row’s harness prompt and reports it back (verified in api/app/services/agent_runtime.py:224-267 and agent/trovensa_agent/server.py:154-167).
  4. 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].toolset

and 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).

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