expedientes: the render becomes a projection; publish case 69/58
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).
This commit is contained in:
parent
56d3c10d9e
commit
fbb6f99f44
20 changed files with 4705 additions and 223 deletions
|
|
@ -113,6 +113,15 @@ diagram:
|
|||
# Project .ontoref/adrs → adr content-kind markdown (site/content/adr/en/{accepted,proposed}).
|
||||
[no-cd]
|
||||
adr-pages:
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
# The status dirs are cleared first because status is part of the PATH
|
||||
# (accepted/adr-070.md vs proposed/adr-070.md): accepting an ADR moves its page,
|
||||
# and the generator only writes — it never removes the page at the old status. Without
|
||||
# this, `ontoref adr accept` leaves the ADR published twice and adr-check fails on the
|
||||
# count. The wipe is safe precisely because this projection is fully reproducible.
|
||||
rm -f "{{ project_root }}"/site/content/adr/en/accepted/adr-[0-9]*.{md,ncl} \
|
||||
"{{ project_root }}"/site/content/adr/en/proposed/adr-[0-9]*.{md,ncl}
|
||||
ONTOREF_NICKEL_IMPORT_PATH="{{ project_root }}/../../code/ontology" \
|
||||
nu "{{ project_root }}/scripts/build/gen-adr-pages.nu" \
|
||||
--root "{{ project_root }}/../.." \
|
||||
|
|
@ -135,6 +144,56 @@ adr-check:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# The howto calls the CASE object the single source of truth; until now it was not —
|
||||
# the .md body was hand-rendered, and 356 of its 500 lines were the shared glossary
|
||||
# widget pasted in verbatim. The body is now a projection of the .ncl; the frontmatter
|
||||
# (title, excerpt, image, sort_order) stays authored. Edit the .ncl, never the body.
|
||||
# Project the typed expediente CASEs (.ncl) → the body of their .md.
|
||||
[no-cd]
|
||||
expedientes:
|
||||
nu "{{ project_root }}/scripts/build/gen-expediente.nu" --root "{{ project_root }}"
|
||||
|
||||
# Fails when a .md body differs from what its .ncl produces — i.e. when someone edited
|
||||
# the render instead of the source, which is how the prose the contract did not model
|
||||
# came to live only in the generated file.
|
||||
# Gate: assert every expediente .md body is reproducible from its typed CASE.
|
||||
[no-cd]
|
||||
expedientes-check:
|
||||
nu "{{ project_root }}/scripts/build/gen-expediente.nu" --root "{{ project_root }}" --check
|
||||
|
||||
# The standalone informe (site/public/images/expedientes/<lang>/expedientes.html) was
|
||||
# hand-kept HTML with its cases inlined as a JS array, while the ARTICLES were already
|
||||
# projected from the same .ncl. Two hands on one dataset drift, and they did: deriva's
|
||||
# ledger carried 6 rows in the article and 8 in the viewer, and `espejo` was never added
|
||||
# at all — so its own article linked to an anchor that did not exist.
|
||||
# Project the typed CASEs → the standalone informe, one per locale.
|
||||
[no-cd]
|
||||
informe:
|
||||
nu "{{ project_root }}/scripts/build/gen-informe.nu" --root "{{ project_root }}"
|
||||
|
||||
# Gate: assert every informe is reproducible from its typed CASEs.
|
||||
[no-cd]
|
||||
informe-check:
|
||||
nu "{{ project_root }}/scripts/build/gen-informe.nu" --root "{{ project_root }}" --check
|
||||
|
||||
# Gate: every case must exist in every locale. The English tree carried two cases by hand
|
||||
# and never got the third — an absence nothing could report, because nothing compared the
|
||||
# two trees. A locale that is deliberately behind should say so, not simply be missing.
|
||||
[no-cd]
|
||||
expedientes-parity:
|
||||
#!/usr/bin/env nu
|
||||
let base = "{{ project_root }}/site/content/expedientes"
|
||||
let langs = (ls $base | where type == dir | get name | each {|d| $d | path basename } | where {|d| not ($d | str starts-with "_") })
|
||||
let by_lang = ($langs | each {|l| { lang: $l, ids: (glob $"($base)/($l)/*/*.ncl" | each {|f| $f | path basename | str replace ".ncl" "" } | sort) } })
|
||||
let all = ($by_lang | get ids | flatten | uniq | sort)
|
||||
let gaps = ($by_lang | each {|e| { lang: $e.lang, missing: ($all | where {|id| $id not-in $e.ids }) } } | where {|e| ($e.missing | length) > 0 })
|
||||
if ($gaps | is-empty) {
|
||||
print $"expedientes-parity: ($all | length) case\(s\) present in all ($langs | length) locales"
|
||||
} else {
|
||||
for g in $gaps { print $"expedientes-parity: ($g.lang) is missing ($g.missing | str join ', ')" }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Makes /catalog (browse) + /catalog/{slug} serve as SSR content (runtime kind, no
|
||||
# rebuild — registered in site/content/content-kinds.toml).
|
||||
# Project .ontoref/catalog → catalog markdown, then build the content index.
|
||||
|
|
@ -311,7 +370,7 @@ sync: templates htmx-assets rotator-assets content
|
|||
# Requires ONTOREF_NICKEL_IMPORT_PATH in .env.
|
||||
# Full resync: projections → content indexes → derived artifacts → serve tree.
|
||||
[no-cd]
|
||||
sync-full: templates htmx-assets rotator-assets adr-pages catalog-content content graph adr-map about-json sync-artifacts
|
||||
sync-full: templates htmx-assets rotator-assets adr-pages expedientes informe catalog-content content graph adr-map about-json sync-artifacts
|
||||
|
||||
# The nu generators own these four files in site/public/r (the deploy tree); the serve
|
||||
# tree needs the same copies. `content` does this too, so a standalone `just content`
|
||||
|
|
|
|||
184
site/scripts/build/expediente-vocab.nu
Normal file
184
site/scripts/build/expediente-vocab.nu
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Locale × skin vocabulary for the expediente renderers.
|
||||
#
|
||||
# What repeats identically across every case is NOT case data. The kicker, the section
|
||||
# headings, "Exhibit A" vs "Hallazgo A", "CASO CERRADO" vs "ALTA MÉDICA", the informe's
|
||||
# chrome — all of it is the renderer speaking, not the case. It lives here, once, and
|
||||
# both generators (gen-expediente.nu → the article, gen-informe.nu → the viewer) import it.
|
||||
#
|
||||
# It lives in scripts/, NOT in site/content/expedientes/: the content tree is the INSTANCE
|
||||
# tree, and a renderer's vocabulary filed among the instances is the same category error
|
||||
# as a schema filed among them (`contract-in-the-instance-tree`, the anti-pattern that
|
||||
# killed `just graph`).
|
||||
#
|
||||
# A case may override `kicker` per skin — see SkinProse in site/schemas/expediente.ncl.
|
||||
|
||||
export const VOCAB = {
|
||||
es: {
|
||||
ui: {
|
||||
skin_detective: "🕵️ Detective",
|
||||
skin_clinical: "🩺 Clínico",
|
||||
theme: "Cambiar tema",
|
||||
back: "Volver a expedientes ▸",
|
||||
back_href: "/expedientes",
|
||||
switch_label: "Expedientes",
|
||||
counter: "Expediente", # "Expediente 2/3"
|
||||
prev_label: "Expediente anterior",
|
||||
next_label: "Expediente siguiente",
|
||||
pager_prev: "‹ Anterior",
|
||||
pager_next: "Siguiente ›",
|
||||
print: "🖨 Versión imprimible",
|
||||
gloss_title: "Nota del archivo · antes de leer",
|
||||
page_title: "Expedientes · con ontoref no hubiera sido así",
|
||||
doc_title: "Expediente {case_no} · con ontoref no hubiera sido así",
|
||||
skin_missing: "Este expediente no tiene esta piel",
|
||||
},
|
||||
md: {
|
||||
protocol: "Protocolo para declarar, versionar y verificar esto → ",
|
||||
status_label: "Estado",
|
||||
cost_title: "Lo que costó el crimen",
|
||||
yielded_title: "Lo que el caso dejó",
|
||||
},
|
||||
skins: {
|
||||
detective: {
|
||||
md: {
|
||||
kicker: "Expediente · Depto. de Homicidios de Código",
|
||||
hero_title: "Abrir el expediente completo",
|
||||
cta: "🕵️ Mostrar expediente completo →",
|
||||
gloss: "Glosario",
|
||||
balance: "El doble balance — lo que costó, y lo que dejó",
|
||||
abandon: "El punto de abandono — lo que no sale en la tabla",
|
||||
suspects: "Los sospechosos — las pistas falsas",
|
||||
weapon: "El arma",
|
||||
fix: "El giro",
|
||||
verdict: "El veredicto",
|
||||
}
|
||||
informe: {
|
||||
kicker: "Expediente · Depto. de Homicidios de Código",
|
||||
exhibit_prefix: ["Exhibit A — " "Exhibit B — " "Exhibit C — " "Exhibit D — " ""],
|
||||
suspect_noun: "Sospechoso",
|
||||
stamp_lead: "CASO CERRADO",
|
||||
meta_case: "Caso Nº",
|
||||
meta_class: "Clasificación",
|
||||
status_word: "RESUELTO",
|
||||
foot_left: "Depto. de Homicidios de Código · Exp.",
|
||||
}
|
||||
}
|
||||
clinical: {
|
||||
md: {
|
||||
kicker: "Historia clínica · Servicio de Patología del Código",
|
||||
hero_title: "Abrir la historia clínica completa",
|
||||
cta: "🩺 Mostrar historia clínica →",
|
||||
gloss: "Cuadro",
|
||||
balance: "El doble balance — lo que costó, y lo que dejó",
|
||||
abandon: "El punto de abandono — lo que no sale en la tabla",
|
||||
suspects: "Diagnóstico diferencial — lo que se descartó",
|
||||
weapon: "Etiología — la causa",
|
||||
fix: "Tratamiento",
|
||||
verdict: "Pronóstico",
|
||||
}
|
||||
informe: {
|
||||
kicker: "Historia clínica · Servicio de Patología del Código",
|
||||
exhibit_prefix: ["Hallazgo A — " "Hallazgo B — " "Hallazgo C — " "Hallazgo D — " ""],
|
||||
suspect_noun: "Diagnóstico",
|
||||
stamp_lead: "ALTA MÉDICA",
|
||||
meta_case: "Historia Nº",
|
||||
meta_class: "Diagnóstico",
|
||||
status_word: "ALTA",
|
||||
foot_left: "Servicio de Patología del Código · Hist.",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
en: {
|
||||
ui: {
|
||||
skin_detective: "🕵️ Detective",
|
||||
skin_clinical: "🩺 Clinical",
|
||||
theme: "Toggle theme",
|
||||
back: "Back to case files ▸",
|
||||
back_href: "/cases",
|
||||
switch_label: "Case files",
|
||||
counter: "Case file",
|
||||
prev_label: "Previous case file",
|
||||
next_label: "Next case file",
|
||||
pager_prev: "‹ Previous",
|
||||
pager_next: "Next ›",
|
||||
print: "🖨 Printable version",
|
||||
gloss_title: "Archive note · before you read",
|
||||
page_title: "Case files · with ontoref it wouldn't have gone like this",
|
||||
doc_title: "Case {case_no} · with ontoref it wouldn't have gone like this",
|
||||
skin_missing: "This case file has no such skin",
|
||||
},
|
||||
md: {
|
||||
protocol: "The protocol to declare, version and verify this → ",
|
||||
status_label: "Status",
|
||||
cost_title: "What the crime cost",
|
||||
yielded_title: "What the case left",
|
||||
},
|
||||
skins: {
|
||||
detective: {
|
||||
md: {
|
||||
kicker: "Case file · Code Homicide Dept.",
|
||||
hero_title: "Open the full case file",
|
||||
cta: "🕵️ Show the full case file →",
|
||||
gloss: "Glossary",
|
||||
balance: "The double ledger — what it cost, and what it left",
|
||||
abandon: "The point of abandonment — what the table doesn't show",
|
||||
suspects: "The suspects — the false leads",
|
||||
weapon: "The weapon",
|
||||
fix: "The turn",
|
||||
verdict: "The verdict",
|
||||
}
|
||||
informe: {
|
||||
kicker: "Case file · Code Homicide Dept.",
|
||||
exhibit_prefix: ["Exhibit A — " "Exhibit B — " "Exhibit C — " "Exhibit D — " ""],
|
||||
suspect_noun: "Suspect",
|
||||
stamp_lead: "CASE CLOSED",
|
||||
meta_case: "Case No.",
|
||||
meta_class: "Classification",
|
||||
status_word: "SOLVED",
|
||||
foot_left: "Code Homicide Dept. · File",
|
||||
}
|
||||
}
|
||||
clinical: {
|
||||
md: {
|
||||
kicker: "Clinical history · Code Pathology Service",
|
||||
hero_title: "Open the full clinical history",
|
||||
cta: "🩺 Show the full clinical history →",
|
||||
gloss: "Clinical picture",
|
||||
balance: "The double ledger — what it cost, and what it left",
|
||||
abandon: "The point of abandonment — what the table doesn't show",
|
||||
suspects: "Differential diagnosis — what was ruled out",
|
||||
weapon: "Etiology — the cause",
|
||||
fix: "Treatment",
|
||||
verdict: "Prognosis",
|
||||
}
|
||||
informe: {
|
||||
kicker: "Clinical history · Code Pathology Service",
|
||||
exhibit_prefix: ["Finding A — " "Finding B — " "Finding C — " "Finding D — " ""],
|
||||
suspect_noun: "Diagnosis",
|
||||
stamp_lead: "DISCHARGED",
|
||||
meta_case: "History No.",
|
||||
meta_class: "Diagnosis",
|
||||
status_word: "DISCHARGED",
|
||||
foot_left: "Code Pathology Service · Hist.",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The content dir is `casos` in Spanish and `cases` in English — the URL is localised, so
|
||||
# the directory is too. One place to know it.
|
||||
export const CASE_DIR = { es: "casos", en: "cases" }
|
||||
|
||||
export def vocab [lang: string]: nothing -> record {
|
||||
let v = ($VOCAB | get -o $lang)
|
||||
if $v == null { error make { msg: $"no expediente vocabulary for locale '($lang)' — add it to scripts/build/expediente-vocab.nu" } }
|
||||
$v
|
||||
}
|
||||
|
||||
# The skin a case declares may be absent from the vocabulary only by typo: the contract
|
||||
# already restricts it to 'detective | 'clinical.
|
||||
export def skin-vocab [lang: string, skin: string, surface: string]: nothing -> record {
|
||||
vocab $lang | get skins | get $skin | get $surface
|
||||
}
|
||||
222
site/scripts/build/gen-expediente.nu
Normal file
222
site/scripts/build/gen-expediente.nu
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env nu
|
||||
|
||||
# Project a typed expediente CASE (.ncl) into the BODY of its article .md.
|
||||
#
|
||||
# The .md frontmatter (title, excerpt, image, sort_order) stays AUTHORED. Everything
|
||||
# below it is a projection: edit the .ncl, never the body. `just expedientes-check` fails
|
||||
# if you edit the body — that is how the prose the old contract did not model came to
|
||||
# live only in the generated file (adr-070, work-in-the-generated-tree).
|
||||
#
|
||||
# The sibling generator gen-informe.nu projects the SAME cases into the standalone viewer
|
||||
# at site/public/images/expedientes/<lang>/expedientes.html. One source, two surfaces.
|
||||
#
|
||||
# The case is written in ONE skin (`skin`); the informe offers both. This renderer reads
|
||||
# skins.<skin> and ignores the rest.
|
||||
#
|
||||
# Usage:
|
||||
# nu scripts/build/gen-expediente.nu # all cases, all locales
|
||||
# nu scripts/build/gen-expediente.nu --case <id> # one case
|
||||
# nu scripts/build/gen-expediente.nu --check # assert reproducible, write nothing
|
||||
|
||||
use ./expediente-vocab.nu [VOCAB]
|
||||
|
||||
const CSS = '<style>
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-ledger h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-ledger.cost h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-ledger.yield h4{background:rgba(20,120,60,.12)}
|
||||
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>'
|
||||
|
||||
def esc []: string -> string {
|
||||
$in | str replace --all '&' '&' | str replace --all '<' '<' | str replace --all '>' '>'
|
||||
}
|
||||
|
||||
# Blank-line-separated paragraphs stay blank-line-separated: they are Markdown, and the
|
||||
# body's chunks are joined by blank lines precisely so this prose renders AS Markdown.
|
||||
def paras [txt: string]: nothing -> string {
|
||||
$txt | split row "\n\n" | each {|p| $p | str trim } | where {|p| $p != "" } | str join "\n\n"
|
||||
}
|
||||
|
||||
def render-ledger [title: string, cls: string, rows: list]: nothing -> string {
|
||||
let items = ($rows | each {|r| $" <li>($r.label) <b>($r.value)</b></li>" } | str join "\n")
|
||||
$" <div class=\"exp-ledger ($cls)\">\n <h4>($title)</h4>\n <ul>\n($items)\n </ul>\n </div>"
|
||||
}
|
||||
|
||||
def render-case [c: record, lang: string, glossary_js: string, hero: record]: nothing -> string {
|
||||
let skin = ($c.skin | into string | str replace "'" "")
|
||||
let v = ($VOCAB | get $lang)
|
||||
let sk = ($c.skins | get -o $skin)
|
||||
if $sk == null {
|
||||
error make { msg: $"($c.id): declares skin '($skin)' but skins.($skin) is missing" }
|
||||
}
|
||||
# The line-up is three index-parallel arrays across two records. The contract cannot say
|
||||
# so; the check can, and a check that runs is worth more than a type that does not exist.
|
||||
let n = ($c.suspects | length)
|
||||
if ($sk.alibis | length) != $n or ($sk.suspect_tags | length) != $n {
|
||||
error make { msg: $"($c.id)/($skin): ($n) suspects but ($sk.alibis | length) alibis and ($sk.suspect_tags | length) tags — they are index-parallel" }
|
||||
}
|
||||
let w = ($v.skins | get $skin | get md)
|
||||
|
||||
mut out = ["<section class=\"exp\">" $CSS]
|
||||
|
||||
# Hero + CTA to the standalone informe. NOT decoration: the first run of this generator
|
||||
# dropped them and only the check caught it — a regeneration that silently loses an
|
||||
# element is the very drift this case is about.
|
||||
if ($hero.image | is-not-empty) {
|
||||
$out = ($out | append $"<a class=\"exp-hero\" href=\"($hero.href)\" target=\"_blank\" rel=\"noopener\" title=\"($w.hero_title)\"><img src=\"($hero.image)\" alt=\"($c.title)\" width=\"1200\" height=\"675\" loading=\"lazy\"></a>")
|
||||
$out = ($out | append $"<p style=\"margin:0 0 1.4rem\"><a class=\"exp-btn\" href=\"($hero.href)\" target=\"_blank\" rel=\"noopener\">($w.cta)</a></p>")
|
||||
}
|
||||
|
||||
# The article's opening breath may be longer than the informe's hook (recaida), and the
|
||||
# ficha's labels are the GENRE's, not the locale's: a clinical case files a Historia Nº,
|
||||
# not a Caso Nº. The old renderer hardcoded the detective labels onto every skin.
|
||||
let iv = ($v.skins | get $skin | get informe)
|
||||
let log = (if ($sk.logline_article | is-empty) { $sk.logline } else { $sk.logline_article })
|
||||
|
||||
$out = ($out | append $"<p class=\"exp-kick\">($sk.kicker? | default $w.kicker)</p>")
|
||||
$out = ($out | append $"<p class=\"exp-log\">($log)</p>")
|
||||
$out = ($out | append $"<div class=\"exp-meta\"><span>($iv.meta_case) <b>($c.case_no)</b></span><span>($iv.meta_class): ($c.classification)</span><span>($v.md.status_label): ($c.status)</span></div>")
|
||||
|
||||
if ($sk.anamnesis? | default "" | is-not-empty) {
|
||||
$out = ($out | append $"<div class=\"exp-anam\">\n(paras $sk.anamnesis)\n</div>")
|
||||
}
|
||||
|
||||
if ($c.glossary | is-not-empty) {
|
||||
let dl = ($c.glossary | each {|t| $" <dt>($t.term)</dt><dd>($t.def)</dd>" } | str join "\n")
|
||||
$out = ($out | append $"<div class=\"exp-gloss\">\n <dl>\n($dl)\n </dl>\n <p style=\"font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0\">($v.md.protocol)<a href=\"($c.ontoref_url)\" target=\"_blank\" rel=\"noopener\">ontoref.dev</a></p>\n</div>")
|
||||
}
|
||||
|
||||
# Doble balance: prose → ledgers → prose. The ledgers are data; the prose is editorial.
|
||||
$out = ($out | append $"<p class=\"exp-ex\">($w.balance)</p>")
|
||||
if ($sk.balance? | default "" | is-not-empty) { $out = ($out | append (paras $sk.balance)) }
|
||||
let ct = (if ($c.cost_title | is-empty) { $v.md.cost_title } else { $c.cost_title })
|
||||
let yt = (if ($c.yielded_title | is-empty) { $v.md.yielded_title } else { $c.yielded_title })
|
||||
$out = ($out | append $"<div class=\"exp-balance\">\n(render-ledger $ct 'cost' $c.cost)\n(render-ledger $yt 'yield' $c.yielded)\n</div>")
|
||||
if ($sk.balance_outro? | default "" | is-not-empty) { $out = ($out | append (paras $sk.balance_outro)) }
|
||||
|
||||
if ($sk.abandonment? | default "" | is-not-empty) {
|
||||
$out = ($out | append $"<p class=\"exp-ex\">($w.abandon)</p>")
|
||||
$out = ($out | append (paras $sk.abandonment))
|
||||
}
|
||||
|
||||
if ($c.suspects | is-not-empty) {
|
||||
$out = ($out | append $"<p class=\"exp-ex\">($w.suspects)</p>")
|
||||
let rows = (0..<$n | each {|i|
|
||||
let s = ($c.suspects | get $i)
|
||||
$"<tr><td><b>($s.name)</b></td><td><em>“($sk.alibis | get $i)”</em></td><td>($sk.suspect_tags | get $i)</td></tr>"
|
||||
} | str join "\n")
|
||||
$out = ($out | append $"<table class=\"exp-susp\"><tbody>\n($rows)\n</tbody></table>")
|
||||
}
|
||||
|
||||
for blk in [
|
||||
{ h: $w.weapon, cap: $sk.weapon_caption, code: $c.weapon.code, note: ($sk.weapon_note? | default "") }
|
||||
{ h: $w.fix, cap: $sk.fix_caption, code: $c.fix.code, note: ($sk.fix_note? | default "") }
|
||||
] {
|
||||
$out = ($out | append $"<p class=\"exp-ex\">($blk.h) — ($blk.cap)</p>")
|
||||
# A BLANK LINE either side of the <pre> is load-bearing. The site renders CommonMark:
|
||||
# `<section class="exp">` opens an HTML block of type 6, closed by the first blank line
|
||||
# — and weapon.code HAS blank lines. Glued to its neighbours the whole body was ONE
|
||||
# type-6 block that died inside the <pre>, so every `#` comment in the snippet re-parsed
|
||||
# as an ATX heading (23 spurious <h1> in espejo, `</pre>` swallowed by the last). As its
|
||||
# own chunk, <pre> opens a type-1 block: it closes only on `</pre>` and blank lines
|
||||
# cannot touch it. Chunks are joined with "\n\n" at the bottom of this function.
|
||||
$out = ($out | append $"<pre>($blk.code | str trim | esc)</pre>")
|
||||
if ($blk.note | is-not-empty) { $out = ($out | append (paras $blk.note)) }
|
||||
}
|
||||
|
||||
$out = ($out | append $"<p class=\"exp-ex\">($w.verdict)</p>")
|
||||
$out = ($out | append (paras $sk.verdict_prose))
|
||||
let rows = ($c.containment | each {|r| $"<tr><td>($r.facet)</td><td>($r.mechanism)</td></tr>" } | str join "\n")
|
||||
$out = ($out | append $"<table class=\"exp-cont\"><tbody>\n($rows)\n</tbody></table>")
|
||||
|
||||
if ($sk.closing? | default "" | is-not-empty) {
|
||||
if ($sk.closing_heading? | default "" | is-not-empty) {
|
||||
$out = ($out | append $"<p class=\"exp-ex\">($sk.closing_heading)</p>")
|
||||
}
|
||||
$out = ($out | append (paras $sk.closing))
|
||||
}
|
||||
|
||||
# The page glossary widget is INJECTED from its single source (_glossary.html). It used
|
||||
# to be pasted by hand: 356 of the .md's 500 lines were that copy.
|
||||
$out = ($out | append $glossary_js)
|
||||
$out = ($out | append "</section>")
|
||||
|
||||
$out | str join "\n\n"
|
||||
}
|
||||
|
||||
def main [
|
||||
--root: string = ".", # site root (the dir containing site/)
|
||||
--case: string = "", # one case id; empty = all
|
||||
--check, # assert reproducible; write nothing
|
||||
] {
|
||||
let base = $"($root)/site/content/expedientes"
|
||||
let gl_path = $"($base)/_glossary.html"
|
||||
let glossary_js = (if ($gl_path | path exists) { open --raw $gl_path | str trim } else { "" })
|
||||
|
||||
let cases = (
|
||||
glob $"($base)/*/*/*.ncl"
|
||||
| where {|f| $case == "" or ($f | path basename | str replace ".ncl" "") == $case }
|
||||
| sort
|
||||
)
|
||||
if ($cases | is-empty) { error make { msg: $"no expediente .ncl found under ($base)" } }
|
||||
|
||||
mut drift = []
|
||||
for f in $cases {
|
||||
let c = (^nickel export $f | from json)
|
||||
let md_path = ($f | str replace ".ncl" ".md")
|
||||
if not ($md_path | path exists) {
|
||||
error make { msg: $"($md_path) does not exist — the .md carries the frontmatter; create it before generating the body" }
|
||||
}
|
||||
# The frontmatter is authored (title, excerpt, image, sort_order); only the BODY is
|
||||
# projected. Splitting on the closing --- keeps authorship where it belongs.
|
||||
let raw = (open --raw $md_path)
|
||||
let parts = ($raw | split row --regex '(?m)^---\s*$' )
|
||||
let front = $"---($parts.1)---"
|
||||
let img = ($parts.1 | lines | where {|l| $l | str starts-with "image_url:" } | get -o 0 | default "" | str replace --regex '^image_url:\s*"?' '' | str replace --regex '"\s*$' '')
|
||||
let lang = ($f | path dirname | path dirname | path basename)
|
||||
let hero = { image: $img, href: $"/images/expedientes/($lang)/expedientes.html#($c.id)" }
|
||||
let body = (render-case $c $lang $glossary_js $hero)
|
||||
let new = $"($front)\n\n($body)\n"
|
||||
|
||||
if $check {
|
||||
if $raw != $new { $drift = ($drift | append $"($lang)/($md_path | path basename)") }
|
||||
} else {
|
||||
$new | save --force $md_path
|
||||
print $"expediente → ($lang)/($md_path | path basename) [($c.skin | into string)]"
|
||||
}
|
||||
}
|
||||
|
||||
if $check {
|
||||
if ($drift | is-empty) {
|
||||
print $"expedientes-check: ($cases | length) case\(s\) reproduce from their .ncl — no hand-edited render"
|
||||
} else {
|
||||
print $"expedientes-check: DRIFT — these .md differ from what their .ncl produces: ($drift | str join ', ')"
|
||||
print " the render is a projection; edit the .ncl, never the .md body."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
164
site/scripts/build/gen-informe.nu
Normal file
164
site/scripts/build/gen-informe.nu
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env nu
|
||||
|
||||
# Project the typed expediente CASEs (.ncl) → the standalone informe, one per locale:
|
||||
# site/public/images/expedientes/<lang>/expedientes.html
|
||||
#
|
||||
# The informe was hand-kept HTML with its data inlined as a JS array, while the ARTICLES
|
||||
# were already projected from the .ncl. Same cases, two hands — so they drifted: deriva's
|
||||
# ledger carried 6 rows in the article and 8 here, and `espejo` never made it in at all,
|
||||
# which left its own article linking to an anchor that did not exist. One source now; the
|
||||
# viewer is a projection like everything else, and `just informe-check` says so.
|
||||
#
|
||||
# The template (scripts/build/informe-template.html) owns the CSS, the chrome and the
|
||||
# render() function. This script owns only the data: it composes each CASE with the
|
||||
# locale × skin vocabulary into the shape render() already reads.
|
||||
#
|
||||
# Usage:
|
||||
# nu scripts/build/gen-informe.nu # every locale
|
||||
# nu scripts/build/gen-informe.nu --check # assert reproducible, write nothing
|
||||
|
||||
use ./expediente-vocab.nu [VOCAB]
|
||||
|
||||
def esc []: string -> string {
|
||||
$in | str replace --all '&' '&' | str replace --all '<' '<' | str replace --all '>' '>'
|
||||
}
|
||||
|
||||
def ledger [rows: list]: nothing -> list {
|
||||
$rows | each {|r| [$r.label, $r.value, (if $r.emphasis { 1 } else { 0 })] }
|
||||
}
|
||||
|
||||
# The renderer wraps nothing in quotes; the old hand-kept data carried the curly quotes
|
||||
# inside each alibi. Add them here so the .ncl stores the words, not the typography.
|
||||
def quoted [xs: list]: nothing -> list {
|
||||
$xs | each {|a| $"“($a)”" }
|
||||
}
|
||||
|
||||
def skin-data [c: record, sk: record, lang: string, skin: string]: nothing -> record {
|
||||
let v = ($VOCAB | get $lang)
|
||||
let iv = ($v.skins | get $skin | get informe)
|
||||
{
|
||||
kicker: ($sk.kicker? | default $iv.kicker)
|
||||
h1: [$sk.h1_lead, $sk.h1_red]
|
||||
logline: $sk.logline
|
||||
meta: [$iv.meta_case, $iv.meta_class, $sk.victim, $"($v.md.status_label): ($iv.status_word)"]
|
||||
# The exhibit label is half vocabulary ("Exhibit A — ") and half case ("El cuerpo").
|
||||
ex: ($sk.exhibits | enumerate | each {|e| $"($iv.exhibit_prefix | get $e.index)($e.item)" })
|
||||
h2: $sk.headings
|
||||
lead2: $sk.lead_cost
|
||||
lead3: $sk.lead_suspects
|
||||
weaponCap: $sk.weapon_caption
|
||||
weaponAfter: $sk.weapon_after
|
||||
fixCap: $sk.fix_caption
|
||||
fixAfter: $sk.fix_after
|
||||
verdictLead: $sk.verdict_lead
|
||||
no: $iv.suspect_noun
|
||||
stamp: [$iv.stamp_lead, $sk.stamp]
|
||||
alibis: (quoted $sk.alibis)
|
||||
tags: $sk.suspect_tags
|
||||
footL: $"($iv.foot_left) ($c.case_no)"
|
||||
footR: $sk.foot_right
|
||||
}
|
||||
}
|
||||
|
||||
def case-data [c: record, lang: string]: nothing -> record {
|
||||
let v = ($VOCAB | get $lang)
|
||||
let n = ($c.suspects | length)
|
||||
for skin in ($c.skins | columns) {
|
||||
let sk = ($c.skins | get $skin)
|
||||
if ($sk.alibis | length) != $n or ($sk.suspect_tags | length) != $n {
|
||||
error make { msg: $"($c.id)/($skin): ($n) suspects but ($sk.alibis | length) alibis and ($sk.suspect_tags | length) tags — they are index-parallel" }
|
||||
}
|
||||
}
|
||||
{
|
||||
id: $c.id
|
||||
caseNo: $c.case_no
|
||||
classification: $c.classification
|
||||
tab: $c.tab
|
||||
printHref: $c.print_href
|
||||
costCaption: $c.cost_caption
|
||||
# The glossary is set in richer type here than in the article; same definition.
|
||||
glossary: ($c.glossary | each {|t| { t: $t.term, d: (if ($t.def_html | is-empty) { $t.def } else { $t.def_html }) } })
|
||||
glossaryMore: $"($v.md.protocol)<a href='($c.ontoref_url)' target='_blank' rel='noopener'>ontoref.dev</a>"
|
||||
cost: (ledger $c.cost)
|
||||
yielded: (ledger $c.yielded)
|
||||
yieldedTitle: (if ($c.yielded_title | is-empty) { $v.md.yielded_title } else { $c.yielded_title })
|
||||
suspectsTech: ($c.suspects | each {|s| $s.name })
|
||||
# A snippet with no highlighted variant is shown escaped rather than not shown.
|
||||
weaponCode: (if ($c.weapon.code_html | is-empty) { $c.weapon.code | str trim | esc } else { $c.weapon.code_html | str trim })
|
||||
fixCode: (if ($c.fix.code_html | is-empty) { $c.fix.code | str trim | esc } else { $c.fix.code_html | str trim })
|
||||
contain: ($c.containment | each {|r| [$r.facet, (if ($r.mechanism_html | is-empty) { $r.mechanism } else { $r.mechanism_html })] })
|
||||
verdictClose: $c.verdict_close
|
||||
skin: ($c.skin | into string | str replace "'" "")
|
||||
skins: ($c.skins | columns | reduce --fold {} {|skin, acc|
|
||||
$acc | insert $skin (skin-data $c ($c.skins | get $skin) $lang $skin)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
# The reading order is the one the articles already declare in their frontmatter. A second
|
||||
# ordering rule would be a second source of truth.
|
||||
def sort-order [ncl: path]: nothing -> int {
|
||||
let md = ($ncl | str replace ".ncl" ".md")
|
||||
if not ($md | path exists) { return 999 }
|
||||
open --raw $md
|
||||
| lines
|
||||
| where {|l| $l | str starts-with "sort_order:" }
|
||||
| get -o 0
|
||||
| default "sort_order: 999"
|
||||
| str replace --regex '^sort_order:\s*' ''
|
||||
| str trim
|
||||
| into int
|
||||
}
|
||||
|
||||
def main [
|
||||
--root: string = ".",
|
||||
--check,
|
||||
] {
|
||||
let base = $"($root)/site/content/expedientes"
|
||||
let tpl = (open --raw $"($root)/scripts/build/informe-template.html")
|
||||
|
||||
let langs = (ls $base | where type == dir | get name | each {|d| $d | path basename } | where {|d| not ($d | str starts-with "_") } | sort)
|
||||
if ($langs | is-empty) { error make { msg: $"no locale dirs under ($base)" } }
|
||||
|
||||
mut drift = []
|
||||
for lang in $langs {
|
||||
let ncls = (glob $"($base)/($lang)/*/*.ncl")
|
||||
if ($ncls | is-empty) { continue }
|
||||
|
||||
let cases = (
|
||||
$ncls
|
||||
| each {|f| { order: (sort-order $f), c: (^nickel export $f | from json) } }
|
||||
| sort-by order
|
||||
| each {|r| case-data $r.c $lang }
|
||||
)
|
||||
let v = ($VOCAB | get $lang)
|
||||
let data = $" var UI = ($v.ui | to json -r);\n\n var CASES = ($cases | to json -r);"
|
||||
|
||||
let html = (
|
||||
$tpl
|
||||
| str replace --all "{{LANG}}" $lang
|
||||
| str replace --all "{{TITLE}}" $v.ui.page_title
|
||||
| str replace "{{DATA}}" $data
|
||||
)
|
||||
|
||||
let out = $"($root)/site/public/images/expedientes/($lang)/expedientes.html"
|
||||
if $check {
|
||||
let cur = (if ($out | path exists) { open --raw $out } else { "" })
|
||||
if $cur != $html { $drift = ($drift | append $"($lang)/expedientes.html") }
|
||||
} else {
|
||||
mkdir ($out | path dirname)
|
||||
$html | save --force $out
|
||||
print $"informe → ($lang)/expedientes.html [($cases | length) casos]"
|
||||
}
|
||||
}
|
||||
|
||||
if $check {
|
||||
if ($drift | is-empty) {
|
||||
print $"informe-check: ($langs | length) locale\(s\) reproduce from their .ncl — no hand-edited viewer"
|
||||
} else {
|
||||
print $"informe-check: DRIFT — ($drift | str join ', ') differ from what the .ncl produce."
|
||||
print " the viewer is a projection; edit the .ncl, never the HTML."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
317
site/scripts/build/informe-template.html
Normal file
317
site/scripts/build/informe-template.html
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<!doctype html>
|
||||
<html lang="{{LANG}}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
/* ── PALETAS por skin × tema ─────────────────────────────────────────── */
|
||||
:root, :root[data-skin="detective"]{
|
||||
--paper:#F6F1E4; --paper-edge:#E4DAC2; --ink:#1C1810; --ink-faded:#5A5240;
|
||||
--desk:#33302A; --stamp:#9C2D25; --accent:#8A6014; --rule:#CDBF9C; --code:#26231D;
|
||||
}
|
||||
:root[data-skin="detective"][data-theme="dark"]{
|
||||
--paper:#D9CBA6; --paper-edge:#C7B78E; --ink:#231D14; --ink-faded:#574F3B;
|
||||
--desk:#100E0A; --stamp:#CF4133; --accent:#C89338; --rule:#B6A67E; --code:#0A0805;
|
||||
}
|
||||
:root[data-skin="clinical"]{
|
||||
--paper:#F5F9FA; --paper-edge:#DBE7EA; --ink:#132630; --ink-faded:#51666E;
|
||||
--desk:#17282E; --stamp:#C0272B; --accent:#0C7A83; --rule:#D2E0E4; --code:#0C1A20;
|
||||
}
|
||||
:root[data-skin="clinical"][data-theme="dark"]{
|
||||
--paper:#D7E4E7; --paper-edge:#BFD2D6; --ink:#0F2028; --ink-faded:#4A5F67;
|
||||
--desk:#081217; --stamp:#E24A44; --accent:#2FA7B0; --rule:#A9C2C8; --code:#050D11;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0; min-height:100vh;
|
||||
background:radial-gradient(120% 80% at 82% -12%, color-mix(in srgb,var(--accent) 20%,transparent), transparent 60%), var(--desk);
|
||||
color:var(--ink); font-family:Georgia,"Iowan Old Style","Times New Roman",serif; line-height:1.6;
|
||||
padding:0 1rem 4rem; display:flex; flex-direction:column; align-items:center;
|
||||
--mono:"Courier New",ui-monospace,"Cascadia Mono",monospace}
|
||||
|
||||
/* ── Barra de navegación del sitio (logo + volver) ───────────────────── */
|
||||
.sitebar{width:100%; max-width:52rem; display:flex; align-items:center; justify-content:space-between;
|
||||
gap:1rem; padding:.9rem .2rem; margin-bottom:.4rem}
|
||||
.sitebar a{color:var(--paper); text-decoration:none; font-family:var(--mono); font-size:.76rem; letter-spacing:.04em}
|
||||
.sitebar .brand{display:flex; align-items:center; gap:.55rem; font-weight:700}
|
||||
.sitebar .brand img{height:24px; width:auto; display:block}
|
||||
.sitebar .back{border:1px solid color-mix(in srgb,var(--paper) 45%,transparent); padding:.34rem .7rem; border-radius:.4rem}
|
||||
.sitebar .back:hover{border-color:var(--paper); background:color-mix(in srgb,var(--paper) 12%,transparent)}
|
||||
|
||||
/* ── Conmutador de expedientes ───────────────────────────────────────── */
|
||||
.switch{width:100%; max-width:52rem; display:flex; align-items:center; gap:.6rem; flex-wrap:wrap;
|
||||
padding:.55rem .8rem; margin-bottom:.9rem; border-radius:.5rem; font-family:var(--mono);
|
||||
background:color-mix(in srgb,var(--paper) 12%,transparent); color:var(--paper)}
|
||||
.switch .idx{font-size:.72rem; letter-spacing:.1em; opacity:.85; white-space:nowrap}
|
||||
.switch .tabs{display:flex; gap:.4rem; flex:1; flex-wrap:wrap}
|
||||
.switch .tab{font-size:.72rem; letter-spacing:.03em; background:transparent; color:var(--paper);
|
||||
border:1px solid color-mix(in srgb,var(--paper) 40%,transparent); padding:.3rem .6rem; border-radius:.35rem; cursor:pointer}
|
||||
.switch .tab[aria-current="true"]{background:var(--paper); color:var(--desk); font-weight:700; border-color:var(--paper)}
|
||||
.switch .nav{display:flex; gap:.35rem}
|
||||
.switch .nav button{font-size:.72rem; background:transparent; color:var(--paper);
|
||||
border:1px solid color-mix(in srgb,var(--paper) 40%,transparent); padding:.3rem .55rem; border-radius:.35rem; cursor:pointer}
|
||||
.switch .nav button:disabled{opacity:.35; cursor:default}
|
||||
|
||||
.file{width:100%; max-width:52rem; background:var(--paper); border:1px solid var(--paper-edge);
|
||||
box-shadow:0 1px 0 color-mix(in srgb,#fff 30%,transparent) inset, 0 24px 60px -20px rgba(0,0,0,.6);
|
||||
padding:clamp(1.25rem,4.5vw,3rem); position:relative; overflow:hidden;
|
||||
background-image:repeating-linear-gradient(0deg, transparent 0 27px, color-mix(in srgb,var(--rule) 20%,transparent) 27px 28px);
|
||||
background-attachment:local}
|
||||
.file::before{content:""; position:absolute; left:15px; top:0; bottom:0; width:14px;
|
||||
background:radial-gradient(circle at 50% 60px,var(--desk) 5px,transparent 6px),
|
||||
radial-gradient(circle at 50% 160px,var(--desk) 5px,transparent 6px),
|
||||
radial-gradient(circle at 50% 260px,var(--desk) 5px,transparent 6px);
|
||||
background-repeat:no-repeat; opacity:.45}
|
||||
|
||||
.mono{font-family:var(--mono)} a{color:var(--stamp)}
|
||||
header{border-bottom:2px solid var(--ink); padding-bottom:1rem; margin-bottom:1.7rem}
|
||||
.kicker{font-family:var(--mono); font-weight:700; font-size:.74rem; letter-spacing:.18em; text-transform:uppercase; color:var(--ink-faded)}
|
||||
h1{font-family:var(--mono); font-weight:700; font-size:clamp(1.55rem,5.4vw,2.6rem); line-height:1.05;
|
||||
margin:.4rem 0 .2rem; text-wrap:balance; letter-spacing:-.01em}
|
||||
h1 .red{color:var(--stamp)}
|
||||
.logline{font-style:italic; max-width:44ch; margin:.6rem 0 0}
|
||||
.meta{display:flex; flex-wrap:wrap; gap:.4rem .5rem; margin-top:1.1rem; font-family:var(--mono); font-size:.68rem}
|
||||
.tag{border:1px solid var(--ink); padding:.22rem .5rem; letter-spacing:.1em; text-transform:uppercase}
|
||||
.tag b{color:var(--stamp)} .tag.closed{background:var(--ink); color:var(--paper)}
|
||||
|
||||
.glossary{border:1px dashed var(--ink); padding:.8rem 1rem; margin:1.5rem 0 0; background:color-mix(in srgb,var(--accent) 8%,var(--paper))}
|
||||
.gl-title{font-family:var(--mono); font-weight:700; font-size:.66rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-faded); margin-bottom:.5rem}
|
||||
.glossary dl{margin:0; display:grid; grid-template-columns:max-content 1fr; gap:.35rem .9rem}
|
||||
.glossary dt{font-family:var(--mono); font-weight:700; color:var(--stamp)}
|
||||
.glossary dd{margin:0; font-size:.9rem}
|
||||
.glossary .more{font-family:var(--mono); font-size:.72rem; margin-top:.55rem}
|
||||
|
||||
section{margin:2.1rem 0}
|
||||
.exhibit{font-family:var(--mono); font-weight:700; font-size:.74rem; letter-spacing:.14em; text-transform:uppercase;
|
||||
color:var(--stamp); display:flex; align-items:center; gap:.6rem; margin-bottom:.7rem}
|
||||
.exhibit::after{content:""; flex:1; height:1px; background:var(--rule)}
|
||||
h2{font-family:var(--mono); font-size:1.22rem; margin:.1rem 0 .6rem; letter-spacing:-.01em}
|
||||
p{margin:.7rem 0}
|
||||
|
||||
.sheet{overflow-x:auto; border:1px solid var(--ink); background:color-mix(in srgb,var(--paper) 80%,#fff)}
|
||||
table{width:100%; border-collapse:collapse; font-family:var(--mono); font-size:.82rem; min-width:30rem}
|
||||
caption{font-family:var(--mono); font-weight:700; font-size:.68rem; letter-spacing:.13em; text-transform:uppercase; color:var(--ink-faded);
|
||||
text-align:left; padding:.5rem .7rem; border-bottom:1px solid var(--rule)}
|
||||
td{padding:.5rem .7rem; border-bottom:1px dashed var(--rule)}
|
||||
td:last-child{text-align:right; font-variant-numeric:tabular-nums; font-weight:700; white-space:nowrap}
|
||||
tr:last-child td{border-bottom:none}
|
||||
tr.big td{background:color-mix(in srgb,var(--stamp) 10%,transparent)}
|
||||
tr.big td:last-child{color:var(--stamp); font-size:1.05rem}
|
||||
.unit{color:var(--ink-faded); font-weight:400}
|
||||
|
||||
.lineup{display:grid; grid-template-columns:repeat(auto-fill,minmax(13.5rem,1fr)); gap:.8rem; margin-top:1rem}
|
||||
.card{border:1px solid var(--ink); background:color-mix(in srgb,var(--paper) 86%,#fff); padding:.7rem .8rem}
|
||||
.card .no{font-family:var(--mono); font-size:.66rem; color:var(--ink-faded); letter-spacing:.14em; font-weight:700}
|
||||
.card h3{font-family:var(--mono); font-size:.9rem; margin:.15rem 0 .35rem}
|
||||
.card p{font-size:.86rem; margin:.2rem 0; font-style:italic}
|
||||
.vtag{font-family:var(--mono); font-size:.62rem; letter-spacing:.12em; text-transform:uppercase; display:inline-block;
|
||||
margin-top:.45rem; padding:.12rem .4rem; border:1px solid var(--stamp); color:var(--stamp)}
|
||||
|
||||
pre{font-family:var(--mono); font-size:.78rem; line-height:1.5; overflow-x:auto; background:var(--code); color:#E9DFC6;
|
||||
border:1px solid #000; padding:.9rem 1rem; margin:.8rem 0; box-shadow:0 8px 20px -12px rgba(0,0,0,.7)}
|
||||
pre .c{color:#8a8570} pre .k{color:var(--accent)} pre .r{color:var(--stamp); font-weight:700} pre .g{color:#89a05a}
|
||||
.caplabel{font-family:var(--mono); font-weight:700; font-size:.66rem; letter-spacing:.12em; text-transform:uppercase; color:var(--ink-faded)}
|
||||
|
||||
.stamp{position:absolute; top:6.6rem; right:1.4rem; transform:rotate(-9deg); border:.32rem solid var(--stamp); color:var(--stamp);
|
||||
font-family:var(--mono); font-weight:700; padding:.42rem 1rem; text-align:center; line-height:1.05; opacity:.96; border-radius:7px;
|
||||
background:color-mix(in srgb,var(--paper) 78%,transparent); box-shadow:0 0 0 2px color-mix(in srgb,var(--stamp) 26%,transparent);
|
||||
pointer-events:none; letter-spacing:.06em}
|
||||
.stamp b{font-size:1.2rem; display:block; letter-spacing:.14em} .stamp span{font-size:.64rem; letter-spacing:.2em}
|
||||
@media (max-width:34rem){.stamp{animation:none; top:auto; bottom:1rem; right:.8rem; transform:rotate(-8deg); padding:.32rem .7rem} .stamp b{font-size:1rem}}
|
||||
|
||||
.verdict{border:2px solid var(--ink); padding:1.1rem 1.2rem; background:color-mix(in srgb,var(--accent) 9%,var(--paper))}
|
||||
.verdict h2{color:var(--stamp)}
|
||||
.cont{width:100%; border-collapse:collapse; margin-top:.6rem; font-size:.92rem}
|
||||
.cont td{padding:.5rem .6rem; border-bottom:1px solid var(--rule); vertical-align:top}
|
||||
.cont td:first-child{width:52%; font-style:italic}
|
||||
.cont td:last-child{font-family:var(--mono); font-size:.8rem}
|
||||
.kicknum{font-family:var(--mono); color:var(--stamp); font-weight:700}
|
||||
|
||||
.filefoot{display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-top:1.4rem}
|
||||
.printlink{font-family:var(--mono); font-size:.72rem; color:var(--stamp); text-decoration:none;
|
||||
border:1px solid var(--stamp); padding:.34rem .7rem; border-radius:.4rem; background:transparent; cursor:pointer}
|
||||
.printlink:hover{background:color-mix(in srgb,var(--stamp) 12%,transparent)}
|
||||
.pager{display:flex; gap:.4rem; font-family:var(--mono)}
|
||||
.pager button{font-size:.72rem; background:var(--paper); color:var(--ink); border:1px solid var(--ink);
|
||||
padding:.34rem .6rem; border-radius:.4rem; cursor:pointer}
|
||||
.pager button:disabled{opacity:.35; cursor:default}
|
||||
|
||||
footer{margin-top:2.1rem; border-top:1px solid var(--rule); padding-top:.9rem; font-family:var(--mono); font-size:.68rem;
|
||||
color:var(--ink-faded); display:flex; justify-content:space-between; flex-wrap:wrap; gap:.5rem}
|
||||
|
||||
.controls{position:fixed; top:.7rem; right:.7rem; display:flex; gap:.35rem; z-index:20; font-family:var(--mono)}
|
||||
.controls button{font-size:.66rem; letter-spacing:.06em; background:var(--paper); color:var(--ink); border:1px solid var(--ink);
|
||||
padding:.35rem .55rem; cursor:pointer; border-radius:4px}
|
||||
.controls button[aria-pressed="true"]{background:var(--ink); color:var(--paper)}
|
||||
.controls button:focus-visible, a:focus-visible, .tab:focus-visible, .pager button:focus-visible{outline:2px solid var(--stamp); outline-offset:2px}
|
||||
|
||||
@keyframes thunk{0%{transform:rotate(-9deg) scale(2.4); opacity:0} 70%{transform:rotate(-9deg) scale(.94); opacity:1} 100%{transform:rotate(-9deg) scale(1); opacity:.96}}
|
||||
.stamp{animation:thunk .5s cubic-bezier(.2,1.3,.4,1) .35s both}
|
||||
@media (prefers-reduced-motion:reduce){.stamp{animation:none}}
|
||||
|
||||
/* Archival/print: hide site chrome, keep the file. */
|
||||
@media print{
|
||||
.sitebar,.switch,.controls,.filefoot{display:none!important}
|
||||
body{background:#fff;padding:0;display:block}
|
||||
.file{box-shadow:none;border:none;max-width:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="controls">
|
||||
<button id="sk-det" aria-pressed="true"></button>
|
||||
<button id="sk-cli" aria-pressed="false"></button>
|
||||
<button id="tg"></button>
|
||||
</div>
|
||||
|
||||
<nav class="sitebar" aria-label="ontoref">
|
||||
<a class="brand" href="/"><img src="/images/ontoref-logo.svg" alt="ontoref"><span>ontoref</span></a>
|
||||
<a class="back" id="sw-back" href="#"></a>
|
||||
</nav>
|
||||
|
||||
<div class="switch" role="tablist" id="sw-bar">
|
||||
<span class="idx" id="sw-idx"></span>
|
||||
<div class="tabs" id="sw-tabs"></div>
|
||||
<div class="nav">
|
||||
<button id="sw-prev">‹</button>
|
||||
<button id="sw-next">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="file" id="file"></article>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/* ══ DATA ══ Projected by scripts/build/gen-informe.nu from the typed CASEs under
|
||||
site/content/expedientes. DO NOT EDIT THIS FILE — edit the .ncl.
|
||||
This viewer was hand-kept for two cases and drifted from the articles it shares a
|
||||
source with (deriva's ledger: 6 rows there, 8 here). */
|
||||
|
||||
{{DATA}}
|
||||
|
||||
/* ══ RENDER ══ */
|
||||
var skinKey = "detective";
|
||||
var idx = 0;
|
||||
|
||||
function render(){
|
||||
var c = CASES[idx], s = c.skins[skinKey] || c.skins[c.skin];
|
||||
document.documentElement.setAttribute("data-skin", skinKey);
|
||||
document.title = UI.doc_title.replace("{case_no}", c.caseNo);
|
||||
// A case may legitimately have one skin. Better a dead button, plainly dead, than
|
||||
// prose invented to fill the other genre.
|
||||
var det = document.getElementById("sk-det"), cli = document.getElementById("sk-cli");
|
||||
det.setAttribute("aria-pressed", skinKey==="detective");
|
||||
cli.setAttribute("aria-pressed", skinKey==="clinical");
|
||||
det.disabled = !c.skins.detective; det.title = c.skins.detective ? "" : UI.skin_missing;
|
||||
cli.disabled = !c.skins.clinical; cli.title = c.skins.clinical ? "" : UI.skin_missing;
|
||||
|
||||
// Switcher chrome
|
||||
document.getElementById("sw-idx").textContent = UI.counter + " " + (idx+1) + "/" + CASES.length;
|
||||
document.getElementById("sw-prev").disabled = (idx===0);
|
||||
document.getElementById("sw-next").disabled = (idx===CASES.length-1);
|
||||
document.getElementById("sw-tabs").innerHTML = CASES.map(function(x,i){
|
||||
return "<button class='tab' role='tab' data-go='"+i+"' aria-current='"+(i===idx)+"'>"+x.tab+"</button>";
|
||||
}).join("");
|
||||
|
||||
var cost = c.cost.map(function(r){
|
||||
return "<tr"+(r[2]?" class='big'":"")+"><td>"+r[0]+"</td><td>"+r[1]
|
||||
.replace("<i>","<span class='unit'>").replace("</i>","</span>")+"</td></tr>";
|
||||
}).join("");
|
||||
var susp = c.suspectsTech.map(function(h,i){
|
||||
var n=(i+1<10?"0":"")+(i+1);
|
||||
return "<div class='card'><div class='no'>"+s.no.toUpperCase()+" "+n+"</div><h3>"+h+
|
||||
"</h3><p>"+s.alibis[i]+"</p><span class='vtag'>"+s.tags[i]+"</span></div>";
|
||||
}).join("");
|
||||
var gloss = c.glossary.map(function(g){return "<dt>"+g.t+"</dt><dd>"+g.d+"</dd>";}).join("");
|
||||
var cont = c.contain.map(function(r){return "<tr><td>"+r[0]+"</td><td>"+r[1]+"</td></tr>";}).join("");
|
||||
var yieldTbl = "";
|
||||
if(c.yielded && c.yielded.length){
|
||||
var yrows = c.yielded.map(function(r){
|
||||
return "<tr"+(r[2]?" class='big'":"")+"><td>"+r[0]+"</td><td>"+r[1]
|
||||
.replace("<i>","<span class='unit'>").replace("</i>","</span>")+"</td></tr>";
|
||||
}).join("");
|
||||
yieldTbl = "<div class='sheet'><table><caption>"+c.yieldedTitle+"</caption><tbody>"+yrows+"</tbody></table></div>";
|
||||
}
|
||||
|
||||
document.getElementById("file").innerHTML =
|
||||
"<div class='stamp' aria-hidden='true'><b>"+s.stamp[0]+"</b><span>"+s.stamp[1]+"</span></div>"+
|
||||
"<header><div class='kicker'>"+s.kicker+"</div>"+
|
||||
"<h1>"+s.h1[0]+"<span class='red'>"+s.h1[1]+"</span></h1>"+
|
||||
"<p class='logline'>"+s.logline+"</p>"+
|
||||
"<div class='meta'>"+
|
||||
"<span class='tag'>"+s.meta[0]+" <b>"+c.caseNo+"</b></span>"+
|
||||
"<span class='tag'>"+s.meta[1]+": <b>"+c.classification+"</b></span>"+
|
||||
"<span class='tag'>"+s.meta[2]+"</span>"+
|
||||
"<span class='tag closed'>"+s.meta[3]+"</span></div>"+
|
||||
"<aside class='glossary'><div class='gl-title'>"+UI.gloss_title+"</div>"+
|
||||
"<dl>"+gloss+"</dl><div class='more'>"+c.glossaryMore+"</div></aside>"+
|
||||
"</header>"+
|
||||
"<section><div class='exhibit'>"+s.ex[0]+"</div><h2>"+s.h2[0]+"</h2><p>"+s.lead2+"</p>"+
|
||||
"<div class='sheet'><table><caption>"+c.costCaption+"</caption><tbody>"+cost+"</tbody></table></div>"+yieldTbl+"</section>"+
|
||||
"<section><div class='exhibit'>"+s.ex[1]+"</div><h2>"+s.h2[1]+"</h2><p>"+s.lead3+"</p>"+
|
||||
"<div class='lineup'>"+susp+"</div></section>"+
|
||||
"<section><div class='exhibit'>"+s.ex[2]+"</div><h2>"+s.h2[2]+"</h2>"+
|
||||
"<span class='caplabel'>"+s.weaponCap+"</span><pre>"+c.weaponCode+"</pre><p>"+s.weaponAfter+"</p></section>"+
|
||||
"<section><div class='exhibit'>"+s.ex[3]+"</div><h2>"+s.h2[3]+"</h2>"+
|
||||
"<span class='caplabel'>"+s.fixCap+"</span><pre>"+c.fixCode+"</pre><p>"+s.fixAfter+"</p></section>"+
|
||||
"<section class='verdict'><div class='exhibit'>"+s.ex[4]+"</div><h2>"+s.h2[4]+"</h2>"+
|
||||
"<p>"+s.verdictLead+"</p><table class='cont'><tbody>"+cont+"</tbody></table>"+
|
||||
"<p style='margin-bottom:0'>"+c.verdictClose+"</p></section>"+
|
||||
"<div class='filefoot'>"+
|
||||
"<button type='button' class='printlink' data-print>"+UI.print+"</button>"+
|
||||
"<div class='pager'>"+
|
||||
"<button data-step='-1'"+(idx===0?" disabled":"")+">"+UI.pager_prev+"</button>"+
|
||||
"<button data-step='1'"+(idx===CASES.length-1?" disabled":"")+">"+UI.pager_next+"</button>"+
|
||||
"</div></div>"+
|
||||
"<footer><span>"+s.footL+"</span><span>"+s.footR+"</span></footer>";
|
||||
}
|
||||
|
||||
function goto(i){
|
||||
if(i<0||i>=CASES.length) return;
|
||||
idx=i;
|
||||
var id=CASES[idx].id;
|
||||
if(location.hash.slice(1)!==id) history.replaceState(null,"","#"+id);
|
||||
render();
|
||||
}
|
||||
|
||||
var root=document.documentElement;
|
||||
root.setAttribute("data-theme", matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light");
|
||||
document.getElementById("sk-det").textContent = UI.skin_detective;
|
||||
document.getElementById("sk-cli").textContent = UI.skin_clinical;
|
||||
document.getElementById("tg").setAttribute("aria-label", UI.theme);
|
||||
document.getElementById("tg").textContent = "☀ / ☾";
|
||||
var back = document.getElementById("sw-back");
|
||||
back.textContent = UI.back; back.setAttribute("href", UI.back_href);
|
||||
document.getElementById("sw-bar").setAttribute("aria-label", UI.switch_label);
|
||||
document.getElementById("sw-prev").setAttribute("aria-label", UI.prev_label);
|
||||
document.getElementById("sw-next").setAttribute("aria-label", UI.next_label);
|
||||
document.getElementById("sk-det").onclick=function(){skinKey="detective";render();};
|
||||
document.getElementById("sk-cli").onclick=function(){skinKey="clinical";render();};
|
||||
document.getElementById("tg").onclick=function(){
|
||||
root.setAttribute("data-theme", root.getAttribute("data-theme")==="dark"?"light":"dark");
|
||||
};
|
||||
document.getElementById("sw-prev").onclick=function(){goto(idx-1);};
|
||||
document.getElementById("sw-next").onclick=function(){goto(idx+1);};
|
||||
// Delegated: tab clicks + pager buttons inside #file.
|
||||
document.addEventListener("click",function(ev){
|
||||
var t=ev.target.closest && ev.target.closest("[data-go],[data-step],[data-print]");
|
||||
if(!t) return;
|
||||
if(t.hasAttribute("data-print")){ window.print(); return; }
|
||||
if(t.hasAttribute("data-go")) goto(parseInt(t.getAttribute("data-go"),10));
|
||||
else goto(idx+parseInt(t.getAttribute("data-step"),10));
|
||||
});
|
||||
window.addEventListener("hashchange",function(){
|
||||
var i=CASES.findIndex(function(x){return x.id===location.hash.slice(1);});
|
||||
if(i>=0 && i!==idx) goto(i);
|
||||
});
|
||||
|
||||
// Open on the deep-linked case if the hash names one.
|
||||
var start=CASES.findIndex(function(x){return x.id===location.hash.slice(1);});
|
||||
idx = start>=0 ? start : 0;
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body></html>
|
||||
106
site/site/content/adr/en/accepted/adr-070.md
Normal file
106
site/site/content/adr/en/accepted/adr-070.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
---
|
||||
id: "adr-070"
|
||||
title: "Delivery Trees Declare Owner, Reproduction and Witness — the Site's Publication Pipeline Is Governed, Not Remembered"
|
||||
slug: "070"
|
||||
subtitle: "Accepted"
|
||||
excerpt: "Four incidents over two sessions (2026-07-10..12) on the outreach/site publication"
|
||||
author: "ontoref"
|
||||
date: "2026-07-12"
|
||||
published: true
|
||||
featured: false
|
||||
category: "accepted"
|
||||
tags: ["adr-070", "adr", "accepted"]
|
||||
css_class: "category-adr"
|
||||
---
|
||||
<h2>Context</h2>
|
||||
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Decision</h2>
|
||||
|
||||
<p>Every tree in the outreach/site delivery pipeline declares three properties, and the
|
||||
pipeline is gated on them.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>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 templates-overlay/ existed) are tracked despite
|
||||
looking generated. Ignoring a tree is a claim that it is reproducible; making that
|
||||
claim falsely deletes work in silence.</p>
|
||||
<p>Consequently, the site level gets the overlay slot the assembler never gave it:
|
||||
site/templates-overlay/ is the declared source for template work owned by THIS level.
|
||||
Anything edited straight into the assembled output is, by definition, unwitnessed.</p>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Constraints</h2>
|
||||
|
||||
<ul><li><strong>Hard</strong> 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.</li><li><strong>Hard</strong> 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.</li><li><strong>Hard</strong> Template work owned by the site level MUST live in site/templates-overlay/; htmx-templates/ is assembled output and MUST NOT be hand-edited — `just templates` deletes it on every run.</li><li><strong>Hard</strong> `just templates-check` MUST pass: the assembled template tree reproduces exactly from its declared sources, so no file in it exists nowhere else.</li><li><strong>Hard</strong> `just adr-check` MUST pass: every ADR in .ontoref/adrs has a page in the site's adr content-kind.</li><li><strong>Hard</strong> 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.</li><li><strong>Hard</strong> 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.</li><li><strong>Soft</strong> 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.</li></ul>
|
||||
|
||||
<h2>Alternatives considered</h2>
|
||||
|
||||
<ul><li><strong>Fix the four bugs, add no ADR</strong> — <em>rejected:</em> 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.</li><li><strong>Extend ADR-066 to cover the publication pipeline</strong> — <em>rejected:</em> 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.</li><li><strong>Generalize to every constellation delivery surface (code, outreach, desktop)</strong> — <em>rejected:</em> 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.</li><li><strong>Make the deploy gate Hard now</strong> — <em>rejected:</em> 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.</li><li><strong>Give the site its own git repo so its delivery is self-contained</strong> — <em>rejected:</em> 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.</li></ul>
|
||||
|
||||
<h2>Anti-patterns</h2>
|
||||
|
||||
<ul><li><strong>A Whole-Tree One-Way Sync Between Two Trees That Own Different Files</strong> — 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.</li><li><strong>Hand-Editing an Assembled Output Because the Level Has No Source Slot</strong> — 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.</li><li><strong>A Projection Generator No Recipe Calls</strong> — 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.</li><li><strong>A Schema Filed Among the Data It Types</strong> — 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.</li><li><strong>A Check That Can Only Run After the Destructive Step</strong> — 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.</li></ul>
|
||||
12
site/site/content/adr/en/accepted/adr-070.ncl
Normal file
12
site/site/content/adr/en/accepted/adr-070.ncl
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
id = "adr-070",
|
||||
title = "Delivery Trees Declare Owner, Reproduction and Witness — the Site's Publication Pipeline Is Governed, Not Remembered",
|
||||
slug = "070",
|
||||
excerpt = "",
|
||||
tags = ["adr", "accepted"],
|
||||
page_route = "/adr/070",
|
||||
graph = {
|
||||
implements = [],
|
||||
related_to = [],
|
||||
},
|
||||
}
|
||||
|
|
@ -16,17 +16,21 @@ sort_order: 1
|
|||
---
|
||||
|
||||
<section class="exp">
|
||||
|
||||
<style>
|
||||
.exp{font-family:Georgia,"Times New Roman",serif}
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.4rem;margin:.9rem 0 0;font-family:ui-monospace,"Courier New",monospace;font-size:.68rem}
|
||||
.exp .exp-meta span{border:1px solid;padding:.2rem .5rem;letter-spacing:.08em;text-transform:uppercase}
|
||||
.exp .exp-gloss{border:1px dashed;padding:.75rem 1rem;margin:1.4rem 0;border-radius:.4rem}
|
||||
.exp .exp-gloss dl{margin:0;display:grid;grid-template-columns:max-content 1fr;gap:.3rem .9rem}
|
||||
.exp .exp-gloss dt{font-family:ui-monospace,"Courier New",monospace;font-weight:700;color:#b4342a}
|
||||
.exp .exp-gloss dd{margin:0;font-size:.92rem}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.13em;text-transform:uppercase;color:#b4342a;margin:2rem 0 .5rem;border-top:1px solid;padding-top:.9rem}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
|
|
@ -37,31 +41,29 @@ sort_order: 1
|
|||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp pre{font-family:ui-monospace,"Courier New",monospace;font-size:.8rem;overflow-x:auto;background:#1a1a1a;color:#e9dfc6;padding:.85rem 1rem;border-radius:.4rem}
|
||||
.exp .exp-cont td{padding:.45rem .5rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top;font-size:.92rem}
|
||||
.exp .exp-cont td:first-child{font-style:italic}
|
||||
.exp .exp-cont td:last-child{font-family:ui-monospace,"Courier New",monospace;font-size:.82rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .8rem;border-radius:.5rem;overflow:hidden;line-height:0}
|
||||
.exp .exp-hero img{width:100%;display:block}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.85rem;font-weight:700;background:#9c2d25;color:#fff;padding:.55rem .95rem;border-radius:.4rem;text-decoration:none}
|
||||
.exp .exp-btn:hover{background:#7f241d}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>
|
||||
|
||||
<a class="exp-hero" href="/images/expedientes/en/expedientes.html#deriva-hardcoded-anti-pap" target="_blank" rel="noopener" title="Open the full case file"><img src="/images/expedientes/deriva-header.webp" alt="Case file 404-PAP — the hardcoded drift" width="1200" height="675" loading="lazy"></a>
|
||||
<a class="exp-hero" href="/images/expedientes/en/expedientes.html#deriva-hardcoded-anti-pap" target="_blank" rel="noopener" title="Open the full case file"><img src="/images/ontology-reflection-post.webp" alt="Case 404-PAP: the hardcoded drift" width="1200" height="675" loading="lazy"></a>
|
||||
|
||||
<p style="margin:0 0 1.4rem"><a class="exp-btn" href="/images/expedientes/en/expedientes.html#deriva-hardcoded-anti-pap" target="_blank" rel="noopener">🕵️ Show the full case file →</a></p>
|
||||
|
||||
<p class="exp-kick">Case file · Code Homicide Dept.</p>
|
||||
|
||||
<p class="exp-log">It was a quiet afternoon. Then <code>/recursos</code> walked in and showed nothing. Nothing at all. And I, naive, thought it would be quick.</p>
|
||||
<p class="exp-log">It was a quiet afternoon. Then <span class='mono'>/recursos</span> walked in and showed nothing. Nothing at all. And I, naive, thought it would be quick.</p>
|
||||
|
||||
<div class="exp-meta"><span>Case No. <b>404-PAP</b></span><span>Classification: ANTI-PAP</span><span>Status: CLOSED</span></div>
|
||||
<div class="exp-meta"><span>Case No. <b>404-PAP</b></span><span>Classification: ANTI-PAP</span><span>Status: SOLVED</span></div>
|
||||
|
||||
<div class="exp-gloss">
|
||||
<dl>
|
||||
<dt>PAP</dt><dd><b>Project's Architecture Principles</b> — the rules and patterns that hold the architecture up: the single source of truth all code must respect.</dd>
|
||||
<dt>anti-PAP</dt><dd>Code written <em>against</em> those rules. Here: a stub re-listing by hand what <code>routes.ncl</code> already declares.</dd>
|
||||
<dt>PAP</dt><dd><b>Project's Architecture Principles</b> — the rules and patterns holding the project's architecture up: the single source of truth all code must respect.</dd>
|
||||
<dt>anti-PAP</dt><dd>Code written <em>against</em> those rules: a stub that re-lists by hand what <span class='mono'>routes.ncl</span> already declares, duplicating and contradicting the source of truth.</dd>
|
||||
</dl>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">A protocol to declare, version and verify all this → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">The protocol to declare, version and verify this → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">The double ledger — what it cost, and what it left</p>
|
||||
|
|
@ -72,12 +74,14 @@ A case isn't judged only by what it costs. It's judged by what it leaves. From a
|
|||
<div class="exp-ledger cost">
|
||||
<h4>What the crime cost</h4>
|
||||
<ul>
|
||||
<li>Pure hunt (framework) <b>~88 min</b></li>
|
||||
<li>Release builds <b>8</b></li>
|
||||
<li>Coexisting fingerprints <b>6</b></li>
|
||||
<li>Pure origin hunt (framework only) <b>~88 <i>min</i></b></li>
|
||||
<li>Release builds launched <b>8</b></li>
|
||||
<li>Server restarts <b>~15+</b></li>
|
||||
<li>False leads <b>8</b></li>
|
||||
<li>The fix <b>8 lines</b></li>
|
||||
<li>Build fingerprints coexisting <b>6</b></li>
|
||||
<li>Files of <i>someone else's</i> code read (2 repos) <b>~20+</b></li>
|
||||
<li>Full <span class='mono'>cargo clean</span> runs (867 MB gone) <b>2</b></li>
|
||||
<li>False leads before the right one <b>8</b></li>
|
||||
<li>Size of the final fix <b>8 <i>lines</i></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="exp-ledger yield">
|
||||
|
|
@ -101,7 +105,20 @@ There's a point in every drift that no cost table records: the one where you sto
|
|||
|
||||
With [ontoref](https://ontoref.dev) that point shouldn't arrive. Not because there are no bugs, but because it doesn't leave you alone with them: the **dao** (ondaod) forces you to *name* the tension instead of collapsing or abandoning it; the **modes** hand you the next step when you no longer know what it is; the **ADRs** close what's already decided so you don't re-litigate it at two in the morning. The drift doesn't sink you because the map holds you up.
|
||||
|
||||
<p class="exp-ex">The weapon — a hardcoded match (anti-PAP)</p>
|
||||
<p class="exp-ex">The suspects — the false leads</p>
|
||||
|
||||
<table class="exp-susp"><tbody>
|
||||
<tr><td><b>The content in <span class='mono'>site/r</span></b></td><td><em>“I wasn't in sync.”</em></td><td>minor accomplice</td></tr>
|
||||
<tr><td><b>filename ≠ <span class='mono'>id</span></b></td><td><em>“The body came back empty, but I'm not the 404 guy.”</em></td><td>other crime</td></tr>
|
||||
<tr><td><b>The <span class='mono'>{# … #}</span> comment</b></td><td><em>“I just slipped in as text. Innocent.”</em></td><td>solid alibi</td></tr>
|
||||
<tr><td><b>Staleness & fingerprints</b></td><td><em>“With 6 fingerprints, how could you not doubt the binary?”</em></td><td>false lead</td></tr>
|
||||
<tr><td><b><span class='mono'>SITE_PUBLIC_PATH</span> / symlink</b></td><td><em>“The dir didn't exist. Symlink for nothing.”</em></td><td>false lead</td></tr>
|
||||
<tr><td><b><span class='mono'>generated.rs</span> → <span class='mono'>vec!["content"]</span></b></td><td><em>“I'm the fallback. I don't even run.”</em></td><td>decoy</td></tr>
|
||||
<tr><td><b>RBAC</b></td><td><em>“A <em>deny</em> from me is a 302, darling. A 404 isn't my job.”</em></td><td>302 alibi</td></tr>
|
||||
<tr><td><b><span class='mono'>build_page_generator</span></b></td><td><em>“That's Leptos. In htmx-ssr I don't even show up.”</em></td><td>wrong jurisdiction</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="exp-ex">The weapon — The weapon · a hardcoded match (pre-existing, anti-PAP)</p>
|
||||
|
||||
<pre>let kind = match base {
|
||||
"blog" => Some("blog"),
|
||||
|
|
@ -112,7 +129,7 @@ With [ontoref](https://ontoref.dev) that point shouldn't arrive. Not because the
|
|||
|
||||
Route registered. Content-type enabled. The API returned the items. The content, present. And still, 404 — because a stub decided the truth <em>against</em> the source of truth.
|
||||
|
||||
<p class="exp-ex">The turn — 8 lines that read from the registry</p>
|
||||
<p class="exp-ex">The turn — PAP-compliant · adding a kind is just NCL again</p>
|
||||
|
||||
<pre>let kind = load_routes_config().routes.iter().find_map(|r| {
|
||||
if !r.enabled { return None; }
|
||||
|
|
@ -128,11 +145,371 @@ We didn't go to the beach because we chose badly: we went because **the map had
|
|||
|
||||
<table class="exp-cont"><tbody>
|
||||
<tr><td>Hardcoded stub shadowing the registry</td><td>PAP + anti-patterns → grep-able</td></tr>
|
||||
<tr><td>Knowledge lost, rediscovered by pain</td><td>qa/howto → content-kind-howto</td></tr>
|
||||
<tr><td>Knowledge lost, rediscovered through pain</td><td>qa/howto → content-kind-howto</td></tr>
|
||||
<tr><td>Building on the invalid without knowing</td><td>review against invariants</td></tr>
|
||||
<tr><td>Assuming the unassumable, believing the unbelievable</td><td>declared state → 3 levels</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
A ten-line drift, unmarked, cost us an afternoon to escape — and was fixed in eight. That differential is the ROI. **[Ontoref](https://ontoref.dev) is the map's signposting.**
|
||||
|
||||
<!--
|
||||
_glossary.html — Section glossary component embedded in the /expedientes hub.
|
||||
Self-contained: inline <style> + <script>, no external deps, CSP-safe, standalone.
|
||||
Data-driven: edit the TERMS array below ({id, term, def, aliases?}) to add entries.
|
||||
Prose links jump to a term with <a href="#gl-pap">…</a> (anchor = "gl-" + id);
|
||||
landing on #gl-<id> scrolls the term into view and briefly highlights it.
|
||||
Search box live-filters by term text, definition, and aliases as you type.
|
||||
-->
|
||||
<section class="glz" aria-label="Glosario de la sección Expedientes">
|
||||
<header class="glz__head">
|
||||
<h2 class="glz__title">Glosario</h2>
|
||||
<div class="glz__search">
|
||||
<input
|
||||
type="search"
|
||||
id="glz-q"
|
||||
class="glz__input"
|
||||
placeholder="Filtrar términos…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filtrar términos del glosario"
|
||||
/>
|
||||
<span class="glz__count" id="glz-count" aria-live="polite"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl class="glz__list" id="glz-list"></dl>
|
||||
<p class="glz__empty" id="glz-empty" hidden>Sin coincidencias.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.glz {
|
||||
--glz-bg: #ffffff;
|
||||
--glz-fg: #1a1a1a;
|
||||
--glz-muted: #6b7280;
|
||||
--glz-border: #e5e7eb;
|
||||
--glz-accent: #2563eb;
|
||||
--glz-mark: #fef08a;
|
||||
--glz-chip: #f3f4f6;
|
||||
--glz-flash: rgba(37, 99, 235, 0.16);
|
||||
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
color: var(--glz-fg);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glz {
|
||||
--glz-bg: #0d1117;
|
||||
--glz-fg: #e6edf3;
|
||||
--glz-muted: #8b949e;
|
||||
--glz-border: #21262d;
|
||||
--glz-accent: #58a6ff;
|
||||
--glz-mark: #9a7b00;
|
||||
--glz-chip: #161b22;
|
||||
--glz-flash: rgba(88, 166, 255, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
.glz__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.glz__title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.glz__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 14rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.glz__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
color: var(--glz-fg);
|
||||
background: var(--glz-bg);
|
||||
border: 1px solid var(--glz-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.glz__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--glz-accent);
|
||||
box-shadow: 0 0 0 3px var(--glz-flash);
|
||||
}
|
||||
.glz__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--glz-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glz__list {
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--glz-border);
|
||||
}
|
||||
.glz__row {
|
||||
padding: 0.7rem 0.15rem;
|
||||
border-bottom: 1px solid var(--glz-border);
|
||||
scroll-margin-top: 5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.glz__row.is-flash {
|
||||
background: var(--glz-flash);
|
||||
}
|
||||
.glz__term {
|
||||
margin: 0;
|
||||
font-weight: 640;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
.glz__aliases {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--glz-muted);
|
||||
}
|
||||
.glz__alias {
|
||||
display: inline-block;
|
||||
padding: 0.02rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
background: var(--glz-chip);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.glz__def {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--glz-fg);
|
||||
}
|
||||
.glz__def mark {
|
||||
background: var(--glz-mark);
|
||||
color: inherit;
|
||||
padding: 0 0.1em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glz__empty {
|
||||
padding: 1rem 0.15rem;
|
||||
color: var(--glz-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
code, .glz__code {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--glz-chip);
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TERMS = [
|
||||
{
|
||||
id: "pap",
|
||||
term: "PAP",
|
||||
aliases: ["Project's Architecture Principles"],
|
||||
def: "Los principios, reglas y patrones de arquitectura del proyecto. Una decisión “conforme a PAP” respeta las invariantes establecidas."
|
||||
},
|
||||
{
|
||||
id: "anti-pap",
|
||||
term: "anti-PAP",
|
||||
aliases: ["contra PAP"],
|
||||
def: "Un enfoque que va completamente en contra de la arquitectura establecida del proyecto; se rechaza aunque “funcione”."
|
||||
},
|
||||
{
|
||||
id: "registry",
|
||||
term: "registry",
|
||||
aliases: ["registro"],
|
||||
def: "Tabla central que mapea cada tipo de contenido a su handler y metadatos, de modo que las rutas se resuelven de forma declarativa."
|
||||
},
|
||||
{
|
||||
id: "contentkind",
|
||||
term: "ContentKind",
|
||||
aliases: ["variant", "enum"],
|
||||
def: "El tipo (enum) que enumera las clases de contenido que el sistema sabe renderizar; cada variante corresponde a un kind."
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
term: "kind",
|
||||
aliases: ["tipo de contenido"],
|
||||
def: "Discriminante de un contenido: identifica a qué ContentKind pertenece y, por tanto, qué plantilla y ruta le aplican."
|
||||
},
|
||||
{
|
||||
id: "htmx-ssr",
|
||||
term: "htmx-ssr",
|
||||
aliases: ["server-side rendering", "SSR"],
|
||||
def: "Renderizado en servidor con fragmentos HTML entregados a htmx: el servidor devuelve HTML listo y el cliente solo intercambia trozos, sin framework pesado."
|
||||
},
|
||||
{
|
||||
id: "routes-ncl",
|
||||
term: "routes.ncl",
|
||||
aliases: ["routes", "Nickel routes"],
|
||||
def: "Archivo Nickel declarativo que define las rutas del sitio y las asocia a cada kind; fuente única de verdad para el enrutado."
|
||||
},
|
||||
{
|
||||
id: "deriva",
|
||||
term: "deriva",
|
||||
aliases: ["drift"],
|
||||
def: "Divergencia silenciosa entre dos representaciones que deberían coincidir (p. ej. código y ontología, o assets y su copia materializada). Se detecta con comprobaciones de <code>check</code>."
|
||||
},
|
||||
{
|
||||
id: "dogfooding",
|
||||
term: "dogfooding",
|
||||
aliases: ["comerse la propia comida"],
|
||||
def: "Usar el propio proyecto para construir y documentar el proyecto: el hub de Expedientes se publica con el mismo motor htmx-ssr que describe."
|
||||
},
|
||||
{
|
||||
id: "expediente",
|
||||
term: "expediente",
|
||||
aliases: ["dossier", "caso"],
|
||||
def: "Unidad documental del hub: un caso autocontenido que recoge contexto, decisiones y evidencia sobre una pieza del sistema."
|
||||
}
|
||||
];
|
||||
|
||||
var norm = function (s) {
|
||||
return (s || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "");
|
||||
};
|
||||
|
||||
var esc = function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[c];
|
||||
});
|
||||
};
|
||||
|
||||
var list = document.getElementById("glz-list");
|
||||
var input = document.getElementById("glz-q");
|
||||
var count = document.getElementById("glz-count");
|
||||
var empty = document.getElementById("glz-empty");
|
||||
if (!list || !input) return;
|
||||
|
||||
// Build rows once; filtering only toggles visibility + highlight.
|
||||
var rows = TERMS.map(function (t) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "glz__row";
|
||||
row.id = "gl-" + t.id;
|
||||
|
||||
var dt = document.createElement("dt");
|
||||
dt.className = "glz__term";
|
||||
dt.innerHTML = esc(t.term);
|
||||
if (t.aliases && t.aliases.length) {
|
||||
var al = document.createElement("span");
|
||||
al.className = "glz__aliases";
|
||||
al.innerHTML = t.aliases
|
||||
.map(function (a) {
|
||||
return '<span class="glz__alias">' + esc(a) + "</span>";
|
||||
})
|
||||
.join("");
|
||||
dt.appendChild(al);
|
||||
}
|
||||
|
||||
var dd = document.createElement("dd");
|
||||
dd.className = "glz__def";
|
||||
dd.dataset.def = t.def; // def may carry inline markup (e.g. <code>)
|
||||
|
||||
row.appendChild(dt);
|
||||
row.appendChild(dd);
|
||||
list.appendChild(row);
|
||||
|
||||
return {
|
||||
el: row,
|
||||
dd: dd,
|
||||
term: t.term,
|
||||
rawDef: t.def,
|
||||
hay: norm(t.term + " " + t.def + " " + (t.aliases || []).join(" "))
|
||||
};
|
||||
});
|
||||
|
||||
// Highlight the query inside definitions without breaking inline markup:
|
||||
// split the def on tag boundaries and only mark text segments.
|
||||
var highlight = function (html, q) {
|
||||
if (!q) return html;
|
||||
var qn = norm(q);
|
||||
return html.replace(/(<[^>]+>)|([^<]+)/g, function (_, tag, text) {
|
||||
if (tag) return tag;
|
||||
var out = "";
|
||||
var i = 0;
|
||||
var tn = norm(text);
|
||||
while (i < text.length) {
|
||||
var at = tn.indexOf(qn, i);
|
||||
if (at === -1) {
|
||||
out += esc(text.slice(i));
|
||||
break;
|
||||
}
|
||||
out += esc(text.slice(i, at));
|
||||
out += "<mark>" + esc(text.slice(at, at + q.length)) + "</mark>";
|
||||
i = at + q.length;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
};
|
||||
|
||||
var render = function () {
|
||||
var q = input.value.trim();
|
||||
var qn = norm(q);
|
||||
var shown = 0;
|
||||
rows.forEach(function (r) {
|
||||
var match = !qn || r.hay.indexOf(qn) !== -1;
|
||||
r.el.hidden = !match;
|
||||
if (match) {
|
||||
shown++;
|
||||
r.dd.innerHTML = highlight(r.rawDef, q);
|
||||
}
|
||||
});
|
||||
count.textContent =
|
||||
shown === TERMS.length ? TERMS.length + " términos" : shown + "/" + TERMS.length;
|
||||
empty.hidden = shown !== 0;
|
||||
};
|
||||
|
||||
var flash = function (row) {
|
||||
if (!row) return;
|
||||
row.classList.remove("is-flash");
|
||||
// reflow so the class re-adds even on repeat clicks to the same anchor
|
||||
void row.offsetWidth;
|
||||
row.classList.add("is-flash");
|
||||
window.setTimeout(function () {
|
||||
row.classList.remove("is-flash");
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
var jumpToHash = function () {
|
||||
var h = window.location.hash;
|
||||
if (!h || h.indexOf("#gl-") !== 0) return;
|
||||
var row = document.getElementById(h.slice(1));
|
||||
if (!row) return;
|
||||
if (row.hidden) {
|
||||
// clear an active filter so the linked term is visible
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
flash(row);
|
||||
};
|
||||
|
||||
input.addEventListener("input", render);
|
||||
window.addEventListener("hashchange", jumpToHash);
|
||||
|
||||
render();
|
||||
jumpToHash();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
# Typed source for the ENGLISH expediente "deriva-hardcoded-anti-pap".
|
||||
# Harvested verbatim from the two hand-written English renders — no prose was
|
||||
# translated or invented. Both renders are PROJECTIONS of this record:
|
||||
#
|
||||
# site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.md (detective skin)
|
||||
# site/public/images/expedientes/en/expedientes.html (both skins)
|
||||
#
|
||||
# The clinical skin has no long-form .md: its verdict_prose falls back to the
|
||||
# informe's verdict lead, and the .md-only editorial slots stay absent.
|
||||
let s = import "../../../../schemas/expediente.ncl" in
|
||||
|
||||
s.make_expediente {
|
||||
id = "deriva-hardcoded-anti-pap",
|
||||
title = "Case 404-PAP: the hardcoded drift",
|
||||
slug = "the-hardcoded-drift",
|
||||
case_no = "404-PAP",
|
||||
classification = "ANTI-PAP",
|
||||
status = "SOLVED",
|
||||
skin = 'detective,
|
||||
|
||||
tab = "404-PAP · the drift",
|
||||
print_href = "/images/expedientes/en/deriva.html",
|
||||
|
||||
glossary = [
|
||||
{ term = "PAP",
|
||||
def = "<b>Project's Architecture Principles</b> — the rules and patterns holding the project's architecture up: the single source of truth all code must respect." },
|
||||
{ term = "anti-PAP",
|
||||
def = "Code written <em>against</em> those rules: a stub that re-lists by hand what <span class='mono'>routes.ncl</span> already declares, duplicating and contradicting the source of truth." },
|
||||
],
|
||||
|
||||
# Union of both renders: the informe carries 8 rows, the .md only 6 (it drops the
|
||||
# files-read and cargo-clean rows). The informe's labels/values/emphasis win.
|
||||
cost = [
|
||||
{ label = "Pure origin hunt (framework only)", value = "~88 <i>min</i>" },
|
||||
{ label = "Release builds launched", value = "8" },
|
||||
{ label = "Server restarts", value = "~15+" },
|
||||
{ label = "Build fingerprints coexisting", value = "6" },
|
||||
{ label = "Files of <i>someone else's</i> code read (2 repos)", value = "~20+" },
|
||||
{ label = "Full <span class='mono'>cargo clean</span> runs (867 MB gone)", value = "2" },
|
||||
{ label = "False leads before the right one", value = "8", emphasis = true },
|
||||
{ label = "Size of the final fix", value = "8 <i>lines</i>", emphasis = true },
|
||||
],
|
||||
|
||||
yielded = [
|
||||
{ label = "Registry-driven fix", value = "PAP-compliant" },
|
||||
{ label = "Knowledge", value = "content-kind-howto" },
|
||||
{ label = "Mechanism", value = "generate-expediente mode" },
|
||||
{ label = "Evidence", value = "proof (positioning)" },
|
||||
{ label = "Series + hub", value = "/case-files" },
|
||||
{ label = "Framework", value = "unblocked" },
|
||||
],
|
||||
|
||||
cost_title = "What the crime cost",
|
||||
yielded_title = "What the case left",
|
||||
cost_caption = "Measurable cost · session 2026-07-10 → 07-11",
|
||||
|
||||
suspects = [
|
||||
{ name = "The content in <span class='mono'>site/r</span>" },
|
||||
{ name = "filename ≠ <span class='mono'>id</span>" },
|
||||
{ name = "The <span class='mono'>{# … #}</span> comment" },
|
||||
{ name = "Staleness & fingerprints" },
|
||||
{ name = "<span class='mono'>SITE_PUBLIC_PATH</span> / symlink" },
|
||||
{ name = m%"<span class='mono'>generated.rs</span> → <span class='mono'>vec!["content"]</span>"% },
|
||||
{ name = "RBAC" },
|
||||
{ name = "<span class='mono'>build_page_generator</span>" },
|
||||
],
|
||||
|
||||
weapon = {
|
||||
code = m%"
|
||||
let kind = match base {
|
||||
"blog" => Some("blog"),
|
||||
"activities" | "actividades" => Some("activities"),
|
||||
// … every kind by hand … except resources …
|
||||
_ => None, // ← /recursos landed here → 404
|
||||
};
|
||||
"%,
|
||||
code_html = m%"<span class="k">let</span> kind = <span class="k">match</span> base {
|
||||
<span class="g">"blog"</span> => Some(<span class="g">"blog"</span>),
|
||||
<span class="g">"activities"</span> | <span class="g">"actividades"</span> => Some(<span class="g">"activities"</span>),
|
||||
<span class="c">// … every kind by hand … except resources …</span>
|
||||
_ => <span class="r">None</span>, <span class="c">// ← /recursos landed here → 404</span>
|
||||
};"%,
|
||||
},
|
||||
|
||||
fix = {
|
||||
code = m%"
|
||||
let kind = load_routes_config().routes.iter().find_map(|r| {
|
||||
if !r.enabled { return None; }
|
||||
let seg = r.path.trim_start_matches('/').split('/').next().unwrap_or("");
|
||||
(seg == base).then(|| r.content_type.clone()).flatten()
|
||||
});
|
||||
"%,
|
||||
code_html = m%"<span class="k">let</span> kind = load_routes_config().routes.iter().find_map(|r| {
|
||||
<span class="k">if</span> !r.enabled { <span class="k">return</span> <span class="r">None</span>; }
|
||||
<span class="k">let</span> seg = r.path.trim_start_matches(<span class="g">'/'</span>).split(<span class="g">'/'</span>).next().unwrap_or(<span class="g">""</span>);
|
||||
(seg == base).then(|| r.content_type.clone()).flatten()
|
||||
});"%,
|
||||
},
|
||||
|
||||
containment = [
|
||||
{ facet = "Hardcoded stub shadowing the registry",
|
||||
mechanism = "PAP + anti-patterns → grep-able",
|
||||
mechanism_html = "PAP + anti-patterns <span class='kicknum'>→ grep-able</span>" },
|
||||
{ facet = "Knowledge lost, rediscovered through pain",
|
||||
mechanism = "qa/howto → content-kind-howto",
|
||||
mechanism_html = "qa/howto <span class='kicknum'>→ content-kind-howto</span>" },
|
||||
{ facet = "Building on the invalid without knowing",
|
||||
mechanism = "review against invariants",
|
||||
mechanism_html = "review against invariants" },
|
||||
{ facet = "Assuming the unassumable, believing the unbelievable",
|
||||
mechanism = "declared state → 3 levels",
|
||||
mechanism_html = "declared state <span class='kicknum'>→ 3 levels</span>" },
|
||||
],
|
||||
|
||||
verdict_close = m%"That differential —<strong>an afternoon ↔ eight lines</strong>— isn't an anecdote: it's the ROI. <a href='https://ontoref.dev' target='_blank' rel='noopener'>ontoref</a>'s mechanisms aren't bureaucracy; they're the brake that turns “an afternoon hunting across two foreign repos” into “one query and an eight-line PR”."%,
|
||||
|
||||
skins = {
|
||||
detective = {
|
||||
h1_lead = "The case of the ",
|
||||
h1_red = "hardcoded drift",
|
||||
logline = "It was a quiet afternoon. Then <span class='mono'>/recursos</span> walked in and showed nothing. Nothing at all. And I, naive, thought it would be quick.",
|
||||
victim = "Victim: an afternoon",
|
||||
stamp = "8 LINES",
|
||||
foot_right = "Detective: night shift · Informant: the PAP",
|
||||
|
||||
exhibits = [
|
||||
"The body",
|
||||
"The line-up",
|
||||
"The weapon",
|
||||
"The confession",
|
||||
"The verdict",
|
||||
],
|
||||
headings = [
|
||||
"What the crime cost",
|
||||
"Eight alibis, eight dead ends",
|
||||
"The body was in <span class='mono'>render_content_or_grid</span>",
|
||||
"Eight lines back into the fold",
|
||||
"We didn't skip the beach by choosing wrong",
|
||||
],
|
||||
lead_cost = "I don't measure damage in feelings. I measure it in builds. This is what landed on the coroner's table:",
|
||||
lead_suspects = m%"With no howto, no review, nobody shouting “that's hardcoded!”, I interrogated every suspect. They all lied. They all cost a build."%,
|
||||
|
||||
alibis = [
|
||||
"I wasn't in sync.",
|
||||
"The body came back empty, but I'm not the 404 guy.",
|
||||
"I just slipped in as text. Innocent.",
|
||||
"With 6 fingerprints, how could you not doubt the binary?",
|
||||
"The dir didn't exist. Symlink for nothing.",
|
||||
"I'm the fallback. I don't even run.",
|
||||
"A <em>deny</em> from me is a 302, darling. A 404 isn't my job.",
|
||||
"That's Leptos. In htmx-ssr I don't even show up.",
|
||||
],
|
||||
suspect_tags = [
|
||||
"minor accomplice",
|
||||
"other crime",
|
||||
"solid alibi",
|
||||
"false lead",
|
||||
"false lead",
|
||||
"decoy",
|
||||
"302 alibi",
|
||||
"wrong jurisdiction",
|
||||
],
|
||||
|
||||
weapon_caption = "The weapon · a hardcoded match (pre-existing, anti-PAP)",
|
||||
weapon_after = "Route registered. Content-type enabled. The <span class='mono'>API</span> returned the items. The content, present. And still, 404 — because a stub decided the truth <em>against</em> the source of truth.",
|
||||
weapon_note = "Route registered. Content-type enabled. The API returned the items. The content, present. And still, 404 — because a stub decided the truth <em>against</em> the source of truth.",
|
||||
fix_caption = "PAP-compliant · adding a kind is just NCL again",
|
||||
fix_after = "<span class='mono'>/recursos</span>, <span class='mono'>/proyectos</span>, <span class='mono'>/dominios</span> — all of them, bilingual aliases included — resolved from the single source.",
|
||||
fix_note = "<code>/recursos</code>, <code>/proyectos</code>, <code>/dominios</code> — all of them, bilingual aliases included — resolved from the single source. **Adding a kind is just NCL again.**",
|
||||
|
||||
verdict_lead = "We chose right. We ended up where we said we'd avoid because <strong>the map had an unmarked drift</strong>: hardcoded, undeclared, undocumented, unreviewed. The four things ontoref instruments:",
|
||||
verdict_prose = "We didn't go to the beach because we chose badly: we went because **the map had an unmarked drift**. The four things [ontoref](https://ontoref.dev) instruments are the four that were missing:",
|
||||
|
||||
abandonment = m%"
|
||||
There's a point in every drift that no cost table records: the one where you stop feeling fear and start wanting to quit. Bugs become ghosts —they appear, vanish, come back with a different face—; everything stalls and you don't know why; there's no urgency left, only fatigue. *"It's too much. It can't be solved."* That's the real moment, the one not measured in builds — the one that kills projects in silence.
|
||||
|
||||
With [ontoref](https://ontoref.dev) that point shouldn't arrive. Not because there are no bugs, but because it doesn't leave you alone with them: the **dao** (ondaod) forces you to *name* the tension instead of collapsing or abandoning it; the **modes** hand you the next step when you no longer know what it is; the **ADRs** close what's already decided so you don't re-litigate it at two in the morning. The drift doesn't sink you because the map holds you up.
|
||||
"%,
|
||||
balance = "A case isn't judged only by what it costs. It's judged by what it leaves. From a **hope-less** low —eight false leads, six fingerprints, the whole afternoon— we found our bearings, and everything that was travelling on the same journey clicked into place at once:",
|
||||
balance_outro = m%"That's the credible depth: the afternoon of pain didn't end in a patch — it ended in a **declared system**. Ontology, modes and positioning weren't "added" — they were travelling together, and the case made them converge."%,
|
||||
closing = "A ten-line drift, unmarked, cost us an afternoon to escape — and was fixed in eight. That differential is the ROI. **[Ontoref](https://ontoref.dev) is the map's signposting.**",
|
||||
},
|
||||
|
||||
clinical = {
|
||||
h1_lead = "Pathology: the ",
|
||||
h1_red = "hardcoded drift",
|
||||
logline = "A quiet shift in the ER. Then <span class='mono'>/recursos</span> was admitted: conscious, with vitals, with routes… but showing nothing. And I, naive, thought it'd be a quick discharge.",
|
||||
victim = "Onset: an afternoon",
|
||||
stamp = "8 LINES",
|
||||
foot_right = "Physician: night duty · Note: the PAP",
|
||||
|
||||
exhibits = [
|
||||
"Clinical picture",
|
||||
"Differential diagnosis",
|
||||
"Etiology",
|
||||
"Treatment",
|
||||
"Prognosis and prophylaxis",
|
||||
],
|
||||
headings = [
|
||||
"Severity of the picture",
|
||||
"Eight diagnoses ruled out",
|
||||
"The pathogen lived in <span class='mono'>render_content_or_grid</span>",
|
||||
"Eight lines of therapy",
|
||||
"Not malpractice: an unmarked chart",
|
||||
],
|
||||
lead_cost = "Severity isn't measured in anguish. It's measured in builds. These were the patient's vitals:",
|
||||
lead_suspects = "With no protocol and no prior history, I ruled out plausible diagnoses one by one. All negative. Every test, a build.",
|
||||
|
||||
alibis = [
|
||||
"My data was stale, but I didn't cause the arrest.",
|
||||
"The picture came back empty; I'm not the focus.",
|
||||
"I only showed up as text on the panel. Benign.",
|
||||
"With 6 discordant markers, how not to suspect the binary?",
|
||||
"The organ didn't exist. A dead-end route.",
|
||||
"I'm the default branch. I don't even fire.",
|
||||
"Mine is a 302, not a 404. Different specialty.",
|
||||
"That's Leptos. In htmx-ssr I'm not even in the chart.",
|
||||
],
|
||||
suspect_tags = [
|
||||
"minor comorbidity",
|
||||
"separate finding",
|
||||
"negative panel",
|
||||
"false positive",
|
||||
"false positive",
|
||||
"benign incidental",
|
||||
"other specialty",
|
||||
"out of service",
|
||||
],
|
||||
|
||||
weapon_caption = "Etiology · a hardcoded match (pre-existing, anti-PAP)",
|
||||
weapon_after = "Route registered. Content-type enabled. The <span class='mono'>API</span> returned the items. The content, present. And still, 404 — because a stub decided the truth <em>against</em> the source of truth.",
|
||||
fix_caption = "PAP-compliant · adding a kind is just NCL again",
|
||||
fix_after = "<span class='mono'>/recursos</span>, <span class='mono'>/proyectos</span>, <span class='mono'>/dominios</span> — all of them, bilingual aliases included — resolved from the single source.",
|
||||
|
||||
verdict_lead = "No malpractice. We acted where we said we'd avoid because <strong>the chart had an unrecorded lesion</strong>: hardcoded, undeclared, undocumented, unreviewed. The four things ontoref instruments:",
|
||||
# GAP: no English long-form clinical article exists. Falls back to the informe's
|
||||
# lead rather than invent or translate prose.
|
||||
verdict_prose = "No malpractice. We acted where we said we'd avoid because <strong>the chart had an unrecorded lesion</strong>: hardcoded, undeclared, undocumented, unreviewed. The four things ontoref instruments:",
|
||||
},
|
||||
},
|
||||
|
||||
related_proofs = ["proof-anti-pap-drift"],
|
||||
related_modes = ["generate-expediente"],
|
||||
tags = ["case-file", "anti-pap", "drift", "dogfooding", "ontoref", "developer"],
|
||||
}
|
||||
|
|
@ -0,0 +1,539 @@
|
|||
---
|
||||
id: "espejo-que-revertia-su-trabajo"
|
||||
title: "Case 69/58: the mirror that reverted its own work"
|
||||
slug: "the-mirror-that-reverted-its-own-work"
|
||||
subtitle: "The tool said 69. The disk had 58. Nobody compared the two — for four weeks"
|
||||
excerpt: "Eleven ADRs written and never published. Zero content reaching production. 214 lines of work one command away from vanishing. And not a single error in the logs: the tool reported success and nobody read the disk. The series asks 'with ontoref, would it not have gone like this?' — and this is the case that answers no, because it happened inside ontoref with the rule already accepted."
|
||||
author: "Jesús Pérez"
|
||||
date: "2026-07-12"
|
||||
published: true
|
||||
featured: true
|
||||
category: "cases"
|
||||
tags: ["case-file", "anti-pap", "deploy", "witness", "dogfooding", "ontoref", "developer"]
|
||||
thumbnail: "/images/expedientes/deriva-header.webp"
|
||||
image_url: "/images/expedientes/deriva-header.webp"
|
||||
sort_order: 3
|
||||
---
|
||||
|
||||
<section class="exp">
|
||||
|
||||
<style>
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-ledger h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-ledger.cost h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-ledger.yield h4{background:rgba(20,120,60,.12)}
|
||||
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>
|
||||
|
||||
<a class="exp-hero" href="/images/expedientes/en/expedientes.html#espejo-que-revertia-su-trabajo" target="_blank" rel="noopener" title="Open the full clinical history"><img src="/images/expedientes/deriva-header.webp" alt="Case 69/58: the mirror that reverted its own work" width="1200" height="675" loading="lazy"></a>
|
||||
|
||||
<p style="margin:0 0 1.4rem"><a class="exp-btn" href="/images/expedientes/en/expedientes.html#espejo-que-revertia-su-trabajo" target="_blank" rel="noopener">🩺 Show the full clinical history →</a></p>
|
||||
|
||||
<p class="exp-kick">Clinical history · Software Pathology</p>
|
||||
|
||||
<p class="exp-log">The patient had no symptoms. It published without errors, the tool confirmed success, the logs came back green. It had been publishing into the void for four weeks.</p>
|
||||
|
||||
<div class="exp-meta"><span>History No. <b>69/58</b></span><span>Diagnosis: ANTI-PAP · THE REPORTER DECIDED</span><span>Status: SOLVED</span></div>
|
||||
|
||||
<div class="exp-gloss">
|
||||
<dl>
|
||||
<dt>PAP</dt><dd>Project's Architecture Principles — the rules that hold the architecture up: the single source of truth all code must respect.</dd>
|
||||
<dt>anti-PAP</dt><dd>Code written against those rules. Here: a mirror that decided by direction instead of by ownership.</dd>
|
||||
<dt>the check decides, never the reporter</dt><dd>ADR-066, accepted 4 Jul 2026: wherever a mechanical check exists, the check decides; the word of whoever reports may refuse to be contradicted, but may never impose itself. This case file is what happens on the one surface where that rule was not applied.</dd>
|
||||
<dt>witness</dt><dd>An independently verifiable record that something is what it claims to be. A backup with no witness is not a backup: it is a copy you trust.</dd>
|
||||
</dl>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">The protocol to declare, version and verify this → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">The double ledger — what it cost, and what it left</p>
|
||||
|
||||
<div class="exp-balance">
|
||||
<div class="exp-ledger cost">
|
||||
<h4>What the crime cost</h4>
|
||||
<ul>
|
||||
<li>What the tool reported <b>"index.json with 69 posts"</b></li>
|
||||
<li>What was on the disk <b>58</b></li>
|
||||
<li>ADRs written and never published <b>11 (059→069)</b></li>
|
||||
<li>Window of invisibility <b>13 Jun → 11 Jul (the ADRs' own dates)</b></li>
|
||||
<li>Content that reached production in that time <b>0</b></li>
|
||||
<li>Files the site repo had tracked <b>1 (README.md)</b></li>
|
||||
<li>git_sha stamped on EVERY deploy pack <b>5619a40 (always the same)</b></li>
|
||||
<li>Live lines of work with not one witness <b>214</b></li>
|
||||
<li>Commands away from erasing them <b>1 (<span class='mono'>just templates</span>)</b></li>
|
||||
<li>Size of the final fix <b>60 <i>lines</i></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="exp-ledger yield">
|
||||
<h4>What the case left</h4>
|
||||
<ul>
|
||||
<li>Decision <b>ADR-070 (accepted)</b></li>
|
||||
<li>Gates <b>adr-check · templates-check · posts-check</b></li>
|
||||
<li>Migration <b>0044 (anchored paths)</b></li>
|
||||
<li>The slot that was missing <b>site/templates-overlay/</b></li>
|
||||
<li>Constraints curated on anchoring <b>29 (88✓ → 117✓)</b></li>
|
||||
<li>Ontology <b>the converse in enforcement-vs-emergence</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">The point of abandonment — what the table doesn't show</p>
|
||||
|
||||
This case's point of abandonment did not arrive with an error, but with a sentence: “I've gone crate by crate through a complex codegen system and I'm well past the reasonable point to keep going blind.” That is what a machine says when it runs out of map and keeps walking. And the answer that unblocked it was not technical: it was someone drawing the hierarchy of levels —rustelo → website-htmx-rustelo → outreach/site— that existed in one head and in no file. That is the whole diagnosis, spoken before it was had: whatever is not declared, someone is holding up with their memory. And memory tires.
|
||||
|
||||
<p class="exp-ex">Differential diagnosis — what was ruled out</p>
|
||||
|
||||
<table class="exp-susp"><tbody>
|
||||
<tr><td><b>The 6 orphans in <span class='mono'>site/r</span></b></td><td><em>“Yes, I'm dead content still being served. But I don't stop anything from being published.”</em></td><td>symptom, not cause</td></tr>
|
||||
<tr><td><b><span class='mono'>gen-adr-pages</span> broken</b></td><td><em>“I work perfectly. I generate all 69 ADRs whenever anyone calls me.”</em></td><td>innocent — nobody was calling it</td></tr>
|
||||
<tr><td><b><span class='mono'>content_processor</span> doesn't process case files</b></td><td><em>“I do process them. What happened is that you read me with a <span class='mono'>head -20</span> and it cut my output off.”</em></td><td>solid alibi (the error was the investigator's)</td></tr>
|
||||
<tr><td><b>The index isn't regenerated</b></td><td><em>“I regenerate every time. With 69. What happens afterwards is no business of mine.”</em></td><td>innocent — and the key to the case</td></tr>
|
||||
<tr><td><b>The graph generator</b></td><td><em>“I export what's in the content tree. If you file a contract in among the data, I blow up. And rightly so.”</em></td><td>innocent — it was right</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="exp-ex">Etiology — the cause — The mirror that decided by direction, not by ownership</p>
|
||||
|
||||
<pre># The `content` recipe, exactly as it stood:
|
||||
content_processor # writes the indexes into site/r
|
||||
rsync -a "site/public/r/" "site/r/" # ← the weapon
|
||||
|
||||
# Two trees. Each one the OWNER of different files:
|
||||
# site/r ← content_processor writes here (the server reads it)
|
||||
# site/public/r ← the nu generators write here (and this is what the deploy SHIPS)
|
||||
#
|
||||
# The rsync ran one way, and in the wrong way:
|
||||
# · it trampled the freshly generated indexes with fossils from the deploy tree
|
||||
# · and it NEVER carried the new content to the tree that travels to production
|
||||
#
|
||||
# rsync -a without --update does not honour "the destination is newer": it compares size
|
||||
# and mtime, and if they differ, IT COPIES THE SOURCE. A mirror whose source has fossilised
|
||||
# is not a no-op: it is a machine for resurrecting old state.
|
||||
#
|
||||
# The tool printed "Generated index.json with 69 posts".
|
||||
# The disk kept 58.
|
||||
# Nobody compared the two.</pre>
|
||||
|
||||
just — the content recipe
|
||||
|
||||
<p class="exp-ex">Treatment — The mirror is typed by ownership, and the check reads the disk</p>
|
||||
|
||||
<pre>content_processor
|
||||
# Content indexes → deploy tree. --delete: without it, a renamed slug
|
||||
# survives as a live URL IN PRODUCTION.
|
||||
rsync -a --delete \
|
||||
--exclude about.json --exclude adr-map.json \
|
||||
--exclude taglines.json --exclude content_graph.json \
|
||||
"site/r/" "site/public/r/"
|
||||
# The four nu artifacts → served tree. They are excluded from the outbound leg, or the
|
||||
# stale copies in site/r would clobber the freshly generated originals.
|
||||
for f in about.json adr-map.json taglines.json content_graph.json; do
|
||||
cp -f "site/public/r/$f" "site/r/$f"
|
||||
done
|
||||
|
||||
# And three gates that ASSERT AGAINST THE DISK, never against the tool's stdout:
|
||||
# adr-check the spine holds no ADR the site does not publish
|
||||
# templates-check the assembled tree reproduces exactly from its declared source
|
||||
# posts-check linkify + graph coverage
|
||||
#
|
||||
# All three can be run WHILE the damage is still hypothetical.
|
||||
# A check you can only run after the destructive step is not a gate:
|
||||
# it is an autopsy.</pre>
|
||||
|
||||
just — the content recipe
|
||||
|
||||
<p class="exp-ex">Prognosis</p>
|
||||
|
||||
The series asks one question: with ontoref, would it not have gone like this? Almost always the answer is yes, and that is why there is a series. This case file is the one that answers NO — and that is why it is worth the most. This happened INSIDE ontoref, with the ontology loaded, the gates written and ADR-066 —“the check decides, never the reporter”— accepted a week earlier and proven with an eight-path falsification run. The rule that names this exact pathology already existed, while the pipeline that publishes ontoref to the world went on letting the reporter decide. The protocol did not fail: outreach was the blind spot, the one surface the protocol did not govern. Every mechanism that would have caught each one of these failures already existed and was accepted in this very repo. Not one of them was pointed here. And that is the useful answer, the one a comfortable case never gives: having the mechanism is not enough — you have to point it at the surface that hurts. An enforcement you cannot trust to fail is indistinguishable from having no enforcement, and it is more dangerous, because it gets believed.
|
||||
|
||||
<table class="exp-cont"><tbody>
|
||||
<tr><td>The mirror ran by direction, not by ownership of each file</td><td>declared state — ADR-070: every tree declares its OWNER. A whole-tree sync running one way, between two trees that own different files, is necessarily false in one of the two directions.</td></tr>
|
||||
<tr><td>The tool reported success and nobody checked the disk</td><td>review against invariants — ADR-066 (the check decides, never the reporter), applied at last to the surface that publishes the protocol: the gates read the disk, not the stdout.</td></tr>
|
||||
<tr><td>A generator existed and no recipe ever called it (11 invisible ADRs)</td><td>declared state — ADR-070: every tree declares its REPRODUCTION, and that recipe is in the chain. A generator outside the dependency chain is a private ritual, not a build.</td></tr>
|
||||
<tr><td>214 lines lived in the one tree the build wipes</td><td>declared state — ADR-070: every tree declares its WITNESS (reproducible, or tracked). The site level had no overlay slot: the work had nowhere legal to live.</td></tr>
|
||||
<tr><td>A contract filed among the data it types (just graph dead)</td><td>PAP + anti-pattern — `contract-in-the-instance-tree`: site/content/ is an instance tree; a schema in there is a category error, and the generator that blows up is right.</td></tr>
|
||||
<tr><td>A check meant one thing or another depending on where you launched it from</td><td>review against invariants — migration 0044: paths and cwd anchored to the project root. A `must_be_empty` over a path that does not resolve reports SATISFIED: a broken path doesn't give you a red — it gives you the benefit of the doubt.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<!--
|
||||
_glossary.html — Section glossary component embedded in the /expedientes hub.
|
||||
Self-contained: inline <style> + <script>, no external deps, CSP-safe, standalone.
|
||||
Data-driven: edit the TERMS array below ({id, term, def, aliases?}) to add entries.
|
||||
Prose links jump to a term with <a href="#gl-pap">…</a> (anchor = "gl-" + id);
|
||||
landing on #gl-<id> scrolls the term into view and briefly highlights it.
|
||||
Search box live-filters by term text, definition, and aliases as you type.
|
||||
-->
|
||||
<section class="glz" aria-label="Glosario de la sección Expedientes">
|
||||
<header class="glz__head">
|
||||
<h2 class="glz__title">Glosario</h2>
|
||||
<div class="glz__search">
|
||||
<input
|
||||
type="search"
|
||||
id="glz-q"
|
||||
class="glz__input"
|
||||
placeholder="Filtrar términos…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filtrar términos del glosario"
|
||||
/>
|
||||
<span class="glz__count" id="glz-count" aria-live="polite"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl class="glz__list" id="glz-list"></dl>
|
||||
<p class="glz__empty" id="glz-empty" hidden>Sin coincidencias.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.glz {
|
||||
--glz-bg: #ffffff;
|
||||
--glz-fg: #1a1a1a;
|
||||
--glz-muted: #6b7280;
|
||||
--glz-border: #e5e7eb;
|
||||
--glz-accent: #2563eb;
|
||||
--glz-mark: #fef08a;
|
||||
--glz-chip: #f3f4f6;
|
||||
--glz-flash: rgba(37, 99, 235, 0.16);
|
||||
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
color: var(--glz-fg);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glz {
|
||||
--glz-bg: #0d1117;
|
||||
--glz-fg: #e6edf3;
|
||||
--glz-muted: #8b949e;
|
||||
--glz-border: #21262d;
|
||||
--glz-accent: #58a6ff;
|
||||
--glz-mark: #9a7b00;
|
||||
--glz-chip: #161b22;
|
||||
--glz-flash: rgba(88, 166, 255, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
.glz__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.glz__title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.glz__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 14rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.glz__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
color: var(--glz-fg);
|
||||
background: var(--glz-bg);
|
||||
border: 1px solid var(--glz-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.glz__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--glz-accent);
|
||||
box-shadow: 0 0 0 3px var(--glz-flash);
|
||||
}
|
||||
.glz__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--glz-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glz__list {
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--glz-border);
|
||||
}
|
||||
.glz__row {
|
||||
padding: 0.7rem 0.15rem;
|
||||
border-bottom: 1px solid var(--glz-border);
|
||||
scroll-margin-top: 5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.glz__row.is-flash {
|
||||
background: var(--glz-flash);
|
||||
}
|
||||
.glz__term {
|
||||
margin: 0;
|
||||
font-weight: 640;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
.glz__aliases {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--glz-muted);
|
||||
}
|
||||
.glz__alias {
|
||||
display: inline-block;
|
||||
padding: 0.02rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
background: var(--glz-chip);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.glz__def {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--glz-fg);
|
||||
}
|
||||
.glz__def mark {
|
||||
background: var(--glz-mark);
|
||||
color: inherit;
|
||||
padding: 0 0.1em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glz__empty {
|
||||
padding: 1rem 0.15rem;
|
||||
color: var(--glz-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
code, .glz__code {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--glz-chip);
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TERMS = [
|
||||
{
|
||||
id: "pap",
|
||||
term: "PAP",
|
||||
aliases: ["Project's Architecture Principles"],
|
||||
def: "Los principios, reglas y patrones de arquitectura del proyecto. Una decisión “conforme a PAP” respeta las invariantes establecidas."
|
||||
},
|
||||
{
|
||||
id: "anti-pap",
|
||||
term: "anti-PAP",
|
||||
aliases: ["contra PAP"],
|
||||
def: "Un enfoque que va completamente en contra de la arquitectura establecida del proyecto; se rechaza aunque “funcione”."
|
||||
},
|
||||
{
|
||||
id: "registry",
|
||||
term: "registry",
|
||||
aliases: ["registro"],
|
||||
def: "Tabla central que mapea cada tipo de contenido a su handler y metadatos, de modo que las rutas se resuelven de forma declarativa."
|
||||
},
|
||||
{
|
||||
id: "contentkind",
|
||||
term: "ContentKind",
|
||||
aliases: ["variant", "enum"],
|
||||
def: "El tipo (enum) que enumera las clases de contenido que el sistema sabe renderizar; cada variante corresponde a un kind."
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
term: "kind",
|
||||
aliases: ["tipo de contenido"],
|
||||
def: "Discriminante de un contenido: identifica a qué ContentKind pertenece y, por tanto, qué plantilla y ruta le aplican."
|
||||
},
|
||||
{
|
||||
id: "htmx-ssr",
|
||||
term: "htmx-ssr",
|
||||
aliases: ["server-side rendering", "SSR"],
|
||||
def: "Renderizado en servidor con fragmentos HTML entregados a htmx: el servidor devuelve HTML listo y el cliente solo intercambia trozos, sin framework pesado."
|
||||
},
|
||||
{
|
||||
id: "routes-ncl",
|
||||
term: "routes.ncl",
|
||||
aliases: ["routes", "Nickel routes"],
|
||||
def: "Archivo Nickel declarativo que define las rutas del sitio y las asocia a cada kind; fuente única de verdad para el enrutado."
|
||||
},
|
||||
{
|
||||
id: "deriva",
|
||||
term: "deriva",
|
||||
aliases: ["drift"],
|
||||
def: "Divergencia silenciosa entre dos representaciones que deberían coincidir (p. ej. código y ontología, o assets y su copia materializada). Se detecta con comprobaciones de <code>check</code>."
|
||||
},
|
||||
{
|
||||
id: "dogfooding",
|
||||
term: "dogfooding",
|
||||
aliases: ["comerse la propia comida"],
|
||||
def: "Usar el propio proyecto para construir y documentar el proyecto: el hub de Expedientes se publica con el mismo motor htmx-ssr que describe."
|
||||
},
|
||||
{
|
||||
id: "expediente",
|
||||
term: "expediente",
|
||||
aliases: ["dossier", "caso"],
|
||||
def: "Unidad documental del hub: un caso autocontenido que recoge contexto, decisiones y evidencia sobre una pieza del sistema."
|
||||
}
|
||||
];
|
||||
|
||||
var norm = function (s) {
|
||||
return (s || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "");
|
||||
};
|
||||
|
||||
var esc = function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[c];
|
||||
});
|
||||
};
|
||||
|
||||
var list = document.getElementById("glz-list");
|
||||
var input = document.getElementById("glz-q");
|
||||
var count = document.getElementById("glz-count");
|
||||
var empty = document.getElementById("glz-empty");
|
||||
if (!list || !input) return;
|
||||
|
||||
// Build rows once; filtering only toggles visibility + highlight.
|
||||
var rows = TERMS.map(function (t) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "glz__row";
|
||||
row.id = "gl-" + t.id;
|
||||
|
||||
var dt = document.createElement("dt");
|
||||
dt.className = "glz__term";
|
||||
dt.innerHTML = esc(t.term);
|
||||
if (t.aliases && t.aliases.length) {
|
||||
var al = document.createElement("span");
|
||||
al.className = "glz__aliases";
|
||||
al.innerHTML = t.aliases
|
||||
.map(function (a) {
|
||||
return '<span class="glz__alias">' + esc(a) + "</span>";
|
||||
})
|
||||
.join("");
|
||||
dt.appendChild(al);
|
||||
}
|
||||
|
||||
var dd = document.createElement("dd");
|
||||
dd.className = "glz__def";
|
||||
dd.dataset.def = t.def; // def may carry inline markup (e.g. <code>)
|
||||
|
||||
row.appendChild(dt);
|
||||
row.appendChild(dd);
|
||||
list.appendChild(row);
|
||||
|
||||
return {
|
||||
el: row,
|
||||
dd: dd,
|
||||
term: t.term,
|
||||
rawDef: t.def,
|
||||
hay: norm(t.term + " " + t.def + " " + (t.aliases || []).join(" "))
|
||||
};
|
||||
});
|
||||
|
||||
// Highlight the query inside definitions without breaking inline markup:
|
||||
// split the def on tag boundaries and only mark text segments.
|
||||
var highlight = function (html, q) {
|
||||
if (!q) return html;
|
||||
var qn = norm(q);
|
||||
return html.replace(/(<[^>]+>)|([^<]+)/g, function (_, tag, text) {
|
||||
if (tag) return tag;
|
||||
var out = "";
|
||||
var i = 0;
|
||||
var tn = norm(text);
|
||||
while (i < text.length) {
|
||||
var at = tn.indexOf(qn, i);
|
||||
if (at === -1) {
|
||||
out += esc(text.slice(i));
|
||||
break;
|
||||
}
|
||||
out += esc(text.slice(i, at));
|
||||
out += "<mark>" + esc(text.slice(at, at + q.length)) + "</mark>";
|
||||
i = at + q.length;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
};
|
||||
|
||||
var render = function () {
|
||||
var q = input.value.trim();
|
||||
var qn = norm(q);
|
||||
var shown = 0;
|
||||
rows.forEach(function (r) {
|
||||
var match = !qn || r.hay.indexOf(qn) !== -1;
|
||||
r.el.hidden = !match;
|
||||
if (match) {
|
||||
shown++;
|
||||
r.dd.innerHTML = highlight(r.rawDef, q);
|
||||
}
|
||||
});
|
||||
count.textContent =
|
||||
shown === TERMS.length ? TERMS.length + " términos" : shown + "/" + TERMS.length;
|
||||
empty.hidden = shown !== 0;
|
||||
};
|
||||
|
||||
var flash = function (row) {
|
||||
if (!row) return;
|
||||
row.classList.remove("is-flash");
|
||||
// reflow so the class re-adds even on repeat clicks to the same anchor
|
||||
void row.offsetWidth;
|
||||
row.classList.add("is-flash");
|
||||
window.setTimeout(function () {
|
||||
row.classList.remove("is-flash");
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
var jumpToHash = function () {
|
||||
var h = window.location.hash;
|
||||
if (!h || h.indexOf("#gl-") !== 0) return;
|
||||
var row = document.getElementById(h.slice(1));
|
||||
if (!row) return;
|
||||
if (row.hidden) {
|
||||
// clear an active filter so the linked term is visible
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
flash(row);
|
||||
};
|
||||
|
||||
input.addEventListener("input", render);
|
||||
window.addEventListener("hashchange", jumpToHash);
|
||||
|
||||
render();
|
||||
jumpToHash();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</section>
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
# Typed source for the ENGLISH expediente "espejo-que-revertia-su-trabajo".
|
||||
#
|
||||
# Faithful translation of the Spanish typed source (es/casos/espejo-que-revertia-su-trabajo.ncl).
|
||||
# The id stays language-neutral; only the slug is localised. Two surfaces project from this
|
||||
# file and neither may be hand-edited:
|
||||
# ../../en/cases/espejo-que-revertia-su-trabajo.md the article (skin = clinical)
|
||||
# ../../../../public/images/expedientes/en/expedientes.html the informe (both skins)
|
||||
let s = import "../../../../schemas/expediente.ncl" in
|
||||
|
||||
s.make_expediente {
|
||||
id = "espejo-que-revertia-su-trabajo",
|
||||
title = "Case 69/58: the mirror that reverted its own work",
|
||||
slug = "the-mirror-that-reverted-its-own-work",
|
||||
case_no = "69/58",
|
||||
classification = "ANTI-PAP · THE REPORTER DECIDED",
|
||||
status = "SOLVED",
|
||||
skin = 'clinical,
|
||||
|
||||
tab = "69/58 · the mirror",
|
||||
print_href = "", # no printable version of its own: the informe is its only sheet
|
||||
|
||||
glossary = [
|
||||
{ term = "PAP",
|
||||
def = "Project's Architecture Principles — the rules that hold the architecture up: the single source of truth all code must respect.",
|
||||
def_html = "<b>Project's Architecture Principles</b> — the rules that hold the architecture up: the single source of truth all code must respect." },
|
||||
{ term = "anti-PAP",
|
||||
def = "Code written against those rules. Here: a mirror that decided by direction instead of by ownership.",
|
||||
def_html = "Code written <em>against</em> those rules. Here: a mirror that decided by <em>direction</em> instead of by <em>ownership</em>." },
|
||||
{ term = "the check decides, never the reporter",
|
||||
def = "ADR-066, accepted 4 Jul 2026: wherever a mechanical check exists, the check decides; the word of whoever reports may refuse to be contradicted, but may never impose itself. This case file is what happens on the one surface where that rule was not applied.",
|
||||
def_html = "<b>ADR-066</b>, accepted 4 Jul 2026: wherever a mechanical check exists, the check decides; the word of whoever reports may refuse to be contradicted, but may never impose itself. This case file is what happens on the one surface where that rule was <em>not</em> applied." },
|
||||
{ term = "witness",
|
||||
def = "An independently verifiable record that something is what it claims to be. A backup with no witness is not a backup: it is a copy you trust.",
|
||||
def_html = "An <em>independently verifiable</em> record that something is what it claims to be. A backup with no witness is not a backup: it is a copy you trust." },
|
||||
],
|
||||
|
||||
cost_caption = "Measurable cost · session 2026-07-11 → 07-12",
|
||||
|
||||
# RULE: every number traces to a log or an artifact. Nothing here is recalled.
|
||||
cost = [
|
||||
{ label = "What the tool reported", value = "\"index.json with 69 posts\"" },
|
||||
{ label = "What was on the disk", value = "58", emphasis = true },
|
||||
{ label = "ADRs written and never published", value = "11 (059→069)", emphasis = true },
|
||||
{ label = "Window of invisibility", value = "13 Jun → 11 Jul (the ADRs' own dates)" },
|
||||
{ label = "Content that reached production in that time", value = "0", emphasis = true },
|
||||
{ label = "Files the site repo had tracked", value = "1 (README.md)" },
|
||||
{ label = "git_sha stamped on EVERY deploy pack", value = "5619a40 (always the same)" },
|
||||
{ label = "Live lines of work with not one witness", value = "214" },
|
||||
{ label = "Commands away from erasing them", value = "1 (<span class='mono'>just templates</span>)", emphasis = true },
|
||||
{ label = "Size of the final fix", value = "60 <i>lines</i>", emphasis = true },
|
||||
],
|
||||
|
||||
yielded = [
|
||||
{ label = "Decision", value = "ADR-070 (accepted)" },
|
||||
{ label = "Gates", value = "adr-check · templates-check · posts-check" },
|
||||
{ label = "Migration", value = "0044 (anchored paths)" },
|
||||
{ label = "The slot that was missing", value = "site/templates-overlay/" },
|
||||
{ label = "Constraints curated on anchoring", value = "29 (88✓ → 117✓)" },
|
||||
{ label = "Ontology", value = "the converse in enforcement-vs-emergence" },
|
||||
],
|
||||
|
||||
# Neutral: the technical name of each suspect. What each one SAYS, and how it is
|
||||
# written off, is the genre's job — it lives in the skins, index-parallel to this list.
|
||||
suspects = [
|
||||
{ name = "The 6 orphans in <span class='mono'>site/r</span>" },
|
||||
{ name = "<span class='mono'>gen-adr-pages</span> broken" },
|
||||
{ name = "<span class='mono'>content_processor</span> doesn't process case files" },
|
||||
{ name = "The index isn't regenerated" },
|
||||
{ name = "The graph generator" },
|
||||
],
|
||||
|
||||
weapon = {
|
||||
code = m%"
|
||||
# The `content` recipe, exactly as it stood:
|
||||
content_processor # writes the indexes into site/r
|
||||
rsync -a "site/public/r/" "site/r/" # ← the weapon
|
||||
|
||||
# Two trees. Each one the OWNER of different files:
|
||||
# site/r ← content_processor writes here (the server reads it)
|
||||
# site/public/r ← the nu generators write here (and this is what the deploy SHIPS)
|
||||
#
|
||||
# The rsync ran one way, and in the wrong way:
|
||||
# · it trampled the freshly generated indexes with fossils from the deploy tree
|
||||
# · and it NEVER carried the new content to the tree that travels to production
|
||||
#
|
||||
# rsync -a without --update does not honour "the destination is newer": it compares size
|
||||
# and mtime, and if they differ, IT COPIES THE SOURCE. A mirror whose source has fossilised
|
||||
# is not a no-op: it is a machine for resurrecting old state.
|
||||
#
|
||||
# The tool printed "Generated index.json with 69 posts".
|
||||
# The disk kept 58.
|
||||
# Nobody compared the two.
|
||||
"%,
|
||||
code_html = m%"
|
||||
<span class="c"># The `content` recipe, exactly as it stood:</span>
|
||||
content_processor <span class="c"># writes the indexes into site/r</span>
|
||||
<span class="k">rsync</span> -a <span class="g">"site/public/r/"</span> <span class="g">"site/r/"</span> <span class="r">← the weapon</span>
|
||||
|
||||
<span class="c"># Two trees. Each one the OWNER of different files:</span>
|
||||
<span class="c"># site/r ← content_processor writes here (the server reads it)</span>
|
||||
<span class="c"># site/public/r ← the nu generators write here (and this is what the deploy SHIPS)</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># The rsync ran one way, and in the wrong way:</span>
|
||||
<span class="c"># · it trampled the freshly generated indexes with fossils from the deploy tree</span>
|
||||
<span class="c"># · and it NEVER carried the new content to the tree that travels to production</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># rsync -a without --update does not honour "the destination is newer": it compares size</span>
|
||||
<span class="c"># and mtime, and if they differ, IT COPIES THE SOURCE. A mirror whose source has fossilised</span>
|
||||
<span class="c"># is not a no-op: it is a machine for resurrecting old state.</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># The tool printed "Generated index.json with 69 posts".</span>
|
||||
<span class="c"># The disk kept 58.</span>
|
||||
<span class="r"># Nobody compared the two.</span>
|
||||
"%,
|
||||
},
|
||||
|
||||
fix = {
|
||||
code = m%"
|
||||
content_processor
|
||||
# Content indexes → deploy tree. --delete: without it, a renamed slug
|
||||
# survives as a live URL IN PRODUCTION.
|
||||
rsync -a --delete \
|
||||
--exclude about.json --exclude adr-map.json \
|
||||
--exclude taglines.json --exclude content_graph.json \
|
||||
"site/r/" "site/public/r/"
|
||||
# The four nu artifacts → served tree. They are excluded from the outbound leg, or the
|
||||
# stale copies in site/r would clobber the freshly generated originals.
|
||||
for f in about.json adr-map.json taglines.json content_graph.json; do
|
||||
cp -f "site/public/r/$f" "site/r/$f"
|
||||
done
|
||||
|
||||
# And three gates that ASSERT AGAINST THE DISK, never against the tool's stdout:
|
||||
# adr-check the spine holds no ADR the site does not publish
|
||||
# templates-check the assembled tree reproduces exactly from its declared source
|
||||
# posts-check linkify + graph coverage
|
||||
#
|
||||
# All three can be run WHILE the damage is still hypothetical.
|
||||
# A check you can only run after the destructive step is not a gate:
|
||||
# it is an autopsy.
|
||||
"%,
|
||||
code_html = m%"
|
||||
content_processor
|
||||
<span class="c"># Content indexes → deploy tree. --delete: without it, a renamed slug</span>
|
||||
<span class="c"># survives as a live URL IN PRODUCTION.</span>
|
||||
<span class="k">rsync</span> -a --delete \
|
||||
--exclude about.json --exclude adr-map.json \
|
||||
--exclude taglines.json --exclude content_graph.json \
|
||||
<span class="g">"site/r/"</span> <span class="g">"site/public/r/"</span>
|
||||
<span class="c"># The four nu artifacts → served tree. They are excluded from the outbound leg, or the</span>
|
||||
<span class="c"># stale copies in site/r would clobber the freshly generated originals.</span>
|
||||
<span class="k">for</span> f <span class="k">in</span> about.json adr-map.json taglines.json content_graph.json; <span class="k">do</span>
|
||||
cp -f <span class="g">"site/public/r/$f"</span> <span class="g">"site/r/$f"</span>
|
||||
<span class="k">done</span>
|
||||
|
||||
<span class="c"># And three gates that ASSERT AGAINST THE DISK, never against the tool's stdout:</span>
|
||||
<span class="c"># adr-check the spine holds no ADR the site does not publish</span>
|
||||
<span class="c"># templates-check the assembled tree reproduces exactly from its declared source</span>
|
||||
<span class="c"># posts-check linkify + graph coverage</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># All three can be run WHILE the damage is still hypothetical.</span>
|
||||
<span class="c"># A check you can only run after the destructive step is not a gate:</span>
|
||||
<span class="r"># it is an autopsy.</span>
|
||||
"%,
|
||||
},
|
||||
|
||||
containment = [
|
||||
{ facet = "The mirror ran by direction, not by ownership of each file",
|
||||
mechanism = "declared state — ADR-070: every tree declares its OWNER. A whole-tree sync running one way, between two trees that own different files, is necessarily false in one of the two directions.",
|
||||
mechanism_html = "declared state <span class='kicknum'>→ ADR-070: every tree declares its OWNER</span>" },
|
||||
{ facet = "The tool reported success and nobody checked the disk",
|
||||
mechanism = "review against invariants — ADR-066 (the check decides, never the reporter), applied at last to the surface that publishes the protocol: the gates read the disk, not the stdout.",
|
||||
mechanism_html = "review against invariants <span class='kicknum'>→ ADR-066: the check decides</span>" },
|
||||
{ facet = "A generator existed and no recipe ever called it (11 invisible ADRs)",
|
||||
mechanism = "declared state — ADR-070: every tree declares its REPRODUCTION, and that recipe is in the chain. A generator outside the dependency chain is a private ritual, not a build.",
|
||||
mechanism_html = "declared state <span class='kicknum'>→ ADR-070: the REPRODUCTION, in the chain</span>" },
|
||||
{ facet = "214 lines lived in the one tree the build wipes",
|
||||
mechanism = "declared state — ADR-070: every tree declares its WITNESS (reproducible, or tracked). The site level had no overlay slot: the work had nowhere legal to live.",
|
||||
mechanism_html = "declared state <span class='kicknum'>→ ADR-070: every tree declares its WITNESS</span>" },
|
||||
{ facet = "A contract filed among the data it types (just graph dead)",
|
||||
mechanism = "PAP + anti-pattern — `contract-in-the-instance-tree`: site/content/ is an instance tree; a schema in there is a category error, and the generator that blows up is right.",
|
||||
mechanism_html = "PAP + anti-pattern <span class='kicknum'>→ contract-in-the-instance-tree</span>" },
|
||||
{ facet = "A check meant one thing or another depending on where you launched it from",
|
||||
mechanism = "review against invariants — migration 0044: paths and cwd anchored to the project root. A `must_be_empty` over a path that does not resolve reports SATISFIED: a broken path doesn't give you a red — it gives you the benefit of the doubt.",
|
||||
mechanism_html = "review against invariants <span class='kicknum'>→ migration 0044: anchored paths</span>" },
|
||||
],
|
||||
|
||||
# Neutral: both genres sign the same bottom line. That is the whole point of the series,
|
||||
# and the one sentence that must not vary with who is telling it.
|
||||
verdict_close = "The other case files answer yes: with ontoref it wouldn't have gone like this. This one answers <strong>no</strong> — and that is why it is worth more than all of them. Every mechanism that would have caught each one of these failures already existed, accepted, in this very repo. Not one of them was <em>pointed here</em>. <a href='https://ontoref.dev' target='_blank' rel='noopener'>Ontoref</a> did not fail: what failed was believing that having the mechanism is the same as having it pointed at the surface that hurts.",
|
||||
|
||||
skins = {
|
||||
clinical = {
|
||||
kicker = "Clinical history · Software Pathology",
|
||||
h1_lead = "Pathology: ",
|
||||
h1_red = "the mirror that reverted its own work",
|
||||
logline = "The patient had no symptoms. It published without errors, the tool confirmed success, the logs came back green. It had been publishing into the void for four weeks.",
|
||||
victim = "Organ: outreach",
|
||||
stamp = "60 LINES",
|
||||
foot_right = "Physician: night duty · Note: the disk",
|
||||
|
||||
exhibits = ["Clinical picture", "Differential diagnosis", "Etiology", "Treatment", "Prognosis and prophylaxis"],
|
||||
headings = [
|
||||
"Four weeks publishing into the void",
|
||||
"Five diagnoses ruled out",
|
||||
"The pathogen lived in the <span class='mono'>content</span> recipe",
|
||||
"Sixty lines of therapy",
|
||||
"The protocol didn't fail: outreach was the blind spot",
|
||||
],
|
||||
lead_cost = "The patient reported no pain. The severity of an asymptomatic picture is not measured in symptoms: it is measured in what never arrived. These were the vitals:",
|
||||
lead_suspects = "Five plausible findings, five negative tests. One of them wasn't a finding at all: it was the investigator himself, misreading the panel.",
|
||||
|
||||
alibis = [
|
||||
"Yes, I'm dead content still being served. But I don't stop anything from being published.",
|
||||
"I work perfectly. I generate all 69 ADRs whenever anyone calls me.",
|
||||
"I do process them. What happened is that you read me with a <span class='mono'>head -20</span> and it cut my output off.",
|
||||
"I regenerate every time. With 69. What happens afterwards is no business of mine.",
|
||||
"I export what's in the content tree. If you file a contract in among the data, I blow up. And rightly so.",
|
||||
],
|
||||
suspect_tags = [
|
||||
"symptom, not cause",
|
||||
"innocent — nobody was calling it",
|
||||
"solid alibi (the error was the investigator's)",
|
||||
"innocent — and the key to the case",
|
||||
"innocent — it was right",
|
||||
],
|
||||
|
||||
weapon_caption = "The mirror that decided by direction, not by ownership",
|
||||
weapon_after = "Two trees, each owning different files, and a whole-tree <span class='mono'>rsync</span> running one way. In one of the two directions it had to be lying — and it lied in both: it trampled the new indexes with fossils, and it never carried the content to the tree that ships.",
|
||||
weapon_note = "just — the content recipe",
|
||||
fix_caption = "The mirror is typed by ownership, and the check reads the disk",
|
||||
fix_after = "Every leg of the sync is declared by OWNER, not by direction. And three gates that assert against the disk, runnable while the damage is still hypothetical. A check you can only run after the destructive step is not a gate: it's an autopsy.",
|
||||
fix_note = "just — the content recipe",
|
||||
|
||||
verdict_lead = "This happened INSIDE ontoref, with the ontology loaded, the gates written and ADR-066 —“the check decides, never the reporter”— accepted a week earlier. The rule that names this exact pathology already existed, while the pipeline that publishes ontoref to the world went on letting the reporter decide:",
|
||||
|
||||
verdict_prose = "The series asks one question: with ontoref, would it not have gone like this? Almost always the answer is yes, and that is why there is a series. This case file is the one that answers NO — and that is why it is worth the most. This happened INSIDE ontoref, with the ontology loaded, the gates written and ADR-066 —“the check decides, never the reporter”— accepted a week earlier and proven with an eight-path falsification run. The rule that names this exact pathology already existed, while the pipeline that publishes ontoref to the world went on letting the reporter decide. The protocol did not fail: outreach was the blind spot, the one surface the protocol did not govern. Every mechanism that would have caught each one of these failures already existed and was accepted in this very repo. Not one of them was pointed here. And that is the useful answer, the one a comfortable case never gives: having the mechanism is not enough — you have to point it at the surface that hurts. An enforcement you cannot trust to fail is indistinguishable from having no enforcement, and it is more dangerous, because it gets believed.",
|
||||
|
||||
abandonment = "This case's point of abandonment did not arrive with an error, but with a sentence: “I've gone crate by crate through a complex codegen system and I'm well past the reasonable point to keep going blind.” That is what a machine says when it runs out of map and keeps walking. And the answer that unblocked it was not technical: it was someone drawing the hierarchy of levels —rustelo → website-htmx-rustelo → outreach/site— that existed in one head and in no file. That is the whole diagnosis, spoken before it was had: whatever is not declared, someone is holding up with their memory. And memory tires.",
|
||||
},
|
||||
|
||||
# ── Detective skin ────────────────────────────────────────────────────────────────
|
||||
# Written 2026-07-12, when the informe became a projection: the viewer offers both
|
||||
# genres and a case with a single skin leaves a dead button. The NUMBERS, the code, the
|
||||
# technical names of the suspects and the closing line are the same: only who is telling
|
||||
# it changes. If a fact changed when the skin changed, it would be fiction, not genre.
|
||||
detective = {
|
||||
h1_lead = "The case of the ",
|
||||
h1_red = "mirror that reverted its own work",
|
||||
logline = "No body, no blood, not one complaint. The tool signed the report every night: <span class='mono'>69 posts</span>. The disk had 58. It had been publishing into a drawer for four weeks.",
|
||||
victim = "Victim: four weeks",
|
||||
stamp = "60 LINES",
|
||||
foot_right = "Detective: night shift · Informant: the disk",
|
||||
|
||||
exhibits = ["The body", "The line-up", "The weapon", "The confession", "The verdict"],
|
||||
headings = [
|
||||
"A crime with no body — and eleven missing persons",
|
||||
"Five alibis, and one of them I signed myself",
|
||||
"The weapon was in the <span class='mono'>content</span> recipe",
|
||||
"Sixty lines to stop lying to itself",
|
||||
"The rule existed. It just didn't point here",
|
||||
],
|
||||
lead_cost = "Nobody filed a report. Nobody missed a thing, and that's the detail that should have made my hair stand on end: for nobody to miss anything, EVERYTHING has to be missing. What was left on the coroner's table:",
|
||||
lead_suspects = "Five suspects, five iron-clad alibis. And one of the alibis was against me: the witness wasn't lying — I'd cut his statement short with a <span class='mono'>head -20</span>.",
|
||||
|
||||
alibis = [
|
||||
"Yes, I'm dead content still being served. But I don't stop anybody from publishing.",
|
||||
"I work like a charm. I turn out all 69 ADRs whenever anyone calls me. Nobody was calling.",
|
||||
"I do process them. You cut my statement short with a <span class='mono'>head -20</span> and walked off with half a sentence.",
|
||||
"I regenerate every night. With 69. What happens after that is no business of mine, sweetheart.",
|
||||
"I export what's in the tree. If you file a contract in among the data, I blow up. And rightly so.",
|
||||
],
|
||||
suspect_tags = [
|
||||
"minor accomplice",
|
||||
"innocent — nobody was calling it",
|
||||
"solid alibi — the error was the investigator's",
|
||||
"innocent — and the key to the case",
|
||||
"innocent — it was right",
|
||||
],
|
||||
|
||||
weapon_caption = "The weapon · a mirror that decided by direction, not by ownership",
|
||||
weapon_after = "Two trees, each owning different files, and a whole-tree <span class='mono'>rsync</span> running one way. A mirror like that has to be lying in one of the two directions. It lied in both: it resurrected fossils on top of what had just been written, and it never carried the new stuff to the tree that ships. The perfect witness: never fails, never complains, always reports green.",
|
||||
fix_caption = "PAP-compliant · the mirror is typed by ownership, and the check reads the disk",
|
||||
fix_after = "Every leg is declared by OWNER, not by direction. And three gates that ask the disk, not the tool — and that you can run while the damage is still hypothetical. A check you can only run after the destructive step is not a gate: it's an autopsy.",
|
||||
|
||||
verdict_lead = "This didn't happen out on the outskirts of ontoref. It happened inside, with the ontology loaded, the gates written, and ADR-066 —“the check decides, never the reporter”— accepted a week earlier. The rule that names this crime with first and last name was already in the book. It just didn't point down this street:",
|
||||
|
||||
verdict_prose = "The series asks one question: with ontoref, would it not have gone like this? Almost always the answer is yes, and that is why there is a series. This case file is the one that answers NO — and that is why it is worth the most. This happened INSIDE ontoref, with the ontology loaded, the gates written and ADR-066 —“the check decides, never the reporter”— accepted a week earlier and proven with an eight-path falsification run. The rule that names this exact pathology already existed, while the pipeline that publishes ontoref to the world went on letting the reporter decide. The protocol did not fail: outreach was the blind spot, the one surface the protocol did not govern. Every mechanism that would have caught each one of these failures already existed and was accepted in this very repo. Not one of them was pointed here. And that is the useful answer, the one a comfortable case never gives: having the mechanism is not enough — you have to point it at the surface that hurts. An enforcement you cannot trust to fail is indistinguishable from having no enforcement, and it is more dangerous, because it gets believed.",
|
||||
|
||||
abandonment = "This case's point of abandonment did not arrive with an error, but with a sentence: “I've gone crate by crate through a complex codegen system and I'm well past the reasonable point to keep going blind.” That is what a machine says when it runs out of map and keeps walking. And the answer that unblocked it was not technical: it was someone drawing the hierarchy of levels —rustelo → website-htmx-rustelo → outreach/site— that existed in one head and in no file. That is the whole diagnosis, spoken before it was had: whatever is not declared, someone is holding up with their memory. And memory tires.",
|
||||
},
|
||||
},
|
||||
|
||||
related_modes = ["generate-expediente"],
|
||||
tags = ["case-file", "anti-pap", "deploy", "witness", "adr-070", "adr-066", "developer"],
|
||||
}
|
||||
|
|
@ -16,68 +16,66 @@ sort_order: 2
|
|||
---
|
||||
|
||||
<section class="exp">
|
||||
|
||||
<style>
|
||||
.exp{font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;color:#0c7a83}
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.4rem;margin:.9rem 0 0;font-family:ui-monospace,"Courier New",monospace;font-size:.68rem}
|
||||
.exp .exp-meta span{border:1px solid;padding:.2rem .5rem;letter-spacing:.08em;text-transform:uppercase}
|
||||
.exp .exp-anam{border-left:3px solid #0c7a83;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.13em;text-transform:uppercase;color:#0c7a83;margin:2rem 0 .5rem;border-top:1px solid;padding-top:.9rem}
|
||||
.exp .exp-vs{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-vs{grid-template-columns:1fr}}
|
||||
.exp .exp-col{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-col h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-col.a h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-col.b h4{background:rgba(12,122,131,.14)}
|
||||
.exp .exp-col ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-col li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-col li:last-child{border-bottom:0}
|
||||
.exp .exp-col b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp pre{font-family:ui-monospace,"Courier New",monospace;font-size:.8rem;overflow-x:auto;background:#0c1a20;color:#d7e4e7;padding:.85rem 1rem;border-radius:.4rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .8rem;border-radius:.5rem;overflow:hidden;line-height:0}
|
||||
.exp .exp-hero img{width:100%;display:block}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.85rem;font-weight:700;background:#0c7a83;color:#fff;padding:.55rem .95rem;border-radius:.4rem;text-decoration:none}
|
||||
.exp .exp-btn:hover{background:#0a636a}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-ledger h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-ledger.cost h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-ledger.yield h4{background:rgba(20,120,60,.12)}
|
||||
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>
|
||||
|
||||
<a class="exp-hero" href="/images/expedientes/en/expedientes.html#recaida-hardcoded-bis" target="_blank" rel="noopener" title="Open the full clinical history"><img src="/images/expedientes/recaida-header.webp" alt="Case file 404-PAP-bis — the relapse" width="1200" height="675" loading="lazy"></a>
|
||||
<a class="exp-hero" href="/images/expedientes/en/expedientes.html#recaida-hardcoded-bis" target="_blank" rel="noopener" title="Open the full clinical history"><img src="/images/expedientes/recaida-header.webp" alt="Case 404-PAP-bis: the relapse" width="1200" height="675" loading="lazy"></a>
|
||||
|
||||
<p style="margin:0 0 1.4rem"><a class="exp-btn" href="/images/expedientes/en/expedientes.html#recaida-hardcoded-bis" target="_blank" rel="noopener">🩺 Show the full clinical history →</a></p>
|
||||
|
||||
<p class="exp-kick">Clinical history · Code Pathology Service</p>
|
||||
|
||||
<p class="exp-log">We thought we were discharged. The `/expedientes` hub green, the case closed, glory. And then `/nosotros` threw a 404. Instantly. From triumph to misery with no transition — a mirage, a rollercoaster that only ever goes down.</p>
|
||||
|
||||
<div class="exp-meta"><span>History No. <b>404-PAP-bis</b></span><span>Diagnosis: ANTI-PAP (relapse)</span><span>Status: DISCHARGED</span></div>
|
||||
<div class="exp-meta"><span>History No. <b>404-PAP-bis</b></span><span>Diagnosis: ANTI-PAP · relapse</span><span>Status: DISCHARGED</span></div>
|
||||
|
||||
<div class="exp-anam">
|
||||
<b>Chief complaint.</b> Patient who, believing himself cured, suffers a sudden relapse of the same syndrome. Reports going from startled surprise to panic and uncertainty in an instant; mood drops from triumph to depression with no intermediate step. Verbalizes: <em>"after this I'm going to need a visit to the clinic"</em>. And here we are.
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">Etiology — the same pathogen, another organ</p>
|
||||
<div class="exp-gloss">
|
||||
<dl>
|
||||
<dt>PAP</dt><dd><b>Project's Architecture Principles</b> — the rules and patterns holding the project's architecture up: the single source of truth all code must respect.</dd>
|
||||
<dt>anti-PAP</dt><dd>Code written <em>against</em> those rules: a stub that re-lists by hand what <span class='mono'>routes.ncl</span> already declares, duplicating and contradicting the source of truth.</dd>
|
||||
</dl>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">The protocol to declare, version and verify this → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
</div>
|
||||
|
||||
[Case 404-PAP](/case-files/cases/the-hardcoded-drift) killed a hardcoded `match` in `render_content_or_grid` (content pages). The twin lived intact in the organ next door — the **static pages**, `pages_htmx::dispatch`:
|
||||
|
||||
<pre>match path {
|
||||
"/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
|
||||
// … every page by hand … except /nosotros …
|
||||
_ => None, // ← /nosotros fell through here → 404
|
||||
}</pre>
|
||||
|
||||
Route declared, FTL present, component baked — and still a 404. The same anti‑PAP: a match that duplicates what `routes.ncl` already knows.
|
||||
|
||||
<p class="exp-ex">Treatment — the same antibiotic</p>
|
||||
|
||||
<pre>_ => resolve_static_page(env, path, lang),
|
||||
// … reads from the registry: any route whose component
|
||||
// ends in "Page" → static_page(page_id kebab). Zero new arms.</pre>
|
||||
|
||||
<p class="exp-ex">Prognosis — and why this relapse wasn't like the first</p>
|
||||
<p class="exp-ex">The double ledger — what it cost, and what it left</p>
|
||||
|
||||
The telling part isn't that it relapsed. It's **how long it lasted**. The first case, with no prior history, cost an afternoon. The twin, identical, cost **minutes** — because the first was already *declared as clinical history* (`content-kind-howto`): I went straight to the organ, recognized the pathogen, applied the same antibiotic.
|
||||
|
||||
<div class="exp-vs">
|
||||
<div class="exp-col a">
|
||||
<div class="exp-balance">
|
||||
<div class="exp-ledger cost">
|
||||
<h4>Case 404-PAP · no history</h4>
|
||||
<ul>
|
||||
<li>Diagnosis <b>~88 min</b></li>
|
||||
|
|
@ -85,20 +83,415 @@ The telling part isn't that it relapsed. It's **how long it lasted**. The first
|
|||
<li>False leads <b>8</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="exp-col b">
|
||||
<div class="exp-ledger yield">
|
||||
<h4>Case bis · with clinical history</h4>
|
||||
<ul>
|
||||
<li>Diagnosis <b>~15 min</b></li>
|
||||
<li>Builds <b>1</b></li>
|
||||
<li>False leads <b>0</b></li>
|
||||
<li>Speed-up factor vs. the first case <b>~30×</b></li>
|
||||
<li>Size of the final fix <b>1 <i>line</i></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Same bug. **~30× faster.** Not because the second was easier — it was identical — but because the first was **declared**. That's [ontoref](https://ontoref.dev) working in real time: the `dao` holds you through the relapse, the *mode* gives you the step, and the prior howto turns "another afternoon lost" into "fifteen minutes and a known antibiotic".
|
||||
Same bug. **~30× faster.** Not because the second was easier — it was identical — but because the first was **declared**. That's [ontoref](https://ontoref.dev) working in real time: the `dao` holds you through the relapse, the *mode* gives you the step, and the prior howto turns “another afternoon lost” into “fifteen minutes and a known antibiotic”.
|
||||
|
||||
<p class="exp-ex">Discharge and prophylaxis</p>
|
||||
<p class="exp-ex">Differential diagnosis — what was ruled out</p>
|
||||
|
||||
<table class="exp-susp"><tbody>
|
||||
<tr><td><b>The binary / the cache</b></td><td><em>“Me again? Markers alone won't do it.”</em></td><td>known repeat</td></tr>
|
||||
<tr><td><b>The declared <span class='mono'>/nosotros</span> route</b></td><td><em>“I'm on record in <span class='mono'>routes.ncl</span>. Normal vital.”</em></td><td>normal vital</td></tr>
|
||||
<tr><td><b>The FTL & the baked component</b></td><td><em>“Present and correct. Negative panel.”</em></td><td>negative panel</td></tr>
|
||||
<tr><td><b><span class='mono'>pages_htmx::dispatch</span></b></td><td><em>“Yes… the <em>match</em> that picks the static page was the focus.”</em></td><td>the pathogen</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="exp-ex">Etiology — the cause — Twin etiology · a hardcoded match in the static pages</p>
|
||||
|
||||
<pre>match path {
|
||||
"/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
|
||||
// … every page by hand … except /nosotros …
|
||||
_ => None, // ← /nosotros fell through here → 404
|
||||
}</pre>
|
||||
|
||||
[Case 404-PAP](/case-files/cases/the-hardcoded-drift) killed a hardcoded `match` in `render_content_or_grid` (content pages). The twin lived intact in the organ next door — the **static pages**, `pages_htmx::dispatch`:
|
||||
|
||||
Route declared, FTL present, component baked — and still a 404. The same anti‑PAP: a match that duplicates what `routes.ncl` already knows.
|
||||
|
||||
<p class="exp-ex">Treatment — PAP-compliant · one line, no new branches</p>
|
||||
|
||||
<pre>_ => resolve_static_page(env, path, lang),
|
||||
// reads from the registry: any route whose component
|
||||
// ends in "Page" → static_page(page_id kebab). Zero new arms.</pre>
|
||||
|
||||
<p class="exp-ex">Prognosis</p>
|
||||
|
||||
The pathogen has family: any hardcoded `match` that shadows the registry. The vaccine is the same for all — read from the single source. The case is closed on record (`static-page-howto`) so that the **third** twin, if it shows up, lasts seconds.
|
||||
|
||||
<table class="exp-cont"><tbody>
|
||||
<tr><td>The first case, already declared as history</td><td>content-kind-howto → direct lookup</td></tr>
|
||||
<tr><td>Recognizing the pathogen without re-interrogating</td><td>same antibiotic → registry</td></tr>
|
||||
<tr><td>Closing the relapse on record too</td><td>static-page-howto → the 3rd will take seconds</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<!--
|
||||
_glossary.html — Section glossary component embedded in the /expedientes hub.
|
||||
Self-contained: inline <style> + <script>, no external deps, CSP-safe, standalone.
|
||||
Data-driven: edit the TERMS array below ({id, term, def, aliases?}) to add entries.
|
||||
Prose links jump to a term with <a href="#gl-pap">…</a> (anchor = "gl-" + id);
|
||||
landing on #gl-<id> scrolls the term into view and briefly highlights it.
|
||||
Search box live-filters by term text, definition, and aliases as you type.
|
||||
-->
|
||||
<section class="glz" aria-label="Glosario de la sección Expedientes">
|
||||
<header class="glz__head">
|
||||
<h2 class="glz__title">Glosario</h2>
|
||||
<div class="glz__search">
|
||||
<input
|
||||
type="search"
|
||||
id="glz-q"
|
||||
class="glz__input"
|
||||
placeholder="Filtrar términos…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filtrar términos del glosario"
|
||||
/>
|
||||
<span class="glz__count" id="glz-count" aria-live="polite"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl class="glz__list" id="glz-list"></dl>
|
||||
<p class="glz__empty" id="glz-empty" hidden>Sin coincidencias.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.glz {
|
||||
--glz-bg: #ffffff;
|
||||
--glz-fg: #1a1a1a;
|
||||
--glz-muted: #6b7280;
|
||||
--glz-border: #e5e7eb;
|
||||
--glz-accent: #2563eb;
|
||||
--glz-mark: #fef08a;
|
||||
--glz-chip: #f3f4f6;
|
||||
--glz-flash: rgba(37, 99, 235, 0.16);
|
||||
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
color: var(--glz-fg);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glz {
|
||||
--glz-bg: #0d1117;
|
||||
--glz-fg: #e6edf3;
|
||||
--glz-muted: #8b949e;
|
||||
--glz-border: #21262d;
|
||||
--glz-accent: #58a6ff;
|
||||
--glz-mark: #9a7b00;
|
||||
--glz-chip: #161b22;
|
||||
--glz-flash: rgba(88, 166, 255, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
.glz__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.glz__title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.glz__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 14rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.glz__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
color: var(--glz-fg);
|
||||
background: var(--glz-bg);
|
||||
border: 1px solid var(--glz-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.glz__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--glz-accent);
|
||||
box-shadow: 0 0 0 3px var(--glz-flash);
|
||||
}
|
||||
.glz__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--glz-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glz__list {
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--glz-border);
|
||||
}
|
||||
.glz__row {
|
||||
padding: 0.7rem 0.15rem;
|
||||
border-bottom: 1px solid var(--glz-border);
|
||||
scroll-margin-top: 5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.glz__row.is-flash {
|
||||
background: var(--glz-flash);
|
||||
}
|
||||
.glz__term {
|
||||
margin: 0;
|
||||
font-weight: 640;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
.glz__aliases {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--glz-muted);
|
||||
}
|
||||
.glz__alias {
|
||||
display: inline-block;
|
||||
padding: 0.02rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
background: var(--glz-chip);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.glz__def {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--glz-fg);
|
||||
}
|
||||
.glz__def mark {
|
||||
background: var(--glz-mark);
|
||||
color: inherit;
|
||||
padding: 0 0.1em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glz__empty {
|
||||
padding: 1rem 0.15rem;
|
||||
color: var(--glz-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
code, .glz__code {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--glz-chip);
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TERMS = [
|
||||
{
|
||||
id: "pap",
|
||||
term: "PAP",
|
||||
aliases: ["Project's Architecture Principles"],
|
||||
def: "Los principios, reglas y patrones de arquitectura del proyecto. Una decisión “conforme a PAP” respeta las invariantes establecidas."
|
||||
},
|
||||
{
|
||||
id: "anti-pap",
|
||||
term: "anti-PAP",
|
||||
aliases: ["contra PAP"],
|
||||
def: "Un enfoque que va completamente en contra de la arquitectura establecida del proyecto; se rechaza aunque “funcione”."
|
||||
},
|
||||
{
|
||||
id: "registry",
|
||||
term: "registry",
|
||||
aliases: ["registro"],
|
||||
def: "Tabla central que mapea cada tipo de contenido a su handler y metadatos, de modo que las rutas se resuelven de forma declarativa."
|
||||
},
|
||||
{
|
||||
id: "contentkind",
|
||||
term: "ContentKind",
|
||||
aliases: ["variant", "enum"],
|
||||
def: "El tipo (enum) que enumera las clases de contenido que el sistema sabe renderizar; cada variante corresponde a un kind."
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
term: "kind",
|
||||
aliases: ["tipo de contenido"],
|
||||
def: "Discriminante de un contenido: identifica a qué ContentKind pertenece y, por tanto, qué plantilla y ruta le aplican."
|
||||
},
|
||||
{
|
||||
id: "htmx-ssr",
|
||||
term: "htmx-ssr",
|
||||
aliases: ["server-side rendering", "SSR"],
|
||||
def: "Renderizado en servidor con fragmentos HTML entregados a htmx: el servidor devuelve HTML listo y el cliente solo intercambia trozos, sin framework pesado."
|
||||
},
|
||||
{
|
||||
id: "routes-ncl",
|
||||
term: "routes.ncl",
|
||||
aliases: ["routes", "Nickel routes"],
|
||||
def: "Archivo Nickel declarativo que define las rutas del sitio y las asocia a cada kind; fuente única de verdad para el enrutado."
|
||||
},
|
||||
{
|
||||
id: "deriva",
|
||||
term: "deriva",
|
||||
aliases: ["drift"],
|
||||
def: "Divergencia silenciosa entre dos representaciones que deberían coincidir (p. ej. código y ontología, o assets y su copia materializada). Se detecta con comprobaciones de <code>check</code>."
|
||||
},
|
||||
{
|
||||
id: "dogfooding",
|
||||
term: "dogfooding",
|
||||
aliases: ["comerse la propia comida"],
|
||||
def: "Usar el propio proyecto para construir y documentar el proyecto: el hub de Expedientes se publica con el mismo motor htmx-ssr que describe."
|
||||
},
|
||||
{
|
||||
id: "expediente",
|
||||
term: "expediente",
|
||||
aliases: ["dossier", "caso"],
|
||||
def: "Unidad documental del hub: un caso autocontenido que recoge contexto, decisiones y evidencia sobre una pieza del sistema."
|
||||
}
|
||||
];
|
||||
|
||||
var norm = function (s) {
|
||||
return (s || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "");
|
||||
};
|
||||
|
||||
var esc = function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[c];
|
||||
});
|
||||
};
|
||||
|
||||
var list = document.getElementById("glz-list");
|
||||
var input = document.getElementById("glz-q");
|
||||
var count = document.getElementById("glz-count");
|
||||
var empty = document.getElementById("glz-empty");
|
||||
if (!list || !input) return;
|
||||
|
||||
// Build rows once; filtering only toggles visibility + highlight.
|
||||
var rows = TERMS.map(function (t) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "glz__row";
|
||||
row.id = "gl-" + t.id;
|
||||
|
||||
var dt = document.createElement("dt");
|
||||
dt.className = "glz__term";
|
||||
dt.innerHTML = esc(t.term);
|
||||
if (t.aliases && t.aliases.length) {
|
||||
var al = document.createElement("span");
|
||||
al.className = "glz__aliases";
|
||||
al.innerHTML = t.aliases
|
||||
.map(function (a) {
|
||||
return '<span class="glz__alias">' + esc(a) + "</span>";
|
||||
})
|
||||
.join("");
|
||||
dt.appendChild(al);
|
||||
}
|
||||
|
||||
var dd = document.createElement("dd");
|
||||
dd.className = "glz__def";
|
||||
dd.dataset.def = t.def; // def may carry inline markup (e.g. <code>)
|
||||
|
||||
row.appendChild(dt);
|
||||
row.appendChild(dd);
|
||||
list.appendChild(row);
|
||||
|
||||
return {
|
||||
el: row,
|
||||
dd: dd,
|
||||
term: t.term,
|
||||
rawDef: t.def,
|
||||
hay: norm(t.term + " " + t.def + " " + (t.aliases || []).join(" "))
|
||||
};
|
||||
});
|
||||
|
||||
// Highlight the query inside definitions without breaking inline markup:
|
||||
// split the def on tag boundaries and only mark text segments.
|
||||
var highlight = function (html, q) {
|
||||
if (!q) return html;
|
||||
var qn = norm(q);
|
||||
return html.replace(/(<[^>]+>)|([^<]+)/g, function (_, tag, text) {
|
||||
if (tag) return tag;
|
||||
var out = "";
|
||||
var i = 0;
|
||||
var tn = norm(text);
|
||||
while (i < text.length) {
|
||||
var at = tn.indexOf(qn, i);
|
||||
if (at === -1) {
|
||||
out += esc(text.slice(i));
|
||||
break;
|
||||
}
|
||||
out += esc(text.slice(i, at));
|
||||
out += "<mark>" + esc(text.slice(at, at + q.length)) + "</mark>";
|
||||
i = at + q.length;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
};
|
||||
|
||||
var render = function () {
|
||||
var q = input.value.trim();
|
||||
var qn = norm(q);
|
||||
var shown = 0;
|
||||
rows.forEach(function (r) {
|
||||
var match = !qn || r.hay.indexOf(qn) !== -1;
|
||||
r.el.hidden = !match;
|
||||
if (match) {
|
||||
shown++;
|
||||
r.dd.innerHTML = highlight(r.rawDef, q);
|
||||
}
|
||||
});
|
||||
count.textContent =
|
||||
shown === TERMS.length ? TERMS.length + " términos" : shown + "/" + TERMS.length;
|
||||
empty.hidden = shown !== 0;
|
||||
};
|
||||
|
||||
var flash = function (row) {
|
||||
if (!row) return;
|
||||
row.classList.remove("is-flash");
|
||||
// reflow so the class re-adds even on repeat clicks to the same anchor
|
||||
void row.offsetWidth;
|
||||
row.classList.add("is-flash");
|
||||
window.setTimeout(function () {
|
||||
row.classList.remove("is-flash");
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
var jumpToHash = function () {
|
||||
var h = window.location.hash;
|
||||
if (!h || h.indexOf("#gl-") !== 0) return;
|
||||
var row = document.getElementById(h.slice(1));
|
||||
if (!row) return;
|
||||
if (row.hidden) {
|
||||
// clear an active filter so the linked term is visible
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
flash(row);
|
||||
};
|
||||
|
||||
input.addEventListener("input", render);
|
||||
window.addEventListener("hashchange", jumpToHash);
|
||||
|
||||
render();
|
||||
jumpToHash();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
188
site/site/content/expedientes/en/cases/recaida-hardcoded-bis.ncl
Normal file
188
site/site/content/expedientes/en/cases/recaida-hardcoded-bis.ncl
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Typed source for expediente "recaida-hardcoded-bis" (English).
|
||||
# Harvested verbatim from the two hand-written English surfaces, which are now its
|
||||
# projections and must not be hand-edited any more:
|
||||
# site/content/expedientes/en/cases/recaida-hardcoded-bis.md (clinical skin)
|
||||
# site/public/images/expedientes/en/expedientes.html CASES[1] (both skins)
|
||||
# Nothing here is translated: every string already existed in English.
|
||||
let s = import "../../../../schemas/expediente.ncl" in
|
||||
|
||||
s.make_expediente {
|
||||
id = "recaida-hardcoded-bis",
|
||||
title = "Case 404-PAP-bis: the relapse",
|
||||
slug = "the-relapse-hardcoded",
|
||||
case_no = "404-PAP-bis",
|
||||
classification = "ANTI-PAP · relapse",
|
||||
status = "DISCHARGED",
|
||||
skin = 'clinical,
|
||||
|
||||
tab = "404-PAP-bis · the relapse",
|
||||
print_href = "/images/expedientes/en/recaida.html",
|
||||
|
||||
glossary = [
|
||||
{ term = "PAP",
|
||||
def = "<b>Project's Architecture Principles</b> — the rules and patterns holding the project's architecture up: the single source of truth all code must respect." },
|
||||
{ term = "anti-PAP",
|
||||
def = "Code written <em>against</em> those rules: a stub that re-lists by hand what <span class='mono'>routes.ncl</span> already declares, duplicating and contradicting the source of truth." },
|
||||
],
|
||||
|
||||
# The two ledgers of this case are not "cost vs yield": they are the SAME case
|
||||
# weighed without and with prior history (the .md's two-column comparison).
|
||||
cost_title = "Case 404-PAP · no history",
|
||||
yielded_title = "Case bis · with clinical history",
|
||||
cost_caption = "Measurable cost · session 2026-07-10 → 07-11",
|
||||
|
||||
cost = [
|
||||
{ label = "Diagnosis", value = "~88 min" },
|
||||
{ label = "Builds", value = "8" },
|
||||
{ label = "False leads", value = "8" },
|
||||
],
|
||||
|
||||
yielded = [
|
||||
{ label = "Diagnosis", value = "~15 min" },
|
||||
{ label = "Builds", value = "1" },
|
||||
{ label = "False leads", value = "0" },
|
||||
# Only the informe carried these two. They are what the prior history BOUGHT, so they
|
||||
# belong in the "with history" column rather than being dropped to fit the article.
|
||||
{ label = "Speed-up factor vs. the first case", value = "~30×", emphasis = true },
|
||||
{ label = "Size of the final fix", value = "1 <i>line</i>", emphasis = true },
|
||||
],
|
||||
|
||||
suspects = [
|
||||
{ name = "The binary / the cache" },
|
||||
{ name = "The declared <span class='mono'>/nosotros</span> route" },
|
||||
{ name = "The FTL & the baked component" },
|
||||
{ name = "<span class='mono'>pages_htmx::dispatch</span>" },
|
||||
],
|
||||
|
||||
weapon = {
|
||||
code = "match path {\n \"/services\" | \"/servicios\" => Some(static_page::render(env, \"services\", lang)),\n // … every page by hand … except /nosotros …\n _ => None, // ← /nosotros fell through here → 404\n}",
|
||||
code_html = "<span class=\"k\">match</span> path {\n <span class=\"g\">\"/services\"</span> | <span class=\"g\">\"/servicios\"</span> => Some(static_page::render(env, <span class=\"g\">\"services\"</span>, lang)),\n <span class=\"c\">// … every page by hand … except /nosotros …</span>\n _ => <span class=\"r\">None</span>, <span class=\"c\">// ← /nosotros fell through here → 404</span>\n}",
|
||||
},
|
||||
|
||||
fix = {
|
||||
code = "_ => resolve_static_page(env, path, lang),\n// reads from the registry: any route whose component\n// ends in \"Page\" → static_page(page_id kebab). Zero new arms.",
|
||||
code_html = "_ => resolve_static_page(env, path, lang),\n<span class=\"c\">// reads from the registry: any route whose component</span>\n<span class=\"c\">// ends in \"Page\" → static_page(page_id kebab). Zero new arms.</span>",
|
||||
},
|
||||
|
||||
containment = [
|
||||
{ facet = "The first case, already declared as history",
|
||||
mechanism = "content-kind-howto → direct lookup",
|
||||
mechanism_html = "content-kind-howto <span class='kicknum'>→ direct lookup</span>" },
|
||||
{ facet = "Recognizing the pathogen without re-interrogating",
|
||||
mechanism = "same antibiotic → registry",
|
||||
mechanism_html = "same antibiotic <span class='kicknum'>→ registry</span>" },
|
||||
{ facet = "Closing the relapse on record too",
|
||||
mechanism = "static-page-howto → the 3rd will take seconds",
|
||||
mechanism_html = "static-page-howto <span class='kicknum'>→ the 3rd will take seconds</span>" },
|
||||
],
|
||||
|
||||
verdict_close = "The telling part isn't that it relapsed: it's <strong>how long it lasted</strong>. Same bug, <strong>~30× faster</strong>, not because it was easier —it was identical— but because the first was <em>declared</em>. That's <a href='https://ontoref.dev' target='_blank' rel='noopener'>ontoref</a> in real time: the prior howto turns “another afternoon lost” into “fifteen minutes and a known antibiotic”.",
|
||||
|
||||
skins = {
|
||||
detective = {
|
||||
h1_lead = "The case of the ",
|
||||
h1_red = "relapse",
|
||||
logline = "We thought the case closed, medal on the chest. And then <span class='mono'>/nosotros</span> threw a 404. Instantly. From triumph to dead end with no transition — the same killer, another victim.",
|
||||
victim = "Victim: /nosotros",
|
||||
stamp = "~30× FASTER",
|
||||
foot_right = "Detective: night shift · With prior file",
|
||||
|
||||
exhibits = [
|
||||
"The repeat offender",
|
||||
"The usual four",
|
||||
"The twin weapon",
|
||||
"The confession, in one line",
|
||||
"The verdict",
|
||||
],
|
||||
headings = [
|
||||
"How long it lasted this time",
|
||||
"Four suspects, no detours",
|
||||
"The body was in <span class='mono'>pages_htmx::dispatch</span>",
|
||||
"One line to close the twin",
|
||||
"Why this relapse wasn't like the first",
|
||||
],
|
||||
lead_cost = "The same M.O., another door. But this time I had the first case's file in my pocket. What it cost, in builds:",
|
||||
lead_suspects = "With the first case on record, there was no long line-up. I went straight to the one I already knew.",
|
||||
|
||||
alibis = [
|
||||
"Me again? The fingerprints won't fly anymore.",
|
||||
"I'm in <span class='mono'>routes.ncl</span>. Iron-clad alibi.",
|
||||
"Present and correct. Not my thing.",
|
||||
"Fine… the <em>match</em> that picks the static page was mine.",
|
||||
],
|
||||
suspect_tags = ["usual suspect", "solid alibi", "ruled out", "the culprit"],
|
||||
|
||||
weapon_caption = "The twin weapon · a hardcoded match in the static pages",
|
||||
weapon_after = "Route declared, FTL present, component baked — and still a 404. The same <span class='mono'>match</span> that duplicates by hand what <span class='mono'>routes.ncl</span> already knows, now in the organ next door.",
|
||||
fix_caption = "PAP-compliant · one line, zero new arms",
|
||||
fix_after = "Any route whose component ends in <span class='mono'>Page</span> resolves from the registry. The twin, and whatever comes next, are no longer touched by hand.",
|
||||
|
||||
verdict_lead = "The pathogen had family: any hardcoded <span class='mono'>match</span> shadowing the registry. The difference wasn't cleverness, it was <strong>prior history</strong>:",
|
||||
# No long-form detective prose exists in English: the .md is written in the clinical
|
||||
# skin. The informe's lead is reused rather than inventing (or translating) a verdict.
|
||||
verdict_prose = "The pathogen had family: any hardcoded <span class='mono'>match</span> shadowing the registry. The difference wasn't cleverness, it was <strong>prior history</strong>:",
|
||||
},
|
||||
|
||||
clinical = {
|
||||
h1_lead = "Pathology: the ",
|
||||
h1_red = "relapse",
|
||||
logline = "We thought we were discharged, the picture resolved. And then <span class='mono'>/nosotros</span> threw a 404. Instantly. From discharge to relapse with no step in between — the same pathogen, another organ.",
|
||||
# The article breathes longer than the informe's hook.
|
||||
logline_article = "We thought we were discharged. The `/expedientes` hub green, the case closed, glory. And then `/nosotros` threw a 404. Instantly. From triumph to misery with no transition — a mirage, a rollercoaster that only ever goes down.",
|
||||
# The chief complaint: the only prose in the article written in the patient's voice.
|
||||
# The contract had no room for it, and both harvests reported it as about to be lost.
|
||||
anamnesis = "<b>Chief complaint.</b> Patient who, believing himself cured, suffers a sudden relapse of the same syndrome. Reports going from startled surprise to panic and uncertainty in an instant; mood drops from triumph to depression with no intermediate step. Verbalizes: <em>\"after this I'm going to need a visit to the clinic\"</em>. And here we are.",
|
||||
victim = "Organ: /nosotros",
|
||||
stamp = "~30× FASTER",
|
||||
foot_right = "Physician: night duty · With prior history",
|
||||
|
||||
exhibits = [
|
||||
"The relapse",
|
||||
"Brief differential",
|
||||
"Twin etiology",
|
||||
"One-line therapy",
|
||||
"Prognosis",
|
||||
],
|
||||
headings = [
|
||||
"How long it lasted this time",
|
||||
"Four diagnoses, no detours",
|
||||
"The pathogen lived in <span class='mono'>pages_htmx::dispatch</span>",
|
||||
"One line of therapy",
|
||||
"Why this relapse wasn't like the first",
|
||||
],
|
||||
lead_cost = "The same picture, another organ. But this time with the first episode's clinical history at hand. What it cost, in vitals:",
|
||||
lead_suspects = "With the prior history, the differential was short: I went straight to the pathogen I already knew.",
|
||||
|
||||
alibis = [
|
||||
"Me again? Markers alone won't do it.",
|
||||
"I'm on record in <span class='mono'>routes.ncl</span>. Normal vital.",
|
||||
"Present and correct. Negative panel.",
|
||||
"Yes… the <em>match</em> that picks the static page was the focus.",
|
||||
],
|
||||
suspect_tags = ["known repeat", "normal vital", "negative panel", "the pathogen"],
|
||||
|
||||
weapon_caption = "Twin etiology · a hardcoded match in the static pages",
|
||||
weapon_after = "Route declared, FTL present, component baked — and still a 404. The same <span class='mono'>match</span> that duplicates by hand what <span class='mono'>routes.ncl</span> already knows, now in the adjacent organ.",
|
||||
# The .md's etiology prose: the paragraph that introduces the snippet and the one
|
||||
# that closes it, in that order, separated by a blank line.
|
||||
weapon_note = m%"
|
||||
[Case 404-PAP](/case-files/cases/the-hardcoded-drift) killed a hardcoded `match` in `render_content_or_grid` (content pages). The twin lived intact in the organ next door — the **static pages**, `pages_htmx::dispatch`:
|
||||
|
||||
Route declared, FTL present, component baked — and still a 404. The same anti‑PAP: a match that duplicates what `routes.ncl` already knows.
|
||||
"%,
|
||||
|
||||
fix_caption = "PAP-compliant · one line, no new branches",
|
||||
fix_after = "Any route whose component ends in <span class='mono'>Page</span> resolves from the registry. The twin, and the next one, are no longer treated by hand.",
|
||||
|
||||
verdict_lead = "The pathogen had family: any hardcoded <span class='mono'>match</span> shadowing the registry. The difference wasn't skill, it was <strong>prior clinical history</strong>:",
|
||||
verdict_prose = "The pathogen has family: any hardcoded `match` that shadows the registry. The vaccine is the same for all — read from the single source. The case is closed on record (`static-page-howto`) so that the **third** twin, if it shows up, lasts seconds.",
|
||||
|
||||
balance = "The telling part isn't that it relapsed. It's **how long it lasted**. The first case, with no prior history, cost an afternoon. The twin, identical, cost **minutes** — because the first was already *declared as clinical history* (`content-kind-howto`): I went straight to the organ, recognized the pathogen, applied the same antibiotic.",
|
||||
|
||||
balance_outro = "Same bug. **~30× faster.** Not because the second was easier — it was identical — but because the first was **declared**. That's [ontoref](https://ontoref.dev) working in real time: the `dao` holds you through the relapse, the *mode* gives you the step, and the prior howto turns “another afternoon lost” into “fifteen minutes and a known antibiotic”.",
|
||||
},
|
||||
},
|
||||
|
||||
related_modes = ["generate-expediente"],
|
||||
tags = ["case-file", "clinical", "relapse", "anti-pap", "dogfooding", "ontoref"],
|
||||
}
|
||||
|
|
@ -16,17 +16,21 @@ sort_order: 1
|
|||
---
|
||||
|
||||
<section class="exp">
|
||||
|
||||
<style>
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.4rem;margin:.9rem 0 0;font-family:ui-monospace,"Courier New",monospace;font-size:.68rem}
|
||||
.exp .exp-meta span{border:1px solid;padding:.2rem .5rem;letter-spacing:.08em;text-transform:uppercase}
|
||||
.exp .exp-gloss{border:1px dashed;padding:.75rem 1rem;margin:1.4rem 0;border-radius:.4rem}
|
||||
.exp .exp-gloss dl{margin:0;display:grid;grid-template-columns:max-content 1fr;gap:.3rem .9rem}
|
||||
.exp .exp-gloss dt{font-family:ui-monospace,"Courier New",monospace;font-weight:700;color:#b4342a}
|
||||
.exp .exp-gloss dd{margin:0;font-size:.92rem}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.13em;text-transform:uppercase;color:#b4342a;margin:2rem 0 .5rem;border-top:1px solid;padding-top:.9rem}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
|
|
@ -37,29 +41,27 @@ sort_order: 1
|
|||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp pre{font-family:ui-monospace,"Courier New",monospace;font-size:.8rem;overflow-x:auto;background:#1a1a1a;color:#e9dfc6;padding:.85rem 1rem;border-radius:.4rem}
|
||||
.exp .exp-cont td{padding:.45rem .5rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top;font-size:.92rem}
|
||||
.exp .exp-cont td:first-child{font-style:italic}
|
||||
.exp .exp-cont td:last-child{font-family:ui-monospace,"Courier New",monospace;font-size:.82rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .8rem;border-radius:.5rem;overflow:hidden;line-height:0}
|
||||
.exp .exp-hero img{width:100%;display:block}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.85rem;font-weight:700;background:#9c2d25;color:#fff;padding:.55rem .95rem;border-radius:.4rem;text-decoration:none}
|
||||
.exp .exp-btn:hover{background:#7f241d}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>
|
||||
|
||||
<a class="exp-hero" href="/images/expedientes/es/expedientes.html#deriva-hardcoded-anti-pap" target="_blank" rel="noopener" title="Abrir el expediente completo"><img src="/images/expedientes/deriva-header.webp" alt="Expediente 404-PAP — la deriva hardcoded" width="1200" height="675" loading="lazy"></a>
|
||||
<a class="exp-hero" href="/images/expedientes/es/expedientes.html#deriva-hardcoded-anti-pap" target="_blank" rel="noopener" title="Abrir el expediente completo"><img src="/images/expedientes/deriva-header.webp" alt="Expediente 404-PAP: la deriva hardcoded" width="1200" height="675" loading="lazy"></a>
|
||||
|
||||
<p style="margin:0 0 1.4rem"><a class="exp-btn" href="/images/expedientes/es/expedientes.html#deriva-hardcoded-anti-pap" target="_blank" rel="noopener">🕵️ Mostrar expediente completo →</a></p>
|
||||
|
||||
<p class="exp-kick">Expediente · Depto. de Homicidios de Código</p>
|
||||
|
||||
<p class="exp-log">Era una tarde tranquila. Entonces <code>/recursos</code> entró por la puerta y no mostró nada. Nada de nada. Y yo, ingenuo, pensé que sería rápido.</p>
|
||||
<p class="exp-log">Era una tarde tranquila. Entonces <span class='mono'>/recursos</span> entró por la puerta y no mostró nada. Nada de nada. Y yo, ingenuo, pensé que sería rápido.</p>
|
||||
|
||||
<div class="exp-meta"><span>Caso Nº <b>404-PAP</b></span><span>Clasificación: ANTI-PAP</span><span>Estado: RESUELTO</span></div>
|
||||
|
||||
<div class="exp-gloss">
|
||||
<dl>
|
||||
<dt>PAP</dt><dd><b>Project's Architecture Principles</b> — las reglas y patrones que sostienen la arquitectura: la fuente única de la verdad que todo el código debe respetar.</dd>
|
||||
<dt>anti-PAP</dt><dd>Código escrito <em>en contra</em> de esas reglas. Aquí: un stub que re-lista a mano lo que <code>routes.ncl</code> ya declara.</dd>
|
||||
<dt>PAP</dt><dd>Project's Architecture Principles — las reglas y patrones que sostienen la arquitectura: la fuente única de la verdad que todo el código debe respetar.</dd>
|
||||
<dt>anti-PAP</dt><dd>Código escrito en contra de esas reglas. Aquí: un stub que re-lista a mano lo que routes.ncl ya declara.</dd>
|
||||
</dl>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">Protocolo para declarar, versionar y verificar esto → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
</div>
|
||||
|
|
@ -72,20 +74,22 @@ Un caso no se juzga solo por lo que cuesta. Se juzga por lo que deja. De un punt
|
|||
<div class="exp-ledger cost">
|
||||
<h4>Lo que costó el crimen</h4>
|
||||
<ul>
|
||||
<li>Caza pura (framework) <b>~88 min</b></li>
|
||||
<li>Builds de release <b>8</b></li>
|
||||
<li>Fingerprints coexistiendo <b>6</b></li>
|
||||
<li>Reinicios del server <b>~15+</b></li>
|
||||
<li>Pistas falsas <b>8</b></li>
|
||||
<li>El arreglo <b>8 líneas</b></li>
|
||||
<li>Caza pura del origen (solo framework) <b>~88 <i>min</i></b></li>
|
||||
<li>Builds de release lanzados <b>8</b></li>
|
||||
<li>Reinicios del servidor <b>~15+</b></li>
|
||||
<li>Fingerprints de build coexistiendo <b>6</b></li>
|
||||
<li>Ficheros de código <i>ajeno</i> leídos (2 repos) <b>~20+</b></li>
|
||||
<li><span class='mono'>cargo clean</span> completos (867 MB al garete) <b>2</b></li>
|
||||
<li>Pistas falsas antes de la buena <b>8</b></li>
|
||||
<li>Tamaño del arreglo final <b>8 <i>líneas</i></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="exp-ledger yield">
|
||||
<h4>Lo que el caso dejó</h4>
|
||||
<ul>
|
||||
<li>Fix registry-driven <b>PAP‑compliant</b></li>
|
||||
<li>Fix registry-driven <b>PAP-compliant</b></li>
|
||||
<li>Conocimiento <b>content-kind-howto</b></li>
|
||||
<li>Mecanismo <b>modo generate‑expediente</b></li>
|
||||
<li>Mecanismo <b>modo generate-expediente</b></li>
|
||||
<li>Evidencia <b>proof (positioning)</b></li>
|
||||
<li>Serie + hub <b>/expedientes</b></li>
|
||||
<li>Framework <b>desatascado</b></li>
|
||||
|
|
@ -97,11 +101,22 @@ Esa es la profundidad creíble: la tarde de dolor no terminó en un parche, **te
|
|||
|
||||
<p class="exp-ex">El punto de abandono — lo que no sale en la tabla</p>
|
||||
|
||||
Hay un punto en toda deriva que ninguna tabla de coste registra: aquel en que dejas de tener miedo y empiezas a tener ganas de abandonar. Los bugs se vuelven fantasmas —aparecen, se van, vuelven con otra cara—; todo se para y no sabes por qué; ya no hay urgencia, solo cansancio. *"Es demasiado. No se puede resolver."* Ese es el momento real, el que no se mide en builds — y el que hace que un proyecto se muera en silencio.
|
||||
Hay un punto en toda deriva que ninguna tabla registra: aquel en que dejas de tener miedo y empiezas a tener ganas de abandonar. Los bugs se vuelven fantasmas; todo se para y no sabes por qué; ya no hay urgencia, solo cansancio. 'Es demasiado, no se puede resolver.' Con ontoref ese punto no debería llegar: el dao te hace nombrar la tensión en vez de colapsarla, los modos te dan el siguiente paso, los ADRs cierran lo ya decidido. La deriva no te hunde porque el mapa te sostiene.
|
||||
|
||||
Con [ontoref](https://ontoref.dev) ese punto no debería llegar. No porque no haya bugs, sino porque no te deja solo frente a ellos: el **dao** (ondaod) te obliga a *nombrar* la tensión en vez de colapsarla o abandonarla; los **modos** te dan el siguiente paso cuando ya no sabes cuál es; los **ADRs** cierran lo ya decidido para que no lo vuelvas a litigar a las dos de la mañana. La deriva no te hunde porque el mapa te sostiene.
|
||||
<p class="exp-ex">Los sospechosos — las pistas falsas</p>
|
||||
|
||||
<p class="exp-ex">El arma — un match hardcoded (anti-PAP)</p>
|
||||
<table class="exp-susp"><tbody>
|
||||
<tr><td><b>El contenido en <span class='mono'>site/r</span></b></td><td><em>“Yo no estaba sincronizado.”</em></td><td>cómplice menor</td></tr>
|
||||
<tr><td><b>filename ≠ <span class='mono'>id</span></b></td><td><em>“El body salía vacío, pero no fui yo el del 404.”</em></td><td>otro delito</td></tr>
|
||||
<tr><td><b>El comentario <span class='mono'>{# … #}</span></b></td><td><em>“Solo me colé como texto. Inocente.”</em></td><td>coartada firme</td></tr>
|
||||
<tr><td><b>Staleness & fingerprints</b></td><td><em>“Con 6 fingerprints, ¿cómo no ibas a dudar del binario?”</em></td><td>pista falsa</td></tr>
|
||||
<tr><td><b><span class='mono'>SITE_PUBLIC_PATH</span> / symlink</b></td><td><em>“El dir no existía. Symlink para nada.”</em></td><td>pista falsa</td></tr>
|
||||
<tr><td><b><span class='mono'>generated.rs</span> → <span class='mono'>vec!["content"]</span></b></td><td><em>“Soy el fallback. Ni me ejecuto.”</em></td><td>señuelo</td></tr>
|
||||
<tr><td><b>El RBAC</b></td><td><em>“Un <em>deny</em> mío es un 302, encanto. Un 404 no es cosa mía.”</em></td><td>coartada 302</td></tr>
|
||||
<tr><td><b><span class='mono'>build_page_generator</span></b></td><td><em>“Eso es de Leptos. En htmx-ssr ni aparezco.”</em></td><td>jurisdicción errónea</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="exp-ex">El arma — El arma · un match hardcoded (preexistente, anti-PAP)</p>
|
||||
|
||||
<pre>let kind = match base {
|
||||
"blog" => Some("blog"),
|
||||
|
|
@ -110,9 +125,9 @@ Con [ontoref](https://ontoref.dev) ese punto no debería llegar. No porque no ha
|
|||
_ => None, // ← /recursos caía aquí → 404
|
||||
};</pre>
|
||||
|
||||
Ruta registrada. Content‑type habilitado. La API devolvía los items. El contenido, presente. Y aun así, 404 — porque un stub decidía la verdad <em>en contra</em> de la fuente de la verdad.
|
||||
Ruta registrada, content-type habilitado, la API devolvía items, el contenido presente — y aun así 404, porque un stub decidía la verdad en contra de la fuente de la verdad.
|
||||
|
||||
<p class="exp-ex">El giro — 8 líneas que leen del registry</p>
|
||||
<p class="exp-ex">El giro — PAP-compliant · añadir un kind vuelve a ser solo NCL</p>
|
||||
|
||||
<pre>let kind = load_routes_config().routes.iter().find_map(|r| {
|
||||
if !r.enabled { return None; }
|
||||
|
|
@ -120,25 +135,21 @@ Ruta registrada. Content‑type habilitado. La API devolvía los items. El conte
|
|||
(seg == base).then(|| r.content_type.clone()).flatten()
|
||||
});</pre>
|
||||
|
||||
<code>/recursos</code>, <code>/proyectos</code>, <code>/dominios</code> — todos, alias bilingües incluidos — resueltos desde la única fuente. **Añadir un kind vuelve a ser solo NCL.**
|
||||
Todos los kinds, alias bilingües incluidos, resueltos desde la única fuente. Añadir un kind vuelve a ser solo NCL.
|
||||
|
||||
<p class="exp-ex">El veredicto</p>
|
||||
|
||||
No fuimos a la playa por decidir mal: fuimos porque **el mapa tenía una deriva sin señalizar**. Las cuatro cosas que [ontoref](https://ontoref.dev) instrumenta son las cuatro que faltaban:
|
||||
|
||||
<table class="exp-cont"><tbody>
|
||||
<tr><td>Stub hardcoded que sombrea el registry</td><td>PAP + anti-patterns → grep-able</td></tr>
|
||||
<tr><td>Conocimiento perdido, redescubierto por dolor</td><td>qa/howto → content-kind-howto</td></tr>
|
||||
<tr><td>Stub hardcoded que sombrea el registry</td><td>PAP + anti-patterns (grep-able)</td></tr>
|
||||
<tr><td>Conocimiento perdido, redescubierto por dolor</td><td>qa/howto (content-kind-howto)</td></tr>
|
||||
<tr><td>Construir sobre lo no-válido sin saberlo</td><td>review contra invariantes</td></tr>
|
||||
<tr><td>Asumir lo no-asumible, creer lo no-creíble</td><td>estado declarado → 3 niveles</td></tr>
|
||||
<tr><td>Asumir lo no-asumible, creer lo no-creíble</td><td>estado declarado (3 niveles)</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
Una deriva de diez líneas, sin señalizar, nos costó una tarde salir — y se arregló en ocho. Ese diferencial es el ROI. **[Ontoref](https://ontoref.dev) es la señalización del mapa.**
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<hr style="margin:2.5rem 0;border:0;border-top:1px solid rgba(128,128,128,.3)">
|
||||
<!--
|
||||
_glossary.html — Section glossary component embedded in the /expedientes hub.
|
||||
Self-contained: inline <style> + <script>, no external deps, CSP-safe, standalone.
|
||||
|
|
@ -498,3 +509,5 @@ Una deriva de diez líneas, sin señalizar, nos costó una tarde salir — y se
|
|||
jumpToHash();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
# Typed source for expediente "deriva-hardcoded-anti-pap".
|
||||
# The sibling .md is the rendered prose; this record is the machine-parseable
|
||||
# case. Regenerate/render via the `generate-expediente` mode.
|
||||
#
|
||||
# TWO surfaces are projected from this record and NEITHER may be hand-edited:
|
||||
# ../deriva-hardcoded-anti-pap.md the article (skin = 'detective)
|
||||
# site/public/images/expedientes/es/expedientes.html the informe (both skins)
|
||||
#
|
||||
# NEUTRAL (top level): what is true whoever tells it — ids, ledgers, code, the technical
|
||||
# names of the suspects, the containment table, the closing bottom line.
|
||||
# SKINNED (skins.<skin>): what the genre owns — loglines, headings, alibis, stamps, prose.
|
||||
#
|
||||
# Harvested 2026-07-12 from the informe's hand-maintained `var CASES` array + the previous
|
||||
# flattened contract. The ledger reconciles both: 6 rows lived in the .ncl, 8 in the
|
||||
# informe — the union (8, the informe's labels) is here.
|
||||
let s = import "../../../../schemas/expediente.ncl" in
|
||||
|
||||
s.make_expediente {
|
||||
|
|
@ -12,20 +22,25 @@ s.make_expediente {
|
|||
status = "RESUELTO",
|
||||
skin = 'detective,
|
||||
|
||||
logline = "Era una tarde tranquila. Entonces /recursos entró por la puerta y no mostró nada. Nada de nada. Y yo, ingenuo, pensé que sería rápido.",
|
||||
tab = "404-PAP · la deriva",
|
||||
print_href = "/images/expedientes/es/deriva.html",
|
||||
|
||||
glossary = [
|
||||
{ term = "PAP", def = "Project's Architecture Principles — las reglas y patrones que sostienen la arquitectura: la fuente única de la verdad que todo el código debe respetar." },
|
||||
{ term = "anti-PAP", def = "Código escrito en contra de esas reglas. Aquí: un stub que re-lista a mano lo que routes.ncl ya declara." },
|
||||
],
|
||||
|
||||
cost_caption = "Coste medible · sesión 2026-07-10 → 07-11",
|
||||
|
||||
cost = [
|
||||
{ label = "Caza pura del origen (framework)", value = "~88 min" },
|
||||
{ label = "Caza pura del origen (solo framework)", value = "~88 <i>min</i>" },
|
||||
{ label = "Builds de release lanzados", value = "8" },
|
||||
{ label = "Fingerprints de build coexistiendo", value = "6" },
|
||||
{ label = "Reinicios del servidor", value = "~15+" },
|
||||
{ label = "Fingerprints de build coexistiendo", value = "6" },
|
||||
{ label = "Ficheros de código <i>ajeno</i> leídos (2 repos)", value = "~20+" },
|
||||
{ label = "<span class='mono'>cargo clean</span> completos (867 MB al garete)", value = "2" },
|
||||
{ label = "Pistas falsas antes de la buena", value = "8", emphasis = true },
|
||||
{ label = "Tamaño del arreglo final", value = "8 líneas", emphasis = true },
|
||||
{ label = "Tamaño del arreglo final", value = "8 <i>líneas</i>", emphasis = true },
|
||||
],
|
||||
|
||||
yielded = [
|
||||
|
|
@ -37,21 +52,18 @@ s.make_expediente {
|
|||
{ label = "Framework", value = "desatascado" },
|
||||
],
|
||||
|
||||
abandonment = "Hay un punto en toda deriva que ninguna tabla registra: aquel en que dejas de tener miedo y empiezas a tener ganas de abandonar. Los bugs se vuelven fantasmas; todo se para y no sabes por qué; ya no hay urgencia, solo cansancio. 'Es demasiado, no se puede resolver.' Con ontoref ese punto no debería llegar: el dao te hace nombrar la tensión en vez de colapsarla, los modos te dan el siguiente paso, los ADRs cierran lo ya decidido. La deriva no te hunde porque el mapa te sostiene.",
|
||||
|
||||
suspects = [
|
||||
{ name = "El contenido en site/r", alibi = "Yo no estaba sincronizado.", verdict = "cómplice menor" },
|
||||
{ name = "filename != id", alibi = "El body salía vacío, pero no fui yo el del 404.", verdict = "otro delito" },
|
||||
{ name = "El comentario {# … #}", alibi = "Solo me colé como texto. Inocente.", verdict = "coartada firme" },
|
||||
{ name = "Staleness & fingerprints", alibi = "Con 6 fingerprints, ¿cómo no ibas a dudar del binario?", verdict = "pista falsa" },
|
||||
{ name = "SITE_PUBLIC_PATH / symlink", alibi = "El dir no existía. Symlink para nada.", verdict = "pista falsa" },
|
||||
{ name = "generated.rs → vec![\"content\"]", alibi = "Soy el fallback. Ni me ejecuto.", verdict = "señuelo" },
|
||||
{ name = "El RBAC", alibi = "Un deny mío es un 302, encanto. Un 404 no es cosa mía.", verdict = "coartada 302" },
|
||||
{ name = "build_page_generator", alibi = "Eso es de Leptos. En htmx-ssr ni aparezco.", verdict = "jurisdicción errónea" },
|
||||
{ name = "El contenido en <span class='mono'>site/r</span>" },
|
||||
{ name = "filename ≠ <span class='mono'>id</span>" },
|
||||
{ name = "El comentario <span class='mono'>{# … #}</span>" },
|
||||
{ name = "Staleness & fingerprints" },
|
||||
{ name = "<span class='mono'>SITE_PUBLIC_PATH</span> / symlink" },
|
||||
{ name = "<span class='mono'>generated.rs</span> → <span class='mono'>vec![\"content\"]</span>" },
|
||||
{ name = "El RBAC" },
|
||||
{ name = "<span class='mono'>build_page_generator</span>" },
|
||||
],
|
||||
|
||||
weapon = {
|
||||
caption = "El arma · un match hardcoded (preexistente, anti-PAP)",
|
||||
code = m%"
|
||||
let kind = match base {
|
||||
"blog" => Some("blog"),
|
||||
|
|
@ -60,11 +72,17 @@ let kind = match base {
|
|||
_ => None, // ← /recursos caía aquí → 404
|
||||
};
|
||||
"%,
|
||||
note = "Ruta registrada, content-type habilitado, la API devolvía items, el contenido presente — y aun así 404, porque un stub decidía la verdad en contra de la fuente de la verdad.",
|
||||
code_html = m%"
|
||||
<span class="k">let</span> kind = <span class="k">match</span> base {
|
||||
<span class="g">"blog"</span> => Some(<span class="g">"blog"</span>),
|
||||
<span class="g">"activities"</span> | <span class="g">"actividades"</span> => Some(<span class="g">"activities"</span>),
|
||||
<span class="c">// … todos los kinds a mano … menos resources …</span>
|
||||
_ => <span class="r">None</span>, <span class="c">// ← /recursos caía aquí → 404</span>
|
||||
};
|
||||
"%,
|
||||
},
|
||||
|
||||
fix = {
|
||||
caption = "El giro · 8 líneas que leen del registry (PAP-compliant)",
|
||||
code = m%"
|
||||
let kind = load_routes_config().routes.iter().find_map(|r| {
|
||||
if !r.enabled { return None; }
|
||||
|
|
@ -72,18 +90,176 @@ let kind = load_routes_config().routes.iter().find_map(|r| {
|
|||
(seg == base).then(|| r.content_type.clone()).flatten()
|
||||
});
|
||||
"%,
|
||||
note = "Todos los kinds, alias bilingües incluidos, resueltos desde la única fuente. Añadir un kind vuelve a ser solo NCL.",
|
||||
code_html = m%"
|
||||
<span class="k">let</span> kind = load_routes_config().routes.iter().find_map(|r| {
|
||||
<span class="k">if</span> !r.enabled { <span class="k">return</span> <span class="r">None</span>; }
|
||||
<span class="k">let</span> seg = r.path.trim_start_matches(<span class="g">'/'</span>).split(<span class="g">'/'</span>).next().unwrap_or(<span class="g">""</span>);
|
||||
(seg == base).then(|| r.content_type.clone()).flatten()
|
||||
});
|
||||
"%,
|
||||
},
|
||||
|
||||
containment = [
|
||||
{ facet = "Stub hardcoded que sombrea el registry", mechanism = "PAP + anti-patterns (grep-able)" },
|
||||
{ facet = "Conocimiento perdido, redescubierto por dolor", mechanism = "qa/howto (content-kind-howto)" },
|
||||
{ facet = "Construir sobre lo no-válido sin saberlo", mechanism = "review contra invariantes" },
|
||||
{ facet = "Asumir lo no-asumible, creer lo no-creíble", mechanism = "estado declarado (3 niveles)" },
|
||||
{
|
||||
facet = "Stub hardcoded que sombrea el registry",
|
||||
mechanism = "PAP + anti-patterns (grep-able)",
|
||||
mechanism_html = "PAP + anti-patterns <span class='kicknum'>→ grep-able</span>",
|
||||
},
|
||||
{
|
||||
facet = "Conocimiento perdido, redescubierto por dolor",
|
||||
mechanism = "qa/howto (content-kind-howto)",
|
||||
mechanism_html = "qa/howto <span class='kicknum'>→ content-kind-howto</span>",
|
||||
},
|
||||
{
|
||||
facet = "Construir sobre lo no-válido sin saberlo",
|
||||
mechanism = "review contra invariantes",
|
||||
mechanism_html = "review contra invariantes",
|
||||
},
|
||||
{
|
||||
facet = "Asumir lo no-asumible, creer lo no-creíble",
|
||||
mechanism = "estado declarado (3 niveles)",
|
||||
mechanism_html = "estado declarado <span class='kicknum'>→ 3 niveles</span>",
|
||||
},
|
||||
],
|
||||
|
||||
verdict = "No fuimos a la playa por decidir mal: fuimos porque el mapa tenía una deriva sin señalizar. Una deriva de diez líneas nos costó una tarde salir y se arregló en ocho. Ese diferencial es el ROI. Ontoref es la señalización del mapa.",
|
||||
verdict_close = "Ese diferencial —<strong>una tarde ↔ ocho líneas</strong>— no es anécdota: es el ROI. Los mecanismos de <a href='https://ontoref.dev' target='_blank' rel='noopener'>ontoref</a> no son burocracia; son el freno que convierte “una tarde de caza por dos repos ajenos” en “una consulta y un PR de ocho líneas”.",
|
||||
|
||||
skins = {
|
||||
detective = {
|
||||
h1_lead = "El caso de la ",
|
||||
h1_red = "deriva hardcoded",
|
||||
logline = "Era una tarde tranquila. Entonces <span class='mono'>/recursos</span> entró por la puerta y no mostró nada. Nada de nada. Y yo, ingenuo, pensé que sería rápido.",
|
||||
victim = "Víctima: una tarde",
|
||||
stamp = "8 LÍNEAS",
|
||||
foot_right = "Detective: turno de noche · Soplón: el PAP",
|
||||
|
||||
exhibits = [
|
||||
"El cuerpo",
|
||||
"La rueda de sospechosos",
|
||||
"El arma",
|
||||
"La confesión",
|
||||
"El veredicto",
|
||||
],
|
||||
|
||||
headings = [
|
||||
"Lo que costó el crimen",
|
||||
"Ocho coartadas, ocho callejones",
|
||||
"El cadáver estaba en <span class='mono'>render_content_or_grid</span>",
|
||||
"Ocho líneas para volver al redil",
|
||||
"No fuimos a la playa por decidir mal",
|
||||
],
|
||||
|
||||
lead_cost = "No mido el daño en sensaciones. Lo mido en builds. Esto quedó sobre la mesa del forense:",
|
||||
lead_suspects = "Sin howto, sin review, sin nadie gritando “¡eso es hardcoded!”, interrogué a cada sospechoso. Todos mentían. Todos costaban un build.",
|
||||
|
||||
alibis = [
|
||||
"Yo no estaba sincronizado.",
|
||||
"El body salía vacío, pero no fui yo el del 404.",
|
||||
"Solo me colé como texto. Inocente.",
|
||||
"Con 6 fingerprints, ¿cómo no ibas a dudar del binario?",
|
||||
"El dir no existía. Symlink para nada.",
|
||||
"Soy el fallback. Ni me ejecuto.",
|
||||
"Un <em>deny</em> mío es un 302, encanto. Un 404 no es cosa mía.",
|
||||
"Eso es de Leptos. En htmx-ssr ni aparezco.",
|
||||
],
|
||||
|
||||
suspect_tags = [
|
||||
"cómplice menor",
|
||||
"otro delito",
|
||||
"coartada firme",
|
||||
"pista falsa",
|
||||
"pista falsa",
|
||||
"señuelo",
|
||||
"coartada 302",
|
||||
"jurisdicción errónea",
|
||||
],
|
||||
|
||||
weapon_caption = "El arma · un match hardcoded (preexistente, anti-PAP)",
|
||||
weapon_after = "Ruta registrada. Content-type habilitado. La <span class='mono'>API</span> devolvía los items. El contenido, presente. Y aun así, 404 — porque un stub decidía la verdad <em>en contra</em> de la fuente de la verdad.",
|
||||
weapon_note = "Ruta registrada, content-type habilitado, la API devolvía items, el contenido presente — y aun así 404, porque un stub decidía la verdad en contra de la fuente de la verdad.",
|
||||
|
||||
fix_caption = "PAP-compliant · añadir un kind vuelve a ser solo NCL",
|
||||
fix_after = "<span class='mono'>/recursos</span>, <span class='mono'>/proyectos</span>, <span class='mono'>/dominios</span> — todos, alias bilingües incluidos — resueltos desde la única fuente.",
|
||||
fix_note = "Todos los kinds, alias bilingües incluidos, resueltos desde la única fuente. Añadir un kind vuelve a ser solo NCL.",
|
||||
|
||||
verdict_lead = "Elegimos bien. Fuimos a donde dijimos evitar porque <strong>el mapa tenía una deriva sin señalizar</strong>: hardcoded, no declarada, no documentada, no revisada. Las cuatro cosas que ontoref instrumenta:",
|
||||
verdict_prose = "No fuimos a la playa por decidir mal: fuimos porque **el mapa tenía una deriva sin señalizar**. Las cuatro cosas que [ontoref](https://ontoref.dev) instrumenta son las cuatro que faltaban:",
|
||||
|
||||
abandonment = "Hay un punto en toda deriva que ninguna tabla registra: aquel en que dejas de tener miedo y empiezas a tener ganas de abandonar. Los bugs se vuelven fantasmas; todo se para y no sabes por qué; ya no hay urgencia, solo cansancio. 'Es demasiado, no se puede resolver.' Con ontoref ese punto no debería llegar: el dao te hace nombrar la tensión en vez de colapsarla, los modos te dan el siguiente paso, los ADRs cierran lo ya decidido. La deriva no te hunde porque el mapa te sostiene.",
|
||||
|
||||
balance = "Un caso no se juzga solo por lo que cuesta. Se juzga por lo que deja. De un punto **des‑esperado** —ocho pistas falsas, seis fingerprints, la tarde entera— recobramos el rumbo, y lo que venía en el mismo viaje encajó de golpe:",
|
||||
|
||||
balance_outro = "Esa es la profundidad creíble: la tarde de dolor no terminó en un parche, **terminó en sistema declarado**. Ontología, modos y posicionamiento no se \"añadieron\" — estaban viajando juntos y el caso los hizo converger.",
|
||||
|
||||
closing = "Una deriva de diez líneas, sin señalizar, nos costó una tarde salir — y se arregló en ocho. Ese diferencial es el ROI. **[Ontoref](https://ontoref.dev) es la señalización del mapa.**",
|
||||
},
|
||||
|
||||
clinical = {
|
||||
h1_lead = "Patología: la ",
|
||||
h1_red = "deriva hardcoded",
|
||||
logline = "Turno tranquilo en urgencias. Entonces ingresó <span class='mono'>/recursos</span>: consciente, con constantes, con rutas… pero sin mostrar nada. Y yo, ingenuo, pensé que sería un alta rápida.",
|
||||
victim = "Ingreso: una tarde",
|
||||
stamp = "8 LÍNEAS",
|
||||
foot_right = "Médico: guardia de noche · Aviso: el PAP",
|
||||
|
||||
exhibits = [
|
||||
"Cuadro clínico",
|
||||
"Diagnóstico diferencial",
|
||||
"Etiología",
|
||||
"Tratamiento",
|
||||
"Pronóstico y profilaxis",
|
||||
],
|
||||
|
||||
headings = [
|
||||
"Gravedad del cuadro",
|
||||
"Ocho diagnósticos descartados",
|
||||
"El patógeno vivía en <span class='mono'>render_content_or_grid</span>",
|
||||
"Ocho líneas de terapia",
|
||||
"No fue mala praxis: fue un mapa sin señalizar",
|
||||
],
|
||||
|
||||
lead_cost = "La gravedad no se mide en angustia. Se mide en builds. Estas fueron las constantes del paciente:",
|
||||
lead_suspects = "Sin protocolo ni historia previa, fui descartando diagnósticos plausibles uno a uno. Todos negativos. Cada prueba, un build.",
|
||||
|
||||
alibis = [
|
||||
"Mis datos no estaban al día, pero no causé el paro.",
|
||||
"El cuadro salía vacío; no soy el foco.",
|
||||
"Solo aparecí como texto en la analítica. Benigno.",
|
||||
"Con 6 marcadores discordantes, ¿cómo no sospechar del binario?",
|
||||
"El órgano no existía. Vía sin salida.",
|
||||
"Soy la rama por defecto. Ni me activo.",
|
||||
"Lo mío es un 302, no un 404. Otra especialidad.",
|
||||
"Eso es de Leptos. En htmx-ssr ni figuro en la historia.",
|
||||
],
|
||||
|
||||
suspect_tags = [
|
||||
"comorbilidad menor",
|
||||
"hallazgo aparte",
|
||||
"analítica negativa",
|
||||
"falso positivo",
|
||||
"falso positivo",
|
||||
"incidental benigno",
|
||||
"otra especialidad",
|
||||
"fuera de servicio",
|
||||
],
|
||||
|
||||
weapon_caption = "Etiología · un match hardcoded (preexistente, anti-PAP)",
|
||||
weapon_after = "Ruta registrada. Content-type habilitado. La <span class='mono'>API</span> devolvía los items. El contenido, presente. Y aun así, 404 — porque un stub decidía la verdad <em>en contra</em> de la fuente de la verdad.",
|
||||
# Sin fuente: `note` es prosa del .md y el .md es detective.
|
||||
weapon_note = "",
|
||||
|
||||
fix_caption = "PAP-compliant · añadir un kind vuelve a ser solo NCL",
|
||||
fix_after = "<span class='mono'>/recursos</span>, <span class='mono'>/proyectos</span>, <span class='mono'>/dominios</span> — todos, alias bilingües incluidos — resueltos desde la única fuente.",
|
||||
fix_note = "",
|
||||
|
||||
verdict_lead = "No hubo mala praxis. Actuamos donde dijimos evitar porque <strong>la carta clínica tenía una lesión sin registrar</strong>: hardcoded, no declarada, no documentada, no revisada. Las cuatro cosas que ontoref instrumenta:",
|
||||
# Sin fuente long-form: no existe .md clínico. Se reutiliza el verdict_lead — la
|
||||
# frase existente más próxima — en vez de inventar prosa.
|
||||
verdict_prose = "No hubo mala praxis. Actuamos donde dijimos evitar porque <strong>la carta clínica tenía una lesión sin registrar</strong>: hardcoded, no declarada, no documentada, no revisada. Las cuatro cosas que ontoref instrumenta:",
|
||||
},
|
||||
},
|
||||
|
||||
ontoref_url = "https://ontoref.dev",
|
||||
related_proofs = ["proof-anti-pap-drift"],
|
||||
related_modes = ["generate-expediente"],
|
||||
tags = ["expediente", "anti-pap", "deriva", "dogfooding", "ontoref", "developer"],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,539 @@
|
|||
---
|
||||
id: "espejo-que-revertia-su-trabajo"
|
||||
title: "Expediente 69/58: el espejo que revertía su propio trabajo"
|
||||
slug: "el-espejo-que-revertia-su-trabajo"
|
||||
subtitle: "La herramienta decía 69. El disco tenía 58. Nadie comparaba las dos cosas — durante cuatro semanas"
|
||||
excerpt: "Once ADR escritos y jamás publicados. Cero contenido llegado a producción. 214 líneas de trabajo a un comando de desaparecer. Y ni un solo error en los logs: la herramienta informaba éxito y nadie leía el disco. La pregunta de la serie es «¿con ontoref no hubiera sido así?» — y este es el caso que responde que no, porque pasó dentro de ontoref con la regla ya aceptada."
|
||||
author: "Jesús Pérez"
|
||||
date: "2026-07-12"
|
||||
published: true
|
||||
featured: true
|
||||
category: "casos"
|
||||
tags: ["expediente", "anti-pap", "deploy", "witness", "dogfooding", "ontoref", "developer"]
|
||||
thumbnail: "/images/expedientes/deriva-header.webp"
|
||||
image_url: "/images/expedientes/deriva-header.webp"
|
||||
sort_order: 3
|
||||
---
|
||||
|
||||
<section class="exp">
|
||||
|
||||
<style>
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-ledger h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-ledger.cost h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-ledger.yield h4{background:rgba(20,120,60,.12)}
|
||||
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>
|
||||
|
||||
<a class="exp-hero" href="/images/expedientes/es/expedientes.html#espejo-que-revertia-su-trabajo" target="_blank" rel="noopener" title="Abrir la historia clínica completa"><img src="/images/expedientes/deriva-header.webp" alt="Expediente 69/58: el espejo que revertía su propio trabajo" width="1200" height="675" loading="lazy"></a>
|
||||
|
||||
<p style="margin:0 0 1.4rem"><a class="exp-btn" href="/images/expedientes/es/expedientes.html#espejo-que-revertia-su-trabajo" target="_blank" rel="noopener">🩺 Mostrar historia clínica →</a></p>
|
||||
|
||||
<p class="exp-kick">Historia clínica · Patología del Software</p>
|
||||
|
||||
<p class="exp-log">El paciente no tenía síntomas. Publicaba sin errores, la herramienta confirmaba el éxito, los logs salían en verde. Llevaba cuatro semanas publicando en el vacío.</p>
|
||||
|
||||
<div class="exp-meta"><span>Historia Nº <b>69/58</b></span><span>Diagnóstico: ANTI-PAP · EL REPORTER DECIDÍA</span><span>Estado: RESUELTO</span></div>
|
||||
|
||||
<div class="exp-gloss">
|
||||
<dl>
|
||||
<dt>PAP</dt><dd>Project's Architecture Principles — las reglas que sostienen la arquitectura: la fuente única que todo el código debe respetar.</dd>
|
||||
<dt>anti-PAP</dt><dd>Código escrito en contra de esas reglas. Aquí: un mirror que decidía por dirección en vez de por propiedad.</dd>
|
||||
<dt>el check decide, nunca el reporter</dt><dd>ADR-066, aceptado el 4-jul-2026: donde exista una comprobación mecánica, decide la comprobación; la palabra de quien informa puede negarse a ser contradicha, jamás imponerse. Este expediente es lo que ocurre en la única superficie donde esa regla no se aplicó.</dd>
|
||||
<dt>testigo</dt><dd>Una constancia verificable de forma independiente de que algo es lo que dice ser. Un backup sin testigo no es un backup: es una copia en la que confías.</dd>
|
||||
</dl>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">Protocolo para declarar, versionar y verificar esto → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">El doble balance — lo que costó, y lo que dejó</p>
|
||||
|
||||
<div class="exp-balance">
|
||||
<div class="exp-ledger cost">
|
||||
<h4>Lo que costó el crimen</h4>
|
||||
<ul>
|
||||
<li>Lo que la herramienta informaba <b>"index.json with 69 posts"</b></li>
|
||||
<li>Lo que había en el disco <b>58</b></li>
|
||||
<li>ADRs escritos y jamás publicados <b>11 (059→069)</b></li>
|
||||
<li>Ventana de invisibilidad <b>13-jun → 11-jul (fechas de los propios ADR)</b></li>
|
||||
<li>Contenido llegado a producción en ese tiempo <b>0</b></li>
|
||||
<li>Ficheros que el repo del site tenía trackeados <b>1 (README.md)</b></li>
|
||||
<li>git_sha estampado en TODOS los packs de deploy <b>5619a40 (siempre el mismo)</b></li>
|
||||
<li>Líneas de trabajo vivas sin un solo testigo <b>214</b></li>
|
||||
<li>Comandos de distancia hasta borrarlas <b>1 (<span class='mono'>just templates</span>)</b></li>
|
||||
<li>Tamaño del arreglo final <b>60 <i>líneas</i></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="exp-ledger yield">
|
||||
<h4>Lo que el caso dejó</h4>
|
||||
<ul>
|
||||
<li>Decisión <b>ADR-070 (aceptado)</b></li>
|
||||
<li>Gates <b>adr-check · templates-check · posts-check</b></li>
|
||||
<li>Migración <b>0044 (rutas ancladas)</b></li>
|
||||
<li>La ranura que faltaba <b>site/templates-overlay/</b></li>
|
||||
<li>Constraints curadas al anclar <b>29 (88✓ → 117✓)</b></li>
|
||||
<li>Ontología <b>el converso en enforcement-vs-emergence</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">El punto de abandono — lo que no sale en la tabla</p>
|
||||
|
||||
El punto de abandono de este caso no llegó con un error, sino con una frase: «he ido crate por crate por un sistema de codegen complejo y estoy muy pasado del punto razonable para seguir a ciegas». Es lo que dice una máquina cuando se le acaba el mapa y sigue caminando. Y la respuesta que lo desbloqueó no fue técnica: fue que alguien dibujara la jerarquía de niveles —rustelo → website-htmx-rustelo → outreach/site— que existía en una cabeza y en ningún fichero. Ese es el diagnóstico entero, dicho antes de tenerlo: lo que no está declarado, alguien lo sostiene con su memoria. Y la memoria se cansa.
|
||||
|
||||
<p class="exp-ex">Diagnóstico diferencial — lo que se descartó</p>
|
||||
|
||||
<table class="exp-susp"><tbody>
|
||||
<tr><td><b>Los 6 huérfanos en <span class='mono'>site/r</span></b></td><td><em>“Sí, soy contenido muerto que sigue sirviéndose. Pero yo no impido publicar.”</em></td><td>síntoma, no causa</td></tr>
|
||||
<tr><td><b><span class='mono'>gen-adr-pages</span> roto</b></td><td><em>“Yo funciono perfectamente. Genero los 69 ADR cuando alguien me llama.”</em></td><td>inocente — nadie lo llamaba</td></tr>
|
||||
<tr><td><b><span class='mono'>content_processor</span> no procesa expedientes</b></td><td><em>“Sí los proceso. Lo que pasa es que me leíste con un <span class='mono'>head -20</span> y te cortó la salida.”</em></td><td>coartada firme (el error fue del investigador)</td></tr>
|
||||
<tr><td><b>El índice no se regenera</b></td><td><em>“Me regenero cada vez. Con 69. Lo que pasa después no es cosa mía.”</em></td><td>inocente — y la clave del caso</td></tr>
|
||||
<tr><td><b>El generador del grafo</b></td><td><em>“Yo exporto lo que hay en el árbol de contenido. Si me metéis un contrato entre los datos, reviento. Y hago bien.”</em></td><td>inocente — tenía razón</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="exp-ex">Etiología — la causa — El mirror que decidía por dirección, no por propiedad</p>
|
||||
|
||||
<pre># La receta `content`, tal y como estaba:
|
||||
content_processor # escribe los índices en site/r
|
||||
rsync -a "site/public/r/" "site/r/" # ← el arma
|
||||
|
||||
# Dos árboles. Cada uno DUEÑO de ficheros distintos:
|
||||
# site/r ← content_processor escribe aquí (lo lee el servidor)
|
||||
# site/public/r ← los generadores nu escriben aquí (y es lo que EMBARCA el deploy)
|
||||
#
|
||||
# El rsync corría en un solo sentido, y en el sentido equivocado:
|
||||
# · pisaba los índices recién generados con los fósiles del árbol de deploy
|
||||
# · y NUNCA llevaba el contenido nuevo al árbol que viaja a producción
|
||||
#
|
||||
# rsync -a sin --update no respeta "el destino es más nuevo": compara tamaño y
|
||||
# mtime, y si difieren, COPIA LA FUENTE. Un mirror cuya fuente se ha fosilizado
|
||||
# no es un no-op: es una máquina de resucitar estado viejo.
|
||||
#
|
||||
# La herramienta imprimía "Generated index.json with 69 posts".
|
||||
# El disco se quedaba con 58.
|
||||
# Nadie comparaba las dos cosas.</pre>
|
||||
|
||||
just — receta content
|
||||
|
||||
<p class="exp-ex">Tratamiento — El mirror se tipa por propiedad, y el check lee el disco</p>
|
||||
|
||||
<pre>content_processor
|
||||
# Índices de contenido → árbol de deploy. --delete: sin él, un slug renombrado
|
||||
# sobrevive como URL viva EN PRODUCCIÓN.
|
||||
rsync -a --delete \
|
||||
--exclude about.json --exclude adr-map.json \
|
||||
--exclude taglines.json --exclude content_graph.json \
|
||||
"site/r/" "site/public/r/"
|
||||
# Los cuatro artefactos nu → árbol servido. Van excluidos del tramo de ida, o las
|
||||
# copias viejas de site/r machacarían los originales recién generados.
|
||||
for f in about.json adr-map.json taglines.json content_graph.json; do
|
||||
cp -f "site/public/r/$f" "site/r/$f"
|
||||
done
|
||||
|
||||
# Y tres gates que ASERTAN CONTRA EL DISCO, nunca contra el stdout de la herramienta:
|
||||
# adr-check el spine no tiene ningún ADR que el site no publique
|
||||
# templates-check el árbol ensamblado se reproduce exacto desde su fuente declarada
|
||||
# posts-check linkify + cobertura del grafo
|
||||
#
|
||||
# Los tres se pueden ejecutar MIENTRAS el daño sigue siendo hipotético.
|
||||
# Un check que solo puedes correr después del paso destructivo no es un gate:
|
||||
# es una autopsia.</pre>
|
||||
|
||||
just — receta content
|
||||
|
||||
<p class="exp-ex">Pronóstico</p>
|
||||
|
||||
La serie hace una pregunta: ¿con ontoref no hubiera sido así? Casi siempre la respuesta es sí, y por eso hay serie. Este expediente es el que responde que NO — y por eso es el que más vale. Esto pasó DENTRO de ontoref, con la ontología cargada, los gates escritos y ADR-066 —«el check decide, nunca el reporter»— aceptado una semana antes y probado con una corrida de falsación de ocho caminos. La regla que nombra exactamente esta patología ya existía mientras el pipeline que publica ontoref al mundo seguía dejando decidir al reporter. El protocolo no falló: es que la difusión era el punto ciego, la única superficie que el protocolo no gobernaba. Cada mecanismo que habría atrapado cada uno de estos fallos ya existía y estaba aceptado en este mismo repo. Ninguno estaba apuntado aquí. Y esa es la respuesta útil, la que un caso cómodo no da: no basta con tener el mecanismo — hay que apuntarlo a la superficie que duele. Un enforcement en el que no puedes confiar que falle es indistinguible de no tener enforcement, y es más peligroso, porque se cree.
|
||||
|
||||
<table class="exp-cont"><tbody>
|
||||
<tr><td>El mirror corría por dirección, no por propiedad de cada fichero</td><td>estado declarado — ADR-070: todo árbol declara su DUEÑO. Un sync de árbol completo en un sentido, entre dos árboles que poseen ficheros distintos, es necesariamente falso en una de las dos direcciones.</td></tr>
|
||||
<tr><td>La herramienta informaba éxito y nadie comprobaba el disco</td><td>review contra invariante — ADR-066 (el check decide, nunca el reporter), aplicado por fin a la superficie que publica el protocolo: los gates leen el disco, no el stdout.</td></tr>
|
||||
<tr><td>Un generador existía y ninguna receta lo llamaba (11 ADR invisibles)</td><td>estado declarado — ADR-070: todo árbol declara su REPRODUCCIÓN, y esa receta está en la cadena. Un generador fuera de la cadena de dependencias es un ritual privado, no un build.</td></tr>
|
||||
<tr><td>214 líneas vivían en el único árbol que el build borra</td><td>estado declarado — ADR-070: todo árbol declara su TESTIGO (reproducible, o rastreado). El nivel del site no tenía ranura de overlay: el trabajo no tenía dónde vivir legalmente.</td></tr>
|
||||
<tr><td>Un contrato archivado entre los datos que tipa (just graph muerto)</td><td>PAP + anti-pattern — `contract-in-the-instance-tree`: site/content/ es árbol de instancias; un esquema ahí es un error de categoría, y el generador que revienta tiene razón.</td></tr>
|
||||
<tr><td>Un check significaba una cosa según desde dónde lo lanzaras</td><td>review contra invariante — migración 0044: rutas y cwd anclados a la raíz del proyecto. Un `must_be_empty` sobre una ruta que no resuelve reporta SATISFECHO: una ruta rota no te da un rojo, te da la razón.</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<!--
|
||||
_glossary.html — Section glossary component embedded in the /expedientes hub.
|
||||
Self-contained: inline <style> + <script>, no external deps, CSP-safe, standalone.
|
||||
Data-driven: edit the TERMS array below ({id, term, def, aliases?}) to add entries.
|
||||
Prose links jump to a term with <a href="#gl-pap">…</a> (anchor = "gl-" + id);
|
||||
landing on #gl-<id> scrolls the term into view and briefly highlights it.
|
||||
Search box live-filters by term text, definition, and aliases as you type.
|
||||
-->
|
||||
<section class="glz" aria-label="Glosario de la sección Expedientes">
|
||||
<header class="glz__head">
|
||||
<h2 class="glz__title">Glosario</h2>
|
||||
<div class="glz__search">
|
||||
<input
|
||||
type="search"
|
||||
id="glz-q"
|
||||
class="glz__input"
|
||||
placeholder="Filtrar términos…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Filtrar términos del glosario"
|
||||
/>
|
||||
<span class="glz__count" id="glz-count" aria-live="polite"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl class="glz__list" id="glz-list"></dl>
|
||||
<p class="glz__empty" id="glz-empty" hidden>Sin coincidencias.</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.glz {
|
||||
--glz-bg: #ffffff;
|
||||
--glz-fg: #1a1a1a;
|
||||
--glz-muted: #6b7280;
|
||||
--glz-border: #e5e7eb;
|
||||
--glz-accent: #2563eb;
|
||||
--glz-mark: #fef08a;
|
||||
--glz-chip: #f3f4f6;
|
||||
--glz-flash: rgba(37, 99, 235, 0.16);
|
||||
|
||||
max-width: 44rem;
|
||||
margin: 0 auto;
|
||||
color: var(--glz-fg);
|
||||
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glz {
|
||||
--glz-bg: #0d1117;
|
||||
--glz-fg: #e6edf3;
|
||||
--glz-muted: #8b949e;
|
||||
--glz-border: #21262d;
|
||||
--glz-accent: #58a6ff;
|
||||
--glz-mark: #9a7b00;
|
||||
--glz-chip: #161b22;
|
||||
--glz-flash: rgba(88, 166, 255, 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
.glz__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.glz__title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.glz__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1 1 14rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.glz__input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.65rem;
|
||||
font: inherit;
|
||||
color: var(--glz-fg);
|
||||
background: var(--glz-bg);
|
||||
border: 1px solid var(--glz-border);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.glz__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--glz-accent);
|
||||
box-shadow: 0 0 0 3px var(--glz-flash);
|
||||
}
|
||||
.glz__count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--glz-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.glz__list {
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--glz-border);
|
||||
}
|
||||
.glz__row {
|
||||
padding: 0.7rem 0.15rem;
|
||||
border-bottom: 1px solid var(--glz-border);
|
||||
scroll-margin-top: 5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.glz__row.is-flash {
|
||||
background: var(--glz-flash);
|
||||
}
|
||||
.glz__term {
|
||||
margin: 0;
|
||||
font-weight: 640;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
.glz__aliases {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
color: var(--glz-muted);
|
||||
}
|
||||
.glz__alias {
|
||||
display: inline-block;
|
||||
padding: 0.02rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
background: var(--glz-chip);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.glz__def {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--glz-fg);
|
||||
}
|
||||
.glz__def mark {
|
||||
background: var(--glz-mark);
|
||||
color: inherit;
|
||||
padding: 0 0.1em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.glz__empty {
|
||||
padding: 1rem 0.15rem;
|
||||
color: var(--glz-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
code, .glz__code {
|
||||
font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, monospace;
|
||||
font-size: 0.86em;
|
||||
background: var(--glz-chip);
|
||||
padding: 0.05em 0.35em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var TERMS = [
|
||||
{
|
||||
id: "pap",
|
||||
term: "PAP",
|
||||
aliases: ["Project's Architecture Principles"],
|
||||
def: "Los principios, reglas y patrones de arquitectura del proyecto. Una decisión “conforme a PAP” respeta las invariantes establecidas."
|
||||
},
|
||||
{
|
||||
id: "anti-pap",
|
||||
term: "anti-PAP",
|
||||
aliases: ["contra PAP"],
|
||||
def: "Un enfoque que va completamente en contra de la arquitectura establecida del proyecto; se rechaza aunque “funcione”."
|
||||
},
|
||||
{
|
||||
id: "registry",
|
||||
term: "registry",
|
||||
aliases: ["registro"],
|
||||
def: "Tabla central que mapea cada tipo de contenido a su handler y metadatos, de modo que las rutas se resuelven de forma declarativa."
|
||||
},
|
||||
{
|
||||
id: "contentkind",
|
||||
term: "ContentKind",
|
||||
aliases: ["variant", "enum"],
|
||||
def: "El tipo (enum) que enumera las clases de contenido que el sistema sabe renderizar; cada variante corresponde a un kind."
|
||||
},
|
||||
{
|
||||
id: "kind",
|
||||
term: "kind",
|
||||
aliases: ["tipo de contenido"],
|
||||
def: "Discriminante de un contenido: identifica a qué ContentKind pertenece y, por tanto, qué plantilla y ruta le aplican."
|
||||
},
|
||||
{
|
||||
id: "htmx-ssr",
|
||||
term: "htmx-ssr",
|
||||
aliases: ["server-side rendering", "SSR"],
|
||||
def: "Renderizado en servidor con fragmentos HTML entregados a htmx: el servidor devuelve HTML listo y el cliente solo intercambia trozos, sin framework pesado."
|
||||
},
|
||||
{
|
||||
id: "routes-ncl",
|
||||
term: "routes.ncl",
|
||||
aliases: ["routes", "Nickel routes"],
|
||||
def: "Archivo Nickel declarativo que define las rutas del sitio y las asocia a cada kind; fuente única de verdad para el enrutado."
|
||||
},
|
||||
{
|
||||
id: "deriva",
|
||||
term: "deriva",
|
||||
aliases: ["drift"],
|
||||
def: "Divergencia silenciosa entre dos representaciones que deberían coincidir (p. ej. código y ontología, o assets y su copia materializada). Se detecta con comprobaciones de <code>check</code>."
|
||||
},
|
||||
{
|
||||
id: "dogfooding",
|
||||
term: "dogfooding",
|
||||
aliases: ["comerse la propia comida"],
|
||||
def: "Usar el propio proyecto para construir y documentar el proyecto: el hub de Expedientes se publica con el mismo motor htmx-ssr que describe."
|
||||
},
|
||||
{
|
||||
id: "expediente",
|
||||
term: "expediente",
|
||||
aliases: ["dossier", "caso"],
|
||||
def: "Unidad documental del hub: un caso autocontenido que recoge contexto, decisiones y evidencia sobre una pieza del sistema."
|
||||
}
|
||||
];
|
||||
|
||||
var norm = function (s) {
|
||||
return (s || "")
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "");
|
||||
};
|
||||
|
||||
var esc = function (s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[c];
|
||||
});
|
||||
};
|
||||
|
||||
var list = document.getElementById("glz-list");
|
||||
var input = document.getElementById("glz-q");
|
||||
var count = document.getElementById("glz-count");
|
||||
var empty = document.getElementById("glz-empty");
|
||||
if (!list || !input) return;
|
||||
|
||||
// Build rows once; filtering only toggles visibility + highlight.
|
||||
var rows = TERMS.map(function (t) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "glz__row";
|
||||
row.id = "gl-" + t.id;
|
||||
|
||||
var dt = document.createElement("dt");
|
||||
dt.className = "glz__term";
|
||||
dt.innerHTML = esc(t.term);
|
||||
if (t.aliases && t.aliases.length) {
|
||||
var al = document.createElement("span");
|
||||
al.className = "glz__aliases";
|
||||
al.innerHTML = t.aliases
|
||||
.map(function (a) {
|
||||
return '<span class="glz__alias">' + esc(a) + "</span>";
|
||||
})
|
||||
.join("");
|
||||
dt.appendChild(al);
|
||||
}
|
||||
|
||||
var dd = document.createElement("dd");
|
||||
dd.className = "glz__def";
|
||||
dd.dataset.def = t.def; // def may carry inline markup (e.g. <code>)
|
||||
|
||||
row.appendChild(dt);
|
||||
row.appendChild(dd);
|
||||
list.appendChild(row);
|
||||
|
||||
return {
|
||||
el: row,
|
||||
dd: dd,
|
||||
term: t.term,
|
||||
rawDef: t.def,
|
||||
hay: norm(t.term + " " + t.def + " " + (t.aliases || []).join(" "))
|
||||
};
|
||||
});
|
||||
|
||||
// Highlight the query inside definitions without breaking inline markup:
|
||||
// split the def on tag boundaries and only mark text segments.
|
||||
var highlight = function (html, q) {
|
||||
if (!q) return html;
|
||||
var qn = norm(q);
|
||||
return html.replace(/(<[^>]+>)|([^<]+)/g, function (_, tag, text) {
|
||||
if (tag) return tag;
|
||||
var out = "";
|
||||
var i = 0;
|
||||
var tn = norm(text);
|
||||
while (i < text.length) {
|
||||
var at = tn.indexOf(qn, i);
|
||||
if (at === -1) {
|
||||
out += esc(text.slice(i));
|
||||
break;
|
||||
}
|
||||
out += esc(text.slice(i, at));
|
||||
out += "<mark>" + esc(text.slice(at, at + q.length)) + "</mark>";
|
||||
i = at + q.length;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
};
|
||||
|
||||
var render = function () {
|
||||
var q = input.value.trim();
|
||||
var qn = norm(q);
|
||||
var shown = 0;
|
||||
rows.forEach(function (r) {
|
||||
var match = !qn || r.hay.indexOf(qn) !== -1;
|
||||
r.el.hidden = !match;
|
||||
if (match) {
|
||||
shown++;
|
||||
r.dd.innerHTML = highlight(r.rawDef, q);
|
||||
}
|
||||
});
|
||||
count.textContent =
|
||||
shown === TERMS.length ? TERMS.length + " términos" : shown + "/" + TERMS.length;
|
||||
empty.hidden = shown !== 0;
|
||||
};
|
||||
|
||||
var flash = function (row) {
|
||||
if (!row) return;
|
||||
row.classList.remove("is-flash");
|
||||
// reflow so the class re-adds even on repeat clicks to the same anchor
|
||||
void row.offsetWidth;
|
||||
row.classList.add("is-flash");
|
||||
window.setTimeout(function () {
|
||||
row.classList.remove("is-flash");
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
var jumpToHash = function () {
|
||||
var h = window.location.hash;
|
||||
if (!h || h.indexOf("#gl-") !== 0) return;
|
||||
var row = document.getElementById(h.slice(1));
|
||||
if (!row) return;
|
||||
if (row.hidden) {
|
||||
// clear an active filter so the linked term is visible
|
||||
input.value = "";
|
||||
render();
|
||||
}
|
||||
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
flash(row);
|
||||
};
|
||||
|
||||
input.addEventListener("input", render);
|
||||
window.addEventListener("hashchange", jumpToHash);
|
||||
|
||||
render();
|
||||
jumpToHash();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</section>
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
# Typed source for expediente "espejo-que-revertia-su-trabajo".
|
||||
#
|
||||
# Two surfaces project from this file and neither may be hand-edited:
|
||||
# ../../es/casos/espejo-que-revertia-su-trabajo.md the article (skin = clinical)
|
||||
# ../../../../public/images/expedientes/es/expedientes.html the informe (both skins)
|
||||
#
|
||||
# The clinical skin is the one this case was written in. The detective skin was authored
|
||||
# afterwards, when the informe stopped being a hand-kept HTML and became a projection:
|
||||
# the viewer offers both genres, so a case that has only one leaves a dead button.
|
||||
let s = import "../../../../schemas/expediente.ncl" in
|
||||
|
||||
s.make_expediente {
|
||||
id = "espejo-que-revertia-su-trabajo",
|
||||
title = "Expediente 69/58: el espejo que revertía su propio trabajo",
|
||||
slug = "el-espejo-que-revertia-su-trabajo",
|
||||
case_no = "69/58",
|
||||
classification = "ANTI-PAP · EL REPORTER DECIDÍA",
|
||||
status = "RESUELTO",
|
||||
skin = 'clinical,
|
||||
|
||||
tab = "69/58 · el espejo",
|
||||
print_href = "", # sin versión imprimible propia: el informe es su única ficha
|
||||
|
||||
glossary = [
|
||||
{ term = "PAP",
|
||||
def = "Project's Architecture Principles — las reglas que sostienen la arquitectura: la fuente única que todo el código debe respetar.",
|
||||
def_html = "<b>Project's Architecture Principles</b> — las reglas que sostienen la arquitectura: la fuente única que todo el código debe respetar." },
|
||||
{ term = "anti-PAP",
|
||||
def = "Código escrito en contra de esas reglas. Aquí: un mirror que decidía por dirección en vez de por propiedad.",
|
||||
def_html = "Código escrito <em>en contra</em> de esas reglas. Aquí: un mirror que decidía por <em>dirección</em> en vez de por <em>propiedad</em>." },
|
||||
{ term = "el check decide, nunca el reporter",
|
||||
def = "ADR-066, aceptado el 4-jul-2026: donde exista una comprobación mecánica, decide la comprobación; la palabra de quien informa puede negarse a ser contradicha, jamás imponerse. Este expediente es lo que ocurre en la única superficie donde esa regla no se aplicó.",
|
||||
def_html = "<b>ADR-066</b>, aceptado el 4-jul-2026: donde exista una comprobación mecánica, decide la comprobación; la palabra de quien informa puede negarse a ser contradicha, jamás imponerse. Este expediente es lo que ocurre en la única superficie donde esa regla <em>no</em> se aplicó." },
|
||||
{ term = "testigo",
|
||||
def = "Una constancia verificable de forma independiente de que algo es lo que dice ser. Un backup sin testigo no es un backup: es una copia en la que confías.",
|
||||
def_html = "Una constancia <em>verificable de forma independiente</em> de que algo es lo que dice ser. Un backup sin testigo no es un backup: es una copia en la que confías." },
|
||||
],
|
||||
|
||||
cost_caption = "Coste medible · sesión 2026-07-11 → 07-12",
|
||||
|
||||
# RULE: every number traces to a log or an artifact. Nothing here is recalled.
|
||||
cost = [
|
||||
{ label = "Lo que la herramienta informaba", value = "\"index.json with 69 posts\"" },
|
||||
{ label = "Lo que había en el disco", value = "58", emphasis = true },
|
||||
{ label = "ADRs escritos y jamás publicados", value = "11 (059→069)", emphasis = true },
|
||||
{ label = "Ventana de invisibilidad", value = "13-jun → 11-jul (fechas de los propios ADR)" },
|
||||
{ label = "Contenido llegado a producción en ese tiempo", value = "0", emphasis = true },
|
||||
{ label = "Ficheros que el repo del site tenía trackeados", value = "1 (README.md)" },
|
||||
{ label = "git_sha estampado en TODOS los packs de deploy", value = "5619a40 (siempre el mismo)" },
|
||||
{ label = "Líneas de trabajo vivas sin un solo testigo", value = "214" },
|
||||
{ label = "Comandos de distancia hasta borrarlas", value = "1 (<span class='mono'>just templates</span>)", emphasis = true },
|
||||
{ label = "Tamaño del arreglo final", value = "60 <i>líneas</i>", emphasis = true },
|
||||
],
|
||||
|
||||
yielded = [
|
||||
{ label = "Decisión", value = "ADR-070 (aceptado)" },
|
||||
{ label = "Gates", value = "adr-check · templates-check · posts-check" },
|
||||
{ label = "Migración", value = "0044 (rutas ancladas)" },
|
||||
{ label = "La ranura que faltaba", value = "site/templates-overlay/" },
|
||||
{ label = "Constraints curadas al anclar", value = "29 (88✓ → 117✓)" },
|
||||
{ label = "Ontología", value = "el converso en enforcement-vs-emergence" },
|
||||
],
|
||||
|
||||
# Neutral: the technical name of each suspect. What each one SAYS, and how it is
|
||||
# written off, is the genre's job — it lives in the skins, index-parallel to this list.
|
||||
suspects = [
|
||||
{ name = "Los 6 huérfanos en <span class='mono'>site/r</span>" },
|
||||
{ name = "<span class='mono'>gen-adr-pages</span> roto" },
|
||||
{ name = "<span class='mono'>content_processor</span> no procesa expedientes" },
|
||||
{ name = "El índice no se regenera" },
|
||||
{ name = "El generador del grafo" },
|
||||
],
|
||||
|
||||
weapon = {
|
||||
code = m%"
|
||||
# La receta `content`, tal y como estaba:
|
||||
content_processor # escribe los índices en site/r
|
||||
rsync -a "site/public/r/" "site/r/" # ← el arma
|
||||
|
||||
# Dos árboles. Cada uno DUEÑO de ficheros distintos:
|
||||
# site/r ← content_processor escribe aquí (lo lee el servidor)
|
||||
# site/public/r ← los generadores nu escriben aquí (y es lo que EMBARCA el deploy)
|
||||
#
|
||||
# El rsync corría en un solo sentido, y en el sentido equivocado:
|
||||
# · pisaba los índices recién generados con los fósiles del árbol de deploy
|
||||
# · y NUNCA llevaba el contenido nuevo al árbol que viaja a producción
|
||||
#
|
||||
# rsync -a sin --update no respeta "el destino es más nuevo": compara tamaño y
|
||||
# mtime, y si difieren, COPIA LA FUENTE. Un mirror cuya fuente se ha fosilizado
|
||||
# no es un no-op: es una máquina de resucitar estado viejo.
|
||||
#
|
||||
# La herramienta imprimía "Generated index.json with 69 posts".
|
||||
# El disco se quedaba con 58.
|
||||
# Nadie comparaba las dos cosas.
|
||||
"%,
|
||||
code_html = m%"
|
||||
<span class="c"># La receta `content`, tal y como estaba:</span>
|
||||
content_processor <span class="c"># escribe los índices en site/r</span>
|
||||
<span class="k">rsync</span> -a <span class="g">"site/public/r/"</span> <span class="g">"site/r/"</span> <span class="r">← el arma</span>
|
||||
|
||||
<span class="c"># Dos árboles. Cada uno DUEÑO de ficheros distintos:</span>
|
||||
<span class="c"># site/r ← content_processor escribe aquí (lo lee el servidor)</span>
|
||||
<span class="c"># site/public/r ← los generadores nu escriben aquí (y es lo que EMBARCA el deploy)</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># El rsync corría en un solo sentido, y en el sentido equivocado:</span>
|
||||
<span class="c"># · pisaba los índices recién generados con los fósiles del árbol de deploy</span>
|
||||
<span class="c"># · y NUNCA llevaba el contenido nuevo al árbol que viaja a producción</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># rsync -a sin --update no respeta "el destino es más nuevo": compara tamaño y</span>
|
||||
<span class="c"># mtime, y si difieren, COPIA LA FUENTE. Un mirror cuya fuente se ha fosilizado</span>
|
||||
<span class="c"># no es un no-op: es una máquina de resucitar estado viejo.</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># La herramienta imprimía "Generated index.json with 69 posts".</span>
|
||||
<span class="c"># El disco se quedaba con 58.</span>
|
||||
<span class="r"># Nadie comparaba las dos cosas.</span>
|
||||
"%,
|
||||
},
|
||||
|
||||
fix = {
|
||||
code = m%"
|
||||
content_processor
|
||||
# Índices de contenido → árbol de deploy. --delete: sin él, un slug renombrado
|
||||
# sobrevive como URL viva EN PRODUCCIÓN.
|
||||
rsync -a --delete \
|
||||
--exclude about.json --exclude adr-map.json \
|
||||
--exclude taglines.json --exclude content_graph.json \
|
||||
"site/r/" "site/public/r/"
|
||||
# Los cuatro artefactos nu → árbol servido. Van excluidos del tramo de ida, o las
|
||||
# copias viejas de site/r machacarían los originales recién generados.
|
||||
for f in about.json adr-map.json taglines.json content_graph.json; do
|
||||
cp -f "site/public/r/$f" "site/r/$f"
|
||||
done
|
||||
|
||||
# Y tres gates que ASERTAN CONTRA EL DISCO, nunca contra el stdout de la herramienta:
|
||||
# adr-check el spine no tiene ningún ADR que el site no publique
|
||||
# templates-check el árbol ensamblado se reproduce exacto desde su fuente declarada
|
||||
# posts-check linkify + cobertura del grafo
|
||||
#
|
||||
# Los tres se pueden ejecutar MIENTRAS el daño sigue siendo hipotético.
|
||||
# Un check que solo puedes correr después del paso destructivo no es un gate:
|
||||
# es una autopsia.
|
||||
"%,
|
||||
code_html = m%"
|
||||
content_processor
|
||||
<span class="c"># Índices de contenido → árbol de deploy. --delete: sin él, un slug renombrado</span>
|
||||
<span class="c"># sobrevive como URL viva EN PRODUCCIÓN.</span>
|
||||
<span class="k">rsync</span> -a --delete \
|
||||
--exclude about.json --exclude adr-map.json \
|
||||
--exclude taglines.json --exclude content_graph.json \
|
||||
<span class="g">"site/r/"</span> <span class="g">"site/public/r/"</span>
|
||||
<span class="c"># Los cuatro artefactos nu → árbol servido. Van excluidos del tramo de ida, o las</span>
|
||||
<span class="c"># copias viejas de site/r machacarían los originales recién generados.</span>
|
||||
<span class="k">for</span> f <span class="k">in</span> about.json adr-map.json taglines.json content_graph.json; <span class="k">do</span>
|
||||
cp -f <span class="g">"site/public/r/$f"</span> <span class="g">"site/r/$f"</span>
|
||||
<span class="k">done</span>
|
||||
|
||||
<span class="c"># Y tres gates que ASERTAN CONTRA EL DISCO, nunca contra el stdout de la herramienta:</span>
|
||||
<span class="c"># adr-check el spine no tiene ningún ADR que el site no publique</span>
|
||||
<span class="c"># templates-check el árbol ensamblado se reproduce exacto desde su fuente declarada</span>
|
||||
<span class="c"># posts-check linkify + cobertura del grafo</span>
|
||||
<span class="c">#</span>
|
||||
<span class="c"># Los tres se pueden ejecutar MIENTRAS el daño sigue siendo hipotético.</span>
|
||||
<span class="c"># Un check que solo puedes correr después del paso destructivo no es un gate:</span>
|
||||
<span class="r"># es una autopsia.</span>
|
||||
"%,
|
||||
},
|
||||
|
||||
containment = [
|
||||
{ facet = "El mirror corría por dirección, no por propiedad de cada fichero",
|
||||
mechanism = "estado declarado — ADR-070: todo árbol declara su DUEÑO. Un sync de árbol completo en un sentido, entre dos árboles que poseen ficheros distintos, es necesariamente falso en una de las dos direcciones.",
|
||||
mechanism_html = "estado declarado <span class='kicknum'>→ ADR-070: todo árbol declara su DUEÑO</span>" },
|
||||
{ facet = "La herramienta informaba éxito y nadie comprobaba el disco",
|
||||
mechanism = "review contra invariante — ADR-066 (el check decide, nunca el reporter), aplicado por fin a la superficie que publica el protocolo: los gates leen el disco, no el stdout.",
|
||||
mechanism_html = "review contra invariante <span class='kicknum'>→ ADR-066: el check decide</span>" },
|
||||
{ facet = "Un generador existía y ninguna receta lo llamaba (11 ADR invisibles)",
|
||||
mechanism = "estado declarado — ADR-070: todo árbol declara su REPRODUCCIÓN, y esa receta está en la cadena. Un generador fuera de la cadena de dependencias es un ritual privado, no un build.",
|
||||
mechanism_html = "estado declarado <span class='kicknum'>→ ADR-070: la REPRODUCCIÓN, en la cadena</span>" },
|
||||
{ facet = "214 líneas vivían en el único árbol que el build borra",
|
||||
mechanism = "estado declarado — ADR-070: todo árbol declara su TESTIGO (reproducible, o rastreado). El nivel del site no tenía ranura de overlay: el trabajo no tenía dónde vivir legalmente.",
|
||||
mechanism_html = "estado declarado <span class='kicknum'>→ ADR-070: todo árbol declara su TESTIGO</span>" },
|
||||
{ facet = "Un contrato archivado entre los datos que tipa (just graph muerto)",
|
||||
mechanism = "PAP + anti-pattern — `contract-in-the-instance-tree`: site/content/ es árbol de instancias; un esquema ahí es un error de categoría, y el generador que revienta tiene razón.",
|
||||
mechanism_html = "PAP + anti-pattern <span class='kicknum'>→ contract-in-the-instance-tree</span>" },
|
||||
{ facet = "Un check significaba una cosa según desde dónde lo lanzaras",
|
||||
mechanism = "review contra invariante — migración 0044: rutas y cwd anclados a la raíz del proyecto. Un `must_be_empty` sobre una ruta que no resuelve reporta SATISFECHO: una ruta rota no te da un rojo, te da la razón.",
|
||||
mechanism_html = "review contra invariante <span class='kicknum'>→ migración 0044: rutas ancladas</span>" },
|
||||
],
|
||||
|
||||
# Neutral: both genres sign the same bottom line. That is the whole point of the series,
|
||||
# and the one sentence that must not vary with who is telling it.
|
||||
verdict_close = "Los demás expedientes responden que sí: con ontoref no hubiera sido así. Este responde que <strong>no</strong> — y por eso vale más que todos. Cada mecanismo que habría atrapado cada uno de estos fallos ya existía, aceptado, en este mismo repo. Ninguno estaba <em>apuntado aquí</em>. <a href='https://ontoref.dev' target='_blank' rel='noopener'>Ontoref</a> no falló: falló creer que tener el mecanismo es tenerlo apuntado a la superficie que duele.",
|
||||
|
||||
skins = {
|
||||
clinical = {
|
||||
kicker = "Historia clínica · Patología del Software",
|
||||
h1_lead = "Patología: ",
|
||||
h1_red = "el espejo que revertía su trabajo",
|
||||
logline = "El paciente no tenía síntomas. Publicaba sin errores, la herramienta confirmaba el éxito, los logs salían en verde. Llevaba cuatro semanas publicando en el vacío.",
|
||||
victim = "Órgano: la difusión",
|
||||
stamp = "60 LÍNEAS",
|
||||
foot_right = "Médico: guardia de noche · Aviso: el disco",
|
||||
|
||||
exhibits = ["Cuadro clínico", "Diagnóstico diferencial", "Etiología", "Tratamiento", "Pronóstico y profilaxis"],
|
||||
headings = [
|
||||
"Cuatro semanas publicando en el vacío",
|
||||
"Cinco diagnósticos descartados",
|
||||
"El patógeno vivía en la receta <span class='mono'>content</span>",
|
||||
"Sesenta líneas de terapia",
|
||||
"El protocolo no falló: la difusión era el punto ciego",
|
||||
],
|
||||
lead_cost = "El paciente no refería dolor. La gravedad de un cuadro asintomático no se mide en síntomas: se mide en lo que no llegó. Estas fueron las constantes:",
|
||||
lead_suspects = "Cinco hallazgos plausibles, cinco pruebas negativas. Uno de ellos no era un hallazgo: era el propio investigador leyendo mal la analítica.",
|
||||
|
||||
alibis = [
|
||||
"Sí, soy contenido muerto que sigue sirviéndose. Pero yo no impido publicar.",
|
||||
"Yo funciono perfectamente. Genero los 69 ADR cuando alguien me llama.",
|
||||
"Sí los proceso. Lo que pasa es que me leíste con un <span class='mono'>head -20</span> y te cortó la salida.",
|
||||
"Me regenero cada vez. Con 69. Lo que pasa después no es cosa mía.",
|
||||
"Yo exporto lo que hay en el árbol de contenido. Si me metéis un contrato entre los datos, reviento. Y hago bien.",
|
||||
],
|
||||
suspect_tags = [
|
||||
"síntoma, no causa",
|
||||
"inocente — nadie lo llamaba",
|
||||
"coartada firme (el error fue del investigador)",
|
||||
"inocente — y la clave del caso",
|
||||
"inocente — tenía razón",
|
||||
],
|
||||
|
||||
weapon_caption = "El mirror que decidía por dirección, no por propiedad",
|
||||
weapon_after = "Dos árboles, cada uno dueño de ficheros distintos, y un <span class='mono'>rsync</span> de árbol completo en un solo sentido. En una de las dos direcciones tenía que estar mintiendo — y mentía en las dos: pisaba los índices nuevos con fósiles, y nunca llevaba el contenido al árbol que embarca.",
|
||||
weapon_note = "just — receta content",
|
||||
fix_caption = "El mirror se tipa por propiedad, y el check lee el disco",
|
||||
fix_after = "Cada tramo del sync se declara por DUEÑO, no por dirección. Y tres gates que asertan contra el disco, ejecutables mientras el daño sigue siendo hipotético. Un check que solo puedes correr después del paso destructivo no es un gate: es una autopsia.",
|
||||
fix_note = "just — receta content",
|
||||
|
||||
verdict_lead = "Esto pasó DENTRO de ontoref, con la ontología cargada, los gates escritos y ADR-066 —«el check decide, nunca el reporter»— aceptado una semana antes. La regla que nombra exactamente esta patología ya existía mientras el pipeline que publica ontoref al mundo seguía dejando decidir al reporter:",
|
||||
|
||||
verdict_prose = "La serie hace una pregunta: ¿con ontoref no hubiera sido así? Casi siempre la respuesta es sí, y por eso hay serie. Este expediente es el que responde que NO — y por eso es el que más vale. Esto pasó DENTRO de ontoref, con la ontología cargada, los gates escritos y ADR-066 —«el check decide, nunca el reporter»— aceptado una semana antes y probado con una corrida de falsación de ocho caminos. La regla que nombra exactamente esta patología ya existía mientras el pipeline que publica ontoref al mundo seguía dejando decidir al reporter. El protocolo no falló: es que la difusión era el punto ciego, la única superficie que el protocolo no gobernaba. Cada mecanismo que habría atrapado cada uno de estos fallos ya existía y estaba aceptado en este mismo repo. Ninguno estaba apuntado aquí. Y esa es la respuesta útil, la que un caso cómodo no da: no basta con tener el mecanismo — hay que apuntarlo a la superficie que duele. Un enforcement en el que no puedes confiar que falle es indistinguible de no tener enforcement, y es más peligroso, porque se cree.",
|
||||
|
||||
abandonment = "El punto de abandono de este caso no llegó con un error, sino con una frase: «he ido crate por crate por un sistema de codegen complejo y estoy muy pasado del punto razonable para seguir a ciegas». Es lo que dice una máquina cuando se le acaba el mapa y sigue caminando. Y la respuesta que lo desbloqueó no fue técnica: fue que alguien dibujara la jerarquía de niveles —rustelo → website-htmx-rustelo → outreach/site— que existía en una cabeza y en ningún fichero. Ese es el diagnóstico entero, dicho antes de tenerlo: lo que no está declarado, alguien lo sostiene con su memoria. Y la memoria se cansa.",
|
||||
},
|
||||
|
||||
# ── Piel detective ────────────────────────────────────────────────────────────────
|
||||
# Escrita el 2026-07-12, al convertir el informe en proyección: el visor ofrece los dos
|
||||
# géneros y un caso con una sola piel deja un botón muerto. Los NÚMEROS, el código, los
|
||||
# nombres técnicos de los sospechosos y el cierre son los mismos: solo cambia quién lo
|
||||
# cuenta. Si un dato cambiara al cambiar de piel, sería ficción, no género.
|
||||
detective = {
|
||||
h1_lead = "El caso del ",
|
||||
h1_red = "espejo que revertía su trabajo",
|
||||
logline = "Ningún cadáver, ninguna sangre, ni una sola queja. La herramienta firmaba el parte cada noche: <span class='mono'>69 posts</span>. El disco tenía 58. Llevaba cuatro semanas publicando en un cajón.",
|
||||
victim = "Víctima: cuatro semanas",
|
||||
stamp = "60 LÍNEAS",
|
||||
foot_right = "Detective: turno de noche · Soplón: el disco",
|
||||
|
||||
exhibits = ["El cuerpo", "La rueda de sospechosos", "El arma", "La confesión", "El veredicto"],
|
||||
headings = [
|
||||
"Un crimen sin cuerpo — y con once desaparecidos",
|
||||
"Cinco coartadas, y una la firmé yo",
|
||||
"El arma estaba en la receta <span class='mono'>content</span>",
|
||||
"Sesenta líneas para dejar de mentirse",
|
||||
"La regla existía. No apuntaba aquí",
|
||||
],
|
||||
lead_cost = "No hubo denuncia. Nadie echó nada en falta, y ese es el detalle que debería haberme puesto los pelos de punta: para que nadie eche nada en falta, tiene que faltar TODO. Lo que quedó sobre la mesa del forense:",
|
||||
lead_suspects = "Cinco sospechosos, cinco coartadas de hierro. Y una de las coartadas era contra mí: el testigo no mentía, yo le había cortado la declaración con un <span class='mono'>head -20</span>.",
|
||||
|
||||
alibis = [
|
||||
"Sí, soy contenido muerto que sigue sirviéndose. Pero yo no impido publicar a nadie.",
|
||||
"Yo funciono de maravilla. Saco los 69 ADR cuando alguien me llama. Nadie llamaba.",
|
||||
"Yo sí los proceso. Me cortaste la declaración con un <span class='mono'>head -20</span> y te quedaste con media frase.",
|
||||
"Me regenero cada noche. Con 69. Lo que pase después ya no es cosa mía, encanto.",
|
||||
"Yo exporto lo que hay en el árbol. Si me metéis un contrato entre los datos, reviento. Y hago bien.",
|
||||
],
|
||||
suspect_tags = [
|
||||
"cómplice menor",
|
||||
"inocente — nadie lo llamaba",
|
||||
"coartada firme — el error fue del investigador",
|
||||
"inocente — y la clave del caso",
|
||||
"inocente — tenía razón",
|
||||
],
|
||||
|
||||
weapon_caption = "El arma · un mirror que decidía por dirección, no por propiedad",
|
||||
weapon_after = "Dos árboles, cada uno dueño de ficheros distintos, y un <span class='mono'>rsync</span> de árbol entero en un solo sentido. Un mirror así tiene que estar mintiendo en una de las dos direcciones. Mentía en las dos: resucitaba fósiles encima de lo recién escrito, y jamás llevaba lo nuevo al árbol que embarca. El testigo perfecto: nunca falla, nunca protesta, siempre informa en verde.",
|
||||
fix_caption = "PAP-compliant · el mirror se tipa por propiedad, y el check lee el disco",
|
||||
fix_after = "Cada tramo se declara por DUEÑO, no por dirección. Y tres gates que le preguntan al disco, no a la herramienta — y que se pueden correr mientras el daño todavía es hipotético. Un check que solo puedes correr después del paso destructivo no es un gate: es una autopsia.",
|
||||
|
||||
verdict_lead = "Esto no pasó a las afueras de ontoref. Pasó dentro, con la ontología cargada, los gates escritos, y ADR-066 —«el check decide, nunca el reporter»— aceptado una semana antes. La regla que nombra este crimen con nombre y apellidos ya estaba en el libro. No apuntaba a esta calle:",
|
||||
|
||||
verdict_prose = "La serie hace una pregunta: ¿con ontoref no hubiera sido así? Casi siempre la respuesta es sí, y por eso hay serie. Este expediente es el que responde que NO — y por eso es el que más vale. Esto pasó DENTRO de ontoref, con la ontología cargada, los gates escritos y ADR-066 —«el check decide, nunca el reporter»— aceptado una semana antes y probado con una corrida de falsación de ocho caminos. La regla que nombra exactamente esta patología ya existía mientras el pipeline que publica ontoref al mundo seguía dejando decidir al reporter. El protocolo no falló: es que la difusión era el punto ciego, la única superficie que el protocolo no gobernaba. Cada mecanismo que habría atrapado cada uno de estos fallos ya existía y estaba aceptado en este mismo repo. Ninguno estaba apuntado aquí. Y esa es la respuesta útil, la que un caso cómodo no da: no basta con tener el mecanismo — hay que apuntarlo a la superficie que duele. Un enforcement en el que no puedes confiar que falle es indistinguible de no tener enforcement, y es más peligroso, porque se cree.",
|
||||
|
||||
abandonment = "El punto de abandono de este caso no llegó con un error, sino con una frase: «he ido crate por crate por un sistema de codegen complejo y estoy muy pasado del punto razonable para seguir a ciegas». Es lo que dice una máquina cuando se le acaba el mapa y sigue caminando. Y la respuesta que lo desbloqueó no fue técnica: fue que alguien dibujara la jerarquía de niveles —rustelo → website-htmx-rustelo → outreach/site— que existía en una cabeza y en ningún fichero. Ese es el diagnóstico entero, dicho antes de tenerlo: lo que no está declarado, alguien lo sostiene con su memoria. Y la memoria se cansa.",
|
||||
},
|
||||
},
|
||||
|
||||
related_modes = ["generate-expediente"],
|
||||
tags = ["expediente", "anti-pap", "deploy", "witness", "adr-070", "adr-066", "developer"],
|
||||
}
|
||||
|
|
@ -16,68 +16,64 @@ sort_order: 2
|
|||
---
|
||||
|
||||
<section class="exp">
|
||||
|
||||
<style>
|
||||
.exp{font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;color:#0c7a83}
|
||||
.exp{--exp-ink:currentColor;font-family:Georgia,"Times New Roman",serif}
|
||||
.exp .exp-kick{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;opacity:.65}
|
||||
.exp .exp-log{font-style:italic;max-width:46ch}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.4rem;margin:.9rem 0 0;font-family:ui-monospace,"Courier New",monospace;font-size:.68rem}
|
||||
.exp .exp-meta span{border:1px solid;padding:.2rem .5rem;letter-spacing:.08em;text-transform:uppercase}
|
||||
.exp .exp-anam{border-left:3px solid #0c7a83;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.72rem;letter-spacing:.13em;text-transform:uppercase;color:#0c7a83;margin:2rem 0 .5rem;border-top:1px solid;padding-top:.9rem}
|
||||
.exp .exp-vs{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-vs{grid-template-columns:1fr}}
|
||||
.exp .exp-col{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-col h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-col.a h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-col.b h4{background:rgba(12,122,131,.14)}
|
||||
.exp .exp-col ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-col li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-col li:last-child{border-bottom:0}
|
||||
.exp .exp-col b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp pre{font-family:ui-monospace,"Courier New",monospace;font-size:.8rem;overflow-x:auto;background:#0c1a20;color:#d7e4e7;padding:.85rem 1rem;border-radius:.4rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .8rem;border-radius:.5rem;overflow:hidden;line-height:0}
|
||||
.exp .exp-hero img{width:100%;display:block}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.85rem;font-weight:700;background:#0c7a83;color:#fff;padding:.55rem .95rem;border-radius:.4rem;text-decoration:none}
|
||||
.exp .exp-btn:hover{background:#0a636a}
|
||||
.exp .exp-meta{display:flex;flex-wrap:wrap;gap:.9rem;font-family:ui-monospace,"Courier New",monospace;font-size:.74rem;letter-spacing:.06em;text-transform:uppercase;opacity:.75;margin:.6rem 0 1.2rem}
|
||||
.exp .exp-hero{display:block;margin:0 0 .6rem}
|
||||
.exp .exp-hero img{width:100%;height:auto;border-radius:.5rem}
|
||||
.exp .exp-btn{display:inline-block;font-family:ui-monospace,"Courier New",monospace;font-size:.78rem;padding:.4rem .8rem;border:1px solid;border-radius:.3rem;text-decoration:none}
|
||||
.exp .exp-anam{border-left:3px solid;padding:.4rem 0 .4rem 1rem;margin:1.3rem 0;font-style:italic}
|
||||
.exp .exp-gloss{border-left:3px solid;padding:.6rem 0 .6rem .9rem;margin:1rem 0;font-size:.92rem}
|
||||
.exp .exp-gloss dl{margin:0}
|
||||
.exp .exp-gloss dt{font-weight:700;font-family:ui-monospace,"Courier New",monospace;font-size:.82rem;margin-top:.5rem}
|
||||
.exp .exp-gloss dd{margin:.15rem 0 0}
|
||||
.exp .exp-ex{font-family:ui-monospace,"Courier New",monospace;font-weight:700;font-size:.8rem;letter-spacing:.1em;text-transform:uppercase;margin:1.8rem 0 .5rem;opacity:.8}
|
||||
.exp .exp-balance{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1rem 0}
|
||||
@media(max-width:38rem){.exp .exp-balance{grid-template-columns:1fr}}
|
||||
.exp .exp-ledger{border:1px solid;border-radius:.4rem;overflow:hidden}
|
||||
.exp .exp-ledger h4{font-family:ui-monospace,"Courier New",monospace;font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;margin:0;padding:.55rem .8rem;border-bottom:1px solid}
|
||||
.exp .exp-ledger.cost h4{background:rgba(180,52,42,.12)}
|
||||
.exp .exp-ledger.yield h4{background:rgba(20,120,60,.12)}
|
||||
.exp .exp-ledger ul{margin:0;padding:.4rem .9rem;list-style:none}
|
||||
.exp .exp-ledger li{display:flex;justify-content:space-between;gap:.8rem;padding:.32rem 0;border-bottom:1px dashed rgba(128,128,128,.35);font-size:.9rem}
|
||||
.exp .exp-ledger li:last-child{border-bottom:0}
|
||||
.exp .exp-ledger b{font-family:ui-monospace,"Courier New",monospace;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.exp .exp-susp{width:100%;border-collapse:collapse;font-size:.9rem;margin:.6rem 0}
|
||||
.exp .exp-susp td{padding:.4rem .5rem;border-bottom:1px dashed rgba(128,128,128,.35);vertical-align:top}
|
||||
.exp .exp-cont{width:100%;border-collapse:collapse;font-size:.92rem;margin:.8rem 0}
|
||||
.exp .exp-cont td{padding:.5rem .6rem;border-bottom:1px solid rgba(128,128,128,.3);vertical-align:top}
|
||||
.exp .exp-cont td:first-child{width:45%;opacity:.85}
|
||||
</style>
|
||||
|
||||
<a class="exp-hero" href="/images/expedientes/es/expedientes.html#recaida-hardcoded-bis" target="_blank" rel="noopener" title="Abrir la historia clínica completa"><img src="/images/expedientes/recaida-header.webp" alt="Expediente 404-PAP-bis — la recaída" width="1200" height="675" loading="lazy"></a>
|
||||
<a class="exp-hero" href="/images/expedientes/es/expedientes.html#recaida-hardcoded-bis" target="_blank" rel="noopener" title="Abrir la historia clínica completa"><img src="/images/expedientes/recaida-header.webp" alt="Expediente 404-PAP-bis: la recaída" width="1200" height="675" loading="lazy"></a>
|
||||
|
||||
<p style="margin:0 0 1.4rem"><a class="exp-btn" href="/images/expedientes/es/expedientes.html#recaida-hardcoded-bis" target="_blank" rel="noopener">🩺 Mostrar historia clínica →</a></p>
|
||||
|
||||
<p class="exp-kick">Historia clínica · Servicio de Patología del Código</p>
|
||||
|
||||
<p class="exp-log">Creíamos estar de alta. El hub `/expedientes` en verde, el caso cerrado, la gloria. Y entonces `/nosotros` dio 404. Al instante. Del logro a la miseria sin transición — un espejismo, una montaña rusa que solo baja.</p>
|
||||
|
||||
<div class="exp-meta"><span>Historia Nº <b>404-PAP-bis</b></span><span>Diagnóstico: ANTI-PAP (recaída)</span><span>Estado: ALTA</span></div>
|
||||
<div class="exp-meta"><span>Historia Nº <b>404-PAP-bis</b></span><span>Diagnóstico: ANTI-PAP · recaída</span><span>Estado: ALTA</span></div>
|
||||
|
||||
<div class="exp-anam">
|
||||
<b>Motivo de consulta.</b> Paciente que, creyéndose curado, sufre recaída súbita del mismo cuadro. Refiere del susto‑sorpresa al pánico y a la incertidumbre en un instante; el ánimo cae del triunfo a la depresión sin escalón intermedio. Verbaliza: <em>"después de esto voy a necesitar una visita a la clínica"</em>. Y aquí está.
|
||||
</div>
|
||||
|
||||
<p class="exp-ex">Etiología — el mismo patógeno, otro órgano</p>
|
||||
<div class="exp-gloss">
|
||||
<dl>
|
||||
<dt>PAP</dt><dd><b>Project's Architecture Principles</b> — las reglas y patrones que sostienen la arquitectura del proyecto: la fuente única de la verdad que todo el código debe respetar.</dd>
|
||||
<dt>anti-PAP</dt><dd>Código escrito <em>en contra</em> de esas reglas: un stub que re-lista a mano lo que <span class='mono'>routes.ncl</span> ya declara, duplicando y contradiciendo la fuente de la verdad.</dd>
|
||||
</dl>
|
||||
<p style="font-family:ui-monospace,monospace;font-size:.72rem;margin:.5rem 0 0">Protocolo para declarar, versionar y verificar esto → <a href="https://ontoref.dev" target="_blank" rel="noopener">ontoref.dev</a></p>
|
||||
</div>
|
||||
|
||||
El [Expediente 404-PAP](/expedientes/casos/la-deriva-hardcoded) mató un `match` hardcoded en `render_content_or_grid` (páginas de contenido). El gemelo vivía intacto en el órgano de al lado — las **páginas estáticas**, `pages_htmx::dispatch`:
|
||||
<p class="exp-ex">El doble balance — lo que costó, y lo que dejó</p>
|
||||
|
||||
<pre>match path {
|
||||
"/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
|
||||
// … cada página a mano … menos /nosotros …
|
||||
_ => None, // ← /nosotros caía aquí → 404
|
||||
}</pre>
|
||||
|
||||
Ruta declarada, FTL presente, componente horneado — y aun así 404. El mismo anti‑PAP: un match que duplica lo que `routes.ncl` ya sabe.
|
||||
|
||||
<p class="exp-ex">Tratamiento — el mismo antibiótico</p>
|
||||
|
||||
<pre>_ => resolve_static_page(env, path, lang),
|
||||
// … lee del registry: cualquier ruta cuyo componente
|
||||
// acabe en "Page" → static_page(page_id kebab). Cero arms nuevos.</pre>
|
||||
|
||||
<p class="exp-ex">Pronóstico — y por qué esta recaída no fue como la primera</p>
|
||||
|
||||
Lo revelador no es que recayera. Es **cuánto duró**. El primer caso, sin historia previa, costó una tarde. El gemelo, idéntico, costó **minutos** — porque el primero ya estaba *declarado como historia clínica* (`content-kind-howto`): fui directo al órgano, reconocí el patógeno, apliqué el mismo antibiótico.
|
||||
|
||||
<div class="exp-vs">
|
||||
<div class="exp-col a">
|
||||
<div class="exp-balance">
|
||||
<div class="exp-ledger cost">
|
||||
<h4>Caso 404-PAP · sin historia</h4>
|
||||
<ul>
|
||||
<li>Diagnóstico <b>~88 min</b></li>
|
||||
|
|
@ -85,26 +81,59 @@ Lo revelador no es que recayera. Es **cuánto duró**. El primer caso, sin histo
|
|||
<li>Pistas falsas <b>8</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="exp-col b">
|
||||
<div class="exp-ledger yield">
|
||||
<h4>Caso bis · con historia clínica</h4>
|
||||
<ul>
|
||||
<li>Diagnóstico <b>~15 min</b></li>
|
||||
<li>Builds <b>1</b></li>
|
||||
<li>Pistas falsas <b>0</b></li>
|
||||
<li>Factor de aceleración vs. el primer caso <b>~30×</b></li>
|
||||
<li>Tamaño del arreglo final <b>1 <i>línea</i></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Mismo bug. **~30× más rápido.** No porque el segundo fuera más fácil — era idéntico — sino porque el primero se **declaró**. Eso es [ontoref](https://ontoref.dev) trabajando en tiempo real: el `dao` te sostiene en la recaída, el *modo* te da el paso, y el howto previo convierte "otra tarde perdida" en "quince minutos y un antibiótico conocido".
|
||||
|
||||
<p class="exp-ex">Alta y profilaxis</p>
|
||||
<p class="exp-ex">Diagnóstico diferencial — lo que se descartó</p>
|
||||
|
||||
<table class="exp-susp"><tbody>
|
||||
<tr><td><b>El binario / la caché</b></td><td><em>“¿Otra vez yo? Los marcadores ya no bastan.”</em></td><td>reincidente conocido</td></tr>
|
||||
<tr><td><b>La ruta <span class='mono'>/nosotros</span> declarada</b></td><td><em>“Consto en <span class='mono'>routes.ncl</span>. Constante normal.”</em></td><td>constante normal</td></tr>
|
||||
<tr><td><b>El FTL & el componente horneado</b></td><td><em>“Presente y correcto. Analítica negativa.”</em></td><td>analítica negativa</td></tr>
|
||||
<tr><td><b><span class='mono'>pages_htmx::dispatch</span></b></td><td><em>“Sí… el <em>match</em> que elige la página estática era el foco.”</em></td><td>el patógeno</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<p class="exp-ex">Etiología — la causa — Etiología gemela · un match hardcoded en las páginas estáticas</p>
|
||||
|
||||
<pre>match path {
|
||||
"/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
|
||||
// … cada página a mano … menos /nosotros …
|
||||
_ => None, // ← /nosotros caía aquí → 404
|
||||
}</pre>
|
||||
|
||||
El [Expediente 404-PAP](/expedientes/casos/la-deriva-hardcoded) mató un `match` hardcoded en `render_content_or_grid` (páginas de contenido). El gemelo vivía intacto en el órgano de al lado — las **páginas estáticas**, `pages_htmx::dispatch`:
|
||||
|
||||
Ruta declarada, FTL presente, componente horneado — y aun así 404. El mismo anti‑PAP: un match que duplica lo que `routes.ncl` ya sabe.
|
||||
|
||||
<p class="exp-ex">Tratamiento — PAP-compliant · una línea, sin ramas nuevas</p>
|
||||
|
||||
<pre>_ => resolve_static_page(env, path, lang),
|
||||
// lee del registry: cualquier ruta cuyo componente
|
||||
// acabe en "Page" → static_page(page_id kebab). Cero arms nuevos.</pre>
|
||||
|
||||
<p class="exp-ex">Pronóstico</p>
|
||||
|
||||
Lo revelador no es que recayera. Es **cuánto duró**. El primer caso, sin historia previa, costó una tarde. El gemelo, idéntico, costó **minutos** — porque el primero ya estaba *declarado como historia clínica* (`content-kind-howto`): fui directo al órgano, reconocí el patógeno, apliqué el mismo antibiótico.
|
||||
|
||||
<table class="exp-cont"><tbody>
|
||||
<tr><td>El primer caso, ya declarado como historia</td><td>content-kind-howto → consulta directa</td></tr>
|
||||
<tr><td>Reconocer el patógeno sin re-interrogar</td><td>mismo antibiótico → registry</td></tr>
|
||||
<tr><td>Cerrar la recaída también registrada</td><td>static-page-howto → el 3º durará segundos</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
El patógeno tiene familia: cualquier `match` hardcoded que sombree el registry. La vacuna es la misma para todos — leer de la fuente única. El caso se cierra registrado (`static-page-howto`) para que el **tercer** gemelo, si aparece, dure segundos.
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<hr style="margin:2.5rem 0;border:0;border-top:1px solid rgba(128,128,128,.3)">
|
||||
<!--
|
||||
_glossary.html — Section glossary component embedded in the /expedientes hub.
|
||||
Self-contained: inline <style> + <script>, no external deps, CSP-safe, standalone.
|
||||
|
|
@ -464,3 +493,5 @@ El patógeno tiene familia: cualquier `match` hardcoded que sombree el registry.
|
|||
jumpToHash();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</section>
|
||||
|
|
|
|||
230
site/site/content/expedientes/es/casos/recaida-hardcoded-bis.ncl
Normal file
230
site/site/content/expedientes/es/casos/recaida-hardcoded-bis.ncl
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
# Typed source for expediente "recaida-hardcoded-bis" (404-PAP-bis · la recaída).
|
||||
# HARVESTED, not authored: every string here is verbatim from the two surfaces that
|
||||
# are now its PROJECTIONS and must stop being hand-edited —
|
||||
#
|
||||
# site/content/expedientes/es/casos/recaida-hardcoded-bis.md (clinical skin only)
|
||||
# site/public/images/expedientes/es/expedientes.html (CASES[1], both skins)
|
||||
#
|
||||
# The only derived strings are weapon.code / fix.code: the informe only ever carried the
|
||||
# syntax-highlighted variant (weaponCode/fixCode), so the plain snippet is the highlighted
|
||||
# one with its <span> tags stripped and its entities un-escaped. The highlighted original
|
||||
# is kept verbatim in *_html.
|
||||
let s = import "../../../../schemas/expediente.ncl" in
|
||||
|
||||
s.make_expediente {
|
||||
id = "recaida-hardcoded-bis",
|
||||
title = "Expediente 404-PAP-bis: la recaída",
|
||||
slug = "la-recaida-hardcoded",
|
||||
case_no = "404-PAP-bis",
|
||||
classification = "ANTI-PAP · recaída",
|
||||
status = "ALTA",
|
||||
|
||||
# The .md is written clinical (kicker "Historia clínica"); the detective skin exists
|
||||
# only in the informe.
|
||||
skin = 'clinical,
|
||||
|
||||
tab = "404-PAP-bis · la recaída",
|
||||
print_href = "/images/expedientes/es/recaida.html",
|
||||
|
||||
# informe · GLOSS (shared by both cases in the hand-written viewer)
|
||||
glossary = [
|
||||
{ term = "PAP", def = "<b>Project's Architecture Principles</b> — las reglas y patrones que sostienen la arquitectura del proyecto: la fuente única de la verdad que todo el código debe respetar." },
|
||||
{ term = "anti-PAP", def = "Código escrito <em>en contra</em> de esas reglas: un stub que re-lista a mano lo que <span class='mono'>routes.ncl</span> ya declara, duplicando y contradiciendo la fuente de la verdad." },
|
||||
],
|
||||
|
||||
# This case's two ledgers are NOT cost/yield: they are the SAME bug weighed without and
|
||||
# with prior history. Titles and rows are the .md's double ledger, verbatim.
|
||||
cost_title = "Caso 404-PAP · sin historia",
|
||||
yielded_title = "Caso bis · con historia clínica",
|
||||
cost_caption = "Coste medible · sesión 2026-07-10 → 07-11",
|
||||
|
||||
cost = [
|
||||
{ label = "Diagnóstico", value = "~88 min" },
|
||||
{ label = "Builds", value = "8" },
|
||||
{ label = "Pistas falsas", value = "8" },
|
||||
],
|
||||
|
||||
yielded = [
|
||||
{ label = "Diagnóstico", value = "~15 min" },
|
||||
{ label = "Builds", value = "1" },
|
||||
{ label = "Pistas falsas", value = "0" },
|
||||
# Only the informe carried these two. They are not a third framing: they are what the
|
||||
# prior history BOUGHT, so they belong in the "con historia" column — and dropping them
|
||||
# to fit the article's shape would have been the same silent loss this case is about.
|
||||
{ label = "Factor de aceleración vs. el primer caso", value = "~30×", emphasis = true },
|
||||
{ label = "Tamaño del arreglo final", value = "1 <i>línea</i>", emphasis = true },
|
||||
],
|
||||
|
||||
# informe · suspectsTech (HTML-rich, entities preserved)
|
||||
suspects = [
|
||||
{ name = "El binario / la caché" },
|
||||
{ name = "La ruta <span class='mono'>/nosotros</span> declarada" },
|
||||
{ name = "El FTL & el componente horneado" },
|
||||
{ name = "<span class='mono'>pages_htmx::dispatch</span>" },
|
||||
],
|
||||
|
||||
weapon = {
|
||||
code = m%"
|
||||
match path {
|
||||
"/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
|
||||
// … cada página a mano … menos /nosotros …
|
||||
_ => None, // ← /nosotros caía aquí → 404
|
||||
}
|
||||
"%,
|
||||
code_html = m%"
|
||||
<span class="k">match</span> path {
|
||||
<span class="g">"/services"</span> | <span class="g">"/servicios"</span> => Some(static_page::render(env, <span class="g">"services"</span>, lang)),
|
||||
<span class="c">// … cada página a mano … menos /nosotros …</span>
|
||||
_ => <span class="r">None</span>, <span class="c">// ← /nosotros caía aquí → 404</span>
|
||||
}
|
||||
"%,
|
||||
},
|
||||
|
||||
fix = {
|
||||
code = m%"
|
||||
_ => resolve_static_page(env, path, lang),
|
||||
// lee del registry: cualquier ruta cuyo componente
|
||||
// acabe en "Page" → static_page(page_id kebab). Cero arms nuevos.
|
||||
"%,
|
||||
code_html = m%"
|
||||
_ => resolve_static_page(env, path, lang),
|
||||
<span class="c">// lee del registry: cualquier ruta cuyo componente</span>
|
||||
<span class="c">// acabe en "Page" → static_page(page_id kebab). Cero arms nuevos.</span>
|
||||
"%,
|
||||
},
|
||||
|
||||
# informe · contain[] — the plain mechanism is the HTML one with its <span> stripped.
|
||||
containment = [
|
||||
{
|
||||
facet = "El primer caso, ya declarado como historia",
|
||||
mechanism = "content-kind-howto → consulta directa",
|
||||
mechanism_html = "content-kind-howto <span class='kicknum'>→ consulta directa</span>",
|
||||
},
|
||||
{
|
||||
facet = "Reconocer el patógeno sin re-interrogar",
|
||||
mechanism = "mismo antibiótico → registry",
|
||||
mechanism_html = "mismo antibiótico <span class='kicknum'>→ registry</span>",
|
||||
},
|
||||
{
|
||||
facet = "Cerrar la recaída también registrada",
|
||||
mechanism = "static-page-howto → el 3º durará segundos",
|
||||
mechanism_html = "static-page-howto <span class='kicknum'>→ el 3º durará segundos</span>",
|
||||
},
|
||||
],
|
||||
|
||||
# Neutral: both skins sign the same bottom line (informe · verdictClose).
|
||||
verdict_close = "Lo revelador no es que recayera: es <strong>cuánto duró</strong>. Mismo bug, <strong>~30× más rápido</strong>, no porque fuera más fácil —era idéntico— sino porque el primero se <em>declaró</em>. Eso es <a href='https://ontoref.dev' target='_blank' rel='noopener'>ontoref</a> en tiempo real: el howto previo convierte “otra tarde perdida” en “quince minutos y un antibiótico conocido”.",
|
||||
|
||||
skins = {
|
||||
detective = {
|
||||
h1_lead = "El caso de la ",
|
||||
h1_red = "recaída",
|
||||
logline = "Creíamos el caso cerrado, medalla al pecho. Y entonces <span class='mono'>/nosotros</span> dio 404. Al instante. Del triunfo al callejón sin transición — el mismo asesino, otra víctima.",
|
||||
victim = "Víctima: /nosotros",
|
||||
stamp = "~30× MÁS RÁPIDO",
|
||||
foot_right = "Detective: turno de noche · Con ficha previa",
|
||||
|
||||
exhibits = [
|
||||
"El reincidente",
|
||||
"Los cuatro de siempre",
|
||||
"El arma gemela",
|
||||
"La confesión, en una línea",
|
||||
"El veredicto",
|
||||
],
|
||||
headings = [
|
||||
"Cuánto duró esta vez",
|
||||
"Cuatro sospechosos, cero rodeos",
|
||||
"El cadáver estaba en <span class='mono'>pages_htmx::dispatch</span>",
|
||||
"Una línea para cerrar el gemelo",
|
||||
"Por qué esta recaída no fue como la primera",
|
||||
],
|
||||
lead_cost = "El mismo modus operandi, otra puerta. Pero esta vez llevaba la ficha del primer caso en el bolsillo. Lo que costó, medido en builds:",
|
||||
lead_suspects = "Con la historia del primer caso, no hubo rueda de reconocimiento larga. Fui directo al que ya conocía.",
|
||||
|
||||
alibis = [
|
||||
"¿Otra vez yo? Los fingerprints ya no cuelan.",
|
||||
"Estoy en <span class='mono'>routes.ncl</span>. Coartada de hierro.",
|
||||
"Presente y correcto. No es lo mío.",
|
||||
"Vale… el <em>match</em> que elige la página estática era mío.",
|
||||
],
|
||||
suspect_tags = ["sospechoso habitual", "coartada firme", "descartado", "el culpable"],
|
||||
|
||||
weapon_caption = "El arma gemela · un match hardcoded en las páginas estáticas",
|
||||
weapon_after = "Ruta declarada, FTL presente, componente horneado — y aun así 404. El mismo <span class='mono'>match</span> que duplica a mano lo que <span class='mono'>routes.ncl</span> ya sabe, ahora en el órgano de al lado.",
|
||||
fix_caption = "PAP-compliant · una línea, cero arms nuevos",
|
||||
fix_after = "Cualquier ruta cuyo componente acabe en <span class='mono'>Page</span> se resuelve desde el registry. El gemelo, y el que venga, ya no se tocan a mano.",
|
||||
|
||||
verdict_lead = "El patógeno tenía familia: cualquier <span class='mono'>match</span> hardcoded que sombree el registry. La diferencia no la marcó el ingenio, la marcó la <strong>historia previa</strong>:",
|
||||
# GAP: no long-form detective verdict exists in any source (the .md is clinical).
|
||||
# The informe's verdictLead is repeated here rather than invent prose.
|
||||
verdict_prose = "El patógeno tenía familia: cualquier <span class='mono'>match</span> hardcoded que sombree el registry. La diferencia no la marcó el ingenio, la marcó la <strong>historia previa</strong>:",
|
||||
# .md-only slots (weapon_note, fix_note, abandonment, balance, balance_outro,
|
||||
# closing) are absent for this skin: the .md article is clinical.
|
||||
},
|
||||
|
||||
clinical = {
|
||||
h1_lead = "Patología: la ",
|
||||
h1_red = "recaída",
|
||||
logline = "Creíamos estar de alta, el cuadro resuelto. Y entonces <span class='mono'>/nosotros</span> dio 404. Al instante. Del alta a la recaída sin escalón — el mismo patógeno, otro órgano.",
|
||||
# El artículo respira más largo que el gancho del informe.
|
||||
logline_article = "Creíamos estar de alta. El hub `/expedientes` en verde, el caso cerrado, la gloria. Y entonces `/nosotros` dio 404. Al instante. Del logro a la miseria sin transición — un espejismo, una montaña rusa que solo baja.",
|
||||
victim = "Órgano: /nosotros",
|
||||
stamp = "~30× MÁS RÁPIDO",
|
||||
foot_right = "Médico: guardia de noche · Con historia previa",
|
||||
|
||||
exhibits = [
|
||||
"La recaída",
|
||||
"Diferencial breve",
|
||||
"Etiología gemela",
|
||||
"Terapia de una línea",
|
||||
"Pronóstico",
|
||||
],
|
||||
headings = [
|
||||
"Cuánto duró esta vez",
|
||||
"Cuatro diagnósticos, sin rodeos",
|
||||
"El patógeno vivía en <span class='mono'>pages_htmx::dispatch</span>",
|
||||
"Una línea de terapia",
|
||||
"Por qué esta recaída no fue como la primera",
|
||||
],
|
||||
lead_cost = "El mismo cuadro, otro órgano. Pero esta vez con la historia clínica del primer episodio a mano. Lo que costó, en constantes:",
|
||||
lead_suspects = "Con la historia previa, el diagnóstico diferencial fue corto: fui directo al patógeno ya conocido.",
|
||||
|
||||
alibis = [
|
||||
"¿Otra vez yo? Los marcadores ya no bastan.",
|
||||
"Consto en <span class='mono'>routes.ncl</span>. Constante normal.",
|
||||
"Presente y correcto. Analítica negativa.",
|
||||
"Sí… el <em>match</em> que elige la página estática era el foco.",
|
||||
],
|
||||
suspect_tags = ["reincidente conocido", "constante normal", "analítica negativa", "el patógeno"],
|
||||
|
||||
weapon_caption = "Etiología gemela · un match hardcoded en las páginas estáticas",
|
||||
weapon_after = "Ruta declarada, FTL presente, componente horneado — y aun así 404. El mismo <span class='mono'>match</span> que duplica a mano lo que <span class='mono'>routes.ncl</span> ya sabe, ahora en el órgano contiguo.",
|
||||
# The .md's etiology section wraps the snippet: lead-in paragraph, code, note. Both
|
||||
# paragraphs live here, blank-line separated (the code goes between them).
|
||||
weapon_note = m%"
|
||||
El [Expediente 404-PAP](/expedientes/casos/la-deriva-hardcoded) mató un `match` hardcoded en `render_content_or_grid` (páginas de contenido). El gemelo vivía intacto en el órgano de al lado — las **páginas estáticas**, `pages_htmx::dispatch`:
|
||||
|
||||
Ruta declarada, FTL presente, componente horneado — y aun así 404. El mismo anti‑PAP: un match que duplica lo que `routes.ncl` ya sabe.
|
||||
"%,
|
||||
|
||||
fix_caption = "PAP-compliant · una línea, sin ramas nuevas",
|
||||
fix_after = "Cualquier ruta cuyo componente acabe en <span class='mono'>Page</span> se resuelve desde el registry. El gemelo, y el próximo, ya no se tratan a mano.",
|
||||
# GAP: the .md's treatment section is the snippet and nothing else — no prose.
|
||||
fix_note = "",
|
||||
|
||||
verdict_lead = "El patógeno tenía familia: cualquier <span class='mono'>match</span> hardcoded que sombree el registry. La diferencia no la marcó la pericia, la marcó la <strong>historia clínica previa</strong>:",
|
||||
verdict_prose = "Lo revelador no es que recayera. Es **cuánto duró**. El primer caso, sin historia previa, costó una tarde. El gemelo, idéntico, costó **minutos** — porque el primero ya estaba *declarado como historia clínica* (`content-kind-howto`): fui directo al órgano, reconocí el patógeno, apliqué el mismo antibiótico.",
|
||||
|
||||
# .md · el bloque «Motivo de consulta» (anamnesis): la única prosa del artículo que
|
||||
# nombra el punto de abandono humano.
|
||||
anamnesis = "<b>Motivo de consulta.</b> Paciente que, creyéndose curado, sufre recaída súbita del mismo cuadro. Refiere del susto‑sorpresa al pánico y a la incertidumbre en un instante; el ánimo cae del triunfo a la depresión sin escalón intermedio. Verbaliza: <em>\"después de esto voy a necesitar una visita a la clínica\"</em>. Y aquí está.",
|
||||
# .md · el párrafo que cierra el doble balance
|
||||
balance_outro = "Mismo bug. **~30× más rápido.** No porque el segundo fuera más fácil — era idéntico — sino porque el primero se **declaró**. Eso es [ontoref](https://ontoref.dev) trabajando en tiempo real: el `dao` te sostiene en la recaída, el *modo* te da el paso, y el howto previo convierte \"otra tarde perdida\" en \"quince minutos y un antibiótico conocido\".",
|
||||
# .md · «Alta y profilaxis»
|
||||
closing = "El patógeno tiene familia: cualquier `match` hardcoded que sombree el registry. La vacuna es la misma para todos — leer de la fuente única. El caso se cierra registrado (`static-page-howto`) para que el **tercer** gemelo, si aparece, dure segundos.",
|
||||
},
|
||||
},
|
||||
|
||||
related_modes = ["generate-expediente"],
|
||||
tags = ["expediente", "clinico", "recaida", "anti-pap", "dogfooding", "ontoref"],
|
||||
}
|
||||
|
|
@ -1,58 +1,153 @@
|
|||
# Expediente — typed case-file schema.
|
||||
#
|
||||
# The .md next to each sidecar is the RENDERED prose; this .ncl is the TYPED
|
||||
# SOURCE — parseable and queryable without interpreting free text. A renderer
|
||||
# (or the `generate-expediente` mode) consumes this record; the two skins
|
||||
# ('detective / 'clinical) are a presentation choice over the same data.
|
||||
# The .md next to each sidecar is a RENDERED projection; this .ncl is the TYPED
|
||||
# SOURCE. Two surfaces consume it and NEITHER may be hand-edited:
|
||||
#
|
||||
# site/content/expedientes/<lang>/<dir>/<id>.md the article body (one skin)
|
||||
# site/public/images/expedientes/<lang>/expedientes.html the informe (both skins)
|
||||
#
|
||||
# ── Why `skins` exists (2026-07-12) ──────────────────────────────────────────
|
||||
# The informe carries prose per CASE **and per SKIN**: detective and clinical do not
|
||||
# share loglines, headings, alibis, stamps or footers. The old contract held a single
|
||||
# flattened skin — whichever `skin` named — so the informe could never be projected from
|
||||
# it and was maintained by hand, in a 35 KB HTML with the data inlined as a JS array.
|
||||
# The two then drifted (deriva's ledger: 6 rows in the .md, 8 in the informe).
|
||||
#
|
||||
# So the case splits in two:
|
||||
#
|
||||
# NEUTRAL what is true regardless of who tells it: ids, numbers, code, the technical
|
||||
# names of the suspects, the containment table. Lives at the top level.
|
||||
# SKINNED what the genre owns: how it is told. Lives in `skins.<skin>`, one entry per
|
||||
# skin the case actually HAS. A case with a single skin is legal and honest —
|
||||
# the informe falls back to `skin` and the absent skin's toggle goes dead.
|
||||
# Better an admitted gap than invented prose.
|
||||
#
|
||||
# Skin VOCABULARY (kicker, "Exhibit A" vs "Hallazgo A", "CASO CERRADO" vs "ALTA MÉDICA",
|
||||
# the section headings) is NOT here: it is the renderer's, per locale × skin, in
|
||||
# scripts/build/expediente-vocab.nu. What repeats identically across every case is not
|
||||
# case data.
|
||||
|
||||
{
|
||||
Ledger = { label | String, value | String, emphasis | Bool | default = false },
|
||||
|
||||
Suspect = {
|
||||
name | String,
|
||||
alibi | String, # the one-line "coartada" / reason ruled out
|
||||
verdict | String, # e.g. "pista falsa", "coartada 302", "señuelo"
|
||||
},
|
||||
# Technical, skin-independent. The alibi and the verdict tag are the genre's job and
|
||||
# live in the skin, index-parallel to this array (the renderer asserts the lengths).
|
||||
# HTML is allowed: the informe styles fragments with <span class='mono'>.
|
||||
Suspect = { name | String },
|
||||
|
||||
Weapon = {
|
||||
caption | String,
|
||||
code | String, # the offending / fixed snippet (verbatim)
|
||||
note | String | default = "",
|
||||
code | String, # verbatim snippet — the source of truth
|
||||
code_html | String | default = "", # same snippet, syntax-highlighted for the
|
||||
# informe. Empty ⇒ the informe escapes `code`.
|
||||
},
|
||||
|
||||
Containment = {
|
||||
facet | String, # a facet of the drift
|
||||
mechanism | String, # the ontoref mechanism that would contain it
|
||||
facet | String,
|
||||
mechanism | String,
|
||||
mechanism_html | String | default = "", # informe variant (carries <span> markup)
|
||||
},
|
||||
|
||||
Term = { term | String, def | String },
|
||||
# The informe sets its definitions in richer type than the article does (<b>, <em>,
|
||||
# <span class='mono'>). Same definition, two typographies — not two definitions.
|
||||
Term = { term | String, def | String, def_html | String | default = "" },
|
||||
|
||||
# Everything the genre owns, for ONE skin of ONE case.
|
||||
SkinProse = {
|
||||
# Overrides the locale × skin vocabulary. `recaida` and `espejo` are both clinical and
|
||||
# sign differently ("Servicio de Patología del Código" vs "Patología del Software");
|
||||
# a vocabulary that cannot be overridden would have silently retitled one of them.
|
||||
kicker | String | optional,
|
||||
# `recaida` closes on a named section ("Alta y profilaxis"); the others just close.
|
||||
closing_heading | String | optional,
|
||||
|
||||
# ── the informe's header
|
||||
h1_lead | String, # "El caso de la "
|
||||
h1_red | String, # "deriva hardcoded" (rendered in red)
|
||||
logline | String,
|
||||
# The article may open on a longer breath than the informe's one-liner (recaida does:
|
||||
# the viewer gets the hook, the article gets the whole ride). Empty ⇒ same as logline.
|
||||
logline_article | String | default = "",
|
||||
victim | String, # "Víctima: una tarde" / "Órgano: /nosotros"
|
||||
stamp | String, # the case half of the rubber stamp: "8 LÍNEAS"
|
||||
foot_right | String,
|
||||
|
||||
# ── the informe's five sections. exhibits[] is the case half of the exhibit label
|
||||
# ("El cuerpo" → "Exhibit A — El cuerpo"); the prefix is vocabulary.
|
||||
exhibits | Array String, # 5
|
||||
headings | Array String, # 5
|
||||
lead_cost | String, # leads the ledger
|
||||
lead_suspects | String, # leads the line-up
|
||||
|
||||
# ── the line-up, index-parallel to the neutral `suspects`
|
||||
alibis | Array String,
|
||||
suspect_tags | Array String,
|
||||
|
||||
# ── weapon & fix. `after` is the informe's paragraph, `note` the .md's.
|
||||
weapon_caption | String,
|
||||
weapon_after | String,
|
||||
weapon_note | String | default = "",
|
||||
fix_caption | String,
|
||||
fix_after | String,
|
||||
fix_note | String | default = "",
|
||||
|
||||
# ── the verdict. `lead` opens the informe's last section; `prose` is the .md's
|
||||
# long-form verdict. They are different texts for different surfaces, not a
|
||||
# duplication: pretending otherwise is what let them drift.
|
||||
verdict_lead | String,
|
||||
verdict_prose | String,
|
||||
|
||||
# ── .md-only editorial prose (Markdown; blank line separates paragraphs)
|
||||
# The clinical article opens on a chief complaint — the patient's own words before any
|
||||
# measurement. It is a section, not a paragraph, and the contract had no room for it:
|
||||
# two independent harvests of `recaida` both reported it as about to be deleted.
|
||||
anamnesis | String | optional,
|
||||
abandonment | String | optional,
|
||||
balance | String | optional,
|
||||
balance_outro | String | optional,
|
||||
closing | String | optional,
|
||||
},
|
||||
|
||||
Expediente = {
|
||||
id | String,
|
||||
title | String,
|
||||
slug | String,
|
||||
case_no | String,
|
||||
classification | String, # e.g. "ANTI-PAP"
|
||||
classification | String,
|
||||
status | String | default = "RESUELTO",
|
||||
skin | [| 'detective, 'clinical |] | default = 'detective,
|
||||
|
||||
logline | String,
|
||||
glossary | Array Term | default = [],
|
||||
cost | Array Ledger, # "lo que costó"
|
||||
yielded | Array Ledger | default = [],# "lo que dejó" (la convergencia)
|
||||
abandonment | String | optional, # el punto de abandono (prosa)
|
||||
suspects | Array Suspect | default = [],
|
||||
weapon | Weapon, # el arma (código anti-PAP)
|
||||
fix | Weapon, # el giro (código PAP-compliant)
|
||||
containment | Array Containment, # tabla deriva → mecanismo
|
||||
verdict | String,
|
||||
# The canonical skin: the one the .md article is written in, and the informe's
|
||||
# fallback when the reader asks for a skin this case does not have.
|
||||
skin | [| 'detective, 'clinical |] | default = 'detective,
|
||||
skins | { _ : SkinProse },
|
||||
|
||||
tab | String, # the informe's tab label
|
||||
print_href | String | default = "", # standalone printable; "" ⇒ no print button
|
||||
|
||||
glossary | Array Term | default = [],
|
||||
cost | Array Ledger,
|
||||
yielded | Array Ledger | default = [],
|
||||
|
||||
# The two ledgers are not always "cost vs yield". `recaida` weighs the SAME case with
|
||||
# and without prior history — its columns are "no history" / "with history", and a
|
||||
# generator that forced the default titles would have flattened the one comparison the
|
||||
# case is about. Empty ⇒ the locale's default titles.
|
||||
cost_title | String | default = "",
|
||||
yielded_title | String | default = "",
|
||||
cost_caption | String | default = "", # the informe's <caption> over the ledger
|
||||
suspects | Array Suspect | default = [],
|
||||
weapon | Weapon,
|
||||
fix | Weapon,
|
||||
containment | Array Containment,
|
||||
|
||||
# The informe's closing paragraph. Neutral: both skins sign the same bottom line —
|
||||
# which is the point of the series, and the one sentence that must not vary by genre.
|
||||
verdict_close | String,
|
||||
|
||||
ontoref_url | String | default = "https://ontoref.dev",
|
||||
related_proofs | Array String | default = [], # positioning proof ids
|
||||
related_modes | Array String | default = [], # reflection mode ids
|
||||
related_proofs | Array String | default = [],
|
||||
related_modes | Array String | default = [],
|
||||
tags | Array String | default = [],
|
||||
},
|
||||
|
||||
# Factory: validate a record against the Expediente contract.
|
||||
make_expediente = fun r => r | Expediente,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue