An agent that can change its own settings is a great feature and a dangerous one.
The model deciding what to change is the same model whose sandbox it is changing,
reasoning over a session that may already have gone wrong. Give it a free hand and
it can argue itself out of its own restraints.
controller/ exists to remove that free hand. Four guardrails do it:
- Arithmetic decides, not the model. A call happens only when
five numbers cross a threshold — never on every turn.
- A closed list of eight actions. Anything else is rejected
by a parser before it can run.
- One direction only. Every policy change must make the
session stricter, checked by a pure function.
- Nothing sticks without a trail. What persists is dry-run
by default, logged, and revertible by id.
flowchart TD
turn["a turn ends
TurnFinished"] --> sig["Signals: five numbers"]
sig --> trip{"tripped()?"}
trip -->|"no"| done["nothing happens
no call, no cost"]
trip -->|"yes"| mcall["one model call
no tools at all"]
mcall --> parse["parse(): one of eight, or Rejected"]
parse --> rej["Rejected
logged, discarded"]
parse --> act["a typed proposal"]
act --> narrow["narrow(): tighten only"]
act --> log["the mutation log
proposed / applied / reverted"]
narrow -.->|"a widening"| rej
Stage 1. Deciding without a model about 10 minutes
1Five numbers and a threshold each
controller/triggers.py
Look for: Signals CHECKS tripped summary
The first question is the cheap one to get wrong: who decides whether to even
call the controller? Ask a model, and every turn pays for a call whose only
job is to say "no call needed" — a hidden cost the harness promises not to have.
So the answer is plain arithmetic, not a model:
Signals — five numbers about the turn that just ended: context
fullness, consecutive tool failures, share of today's cost cap spent, tokens
written, seconds taken.
CHECKS — one comparison per number, against thresholds set in
[controller].
tripped() — returns the names of the checks that fired, or
nothing.
Signals = { context_full, error_streak, cost_used_pct, tokens_written, turn_seconds }
CHECKS = [
(context_full over threshold.context, "context almost full"),
(error_streak over threshold.errors, "too many failed tools in a row"),
(cost_used_pct over threshold.cost, "near the daily cap"),
(tokens_written over threshold.tokens, "wrote a lot this turn"),
(turn_seconds over threshold.time, "turn ran long"),
]
def tripped(signals):
return [name for check, name in CHECKS if check(signals)]
This is pure: no I/O, no state, no idea what a provider is — the same shape as
permissions.decide() and routing.select_model(), for the
same reason.
- A threshold of zero for
error_streak turns that
check off, not "fire every turn" — the reading a person actually means
when they set a config value to zero.
burn_rate is spend over the daily cap. No cap means a rate of
zero, so it never trips: there's no sensible fraction of a limit that isn't
there.
summary() turns a bare name like "errors" into a line with the
actual count. It feeds both the controller's own prompt and
edgar controller log, so the two never drift apart.
Take with you: before you let a model decide
something, check whether a comparison would do. The comparison is cheaper, it is
testable, and you can read it.
Stage 2. The whitelist about 15 minutes
2Eight actions, and no ninth
controller/proposals.py
Look for: ACTIONS Proposal parse BUILDERS targets Outline Rejected
The controller answers with one JSON object. This file turns it into one of eight
frozen types — or a Rejected that says why not. Same shape as
permissions/policy.py's Allow | Deny | Ask: a tagged union
the type checker enforces, which is a better place for a whitelist than a prompt.
def parse(json_from_model):
# 1. is it valid JSON with an "action" field?
# 2. is that action in ACTIONS? <- the whole security property
# not in the list -> Rejected, logged, done
# 3. build the typed Proposal for that action, or Rejected if the fields don't fit
...
Step 2 is the whole security property: an action not in ACTIONS never
reaches a builder. The eight:
compact, switch_model — change something lasting,
dry-run first
tighten_policy, abort, warn_user,
noop — act now, gone when the process ends
propose_instruction, propose_skill — write a file
for a human to read, never applied by the controller itself
Notice what's not on the list: no learn action.
ADR-0017
closed four roads by which text the harness didn't choose could become an active
fact. A controller that could write one would be a fifth — and the widest, since it
reads a summary of a session that may already be compromised. It can suggest you type
/remember; only a human actually types it.
targets() (ROUTE-8) picks which models switch_model may
name. Not "whatever the provider registry accepts" — only models the project already
named itself, in a [[route]] rule or a [model] binding. A
host you never configured is refused by the parser, not trusted and audited later
(PRV-15).
Outline, last, is all the controller is given: counts and
names — which checks tripped, which tools ran, how many failed, what the check said.
No tool output, no error text, no prompt body. The same discipline the learner works
under (Learning, stop 1): the controller's answer changes
how edgar behaves, so it only ever reads text edgar wrote about itself.
Take with you: a whitelist enforced by a parser beats
the same whitelist written in a system prompt. One is a rule; the other is a
request.
Stage 3. One direction only about 15 minutes
3Humans widen, machines tighten
controller/tighten.py
Look for: Narrowing narrow is_narrowing MODES VERDICTS Malformed
This is the file the whole milestone leans on. One of edgar's standing rules is
"no automated component may widen policy" (PERM-8,
ADR-0021).
narrow() is the controller's half of it: a pure function that takes the
current Policy plus a proposed Narrowing, and returns
either a stricter policy, or one line naming the field that tried to loosen.
Four fields may move, each with its own "stricter":
mode — one road only: yolo → auto → ask → read-only,
never back
shell_deny — added to, never taken from
write_paths — may shrink, only to patterns already on the list
- per-tool verdicts —
allow → ask → deny, never the other way
Two rules are blunter than they look, on purpose:
- A narrowing can never introduce a new
write_paths
pattern — even one that looks tighter. Whether ./src/** is narrower
than ./** is glob-semantics judgement, and neither this function nor a
model gets to make that call.
- An explicit
allow is refused outright, not compared. An explicit
rule overrides the mode's own default, so it can widen the policy even where it
looks like a no-op.
Where each check lives matters too. allow, and a mode that isn't a
real mode, are refused in Narrowing.from_dict at parse time — neither
answer depends on the current policy. Whether ask is looser than what
you already have does depend on it, so that check lives in
narrow() instead. The property test found this split, not a read-through:
its first draft happily parsed {"rules": {"shell": "allow"}} into a
proposal and left the refusal to apply time.
def narrow(policy, proposal):
# per-field checks above (mode direction, deny-only-grows, ...)
new_policy = apply(policy, proposal) # build the candidate
# second, independent check: did we actually get stricter?
if not is_narrowing(policy, new_policy):
return Rejected("not a narrowing")
return new_policy
The last check looks redundant, and that's the point: it rebuilds the policy and
asks is_narrowing() whether the result really is stricter than the
start. The field rules above are where a mistake would live; this is a second,
simpler statement of the same property, used both here and by
tests/property/test_controller_tightening.py.
A bug has to defeat both.
That property test generates policies and narrowings together — looser modes,
unknown verdicts, patterns the policy never had — and checks every pair either comes
back at least as strict, or gets refused. It also checks the quieter half: an
accepted narrowing was actually applied. A narrow() that gives up and
hands back the original policy is a narrowing too, and the wrong one.
Take with you: when a rule has a direction, write
the direction down as an ordered tuple and index into it. MODES.index(a) <
MODES.index(b) is a sentence you can check; four nested conditionals about
which mode is stricter is a sentence you can only hope about.
Stage 4. The log is the state about 10 minutes
4Dry run, one row, and a revert by id
controller/store.py,
controller/apply.py
Look for: Controls Mutation overrides apply Site Outcome revert approve PERSISTED
Start with overrides() — the whole design is in that one query.
There is no table of current settings. What edgar is running under is
derived from the log: the newest applied row per action wins.
- Reverting is a single
UPDATE on one row.
- The log and the live state can never disagree — there's no second place to
look when they seem to (CTRL-10).
- Compare
memory/store.py's undo, which reached the same shape from
the other direction.
The eight actions split into three groups, and the split is the whole safety
argument:
flowchart LR
subgraph lasting["outlives the session"]
direction TB
c1["compact"]
c2["switch_model"]
end
subgraph now["dies with the process"]
direction TB
n1["tighten_policy"]
n2["abort"]
n3["warn_user"]
n4["noop"]
end
subgraph file["a file, for a human"]
direction TB
p1["propose_instruction"]
p2["propose_skill"]
end
lasting -->|"dry-run row"| human["edgar controller apply ID"]
now -->|"acts immediately"| gone(["logged, then forgotten"])
file -->|"writes .edgar/proposals/*.md"| person(["a person reads and adds it"])
tighten_policy is the one exception — not dry-run, because CTRL-8
already guarantees it can only make the session stricter. A tightening that waits
for approval doesn't happen while the thing it's reacting to is still going on. It's
logged like everything else; it just also takes effect now.
abort sounds like it should do more than it does. The gate runs
after a turn, so there's no turn left to stop — instead it takes the
session's hands away: narrow() to read-only for the rest
of it, in memory, never persisted. Not a consolation prize: the failure it targets
(a model looping, repeating an edit, burning the budget) continues into the
next turn, and read-only is exactly what stops that.
Last, the rule this whole file is shaped around: nothing here can write a
hand-authored file. propose_instruction writes Markdown into
.edgar/proposals/, named after its own log row, saying what to add — a
person adds it. The code never spells out which file the instructions
belong in (AGENTS.md? a CLAUDE.md? a prompt profile?),
and a path it never writes is a path it can never open. Enforced by
tests/unit/test_controller_boundary.py,
which reads every string literal in the package out of its syntax tree (CTRL-12,
ADR-0008).
Take with you: if you can derive the current state
from the audit log, do, and delete the copy. Two sources of truth about what a system
is doing is one more than anybody can keep correct.
5One call, in the background, that cannot fail your turn
controller/gate.py,
controller/__init__.py
Look for: Gate BRIEF attach overrides _running _look
Everything so far has been pure or local. This is the file that watches a real
session:
on TurnStarted: start a clock
on ToolFinished: if it failed, count it
on TurnFinished:
signals = build the five Signals (context, errors, cost, tokens, time)
if not tripped(signals):
return # no call, no cost, no latency
outline = Outline(signals, tools_run, failures, check_result)
reply = provider.stream(prompt(outline), tools=[]) # <- no tools at all
proposal = parse(reply) # one of eight, or Rejected
act on it, log it
The empty tool list is the whole safety argument in one argument.
ADR-0008
asked for a "hard-limited tool set"; the limit that needs no maintenance is zero. The
controller cannot read a file, run a command, or call anything at all — its entire
power is the shape of its answer, one of eight (CTRL-3). What it reads is an
Outline: counts and names, nothing more. Same discipline as the
learner, same reason (MEM-9).
- The bus is synchronous; a model call is not. The gate hands it off with
asyncio.ensure_future and a module-level _running set
so the task survives, the same trick extensions/hooks.py already
uses for a fired hook.
- A
-p run that exits immediately can drop a call still in flight.
That's CTRL-11 as designed, not a bug — the turn never waits on the controller.
_look() holds the package's only bare except, on
purpose. Housekeeping that can break your session is worse than no housekeeping:
a timeout, nonsense answer, or unreachable host just leaves a row in
rejected.
__init__.py is two functions and the whole seam: attach()
subscribes the gate, overrides() reads the log. cli/setup.py
reaches both by name with importlib — same as
edgar.learning — so no Core, v1 or v2 module imports this package, and
deleting the folder costs one quiet ModuleNotFoundError (NFR-12,
ADR-0015).
The first line of attach() checks [controller] enabled,
false by default: subscribes nothing, returns. A harness that promises "no hidden
calls" doesn't start making one the turn you upgrade.
Take with you: the cheapest way to make an
automated helper safe is to give it nothing to work with. No tools, one answer
shape, and a wrapper that swallows its failures.
6Three commands, and the one that is missing
controller/cli.py
Look for: command USAGE
edgar controller log — everything it did, would have done, and
was refused, newest first, id in the first column.
edgar controller apply ID — turns one dry run into the real
thing.
edgar controller revert ID — takes one applied row back out.
That's the entire surface. Refusals print right under the mutations, not hidden
behind a flag — on purpose. A controller refused every single turn is a
configuration problem (threshold too low, a model that can't follow the brief), and
the failure mode worth designing against is the one where nothing says so.
There is no edgar controller run. You cannot ask for a controller
call from the outside: the gate is the only thing that starts one, and a crossed
threshold is the only thing that asks the gate (CTRL-2). A manual trigger would be
one line of code — and would quietly undo the argument the rest of this page
makes.
The file lives here, not in cli/, like learning/cli.py
before it and for the same reason: a module under cli/ that imported
edgar.controller would break tier isolation and fail
tests/unit/test_architecture.py. cli/main.py reaches it by
name, so deleting the package deletes the commands and nothing else (NFR-12).
Take with you: the commands you leave out are part
of the design. Ask what each new verb lets someone bypass.
Sizes
| File | Lines of code | What it is |
controller/triggers.py | ~40 | Five deterministic checks, no model involved |
controller/proposals.py | ~176 | The eight actions, parsed and refused |
controller/tighten.py | ~118 | Policy may only get stricter |
controller/store.py | ~84 | The mutation log, and the state derived from it |
controller/apply.py | ~165 | What each action does, dry-run by default |
controller/gate.py | ~232 | The post-turn gate, its one tools-free call, and the synthesis step |
controller/cli.py | ~38 | edgar controller log|apply|revert |
controller/__init__.py | ~37 | attach() and overrides(): the whole seam |