Delivery Trees Declare Owner, Reproduction and Witness — the Site's Publication Pipeline Is Governed, Not Remembered
Accepted
Context
Four incidents over two sessions (2026-07-10..12) on the outreach/site publication pipeline. They read as four unrelated bugs and are one missing declaration.
1. THE MIRROR THAT REVERTED ITS OWN WORK. `content_processor` writes the content indexes to site/r, but the `content` recipe ended with `rsync -a site/public/r/ -> site/r/`. public/r had stopped being the tool's output root, so the rsync copied a fossil tree over the fresh one: the tool printed "Generated index.json with 69 posts" while the disk kept 58. Worse, public/r is the tree the deploy pack ships — so no new content could reach production at all. The ADR-066 rule was violated in its purest form: the reporter said done, nothing checked, and no one noticed for months.
2. 214 LINES IN A TREE THAT IS rm -rf'd. The template assembler has two layers (framework defaults, then source-project templates from website-htmx-rustelo) while the level chain has three (rustelo -> website-htmx-rustelo -> outreach/site). The site level had NO overlay slot, so template work owned by this level had nowhere legitimate to live and ended up in htmx-templates/ — the one tree `just templates` deletes and rebuilds. The nav submenus were lost that way once already; at the time of writing, 214 lines (nav submenus, subscribe CTA, post engagement block) were one command from annihilation, with no copy in git, in the deploy snapshots, or anywhere else.
3. ELEVEN ADRs THAT NEVER REACHED THE SITE. `gen-adr-pages.nu` existed and worked, but no recipe called it. ADR-059..069 were written, accepted, and invisible on ontoref.dev. A generator outside the dependency chain is a generator that does not run.
4. A CONTRACT INSIDE THE DATA TREE. `gen-content-graph` nickel-exports every .ncl under site/content, and expedientes/_schema/expediente.ncl was a CONTRACT, not an instance — the export demanded `id` and the recipe aborted. `just graph` had been dead since expedientes landed. The generator was right; the file was in the wrong tree.
The forensics behind these: git was not a witness (outreach/.git tracked ONE file, README.md) and every content pack stamped `git_sha=5619a40` regardless of what it shipped — a signature with no provenance behind it. Source content survived (0 files lost, verified against both deploy snapshots), but nothing in the pipeline could have PROVEN that; the recovery was archaeology, not verification.
Each incident is a tree whose consumers assume it holds only something else, and in every case what was missing is the same three facts: who owns this tree, what command reproduces it, and what witnesses it.
Decision
Every tree in the outreach/site delivery pipeline declares three properties, and the pipeline is gated on them.
OWNER — exactly one writer. A tree is either AUTHORED (a human writes it; it is tracked in git) or GENERATED (one named recipe writes it). No tree has two owners. Where two trees exist for one concern (site/r serves, site/public/r ships), each FILE has one owner, and the mirror between them is typed by ownership, never by direction: content indexes flow r -> public/r (with --delete, or a renamed slug survives as a live URL), and the four nu artifacts (about, adr-map, taglines, content_graph) flow public/r -> r, excluded from the outbound leg so a stale copy cannot overwrite a fresh original. A whole-tree one-way sync between two trees that own different files is always wrong in one of the two directions.
REPRODUCTION — a named recipe, reachable from `sync-full`. A projection generator that must be remembered and invoked by hand is not part of the build; it is a private ritual, and it stops tracking its source the day the human forgets. Every generator is a link in the chain, and the chain's ORDER is load-bearing: emitters before `content`, readers of the indexes after it, the serve-tree artifact copy last.
WITNESS — reproducible, or tracked. A tree may be gitignored ONLY if a tracked source plus a recipe reproduce it. A generated tree that holds the only copy of any work is tracked until that stops being true. This is the rule that governs the .gitignore, and it is why site/public/images (196 of 209 images exist nowhere else), site/content/{adr,catalog,domains} (projections whose generators were unwired or broken) and htmx-templates/ (until site/_htmx/ existed) are tracked despite looking generated. Ignoring a tree is a claim that it is reproducible; making that claim falsely deletes work in silence.
Consequently, the site level gets the overlay slot the assembler never gave it: site/_htmx/ is the declared source for work owned by THIS level — templates in site/_htmx/templates/, htmx-runtime overrides in site/_htmx/assets/ — and the `_` prefix says what site/_public already says here: this is the tree you edit, its unprefixed twin is the tree that gets generated. Anything edited straight into the assembled output is, by definition, unwitnessed.
And the rule is ONE rule, so it governs both delivery trees and both are assembled by ONE script (scripts/build/assemble-layers.nu): a file present in a higher layer wins, a file absent from it is inherited from the layer below — the exact cascade the implementation already applies to the framework. htmx-assets/ had only the framework layer, which meant an ext/* override shipped by website-htmx-rustelo could never reach the site; the asymmetry was invisible precisely because nothing had tried to use it.
THE GATES DECIDE, AND THEY RUN BEFORE THE DAMAGE. Three exist and pass today: `adr-check` (the spine holds no ADR the site never published), `templates-check` (the assembled tree is exactly reproducible from its declared sources — any drift names the file that exists nowhere else), `posts-check` (linkify + graph coverage). Each asserts against the DISK, never against a tool's stdout — ADR-066's rule applied to the surface that publishes ontoref, the last place still trusting a reporter. And each is runnable while the damage is still hypothetical: a check you can only run after the destructive step is not a gate, it is an autopsy.
The deploy gate — `just authoring publish` refusing to ship when a gate fails — is declared Soft, not Hard, because its enforcement point lives in another repo (website-htmx-rustelo) and does not exist yet. Declaring it Hard with no executable check would be precisely the reporter-believes-itself pattern this ADR forbids.
Constraints
- Hard Every projection generator that feeds a site content-kind MUST be invoked by a recipe reachable from `sync-full`; a generator runnable only by hand is drift by construction.
- Hard The site/r <-> site/public/r mirror MUST be typed by file ownership, never by direction: content indexes flow r -> public/r with --delete, and the four nu artifacts flow public/r -> r and are excluded from the outbound leg.
- Hard Work owned by the site level in either delivery tree MUST live in its layer under site/_htmx/ — templates in site/_htmx/templates/, htmx-runtime overrides in site/_htmx/assets/. htmx-templates/ and htmx-assets/ are assembled output and MUST NOT be hand-edited: `just templates` and `just htmx-assets` delete them on every run.
- Hard `just templates-check` AND `just assets-check` MUST pass: each assembled delivery tree reproduces exactly from its declared layers, so no file in either exists nowhere else.
- Hard `just adr-check` MUST pass: every ADR in .ontoref/adrs has a page in the site's adr content-kind.
- Hard A tree MUST NOT be gitignored unless a tracked source plus a named recipe reproduce it; a generated tree holding the only copy of any work stays tracked until that ceases to be true.
- Hard site/content/ MUST contain only content instances; schemas and contracts live outside it (site/schemas/), because every consumer of that tree nickel-exports each .ncl as data.
- Soft The deploy SHOULD refuse to ship when a declared gate fails — the check decides, never the operator's memory. Enforcement point: the publish path in website-htmx-rustelo.
Alternatives considered
- Fix the four bugs, add no ADR — rejected: This is the status quo that produced them. The first two incidents (the drift and the relapse) were already fixed once, individually, and the pathology recurred one layer down each time. Four instances of one shape is not a run of bad luck; it is an undeclared invariant asking to be named.
- Extend ADR-066 to cover the publication pipeline — rejected: ADR-066 governs the mode-DAG executor: run start, guards, verify exit codes, postchecks, witness signatures. The publication pipeline is not a mode run — it is a justfile, three binaries and a deploy pack. The RULE is shared (the check decides) and is cited as such; the substrate, the trees and the constraint class (owner/reproduction/witness) are new. Folding them into ADR-066 would blur an accepted, falsación-proven decision with an unproven one.
- Generalize to every constellation delivery surface (code, outreach, desktop) — rejected: One realized instance. ADR-063 was explicitly gated from approach to substrate on a SECOND instance exercising the mechanism end-to-end, and ADR-066 discharged that gate before promoting. Generalizing from the site alone would assert what has not been exercised — the same unearned confidence this ADR exists to prevent. Scoped to outreach/site; the second instance may promote it.
- Make the deploy gate Hard now — rejected: Its check cannot run: the enforcement point is in another repo. A Hard constraint with no executable check passes the validator while verifying nothing — a reporter believing itself, wearing a severity field. Soft reports the direction without the lie.
- Give the site its own git repo so its delivery is self-contained — rejected: ADR-062, projection-not-own-repo: the repo driver is the publication boundary, never conceptual separability, and site/web share outreach's public boundary — 'different deploy target is not different visibility boundary'. The delivery problem is solved by declaring the trees, not by drawing a new git boundary around them.
Anti-patterns
- A Whole-Tree One-Way Sync Between Two Trees That Own Different Files — Two trees exist for one concern (a serve tree and a deploy tree) and a single rsync runs between them in one direction. Because each tree is the rightful owner of DIFFERENT files, the sync is necessarily wrong for one set: it either starves the destination or resurrects fossils over fresh output. Here it did both — fresh indexes were clobbered by a stale deploy tree, and no content could ever reach production.
- Hand-Editing an Assembled Output Because the Level Has No Source Slot — A build assembles a tree from N layers, but the level doing the work is not one of them. Its edits therefore land in the assembled output — the one tree the build rm -rf's — and are destroyed on the next run. The author is not careless; there is no correct place to put the work.
- A Projection Generator No Recipe Calls — A generator exists, works, is documented, and is invoked by hand exactly once. Its source then evolves and the projection silently stops tracking it. The artifact looks maintained because it was maintained — once. Eleven accepted ADRs stayed invisible on the site this way.
- A Schema Filed Among the Data It Types — A contract (.ncl with undefined required fields) is placed inside a tree whose consumers treat every file as an instance and export it as data. The export demands the undefined field and the whole build step dies — not because the generator is wrong, but because the file is in a tree that promises to hold only instances.
- A Check That Can Only Run After the Destructive Step — The verification exists but its only possible moment is after the irreversible operation. It can tell you what you lost; it cannot stop you losing it. This reads as rigor and delivers none — and it is the trap a team falls into precisely when it starts taking verification seriously.