treelay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of treelay might be problematic. Click here for more details.

package/SPEC.md ADDED
@@ -0,0 +1,920 @@
1
+ # treelay — design spec
2
+
3
+ An inheritance/composition system for directory trees. A directory can declare
4
+ inheritance **parents** and **mixins**; "compiling" it resolves the whole graph
5
+ and materializes a flat output directory. Crucially, the output stays **linked**
6
+ to its template, so template changes can be pulled into an already-created,
7
+ already-edited project via three-way merge.
8
+
9
+ > Status: design draft. Decisions marked **[decided]** are locked; **[open]**
10
+ > needs a call before implementation.
11
+
12
+ ---
13
+
14
+ ## 1. Mental model
15
+
16
+ A directory is a **class**; compiling it produces an **instance** (a plain,
17
+ flat directory). Everything else is borrowed from language semantics:
18
+
19
+ | Filesystem concept | Language analogue |
20
+ |-------------------------------|------------------------------|
21
+ | `parents` | base classes (*is-a*) |
22
+ | `mixins` | traits/mixins (*with-a*) |
23
+ | compile | instantiation / flattening |
24
+ | same file in multiple layers | method override / `super()` |
25
+ | a `.patch` on an inherited file | calling `super()` then editing the result |
26
+ | a tombstone | deleting an inherited member |
27
+
28
+ If a "what should happen here?" question comes up, the honest answer is usually
29
+ "what would the class-inheritance version do?"
30
+
31
+ ### The two relationships, and why both exist
32
+
33
+ - **parent** — a full base you are a specialization of. Transitively resolved:
34
+ a parent contributes *its own* parents into your lineage. Use for "this
35
+ project *is a* TypeScript service."
36
+ - **mixin** — a focused, reusable fragment layered on top. Not necessarily
37
+ buildable standalone. Use for "...also *with* Docker, *with* CI."
38
+
39
+ Precedence, lowest → highest:
40
+
41
+ ```
42
+ parents (C3-linearized) < mixins (declaration order) < self
43
+ ```
44
+
45
+ `self > mixins > parents`. Write this on the box; every user needs it in their head.
46
+
47
+ ---
48
+
49
+ ## 2. Manifest
50
+
51
+ Each overlay directory carries `treelay.json` (or a `"treelay"` key in
52
+ `package.json`, so an overlay *is* a normal npm package).
53
+
54
+ ```jsonc
55
+ {
56
+ "name": "@acme/service-base",
57
+ "abstract": false, // true = inherit-only, not compilable standalone
58
+ "parents": ["@acme/node-base@^2", "../shared-eslint"],
59
+ "mixins": ["@acme/with-docker", "@acme/with-ci"],
60
+ "mounts": { "packages": "git+https://host/acme/packages.git#v1.2.0" },
61
+ "ignore": ["node_modules", ".git", ".treelay"],
62
+ "merge": {
63
+ "*.json": "deep-merge",
64
+ "*.yaml": "deep-merge",
65
+ "package.json": "deep-merge",
66
+ "**/*.png": "replace"
67
+ },
68
+ "arrays": "replace", // deep-merge array policy: replace | concat | by-key
69
+
70
+ "templateSuffix": ".tmpl", // only files ending in this are rendered (see §6)
71
+ "variables": {
72
+ "serviceName": { "type": "string", "prompt": "Service name?" },
73
+ "port": { "type": "number", "default": 3000 },
74
+ "useDocker": { "type": "boolean", "default": true },
75
+ "registry": { "type": "string", "default": "{{ org }}.registry.io", "computed": true },
76
+ "license": { "type": "string", "choices": ["MIT", "Apache-2.0"], "default": "MIT" }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Variable *declarations* merge across the inheritance graph by the same layer
82
+ precedence as files (§3) — a parent declares, a mixin or the leaf overrides the
83
+ default — producing **one merged questionnaire** for the whole composition
84
+ (detailed in §6). This is the thing copier structurally cannot do (its templates
85
+ each keep an isolated answers file).
86
+
87
+ ### Referencing layers (all npm-native) **[decided]** ✅ *implemented*
88
+
89
+ Three origins, told apart by shape alone so nothing has to be declared twice:
90
+
91
+ | Form | Example | Resolution |
92
+ |---|---|---|
93
+ | **local path** | `../base`, `/abs/base`, `file:./base` | as written (monorepo / dev) |
94
+ | **git** | `git+https://host/o/r.git#v1.2.0`, `git+ssh://git@host/o/r.git#main`, `git+file:///srv/r.git#main`, `github:acme/base#v2` | cloned and pinned to a commit |
95
+ | **npm** | `@acme/base@^2`, `pkg@1.2.3`, `npm:@acme/base@^2` | through the installed `node_modules` |
96
+
97
+ Any non-local ref may carry **`?path=<subdir>`** to use a subdirectory of the
98
+ fetched tree as the layer root — the monorepo-of-layers case
99
+ (`git+https://…/lake.git?path=core/_layer#v3`). Order follows URL convention:
100
+ query before fragment.
101
+
102
+ **Git refs are enforced; npm refs are delegated.** A git ref names a commit, and
103
+ treelay materializes exactly that commit from its own cache, so a build
104
+ reproduces regardless of what has happened to the branch. An npm ref names a
105
+ *range*, and the package on disk was put there by a package manager that already
106
+ owns installation, integrity and an offline cache. treelay reads what is
107
+ installed, verifies it satisfies the declared range, and records the exact
108
+ version — it does not install, and it cannot roll a package back. Shipping a
109
+ second, worse package manager inside a composition engine would only produce two
110
+ lockfiles disagreeing about one tree. Reproducing an old npm pin is `npm ci`'s
111
+ job; that asymmetry is deliberate and is the one place where "pinned" means
112
+ *recorded* rather than *enforced*.
113
+
114
+ ### Vendored trees: `mounts` **[decided]** ✅ *implemented*
115
+
116
+ A layer can pull an entire external tree into the composed output at a fixed
117
+ subpath:
118
+
119
+ ```jsonc
120
+ { "mounts": { "packages": "git+https://host/acme/packages.git#v1.2.0" } }
121
+ ```
122
+
123
+ Every compiled tree then carries its own `packages/` — a self-contained
124
+ mini-monorepo where `file:`/workspace references resolve at stable internal
125
+ paths, with no registry and no path rewriting.
126
+
127
+ Mount **paths** merge across the graph by ordinary layer precedence: the highest
128
+ layer naming `packages` decides the ref. That is the whole feature — a leaf
129
+ holds a vendored tree back at an older pin while its parents float, using the
130
+ same override rule as everything else rather than a bespoke package mechanism.
131
+
132
+ Mounted layers sit at the **bottom** of the stack, below every declared layer.
133
+ Vendored content is substrate: any layer must be able to patch or tombstone a
134
+ file inside it, and that only works if the mount is beneath them all. The
135
+ alternative — placing a mount beside its declaring layer — would make an
136
+ intermediate layer's patch on `packages/**` apply or not depending on which
137
+ ancestor happened to win the ref, which is exactly the unexplainable-bug-report
138
+ outcome C3 exists to avoid. Overlapping mount paths are refused; they have no
139
+ unambiguous owner. Mounted layers are read-only for reflux in v1: promotion maps
140
+ an output path back to a layer path, and a mount's output paths carry a prefix
141
+ its sources do not (§8).
142
+
143
+ ---
144
+
145
+ ## 3. Resolution — the crux
146
+
147
+ A graph (with possible diamonds) flattened to a deterministic, ordered layer
148
+ list, lowest precedence → highest.
149
+
150
+ ### Linearization: C3 **[decided]**
151
+
152
+ Use C3 linearization (Python's MRO) over the `parents` graph. It is the only
153
+ approach that simultaneously:
154
+
155
+ 1. handles diamonds — a shared grandparent applies **once**, before both children;
156
+ 2. respects local order — your declared parent order is honored;
157
+ 3. is monotonic — a parent's ancestry is never reshuffled by a child.
158
+
159
+ The naive alternative (depth-first + last-wins dedupe) is simpler but produces
160
+ surprising orders in diamonds and generates unexplainable bug reports.
161
+
162
+ Final stack, lowest → highest:
163
+
164
+ 0. `mounts` (§2), sorted by mount path — vendored substrate
165
+ 1. linearized `parents` (C3 order)
166
+ 2. `mixins` in declaration order (each strictly above all parents)
167
+ 3. the directory's own files (always win)
168
+
169
+ ### Guards **[decided]**
170
+
171
+ - **Cycle detection** — `A → B → A` fails loud with the full path shown.
172
+ - **Lockfile** — `treelay.lock` records what every ref resolved to, for
173
+ reproducible builds and upstream-drift detection (below).
174
+
175
+ ### `treelay.lock` **[decided]** ✅ *implemented*
176
+
177
+ Committed beside the **leaf layer's manifest**. Deterministically serialized —
178
+ sorted keys, fixed field order, two-space indent, trailing newline — so
179
+ re-resolving an unchanged tree produces byte-identical output and never appears
180
+ in a diff.
181
+
182
+ ```jsonc
183
+ {
184
+ "lockfileVersion": 1,
185
+ "refs": {
186
+ "git+https://host/acme/packages.git#v1.2.0": { // canonical ref = the key
187
+ "kind": "git",
188
+ "source": "https://host/acme/packages.git",
189
+ "requested": "v1.2.0", // the mutable thing asked for
190
+ "resolved": "3f2a1c9e…", // the immutable thing it became
191
+ "integrity": "sha256:…", // over the materialized tree
192
+ "path": "core/_layer", // ?path=, when present
193
+ "requestedBy": ["edo/soredi/_layer"] // relative to the lockfile
194
+ }
195
+ }
196
+ }
197
+ ```
198
+
199
+ Four properties worth stating outright:
200
+
201
+ - **The key is canonical, not textual.** `github:acme/base#v2` and
202
+ `git+https://github.com/acme/base.git#v2` are the same layer and share one
203
+ entry; the key is derived from the parsed ref, so two spellings cannot pin
204
+ independently.
205
+ - **`requested` and `resolved` are both recorded.** A lock that stored only the
206
+ commit could say *that* something changed but never *what was asked for* — and
207
+ "v1.2.0 now points somewhere else" is the sentence a drift report needs.
208
+ - **`integrity` covers content, not history.** It hashes the materialized tree
209
+ (`.git` and `node_modules` pruned), so a cache entry edited in place is caught
210
+ even though its commit id is unchanged. It is enforced only when both sides
211
+ claim the same revision — a *different* revision is drift, which for npm is
212
+ legitimate and is reported rather than mistaken for corruption.
213
+ - **Only refs actually materialized are pinned.** A mount ref that lost the
214
+ precedence contest is never fetched and never appears; the lock is a record of
215
+ what was built, not of everything mentioned.
216
+
217
+ Distinct from `<dest>/.treelay/lock.json` (§7): that file is regenerated on
218
+ every compile and says what one *destination* was built from. `treelay.lock` is
219
+ the pin a repository commits and reviews.
220
+
221
+ **Fetching and the cache.** Fetched trees land at a path derived from what they
222
+ are — `<cache>/git/<repo-key>/rev/<commit>/` — never from who asked. Two layers
223
+ pinning the same commit share one checkout; a held-back pin coexists with a
224
+ floating one instead of fighting over a working directory; and the cache is
225
+ disposable, since the worst case is a re-fetch. Git trees are extracted with
226
+ `git archive`, so a checkout carries no `.git` at all and cannot leak a gitlink
227
+ into a composed tree (§4 guards the same hazard from the other side).
228
+ `TREELAY_CACHE_DIR` relocates it.
229
+
230
+ **Resolution is synchronous**, even though it may clone. Every caller in the
231
+ library and CLI treats `resolve` as a plain function, and a build cannot proceed
232
+ without its layers, so there is nothing to overlap with — going async would tax
233
+ every consumer for no gain.
234
+
235
+ ### Drift: ref moved vs lock **[decided]** ✅ *implemented*
236
+
237
+ Drift is **reported, never acted on**. `compile` and `update` materialize the
238
+ locked revision even after `main` advances; that is what pinning means. An
239
+ update that silently recomposed at a newer revision would make "pull my
240
+ template's changes down" mean something different depending on the day.
241
+
242
+ - `treelay lock --drift` probes each pinned ref and exits non-zero if any moved.
243
+ - `update` prints moved refs on stderr before merging, then composes from the pins.
244
+ - `explain` / `plan` annotate each fetched layer with the revision in use.
245
+ - `treelay lock --update` is the *only* thing that advances a pin.
246
+ - `--frozen-lockfile` refuses to resolve anything the lock does not already
247
+ pin — the CI posture: a build reproduces from what was committed, or it fails.
248
+
249
+ The probe is best-effort by construction. Offline, unauthenticated, or
250
+ uninstalled makes the current revision **unknown**, which is a distinct answer
251
+ from "unchanged" and is presented as one — an advisory check that reported
252
+ in-sync when it could not look would be worse than saying nothing. Annotated
253
+ tags are peeled before comparison, or every one of them would report drift
254
+ forever (a tag object's id is not the commit's).
255
+
256
+ Absent a lock entry, the first build resolves the ref, materializes it, and
257
+ records the pin — the package-manager convention, so day one needs no separate
258
+ step. That write touches the *source* tree, so it is always announced rather
259
+ than done silently.
260
+
261
+ ---
262
+
263
+ ## 4. Per-file merge semantics
264
+
265
+ When the same relative path exists in N layers:
266
+
267
+ | Strategy | Default for | Mechanism |
268
+ |---------------------|-------------------------|--------------------------------------------|
269
+ | **replace** | binary / unknown | higher layer wins wholesale |
270
+ | **deep-merge** | JSON / YAML / TOML | recursive merge (array policy configurable)|
271
+ | **patch** | text | apply unified diff onto inherited file |
272
+ | **append / prepend**| `.gitignore`, logs | concatenate |
273
+ | **delete (tombstone)** | — | whiteout an inherited file |
274
+
275
+ Strategy is chosen three ways, in increasing power:
276
+
277
+ - **manifest globs** (`"merge"` block) — broad defaults.
278
+ - **filename suffix conventions** — discoverable one-off *sugar*:
279
+ - `config.json.patch` → apply diff onto inherited `config.json`
280
+ - `.gitignore.append` → append to inherited file
281
+ - `config.json.delete` → tombstone the inherited file
282
+ - **`.treelay` sidecar** — the canonical, full-power form (below). Suffixes
283
+ desugar to a sidecar op; anything a suffix can express, a sidecar can too.
284
+
285
+ Array merge policy (`"arrays"`) defaults to **replace** — concat surprises people.
286
+
287
+ ### The `.treelay` sidecar — canonical operation format
288
+
289
+ A `<targetPath>.treelay` file sitting beside where a file would land describes an
290
+ **operation on the inherited file**. It exists because filename suffixes can't
291
+ carry metadata — most importantly the **base** that §5's true 3-way merge
292
+ needs. YAML, so unified-diff payloads read cleanly as block scalars:
293
+
294
+ ```yaml
295
+ # config.json.treelay → operates on the inherited config.json
296
+ op: patch # patch | merge | append | prepend | delete | replace
297
+ base: sha256:ab12… # hash of the parent text this was authored against → drift detection
298
+ baseContent: | # the parent text itself → enables true 3-way once it drifts (§5)
299
+ {
300
+ "name": "svc",
301
+ }
302
+ when: "{{ useDocker }}" # optional conditional (skip the op when false)
303
+ render: true # render the payload/result with variables (§6)
304
+ patch: |
305
+ @@ -2,3 +2,4 @@
306
+ "name": "svc",
307
+ + "port": 3000,
308
+ ```
309
+
310
+ ```yaml
311
+ # package.json.treelay → structured merge, no line-drift
312
+ op: merge
313
+ merge: { scripts: { build: "tsc" } } # RFC 7386 JSON Merge Patch (or `jsonPatch:` for RFC 6902)
314
+ ```
315
+
316
+ ```yaml
317
+ # README.md.treelay → remove an inherited file
318
+ op: delete
319
+ ```
320
+
321
+ Division of power: a bare `*.patch` (no recorded base) is **best-effort apply**;
322
+ a sidecar `op: patch` carrying `baseContent` gets a **true 3-way merge**, and one
323
+ carrying only `base` gets honest drift detection in between (§5 spells out the
324
+ four cases). **Reflux (§8) auto-generates sidecars** with both fields baked in,
325
+ so hand-authoring is the exception, not the rule. (Sidecars are distinct from the
326
+ `.treelay/` *state directory*, which only ever exists in compiled destinations,
327
+ never in layers.)
328
+
329
+ ### Never composed — built-in exclusions **[decided]**
330
+
331
+ Some paths are excluded from every layer's walk before any strategy applies —
332
+ an implicit tombstone that no manifest has to declare:
333
+
334
+ | Excluded | Why |
335
+ |---|---|
336
+ | `.git` (**file or directory**) | VCS metadata; see below |
337
+ | `node_modules/` | dependency install output, never template content |
338
+ | `.treelay/` | destination *state* dir (§7); only valid in an output |
339
+ | `treelay.{json,yaml,yml}` | the layer's own manifest, not payload |
340
+
341
+ **`.git` is excluded in both of its forms, and this matters.** A layer vendored
342
+ as a **git submodule** carries a `.git` *gitlink file* (`gitdir: …`) where a
343
+ normal clone has a directory. Composing either one publishes VCS metadata into
344
+ the output; the gitlink case is worse, because the compiled tree then looks to
345
+ git like a broken submodule pointing at a path that does not exist there. Only
346
+ the exact name `.git` is matched — `.gitignore` and `.gitmodules` are ordinary
347
+ content and compose normally.
348
+
349
+ This is deliberately *not* configurable. A layer that wants to ship VCS-adjacent
350
+ content can name it something else; there is no legitimate case for a compiled
351
+ instance inheriting its template's `.git`.
352
+
353
+ ---
354
+
355
+ ## 5. Patches & three-way merge **[decided]** ✅ *implemented*
356
+
357
+ Patches make this powerful *and* fragile: if a parent file changes, a child's
358
+ line-diff may no longer apply.
359
+
360
+ - **3-way merge with a recorded base.** A patch records the parent text it was
361
+ authored against, so compile can reconstruct the author's intent
362
+ (base → patched) and reconcile it against the drift the parent actually took
363
+ (base → parent-now). Resolves cleanly far more often than a flat apply, and
364
+ produces honest conflicts when it can't.
365
+ - **Structured patches for structured files.** JSON Merge Patch (RFC 7386) for
366
+ simple cases, JSON Patch (RFC 6902) for precise array ops. No line-drift at
367
+ all — prefer these for config.
368
+ - **Fail loud, never silent.** A patch that won't apply stops the build. Compile
369
+ is all-or-nothing: nothing is materialized until every layer has merged, so a
370
+ conflict leaves the destination untouched rather than half-written.
371
+
372
+ ### `base` vs `baseContent` — why a hash is not enough
373
+
374
+ An earlier draft said the `base:` **hash** alone enabled a real 3-way merge. It
375
+ doesn't: a hash can prove the parent drifted, but it cannot reconstruct the
376
+ original text that diff3 needs as its merge base. The two fields split that job:
377
+
378
+ | Field | Carries | Buys you |
379
+ |---|---|---|
380
+ | `base:` | `sha256:…` of the authored-against parent | **drift detection** — cheap, and proves a clean apply when it still matches |
381
+ | `baseContent:` | the parent text itself | **true diff3** once the parent has moved on |
382
+
383
+ Compile picks its path accordingly:
384
+
385
+ 1. **`baseContent` present** → true diff3. When `base` is also recorded it is
386
+ verified against it; a mismatch means the sidecar contradicts itself and is
387
+ rejected rather than trusted.
388
+ 2. **`base` matches the inherited file** → no drift, so the file *is* the base;
389
+ the patch is guaranteed to apply exactly.
390
+ 3. **`base` no longer matches** → drift, with no way to reconstruct the original.
391
+ Compile does *not* blindly apply a patch it knows was authored elsewhere: it
392
+ relocates hunks that merely moved and fails loud on the rest.
393
+ 4. **No `base` at all** (a bare `*.patch`) → best-effort apply, same as (3).
394
+
395
+ Reflux (§8) has the base text in hand when it generates a sidecar, so it records
396
+ both fields and case (1) is the norm for tool-authored patches; hand-authored
397
+ sidecars can record just the hash and still get honest drift detection.
398
+
399
+ ### What "best-effort" actually recovers
400
+
401
+ Worth being precise, since it sets expectations for hand-authored patches:
402
+
403
+ - **Moved hunks** — recovered. The matcher searches for each hunk's location, so
404
+ content added or removed *elsewhere* in the file doesn't break the patch.
405
+ - **Changed context** — rejected. If a line inside the hunk's context window
406
+ changed, there is no safe way to guess, so it becomes a conflict. (A `fuzz`
407
+ setting does not rescue this case; it only tolerates truncated leading/trailing
408
+ context.) This is exactly the case `baseContent` upgrades to a clean merge:
409
+ diff3 sees the two edits are separated by unchanged lines and takes both.
410
+ - **Overlapping edits** — conflict, including edits on *immediately adjacent*
411
+ lines, which diff3 cannot order. Same rule git applies.
412
+
413
+ ### Patch meets tombstone
414
+
415
+ A patch edits an inherited file, so it needs something to inherit — the `super()`
416
+ analogy of §1 holds:
417
+
418
+ - **tombstone above a patch** — the delete wins; the file is gone. (The patch ran
419
+ at a lower layer; a later layer removing the file is a normal override.)
420
+ - **patch above a tombstone** — the patch has nothing to apply to and **fails
421
+ loud**. It is not treated as a file-creating operation, because a patch that
422
+ silently becomes "write this whole file" hides a real authoring mistake.
423
+ - **patch on a file no layer produces** — same failure, same reason.
424
+
425
+ ---
426
+
427
+ ## 6. Template variables **[decided]**
428
+
429
+ Layers declare variables; values are **merged and evaluated first**, then file
430
+ content is rendered with them. Variables compose across the inheritance graph,
431
+ which is what makes treelay's questionnaire fundamentally different from copier's
432
+ per-template, non-shared answers.
433
+
434
+ ### The compile pipeline (where variables sit)
435
+
436
+ Ordering is the whole design. A compile runs:
437
+
438
+ 1. **Resolve graph** (C3) → ordered layers (§3).
439
+ 2. **Merge variable declarations** across the linearized stack → one schema.
440
+ Declarations deep-merge per-key (parents C3 < mixins < self), so a child can
441
+ override just a parent's `default` while keeping its `prompt`/`type`.
442
+ 3. **Resolve values**, lowest → highest precedence:
443
+ declared `default` → answers/values file(s) → interactive prompts → CLI
444
+ `--set k=v` / env overrides.
445
+ 4. **Evaluate computed variables** (`"computed": true`) in topological order;
446
+ cycles are detected and fail loud.
447
+ 5. **Render** each layer's file *names* and *contents* with the final value set.
448
+ 6. **Merge** the rendered layers per-file (§4/§5) — i.e. **render-then-merge**.
449
+ 7. **Drop conditional files** whose rendered name is empty.
450
+ 8. **Materialize** to dest + persist answers and baseline (§7).
451
+
452
+ **Render-then-merge, not merge-then-render** — because a child's `.patch` is
453
+ authored against the parent's *rendered* output, and structured deep-merge needs
454
+ valid JSON/YAML on both sides, not template-y source. "Variables first, then
455
+ content" is exactly this ordering.
456
+
457
+ ### Variable declaration
458
+
459
+ Mirrors copier's proven question set, but composed across layers:
460
+
461
+ ```jsonc
462
+ "serviceName": {
463
+ "type": "string", // string | number | boolean | json | yaml | path
464
+ "prompt": "Service name?", // omit → never prompted (pure default/computed)
465
+ "default": "svc", // templatable: "{{ org }}-svc"
466
+ "choices": ["a", "b"], // optional enum (can be Jinja-dynamic)
467
+ "when": "{{ useDocker }}", // skip the question + value when false
468
+ "validate": "...", // renders to "" if valid, else the error message
469
+ "secret": true, // masked input; excluded from the persisted answers
470
+ "computed": true // derived from other vars; never prompted
471
+ }
472
+ ```
473
+
474
+ ### What gets rendered — suffix opt-in **[decided]**
475
+
476
+ **Only files ending in `templateSuffix` (default `.tmpl`) are rendered**;
477
+ everything else is copied byte-for-byte. `config.json.tmpl` → renders →
478
+ `config.json`. This is copier's hard-won default, and the reason is collisions:
479
+ `{{ }}` is also GitHub Actions (`${{ }}`), Vue, Handlebars, Go templates… a
480
+ render-by-default tool would corrupt those files. Opt-in is the safe baseline; a
481
+ layer can set `"render": "all-text"` if it really wants render-by-default.
482
+
483
+ Suffix order: the template suffix is outermost — strip-and-render first, then the
484
+ inner merge suffix applies (`config.json.patch.tmpl` → render → treat as a
485
+ `.patch` against the inherited `config.json`).
486
+
487
+ ### Engine & trust
488
+
489
+ Engine: **LiquidJS [decided]** — safe by design, very active, no arbitrary code
490
+ execution. Chosen over Nunjucks (most Jinja-familiar but a weaker sandbox) and
491
+ Eta (tiny/fast) because layers arrive as **third-party npm packages**: a template
492
+ that can run arbitrary code is a supply-chain hazard. Rendering runs with
493
+ filesystem/network access disabled — a template can only read the resolved
494
+ variable values, nothing else.
495
+
496
+ ---
497
+
498
+ ## 7. The living template — compile & update **[decided]**
499
+
500
+ This is the headline feature: a compiled project stays linked to its template
501
+ and can absorb template updates *after* it has been created and locally edited.
502
+
503
+ ### Compile (template dir → destination dir)
504
+
505
+ ```
506
+ treelay compile <srcDir> <destDir>
507
+ ```
508
+
509
+ `srcDir` is the leaf overlay (the project template). It resolves parents/mixins
510
+ and writes the flattened result to `destDir`. The destination gets a state dir:
511
+
512
+ ```
513
+ <destDir>/.treelay/
514
+ lock.json # resolved lineage + version refs at last compile
515
+ answers.json # resolved variable values (§6); secrets excluded
516
+ baseline.json # relative path → content hash of every generated file
517
+ baseline/ # the generated content itself = the diff3 merge base
518
+ manifest.json # per file: generated-by-template | user-owned, + producing layer
519
+ ```
520
+
521
+ **Why both a hash index and a content snapshot.** They answer different
522
+ questions. The hash cheaply decides *whether* a file changed, which is all the
523
+ first two merge cases below need. But a hash cannot reconstruct the text diff3
524
+ requires as its merge base, so "both sides changed" — the case the whole feature
525
+ exists for — needs the content itself. (Same lesson as `base` vs `baseContent`
526
+ in §5; a digest detects drift, it never reverses it.) The snapshot is replaced
527
+ wholesale on each write so files the template no longer produces cannot linger
528
+ as stale merge bases, and reads are verified against the recorded hash: a
529
+ snapshot that has fallen out of sync degrades to "no base available", which
530
+ surfaces as a conflict rather than a silently wrong merge.
531
+
532
+ `lock.json`'s lineage doubles as the pointer home — its last entry is the leaf,
533
+ which is how `treelay update <dest>` rediscovers the template it came from
534
+ without being told.
535
+
536
+ First compile = fresh instantiation. The baseline records "this is exactly what
537
+ the template produced *with these answers*," which is what later updates merge
538
+ against. Persisting `answers.json` is what makes re-rendering on update
539
+ deterministic (copier's `.copier-answers.yml`, but one file for the whole
540
+ composition rather than one per template).
541
+
542
+ #### Destinations inside the source tree **[decided]**
543
+
544
+ `destDir` may be **nested inside a layer** — compiling into a gitignored
545
+ `build/` within the source repo is a first-class, supported layout, not an
546
+ accident to be worked around. A destination that is a strict descendant of a
547
+ layer is pruned from that layer's walk, so a recompile never re-consumes its own
548
+ prior output. (The first compile is safe by construction — enumeration precedes
549
+ materialization — but the second would otherwise fold `build/` back in, and
550
+ again on the third, compounding each time.)
551
+
552
+ The degenerate case fails loud rather than silently eating its own sources:
553
+ `destDir` **equal to** a layer root is refused, because materializing over the
554
+ directory being read has no correct interpretation.
555
+
556
+ ### Update (re-pull template changes into an edited project) ✅ *implemented*
557
+
558
+ ```
559
+ treelay update <destDir> [--set k=v] [--on-conflict markers|rej] [--dry-run]
560
+ ```
561
+
562
+ Update first reloads `answers.json`, prompts **only for variables the new
563
+ template version newly introduced** (existing answers are reused, not
564
+ re-asked), then recompiles. Three inputs, per file:
565
+
566
+ - **base** = `.treelay/baseline` (template output at last compile)
567
+ - **ours** = current working copy in `destDir` (user's edits)
568
+ - **theirs**= freshly recompiled template at the new version, same answers
569
+
570
+ Crucially, `theirs` is composed **in memory**. Recompiling onto the destination
571
+ would destroy the very edits the merge exists to preserve.
572
+
573
+ Per-file three-way merge:
574
+
575
+ - base == ours → take theirs (user never touched it; accept update cleanly)
576
+ - base == theirs→ keep ours (template unchanged; preserve user edits)
577
+ - both changed, mergeable → merge (structured merge for JSON/YAML; 3-way text merge otherwise)
578
+ - both changed, conflicting → surface for resolution (see below)
579
+ - file gone in theirs, unchanged in ours → delete it
580
+ - file gone in theirs, edited in ours → conflict (don't silently discard user work)
581
+ - file gone in ours, unchanged in theirs → stay deleted (a deletion is an edit too)
582
+ - file gone in ours, changed in theirs → conflict (don't silently resurrect it)
583
+ - file new in theirs, already present in ours → conflict (the user got there first)
584
+
585
+ **Text first, then structure.** For JSON/YAML the line merge is tried *before*
586
+ the structured one, because it preserves formatting and comments. Only when it
587
+ conflicts do we retry as merge patches, where two sides adding unrelated keys
588
+ compose cleanly however adjacent those keys happen to be — the `package.json`
589
+ case. The cost is reserialization, which is why it is the fallback and not the
590
+ default.
591
+
592
+ **Conflicts are written, not thrown.** Unlike compile (§5), which can safely
593
+ refuse to produce anything, `update` is editing a project that already exists —
594
+ doing nothing leaves the user stuck. So conflicts are reported in the plan and
595
+ materialized one of two ways:
596
+
597
+ - **`markers`** (default) — the merged file carries diff3 markers, *including the
598
+ base section*, so you can see what the template previously produced rather than
599
+ guessing why the two sides disagree.
600
+ - **`rej`** — the working file is left byte-identical and the incoming version
601
+ lands at `<file>.rej`. For projects where a file must stay parseable (a
602
+ committed lockfile, anything a pre-commit hook reads) markers are worse than
603
+ useless.
604
+
605
+ Either way the *whole plan is computed before anything is written*, so a failure
606
+ partway through leaves the project exactly as it was — the same all-or-nothing
607
+ guarantee compile makes, applied to a tree that already has work in it.
608
+
609
+ The baseline is then rewritten to the new template output **unconditionally**,
610
+ including for conflicted files: "what the template last produced" is factually
611
+ `theirs` regardless of how each merge landed. This is what makes a repeated
612
+ update a no-op, and stops a conflict from being re-offered on every future run.
613
+
614
+ ### Generated vs owned files
615
+
616
+ `.treelay/manifest.json` tracks which files the template is responsible for vs.
617
+ files the user added themselves. Update only governs generated files; user-owned
618
+ files are never touched. This is what stops `update` from clobbering the project.
619
+
620
+ ---
621
+
622
+ ## 8. Reflux / promotion — pushing instance edits back up **[decided]**
623
+
624
+ The mirror image of §7. `update` pulls template changes *down* into a project;
625
+ **reflux** pushes a project's local edits *up* into the inheritance graph. The
626
+ OOP analogue is exact: `compile` is instantiation, reflux is **"pull member up"**
627
+ — deciding an edit you made on the instance actually belongs on a superclass.
628
+ Together they make the template↔project link **bidirectional**.
629
+
630
+ treelay is unusually suited to this because per-file **provenance** is already
631
+ first-class (§10): the tool knows which layer produced each file, so it can
632
+ *suggest* where an edit belongs instead of making you pick blind — the
633
+ `git absorb` experience.
634
+
635
+ ### Listing changes
636
+
637
+ Everything keys off `.treelay/baseline` (exactly what the template produced last
638
+ compile). The working copy diverges three ways: **modified** (hunks vs baseline),
639
+ **added** (user-owned, no template origin), **deleted** (tombstone candidate).
640
+
641
+ `treelay status` is `git status` *plus blame* — it annotates each change with the
642
+ layer that currently produces the file, because the useful question is "where
643
+ could this go," not just "what changed":
644
+
645
+ ```
646
+ treelay status <dest>
647
+ M src/config.json ← produced by @acme/node-base (+ patched by with-ci)
648
+ M .eslintrc ← produced by ../shared-eslint
649
+ A src/custom/thing.ts ← local-only (no template origin)
650
+ D README.md ← produced by @acme/service-base
651
+ ```
652
+
653
+ ### Dispositions — the menu per change
654
+
655
+ | Disposition | Meaning | OOP analogue |
656
+ |---|---|---|
657
+ | **Keep local** | project-specific; never promote | instance field |
658
+ | **Extract to new layer** | factor into a brand-new overlay (optionally a mixin) | extract superclass/trait |
659
+ | **Promote into parent/mixin** | push up into a *chosen existing* layer so siblings inherit it | pull member up |
660
+ | **Promote into self** | bake into the leaf template itself | edit the class directly |
661
+
662
+ The interactive flow is `git add -p` where the staging question is *"where does
663
+ this belong?"* rather than yes/no.
664
+
665
+ ### Granularity
666
+
667
+ - **File-level** — promote whole files. Predictable; the v1 target.
668
+ - **Hunk-level** — a single file's edits split across targets (the `git absorb`
669
+ power-move). Real jump in complexity; **v2**. **[open]**
670
+
671
+ ### How a promoted change lands in the target layer
672
+
673
+ Chosen automatically by what the target layer already does with the file:
674
+
675
+ | Situation at target L | Mode | What lands |
676
+ |---|---|---|
677
+ | L already produces the file | **rewrite** | L's *own source file* is rewritten in place |
678
+ | a *lower* layer produces it, L does not | **patch** | a sidecar in L carrying only the delta |
679
+ | no layer produces it (locally added) | **create** | the file, dropped into L |
680
+ | the change is a deletion | **tombstone** | L's source removed if it is the sole producer, else an `op: delete` sidecar |
681
+
682
+ Two details matter more than they look:
683
+
684
+ - **Rewrite reuses the layer's existing source path.** If L produces `config.json`
685
+ from `config.json.tmpl`, the rewrite goes back into the `.tmpl` — writing a
686
+ plain `config.json` beside it would leave the layer producing the same path
687
+ twice. The consequence is the one §8 already documents under
688
+ "reflux meets variables": the promoted file stops being parametric. Round-trip
689
+ verification is what makes that safe to do by default — if baking the rendered
690
+ values in changes the output, the promotion fails rather than quietly
691
+ de-templating the layer.
692
+ - **Patch mode records both `base` and `baseContent` (§5).** Reflux composes the
693
+ sub-stack *below* L to obtain the exact inherited text, so it always has the
694
+ base in hand and never has to emit the weaker hash-only form. Structured files
695
+ get an RFC 7386 merge patch instead of a line diff.
696
+
697
+ ### Why guard 1 refuses so little
698
+
699
+ Guard 1 (shadowing) fires only on a **wholesale** override above the target — a
700
+ higher layer that creates, replaces, or deletes the same path. Partial actions
701
+ (deep-merge, append, prepend, patch) are deliberately *not* treated as shadowing,
702
+ even though they can also stop a promotion from reproducing.
703
+
704
+ The split is about the quality of the answer. A wholesale override is provably
705
+ futile and can be named precisely: *"with-ci overrides this file; promote there
706
+ instead."* Whether a *partial* transform preserves the promoted bytes depends on
707
+ merge order, array policy, and re-rendering — questions a static check can only
708
+ guess at. Rather than guess, those fall to guard 2, which recompiles and simply
709
+ looks. The result is that guard 1 never produces a false refusal, and guard 2
710
+ never lets a bad promotion through; a failure there rolls the layer writes back
711
+ through an undo log, because a half-written layer would be picked up by the very
712
+ next compile.
713
+
714
+ **Layer writability — three tiers, not two:**
715
+
716
+ - **Writable in place** — local paths and monorepo packages. Edited directly.
717
+ - **Writable via a clone** — **git layers** (below). A git ref is a real repo
718
+ with a push target, so reflux *can* land there — it just commits instead of
719
+ rewriting a file on disk.
720
+ - **Truly read-only** — npm-package layers resolved as tarballs in
721
+ `node_modules`. No upstream working tree to commit to; the tool falls back to
722
+ "capture as a patch in a writable layer above it" and says so rather than
723
+ failing silently.
724
+
725
+ ### Committing reflux back to a git layer **[decided]**
726
+
727
+ A git-referenced layer (`github:acme/base#…`) is a first-class promotion target,
728
+ not a read-only one. The mechanics differ from a local path only at the end:
729
+
730
+ - **Mutable clone, not the resolved snapshot.** The snapshot pulled for *compile*
731
+ is detached and unsuitable for writing. Promotion operates on a real working
732
+ clone in a treelay cache (`~/.treelay/git/<repo>/`), shelling out to the user's
733
+ configured `git` so SSH/credential-helper auth is respected — treelay never
734
+ handles tokens itself.
735
+ - **Promote onto a branch, never a pinned ref.** A layer pinned to a tag or SHA
736
+ is immovable by definition, so git promotion *requires* a target branch and
737
+ refuses a detached/tag ref with an explanation. On success the project's own
738
+ reference and `treelay.lock` are rewritten to the new commit — otherwise the
739
+ project stays pinned to the old ref and would never see the change it just
740
+ promoted.
741
+ - **Landing mode is the user's call, per promote.** The interactive flow offers,
742
+ for each git promotion: **commit on a branch** (nothing leaves the machine),
743
+ **commit + push** (branch only, never the pinned/default ref), or **commit +
744
+ open a PR** (via `gh`/`glab` when available). Non-interactive runs
745
+ (`--no-prompt`) default to the most conservative — local commit on a branch —
746
+ and require an explicit flag to push or PR.
747
+ - **Round-trip verification still gates the commit (§8 guard 2).** After
748
+ committing, re-resolve the layer at the new commit, recompile, and assert
749
+ byte-identity. If it doesn't reproduce, the commit is rolled back
750
+ (`git reset --hard`) — never left dangling.
751
+ - **Blast radius is maximal (§8 guard 3).** A pushed git layer reaches *every*
752
+ consumer everywhere, and a push is far harder to walk back than editing a
753
+ sibling directory. The warning is louder, and push/PR is always a deliberate,
754
+ separately-confirmed step.
755
+
756
+ ### The three guards (where reflux earns its keep)
757
+
758
+ 1. **Precedence shadowing.** If you promote to `parent X` but `mixin Y` overrides
759
+ the same file higher up, the change vanishes on recompile. The tool detects
760
+ this from the resolved stack and refuses: *"Promoting to node-base has no
761
+ effect; with-ci overrides this file. Promote to with-ci or self instead."*
762
+ 2. **Round-trip verification.** After any promote/extract, **recompile and assert
763
+ the working copy is byte-identical.** If the change doesn't reproduce (merge
764
+ order interactions), fail loud. This is what makes reflux trustworthy.
765
+ 3. **Blast radius.** Promoting up reaches *every sibling* inheriting that layer —
766
+ the intent, but a footgun. Warn: *"node-base is consumed by 6 projects; this
767
+ edit reaches all of them on their next update."*
768
+
769
+ After a verified promote, `.treelay/baseline` is rewritten so the change counts
770
+ as "from template" and drops off the local-changes list — it now flows down by
771
+ inheritance instead of being a local override.
772
+
773
+ ### Reflux meets variables (the hard interaction) **[open]**
774
+
775
+ The working copy lives in **rendered** space; layers live in **template** space.
776
+ Promoting a rendered edit up therefore has a representation problem: the literal
777
+ text `port: 3000` in the project may have come from `port: {{ port }}` in a
778
+ layer. Default behavior: **store the promoted content literally** (rendered
779
+ values baked in) — correct and predictable, but the promoted file stops being
780
+ parametric in that layer. Optional **assisted re-templatization** can substitute
781
+ known variable *values* back to `{{ var }}` references, gated behind explicit
782
+ review (fragile when a value is a short/common string like `"1"` or `"true"`).
783
+ Round-trip verification (§8 guard 2) still applies — it re-renders the target
784
+ layer with the persisted answers and asserts byte-identity. Granularity of the
785
+ re-templatization assist is **[open]**.
786
+
787
+ ---
788
+
789
+ ## 9. CLI surface
790
+
791
+ ```
792
+ treelay compile <src> <dest> [--set k=v] [--answers f] [--no-prompt]
793
+ # materialize template → destination (first run = instantiate)
794
+ treelay update <dest> [--set k=v] # re-render with saved answers (prompt only new vars) + 3-way merge
795
+ treelay status <dest> [--json] # list changes vs baseline, annotated with producing layer
796
+ treelay diff <dest|a> [b] # working-vs-baseline hunks, or layer-vs-layer
797
+ treelay promote <dest> [files...] [--to <layer>] [--dry-run] [--no-verify]
798
+ # push edits up; auto-suggests --to from provenance
799
+ # git targets: [--branch <name>] [--push] [--pr]
800
+ # (commit-on-branch only unless --push/--pr given)
801
+ treelay extract <dest> [files...] --as <path> [--mixin] [--name <n>]
802
+ # capture edits as a NEW overlay layer
803
+ treelay lock [dir] [--check] [--update] [--drift]
804
+ # resolve every layer ref and pin it in treelay.lock
805
+ # --check verify it is complete + current (CI), write nothing
806
+ # --update advance moving refs to their current revision
807
+ # --drift report refs whose upstream has moved (network)
808
+ treelay plan [dir] # print linearized layer order + per-file resolution; write nothing
809
+ treelay explain <dir> [file] [--set k=v] [--answers f] [--json]
810
+ # trace which layers touched a file, in order, with patches
811
+ # <dir> = a source layer, or a compiled destination
812
+ # omit [file] to explain every path in the composition
813
+ treelay validate [dir] # cycles? patches apply? unresolved conflicts? drift vs lock?
814
+ treelay watch <src> <dest> # recompile on change
815
+ treelay eject <dest> # flatten + drop .treelay state (sever the template link)
816
+ ```
817
+
818
+ `promote` and `extract` always end with the §8 round-trip recompile-and-verify,
819
+ and both refuse read-only or shadowed targets with a clear explanation.
820
+
821
+ `compile`, `update` and `plan` all accept `--frozen-lockfile` (§3).
822
+
823
+ `plan` and `explain` are not nice-to-haves — they are the debugging story for a
824
+ system whose entire job is "this file came from somewhere non-obvious." Build
825
+ `plan` before `compile`.
826
+
827
+ ---
828
+
829
+ ## 10. Programmatic API
830
+
831
+ The CLI is a thin shell over a library (people will want this in build tools):
832
+
833
+ ```ts
834
+ const graph = await resolve(srcDir, { frozen, updateRefs, noLock, cacheDir });
835
+ // linearized layers + provenance, no output I/O
836
+ // graph.variables = merged declaration schema across all layers (§6)
837
+ // graph.lock / lockDirty / lockDir = pins resolved in memory (§3). Resolution
838
+ // never writes: `explain` must not have a lockfile as a side effect, so the
839
+ // caller that owns the source tree persists it.
840
+ writeLock(graph.lockDir, graph.lock); // what `treelay lock` does
841
+ const drift = checkDrift(graph); // [{ ref, requested, locked, current, status }]
842
+
843
+ const values = await resolveValues(graph, { answers, set, prompt }); // §6 steps 3–4
844
+ const result = await compile(graph, { destDir, values });
845
+ // result.files[path] = { fromLayer, strategy, patchedFrom, owned } ← powers `explain`
846
+
847
+ const why = await explain(graph, { values }); // no output I/O; read-only provenance
848
+ // why.layers = [{ id, name, role: parent|mixin|self, position, writable }]
849
+ // why.files[p] = { contributions[], present, winner, strategy, patchedFrom }
850
+ // winner/strategy/patchedFrom mirror result.files[p] exactly (asserted by test)
851
+ const dest = await explainDest(destDir); // re-explains from lockfile lineage + saved answers
852
+
853
+ const plan = await planUpdate(destDir); // dry-run 3-way, returns clean/conflict per file
854
+ await update(destDir, { onConflict: "markers" }); // reuses saved answers, prompts only new vars
855
+
856
+ const changes = await status(destDir); // per file: kind (M/A/D) + producing layer + targets
857
+ // changes[i].targets already excludes layers a higher layer would shadow
858
+
859
+ const promoted = await promote(destDir, changes, { to: layerRef, verify: true });
860
+ // throws on a read-only or shadowed target, or a failed round-trip (writes rolled back)
861
+ // promoted.landed[i] = { path, mode: rewrite|patch|create|tombstone, wrote }
862
+ // promoted.blastRadius = { dependents[], destinations[] } ← §8 guard 3
863
+
864
+ const created = await extract(destDir, changes, { as: path, asMixin: true });
865
+ // created.wired === false ⇒ not in the graph yet, so nothing was verified or rebaselined
866
+
867
+ // The guards are reusable on their own:
868
+ const check = await roundTripVerify(destDir, graph, values); // { ok, mismatches, composed }
869
+ const reach = blastRadius(layerDir, { searchRoot }); // who else consumes this layer
870
+ ```
871
+
872
+ ---
873
+
874
+ ## 11. Open decisions
875
+
876
+ - **Array merge default** — `replace` proposed; revisit if config use cases want `by-key`. **[open]**
877
+ - **What's tracked** — contents always; modes + symlinks proposed yes; empty dirs only via `.keep`. **[open]**
878
+ - **Conflict UX** — **[decided]**, split by direction. For **compile**: fail the
879
+ build, write nothing (§5) — a template that can't compose has no partial output
880
+ worth keeping. For **update**: write the conflict, since refusing to act on a
881
+ project that already exists just leaves the user stuck. Both presentations ship
882
+ because neither dominates — inline `markers` (diff3 style, base section shown)
883
+ by default, `rej` sidecars when a file has to stay parseable (§7). An
884
+ interactive resolver is deferred; it composes on top of either mode rather
885
+ than replacing them.
886
+ - **Reflux granularity** — file-level for v1; hunk-level splitting + auto-`absorb` routing deferred to v2. **[open]**
887
+ - **Template engine** — **LiquidJS [decided]** (safe, sandboxed; over Nunjucks/Eta) — see §6.
888
+ - **Reflux re-templatization** — store promoted edits literally vs assisted value→`{{ var }}` substitution (§8). **[open]**
889
+ - **Git layer write-back** — git layers are writable via a working clone; reflux commits onto a branch (never a pinned ref), landing mode (commit / push / PR) chosen per promote, lockfile + project ref advanced on success. **[decided]** (§8)
890
+ - **Template variables** — interpolate values, merged across the graph, suffix opt-in rendering. **[decided]** (§6)
891
+ - **Layer refs & pinning** — three origins by shape, `?path=` subdirs, mounts at the
892
+ bottom of the stack, deterministic `treelay.lock`, drift reported not followed.
893
+ **[decided]** (§2, §3)
894
+ - **npm pins are recorded, not enforced** — installation stays the package
895
+ manager's job; git pins *are* enforced from treelay's own cache. **[decided]** (§3)
896
+ - **Reflux into mounted layers** — a mount's output paths carry a prefix its
897
+ sources do not, so promotion into one needs a path mapping that does not exist
898
+ yet; read-only for now. **[open]**
899
+ - **Virtual/FUSE mode** — deferred; materialize-first is **[decided]**. Revisit later for dev loops.
900
+
901
+ ---
902
+
903
+ ## 12. MVP build order
904
+
905
+ De-risk by building resolution first, then output, then the bidirectional loops:
906
+
907
+ 1. ✅ Manifest parsing + C3 resolution + `plan` ← the risky core, visible early
908
+ 2. ✅ `replace` / `deep-merge` / tombstone strategies (+ append/prepend, sidecar `merge`)
909
+ 3. ✅ `compile` to a destination (fresh instantiation + `.treelay` state)
910
+ 4. ✅ Variable schema merge + value resolution + suffix-opt-in rendering (§6)
911
+ 5. ✅ Unified-diff patches with 3-way merge (`.patch` suffix, sidecar `op: patch`, `patch` merge glob)
912
+ 6. ✅ `update` — the living-template three-way loop, reusing saved answers (pulls *down*)
913
+ 7. ✅ `explain` — per-file provenance (source layers or a compiled destination; `--json`)
914
+ 8. ✅ `status` + file-level `promote` / `extract` — reflux (pushes changes *up*)
915
+ 9. ✅ npm/git layer resolution + `mounts` + `treelay.lock` (pins, drift, `--frozen-lockfile`)
916
+ 10. `watch` / `eject` ← next
917
+
918
+ Demoable and trustworthy after step 3; templated scaffolding works at step 4;
919
+ the headline pull-down lands at step 6, and the bidirectional link closes at
920
+ step 8.