Requirements: why the CLI reads harness-config.yaml, and the rule that says when it may
Phase 1 of 3 (requirements → design → tasks). Following the Kiro spec approach (https://kiro.dev/docs/specs/). This phase MUST be reviewed and approved by the required collaborators before moving to design.
Introduction
Issue #121 asks three questions about one sentence in the CLI docs — "Repo-scoped — reads the repo's harness-config.yaml":
- Can we reason on why the CLI is reading the harness config?
- Should it be doing it?
- If not, can we move it to
cli-config?
The answers, established by tracing the tree and recorded here so nobody has to trace it again: because those values are the repository's own policy and the CLI executes that policy on the repository's behalf; yes; and no — moving them would be a regression, for the four reasons in Analysis.
The question is worth an issue because the documentation invited it. The rule the docs state is per-process ("daemons read cli-config.yaml, repo-scoped commands read harness-config.yaml"), and that rule is both the wrong shape and — since issue-113 — factually false in four places. The rule that actually holds is per-direction. This work item replaces the one with the other, and pins the resulting read surface with a test so it cannot drift again.
What the CLI reads today
Three readers, five keys, all of them per-repository policy the plugin/skill reads too:
| Reader | Keys | Consumers |
|---|---|---|
graph/bootstrap.py::load_harness_config | workflow.phaseLabelPrefix, workflow.specDir, notifications | the-loop check, the-loop graph, and the daemon via graphlink.py |
critics.py::load_critics | reviews.critics[] | the-loop critic list/show/run |
commands/scenarios.py::_load_config_globs | testing.integrationTestGlobs | the-loop scenarios |
Each reader carries its own copy of the same harness-config.yaml → pre-rename config.yaml fallback (issue-82, decision-035) — three copies of one rule, in three modules, with three different failure behaviours for an unparseable file.
The documentation is wrong, not the code
graphlink.py is constructed by webhook/dispatcher.py, which both ingresses share, and GraphLinkConfig.enabled defaults to true. So on every spawn and every routed event with a spec directory, the daemon calls build_runtime(root) → and reads the checkout's harness-config.yaml. That has been true since issue-113 shipped. Four documents still say it never happens:
| Document | The claim |
|---|---|
docs/config/index.md | "The daemon never reads a repository's harness config … Not for anything" |
docs/cli/concepts.md | "The daemon never reads a repository's harness config" |
docs/cli/commands/index.md | daemon commands "read the CLI config — not any repository's harness config" |
docs/cli/index.md | a Mermaid split of "Daemon — reads cli-config.yaml" vs "Repo-scoped — reads the repo's harness-config.yaml" |
The intent behind those sentences is correct and important — it is decision-032's consequence that the daemon's own settings (who may trigger it, what it watches) never come from a checkout. Overstating it into "never reads the file at all" is what makes it false, and what made #121 a reasonable question to ask.
Analysis
Recorded here because the issue asks for reasoning, not just an outcome. The four reasons reviews.critics[], workflow.*, notifications and testing.integrationTestGlobs cannot move to cli-config.yaml:
- Wrong cardinality. They are per-repository values.
cli-config.yamlis one machine-scoped file for a daemon that watches N repositories, so holding them there needs a hand-maintainedOWNER/REPO →map that drifts from the repository it describes the moment someone edits one and not the other. - Two sources of truth for one value. The skill reads
reviews.critics[](reference/reviewing.md) andworkflow.specDirfrom the harness config. If the CLI read them fromcli-config.yaml,the-loop critic runand the agent following the skill could disagree about what the critics are — the same fork decision-043 refused when it kept the review loop in one implementation. - It breaks the checkout-only cases.
the-loop checkis a CI gate: it runs in a bare checkout, as a job with no operator home directory and nocli-config.yamlanywhere.the-loop scenariosis the same. Sourcing their settings from the operator's machine config would make them unconfigurable exactly where they are used. - The trust argument runs one way only. Decision-032 removed the plugin → daemon fallback because a checkout must not be able to tell the daemon who may trigger it or what to watch — a repository is untrusted input to an operator's machine. The reverse carries no such risk: a repository saying "my specs live in
docs/specs" can only affect work on that same repository. The one genuinely powerful key,reviews.critics[], is already labelled executable config, is reviewed like code, and is never run implicitly (critic runnames exactly one critic).
Requirements
Requirement 1 — the rule is recorded as a decision
User story: As a maintainer, I want the direction-based rule written down as a decision record, so that the next "why does the CLI read this?" is answered by a link.
Acceptance criteria
- WHEN a reader opens
docs/decisions/THEN the repository SHALL contain a decision record stating the invariant: a repository's harness config may configure work done on that repository; it may never configure the daemon itself. - WHEN that record is read THEN it SHALL name the three current readers and their keys, and SHALL record the four reasons the keys cannot move to
cli-config.yaml. - WHEN that record is read THEN it SHALL state its relationship to decision-032 — refining its consequence, not reversing it — and SHALL be listed in
docs/decisions/decisions.md.
Requirement 2 — the false claims are corrected
User story: As a CLI user, I want the docs to describe what the code does, so that I do not have to read graphlink.py to find out which file configures what.
Acceptance criteria
- WHEN
docs/config/index.md,docs/cli/concepts.md,docs/cli/commands/index.mdanddocs/cli/index.mdare read THEN none SHALL claim that the daemon never reads a repository's harness config. - WHEN those pages are read THEN each SHALL state the direction-based rule, and SHALL keep the still-true, load-bearing part of the old sentence:
authorizedUsersand a poll source'sreposare CLI-config-only, with no fallback, failing closed. - WHEN
docs/config/harness-config.mdis read THEN it SHALL carry a section enumerating every key the CLI reads, which command reads it, and what it falls back to. - WHEN
docs/cli/index.md's diagram is read THEN it SHALL show the daemon's read of a work item's own checkout, rather than partitioning the two files by command.
Requirement 3 — one reader, not three
User story: As a contributor, I want a single module that reads the harness config, so that "which keys does the CLI read?" is a question with a place to look.
Acceptance criteria
- WHEN the CLI resolves a harness config path THEN it SHALL do so in exactly one module, and
graph/bootstrap.py,critics.pyandcommands/scenarios.pySHALL delegate to it. - WHEN a repository has
.the-loop/config.yamlbut not.the-loop/harness-config.yamlTHEN the CLI SHALL still read it (issue-82, decision-035), with the fallback expressed once. - WHEN a harness config is absent, unparseable, or not a YAML mapping THEN the shared reader SHALL return an empty mapping, AND
criticSHALL continue to raiseCriticConfigErroron an unparseable file — a critic that silently reviews nothing is worse than one that refuses. - WHEN the change is complete THEN every existing public name (
bootstrap.load_harness_config,critics.config_path) SHALL still resolve, so no caller or test outside the CLI package breaks.
Requirement 4 — the read surface is pinned by a test
User story: As a maintainer, I want a red build when a new harness-config key is read without being declared, so that the rule survives contact with the next feature.
Acceptance criteria
- WHEN the shared reader is added THEN it SHALL declare the read surface as data — each key, the command that reads it, and why it is repo-scoped.
- WHEN the test suite runs THEN it SHALL assert every declared key resolves in
.the-loop/harness-config.schema.json. - WHEN the test suite runs THEN it SHALL assert that no module outside the shared reader opens a harness config file, naming the offending file and line when one does.
- WHEN the test suite runs THEN it SHALL assert every declared key is documented in
docs/config/harness-config.md, and that no key documented as CLI-read is absent from the declaration. - WHEN
docs/is absent (a source distribution) THEN the documentation assertions SHALL skip rather than fail, matchingtest_docs_parity.py.
Requirement 5 — no behavioural change
User story: As an operator, I want this to be a rename-and-explain, so that upgrading changes nothing about how my daemon or my CI gate behaves.
Acceptance criteria
- WHEN the CLI runs after this change THEN every command SHALL read exactly the keys it read before, from exactly the same paths, with the same defaults.
- WHEN the full test suite runs THEN it SHALL pass with no pre-existing test modified to accommodate the change.
- WHEN the capability docs are read THEN
docs/capabilities/cli.mdSHALL describe the invariant and carry a history row for this work item.
Security considerations
Threat model (lite). The asset is the operator's machine; the untrusted input is any repository checkout the daemon can reach. The question this work item answers is a trust-direction question, so it is a security question.
- Trust boundary unchanged. No new key is read, no key is read from a new place, and no read moves across the boundary decision-032 drew.
authorizedUsers,polling.sources[].reposand every other ingress setting remain CLI-config-only with no fallback; nothing in this change gives a checkout a say in them. - The keys the CLI does read from a checkout are bounded by blast radius.
phaseLabelPrefix,specDir,notificationsandintegrationTestGlobsaffect only work done on that same repository.reviews.critics[]is the exception that matters — it is executable configuration — and it is unchanged here: still committed, still reviewed like code, still only ever spawned by an explicitthe-loop critic run <name>withshell=False(decision-043). - The daemon's read is already gated.
graphlink._checkout_belongs_toproves, via the checkout'soriginremote, that the directory really is a checkout of the work item's repository before any harness config there is read, and fails closed when it cannot tell (issue-113, A6). Writing the rule down makes that gate's purpose legible; it does not relax it. - Documenting the read surface is not disclosure. Every key named is already in the published schema and the shipped template.
- Risk tier: 3 (
autonomy.defaultTier). Docs, a decision record, an internal refactor with no behavioural delta, and a test.security.review.humanSignOffMinTieris 4, so no human security sign-off is required; the PR review is the tier-3 gate.