ADR-0184: A documented script path must resolve, and the exception register is pinned by key
Date: 2026-08-11 Status: Accepted Deciders: Adrian (Soft4U BV), Claude Opus 5, Codex (review, PR #1117)
Context
#1093 began as a narrow observation and turned out to be a rule with repository-wide
reach, which is why it needs a record rather than a test file alone (Codex P1 on
PR #1117).
The observation: a quality gate's remediation message told the reader to run a script that does not exist. That failure is not evenly distributed over time — a remediation message is read precisely when someone is blocked and trying to get unblocked, so a broken path in it costs the most at the moment it is discovered.
Three further failure shapes were measured while building the detector, and each is a distinct way a documented path can be wrong while the file itself exists:
- It does not resolve from the directory the instruction runs in. A step pinned
with
working-directory: backendrunningpython ../scripts/<name>.py, and a root-relative reading, are different paths. The detector composes the step's pin with any inlinecdrather than validating against the repository root alone. - It is invoked as
./x.shwithout the executable bit. The path resolves, the file is there, and the command fails. - It redirects into a directory nothing creates. The shell opens the redirect
before starting the command, so on a fresh host
>> backups/logs/x.logfails before the script runs — the failure recorded in ADR-0175, where a freshness checker went missing through its own installation instructions.
The premise of the whole rule is narrow and worth stating: a documented path in this
repository resolves in this repository. Quoting another project's script — the
vendored s4u-methodology cards under .claude/skills/, or a path in
actions/runner-images cited to explain what ubuntu-latest does — is not a broken
link, and treating it as one would produce permanent false alarms in a control whose
whole value is that a red result means something (ADR-0121).
Decision
backend/tests/test_documented_script_paths_1093.py is a repository-wide blocking
rule. It rides the required Backend Tests check rather than being its own required
context, and docs/quality-gates.md names it in the detector table for that reason: a
contributor reading the Layer-4 required-checks table alone would find no entry and
could not tell that documented paths are enforced at all, or by what.
Scope. Every Markdown document, workflow, hook and operational config in the repository, minus two exclusions that are decisions rather than convenience:
_SKIP_PATH_PREFIXES = (".claude/skills/", ".claude/s4u-")— vendored copies of another repository's documentation. Deliberately not the whole.claude/tree:settings.jsonregistersscripts/check-adr.shas a hook andhooks/pre-push-gate.shinvokes several blocking checks, so those are this repository's own operational config and are scanned._SKIP_PARTScontainsworktrees(plusnode_modules,.git,.venv) — the worktree scratch area holds 90+ stale copies of the entire repository, which reported one real finding ninety times and took the scan from seconds to four minutes.
Exception policy, and this is the half that needed the record:
- An exception is keyed by
(reference, source document), never by reference alone. Keying by reference alone lets one justification cover every future use: once a historical plan or a sister-project quotation declares a path, any later deployment runbook can reuse the same nonexistent path and be skipped in silence. A reason is a reason about a document — "a sister project has its own tree" says nothing about a runbook telling an operator to run it. - Every exception carries a reason. An empty reason is not an exception, it is a shrug.
- The register is pinned by key in
_APPROVED_DECLARATIONS, not by count. A count lets one entry be swapped for another silently, and it is the identity of what has been agreed to tolerate that matters. - The pin exists because a staleness rule alone makes the register write-only in the
dangerous direction:
_broken_references()skips anything declared, so adding a tuple for a freshly-broken path silences the gate, and a staleness check sees nothing wrong — the path is absent and the source still cites it, so the declaration reads perfectly healthy. Without the pin the register grows every time declaring a break is easier than fixing it, which is how a ratchet turns into a permission.
A declared exception may be a genuine break. One is:
docs/runbooks/pilot-one-time-setup.md tells an operator to run
scripts/check-keycloak-client.sh and no such script exists. It is left declared with
that stated plainly rather than repaired by guessing what the step was meant to do —
the nearest candidate, scripts/reconcile-keycloak-client.py, may or may not be the
same intent. It belongs to the deployment workstream, not to this gate.
Consequences
Positive
- A remediation message that names a nonexistent script cannot merge.
- The three non-existence failures — wrong resolution directory, missing executable bit, redirect into an uncreated directory — are caught by the same rule, and each was a real incident before it was a test.
- What the repository tolerates is legible: every exception names a document and a reason, and the set is pinned.
Negative
- It is a repository-wide gate hanging off a check whose name says nothing about it.
Riding
Backend Testswas chosen over a new required context because this repository has no branch protection to attach one to (#963), but the cost is real: the gate is discoverable only throughdocs/quality-gates.md. - The detector is 1,800 lines of parsing — shell prologues, inline
cd, redirect forms, combined short-option groups. Parsing prose and shell for paths is inherently approximate, and every round of review found another shape it read wrongly. - Adding a legitimate cross-project quotation to a scanned document now requires a declared exception with a reason, which is friction on a correct action.
Neutral
- The rule says nothing about whether a script is correct, only that it exists, resolves, and can be executed as invoked.
- No migration, no flag, no runtime code. It is a test.
Alternatives Considered
Alternative 1: Leave it as a test with no ADR
- The detector exists and passes; the rule could simply live in the file.
- Why rejected: it defines a repository-wide blocking rule with a non-trivial exception and resolution policy, affecting every future PR. A contributor hitting it needs to know why the exclusions are what they are and what a legitimate exception looks like; that reasoning cannot live only in the comments of the thing enforcing it.
Alternative 2: Its own required status check
- Structurally clearer — the gate's name would say what it checks.
- Why rejected:
masterhas no branch protection (#963), so a new context would block nothing while implying it does. Riding an existing required check makes it actually enforced today, anddocs/quality-gates.mdcarries the disclosure. Revisit when protection is enabled.
Alternative 3: Warn instead of block
- A red gate on a documentation path is friction on a correct change.
- Why rejected: the failure this exists for is a person already blocked, following an instruction that cannot work. A warning nobody reads at authoring time is exactly the state that produced #1093.
Decision context:
- Latency: none at runtime. ~seconds in CI after excluding
worktrees; four minutes before that exclusion, which is why it is a decision and not an optimisation. - Dependency surface: none. Standard library only, inside the existing pytest run.
- Debuggability: a failure names the reference, the document, and the resolved path it tried — the information the reader needs to fix it in one step.
- Reversibility: delete one test file. Minutes.
- Blast radius: every documented
scripts/…reference in the repository; additive (nothing changes behaviour, a violation blocks a merge). - Alternative considered: warn instead of block — rejected because the cost of the defect falls on someone already blocked.