You are building your own coding agent, not configuring someone else's. That changes what a rule is. It is not a line of prose you hope the model weighs. It is a decision about where in your own code the constraint lives. A custom harness has five distinct places to put one. 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.
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 carry
Most rules should stay prose forever. Promotion costs code. You have to keep that code 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 three reasons. If your rule cannot make all three claims, leave it as prose. Spend the effort where it matters.
That third test disqualifies most candidates. No gate can decide the design rules that matter most. Layering, dependency direction, and where business logic lives stay in prose. The code the agent imitates carries them. So do the humans who review it. Promotion is not a general answer to rule-following. Treat it as a narrow tool for invariants that are 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. That 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 crude on purpose. A
gate that is hard to explain is a gate people route around. It catches the common case:
someone typed / where they meant //. It costs 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. You can decide per-turn whether the rule is worth its tokens.
The rule itself is one paragraph. A capable model reading it writes // most of
the time. Most of the time is the operative phrase. That is what the next
four rungs are for. Owning the harness does not change the sentence. It changes when the
sentence 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. That saving keeps a growing rule set from crowding out the task. The win is small. It is all rung 1 gains from being in a custom harness.
/, imitation beats instruction. The rule loses to the surrounding code every time.You wrote the rule clearly. It reaches the model. 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. That avoids a harness made entirely of gates.
The cheapest enforcement in a custom harness is not a gate. It is a tool whose schema cannot express what you forbid. You only get this rung because you own the tool surface. That is also why most people skip straight past it.
Two tools do the same job. One needs every rung above it. The other needs none.
@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. It cannot drift out of sync with the
rule. It produces a validation error the model can act on. No code of yours fires. Before you
write a gate, ask whether the tool that permits the violation needed to be that wide. Often
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. It 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. 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 normal, not exceptional.
This is the actual promotion. 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. It 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. 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. The whole rung is in it. The gate does not warn the model. It
does not ask it to reconsider. It does not add a line to a system prompt hoping it lands. It
returns before handler(request) is ever called. There is no write to undo. 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. A persuasive intermediate step cannot talk it down. The constraint is not a claim the model weighs. It is 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. 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. Only one is about enforcement.
MAX_ATTEMPTS is a number in your code. A persuasive model cannot renegotiate it.
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 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. 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],
)
The system prompt still carries the rule in prose. 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 harness states the same invariant in prose and enforces it at the tool boundary. It bounds the retry in the graph. It 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. 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. 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. A harness that only accumulates is a harness that eventually gets bypassed wholesale.
Read the ledger quarterly. It takes ten minutes. It 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. It is the one people resist. A gate that has not blocked anything in a quarter is not proof the gate is working. 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. The predicate stays in the repo. Re-promoting is a three-line change you have already done once.
Two other write-ups here overlap deliberately. 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 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 puts tool schemas, tool-call
interception, graph topology, and the subagent boundary on the table.
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.