The article was a projection; the informe was not. Same cases, two hands, and
they drifted: deriva's ledger carried 6 rows in the .md and 8 in the viewer,
recaida had no typed source at all, and espejo never made it into the informe —
so its own article linked to an anchor that did not exist. The English tree was
two hand-written .md nobody regenerated and a third case that was simply absent.
Three fixes, one shape:
- The <pre> was killing the render. `<section class="exp">` opens a CommonMark
HTML block of type 6, closed by the first blank line — and weapon.code has
blank lines. Joined with "\n" the whole body was ONE block that died inside
the snippet, so every `#` comment re-parsed as an ATX heading: 23 spurious
<h1> in espejo, `</pre>` swallowed by the last one. Chunks are now separated
by blank lines, so <pre> opens a type-1 block that only closes on `</pre>`.
The editorial prose stops being buried in raw HTML and renders as Markdown.
- The skin becomes a dimension of the contract. The informe carries prose per
case AND per skin; the old Expediente held one flattened skin, so the viewer
could never be projected from it. `skins.<skin>` now holds what the genre owns
(loglines, alibis, headings, stamps); the numbers, the code, the technical
names of the suspects and the closing stay neutral. A datum that changed with
the skin would be fiction, not genre.
- The locale becomes a dimension of the build. Both trees project from .ncl,
the renderer's vocabulary lives in scripts/ (not among the instances it types
— `contract-in-the-instance-tree`), and expedientes-parity fails when a case
exists in one locale and not the other.
Gates: expedientes-check · informe-check · expedientes-parity, all in the chain.
A projection nobody re-derives is not a projection, it is a copy you trust.
The howto called the CASE object the single source of truth. It was not. The rendered .md
was hand-written — 356 of its 500 lines were the shared glossary widget pasted in verbatim,
and it carried editorial prose (the balance, the closing) that the contract never modelled.
A generator run against the old contract would have DELETED that prose: the exact
work-in-the-generated-tree shape adr-070 names.
So the contract was extended first (balance / balance_outro / closing, additive — every
existing case validates unchanged), the prose was rescued into it, and only then was
gen-expediente.nu written. The glossary widget is now injected from its single source
instead of retyped. `just expedientes-check` asserts every .md body reproduces from its .ncl:
edit the source, never the render.
The generator dropped the hero and the "show full expediente" CTA on its first run — half an
hour after the ADR that forbids exactly that. It was caught only because the output was
diffed against the previous render instead of trusted because it printed a green tick. Both
are emitted from data now, and the check is why we know.
New case, clinical skin: 69/58 — "the mirror that reverted its own work". The tool reported
"index.json with 69 posts" while the disk held 58; eleven ADRs (059..069) were written and
never published; zero content reached production for four weeks; 214 lines of template work
sat one command from deletion with no witness anywhere. Every number in its ledger traces to
a log or an artifact, per the mode's own rule — none is recalled.
Its verdict is the one the series exists to ask and rarely gets: "with ontoref, would it not
have gone like this?" — and here the answer is NO. It happened INSIDE ontoref, with ADR-066
("the check decides, never the reporter") accepted a week earlier and proven by an eight-path
falsación run. The protocol did not fail. Difusión was the blind spot: the one surface the
protocol did not govern. Every mechanism that would have caught every one of these failures
already existed and was already accepted in this very repo. None of them was pointed here.
Also: adr-070's pages (the ADR projection now runs in the build chain).
Two failures, one shape: something living in a tree whose consumers assume it holds
only something else.
adr-pages: gen-adr-pages.nu existed but no recipe called it, so the ADR projection
silently stopped tracking the spine — ADR-059..069 were written months ago and never
reached the site. A generator outside the dependency chain is a generator that does
not run. Wired into sync-full ahead of `content` (it emits the markdown that content
indexes), and it is idempotent: this commit carries no ADR diff because regenerating
reproduces the tree byte for byte.
graph: gen-content-graph nickel-exports every .ncl under site/content, and
expedientes/_schema/expediente.ncl was a CONTRACT, not an instance — export demanded
`id` and the recipe aborted. `just graph` had been dead since expedientes landed. The
generator was right and the file was in the wrong tree: site/content/ holds instances,
so a schema there is a category error. Moved to site/schemas/expediente.ncl; the one
sidecar that imports it still type-checks against the contract. graph now builds
(mini:154 related:222 ontology:15).
sync-full ordering: graph / adr-map / about-json ran AFTER content, but the leg that
copies the four nu artifacts into the serve tree lived INSIDE content — so a full sync
left site/r rendering the previous run's About and ADR map. Added sync-artifacts as the
final link. Both trees now agree on all four.
Gates
-----
adr-check spine vs site: fails if the spine holds an ADR the site never published.
Verified by deleting adr-069 — the gate caught it (69 vs 68, exit 1) and
sync-full rebuilt it unattended.
This is the third gate (with templates-check and posts-check) and, like the others, it
runs before the damage, not after. None of them run on deploy yet: `just authoring
publish` still ships without asking. That is what keeps these gates a habit rather
than a law — and is the subject of the governed-delivery ADR still to be written.
Refs: ADR-066 (the check decides, never the reporter).
Versión corta, si la prefieres:
site: wire the ADR projection into the build chain; move a contract out of the data tree
gen-adr-pages existed but no recipe called it — eleven ADRs never reached the site. Now in
sync-full, with an adr-check gate that fails when the spine holds an ADR the site doesn't.
`just graph` had been dead since expedientes landed: a schema sat inside site/content/, which
gen-content-graph nickel-exports as if everything there were an instance. Moved to
site/schemas/. Also fixed sync-full ordering so the serve tree stops rendering the previous
run's About and ADR map.
First real commit of site/. Until now outreach/.git tracked exactly one file
(README.md) and every content pack stamped git_sha=5619a40 regardless of what it
shipped — a signature with no provenance behind it. The site had no witness, which
is why a deploy could erase two sessions of work with nothing to diff against and
nothing to restore from.
Tracked here (authored): site/content, site/config, site/i18n, site/public/images,
justfile, scripts/, .just/, run*.sh, rustelo.manifest.toml.
Ignored (reproducible, or secret):
.k, .env real keys — outreach mirrors to a PUBLIC remote
site/r, site/public/r content indexes, rebuilt by `just content`
provisioning/.content-packs 228 MB of deploy tarballs
cache, data, logs_*.out runtime droppings
Three trees are deliberately NOT ignored despite looking generated, because their
declared source cannot currently reproduce them — ignoring them would delete the
only copy:
site/public/images 196 of 209 images exist nowhere else (assets/site is empty)
site/content/{adr,catalog} projections whose generators are unwired (gen-adr-pages)
site/content/domains or failing (`just graph`)
They become ignorable when an audit proves regeneration is a no-op, not before.
Fixes carried in this import
---------------------------
content: the mirror ran one way and reverted its own work. content_processor writes
site/r, but the recipe ended with `rsync -a site/public/r/ -> site/r/`, so each fresh
index was clobbered by the stale deploy-tree copy: the tool reported "index.json with
69 posts" while the disk kept 58. Meanwhile public/r is the tree the deploy pack ships,
so no new content could ever reach production. The two trees own different files, so
the mirror now runs both ways: content indexes 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) back public/r -> r, excluded from the outbound leg so stale
copies cannot overwrite the fresh originals.
templates: the assembler has two layers (framework defaults, source-project templates)
but the level chain has three (rustelo -> website-htmx-rustelo -> outreach/site). The
site level had no overlay slot, so its template work had nowhere legitimate to live and
ended up in htmx-templates/ — the one tree `just templates` rm -rf's. That is how the
nav submenus were lost. site/templates-overlay/ is the missing third slot; 214 lines
(nav submenus, subscribe CTA, post engagement block) now live in a declared source and
survive regeneration byte-identical.
adr: projected ADR-059..069 into the content tree. They were written months ago and
never reached the site because gen-adr-pages.nu is wired into no recipe — a generator
outside the dependency chain is a generator that does not run.
Gates added
-----------
templates-check reassembles into a temp tree and asserts an empty diff against the
live one: any drift means htmx-templates/ holds work that exists
live one: any drift means htmx-templates/ holds work that exists
nowhere else and the next run deletes it.
Both gates run before the destructive operation, not after. A check you can only run
afterwards is not a gate, it is an autopsy.
Refs: ADR-062 (projection-not-own-repo: site is a Projection of outreach, not its own
repo — different deploy target is not a different visibility boundary), ADR-048
(materialization), ADR-066 (the check decides, never the reporter — which the content
pipeline, the surface that publishes ontoref, was the last place not to honour).