Six loops, one map: what kstrl becomes after R10
PR 221, "remodel kstrl as a control system", merged into main as
72404e9. It adds one document, docs/control-loop-design.md, and files
thirteen issues, #222 to #234, tracked by #235. No code moved. This lesson teaches the system
those issues build, as a picture you can click, for the person who built the rest of it.
Click things; nothing here can break, and nothing is recorded.
1. The problem, before any file name
A story in a component's PRD is done when its passes flag is true. The only
thing that ever sets that flag is the engineer agent, the same agent that wrote the code.
Mechanical verification then reads the flag back and fails the component if any story is
still false. Nothing in the harness writes it. The thing that acts is reporting its own
result, and the harness has been treating that report as a measurement.
That is one defect, and it had nowhere to be seen from. kstrl is described everywhere as a pipeline of phases, and a pipeline has no word for "the thing that measures must not be the thing that acts". The design supplies the frame in which that rule is the first thing you see. Under it, kstrl is not one pipeline but six loops at different rates, and the phase chain everybody quotes is one tick of the middle two.
Two more defects become visible the moment the parts have names. The retry context handed to an agent on its third attempt still carries every failure from its first, with an instruction to fix them all, even the ones attempt two already fixed. And no sensor in kstrl can be run without a full paid factory run, which is why every threshold in the codebase is, in the source's own words, an unmeasured placeholder.
The plan fixes those in a fixed order, adds no phase, renames nothing, and edits no prompt body. What it adds is mostly small: one command, a few files, a handful of rules that make two sensors agree before a claim counts.
2. Words
Each plain word below is used the same way in the prose, the captions and the widgets. The register's glossary defines them; this table only maps each to the thing in kstrl.
| Plain word | In kstrl | What it is |
|---|---|---|
| set point | passes and the acceptance criteria; the PRD; the policy envelope | The state a run steers toward. |
| sensor | MechanicalVerifier, Reviewer, SecurityReviewer, ContractTester, FixturesOracle, Calibration | Anything that measures the tree independently of the agent that changed it. |
| error signal | the typed Finding | The gap between set point and measurement. |
| controller | Pipeline, Scheduler, AutonomyLadder | The code that turns findings into the next action. |
| actuator | EngineerLoop driving the coding agent | The thing that changes the tree. |
| disturbance | a transport failure, a moved base branch, model noise | A change the loop did not command. |
| windup | today's RetryContext | Error accumulating while nothing discharges it. |
| level-triggered | RetryContext after R10.2 | Acting on what is failing now, not on the history of what failed. |
| advisory first | [factory] setpoint_agreement, the dampener report, the demotion triggers | A gate ships measuring and reporting, and blocks only by the operator's judgement. |
| flow control | FlowControl, [serve] max_open_prs | A bound on work in flight, so the loop cannot outrun its reviewer. |
| dampener | Dampener, ks sense --compare-baseline | A stored baseline plus a report of what a branch added. |
| safe mode | SafeMode | One predicate over the degraded states that already exist. |
| memory file | OperatorContext, scripts/kstrl/memory.md | Standing corrections, loaded right after the retry context. |
| human on the loop | Operator, Inbox, Steering | The loop runs itself; the human intervenes on exception. |
3. The system, as one map you can click
This is the atlas's own drawing of kstrl, with the components the R10 plan reaches marked.
It is the spine of this lesson: every later section points back into it by component name.
Press TODAY to see what exists on main and END STATE to see the
system the plan leaves. Then click any card. Its panel says what it does, its interface, its
build state, what flows in and out, and the invariants it serves. Drag to pan, scroll to zoom,
press zero to reset.
Scroll to zoom, drag to pan, click a component to frame it, Escape to go back.
Three things to read off the map before going on. The planned cards sit inside existing
bands, never in a new one: that is doctrine 5, the frozen phase count, drawn. The one card
outside every loop is Sense, the sensors run by hand,
and it is the first item in the build order because every later item is cheaper once a
sensor runs without a factory. And the only line that runs from the actuator back into the
set point is passes flag (a claim), from
EngineerLoop to
PRD. Section 5.1 is about that line.
4. Walk one spec through it
The same map, one step at a time. Each step lights the components that act (amber) and the component that measures the step (steel). Where nothing measures a step, the step says so; those are the open loops.
Try this. Step to "the engineer builds one story" and look for a steel card: only the breaker and the path guard measure anything, and neither reads the code, so the fast loop is open. Then step to "release" and "runtime signals": nothing lights but a ghost, because the operate loop is not built.
scripts/atlas/logical_model.py by docs/lessons/verify/pr-221/map_paths.py;
a step that named a flow the model does not declare would light nothing.5. Why it is built this way
Eight questions the operator would ask, each answered by a rule you can operate. Under
every widget: two moves to try, then the first answer the design tried and what killed it.
Every rule was pulled out of the page into a standalone script and swept over its whole input
range before it was allowed in; the scripts live under
docs/lessons/verify/pr-221/ and the register quotes their output.
5.1 Who is allowed to say a story is done?
Today: the engineer, alone. check_prd_stories at kstrl/verify.py:526
re-reads the PRD the agent just wrote and fails only if a story is still false. The reviewer
already emits a verdict per acceptance criterion, CriterionReview.verdict at
kstrl/review.py:65, and already knows which stories it covered. The second sensor
exists and is not consulted. After R10.3 the two must agree.
Try this. Set criterion 2 to uncovered and criterion 1 to
pass: still agreement, because a criterion with no verdict is ignored and the story
counts as covered by the other. Now set both to uncovered: a finding, "not covered".
Then, with a fail verdict showing, drag the level from 0 to 1 and watch the same finding turn
from recorded to blocking without anyone touching the config.
block or the resolved level is 1
or higher, mirroring layer0_blocks at kstrl/adequacy.py:721. Story and
criteria are invented; the rule is the issue's. Swept by setpoint_agreement.py: 960
rows, 130 findings, 117 of them blocking, none with the default config.The first answer, and what killed it. Keep the flag as the verdict and make the
reviewer's disagreement a hard failure inside the review phase. That is what hard mode already
does for a criterion marked fail, and it misses two cases the widget shows: a story
the reviewer never covered, and an advisory verdict, both of which pass hard mode today. The
second answer, the one the design's earlier draft held, was that requiring agreement meant
editing the engineer prompt's step 14 and so crossing H3. Read step 14 at
kstrl/init_cmd.py:81: "set that story's passes to true
(only after tests/typecheck pass AND the self-critique is written)". It stays exactly correct.
The flag was always a claim; the harness just starts treating it as one. That removed the
supposed prompt cost and moved the item from expensive to cheap.
Two things about the end state are easy to miss. check_prd_stories stays; it
still catches an agent that emitted COMPLETE with a story left false. And in blocking mode the
harness becomes the first writer of passes in kstrl's history: it reverts the story
to false, appends a notes line beginning "reverted by reviewer", and saves the PRD,
so the engineer's own step 7 picks the story up again. The set point is corrected by the
sensor, not by the actuator. That is invariant 12 on the atlas, enforced for the first time.
5.2 Why does the agent see only what is failing now?
The retry context is four lists whose only mutators append, kstrl/context.py:20-46.
There is no clear, no dedup, no expiry. Its renderer ends with the line "Fix ALL issues listed
above before completing." So on attempt 3 the agent is handed attempt 1's failures, which it
may have fixed on attempt 2, unmarked, with an instruction to fix them all. Add failures one
attempt at a time and watch where each lands.
Try this. Add verification four times. The current section
keeps one entry while the old rendering carries four, and from the fourth attempt the new
prompt is the shorter one. Then reset, add review, then verification:
the review entry moves to "not re-measured", because attempt 2 stopped before review ran.
retry_context_rank.py: 1560 sequences.The first answer, and what killed it. Clear the context at the start of every attempt.
The manifest already does that for a sibling field: begin_attempt at
kstrl/pipeline.py:1035 resets comp.review_findings. Clearing throws away
the entries whose sensors did not run in the latest attempt: an attempt that dies in
verification never reaches review, so last attempt's review findings are neither fixed nor
refuted. The rank rule exists to keep exactly those, and it is what the second move above
shows.
The sweep also corrected an expectation. The new rendering is not smaller; it is bounded. For one or two attempts it is longer than today's, because its headings are longer. What changes is the growth: each further attempt at the same phase adds a full failure text to the old prompt and one history line to the new one. Windup is the growth, not the size.
5.3 What happens when the factory runs out of budget for reviewers?
Today the pipeline holds both answers at once. In hard mode with an unchunked review and
max_adversarial_calls exhausted, kstrl/pipeline.py:2465 downgrades the
review to SKIP, warns, and lets the component proceed on mechanical checks alone. A few lines
later the chunked path refuses with an infrastructure_error finding. Same budget,
same mode, two verdicts. Run components through the cap.
Try this. Cap 1, three components, hard mode, after R10.5: the second and
third halt with check adversarial_budget. Flip the rule to today: they merge
unreviewed, with phase_skipped recorded. Then drag the cap to 0: identical under
both rules, which is the default and why the design calls this defect latent.
kstrl/pipeline.py:2696 and is left out: one rule per widget. Swept by
budget_halt.py: under the old rule 20 components in the grid merge unreviewed,
under the new hard rule none.The first answer, and what killed it. Keep the downgrade because it is loud. It is:
the warning prints and phase_skipped is recorded, so an empty finding list still
means every phase ran. The design accepts that and still calls it a priority inversion. Under
budget pressure the harness sheds the reviewer, and by this repository's journal the reviewer
accounts for most of the failure signatures the factory ever catches. unconfirmed
That count, fourteen of seventeen, comes from a journal that is not in the repository. What
can be confirmed is the shape: the chunked path already fails closed, so R10.5 makes the
unchunked path match it and leaves advisory mode alone. Invariant 6, enforcement reads
artifacts: a component that was never measured cannot be treated as measured.
5.4 Why can the daemon refuse to start work?
The daemon, ServeDaemon, runs its admission gates in a fixed order, every one before the claim, because a gate evaluated after the spend is a post-mortem. R10.7 adds one more: refuse while a kstrl-authored pull request is still open. Flip conditions and read which gate refuses first, and whether the refusal is a wait, a pause, or a halt.
Try this. Set open kstrl PRs to 1 with the bound at 1: refused, as a wait, so the next cycle re-checks; nothing is paused and nobody is paged. Untick "gh answered": still refused, because an unknown number of open pull requests is not zero. Then set the poison streak to 3 as well and see the earlier gate win: the order is the mechanism.
serve_cycle at kstrl/serve.py:1866 onward on
72404e9, with R10.7's gate placed where issue #228 puts it, in the gates tuple after
check_budget. That is before the inbox cap and the factory lock, so the issue's
sentence "a GitHub call happens only when everything else admits" holds for the three ledger
gates and not for those two. Defaults: three consecutive poisons pause, the daily budget is off,
the bound is 1. ks factory and ks run never enter this function: a
human typing the command is the authorisation. Swept by admission_gates.py:
110592 rows; only the three ledger gates ever pause.The first answer, and what killed it. Nothing bounded pull requests in flight at all.
Grepping kstrl/ for max_open, open_prs, wip_
and concurrent_pr returns nothing; the bounds that exist are
max_parallel = 4, one queue item per cycle, and an inbox cap of 50, none of which
is about output. A loop that runs daily can open several unreviewed pull requests in a week,
and then the reviewer is the thing that saturates. The design's earlier draft wanted a month
of review-throughput data before choosing the bound; the merged draft ships it at 1, one line
of config, reversible in seconds. unconfirmed The design says that default
is proven in production elsewhere; the source is not named on main. The second dead
end is the one the widget's second move shows: reading a failed count as zero.
5.5 How does the factory earn and lose autonomy?
The ladder, AutonomyLadder, is the controller's gain schedule: it selects how much the factory may do without a human from the evidence it has accumulated. Promotion needs evidence plus a human's acknowledgement; demotion is automatic and immediate; and the permissions a level grants are derived at run start, never stored, so a hand-edited flag cannot grant what the ladder never awarded. Drive it.
Try this. Press decisive run eight times and clean merge five times, then promote: L2. Press policy violation: back to L1 at once, counters reset, and promotion is locked for ten decisive runs, so the way up is slower than the way down. Then, at any level above L2, untick "policy envelope enabled": the earned level stays, the level a run may use drops to L2, because there is no envelope to auto-merge inside.
kstrl/autonomy.py on 72404e9: promote,
demote, promotion_blockers, flag_bundle_for,
resolve_runtime_level and manual_override_notes, plus R10.11's
calibration trigger from issue #232. The thresholds (8 decisive runs, 5 merges for L2, 15
clean merges for L3, 30 for L4, a 10-run cool-down) are copied from the source, which calls
every one of them an unmeasured placeholder; the widget teaches the shape, not the numbers.
Swept by autonomy_ladder.py: eleven scripted sequences and 64 clamp
combinations.The first answer, and what killed it. Independent flags. Before the ladder,
pause_before_pr_merge, review_mode and deps_allow_new could
each be flipped alone in a config file, so "how much may this factory do without me" had no
single answer and no audit record. The ladder makes autonomy one ordered level with a derived
bundle that can only withhold. Tick "config says merge gate off" at L1 and read the note: the
contradiction is recorded, and the bundle wins. That is invariant 7. The gap R10.11 closes is
the other direction: five demotion triggers are declared and only the policy violation fires
today, so a factory quietly getting worse without breaking policy kept its level. The
calibration trigger ships advisory, an inbox item, with demotion behind a switch, for the same
reason every other gate in this cycle does: the numbers are placeholders.
5.6 In what order is the agent's context assembled, and why does memory come last?
The engineer's prompt is one string, built once per attempt, and the order of its parts is
a mechanism. Today the worker joins knowledge, feedforward and the retry context,
kstrl/factory.py:1439-1450; then run_loop prepends the worktree's
CLAUDE.md to the instructions and puts that whole prefix in front,
kstrl/loop.py:529-541. R10.8 adds golden patterns after knowledge; R10.9 adds the
memory file as the last part of the prefix, immediately after the retry context.
Try this. Untick the memory file: what follows the retry context is now
CLAUDE.md, the project's general instructions, and if the worktree has none, the
templated prompt itself. Switch the state to today: the golden and memory slots vanish
whatever you ticked, because nothing loads them yet.
parts list at kstrl/factory.py:1439-1450 and
the prepend at kstrl/loop.py:529-541 on 72404e9, with the two insertions
issues #229 and #230 specify. Presence toggles are the only inputs; nothing here is measured.
Swept by prefix_stack.py: 80 combinations.The first answer, and what killed it. kstrl already has three files that look like
standing feedback: the Agent Learnings section of CLAUDE.md, per-directory
AGENTS.md files, and codebase_map.md. The design says none of them is
guaranteed to load after the controller's output, so standing feedback can be shadowed by the
retry context. The code is subtler than that sentence: CLAUDE.md does land after
the retry context today, as the widget shows, while the other two are never in the prompt at
all; the agent reads them, or not, by opening files. What none of the three has is the memory
file's contract: a fixed slot right after the retry context, read from the worktree, a
character budget with a truncation warning, and a writer, since R10.10 appends reviewer
comments to it. The ordering is the mechanism, and so is having exactly one file with that
job. Invariant 9: the human is a role, and this is the file the role writes.
5.7 Why measure by hand before automating?
Every threshold in kstrl is a placeholder, and the design's diagnosis of why is one sentence: you cannot cheaply measure a sensor you cannot run. Pick a command and see how it reaches the mechanical sensors.
Try this. Pick factory: it reaches the sensors, through a PRD,
a branch, a worktree and paid agent time. Pick status: it never reaches them. Then
switch the tree to after PR #237 and pick sense: the first command that measures a
tree and nothing else, exit 0 pass, 1 fail, 2 could not measure.
uv run ks --help on 72404e9, fifteen
commands; the design document says fourteen, and the zero is the claim that matters. Whether a
command reaches the sensors was read from each command's code path; whether any does so
standalone was measured by grepping kstrl/ for callers of
run_mechanical_verification: one, inside the pipeline. The "after PR #237" column
is PR #237's description of itself; it was open, not merged, when this was written.
Swept by sense_commands.py.The first answer, and what killed it. Measure first. An earlier draft of the design
gated seven mechanisms behind "measure this number first": how often the reviewer disagrees
with the done flag, how much stale text attempt 3 carries, review throughput for the bound, and
four more. Every one of those needed a factory run to collect, so measure-first delayed both
the value and the data. What replaced it is advisory-first: ship the gate measuring and
reporting, and the number arrives as a byproduct of running. The rule that survives is the
repository's own: no threshold is chosen to make a feature shippable. So ks sense
goes first, because the dampener is the same sensor pointed at a baseline and the inner-loop
sensor is the same function called between iterations. Invariant 13.
5.8 Which loop does a change belong to, and how fast is it?
Six loops at different rates, and a seventh not built. A change belongs to the loop whose set point it moves, and the loop's rate says how soon its effect can be measured. Pick a proposed change; it lights the loop on the map and says what measures it.
Try this. Pick "a sensor between engineer iterations": the implement loop, seconds to minutes, and the only loop with no sensor today. Then pick "a safe-mode chip in the dashboard masthead": it belongs to no loop. The observe band is a view over every loop, and a sink is never control flow.
docs/control-loop-design.md, mapped onto
the atlas's components by hand; the assignment of the plan step to the integrate loop and of
feedforward to the accept loop is this lesson's reading, not the design's. Every component name
was checked against logical_model.py by map_paths.py.The first answer, and what killed it. Rename the modules to the loop's words:
verify.py to sensor.py, [breaker] to [dampener],
control-flavoured status values. Four things killed it, and the design lists them: doctrine 6
forbids retrofitting existing enums; a breaker interrupts and a dampener opposes rate of change,
so the rename would make the vocabulary less accurate; the README's config sections are
generated from the dataclasses and every rename is a regeneration; and any wording change
inside an adversarial prompt is a calibration-gated change under H2 and H3. New code takes
loop names. Nothing that exists is renamed.
5.9 What the design deletes
In code, nothing yet; the pull request is a document. In the system it defines, four things go, and each removal serves a numbered invariant on the atlas.
- The actuator's sole authority over
passes. The flag stays; its authority does not. Invariant 12: what acts never measures its own result. - One instruction line, "Fix ALL issues listed above before completing", and the accumulation behind it. Invariant 12 again, applied to the controller's own memory.
- The downgrade to SKIP in hard mode when the adversarial budget is exhausted. Invariant 6: enforcement reads artifacts, and an unreviewed component has none.
- Measure-first as a gate on mechanisms. Invariant 8: no assumed numbers, advisory first, graduation by the operator's judgement.
Nothing is renamed. No phase is added. No enum is retrofitted, with one argued exception:
R10.11 adds HEALTH_BREACH to the inbox's ItemKind together with its
emitter, which that enum's own docstring permits; a reviewer of that pull request decides whether
the reading holds.
6. Making the next change here
The plan is thirteen issues in a fixed order. Each ships, and its advisory output is read, before the next starts. The order is the method's own phase order with the by-hand sensor pulled to the front, because every later item is cheaper once a sensor runs without a factory.
| Order | Issue | Lands in | New file or key | Depends on | Advisory first |
|---|---|---|---|---|---|
| 1 | #222 R10.1 ks sense | Sense | kstrl/cli.py command; prd_path: Path | None | nothing | n/a |
| 2 | #223 R10.2 level-triggered retry context | RetryContext | FailureEntry in kstrl/context.py | nothing | exact, no gate |
| 3 | #224 R10.3 set-point agreement | Reviewer, Pipeline | [factory] setpoint_agreement; SETPOINT_DISAGREEMENT_CATEGORY | #223 should land first | yes |
| 4 | #225 R10.4 safe mode | SafeMode | kstrl/safemode.py; a line in ks status | nothing | n/a, a read |
| 5 | #226 R10.5 budget halts in hard mode | Pipeline | none; CHANGELOG marked breaking | nothing | n/a |
| 6 | #227 R10.6 dampener | Dampener | scripts/kstrl/sense-baseline.json; .github/workflows/sense-dampener.yml | #222 | yes |
| 7 | #228 R10.7 flow control | FlowControl | [serve] max_open_prs; PR_FOOTER_MARKER | nothing | n/a |
| 8 | #229 R10.8 golden patterns | OperatorContext | kstrl/operator_context.py; scripts/kstrl/golden-patterns.md | nothing | n/a |
| 9 | #230 R10.9 memory file | OperatorContext | scripts/kstrl/memory.md, last in the prefix | #229 | n/a |
| 10 | #231 R10.10 polled steering, two PRs | Steering, WorkQueue | QueueItem.pr_urls; [intake_github] steer_enabled | #228, #230 | n/a |
| 11 | #232 R10.11 demotion triggers | AutonomyLadder, Calibration | apply_demotion; the kstrl/health.py seam for #151 | #151 for the health half | yes |
| 12 | #233 R10.12 iterate faster | EngineerLoop | [verify] fast_iteration_checks; [factory] convergence_attempts | #222, #223, and an entry criterion | yes, and blocked |
| 13 | #234 R10.13 reframe ARCHITECTURE.md | docs | the loop nest and a glossary | nothing | n/a |
The graduation rule, stated once. A gate moves from advisory to blocking when the operator has seen its output on real runs and can name what it caught and what it flagged wrongly. That is a judgement, deliberately the operator's; no issue encodes a number in its place. The follow-ups it unlocks are listed on #235 and are deliberately not filed yet.
Three things nobody may do in this cycle. Edit a prompt body: no item touches
DECOMPOSE_PROMPT, REVIEWER_PROMPT, SECURITY_PROMPT,
DISTILL_PROMPT or DEFAULT_PROMPT, so H2 and H3 are not engaged, and the
first item that does must bump the version, update the snapshot hash and re-run calibration in
one pull request. Add a phase: new behaviour lands inside an existing phase or outside the
chain. Add an outcome enum: new surfaces reuse the shared disposition.
Two things that will bite whoever picks up the first issue. Every issue ends with
"tick its box in the tracker section of docs/control-loop-design.md", and on
main at the time of writing that section does not exist; the pull request's last
commit removed it before merge, and PR #239 restores it. And the line numbers in the issues
were cited against 94fdbbd; kstrl/ did not change between that commit
and the merge, so they resolve today, and every issue says they will drift.
7. Judge it: what would make this wrong
Answer before you press. Each button says why.
Q1. R10.3 has landed with its default configuration and the ladder off. The engineer marked story B done and the reviewer did not cover story B. What happens to the component?
Q2. Attempt 1 failed review on criterion X. Attempt 2 failed verification on a type error. What does attempt 3's prompt say about criterion X?
Q3. The daemon's max_open_prs is 1 and the gh pr list call
fails with a network error. Does the daemon admit the next queue item?
Q4. An operator sets pause_before_pr_merge = false in kstrl.toml
while the ladder holds L1. What does the run do at the merge?
Q5. Which change belongs to the loop that runs slowest today, and what is its sensor?
8. Close
The system on the map above is the one the plan leaves: the same bands, the same phase count, the same names, with a sensor that runs by hand, a set point corrected by a sensor rather than an actuator, a retry context that discharges, a daemon that will not outrun its reviewer, and a ladder with all its inputs wired. Most of it was already there without a word for it. The design supplies the word.
Anything here that did not land, ask your teacher. The map, the walk and every widget
answer to a script in docs/lessons/verify/pr-221/; if a claim and a script
disagree, the script is the one to read first.