docs(#4650): record the path-containment and filename-classification design lock (#4655)

Phase 0 of epic #4636. ADR-4650 fixes the decisions Phases 1-4 inherit, so that
four phases do not each invent them independently.

The load-bearing decision is that the engine and the exported shape are
separable. A resolver-based, symlink-safe containment predicate already exists
as validatePath, and building the epic's literal assertWithinRoot() from scratch
would create a sixth implementation of the very thing this epic consolidates --
while risking silent loss of behavior validatePath acquired as bug fixes (a
closed dangling-symlink existence oracle, ancestor canonicalization for
non-canonical roots, a separator-aware boundary test).

But the epic's other clause is correct and lands on the current export:
validatePath returns a boolean a caller can forget to check, and populates
`resolved` with the escaping path precisely on the traversal branch. While that
form stays exported, the Phase-4 ratchet could only assert that a helper was
called -- validatePath(x, root).resolved would pass the rule.

So: preserve the engine, narrow the export. assertWithinRoot becomes the only
export and yields a branded ContainedPath.

Also recorded, each found by measurement rather than from the epic text:

- The rejection message text is a real contract. tests/quick-batch.test.cjs
  asserts a user-facing `reason` field matches /escapes allowed directory/, so
  the string reaches CLI consumers and Phase 3 must preserve it verbatim.
- Two further unconfined boundaries the epic does not enumerate: resolvePath
  and gap-analysis.plan-post, both in check-command-router.cts.
- --phase-dir also interpolates into ${PHASE_DIR} for command-exit-zero, so
  confining at the boundary covers both predicate kinds; the evaluator stays
  fs-free.
- opts.allowAbsolute is a per-call-site liberality knob, which is an acceptance
  policy living exactly where this ADR says it must not.
- planning-inspect's isWithinRoot is deliberately pure-string with no I/O; its
  contract differs, so Phase 3 decides rather than assumes.

The acceptance policy is stated once: conservative about the resource, exact
about the classification. #4580's guard was not too strict, it was wrong -- a
category error comparing a whole suffix against a set of final extensions.

ADR opens as Proposed; ratified at Phase 4 closeout per docs/adr/README.md.
No changeset: the diff touches docs/adr/ only, which is outside
USER_FACING_PREFIXES in scripts/changeset/lint.cjs.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-11 23:45:04 -04:00
committed by GitHub
parent a2331c01f1
commit 8fa2c3dbcf
2 changed files with 246 additions and 0 deletions

View File

@@ -289,6 +289,7 @@ Decided in principle, not yet ratified. Do not cite as settled architecture.
| [ADR-3646](3646-per-task-content-resolution-seam.md) | Per-task external-tracker content-resolution seam | Proposed | — |
| [ADR-3889](3889-process-exit-contract.md) | One exit-code registry — 0 and 1 are free, everything else is allocated | Proposed | — |
| [ADR-3942](3942-emitted-drift-ack-commit-trailer.md) | The emitted-drift acknowledgment is PR-lifetime data — it belongs in a commit trailer, not the working tree | Proposed | — |
| [ADR-4650](4650-path-containment-and-filename-classification-seam.md) | One path-containment predicate and one filename-classification helper | Proposed | — |
### Superseded, Retired, and Legacy