Skip to main content

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:

  1. It does not resolve from the directory the instruction runs in. A step pinned with working-directory: backend running python ../scripts/<name>.py, and a root-relative reading, are different paths. The detector composes the step's pin with any inline cd rather than validating against the repository root alone.
  2. It is invoked as ./x.sh without the executable bit. The path resolves, the file is there, and the command fails.
  3. It redirects into a directory nothing creates. The shell opens the redirect before starting the command, so on a fresh host >> backups/logs/x.log fails 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.json registers scripts/check-adr.sh as a hook and hooks/pre-push-gate.sh invokes several blocking checks, so those are this repository's own operational config and are scanned.
  • _SKIP_PARTS contains worktrees (plus node_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 Tests was 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 through docs/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: master has 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, and docs/quality-gates.md carries 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.