From 6aa2ba0988a5c6b686a985fd6b0b1ee69374913e Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Mon, 3 Aug 2026 22:32:51 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs(adr-006):=20propose=20canonical=20path?= =?UTF-8?q?=20identity=20=E2=80=94=20stored=20keys=20are=20data,=20not=20c?= =?UTF-8?q?ommands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draft record, status Proposed. No implementation, no schema change, no producer behaviour change. Rebuilt on merged main (f85039b) after ADR-003 landed. The schema calls path-bearing values "repo-root-relative POSIX paths" and stops. Case, encoding, ./ prefixes, trailing slashes, symlink resolution order, and what "repo-root-relative" means in a submodule or linked worktree are unwritten, so two implementers can guess differently and both conform. They already have: both shipped consumers route stored keys AND host queries through node:path.normalize, so src/../a.ts matches stored a.ts. The extension's isValidRelativeKey runs after normalization, so it cannot see malformation normalization already erased. The record specifies every path-bearing location, not just fileIndex, and separates the two operations the implementations conflate: validateStoredKey(rawKey) total, no filesystem, never repairs canonicalizeHostQuery(root, inputPath) may touch disk, may fail, never invents Evidence rather than assertion. The harness builds throwaway Git repositories and records what Git, Node and JSON do; receipts for both platforms are committed. git ls-files -> "caf\303\251.ts" quoted, octal-escaped, literal quotes git ls-files -z -> café.ts raw bytes link/a.ts is NOT a tracked entry under a symlinked directory, so it has no stored key; realpath() would rewrite it to real/a.ts, answering a different question. A host repo tracks .gitmodules, h.ts and vendor — not vendor/s.ts. Five of fourteen malformed stored keys normalize into a valid-LOOKING key. Linux run closes the three questions darwin could not answer: case ["A.ts", "a.ts"] — two tracked entries unicode NFC and NFD distinct: 63 61 66 65 301 … vs 63 61 66 e9 … non-UTF-8 626164ff2e7473 -> "bad.ts", re-encode == original: false so §4 and §5 rest on observation rather than on reasoning about a failure mode. Windows is resolved by scoping, not measurement: §7a scopes the accepted host-query profile to POSIX and states Windows canonicalization is not yet specified. The stored-key grammar stays universal and validateStoredKey touches no filesystem, so reading on Windows is unaffected. Control characters narrowed to NUL alone — U+000A and U+0009 are legal in POSIX filenames, and forbidding them would make real repositories unrepresentable. Stored text defined as Unicode scalar values from lossless UTF-8 decoding, rejecting unpaired surrogates and U+FFFD substitution. Release sequencing made explicit: a v0.4.x reader reports a malformed key and declines to match it, including declining to match the value it would have become under normalization. Tolerance governs whether the reader continues, never whether it rewrites. scripts/check-docs.mjs gains one PROVENANCE_FILES entry because this record names its ratification issue in metadata; without it check:docs fails. docs/adr/index.json is regenerated, which also enriches ADR-003's now-derivable revision to f85039b and PR 18 — the post-merge enrichment path working as designed, on a main that was never red. --- .github/workflows/adr-006-evidence.yml | 59 +++ docs/adr/006-canonical-path-identity.md | 477 ++++++++++++++++++ docs/adr/README.md | 1 + .../006-path-identity/receipts-darwin.json | 242 +++++++++ .../006-path-identity/receipts-linux.json | 255 ++++++++++ .../adr/experiments/006-path-identity/run.mjs | 335 ++++++++++++ docs/adr/index.json | 15 +- scripts/check-docs.mjs | 1 + 8 files changed, 1383 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/adr-006-evidence.yml create mode 100644 docs/adr/006-canonical-path-identity.md create mode 100644 docs/adr/experiments/006-path-identity/receipts-darwin.json create mode 100644 docs/adr/experiments/006-path-identity/receipts-linux.json create mode 100644 docs/adr/experiments/006-path-identity/run.mjs 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..76dd440 --- /dev/null +++ b/docs/adr/006-canonical-path-identity.md @@ -0,0 +1,477 @@ +# ADR-006: Canonical path identity — stored keys are data, not commands + +| Field | Value | +| -- | -- | +| **Status** | Proposed | +| **Decision date** | *not yet decided — this record is under review* | +| **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.** Four cases: + +| Case | Behavior | +| -- | -- | +| Query names a tracked symlink entry (`alias.ts`) | key is `alias.ts` | +| Query traverses a symlinked directory (`link/a.ts`) | **unsupported** — no tracked entry names it; rewriting to `real/a.ts` answers a different question | +| Tracked symlink whose target escapes the repository (`escape.ts`) | key is `escape.ts`; the target is irrelevant and is not followed | +| `realpath()` differs from the lexical tracked entry | the tracked entry wins | + +Confirmed in the run: for `link/a.ts`, lexical and `realpath()` disagree, and the +escaping symlink's `realpath()` leaves the repository entirely. + +A traversal that cannot prove both containment and tracked identity is explicitly +`ambiguous` or `unsupported`. It is never resolved to a nearby key. + +### 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. + +## 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`. 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..47397b7 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 | Proposed | — | ## 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..5a52081 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": "Proposed", + "decisionDate": "*not yet decided — this record is under review*", + "ratifyingIssue": "META-278 (internal tracker)", + "blob": "76dd4405f36e923676beaf9ade11f14514a8fbfa", + "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. From b98cd69b52d4674bf8c73c505d9123cbfacd6790 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Mon, 3 Aug 2026 22:57:50 -0400 Subject: [PATCH 2/3] docs(adr-006): make the symlink traversal rule coherent; cite both receipt files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The symlink section stated two different rules and was not ratifiable as written. The case table said a query traversing a symlinked directory is unsupported, full stop. The closing sentence said a traversal that "cannot prove both containment and tracked identity" is unsupported — which implies that a traversal which CAN prove them yields a key. §2 compounds it by granting canonicalizeHostQuery filesystem access specifically to establish identity. Resolved on the proof-based rule. Traversal is permitted only against proof: 1. resolve the target 2. prove it remains within the repository root 3. prove its exact tracked entry 4. return that tracked entry Any step failing — target escapes, untracked, ambiguous — yields unsupported. No fallback, no nearest-match. So link/a.ts may map to real/a.ts, but only when all four succeed. The distinction that makes this consistent with reject-don't-resolve is now stated outright: canonicalizing a HOST QUERY into an existing tracked identity is not repairing a STORED KEY. The input is a host path, not an artifact key, and nothing in the artifact is rewritten. The record previously left those two operations close enough to be conflated. One constraint added that the ruling implies but did not spell out: a lexical alias is never replaced by its target when the alias is ITSELF tracked. Two tracked entries collapsing to one key would destroy the artifact's ability to distinguish them. Resolution applies only where the lexical path has no tracked entry of its own, and therefore no identity to lose. Provenance now cites receipts-darwin.json AND receipts-linux.json, and says what each carries: darwin answers symlinks, repository roots, malformed keys and display quoting; Linux answers case distinction, NFC/NFD and non-UTF-8 decoding, which APFS cannot express. Neither alone supports §4 and §5. index.json regenerated. Full gate set green. --- docs/adr/006-canonical-path-identity.md | 52 ++++++++++++++++++++----- docs/adr/index.json | 2 +- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/docs/adr/006-canonical-path-identity.md b/docs/adr/006-canonical-path-identity.md index 76dd440..b031076 100644 --- a/docs/adr/006-canonical-path-identity.md +++ b/docs/adr/006-canonical-path-identity.md @@ -254,20 +254,45 @@ 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.** Four cases: +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` | -| Query traverses a symlinked directory (`link/a.ts`) | **unsupported** — no tracked entry names it; rewriting to `real/a.ts` answers a different question | -| Tracked symlink whose target escapes the repository (`escape.ts`) | key is `escape.ts`; the target is irrelevant and is not followed | -| `realpath()` differs from the lexical tracked entry | the tracked entry wins | +| 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. - -A traversal that cannot prove both containment and tracked identity is explicitly -`ambiguous` or `unsupported`. It is never resolved to a nearby key. +escaping symlink's `realpath()` leaves the repository entirely. Both are exactly +the conditions the proofs above test. ### 7. Repository roots @@ -462,7 +487,14 @@ 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`. The harness +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. diff --git a/docs/adr/index.json b/docs/adr/index.json index 5a52081..7f33024 100644 --- a/docs/adr/index.json +++ b/docs/adr/index.json @@ -63,7 +63,7 @@ "status": "Proposed", "decisionDate": "*not yet decided — this record is under review*", "ratifyingIssue": "META-278 (internal tracker)", - "blob": "76dd4405f36e923676beaf9ade11f14514a8fbfa", + "blob": "b031076ec3c73092f5bd94b81f5cbc3de9876520", "revision": null, "pullRequest": null } From bc18355c8e3cbe8fe21ff399ab89aff80743c0d6 Mon Sep 17 00:00:00 2001 From: Qwynn Marcelle Date: Sun, 9 Aug 2026 18:05:03 -0400 Subject: [PATCH 3/3] docs(adr-006): ratify canonical path identity as Accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steward acceptance given 2026-08-09 by Qwynn Marcelle, sole steward, in explicit terms. Status moves Proposed → Accepted, the decision date is the date acceptance was given rather than the drafting date, the README index row is updated, and docs/adr/index.json is regenerated. Acceptance authorized ratification only. It did not authorize implementation: the executable corpus, the standard-owned validation contract, producer and reader convergence, and the public-documentation sync remain separate work items under the ratification issue. Four scope corrections were issued with the acceptance and are recorded in the record itself, because they narrow what it 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; a filesystem-aware implementation in the spec package needs its own architectural decision. 3. A genuine U+FFFD in a filename is valid. Lossy-decoding detection belongs at raw-byte acquisition, where re-encoded bytes can be compared against the originals, not in a pure string validator that has no bytes. 4. Acquisition repair is a separate change from stored-key validation and needs its own caller trace and watched-red evidence first. Rebuilt on bd14f395 rather than merged from the stale f85039b base. The only conflict was docs/adr/index.json, resolved exclusively by regeneration — the generator is its sole author. Regeneration also enriched ADR-003's revision pin to bd14f395, now that #21 has published those bytes. No file from #20 or #21 is reverted or reintroduced. The two edits to shared files are pure additions: one README index row and one path in scripts/check-docs.mjs. Refs: META-278 --- docs/adr/006-canonical-path-identity.md | 35 +++++++++++++++++++++++-- docs/adr/README.md | 2 +- docs/adr/index.json | 6 ++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/adr/006-canonical-path-identity.md b/docs/adr/006-canonical-path-identity.md index b031076..ea92906 100644 --- a/docs/adr/006-canonical-path-identity.md +++ b/docs/adr/006-canonical-path-identity.md @@ -2,8 +2,8 @@ | Field | Value | | -- | -- | -| **Status** | Proposed | -| **Decision date** | *not yet decided — this record is under review* | +| **Status** | Accepted | +| **Decision date** | 2026-08-09 | | **Record written** | 2026-08-03 | | **Author** | Qwynn Marcelle ([@qmarcelle](https://github.com/qmarcelle)) | | **Decider** | Qwynn Marcelle | @@ -480,6 +480,37 @@ 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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 47397b7..b3613e3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,7 +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 | Proposed | — | +| [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/index.json b/docs/adr/index.json index 7f33024..fabe9fe 100644 --- a/docs/adr/index.json +++ b/docs/adr/index.json @@ -60,10 +60,10 @@ "adr": 6, "title": "Canonical path identity — stored keys are data, not commands", "path": "docs/adr/006-canonical-path-identity.md", - "status": "Proposed", - "decisionDate": "*not yet decided — this record is under review*", + "status": "Accepted", + "decisionDate": "2026-08-09", "ratifyingIssue": "META-278 (internal tracker)", - "blob": "b031076ec3c73092f5bd94b81f5cbc3de9876520", + "blob": "ea92906a9229b41cd7fa21b9280f3e840f9bf622", "revision": null, "pullRequest": null }