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 every move up trades effort for a guarantee the rung below cannot make.
github.com/tacoda/fulcorum-harness-promotionlangchain[anthropic] · langgraph · deepagents · one env var, ANTHROPIC_API_KEYmoney.py = your highest-risk module · the predicate = whatever invariant you actually carry
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 its current rung, in a way you can point at.
The rule this page follows is one sentence: money is integer cents, never float. That rule is a good candidate for exactly three reasons. If your rule cannot make all three claims, leave it as prose and spend the effort where it matters.
That third test is the one that disqualifies most candidates, and it is worth being blunt about. No gate can decide the design rules that matter most: layering, dependency direction, and where business logic may live. Those rules stay in prose, and the code the agent imitates carries them, along with the humans who review it. Promotion is not a general answer to rule-following. Instead, treat it as a narrow tool for the small set of invariants that are both expensive and mechanical.
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 why you can mount it in five places unchanged.
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
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. It catches
the common case (someone typed / where they meant
//) at a cost of forty lines. Anything subtler is a test's job, or
a type's.
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. So you can decide per-turn whether the rule is worth its tokens.
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, which is what the next four rungs are for. What owning the harness changes is not the sentence, it is that
you get to choose when it is present.
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. In practice, that saving is how you keep a growing rule set from crowding out the task. The win is small, and it is all rung 1 gains from being in a custom harness.
/, imitation beats instruction, and the rule loses to the surrounding code every time.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 pattern is the only evidence that justifies the next rung. Ask for it before you climb, and you avoid a harness made entirely of gates.
The cheapest enforcement in a custom harness is not a gate: a tool whose schema cannot express what you forbid. You only get this rung because you own the tool surface, which is also why most people skip straight past it.
Look at two tools that do roughly the same job. One needs every rung above it. The other needs none of them.
@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 and cannot drift out of sync with
the rule. It produces a validation error the model can act on, without any code of yours
firing. Before you write a gate, ask whether the tool that permits the violation needed to
be that wide. Often the honest answer is that
write_file exists because it was easy, not because the agent needs arbitrary
writes.
cents: int rejects 1080.5, but validation silently coerces a whole-valued float like 1080.0 to 1080. The coercion is harmless here and would not be in every domain. Check what your validator does, rather than assuming.
You reach rung 3 the moment you cannot narrow the tool further and still have it do its job. For a coding agent, that moment comes almost immediately, because the whole point of the tool is to write arbitrary source. Every rung above this one addresses the case where the schema has run out. For agents that edit code, that case is the normal one rather than the exception.
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. There it runs on every tool call the harness brokers, in every run the harness drives.
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. The reason travels
back to the model as an ordinary tool result, so the model keeps working with the
specifics.
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.
| Property | 1 · Prose | 2 · Tool schema | 3 · 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 |
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. The constraint is not a claim the model weighs, but a branch in your code. That difference separates a rule the agent follows from an invariant the system has.
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.
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.
MAX_ATTEMPTS is a number, in your code, that a persuasive model cannot renegotiate.
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 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 needs to do something other than stop.
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. It also leaves enough evidence to decide whether the rule still deserves to be here.
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: gate what the subagent returns, and a violating result never enters the planner's history. The planner cannot later imitate a mistake it never saw.
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. So by rung 5 the harness states the same invariant in prose and enforces it at the tool boundary. It also bounds the retry in the graph and records every firing in the ledger. Each of those four mounts does a different job.
Every firing appends one line. The ledger is deliberately boring, and nothing else on this page tells you whether the work above was worth doing.
{"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"}
money-gate: allow escape, and that line is the most valuable one in the file. A rising allow rate means the rule no longer matches the code, so the rule is what needs fixing.
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 default is how a harness silently accretes into something nobody wants to touch.
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.
| Pattern over a quarter | What it means | Move |
|---|---|---|
| Blocks steady, allows near zero | The rule is live and correctly scoped, catching real attempts. | Leave it. That gate is 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. But the same silence is equally consistent with the gate being unnecessary. To tell the two apart, remove the gate and watch. Demotion is cheap and reversible. The sentence stays in the charter, and the predicate stays in the repo. Re-promoting is a three-line change you have already done once.
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.
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.
Promote a rule only when its rung has demonstrably failed, and mount the same predicate rather than a new one. Keep every rung below it in place, and record enough to argue for taking it back down.