diff --git a/.github/workflows/adr-006-evidence.yml b/.github/workflows/adr-006-evidence.yml new file mode 100644 index 0000000..b42eb68 --- /dev/null +++ b/.github/workflows/adr-006-evidence.yml @@ -0,0 +1,59 @@ +name: ADR-006 path identity evidence + +# Runs the ADR-006 experiment harness on Linux. +# +# Three of its questions are unanswerable on darwin: APFS is case-insensitive, +# collapses NFC and NFD to one tracked entry, and refuses a non-UTF-8 pathname. +# ext4 answers all three. Rather than assert what Linux "would" do, this runs the +# same harness there and publishes the receipts, so the claim is reproducible by +# anyone with a fork. +# +# This workflow only reads and reports. It publishes no package, holds no +# credential, and writes nothing back to the repository. + +on: + workflow_dispatch: + push: + paths: + - "docs/adr/experiments/006-path-identity/**" + - ".github/workflows/adr-006-evidence.yml" + +permissions: + contents: read + +jobs: + evidence: + name: Path identity evidence (linux) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + + - name: Filesystem under test + run: | + echo "kernel: $(uname -srm)" + echo "filesystem: $(stat -f -c %T .)" + echo "git: $(git --version)" + echo "locale: ${LANG:-unset}" + + - name: Run the harness + run: node docs/adr/experiments/006-path-identity/run.mjs + + # Printed to the log as well as uploaded: the log is the citable receipt, + # the artifact is what gets committed alongside the darwin run. + - name: Raw receipts + run: | + node docs/adr/experiments/006-path-identity/run.mjs --json \ + | tee "$RUNNER_TEMP/receipts-linux.json" + + - name: Upload receipts + uses: actions/upload-artifact@v4 + with: + name: adr-006-receipts-linux + path: ${{ runner.temp }}/receipts-linux.json + if-no-files-found: error diff --git a/docs/adr/006-canonical-path-identity.md b/docs/adr/006-canonical-path-identity.md new file mode 100644 index 0000000..ea92906 --- /dev/null +++ b/docs/adr/006-canonical-path-identity.md @@ -0,0 +1,540 @@ +# ADR-006: Canonical path identity — stored keys are data, not commands + +| Field | Value | +| -- | -- | +| **Status** | Accepted | +| **Decision date** | 2026-08-09 | +| **Record written** | 2026-08-03 | +| **Author** | Qwynn Marcelle ([@qmarcelle](https://github.com/qmarcelle)) | +| **Decider** | Qwynn Marcelle | +| **Ratifying authority** | Qwynn Marcelle, sole steward ([OWNERSHIP.md](../../OWNERSHIP.md)) | +| **Canonical repository** | `workspacejson/standard` | +| **Canonical path** | `docs/adr/006-canonical-path-identity.md` | +| **Revision index** | [`index.json`](./index.json) — generated; see [README](./README.md#the-revision-index) | +| **Ratification issue** | META-278 (internal tracker) | +| **Evidence** | [`experiments/006-path-identity/`](./experiments/006-path-identity/) — harness, receipts, platform limitations | +| **Supersedes** | Nothing | +| **Superseded by** | Nothing | +| **Depends on** | [ADR-001](./001-canonical-artifact-path.md), [ADR-003](./003-field-lifecycle-and-admission.md) (Accepted) | +| **Spec version at decision** | v0.4 | + +## Context + +The schema calls path-bearing values "repo-root-relative POSIX paths" and stops +there. It does not say whether comparison is case-sensitive, what encoding a key +is in, whether `./a.ts` and `a.ts` are the same key, whether a symlink is +resolved before or after comparison, or what "repo-root-relative" means inside a +submodule or a linked worktree. The four-path conformance suite assumes these are +settled. They are not written down anywhere, so an independent implementer has to +guess, and two implementers guessing differently both conform. + +They already have. Two consumers of this standard route both stored keys and host +queries through `node:path.normalize`, which collapses `..` lexically. Measured +against the shipped code: + +| Stored key | Query | Result | +| -- | -- | -- | +| `a.ts` | `src/../a.ts` | **matches** | +| `a.ts` | `a/b/../../a.ts` | **matches** | + +`src/../a.ts` does not name `a.ts` — not when `src` is a symlink, and not as a +statement about the repository. The reader answers a question it was not asked. +The same call is applied to *stored* keys, so an artifact containing the key +`src/../a.ts` is read as though it contained `a.ts`: the defect is repaired into +meaning and becomes unobservable downstream. + +This record fixes the semantics for every path-bearing location in the artifact, +not for one field. Filing it against `generated.fileIndex` alone would produce a +rule half the artifact does not follow. + +| Location | Shape | +| -- | -- | +| `generated.fileIndex` | object keys | +| `generated.coChange[].files` | 2-element array, set semantics | +| `generated.fragility[]` | per-entry path | +| `manual.fragileFiles[].path` | string | +| `manual.coChangePatterns[]` | item shape undefined; inherits these rules once ADR-003 A-005 defines it | + +Any path-bearing field admitted later inherits these rules by default. A future +field does not silently opt out. + +## Decision + +### 1. Stored artifact paths are data, not commands + +**A stored key is either canonical or invalid.** Readers never normalize, repair, +collapse, or reinterpret a malformed stored key. This was settled before this +record was drafted and is not reopened here; what follows is its specification. + +The consequence that matters: `src/../a.ts` is an invalid stored key. It must +never become `a.ts`. An artifact carrying it is artifact-invalid, and the reader's +obligation is to say so, not to guess what was meant. + +### 2. Two operations, deliberately separated + +The single most damaging conflation in the current implementations is treating +"is this stored key well-formed?" and "what key does this host path correspond +to?" as one function. They have different inputs, different failure modes, and +different owners. + +``` +validateStoredKey(rawKey) -> valid | invalid(reason) +``` + +Total function of the key alone. No filesystem access. No repository access. No +repair. Its answer does not depend on any host. + +``` +canonicalizeHostQuery(root, inputPath) -> key | unsupported(reason) +``` + +Takes a proven repository root and a host path. May touch the filesystem. May +fail. Never invents a key that is not the tracked entry. + +A validator that normalizes cannot report an invalid artifact, because after +normalization there is nothing left to report. A host-query canonicalizer that +refuses to touch the filesystem cannot prove containment. Both properties are +required; one function cannot have both. + +### 3. Canonical stored-key grammar + +A canonical key is a non-empty sequence of segments joined by a single `/`: + +- segments are separated by exactly one `/`; no repeated separators +- no leading `/`, no leading `./`, no trailing `/` +- no segment is `.` or `..` +- no drive letter (`C:`), no UNC prefix (`\\`), no backslash anywhere +- no NUL (U+0000) +- the key is a sequence of Unicode scalar values (see §5) + +**Control characters other than NUL are permitted.** An earlier draft of this +grammar forbade all of them; that was broader than the settled NUL rejection and +is narrowed here deliberately. + +NUL is rejected because it cannot occur in a POSIX pathname at all — it +terminates the pathname — so a stored key containing one did not come from a +filesystem, and because NUL is the delimiter §5 requires for acquisition, a key +containing one would be unparseable by the mechanism that produced it. + +Every other control character, including U+000A and U+0009, **is legal in a +POSIX filename and does occur.** Forbidding them would make real repositories +unrepresentable and would push producers toward exactly the silent omission §5 +prohibits — an artifact that quietly lacks a file is worse than one that names it +awkwardly. Such names are hostile to line-oriented tooling, which is a reason to +acquire paths NUL-delimited rather than a reason to reject the file; identity and +ergonomics are different questions. + +The rejection corpus, with what a normalizing reader would silently turn each +into — the second column is the behavior this record forbids: + +| Stored key | A normalizing reader yields | Class | +| -- | -- | -- | +| `../x` | `../x` | escapes root | +| `src/../x` | **`x`** | **silently repaired** | +| `a/b/../b/c.ts` | **`a/b/c.ts`** | **silently repaired** | +| `/abs/posix/x.ts` | `/abs/posix/x.ts` | absolute | +| `C:\drive\x.ts` | drive path | absolute, non-POSIX | +| `\\unc\share\x.ts` | UNC | absolute, non-POSIX | +| `back\slash.ts` | unchanged on POSIX | separator | +| `./leading.ts` | **`leading.ts`** | **silently repaired** | +| `double//sep.ts` | **`double/sep.ts`** | **silently repaired** | +| `trailing/` | **`trailing`** | **silently repaired** | +| `` (empty) | `.` | empty | +| `.` | `.` | not a file | +| `..` | `..` | escapes root | +| `a\0b.ts` | unchanged | NUL | + +Five of fourteen are repaired into a *valid-looking* key. Those are the cases +that make reject-don't-resolve necessary rather than merely tidy. + +### 4. Case + +**Stored-key comparison is exact and case-sensitive.** `A.ts` and `a.ts` are +different keys. This is a property of the artifact format, not of any filesystem, +and it does not vary by platform. + +Host *query* behavior does vary, and the standard does not pretend otherwise. On +a case-insensitive filesystem a host path may name a file whose tracked entry +differs in case; `canonicalizeHostQuery` must return the **tracked** spelling, +because that is what the producer stored. Where the host cannot establish the +tracked spelling, the result is `unsupported`, not a case-folded guess. + +**Observed.** On Linux/ext4, `A.ts` and `a.ts` are two tracked entries: + +``` +trackedEntries: ["A.ts", "a.ts"] +``` + +Exact case-sensitive comparison is therefore implementable, not merely desirable. +The darwin run could not answer this — the second write replaced the first and +Git recorded one entry — which is a property of APFS, not of the rule. Both runs +are recorded; see §11. + +### 5. Unicode and encoding + +**A stored key is a sequence of Unicode scalar values obtained by lossless UTF-8 +decoding of the repository path's bytes.** + +"Scalar value" is the operative term and is narrower than "code point". It +excludes surrogate code points U+D800–U+DFFF, which have no UTF-8 encoding. +Concretely, a key is invalid if: + +- decoding its bytes as UTF-8 is not lossless — any byte sequence that does not + round-trip; +- it contains an **unpaired surrogate**, which JavaScript strings can hold and + JSON can carry as `\uD800` but which no valid UTF-8 byte sequence produces; +- a U+FFFD replacement character was **substituted** for undecodable input. + +A U+FFFD that is genuinely present in the repository pathname is a legitimate +character and is not rejected. What is rejected is U+FFFD arriving as the +*result* of a failed decode — the distinguishing test is whether re-encoding the +key reproduces the original bytes. Substitution is silent identity mutation: two +distinct tracked paths can decode to the same key. + +Comparison is exact over scalar values. **No normalization form is applied** — +NFC and NFD spellings are different keys, because normalizing at read time is the +same defect as collapsing `..` at read time. + +Producers acquire paths from Git in **raw, NUL-delimited form** (`git ls-files +-z` or equivalent). This is not a style preference. Measured on darwin, the same +tracked file reported through the two forms: + +``` +git ls-files -z -> café.ts (raw bytes: 63 61 66 c3 a9 2e 74 73) +git ls-files -> "caf\303\251.ts" (quoted, octal-escaped, literal quotes) +``` + +A producer using the default form would store the key `"caf\303\251.ts"` — +including the quote characters — and no consumer would ever match it. **Display +quoting must never become artifact identity.** + +**Unrepresentable paths.** A tracked pathname that is not valid UTF-8 cannot be +carried as a JSON string without lossy decoding, and lossy decoding is silent +identity mutation: distinct tracked paths can decode to the same key via U+FFFD. +The rule: + +- a producer encountering a tracked path that cannot be represented as a valid + Unicode string **fails with an explicit unsupported-path error**; +- it does **not** silently omit the path; +- it does **not** substitute U+FFFD or any other replacement. + +**Observed.** On Linux/ext4 a non-UTF-8 tracked pathname was created and +measured. The decode is lossy and the original bytes are unrecoverable from the +JSON key: + +``` +raw bytes 626164ff2e7473 ("bad\xFF.ts") +decoded as UTF-8 bad.ts code points: 62 61 64 fffd 2e 74 73 +JSON round trip {"bad.ts":{}} +re-encode == original bytes false +``` + +The unpaired-surrogate case is confirmed on both platforms, since it needs no +filesystem: + +``` +lone U+D800 JSON encodes it as "\ud800" re-encode -> efbfbd (U+FFFD) + reEncodeIsLossless: false isWellFormed(): false +``` + +Both are silent identity mutation, which is why §5 requires an explicit error +rather than substitution or omission. The darwin run could not create the +non-UTF-8 name; APFS enforces UTF-8. Both runs are recorded in §11. + +### 6. Symlinks + +**Stored identity is the tracked entry. It is never the target.** + +Git tracks a symlink as an entry whose content is the link text; it does not +track a second path to the target. Measured: + +``` +tracked entries: alias.ts escape.ts link real/a.ts +link/a.ts is tracked: false +``` + +`link` is a symlinked directory and `real/a.ts` is the real file. **`link/a.ts` +is not a tracked entry and therefore has no stored key of its own.** + +That does not make it unanswerable. Resolving a host query through a symlink is +`canonicalizeHostQuery`'s job, and §2 grants that function the filesystem access +required to establish identity. What is forbidden is *repairing stored evidence* +— and no stored key is being repaired here, because the input is a host path, not +an artifact key. **Canonicalizing a host query into an existing tracked identity +is not the same operation as rewriting a malformed stored key, and this record +must not conflate them.** + +**Traversal is permitted only against proof.** For a query that passes through a +symlink, all of the following must succeed: + +1. resolve the target; +2. prove the resolved target is still within the repository root; +3. prove its exact tracked entry; +4. return **that tracked entry** as the key. + +If any step fails — the target escapes the repository, is untracked, or the +identity is ambiguous — the result is `unsupported`. There is no fallback and no +nearest-match. + +| Case | Behavior | +| -- | -- | +| Query names a tracked symlink entry (`alias.ts`) | key is `alias.ts` — the entry itself, never its target | +| Query traverses an internal symlink (`link/a.ts`) | key is `real/a.ts` **if and only if** all four proofs succeed; otherwise `unsupported` | +| Tracked symlink whose target escapes the repository (`escape.ts`) | key is `escape.ts`; the target is not followed | +| Query traverses a symlink whose target escapes, or is untracked | **`unsupported`** | +| `realpath()` differs from a lexical path that is *itself* tracked | the tracked entry wins — never rewrite a tracked alias to its target | + +The last row is the load-bearing constraint. **A lexical alias is never replaced +by its target when the alias is itself tracked**, because then two tracked +entries would collapse to one key and the artifact could no longer distinguish +them. Resolution applies only where the lexical path has no tracked entry of its +own and therefore no identity to preserve. + +Confirmed in the run: for `link/a.ts`, lexical and `realpath()` disagree, and the +escaping symlink's `realpath()` leaves the repository entirely. Both are exactly +the conditions the proofs above test. + +### 7. Repository roots + +**A linked worktree is its own artifact root. A submodule is its own artifact +root.** Measured: + +- a linked worktree reports a different `--show-toplevel` from its main + worktree while sharing `--git-common-dir`; +- a host repository tracks `.gitmodules`, `h.ts`, and `vendor` — **not** + `vendor/s.ts`. The submodule's files are not entries in the host's index. + +Therefore a key resolved against the host root can never name a file inside a +submodule, and a reader that walked upward from inside a submodule and selected +the host's artifact would resolve every key against the wrong root. + +**No ancestor artifact discovery.** A reader does not walk upward past a +repository boundary to find an artifact. This is consistent with +[ADR-001](./001-canonical-artifact-path.md): the artifact belongs to the +repository it sits in, and a query originating inside repository R is answered by +R's artifact or by nothing. + +### 7a. Host-query profile is scoped to POSIX + +**The host-query profile this record accepts covers POSIX hosts only. Windows +host-query canonicalization is not yet specified.** + +This is a deliberate scoping, not an oversight. No Windows fixture has been run, +and §11 records that. The alternative — writing a Windows rule from reasoning +about drive letters, UNC paths, `\\?\` prefixes, reserved device names and +case-insensitive-but-case-preserving semantics, with no measurement — would be +exactly the guessing this record exists to stop. + +What this scoping does and does not mean: + +- The **stored-key grammar (§3) is universal.** It forbids drive letters, UNC + prefixes and backslashes on every platform. A Windows producer emits the same + canonical keys as a POSIX one, because the key describes the repository, not + the host. +- **Reading an artifact on Windows is unaffected.** `validateStoredKey` touches + no filesystem and behaves identically everywhere. +- What is unspecified is `canonicalizeHostQuery` on Windows: how a native path + becomes a key, and how containment is proven against a Windows root. +- A Windows integration must therefore either implement that mapping as a + documented extension and say so, or return `unsupported`. It must not invent + one and present it as conformance. + +A later ADR — or an amendment to this one, per ADR-003 §11 — specifies Windows +once fixtures exist. Ratifying a POSIX-scoped profile now does not prejudge it. + +### 8. Matching + +**Exact string equality over canonical keys.** No prefix matching, no suffix +matching, no case folding, no normalization, no fuzzy fallback. + +The existing absolute-query suffix fallback is a `canonicalizeHostQuery` concern, +not a matching concern: an absolute host path is canonicalized against a proven +root first, and then compared exactly. If containment cannot be proven, the +result is `unsupported` — not a suffix match. + +### 9. Compatibility + +Artifacts already published may contain non-canonical keys, and this record does +not retroactively invalidate the files people already have. + +The sequencing is explicit, because "tolerant" is the word most likely to be +read as permission to repair. + +**In the current v0.4.x line.** A reader encountering a malformed stored key has +exactly two obligations, and they are both required: + +1. **Report it.** The key is surfaced as malformed — through validation output, + a diagnostic, or whatever channel the integration owns per §10. +2. **Decline to match it.** The key participates in no lookup. It matches + nothing, including the value it would have become under normalization. + +A reader **never repairs** a malformed key — not in v0.4.x, not in tolerant +mode, not ever. Tolerance governs whether the reader *continues*, not whether it +*rewrites*. A v0.4.x reader may keep operating over the artifact's well-formed +remainder; it may not turn `src/../a.ts` into `a.ts` at any point. + +**Producers** emit canonical keys only, starting from the version that +implements this record. This is not tolerant: a producer that cannot emit a +canonical key fails per §5 rather than emitting a malformed one. + +**In v0.5.** Two changes belong to the declared compatibility transition and to +neither this record nor the v0.4.x line: + +- the **narrowing validation change** — packaged validation rejecting artifacts + that carry malformed keys, where v0.4.x validation reported them; +- the **hard-failure boundary** — readers failing the read outright rather than + continuing over the well-formed remainder. + +Both are breaking, both are announced through the v0.5 compatibility transition, +and both are sequenced under ADR-003 §5, which distinguishes relaxing a +constraint from ceasing to emit from removing. Landing either inside v0.4.x +would break consumers of already-published artifacts without a declared +boundary. + +No behavior in this section is implemented before this record is Accepted. + +### 10. Ownership + +| Operation | Owner | +| -- | -- | +| Stored-key grammar | standard | +| Stored-key validation | standard | +| Artifact validity | standard | +| Host root selection | integration or host | +| Host containment proof | integration, using standard semantics | +| Canonical key comparison | standard semantics | +| Error presentation | integration | +| Key production | CLI | + +The division is deliberate: an integration owns *where the user is* and *how a +failure is shown*, and owns neither *what a key means* nor *whether an artifact +is valid*. + +### 11. What the evidence does not cover + +Recorded as open rather than assumed, per ADR-003 §3's rule that a criterion with +no runnable procedure is *not currently determinable*. + +Two platforms have been run. Receipts are committed as `receipts-darwin.json` +and `receipts-linux.json`; the Linux run is reproducible from +[`.github/workflows/adr-006-evidence.yml`](../../.github/workflows/adr-006-evidence.yml). + +| Question | darwin 25.1.0 (APFS) | linux 6.17 (ext4), git 2.54.0 | +| -- | -- | -- | +| Case-distinct tracked entries | not answerable — case-insensitive | **answered** — `["A.ts", "a.ts"]`, two entries | +| NFC vs NFD as distinct entries | not answerable — collapsed to one | **answered** — two entries; `63 61 66 65 301 …` vs `63 61 66 e9 …` | +| Non-UTF-8 tracked pathname | not answerable — platform refused | **answered** — lossy decode, `re-encode == original: false` | +| Display quoting | **answered** — default `ls-files` quotes | **answered** — quotes both spellings | +| Symlinks, roots, malformed keys | **answered** | **answered**, same results | + +The rules in §4 and §5 are therefore stated from observation, not from reasoning +about a failure mode. The earlier draft of this record could not say that. + +**Still open: Windows.** Resolved by scoping rather than by measurement — §7a +scopes the accepted host-query profile to POSIX and states that Windows +canonicalization is not yet specified. The stored-key grammar remains universal. +Windows host-query fixtures are owed before any Windows host profile is +accepted; they are not owed before this record is. + +## Boundaries + +This record does **not** cover: + +- **The `manual.coChangePatterns` item schema.** ADR-003 A-005 ratified *keep and + specify*; the item shape is a separate decision. This record governs the + identity of whatever paths that shape carries. +- **Artifact discovery.** Which file is the artifact is + [ADR-001](./001-canonical-artifact-path.md). This record governs the keys + inside it and says only that discovery does not cross a repository boundary + upward. +- **Whether a path *should* be in the artifact.** Membership is a producer-profile + obligation. This record governs the spelling of a path that is present. +- **Error message text.** §10 assigns presentation to integrations. The standard + owns the classification, not the wording. +- **Performance.** Exact equality over a set is not slower than what is currently + done; no claim beyond that is made. + +## Consequences + +Some currently accepted artifacts become invalid. That is the point: they were +being read by guessing. The tolerant v0.4.x reading mode in §9 keeps the +transition from being a cliff. + +Some host queries that currently return an answer will return `unsupported` — +notably queries traversing a symlinked directory. A consumer that previously got +a confident wrong answer now gets an honest refusal, which is a regression in +apparent capability and an improvement in correctness. + +Two consumer implementations must converge. They have already diverged while +being copies of each other, which is evidence that "keep it in sync by hand" is +not a mechanism. + +## Supersession + +Replace this record if the artifact format stops using JSON object keys for +paths, which would remove the Unicode-representability constraint in §5. Replace +it also if the standard adopts a path encoding that is not a Unicode string — +for example a byte-array form — which would make §5's unsupported-path error +unnecessary rather than merely rare. + +Any replacement must state what happens to artifacts published under this +record's grammar, and whether §9's tolerant reading mode carries forward. + +## Ratification + +Accepted **2026-08-09** by Qwynn Marcelle, sole steward +([OWNERSHIP.md](../../OWNERSHIP.md)). + +The steward's acceptance was given explicitly and in those terms — "I explicitly +accept ADR-006 as steward" — and authorized this ratification alone. It did not +authorize implementation: §11's sequencing stands, and the executable corpus, +the standard-owned validation contract, the producer and reader convergence, and +the public-documentation sync are separate work items under the ratification +issue. + +Four scope corrections were issued with the acceptance and bind the +implementation that follows. They are recorded here because they narrow what +this record authorizes, and a reader who has only the record should see them: + +1. `validateStoredKey` is standard-owned, pure, total and filesystem-free. +2. `canonicalizeHostQuery` is **not** the schema package's to implement. Hosts + and integrations implement it against these semantics; placing a + filesystem-aware implementation in the spec package requires its own + architectural decision. +3. A genuine U+FFFD in a filename is **valid**. `validateStoredKey` must not + reject every U+FFFD. Detecting *lossy decoding* belongs at raw-byte + acquisition, where the re-encoded bytes can be compared against the original + — not in a pure string validator, which has no bytes to compare. +4. Acquisition repair is a separate change from stored-key validation, and needs + its own caller trace and watched-red evidence before it is attempted. + +The decision date is the date acceptance was given. It is not backdated to the +drafting date, which remains recorded separately above. + +## Provenance + +Written 2026-08-03 in `workspacejson/standard`, against the ratification issue +named in the metadata table. + +Every empirical claim comes from +[`experiments/006-path-identity/run.mjs`](./experiments/006-path-identity/run.mjs), +whose raw output is committed alongside it as **`receipts-darwin.json` and +`receipts-linux.json`**. Both are load-bearing: the darwin run answers the +symlink, repository-root, malformed-key and display-quoting questions, and the +Linux run answers case distinction, NFC/NFD distinction and non-UTF-8 decoding, +which APFS cannot express. Neither alone supports §4 and §5. The Linux run is +reproducible from +[`.github/workflows/adr-006-evidence.yml`](../../.github/workflows/adr-006-evidence.yml). +The harness +builds throwaway Git repositories and records what Git, Node and JSON do; it +asserts nothing and gates nothing. + +The consumer behavior in Context was reproduced by executing the shipped +consumers read-only and recording their output. It is cited here rather than +imported: the harness reproduces the *cause* (`node:path.normalize`) and the ADR +cites the consumers by location, because importing across repository boundaries +is forbidden by the architecture guard and would make this record's evidence +unrunnable outside a full multi-repository checkout. + +The reject-don't-resolve principle in §1 was settled before drafting and is +recorded here rather than decided here. diff --git a/docs/adr/README.md b/docs/adr/README.md index cff1540..b3613e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ repository under version control. | [003](./003-field-lifecycle-and-admission.md) | Field lifecycle and admission | Accepted | 2026-08-03 | | [004](./004-root-version-compatibility.md) | Root `version` compatibility profile for v0.4.x | Accepted | 2026-07-28 | | [005](./005-schema-identity.md) | Schema identity — canonical host, filename, and single-file profile carriage | Accepted | 2026-07-28 | +| [006](./006-canonical-path-identity.md) | Canonical path identity — stored keys are data, not commands | Accepted | 2026-08-09 | ## When an ADR is required diff --git a/docs/adr/experiments/006-path-identity/receipts-darwin.json b/docs/adr/experiments/006-path-identity/receipts-darwin.json new file mode 100644 index 0000000..8a517ab --- /dev/null +++ b/docs/adr/experiments/006-path-identity/receipts-darwin.json @@ -0,0 +1,242 @@ +{ + "platform": "darwin 25.1.0", + "node": "v22.19.0", + "receipts": [ + { + "question": "case-identity", + "finding": "filesystem is case-INSENSITIVE: only one tracked entry survives", + "platform": "darwin 25.1.0", + "secondWriteSucceeded": true, + "trackedEntries": [ + "A.ts" + ], + "gitIndexCount": 1, + "hostLookupCollides": true, + "note": "Stored-key comparison is a string operation and is unaffected by this. What varies is whether a HOST QUERY can name a file whose stored key differs only by case." + }, + { + "question": "unicode-normalization", + "finding": "NFC and NFD collapse to a single tracked entry on this filesystem", + "platform": "darwin 25.1.0", + "nfcWritten": "\"café.ts\"", + "nfdWriteSucceeded": true, + "trackedCount": 1, + "trackedViaLsFilesZ": [ + "café.ts" + ], + "trackedCodePoints": [ + "63 61 66 e9 2e 74 73" + ], + "lsFilesDefaultQuoted": [ + "\"caf\\303\\251.ts\"" + ], + "quotingObserved": true, + "note": "If `git ls-files` (no -z) quotes a name and `-z` does not, then display quoting would become artifact identity for any producer using the default form." + }, + { + "question": "json-representability", + "finding": "this platform refused to create a non-UTF-8 pathname; question not answerable here", + "platform": "darwin 25.1.0", + "created": false, + "attemptedBytesHex": "626164ff2e7473", + "entry": null, + "roundTrip": null, + "surrogateProbe": { + "key": "lone high surrogate U+D800", + "survivesJsonRoundTrip": true, + "jsonEncodesIt": "\"\\ud800\"", + "reEncodedHex": "efbfbd", + "reEncodeIsLossless": false, + "isWellFormed": false + }, + "note": "Lossy decode means silent identity mutation: two distinct tracked paths could serialize to the same JSON key. That is the case the rule must reject explicitly rather than absorb." + }, + { + "question": "symlinks", + "finding": "tracked symlink ENTRIES are stored; Git does not store their targets as entries", + "platform": "darwin 25.1.0", + "trackedEntries": [ + "alias.ts", + "escape.ts", + "link", + "real/a.ts" + ], + "symlinkEntriesTracked": [ + "alias.ts", + "escape.ts", + "link" + ], + "directoryNotWalkedThrough": true, + "queryThroughSymlinkedDir": { + "lexical": "link/a.ts", + "realpath": "../../../../../../private/var/folders/sy/frt_v9rn73lbqr1l92qfjxhw0000gp/T/adr006-XcfSgW/real/a.ts", + "lexicalEqualsRealpath": false + }, + "escapingSymlink": { + "entry": "escape.ts", + "realpathEscapesRepo": true + }, + "note": "`link/a.ts` is NOT a tracked entry — Git records the symlink `link`, not a second path to `real/a.ts`. A host query arriving as `link/a.ts` therefore has no stored key, and realpath() would silently rewrite it to `real/a.ts`." + }, + { + "question": "repository-roots", + "finding": "a linked worktree and a submodule are SEPARATE artifact roots", + "platform": "darwin 25.1.0", + "linkedWorktree": { + "toplevel": "/private/var/folders/sy/frt_v9rn73lbqr1l92qfjxhw0000gp/T/adr006-wt-T9qg7M/linked", + "gitCommonDir": "/private/var/folders/sy/frt_v9rn73lbqr1l92qfjxhw0000gp/T/adr006-dgpuMO/.git", + "isSeparateToplevel": true, + "tracked": [ + "a.ts" + ] + }, + "submodule": { + "added": true, + "hostTracked": [ + ".gitmodules", + "h.ts", + "vendor" + ], + "hostSeesSubmoduleFiles": false, + "submoduleToplevel": "/private/var/folders/sy/frt_v9rn73lbqr1l92qfjxhw0000gp/T/adr006-lnUpY7/vendor", + "hostToplevel": "/private/var/folders/sy/frt_v9rn73lbqr1l92qfjxhw0000gp/T/adr006-lnUpY7" + }, + "note": "The host repo tracks `vendor` as a gitlink, NOT `vendor/s.ts`. A reader that walked upward from inside the submodule and selected the host's artifact would resolve keys against the wrong root." + }, + { + "question": "malformed-stored-keys", + "finding": "every entry is non-canonical; normalization would REPAIR several into a different, valid-looking key", + "platform": "darwin 25.1.0", + "corpus": [ + { + "key": "\"../x\"", + "nodeNormalize": "\"../x\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"src/../x\"", + "nodeNormalize": "\"x\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"a/b/../b/c.ts\"", + "nodeNormalize": "\"a/b/c.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"/abs/posix/x.ts\"", + "nodeNormalize": "\"/abs/posix/x.ts\"", + "isAbsolute": true, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"C:\\\\drive\\\\x.ts\"", + "nodeNormalize": "\"C:\\\\drive\\\\x.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"\\\\\\\\unc\\\\share\\\\x.ts\"", + "nodeNormalize": "\"\\\\\\\\unc\\\\share\\\\x.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"back\\\\slash.ts\"", + "nodeNormalize": "\"back\\\\slash.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"./leading.ts\"", + "nodeNormalize": "\"leading.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"double//sep.ts\"", + "nodeNormalize": "\"double/sep.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"trailing/\"", + "nodeNormalize": "\"trailing/\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"\"", + "nodeNormalize": "\".\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\".\"", + "nodeNormalize": "\".\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"..\"", + "nodeNormalize": "\"..\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"a\\u0000b.ts\"", + "nodeNormalize": "\"a\\u0000b.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": true + } + ], + "note": "`src/../x` -> `x` and `a/b/../b/c.ts` -> `a/b/c.ts` are the reject-don't-resolve cases: a reader that normalizes cannot report the artifact as invalid, because after normalization there is nothing left to report." + }, + { + "question": "silent-redirect", + "finding": "node:path.normalize collapses traversal, so a query naming a path that is not the stored key compares EQUAL to it", + "platform": "darwin 25.1.0", + "storedKey": "a.ts", + "probes": [ + { + "query": "\"src/../a.ts\"", + "normalized": "\"a.ts\"", + "wouldMatchStoredKey": true + }, + { + "query": "\"a/b/../../a.ts\"", + "normalized": "\"a.ts\"", + "wouldMatchStoredKey": true + }, + { + "query": "\"./a.ts\"", + "normalized": "\"a.ts\"", + "wouldMatchStoredKey": true + }, + { + "query": "\"../a.ts\"", + "normalized": "\"../a.ts\"", + "wouldMatchStoredKey": false + } + ], + "note": "Measured on node:path only. Consumer behavior is cited in the ADR by file:line and was reproduced separately by executing the consumers read-only; importing them here would create the cross-repository coupling the architecture guard forbids." + } + ] +} diff --git a/docs/adr/experiments/006-path-identity/receipts-linux.json b/docs/adr/experiments/006-path-identity/receipts-linux.json new file mode 100644 index 0000000..9fdef52 --- /dev/null +++ b/docs/adr/experiments/006-path-identity/receipts-linux.json @@ -0,0 +1,255 @@ +{ + "platform": "linux 6.17.0-1020-azure", + "node": "v22.23.1", + "receipts": [ + { + "question": "case-identity", + "finding": "filesystem is case-SENSITIVE: A.ts and a.ts are two tracked entries", + "platform": "linux 6.17.0-1020-azure", + "secondWriteSucceeded": true, + "trackedEntries": [ + "A.ts", + "a.ts" + ], + "gitIndexCount": 2, + "hostLookupCollides": false, + "note": "Stored-key comparison is a string operation and is unaffected by this. What varies is whether a HOST QUERY can name a file whose stored key differs only by case." + }, + { + "question": "unicode-normalization", + "finding": "NFC and NFD are DISTINCT tracked entries on this filesystem", + "platform": "linux 6.17.0-1020-azure", + "nfcWritten": "\"café.ts\"", + "nfdWriteSucceeded": true, + "trackedCount": 2, + "trackedViaLsFilesZ": [ + "café.ts", + "café.ts" + ], + "trackedCodePoints": [ + "63 61 66 65 301 2e 74 73", + "63 61 66 e9 2e 74 73" + ], + "lsFilesDefaultQuoted": [ + "\"cafe\\314\\201.ts\"", + "\"caf\\303\\251.ts\"" + ], + "quotingObserved": true, + "note": "If `git ls-files` (no -z) quotes a name and `-z` does not, then display quoting would become artifact identity for any producer using the default form." + }, + { + "question": "json-representability", + "finding": "a non-UTF-8 tracked path DECODES LOSSILY: U+FFFD appears, and the original bytes are unrecoverable from the JSON key", + "platform": "linux 6.17.0-1020-azure", + "created": true, + "attemptedBytesHex": "626164ff2e7473", + "entry": { + "rawBytesHex": "626164ff2e7473", + "decodedAsUtf8": "bad�.ts", + "decodedCodePoints": "62 61 64 fffd 2e 74 73", + "containsReplacementChar": true + }, + "roundTrip": { + "serialized": "{\"bad�.ts\":{}}", + "reparsedKey": "bad�.ts", + "reparsedEqualsOriginalBytes": false + }, + "surrogateProbe": { + "key": "lone high surrogate U+D800", + "survivesJsonRoundTrip": true, + "jsonEncodesIt": "\"\\ud800\"", + "reEncodedHex": "efbfbd", + "reEncodeIsLossless": false, + "isWellFormed": false + }, + "note": "Lossy decode means silent identity mutation: two distinct tracked paths could serialize to the same JSON key. That is the case the rule must reject explicitly rather than absorb." + }, + { + "question": "symlinks", + "finding": "tracked symlink ENTRIES are stored; Git does not store their targets as entries", + "platform": "linux 6.17.0-1020-azure", + "trackedEntries": [ + "alias.ts", + "escape.ts", + "link", + "real/a.ts" + ], + "symlinkEntriesTracked": [ + "alias.ts", + "escape.ts", + "link" + ], + "directoryNotWalkedThrough": true, + "queryThroughSymlinkedDir": { + "lexical": "link/a.ts", + "realpath": "real/a.ts", + "lexicalEqualsRealpath": false + }, + "escapingSymlink": { + "entry": "escape.ts", + "realpathEscapesRepo": true + }, + "note": "`link/a.ts` is NOT a tracked entry — Git records the symlink `link`, not a second path to `real/a.ts`. A host query arriving as `link/a.ts` therefore has no stored key, and realpath() would silently rewrite it to `real/a.ts`." + }, + { + "question": "repository-roots", + "finding": "a linked worktree and a submodule are SEPARATE artifact roots", + "platform": "linux 6.17.0-1020-azure", + "linkedWorktree": { + "toplevel": "/tmp/adr006-wt-hfA6Uz/linked", + "gitCommonDir": "/tmp/adr006-8FzGuk/.git", + "isSeparateToplevel": true, + "tracked": [ + "a.ts" + ] + }, + "submodule": { + "added": true, + "hostTracked": [ + ".gitmodules", + "h.ts", + "vendor" + ], + "hostSeesSubmoduleFiles": false, + "submoduleToplevel": "/tmp/adr006-qNhHtA/vendor", + "hostToplevel": "/tmp/adr006-qNhHtA" + }, + "note": "The host repo tracks `vendor` as a gitlink, NOT `vendor/s.ts`. A reader that walked upward from inside the submodule and selected the host's artifact would resolve keys against the wrong root." + }, + { + "question": "malformed-stored-keys", + "finding": "every entry is non-canonical; normalization would REPAIR several into a different, valid-looking key", + "platform": "linux 6.17.0-1020-azure", + "corpus": [ + { + "key": "\"../x\"", + "nodeNormalize": "\"../x\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"src/../x\"", + "nodeNormalize": "\"x\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"a/b/../b/c.ts\"", + "nodeNormalize": "\"a/b/c.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"/abs/posix/x.ts\"", + "nodeNormalize": "\"/abs/posix/x.ts\"", + "isAbsolute": true, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"C:\\\\drive\\\\x.ts\"", + "nodeNormalize": "\"C:\\\\drive\\\\x.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"\\\\\\\\unc\\\\share\\\\x.ts\"", + "nodeNormalize": "\"\\\\\\\\unc\\\\share\\\\x.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"back\\\\slash.ts\"", + "nodeNormalize": "\"back\\\\slash.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"./leading.ts\"", + "nodeNormalize": "\"leading.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"double//sep.ts\"", + "nodeNormalize": "\"double/sep.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": true, + "containsNul": false + }, + { + "key": "\"trailing/\"", + "nodeNormalize": "\"trailing/\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"\"", + "nodeNormalize": "\".\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\".\"", + "nodeNormalize": "\".\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"..\"", + "nodeNormalize": "\"..\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": false + }, + { + "key": "\"a\\u0000b.ts\"", + "nodeNormalize": "\"a\\u0000b.ts\"", + "isAbsolute": false, + "silentlyRepairedToCanonical": false, + "containsNul": true + } + ], + "note": "`src/../x` -> `x` and `a/b/../b/c.ts` -> `a/b/c.ts` are the reject-don't-resolve cases: a reader that normalizes cannot report the artifact as invalid, because after normalization there is nothing left to report." + }, + { + "question": "silent-redirect", + "finding": "node:path.normalize collapses traversal, so a query naming a path that is not the stored key compares EQUAL to it", + "platform": "linux 6.17.0-1020-azure", + "storedKey": "a.ts", + "probes": [ + { + "query": "\"src/../a.ts\"", + "normalized": "\"a.ts\"", + "wouldMatchStoredKey": true + }, + { + "query": "\"a/b/../../a.ts\"", + "normalized": "\"a.ts\"", + "wouldMatchStoredKey": true + }, + { + "query": "\"./a.ts\"", + "normalized": "\"a.ts\"", + "wouldMatchStoredKey": true + }, + { + "query": "\"../a.ts\"", + "normalized": "\"../a.ts\"", + "wouldMatchStoredKey": false + } + ], + "note": "Measured on node:path only. Consumer behavior is cited in the ADR by file:line and was reproduced separately by executing the consumers read-only; importing them here would create the cross-repository coupling the architecture guard forbids." + } + ] +} diff --git a/docs/adr/experiments/006-path-identity/run.mjs b/docs/adr/experiments/006-path-identity/run.mjs new file mode 100644 index 0000000..9d278ea --- /dev/null +++ b/docs/adr/experiments/006-path-identity/run.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node + +// ADR-006 evidence harness — canonical path identity. +// +// This answers, with receipts rather than assertion, the questions ADR-006 has +// to decide. It builds throwaway Git repositories, puts adversarial pathnames +// in them, and records what Git, Node and JSON actually do — on THIS platform. +// +// node docs/adr/experiments/006-path-identity/run.mjs human output +// node docs/adr/experiments/006-path-identity/run.mjs --json raw receipts +// +// Nothing here is a test and nothing here gates CI. It is an experiment log: +// re-running it on another platform is expected to produce different answers to +// several questions, which is itself the finding. Every result carries the +// platform it was observed on, because "case-insensitive" is a property of a +// filesystem, not of a specification. +// +// Deliberate non-goals: +// - It does not import from workspacejson/cli or workspacejson/integrations. +// Cross-repository source coupling is forbidden, so where a consumer's +// behavior is at issue, this reproduces the SEMANTICS (node:path) and cites +// the consumer by file:line rather than importing it. +// - It draws no conclusions. The ADR draws conclusions; this produces the +// evidence they must survive. + +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, symlinkSync, realpathSync } from "node:fs"; +import { tmpdir, platform, release } from "node:os"; +import { join, normalize, resolve, relative, isAbsolute } from "node:path"; +import { spawnSync } from "node:child_process"; + +const asJson = process.argv.includes("--json"); +const receipts = []; + +const record = (question, finding, data) => receipts.push({ question, finding, ...data }); + +// Git invocations return raw bytes where the answer is about encoding: decoding +// to a JS string is exactly the step under investigation and must not be +// silently performed before the measurement. +const gitRaw = (cwd, ...args) => spawnSync("git", args, { cwd, maxBuffer: 1 << 24 }).stdout ?? Buffer.alloc(0); +const git = (cwd, ...args) => + (spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 24 }).stdout ?? "").trim(); + +function scratchRepo() { + const dir = mkdtempSync(join(tmpdir(), "adr006-")); + spawnSync("git", ["init", "-q"], { cwd: dir }); + spawnSync("git", ["config", "user.email", "e@e"], { cwd: dir }); + spawnSync("git", ["config", "user.name", "e"], { cwd: dir }); + return dir; +} +const commitAll = (dir, msg = "fixture") => { + spawnSync("git", ["add", "-A"], { cwd: dir }); + spawnSync("git", ["commit", "-qm", msg], { cwd: dir }); +}; + +// `git ls-files -z` is the acquisition form under test: NUL-delimited, so no +// quoting, escaping or display transformation can become artifact identity. +const lsFilesZ = (dir) => + gitRaw(dir, "ls-files", "-z").toString("binary").split("\0").filter(Boolean); +const lsFilesDefault = (dir) => git(dir, "ls-files").split("\n").filter(Boolean); + +const platformTag = `${platform()} ${release()}`; + +// ---------------------------------------------------------------- 1. case +{ + const dir = scratchRepo(); + writeFileSync(join(dir, "A.ts"), "a"); + let secondCreated = false; + try { + writeFileSync(join(dir, "a.ts"), "b"); + // On a case-insensitive filesystem this OVERWRITES A.ts rather than creating + // a sibling, so existence of two tracked entries is the real question. + secondCreated = true; + } catch { /* case-sensitive refusal is itself a finding */ } + commitAll(dir); + const tracked = lsFilesZ(dir); + const caseSensitiveFs = tracked.length === 2; + + record("case-identity", caseSensitiveFs + ? "filesystem is case-SENSITIVE: A.ts and a.ts are two tracked entries" + : "filesystem is case-INSENSITIVE: only one tracked entry survives", { + platform: platformTag, + secondWriteSucceeded: secondCreated, + trackedEntries: tracked, + gitIndexCount: tracked.length, + hostLookupCollides: !caseSensitiveFs, + note: "Stored-key comparison is a string operation and is unaffected by this. " + + "What varies is whether a HOST QUERY can name a file whose stored key differs only by case.", + }); + rmSync(dir, { recursive: true, force: true }); +} + +// ------------------------------------------------------------- 2. unicode +{ + const dir = scratchRepo(); + const nfc = "caf\u00e9.ts"; // é as one code point + const nfd = "cafe\u0301.ts"; // e + combining acute + writeFileSync(join(dir, nfc), "x"); + let nfdDistinct = false; + try { + writeFileSync(join(dir, nfd), "y"); + nfdDistinct = true; + } catch { /* ignore */ } + commitAll(dir); + + const z = lsFilesZ(dir); + const def = lsFilesDefault(dir); + const asUtf8 = gitRaw(dir, "ls-files", "-z").toString("utf8").split("\0").filter(Boolean); + + record("unicode-normalization", z.length === 2 + ? "NFC and NFD are DISTINCT tracked entries on this filesystem" + : "NFC and NFD collapse to a single tracked entry on this filesystem", { + platform: platformTag, + nfcWritten: JSON.stringify(nfc), + nfdWriteSucceeded: nfdDistinct, + trackedCount: z.length, + trackedViaLsFilesZ: asUtf8, + trackedCodePoints: asUtf8.map((p) => [...p].map((c) => c.codePointAt(0).toString(16)).join(" ")), + lsFilesDefaultQuoted: def, + quotingObserved: def.some((p) => p.startsWith('"')), + note: "If `git ls-files` (no -z) quotes a name and `-z` does not, then display " + + "quoting would become artifact identity for any producer using the default form.", + }); + rmSync(dir, { recursive: true, force: true }); +} + +// -------------------------------------------- 3. JSON representability +{ + const dir = scratchRepo(); + // A tracked pathname that is not valid UTF-8: a lone continuation byte. + const rawName = Buffer.from([0x62, 0x61, 0x64, 0xff, 0x2e, 0x74, 0x73]); // bad\xFF.ts + let created = false; + try { + writeFileSync(Buffer.concat([Buffer.from(`${dir}/`), rawName]), "x"); + created = true; + } catch { /* some platforms refuse */ } + let entry = null; + let roundTrip = null; + if (created) { + commitAll(dir); + const raw = gitRaw(dir, "ls-files", "-z"); + const bytes = raw.toString("binary").split("\0").filter(Boolean)[0] ?? ""; + entry = { + rawBytesHex: Buffer.from(bytes, "binary").toString("hex"), + decodedAsUtf8: Buffer.from(bytes, "binary").toString("utf8"), + decodedCodePoints: [...Buffer.from(bytes, "binary").toString("utf8")] + .map((c) => c.codePointAt(0).toString(16)).join(" "), + containsReplacementChar: Buffer.from(bytes, "binary").toString("utf8").includes("\uFFFD"), + }; + const json = JSON.stringify({ [entry.decodedAsUtf8]: {} }); + roundTrip = { + serialized: json, + reparsedKey: Object.keys(JSON.parse(json))[0], + reparsedEqualsOriginalBytes: + Buffer.from(Object.keys(JSON.parse(json))[0], "utf8").toString("hex") === + entry.rawBytesHex, + }; + } + // Unpaired surrogates and replacement-character substitution are the two + // failure modes §5 names. Both are measurable without a filesystem, so they + // are answerable on every platform even where creating the file is not. + const loneSurrogate = "\uD800"; + const surrogateProbe = { + key: "lone high surrogate U+D800", + survivesJsonRoundTrip: JSON.parse(JSON.stringify({ [loneSurrogate]: 1 })) !== undefined, + jsonEncodesIt: JSON.stringify(loneSurrogate), + // Buffer.from(...,'utf8') replaces an unpaired surrogate with U+FFFD, so the + // round trip is lossy: the string is not a sequence of scalar values. + reEncodedHex: Buffer.from(loneSurrogate, "utf8").toString("hex"), + reEncodeIsLossless: + Buffer.from(loneSurrogate, "utf8").toString("utf8") === loneSurrogate, + isWellFormed: typeof "".isWellFormed === "function" ? loneSurrogate.isWellFormed() : null, + }; + + record("json-representability", created + ? (entry.containsReplacementChar + ? "a non-UTF-8 tracked path DECODES LOSSILY: U+FFFD appears, and the original bytes are unrecoverable from the JSON key" + : "a non-UTF-8 tracked path decoded without a replacement character on this platform") + : "this platform refused to create a non-UTF-8 pathname; question not answerable here", { + platform: platformTag, + created, + attemptedBytesHex: rawName.toString("hex"), + entry, + roundTrip, + surrogateProbe, + note: "Lossy decode means silent identity mutation: two distinct tracked paths " + + "could serialize to the same JSON key. That is the case the rule must reject " + + "explicitly rather than absorb.", + }); + rmSync(dir, { recursive: true, force: true }); +} + +// ------------------------------------------------------------- 4. symlinks +{ + const dir = scratchRepo(); + mkdirSync(join(dir, "real"), { recursive: true }); + writeFileSync(join(dir, "real/a.ts"), "x"); + const outside = mkdtempSync(join(tmpdir(), "adr006-outside-")); + writeFileSync(join(outside, "external.ts"), "x"); + + symlinkSync("real", join(dir, "link")); // symlinked directory + symlinkSync("real/a.ts", join(dir, "alias.ts")); // symlink to tracked file + symlinkSync(join(outside, "external.ts"), join(dir, "escape.ts")); // escapes repo + commitAll(dir); + + const tracked = lsFilesZ(dir); + const throughLink = join(dir, "link/a.ts"); + record("symlinks", "tracked symlink ENTRIES are stored; Git does not store their targets as entries", { + platform: platformTag, + trackedEntries: tracked, + symlinkEntriesTracked: tracked.filter((p) => ["alias.ts", "escape.ts", "link"].includes(p)), + directoryNotWalkedThrough: !tracked.includes("link/a.ts"), + queryThroughSymlinkedDir: { + lexical: relative(dir, normalize(throughLink)), + realpath: relative(dir, realpathSync(throughLink)), + lexicalEqualsRealpath: normalize(throughLink) === realpathSync(throughLink), + }, + escapingSymlink: { + entry: "escape.ts", + realpathEscapesRepo: !realpathSync(join(dir, "escape.ts")).startsWith(realpathSync(dir)), + }, + note: "`link/a.ts` is NOT a tracked entry — Git records the symlink `link`, not a " + + "second path to `real/a.ts`. A host query arriving as `link/a.ts` therefore has " + + "no stored key, and realpath() would silently rewrite it to `real/a.ts`.", + }); + rmSync(dir, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); +} + +// -------------------------------------------------- 5. roots: worktree/submodule +{ + const main = scratchRepo(); + writeFileSync(join(main, "a.ts"), "x"); + commitAll(main); + + // linked worktree + const wt = join(mkdtempSync(join(tmpdir(), "adr006-wt-")), "linked"); + spawnSync("git", ["worktree", "add", "-q", "-b", "wt", wt], { cwd: main }); + + // submodule + const sub = scratchRepo(); + writeFileSync(join(sub, "s.ts"), "x"); + commitAll(sub); + const host = scratchRepo(); + writeFileSync(join(host, "h.ts"), "x"); + commitAll(host); + const addSub = spawnSync("git", + ["-c", "protocol.file.allow=always", "submodule", "add", "-q", sub, "vendor"], + { cwd: host, encoding: "utf8" }); + commitAll(host, "add submodule"); + + record("repository-roots", "a linked worktree and a submodule are SEPARATE artifact roots", { + platform: platformTag, + linkedWorktree: { + toplevel: git(wt, "rev-parse", "--show-toplevel"), + gitCommonDir: git(wt, "rev-parse", "--git-common-dir"), + isSeparateToplevel: git(wt, "rev-parse", "--show-toplevel") !== git(main, "rev-parse", "--show-toplevel"), + tracked: lsFilesZ(wt), + }, + submodule: { + added: addSub.status === 0, + hostTracked: lsFilesZ(host), + hostSeesSubmoduleFiles: lsFilesZ(host).includes("vendor/s.ts"), + submoduleToplevel: git(join(host, "vendor"), "rev-parse", "--show-toplevel"), + hostToplevel: git(host, "rev-parse", "--show-toplevel"), + }, + note: "The host repo tracks `vendor` as a gitlink, NOT `vendor/s.ts`. A reader that " + + "walked upward from inside the submodule and selected the host's artifact would " + + "resolve keys against the wrong root.", + }); + spawnSync("git", ["worktree", "remove", "--force", wt], { cwd: main }); + for (const d of [main, sub, host]) rmSync(d, { recursive: true, force: true }); +} + +// ------------------------------------------------- 6. malformed stored keys +{ + // The corpus a stored-key validator must reject. `normalize()` is shown to + // demonstrate what a reader that normalizes would silently turn each into — + // this is the behavior ADR-006 forbids, not the behavior it specifies. + const corpus = [ + "../x", "src/../x", "a/b/../b/c.ts", "/abs/posix/x.ts", "C:\\drive\\x.ts", + "\\\\unc\\share\\x.ts", "back\\slash.ts", "./leading.ts", "double//sep.ts", + "trailing/", "", ".", "..", "a\0b.ts", + ]; + record("malformed-stored-keys", "every entry is non-canonical; normalization would REPAIR several into a different, valid-looking key", { + platform: platformTag, + corpus: corpus.map((k) => ({ + key: JSON.stringify(k), + nodeNormalize: JSON.stringify(normalize(k)), + isAbsolute: isAbsolute(k), + // The dangerous class: normalization turns an invalid key into a key that + // looks canonical, so the defect becomes unobservable downstream. + silentlyRepairedToCanonical: + k !== normalize(k) && !normalize(k).startsWith("..") && !isAbsolute(normalize(k)) && normalize(k) !== ".", + containsNul: k.includes("\0"), + })), + note: "`src/../x` -> `x` and `a/b/../b/c.ts` -> `a/b/c.ts` are the reject-don't-resolve " + + "cases: a reader that normalizes cannot report the artifact as invalid, because after " + + "normalization there is nothing left to report.", + }); +} + +// ------------------------------- 7. the silent redirect, reproduced by semantics +{ + // Reproduces the CAUSE of the observed consumer behavior without importing + // from the consumer repository. The consumers are cited in the ADR by + // file:line; what is measured here is node:path.normalize, which is the + // single call both of them route stored keys and host queries through. + const stored = "a.ts"; + const probes = ["src/../a.ts", "a/b/../../a.ts", "./a.ts", "../a.ts"]; + record("silent-redirect", "node:path.normalize collapses traversal, so a query naming a path that is not the stored key compares EQUAL to it", { + platform: platformTag, + storedKey: stored, + probes: probes.map((q) => ({ + query: JSON.stringify(q), + normalized: JSON.stringify(normalize(q)), + wouldMatchStoredKey: normalize(q).replace(/^\.\//, "") === stored, + })), + note: "Measured on node:path only. Consumer behavior is cited in the ADR by file:line " + + "and was reproduced separately by executing the consumers read-only; importing them " + + "here would create the cross-repository coupling the architecture guard forbids.", + }); +} + +// ---------------------------------------------------------------- output +if (asJson) { + console.log(JSON.stringify({ platform: platformTag, node: process.version, receipts }, null, 2)); +} else { + console.log(`ADR-006 path identity — evidence run`); + console.log(`platform: ${platformTag} node: ${process.version}\n`); + for (const r of receipts) { + console.log(`## ${r.question}`); + console.log(` ${r.finding}\n`); + } + console.log(`${receipts.length} questions probed. Use --json for raw receipts.`); +} diff --git a/docs/adr/index.json b/docs/adr/index.json index 72818f6..fabe9fe 100644 --- a/docs/adr/index.json +++ b/docs/adr/index.json @@ -31,8 +31,8 @@ "decisionDate": "2026-08-03", "ratifyingIssue": "META-264 (internal tracker)", "blob": "e7874b9cc4d27eea3a6c661d3ccc1e151cacf5fa", - "revision": null, - "pullRequest": null + "revision": "bd14f3954e9c359978fc691784b11561b2288ef0", + "pullRequest": 21 }, { "adr": 4, @@ -55,6 +55,17 @@ "blob": "43645ce5ef512706e6e3ebccdf121f5e93740873", "revision": "77392605f46e9c26799ee3f66039e650ec8150fe", "pullRequest": 14 + }, + { + "adr": 6, + "title": "Canonical path identity — stored keys are data, not commands", + "path": "docs/adr/006-canonical-path-identity.md", + "status": "Accepted", + "decisionDate": "2026-08-09", + "ratifyingIssue": "META-278 (internal tracker)", + "blob": "ea92906a9229b41cd7fa21b9280f3e840f9bf622", + "revision": null, + "pullRequest": null } ] } diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 98ce902..e27fea0 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -53,6 +53,7 @@ const PROVENANCE_FILES = new Set([ "docs/adr/001-canonical-artifact-path.md", "docs/adr/002-bounded-enrichment-program.md", "docs/adr/003-field-lifecycle-and-admission.md", + "docs/adr/006-canonical-path-identity.md", ]); // Historical release notes are a record of what was published, not live prose.