ADR-0145: Charter-declared loops — the repo owns the workflow, the factory owns governance¶
Status: Proposed Date: 2026-08-31 Enforcement: enforced Enforced by: - pytest:tests/test_charter_loops_schema.py::TestBidirectionalBinding::test_an_actor_that_moved_to_a_package_is_still_enumerated - pytest:tests/test_charter_loops_schema.py::TestBidirectionalBinding::test_an_actor_named_by_no_loop_is_a_drift_finding - pytest:tests/test_charter_loops_schema.py::TestMisparseIsLoud::test_a_loop_body_that_is_not_a_mapping_raises - pytest:tests/test_charter_loops_schema.py::TestEmptyIsNotAbsent::test_the_two_are_distinguishable_on_a_loaded_charter - pytest:tests/test_charter_loops_schema.py::TestDuplicateKeysAreAnError::test_the_guard_covers_the_whole_charter_not_only_loops - pytest:tests/test_charter_loops_schema.py::TestTriggerVocabularyIsBound::test_an_on_clause_is_rejected_citing_this_adr Binds: factory Supersedes: none Superseded by: none
Precedent: GitHub Actions and GitLab CI, where the repository declares which
workflows exist and when they run while the platform owns isolation, secrets,
retries and the merge gate (.github/workflows/*.yml; GNAA's loops.yml, the
evidence repo for this design)
Divergence: a CI workflow declares steps — an ordered list of commands the platform executes literally. A charter loop declares an actor and a goal, and the executed behaviour comes from a markdown contract the platform renders as a system prompt, so the platform cannot validate the work by reading the declaration. Every binding here is therefore checked structurally rather than trusted: an unresolvable actor, an unbound trigger, or a duplicate key is an error at load, not a run that quietly does nothing. (receipts: #11669 — the ten-instance path-membership class this ADR's enumeration predicate is written against; #11751 — the one-way binding that let standard.yaml and its README drift; GNAA hit the duplicate-key defect and fixed it in bin/lib/loops.py:load_loops, and its own loops.yml header concedes "today the Operator is the event detector for all of them")
Context¶
charter.yaml schema_version: 1 declares what a repo is — its purpose,
articles, actors and artifacts (ADR-0143).
It cannot declare what runs. Every agent HydraFlow executes is a catalogued
Python class: DirectorTurnRunner, PlanWorkerRunner, ReviewWorkerRunner.
Adding an agent to a repo means adding a class to the factory.
docs/proposals/charter-declared-loops-v1-1-0.md (revised 2026-08-29 against
its own evidence repo) proposes the inversion: the repo declares which actors it
has and when they run; HydraFlow runs the outer loop. The proposal is reviewed
and on main, but a proposal is not a decision, and its three open operator
questions were answered in a session on 2026-08-31 — that is, nowhere durable.
The v1.1.0 implementation children (#11860, #11861, #11862, #11863) have no
ruling to conform to until this exists.
The evidence is one repo, and it is not an independent sample. GNAA is the only repository running this pattern, and it was built by the same operator. The proposal says so plainly, and this ADR inherits that limitation: what follows is ruled from one worked example plus this repo's own defect history, not from a population.
Decision¶
1. The contract: schema_version: 2, a loops: block keyed by loop¶
schema_version: 2
actors: agents/ # UNCHANGED — still a pointer, never a roster
loops:
records-docket:
actor: records # defaults to the key when omitted
enabled: true
trigger: # a LIST of clauses; any may fire the loop
- cron: "0 9 * * MON"
goal: >
Produce the docket: every open request with its custodian, statutory due
date, state, and next action.
budget_usd: 4.00
timeout_s: 900
model: sonnet
output:
branch_prefix: "records/"
gate: pr
Keyed by loop, actor: defaulting to the key. The loop↔actor relation is
many-to-one: GNAA runs records and records-quarterly over one
agents/records.md. Its runtime already resolves this by trimming a name suffix
(bin/convene: "tried trimming to a base agent"). Making the relation
explicit beats inferring it from a naming convention, because a convention is a
predicate over names, and predicates over names are what this repo keeps getting
wrong (#11669, #11723, #11781).
enabled: false IS dormancy. No separate dormant: list. A second list is a
second roster in miniature — the exact shape _parse_actors already exists to
reject — and it admits both contradiction (an actor in both lists) and staleness
(a dormant entry naming a deleted actor). One table, one namespace, no
both-lists state.
The envelope ships at v1.1.0. budget_usd, timeout_s and model are in
the schema from the start, not bolted on. A kernel worker needs all three on day
one; the only question was whether they are schematised or improvised, and GNAA
already has them plus a budget-refusal exit path.
gate: pr is the only value at v1.1.0. It names what already happens rather
than inventing policy.
2. The five guards, each anchored to a shipped defect¶
Each is non-negotiable and ships with its own test.
Guard 1 — bidirectional resolution, with a DEFINED enumeration predicate.
Every loop's actor resolves to an actor file, and every actor file is named
by at least one loop. One-way binding is how standard.yaml and its README
drifted until #11751.
The enumeration predicate is part of the contract, not an implementation detail:
top-level agents/*.md plus agents/<name>/README.md, minus README.md
and runtime.md, minus governance directories (council/, board/,
operator/). A narrower predicate — "top-level *.md minus README" — silently
stops seeing an actor the day it moves from finance.md to finance/README.md.
That is this repo's documented ten-instance path-membership class (#11669),
where a membership test matching nothing returns False and nothing reddens.
It therefore ships with a mutation test performing exactly that module→package move and asserting the guard still sees the actor. A missing side of the binding is a drift finding, not a load error: a repo mid-migration must still load.
Guard 2 — a mis-parse fails LOUD, never "dormant." An unparseable loops:
block raises CharterError. src/charter.py already draws this line correctly
one level up: load_charter returns None only for a repo with no charter.
Guard 3 — empty is not absent. A present-but-empty loops: {} declares that
nothing runs, and is valid. A missing block means an unmigrated repo. The
caretaker skips the second and must not skip the first.
This extends charter.py's fail-loud convention and explicitly not
policy.models.Charter.governs()'s fail-open one. The two words live in one
codebase and mean opposite things; naming which one applies is the point.
Guard 4 — duplicate keys are an error. yaml.safe_load silently keeps the
last duplicate. Two finance-close: entries — one enabled: true, one false —
load clean, schema-validate clean, and the first vanishes. GNAA already hit this
and already fixed it (bin/lib/loops.py:load_loops). The loader uses a
duplicate-key-rejecting constructor, applied charter-wide: today's
_read_mapping has the same hole for the charter as a whole and is fixed in the
same change.
Guard 5 — trigger vocabulary is bound, like actors are. on: <event> values
resolve against a declared event registry; an unresolvable one is a
CharterError. Without this, on: inbound_recieved is schema-valid and the loop
silently never fires — silence-as-failure one field over.
Also settled: if both agents/x.md and agents/x/README.md exist, that is a
CharterError. Two files for one key is the two-tables defect at file
granularity.
3. Operator rulings (2026-08-31)¶
Ruling 1 — a per-run goal override is ALLOWED, and every override is
recorded in the run receipt. Unrecorded input is disqualifying; recorded input
is fine. The receipt is what makes the difference, not the override.
Ruling 2 — an enabled loop with an unparseable actor contract REFUSES the run and alerts. It files nothing and never degrades to a default prompt. A default prompt would produce plausible work attributed to an actor whose contract nobody could read — worse than no run, because it looks like a run.
Ruling 3 — on: event triggers are DEFERRED. v1.1.0 ships cron clauses
only. The schema reserves the on: shape; the validator rejects it with a
message naming this ADR. Chosen because on: is aspirational even in the
evidence repo, whose own loops.yml header says "today the Operator is the
event detector for all of them." Shipping a field that looks automatic but
requires a human detector is silence-as-failure one field over — guard 5's own
objection, applied to guard 5's own feature. on: returns when a detector
exists.
4. What each side owns¶
| owns | |
|---|---|
| the repo | which actors exist · what each is for · when it runs · what done looks like |
| the factory | worktree isolation · PR lifecycle · label/phase transitions · review gates · HITL escalation · credit exhaustion · watchdogs · workspace GC |
The repo half is markdown plus one YAML block. The factory half is machinery HydraFlow already has and a repo should never reimplement. That asymmetry is the whole argument for the inversion: the parts a repo would get wrong are exactly the parts it no longer writes.
5. The kernel worker¶
One generic runner parameterised by charter rather than by code. Per tick:
- Load the target repo's
charter.yaml; refuse loudly ifloopsis unparseable. - Select due loops (
enabled, and anytriggerclause fired). - For each: create an isolated worktree, render the actor's charter file as the
system prompt with
goalas the task, dispatch one brokered worker inside its envelope. - Hand the output to the existing gates — branch, PR, labels, review, merge policy.
- Record a receipt.
Steps 1–3 are new. Steps 4–5 already exist, and that is the reason this is worth doing at all.
The kernel may never enable a loop. Enabling is an ENACT belonging to a human, already ruled by ADR-0143 Ruling 6 guard 4. This ADR adds no new authority; it adds a declaration surface under the authority that already exists.
Consequences¶
Migration is additive. schema_version: 1 charters keep loading unchanged.
An absent loops block means "no charter-declared loops", which is every repo
today.
A new failure mode: a repo can declare a loop the factory then refuses to run. Guards 1, 2, 4 and 5 all turn a malformed declaration into a load error, which surfaces as a broken charter rather than a quiet non-run. That is the intended trade — the alternative is the pattern this whole ADR exists to avoid.
The one-sample limitation stays live. Every ruling above is derived from one repo built by the same operator, plus this repo's defect history. A second independent adopter is the evidence that would confirm or break these choices, and none exists yet. Rulings that turn out to be GNAA-shaped rather than generally right should be superseded on that evidence, not defended.
Enforcement landed with #11860. This shipped as decision-of-record
because the five guards named tests that did not exist yet, and listing them as
Enforced by: anchors would have been six citations resolving to nothing — the
dangling-anchor class this repo has already paid for twice (#11781, and the
term-file anchors that resolved green while their prose cited a deleted class).
11860 added the tests and flipped the status in the same change, which is the¶
only sequence that never leaves a decision nothing checks.
on: is a reserved hole. The schema carries a shape the validator rejects.
That is deliberate — it keeps migration lossless for repos whose prose triggers
say "and on each telemetry candidate" — but a reserved field that always errors
is itself a small lie, and it should not survive more than one minor version
without either a detector or removal.