A Tour of the Harness · v3 · M13

The controller

Self-management that cannot hurt you. A second model call, off until you turn it on, that may do exactly eight things and can only ever make the session stricter.

Built M13
Kept in sync by a test

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:

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

FileLines of codeWhat it is
controller/triggers.py~40Five deterministic checks, no model involved
controller/proposals.py~176The eight actions, parsed and refused
controller/tighten.py~118Policy may only get stricter
controller/store.py~84The mutation log, and the state derived from it
controller/apply.py~165What each action does, dry-run by default
controller/gate.py~232The post-turn gate, its one tools-free call, and the synthesis step
controller/cli.py~38edgar controller log|apply|revert
controller/__init__.py~37attach() and overrides(): the whole seam