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.
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 the one it is on, in a way you can point at.
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.
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.
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.
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. 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.
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.
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.
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.
/, 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 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.
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.
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, 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.
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.
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.
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.
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.
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, 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.
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 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.
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.
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.
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.
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.
{"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. 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.
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.
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. 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.
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 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.