ADR-0041.
`runward rules --for <paths>` — declared territory, with a named match reason
Date: 2026-07-31
Status: accepted (ratified 2026-07-31 — see Ratification)
Deciders: Thibault Souris (maintainer)
Method: decision-loop — a field report from the Dropyour dogfooding mission (2026-07-31: two mapped rules violated in one afternoon, gate green throughout), reality-checked against src/lib/rules.ts, src/commands/rules.ts and the 64 shipped rule files by a four-agent read (fact verification, ADR-corpus constraint analysis, prior-art survey, codebase feasibility); candidate position pending maintainer ratification, not a durable position yet
Context
AGENTS.md prescribes, verbatim: "Apply the craft rules in runward/rules/ while building — and confront them at the point of action, not from memory." Nothing in the tool makes that mechanisable. rules filters by --phase and nothing else; the govern phase alone returns 12 rules (6 CRITICAL, 6 HIGH). At the point of action the instruction therefore resolves to "re-read the 64 and guess" — which no operator and no agent does reliably.
The field report is the evidence ADR-0001 said it was waiting for. Its §Decision 1 states: "This is the coarse, deterministic form of routing… Finer per-task triggers are deferred until evidence demands them." Its reopening trigger (c) names this exact failure: "the phases: mapping produces false negatives — a CRITICAL/HIGH rule that should have fired on a phase was not mapped". In the reported case the agent rewrote a cron and a secret relay, and async-job-guardrails (HIGH) and config-secrets-boundary (CRITICAL) were never surfaced at the moment of writing.
Two things the report got wrong are worth recording, because they narrow what this decision must fix. First, the manifest is not a snapshot in operation: every check --strict re-verifies that cited pointers resolve and that sealed evidence has not drifted. What is a snapshot is the semantic judgment, and that is declared — GATE_NON_SCOPE, amended alongside this ADR, now names both the depth and the temporal blind zone. Second, the report claims the rules "already carry the necessary metadata: tags:". tags: is indeed present on 64/64 rules, but it is thematic (llm ×16, architecture ×14, security ×9), never structural: nothing in the rule set says which files a rule governs. Deriving "this rule applies to src/entry.serve.ts" from the tag async is inference, not fact — precisely the magic the report itself says would destroy trust in the filter.
Prior art converges on one shape: the rule declares its own territory. ESLint config objects carry files:; Semgrep rules carry paths: with include/exclude; CODEOWNERS, pre-commit (files:/exclude:) do the same. And the canonical format for explaining a match has existed for twenty years: git check-ignore -v prints <source>:<linenum>:<pattern>\t<pathname> — which rule, from which file, at which line, retained this path. Semgrep provides the machine equivalent for rejection (skipped_target with a skip_reason enum and an optional rule_id). No surveyed tool emits that explanation for a positive match in its normal output. The space is open, and the format to imitate already exists.
Decision
Proposed, pending ratification: runward rules --for <paths> answers "which rules govern these files", matching only on territory a rule declares, and rendering the pattern that retained it. It is a reading, never a verdict.
The candidate shape (to be confirmed or amended at ratification):
- A declared territory: an optional
appliesTo:frontmatter field, besidephases:/impact:/tags:, holding path globs in the existing inline-list form (appliesTo: [**/cron/**, **/jobs/**]). The rule file is the carrier, for the reason ADR-0040 already established fornonScope: territory is a property of the rule, identical across missions; putting it in the mission would make every mission restate it and let it drift. --for <paths>matches onappliesToonly. A tag is never a path match:tags:remains a thematic index and may later back an explicit--tag <t>filter, where the caller names the tag. The distinction is the fact/inference line of ADR-0014 and ADR-0036 ("counts, never seams").- The match reason is rendered, mechanically, on the
git check-ignore -vmodel: the rule slug, the kind of match, the pattern that matched, the path. In--json,matchedBy: { kind: "appliesTo", pattern: "**/cron/**" }per rule, plus theselectorechoed in the envelope so a stored blob is self-describing. The output says "matched: appliesTo=**/cron/**" — never "applies here", never "violated". - Unscoped rules are reported, loudly, as the honesty mechanism. On the day this ships, 0 of 64 rules declare a territory, so
--forwould return nothing. A primitive that answers "nothing" where it means "I was never told" is the weak verifier ADR-0040 warns about ("if the verifier is weak, the agent will learn to optimize against the wrong signal"). Every--forrun therefore states how many rules declare no territory and are consequently not evaluated, and the output carries the standing caveat: surfacing, never masking — a rule absent from this list is not thereby inapplicable. - Always exit 0. An empty match is never red. Exit 2 stays reserved for "the question could not be asked" (bad path, no rule set). The primitive returns no verdict, so it cannot be mistaken for a second gate.
- runward never computes the change set. There is no
--changed <base>on any command. Paths come from the caller;git diff --name-only "$BASE...HEAD" | xargs runward rules --forships as an inert adapter sample (ADR-0012 form). This is the ADR-0039 line applied literally: the CLI reads your mission repo, never your behavior — the moment runward runs git against a base ref, it stops reading its own data and starts reading the state of the harness. - Territory is written rule by rule, never wholesale.
appliesTois added where a rule's text genuinely prescribes a territory, on the ADR-0020 discipline forsignature:. An absent field degrades cleanly (the rule is unscoped and reported as such), exactly assignature:(1/64) andnonScope:(4/64) do today.
Explicitly out of scope, named so they are not silently dropped:
- Content signals (reading project files to infer that a rule applies). A grep is deterministic, but the object changes: ADR-0020 greps evidence the operator declared, to check a claim has the prescribed shape; a content signal would grep arbitrary code on which the operator asserted nothing, to conclude an obligation. In ADR-0014's vocabulary that is a hypothesis presented as a fact. If ever admitted, it belongs in a separately labelled
hypothesisclass behind its own opt-in flag, never mixed with declared matches and never counted — its own decision. - Ranking or scoring matches.
rules --jsonis sorted by slug and that ordering is a pinned contract (test/smoke.js). A relevance score would be an interpretation; if exposed at all, it is a field, never the sort key.
Alternatives discarded
- Match on
tags:alone (the field report's proposal). Rejected: tags are thematic, not structural, so any path→tag correspondence is an implicit heuristic. It fails the report's own acceptance criterion ("without the reason, no one will trust the filter and everyone will fall back on 'read everything'") because there is no auditable reason to render — the pattern would exist only in the matcher's code. - Infer territory from the rule body. Rejected for the same reason as content signals, one level worse: 8 of 64 rule bodies mention
src/at all, and always inside illustrative code blocks. Mining prose for globs manufactures a fact. runward rules --changed <base>(the report's second form of demand 1). Rejected on the ADR-0039 boundary, and it is the line this ADR is most careful about: a matcher that reads paths handed to it stays a reading of runward's own data; a matcher that derives them from git reads the operator's history. The composition is one shell pipe and stays with the operator, where ADR-0012 puts every wiring.runward check --changed <base>— a conformance regime for the change (the report's demand 2, branch B2). Rejected as contrary to the load-bearing invariant, stated inrunward/contracts/port-contract.md: "Same working tree ⇒ same verdict and same machine outputs." A base ref is not in the working tree and mutates by someone else's push, so the verdict would become a function of (tree, ref, clone depth, git config). ADR-0032 already rejected this class for the clock — "a test that reddens on a Tuesday with no code change is exactly the non-determinism runward exists to ban" — and a remote ref is worse than a clock: it is a third party's write. The failure modes are documented in the field, not hypothetical: SonarSource staff state that reducing clone depth "can lead to issues appearing when they should not, with impacts on the quality gate"; afetch-depth: 1would neutralise the gate in one line of YAML (the ADR-0002 class), or produce the false red ADR-0020 names as the fastest way to erode a gate. Semgrep, the one surveyed tool that protects its determinism here, does so by aborting (no git dir, unstaged changes, or unknown hash) — the guarantee is obtained by refusal, never by promise. Finally, the doctrine is explicit that this would be a second gate: "Nothing else plays this role. There is no 'second gate.'"- An Iterate deliverable carrying a
## Rule conformance(the report's demand 2, branch B1). Already rejected by name in ADR-0033 §Alternatives discarded: "Additerateas a sixth gated phase inPHASES. Rejected:iteratehas no deliverable that is 'filled once and done' — it is a recurring posture… As a gate it would either never complete (permanent red on every mature mission) or complete falsely." The code confirms it is not rhetorical: a missing gated deliverable yields one violation per expected rule. And a manifest that restates the floor manifest is the vacuous paperwork ADR-0017 and ADR-0002 exist to refuse.
Consequences
If ratified:
- Positive.
AGENTS.md's "confront at the point of action" becomes a command instead of an incantation, and it is reusable by any harness — an editor hook, a CI step, a review — without any of them re-implementing a file→rule correspondence that would drift. The rendered pattern makes the result auditable rather than magical, which is the condition of it being trusted at all. The primitive is the missing brick the field report correctly identified; the two glues it names stay outside, where ADR-0012 and ADR-0039 put them. - Negative, accepted. The real cost is editorial, not technical: the code is roughly seven files, but the value only arrives as
appliesTois written rule by rule, and a rule set where few rules declare a territory yields a thin answer. This is mitigated by reporting the unscoped count rather than hidden, but it is a genuine slope — the primitive ships useful-but-empty and earns its worth over time. A second, sharper risk: operators may read the returned list as exhaustive. The standing caveat and the unscoped count are the mitigation; if field use shows they are read past, that is a reopening signal. - On other boundaries. The gate is untouched: no exit-code semantics, no manifest shape, no
checkbehaviour, no new dependency, no git in any verdict path.rules --jsongrows additive fields (ADR-0024).port-contract.mdis updated in the same change.
If rejected: this file stays as the dated record that a change→rule matching primitive was considered against a real field failure and declined, with the reasons above — and the operator-composition route below remains the answer.
Either way, the field need has a home today. The operator composes: runward rules --for $(git diff --name-only "$BASE...HEAD") in a CI step to inform, and runward check --hooks with their own command in runward/hooks.json to block — ADR-0008: "A non-zero hook fails the gate — the operator wired it, so it gates for them." runward stays zero-run, zero-git and deterministic; the operator gets a blocking PR check. No invariant is bent. Separately, and independent of this decision, templates/workflows/iterate.md mentions the craft rules nowhere — a doctrine gap that plausibly did more to cause the reported violations than any missing mechanism, and that costs nothing to close.
Ratification — 2026-07-31
Ratified by the maintainer the same day, ahead of the 2026-11-01 deadline the trigger set.
Nothing is delivered with this ratification, and that is the point of the sequence this ADR states: ratification first, then the code, then the editorial work. Unlike ADR-0040, which shipped its implementation the day it was ratified because the sizing came back trivial, this decision commits to work that does not exist yet. Recording that plainly is part of ratifying it honestly.
What ratification settles, so the implementation has no open questions to re-litigate:
- The carrier is the rule file, an optional
appliesTo:glob list in the existing inline-list form — not the mission, not the manifest, not a separate map. - Matching is on declared territory only.
tags:are a thematic index and never a path match; if a tag filter appears it is an explicit--tag <t>where the caller names the tag. - The match reason is rendered, on the
git check-ignore -vmodel, in both the human and--jsonsurfaces (matchedBy, plus theselectorechoed in the envelope). - Unscoped rules are counted and reported on every run, with the standing caveat surfacing, never masking. This is not decoration: on day one, 0 of 64 rules declare a territory, and a primitive that answers "nothing" where it means "I was never told" would be the weak verifier ADR-0040 warns about.
- Always exit 0; 2 stays reserved for "the question could not be asked".
- runward never computes the change set. No
--changedon any command; thegit diff --name-only | xargscomposition ships as an inert adapter sample. - Content signals stay out, deferred to their own decision if ever demanded.
What ratification does not settle, and what the implementation must still weigh: which rules receive appliesTo first (the discipline is ADR-0020's "never wholesale" — written where a rule's text genuinely prescribes a territory, not by quota); the glob dialect and its cross-OS input normalisation; and the base against which a relative path is resolved in a monorepo, which must be explicit and documented or the result would depend on the caller's cwd.
Delivered — 2026-07-31 (same day)
The implementation landed the same day, and the three questions ratification left open are answered here rather than in a commit message:
- The glob dialect is declared, not inherited. No dependency was added (runward runs on three); a ~20-line translator implements a five-construct grammar stated in
GLOB_DIALECTand echoed in--json: leading**/(zero or more leading segments, so a root-level hit matches too), trailing/**(the directory itself and everything under it),**(any run, crossing/),*(within a segment),?(one character). No braces, no ranges, no negation. Inheriting minimatch or wcmatch semantics would have meant shipping a grammar nobody in the mission could state; a rule author reads this one in five lines. Guarded by the segment-vs-prefix test:**/cron/**matchescron/x,src/cron/xandcron, neversrc/cronjob.ts. - Cross-OS input is normalised, not assumed.
normalizeForPathaccepts Windows separators (src\cron\run.tsgives the same answer assrc/cron/run.ts), strips a leading./, and refuses what cannot be asked: an absolute path, a drive-lettered path, or one escaping the project with..— exit 2, the "question could not be asked" code, never a silent empty answer. - The base is the project, and matching touches no filesystem. Paths are project-relative, the same base
git diff --name-onlyemits from the repo root, so the documented composition needs no translation. Matching is pure string work: nostat, no git, no model — so a path need not exist yet, and you can ask before writing the file. This also settles the monorepo worry: there is nothing to resolve, hence nothing that could depend on the caller's cwd.
Also delivered: RuleInfo.appliesTo, the --for <paths...> flag (variadic, each argument also comma-splittable, so both xargs and a single comma list work), the matchedBy array — one entry per (pattern, path) pair rather than the single object sketched above, because a rule matching three paths through two patterns would otherwise have its reason masked — the selector and unscoped envelope fields, the human rendering on the check-ignore model, and port-contract.md.
Seeded territories: four rules, on the ADR-0020 "never wholesale" discipline — async-job-guardrails, async-scheduled-maintenance, config-secrets-boundary, data-migrations-forward-only. Each was chosen because the rule's own text is about a specific artifact class (background jobs, cron, the secrets boundary, numbered migration files); security-mcp-server-pinning was considered and left unscoped, because its text prescribes no path and a territory would have been invented. A regression test pins the field-report case: rules --for src/cron/graduation-runner.ts src/config/egress-key.ts surfaces both rules that were violated unread on 2026-07-31.
The honest state after delivery: 4 of 64 rules declare a territory. The primitive is complete; its coverage is not, and every run says so. The editorial pass is the remaining work, and it is deliberately slow.
Sequencing. Tracked in ROADMAP.md under Next. The work is roughly seven files of code plus the editorial pass; it does not require a rule-set migration (ADR-0006 tracks renames and removals, and an optional frontmatter field is additive — the ADR-0040 precedent added nonScope with no migration entry). Until it lands, the answer to "confront at the point of action" remains the coarse gesture named in ADR-0042.
Amendment — 2026-07-31: silence is not a declaration
The shipped design had two states — a rule declares a territory, or it does not — and reported the second as a single count. The maintainer objected, and the objection is this ADR's own standard turned on itself: not writing anything is not the same as openly stating that a rule has no territory and never will. That is precisely the lesson ADR-0040 drew from the field survey — an undeclared scope is a weak verifier — applied one level down. A count that lumps "decided: this rule governs no class of files" together with "nobody has looked yet" tells the reader neither.
Amended: territory is declared in both directions, and the two absences are counted apart.
noTerritory: <reason>, an optional prose field besideappliesTo:, states that a rule has no file territory and why. It is the exact mirror ofnonScope:(ADR-0040): per-rule, optional, prose, and the reason is the valuable part. A rule declares one or the other, never both.- Three states, reported as three: territory declared · no territory, declared with its reason · not ruled on yet.
runward explainprints whichever applies, including the third ("not ruled on yet — an omission, not a scope"), and--forreports the last two separately. unscoped.countkeeps its v0.24.0 meaning (rules--forcould not evaluate);declaredNoTerritoryandunreviewedare additive alongside it (ADR-0024). Onlyunreviewedis a backlog, and unlike the other two it is meant to reach zero.
The editorial pass, completed the same day. A five-agent read classified all 64 rules against their own text, with the negative decisions treated as first-class results, and a four-agent second pass ruled on the seven it had left open. Final state: 14 declare a territory · 50 declare they have none, with a reason · 0 unreviewed. The first pass corrected two of the four seeds it inherited — config-secrets-boundary had **/secrets/** and **/env/**, conventional patterns that appear nowhere in the rule's text, and data-migrations-forward-only had **/migrate/**, which does not match scripts/migrate.ts, the runner the rule itself cites. It also refused several plausible territories: process-adr-and-journal names no ADR directory, tools-registry-pattern names no location, and cache-three-tier-architecture is misleadingly named (it governs prompt-prefix stability), so all three would have been inferred from a slug rather than read from a text.
The seven hard cases, ruled on separately. Each targeted a real artifact while its own text prescribed no path, so the second pass weighed one question per rule: does the rule already imply a location a sentence could make explicit, or would anchoring add a prescription it does not carry? Five went to a declared absence, two to an anchor, and the split follows a line worth keeping: topology-sovereignty-by-data-class and topology-usage-registry-present were anchored to execution-topology.md because that is a mission deliverable runward itself scaffolds — naming it prescribes nothing to the client, and the template already carries the very columns each rule demands. The five refusals all turned on the same failure mode: scaling-db-connection-pooling would have dictated the client's tree (its one literal path, prisma/schema.prisma, contradicts its own prescription, since in that variant pooling lives in the connection URL); contracts-governance would have under-declared on **/ports/**, letting a DTO or SQL change read as out of scope — the false negative this ADR's own trigger (a) names; data-orphan-cleanup says "cron" five times, but as a cadence, not a place, so the glob would select on a property orthogonal to the rule's subject; async-post-turn-pipeline would have matched the code that is already correct and stayed silent on the turn handler where the violation lives; observability-alert-configuration shows its own alerts realised three ways, one of them outside the repository. A test now asserts that no rule is unreviewed, so a rule added later must be ruled on rather than quietly reopening the ambiguity this amendment closed.
Reevaluation trigger (mandatory, dated)
This is a candidate: the trigger governs the ratification decision, not a watch on the world. Decide — accept (moving Status to accepted, and naming the first rules that will carry appliesTo) or reject (moving Status to rejected) — at the first groom after a second mission has reported the same point-of-action gap, and no later than 2026-11-01. If neither has happened by that date, the candidate lapses to rejected by default: an unratified primitive must not sit half-open while harness glue is written against it.
Once ratified, reopen if (a) --for results are read as exhaustive despite the standing caveat and the unscoped count — then the caveat is insufficient and the output shape must change; (b) appliesTo coverage stalls below a useful fraction of the CRITICAL/HIGH set, making the primitive decorative — then the carrier or the authoring discipline is wrong; or (c) operators demand content signals strongly enough to justify the labelled-hypothesis class this ADR defers.
Trigger set on: 2026-07-31 · Watched via: the ADR journal at each groom, and field reports from missions scaffolded by runward init (the Dropyour graduated-app fleet is the first population where the same gap would replicate).
References
- ADR-0001 — the coarse phase routing this refines; §Decision 1 defers "finer per-task triggers… until evidence demands them", and trigger (c) names the false-negative case this answers.
- ADR-0005, ADR-0040 — what a green row never proved;
GATE_NON_SCOPEis amended alongside this ADR to declare the temporal blind zone the field report exposed. - ADR-0012 — runward emits inert samples, the operator wires them; the
git diff | xargscomposition lives there. - ADR-0014, ADR-0036 — facts are labelled facts, guesses labelled guesses; "counts, never seams" is why content signals stay out.
- ADR-0020 — the "never wholesale" discipline for adding a per-rule field, and the grep-on-declared-evidence precedent that content signals would exceed.
- ADR-0024 — the additive machine contract
--forandmatchedByextend. - ADR-0033 — §Alternatives discarded rejects
iterateas a gated phase by name; the ground for refusing the report's demand 2 branch B1. - ADR-0039 — "the CLI reads your mission repo, never your behavior": the line that puts
--forinside and--changedoutside. - ADR-0008 — the seam through which an operator gets a blocking per-change check without runward growing one.
runward/contracts/port-contract.md§Invariants — "Same working tree ⇒ same verdict and same machine outputs"; the invariantcheck --changedwould break.- The Dropyour field report, Brief à transmettre à l'agent runward — durcir la conformité aux règles de métier (2026-07-31) — the two violations, the diagnosis, and the argument that a gap replicating into every scaffolded mission is a framework gap.
- Prior art:
git check-ignore -v(the match-reason format imitated here); Semgrep rulepaths:and theskipped_target/skip_reasonschema (machine-readable non-match); ESLint flat-configfiles:; SonarSource Clean as You Code and its checked-out-code constraints (the documented cost of a baseline-dependent verdict); Sadowski et al., Tricorder (ICSE 2015) — analyse the change, with a declared false-positive budget.