Harness Engineering / Promotion to the harness / One rule, five mount points

The journey of a rule that got promoted

You are building your own coding agent, not configuring someone else's. That changes what a rule is: not a line of prose you hope the model weighs, but a decision about where in your own code the constraint lives. A custom harness has five distinct places to put one, and choosing between them is the engineering. This follows a single rule up all five, in LangChain, LangGraph, and deepagents. The predicate never changes. Only its mount point does, and each move buys something the rung below it could not.

companion repo · github.com/tacoda/fulcorum-harness-promotion
runtime · Python 3.11+ · langchain[anthropic] · langgraph · deepagents · one env var, ANTHROPIC_API_KEY
the rule under promotion · money is integer cents, never float · 40 lines of predicate, 5 mount points, 1 ledger
who it's for · engineers building a coding agent of their own, deciding where a constraint belongs in their code
substitutions · money.py = your highest-risk module · the predicate = whatever invariant you actually carry
Feedforward
Prose the model reads before it writes. Shapes the draft. A nudge, never a guarantee.
Enforced
The harness refuses. Runs whether or not the agent read anything, in code the agent never sees.
Sanctioned & recorded
The escape hatch the gate deliberately leaves open, and the ledger line it writes when taken.
0 The candidate

What earns a rule a promotion, and what does not

Most rules should stay prose forever. Promotion costs code, and code you have to keep true as the system changes. A rule earns the next rung only by failing at the one it is on, in a way you can point at.

rule money is integer cents, never float predicate gate.py · check()

The rule this page follows is one sentence: money is integer cents, never float. It is a good candidate for exactly three reasons, and if a rule of yours cannot make all three claims, leave it as prose and spend the effort somewhere it matters.

  1. Consequential Being wrong costs money, and it costs it silently. A float that drifts a hundredth of a cent does not raise, does not fail a test, and does not appear in a log. It appears in a reconciliation report six weeks later.
  2. Recurrent It has been violated more than once, by different people and different agents, in the same module. A rule broken once is an incident. A rule broken four times is a rule the prose is not carrying.
  3. Decidable A machine can settle it. "No float in the money module" is a predicate over a syntax tree. "Use ports and adapters" is not, and no amount of promotion will make it one.

That third test is the one that disqualifies most candidates, and it is worth being blunt about. The design rules that matter most to a codebase, the ones about layering and dependency direction and where business logic is allowed to live, are exactly the rules a gate cannot decide. They stay in prose, and they are carried by the code the agent imitates and by the humans who review it. Promotion is not a general answer to rule-following. It is a narrow tool for the small set of invariants that are both expensive and mechanical.

The predicate, once

Everything below mounts this same function. It parses a source file, walks the tree, and returns a list of strings. It knows nothing about agents, tools, graphs, or models, which is the entire reason it can be mounted in five places without modification.

gate.py · the whole enforceable surface40 lines, no dependencies
import ast

ALLOW = "money-gate: allow"          # the sanctioned escape, per line

def check(source: str, filename: str = "<source>") -> list[str]:
    """Return one message per float-shaped money expression. Empty list = clean."""
    lines = source.splitlines()
    problems = []

    def allowed(node) -> bool:
        line = lines[node.lineno - 1] if node.lineno <= len(lines) else ""
        return ALLOW in line

    for node in ast.walk(ast.parse(source, filename)):
        if allowed(node):
            continue
        if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
            problems.append(f"{filename}:{node.lineno}: `/` yields a float; use `//`")
        elif isinstance(node, ast.Call) and getattr(node.func, "id", None) == "float":
            problems.append(f"{filename}:{node.lineno}: float() in a money path")
        elif isinstance(node, ast.Constant) and isinstance(node.value, float):
            problems.append(f"{filename}:{node.lineno}: float literal {node.value!r}")

    return problems
Stated ceiling

This predicate is a syntactic heuristic, not a proof. It cannot see a float arriving from a JSON payload, a database driver, or a function in another module. It is deliberately crude, because a gate that is hard to explain is a gate people route around. Its job is to catch the overwhelmingly common case (someone typed / where they meant //) at a cost of forty lines. Anything subtler than that is a test's job, or a type's.

1 One sentence

Prose, but injected by your own code

Where every rule starts. In a stock agent it is a static line in a charter file. In a harness you wrote, you build the system message yourself, which means you can decide per-turn whether the rule is worth its tokens.

surface the system message you assemble hook @wrap_model_call loop feedforward

The rule itself is one paragraph, and a capable model reading it writes // most of the time. Most of the time is the operative phrase, and it is why there are four more rungs. What owning the harness changes is not the sentence, it is that you get to choose when it is present.

rung1_prompt.py · conditional feedforwardshapes the draft
from langchain.agents.middleware import wrap_model_call
from langchain_core.messages import SystemMessage

MONEY_RULE = (
    "Money is integer cents. Never float, never Decimal, never a string. "
    "Division is `//`. If you need a fraction of a cent you need a different "
    "function and a conversation, not a `/`."
)

@wrap_model_call
def inject_money_rule(request, handler):
    """Append the rule to the system message, but only when it is relevant."""
    if not touches_money(request.messages):
        return handler(request)                # spend the budget elsewhere

    base = request.system_message
    text = base.content if isinstance(base, SystemMessage) else (base or "")
    joined = f"{text}\n\n## Money\n{MONEY_RULE}".strip()
    return handler(request.override(system_message=SystemMessage(content=joined)))

A charter file is unconditional: every rule pays for itself in context on every turn, including the turns about CSS. Assembling the prompt in code lets a rule cost nothing until it is relevant, which in practice is how you keep a growing rule set from crowding out the task. It is a small win, and it is the only thing rung 1 gains from being in a custom harness.

What rung 1 buys

  • Universality. It applies to every task, including the ones you did not anticipate. A gate only fires where you mounted it; prose reaches everywhere the prompt goes.
  • The why. A gate says no. Prose says why, and the why is what stops the agent from working around the no.
  • Zero maintenance drift. A sentence cannot break when you rename a module.

What it cannot buy

  • A guarantee. The rule competes for attention with everything else in the prompt, and it loses that competition on long tasks.
  • Reach past the context window. If the rule is not in what the model read, the rule does not exist for that turn.
  • Any force at all against the code. If the tree is full of /, imitation beats instruction, and the rule loses to the surrounding code every time.
Promotion trigger

You have written the rule clearly, it is reaching the model, and it was violated anyway. Not once under time pressure, but repeatedly, on a module where the cost is real. That is the only evidence that justifies the next rung, and asking for it before you climb is how you avoid a harness made entirely of gates.

2 Tool design

Make the violation unrepresentable

The cheapest enforcement in a custom harness is not a gate at all. It is a tool whose schema cannot express the thing you are forbidding. You only get this rung because you own the tool surface, and it is the one most people skip straight past.

surface the tools you define mechanism the signature

Look at two tools that do roughly the same job. One needs every rung above it. The other needs none of them.

rung2_tools.py · the same job, two surfacesthe rule is the type
@tool
def write_file(path: str, content: str) -> str:
    """Write content to a path in the working tree."""
    # `content` is a string, so any rule about what may be inside it
    # has to be enforced somewhere else. This tool forces rung 3.

@tool
def set_price(sku: str, cents: int) -> str:
    """Set a product's price, in integer cents."""
    # There is no float-shaped argument to pass. The violation this
    # page is about cannot be expressed. No middleware, no ledger,
    # no retry loop, nothing to keep true as the code changes.
    if cents < 0:
        raise ValueError("price cannot be negative")

A narrow tool is enforcement that costs nothing to run, cannot drift out of sync with the rule, and produces a validation error the model can act on without any code of yours firing. Before you write a gate, it is worth asking whether the tool that permits the violation needed to be that wide in the first place. Frequently the honest answer is that write_file exists because it was easy, not because the agent needs arbitrary writes.

Where the ceiling is

  1. Coercion A schema is not a proof. cents: int rejects 1080.5, but a whole-valued float like 1080.0 is silently coerced to 1080 by validation. That is harmless here and would not be in every domain. Check what your validator actually does rather than assuming.
  2. Irreducible width Some tools genuinely have to take arbitrary text. A coding agent that edits source cannot be handed a schema that expresses "valid money code". That is precisely the case rung 3 exists for.
  3. Not retrofittable Narrowing a tool changes the agent's whole vocabulary and every prompt that assumed the old one. Cheap when you are designing the surface, expensive once a system depends on it.
The trigger for rung 3

You reach rung 3 the moment the tool cannot be narrowed further and still do its job. For a coding agent that is almost immediately, because the whole point of the tool is to write arbitrary source. Everything above this line is about the case where the schema ran out of road, which for agents that edit code is the normal case rather than the exception.

3 The promotion

Into the harness: a tool-call gate the agent cannot see

This is the actual promotion, and it is a smaller code change than it sounds. The predicate moves out of the agent's environment and into the orchestration layer, where it runs on every tool call the harness brokers, in every run the harness drives.

dep langchain[anthropic] hook @wrap_tool_call env ANTHROPIC_API_KEY

LangChain's agent middleware wraps the tool-call boundary. A decorated function receives the request and a handler, and decides whether to call it. Refusing means returning a different ToolMessage instead of invoking the handler, so the reason travels back to the model as an ordinary tool result and it keeps working with the specifics in hand.

rung3_middleware.pythe tool call never happens
from langchain.agents import create_agent
from langchain.agents.middleware import wrap_tool_call
from langchain_core.messages import ToolMessage

from gate import check, guards
from ledger import record
from rung2_tools import write_file

@wrap_tool_call
def money_gate(request, handler):
    """Refuse a write that puts a float in a money path."""
    call = request.tool_call                   # {"name", "args", "id"}
    if call["name"] != "write_file":
        return handler(request)

    path = call["args"].get("path", "")
    if not guards(path):
        return handler(request)

    problems = check(call["args"].get("content", ""), path)
    if problems:
        record("blocked", rule="money-cents", path=path, detail=problems)
        return ToolMessage(
            content=(f"REFUSED by money-gate: {'; '.join(problems)}. "
                     "Money is integer cents. Use `//`, not `/`."),
            tool_call_id=call["id"],
            status="error",
        )

    record("passed", rule="money-cents", path=path)
    return handler(request)                    # clean; let the write happen

agent = create_agent(model="claude-opus-5",    # reads ANTHROPIC_API_KEY
                     tools=[write_file], middleware=[money_gate])

Read the refusal branch again, because the whole rung is in it. The gate does not warn the model, or ask it to reconsider, or add a line to a system prompt hoping it lands. It returns before handler(request) is ever called. There is no write to undo, because there was no write. The bad state never existed.

What promotion actually bought

the same rule, at the three rungs so far
Property1 · Prose2 · Tool schema3 · Middleware
Holds when the model ignores it No Yes Yes
Works on arbitrary source edits Yes, weakly No. A schema cannot express "valid money code" Yes
Survives a model swap Depends on the new model Yes Yes. It is not in the prompt, so the model is irrelevant to it
Reachable by prompt injection Yes No No
Explains itself Yes, that is its whole job Only as a validation error Yes, in the refusal text
Cost Tokens Free, where it fits at all Code to keep true as the rule changes
The property worth naming

At rung 3 the constraint stops being part of the conversation. Prompt injection cannot reach it, a long context cannot crowd it out, and a persuasive intermediate step cannot talk it down, because it is not a claim the model is weighing. It is a branch in your code. That is the whole difference between a rule the agent follows and an invariant the system has.

4 A graph node

The gate becomes a node with a bounded retry

Middleware refuses and hopes the agent recovers inside its own loop. A graph makes the recovery explicit: the gate is a node, the failure is a value in state, and the retry is an edge you can count and cap.

dep langgraph node gate edge add_conditional_edges
rung4_graph.pyfail closed after N attempts
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import AnyMessage, add_messages

from gate import check
from ledger import record

MAX_ATTEMPTS = 3

class State(TypedDict):
    messages: Annotated[list[AnyMessage], add_messages]
    draft: str            # the candidate source
    problems: list[str]   # why the gate refused, fed back to the writer
    attempts: int

def write(state: State) -> dict:
    """Ask the model for money.py. state["problems"] is the feedback channel."""
    ...

def gate(state: State) -> dict:
    problems = check(state["draft"], "money.py")
    record("blocked" if problems else "passed", rule="money-cents",
           path="money.py", detail=problems)
    return {"problems": problems, "attempts": state["attempts"] + 1}

def route(state: State) -> str:
    if not state["problems"]:
        return "accept"                       # clean
    if state["attempts"] >= MAX_ATTEMPTS:
        return "give_up"                      # fail closed, do not ship
    return "write"                            # retry with the reason in state

builder = StateGraph(State)
builder.add_node("write", write)
builder.add_node("gate", gate)
builder.add_node("give_up", lambda s: {"messages": ["money-gate: unresolved"]})

builder.add_edge(START, "write")
builder.add_edge("write", "gate")
builder.add_conditional_edges("gate", route, {
    "accept": END,
    "write": "write",
    "give_up": "give_up",
})
builder.add_edge("give_up", END)

graph = builder.compile()

Three things changed, and only one of them is about enforcement.

  1. The loop is bounded An agent left to recover on its own can retry the same wrong idea until it exhausts a budget. MAX_ATTEMPTS is a number, in your code, that a persuasive model cannot renegotiate.
  2. Failure is a route Giving up is a node, not an exception. It can notify, open a ticket, hand to a human, or write a record. Compare the factory's governance gates, which are the same idea at a larger grain.
  3. The loop is legible Someone can read the graph and see exactly how many times a violation can recur before the system stops. At rung 3 that behavior existed, but only in the agent's judgment.
Do not climb here by default

Rung 4 buys control over the recovery, not stronger enforcement. Rung 3 already made the violation impossible; the graph makes the response to it explicit and bounded. If your agents recover fine on their own and you have never watched one thrash against a gate, this rung is ceremony. Climb it when you have seen the thrash, or when the give-up path genuinely needs to do something other than stop.

5 Isolation & evidence

Gate the subagent, and write the ledger

At the top of the ladder the gate does two things it could not do below: it keeps a bad result out of the planner's context entirely, and it leaves enough evidence behind to decide whether the rule still deserves to be here.

dep deepagents subagent money-writer evidence ledger.jsonl

A subagent is a context boundary. Work happens in its own window and only a summary returns to the planner. That boundary is a mount point: apply the gate to what the subagent returns, and a violating result never enters the planner's history at all. The planner cannot later imitate a mistake it never saw.

rung5_deepagent.pygated at the boundary
from deepagents import create_deep_agent

from rung2_tools import write_file
from rung3_middleware import money_gate       # the same gate object, unchanged

money_writer = {
    "name": "money-writer",
    "description": "Writes and edits anything under the money module.",
    "system_prompt": (
        "Money is integer cents. Never float. Division is `//`. "     # rung 1 is still here
        "A money-gate refusal names the exact line; fix that line."
    ),
    "tools": [write_file],
}

agent = create_deep_agent(
    model="anthropic:claude-opus-5",           # reads ANTHROPIC_API_KEY
    tools=[write_file],
    subagents=[money_writer],
    middleware=[money_gate],
)

Notice what did not disappear. The system prompt still carries the rule in prose, because rung 1 never stopped being useful. A gated agent that has not been told the rule burns turns rediscovering it by trial and error. The rungs accumulate; they do not replace. By rung 5 the same invariant is stated in prose, enforced at the tool boundary, bounded in the graph, and recorded on every firing, and each of those is doing a different job.

The ledger, which is the actual point of rung 5

Every firing appends one line. It is deliberately boring, and it is the only thing on this page that tells you whether any of the preceding work was worth doing.

ledger.jsonl · one line per decisionevidence
{"ts":"2026-08-18T14:02:11Z","rule":"money-cents","outcome":"blocked","path":"billing/money.py",
 "detail":["billing/money.py:14: `/` yields a float; use `//`"],"run":"a41f"}
{"ts":"2026-08-18T14:02:19Z","rule":"money-cents","outcome":"passed","path":"billing/money.py","run":"a41f"}
{"ts":"2026-08-18T16:40:03Z","rule":"money-cents","outcome":"allowed","path":"billing/report.py",
 "detail":["line 88 carries `# money-gate: allow`"],"run":"b07c"}
  1. blocked The gate refused. A rule with a healthy block rate is doing work. A rule that has never blocked anything is a candidate for demotion.
  2. passed The gate ran and found nothing. Counting these is what turns "it feels like this fires a lot" into a rate you can act on.
  3. allowed Someone took the money-gate: allow escape. This is the most valuable line in the file: a rising allow rate means the rule no longer matches the code, and the rule is what needs fixing.
Why evidence is the top rung

Rungs 1 through 4 add constraint. Rung 5 adds the only thing that lets you ever take constraint away. Without a ledger, every gate you write is permanent by default, because nobody can argue it is not pulling its weight. That is how a harness silently accretes into something nobody wants to touch.

6 Down the ladder

The move nobody makes: demotion

Every team that adopts gates gets good at adding them. Almost none get good at removing them, and a harness that only accumulates is a harness that eventually gets bypassed wholesale.

Read the ledger quarterly. It takes ten minutes and answers a question the team is otherwise guessing at.

reading the ledger, and what each pattern means
Pattern over a quarterWhat it meansMove
Blocks steady, allows near zero The rule is live and correctly scoped. It is catching real attempts. Leave it. This is a gate earning its keep.
Zero blocks, many passes Nothing has tried to violate it in months. The code and the models have both settled. Demote to prose. Delete the mount, keep the sentence. Re-promote if it comes back.
Allows climbing The predicate no longer matches how the code legitimately works. People are routing around it correctly. Fix the predicate, or narrow its scope. Do not tighten the gate.
Blocks on the same line repeatedly The gate is fighting the tree. Some existing code teaches the pattern the gate refuses. Fix the code. A gate cannot out-argue imitation forever.
Never fired at all The mount point is wrong, or the path filter never matches. The gate is decorative. Verify with a deliberate violation, then fix the mount or delete it.

That second row is the one worth internalizing, because it is the one people resist. A gate that has not blocked anything in a quarter is not proof the gate is working. It is equally consistent with the gate being unnecessary, and the only way to tell is to remove it and watch. Demotion is cheap and reversible: the sentence stays in the charter, the predicate stays in the repo, and re-promoting is a three-line change you have already done once.

Where this page sits

There is deliberate overlap with two other write-ups here. They are not three points on one ladder; they are three different questions about whose code the constraint lives in.

  1. Guardrails Constraining a stock coding agent. Five guardrails is what you do when the agent is someone else's: you shape it through the surfaces it exposes, its charter, its rules, its hook contract. You are a configurer.
  2. This page Building a coding agent of your own. No hook contract to conform to, because the loop is yours. The constraint is a branch in your code, and the engineering is choosing which of five mount points it belongs at.
  3. The factory Running any coding agent. A lights-on factory sits above both: it takes a tracker card and returns a merged pull request, and it does not care whether the agent inside is stock or custom. Its gates are at the grain of whole jobs, not single tool calls.

The rungs on this page therefore have no equivalent in the other two. A stock agent gives you rung 1 and a hook, and nothing between them. A factory gates the job, not the write_file call inside it. Owning the loop is what puts tool schemas, tool-call interception, graph topology, and the subagent boundary on the table at all.

The one-sentence version

Promote a rule only when the rung it is on has demonstrably failed, mount the same predicate rather than writing a new one, keep every rung below it in place, and record enough to argue for taking it back down.