From fbb6f99f4481db00994d71a2e077253ae11ac40d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesu=CC=81s=20Pe=CC=81rez?= Date: Sun, 12 Jul 2026 12:11:42 +0100 Subject: [PATCH] expedientes: the render becomes a projection; publish case 69/58 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- site/justfile | 61 +- site/scripts/build/expediente-vocab.nu | 184 ++++++ site/scripts/build/gen-expediente.nu | 222 ++++++++ site/scripts/build/gen-informe.nu | 164 ++++++ site/scripts/build/informe-template.html | 317 ++++++++++ site/site/content/adr/en/accepted/adr-070.md | 106 ++++ site/site/content/adr/en/accepted/adr-070.ncl | 12 + .../en/cases/deriva-hardcoded-anti-pap.md | 437 +++++++++++++- .../en/cases/deriva-hardcoded-anti-pap.ncl | 245 ++++++++ .../cases/espejo-que-revertia-su-trabajo.md | 539 ++++++++++++++++++ .../cases/espejo-que-revertia-su-trabajo.ncl | 295 ++++++++++ .../en/cases/recaida-hardcoded-bis.md | 485 ++++++++++++++-- .../en/cases/recaida-hardcoded-bis.ncl | 188 ++++++ .../es/casos/deriva-hardcoded-anti-pap.md | 93 +-- .../es/casos/deriva-hardcoded-anti-pap.ncl | 226 +++++++- .../casos/espejo-que-revertia-su-trabajo.md | 539 ++++++++++++++++++ .../casos/espejo-que-revertia-su-trabajo.ncl | 297 ++++++++++ .../es/casos/recaida-hardcoded-bis.md | 133 +++-- .../es/casos/recaida-hardcoded-bis.ncl | 230 ++++++++ site/site/schemas/expediente.ncl | 155 ++++- 20 files changed, 4705 insertions(+), 223 deletions(-) create mode 100644 site/scripts/build/expediente-vocab.nu create mode 100644 site/scripts/build/gen-expediente.nu create mode 100644 site/scripts/build/gen-informe.nu create mode 100644 site/scripts/build/informe-template.html create mode 100644 site/site/content/adr/en/accepted/adr-070.md create mode 100644 site/site/content/adr/en/accepted/adr-070.ncl create mode 100644 site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.ncl create mode 100644 site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.md create mode 100644 site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.ncl create mode 100644 site/site/content/expedientes/en/cases/recaida-hardcoded-bis.ncl create mode 100644 site/site/content/expedientes/es/casos/espejo-que-revertia-su-trabajo.md create mode 100644 site/site/content/expedientes/es/casos/espejo-que-revertia-su-trabajo.ncl create mode 100644 site/site/content/expedientes/es/casos/recaida-hardcoded-bis.ncl diff --git a/site/justfile b/site/justfile index 8a54b8a..0fc62db 100644 --- a/site/justfile +++ b/site/justfile @@ -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//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` diff --git a/site/scripts/build/expediente-vocab.nu b/site/scripts/build/expediente-vocab.nu new file mode 100644 index 0000000..cacfbf3 --- /dev/null +++ b/site/scripts/build/expediente-vocab.nu @@ -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 +} diff --git a/site/scripts/build/gen-expediente.nu b/site/scripts/build/gen-expediente.nu new file mode 100644 index 0000000..44dc1e2 --- /dev/null +++ b/site/scripts/build/gen-expediente.nu @@ -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//expedientes.html. One source, two surfaces. +# +# The case is written in ONE skin (`skin`); the informe offers both. This renderer reads +# skins. and ignores the rest. +# +# Usage: +# nu scripts/build/gen-expediente.nu # all cases, all locales +# nu scripts/build/gen-expediente.nu --case # one case +# nu scripts/build/gen-expediente.nu --check # assert reproducible, write nothing + +use ./expediente-vocab.nu [VOCAB] + +const CSS = '' + +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| $"
  • ($r.label) ($r.value)
  • " } | str join "\n") + $"
    \n

    ($title)

    \n
      \n($items)\n
    \n
    " +} + +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 = ["
    " $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 $"\"($c.title)\"") + $out = ($out | append $"

    ($w.cta)

    ") + } + + # 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 $"

    ($sk.kicker? | default $w.kicker)

    ") + $out = ($out | append $"

    ($log)

    ") + $out = ($out | append $"
    ($iv.meta_case) ($c.case_no)($iv.meta_class): ($c.classification)($v.md.status_label): ($c.status)
    ") + + if ($sk.anamnesis? | default "" | is-not-empty) { + $out = ($out | append $"
    \n(paras $sk.anamnesis)\n
    ") + } + + if ($c.glossary | is-not-empty) { + let dl = ($c.glossary | each {|t| $"
    ($t.term)
    ($t.def)
    " } | str join "\n") + $out = ($out | append $"
    \n
    \n($dl)\n
    \n

    ($v.md.protocol)ontoref.dev

    \n
    ") + } + + # Doble balance: prose → ledgers → prose. The ledgers are data; the prose is editorial. + $out = ($out | append $"

    ($w.balance)

    ") + 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 $"
    \n(render-ledger $ct 'cost' $c.cost)\n(render-ledger $yt 'yield' $c.yielded)\n
    ") + 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 $"

    ($w.abandon)

    ") + $out = ($out | append (paras $sk.abandonment)) + } + + if ($c.suspects | is-not-empty) { + $out = ($out | append $"

    ($w.suspects)

    ") + let rows = (0..<$n | each {|i| + let s = ($c.suspects | get $i) + $"($s.name)“($sk.alibis | get $i)”($sk.suspect_tags | get $i)" + } | str join "\n") + $out = ($out | append $"\n($rows)\n
    ") + } + + 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 $"

    ($blk.h) — ($blk.cap)

    ") + # A BLANK LINE either side of the
     is load-bearing. The site renders CommonMark:
    +    # `
    ` 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
    , so every `#` comment in the snippet re-parsed
    +    # as an ATX heading (23 spurious 

    in espejo, `

    ` swallowed by the last). As its + # own chunk,
     opens a type-1 block: it closes only on `
    ` and blank lines + # cannot touch it. Chunks are joined with "\n\n" at the bottom of this function. + $out = ($out | append $"
    ($blk.code | str trim | esc)
    ") + if ($blk.note | is-not-empty) { $out = ($out | append (paras $blk.note)) } + } + + $out = ($out | append $"

    ($w.verdict)

    ") + $out = ($out | append (paras $sk.verdict_prose)) + let rows = ($c.containment | each {|r| $"($r.facet)($r.mechanism)" } | str join "\n") + $out = ($out | append $"\n($rows)\n
    ") + + if ($sk.closing? | default "" | is-not-empty) { + if ($sk.closing_heading? | default "" | is-not-empty) { + $out = ($out | append $"

    ($sk.closing_heading)

    ") + } + $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 "
    ") + + $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 + } + } +} diff --git a/site/scripts/build/gen-informe.nu b/site/scripts/build/gen-informe.nu new file mode 100644 index 0000000..ba8c1bc --- /dev/null +++ b/site/scripts/build/gen-informe.nu @@ -0,0 +1,164 @@ +#!/usr/bin/env nu + +# Project the typed expediente CASEs (.ncl) → the standalone informe, one per locale: +# site/public/images/expedientes//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)ontoref.dev" + 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 + } + } +} diff --git a/site/scripts/build/informe-template.html b/site/scripts/build/informe-template.html new file mode 100644 index 0000000..aff3ee2 --- /dev/null +++ b/site/scripts/build/informe-template.html @@ -0,0 +1,317 @@ + + +{{TITLE}} + + + + +
    + + + +
    + + + +
    + +
    + +
    + +
    + + + + diff --git a/site/site/content/adr/en/accepted/adr-070.md b/site/site/content/adr/en/accepted/adr-070.md new file mode 100644 index 0000000..e16944c --- /dev/null +++ b/site/site/content/adr/en/accepted/adr-070.md @@ -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" +--- +

    Context

    + +

    Four incidents over two sessions (2026-07-10..12) on the outreach/site publication +pipeline. They read as four unrelated bugs and are one missing declaration.

    +

    1. THE MIRROR THAT REVERTED ITS OWN WORK. `content_processor` writes the content + indexes to site/r, but the `content` recipe ended with + `rsync -a site/public/r/ -> site/r/`. public/r had stopped being the tool's + output root, so the rsync copied a fossil tree over the fresh one: the tool + printed "Generated index.json with 69 posts" while the disk kept 58. Worse, + public/r is the tree the deploy pack ships — so no new content could reach + production at all. The ADR-066 rule was violated in its purest form: the + reporter said done, nothing checked, and no one noticed for months.

    +

    2. 214 LINES IN A TREE THAT IS rm -rf'd. The template assembler has two layers + (framework defaults, then source-project templates from website-htmx-rustelo) + while the level chain has three (rustelo -> website-htmx-rustelo -> + outreach/site). The site level had NO overlay slot, so template work owned by + this level had nowhere legitimate to live and ended up in htmx-templates/ — + the one tree `just templates` deletes and rebuilds. The nav submenus were lost + that way once already; at the time of writing, 214 lines (nav submenus, + subscribe CTA, post engagement block) were one command from annihilation, with + no copy in git, in the deploy snapshots, or anywhere else.

    +

    3. ELEVEN ADRs THAT NEVER REACHED THE SITE. `gen-adr-pages.nu` existed and worked, + but no recipe called it. ADR-059..069 were written, accepted, and invisible on + ontoref.dev. A generator outside the dependency chain is a generator that does + not run.

    +

    4. A CONTRACT INSIDE THE DATA TREE. `gen-content-graph` nickel-exports every .ncl + under site/content, and expedientes/_schema/expediente.ncl was a CONTRACT, not + an instance — the export demanded `id` and the recipe aborted. `just graph` had + been dead since expedientes landed. The generator was right; the file was in + the wrong tree.

    +

    The forensics behind these: git was not a witness (outreach/.git tracked ONE file, +README.md) and every content pack stamped `git_sha=5619a40` regardless of what it +shipped — a signature with no provenance behind it. Source content survived (0 files +lost, verified against both deploy snapshots), but nothing in the pipeline could have +PROVEN that; the recovery was archaeology, not verification.

    +

    Each incident is a tree whose consumers assume it holds only something else, and in +every case what was missing is the same three facts: who owns this tree, what command +reproduces it, and what witnesses it.

    + +

    Decision

    + +

    Every tree in the outreach/site delivery pipeline declares three properties, and the +pipeline is gated on them.

    +

    OWNER — exactly one writer. A tree is either AUTHORED (a human writes it; it is + tracked in git) or GENERATED (one named recipe writes it). No tree has two owners. + Where two trees exist for one concern (site/r serves, site/public/r ships), each + FILE has one owner, and the mirror between them is typed by ownership, never by + direction: content indexes flow r -> public/r (with --delete, or a renamed slug + survives as a live URL), and the four nu artifacts (about, adr-map, taglines, + content_graph) flow public/r -> r, excluded from the outbound leg so a stale copy + cannot overwrite a fresh original. A whole-tree one-way sync between two trees that + own different files is always wrong in one of the two directions.

    +

    REPRODUCTION — a named recipe, reachable from `sync-full`. A projection generator + that must be remembered and invoked by hand is not part of the build; it is a + private ritual, and it stops tracking its source the day the human forgets. Every + generator is a link in the chain, and the chain's ORDER is load-bearing: emitters + before `content`, readers of the indexes after it, the serve-tree artifact copy last.

    +

    WITNESS — reproducible, or tracked. A tree may be gitignored ONLY if a tracked + source plus a recipe reproduce it. A generated tree that holds the only copy of any + work is tracked until that stops being true. This is the rule that governs the + .gitignore, and it is why site/public/images (196 of 209 images exist nowhere else), + site/content/{adr,catalog,domains} (projections whose generators were unwired or + broken) and htmx-templates/ (until 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.

    +

    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.

    +

    THE GATES DECIDE, AND THEY RUN BEFORE THE DAMAGE. Three exist and pass today: + `adr-check` (the spine holds no ADR the site never published), `templates-check` + (the assembled tree is exactly reproducible from its declared sources — any drift + names the file that exists nowhere else), `posts-check` (linkify + graph coverage). + Each asserts against the DISK, never against a tool's stdout — ADR-066's rule + applied to the surface that publishes ontoref, the last place still trusting a + reporter. And each is runnable while the damage is still hypothetical: a check you + can only run after the destructive step is not a gate, it is an autopsy.

    +

    The deploy gate — `just authoring publish` refusing to ship when a gate fails — is + declared Soft, not Hard, because its enforcement point lives in another repo + (website-htmx-rustelo) and does not exist yet. Declaring it Hard with no executable + check would be precisely the reporter-believes-itself pattern this ADR forbids.

    + +

    Constraints

    + +
    • Hard Every projection generator that feeds a site content-kind MUST be invoked by a recipe reachable from `sync-full`; a generator runnable only by hand is drift by construction.
    • Hard The site/r <-> site/public/r mirror MUST be typed by file ownership, never by direction: content indexes flow r -> public/r with --delete, and the four nu artifacts flow public/r -> r and are excluded from the outbound leg.
    • Hard 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.
    • Hard `just templates-check` MUST pass: the assembled template tree reproduces exactly from its declared sources, so no file in it exists nowhere else.
    • Hard `just adr-check` MUST pass: every ADR in .ontoref/adrs has a page in the site's adr content-kind.
    • Hard A tree MUST NOT be gitignored unless a tracked source plus a named recipe reproduce it; a generated tree holding the only copy of any work stays tracked until that ceases to be true.
    • Hard site/content/ MUST contain only content instances; schemas and contracts live outside it (site/schemas/), because every consumer of that tree nickel-exports each .ncl as data.
    • Soft The deploy SHOULD refuse to ship when a declared gate fails — the check decides, never the operator's memory. Enforcement point: the publish path in website-htmx-rustelo.
    + +

    Alternatives considered

    + +
    • Fix the four bugs, add no ADRrejected: This is the status quo that produced them. The first two incidents (the drift and the relapse) were already fixed once, individually, and the pathology recurred one layer down each time. Four instances of one shape is not a run of bad luck; it is an undeclared invariant asking to be named.
    • Extend ADR-066 to cover the publication pipelinerejected: ADR-066 governs the mode-DAG executor: run start, guards, verify exit codes, postchecks, witness signatures. The publication pipeline is not a mode run — it is a justfile, three binaries and a deploy pack. The RULE is shared (the check decides) and is cited as such; the substrate, the trees and the constraint class (owner/reproduction/witness) are new. Folding them into ADR-066 would blur an accepted, falsación-proven decision with an unproven one.
    • Generalize to every constellation delivery surface (code, outreach, desktop)rejected: One realized instance. ADR-063 was explicitly gated from approach to substrate on a SECOND instance exercising the mechanism end-to-end, and ADR-066 discharged that gate before promoting. Generalizing from the site alone would assert what has not been exercised — the same unearned confidence this ADR exists to prevent. Scoped to outreach/site; the second instance may promote it.
    • Make the deploy gate Hard nowrejected: Its check cannot run: the enforcement point is in another repo. A Hard constraint with no executable check passes the validator while verifying nothing — a reporter believing itself, wearing a severity field. Soft reports the direction without the lie.
    • Give the site its own git repo so its delivery is self-containedrejected: ADR-062, projection-not-own-repo: the repo driver is the publication boundary, never conceptual separability, and site/web share outreach's public boundary — 'different deploy target is not different visibility boundary'. The delivery problem is solved by declaring the trees, not by drawing a new git boundary around them.
    + +

    Anti-patterns

    + +
    • A Whole-Tree One-Way Sync Between Two Trees That Own Different Files — Two trees exist for one concern (a serve tree and a deploy tree) and a single rsync runs between them in one direction. Because each tree is the rightful owner of DIFFERENT files, the sync is necessarily wrong for one set: it either starves the destination or resurrects fossils over fresh output. Here it did both — fresh indexes were clobbered by a stale deploy tree, and no content could ever reach production.
    • Hand-Editing an Assembled Output Because the Level Has No Source Slot — A build assembles a tree from N layers, but the level doing the work is not one of them. Its edits therefore land in the assembled output — the one tree the build rm -rf's — and are destroyed on the next run. The author is not careless; there is no correct place to put the work.
    • A Projection Generator No Recipe Calls — A generator exists, works, is documented, and is invoked by hand exactly once. Its source then evolves and the projection silently stops tracking it. The artifact looks maintained because it was maintained — once. Eleven accepted ADRs stayed invisible on the site this way.
    • A Schema Filed Among the Data It Types — A contract (.ncl with undefined required fields) is placed inside a tree whose consumers treat every file as an instance and export it as data. The export demands the undefined field and the whole build step dies — not because the generator is wrong, but because the file is in a tree that promises to hold only instances.
    • A Check That Can Only Run After the Destructive Step — The verification exists but its only possible moment is after the irreversible operation. It can tell you what you lost; it cannot stop you losing it. This reads as rigor and delivers none — and it is the trap a team falls into precisely when it starts taking verification seriously.
    diff --git a/site/site/content/adr/en/accepted/adr-070.ncl b/site/site/content/adr/en/accepted/adr-070.ncl new file mode 100644 index 0000000..97d193e --- /dev/null +++ b/site/site/content/adr/en/accepted/adr-070.ncl @@ -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 = [], + }, +} diff --git a/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.md b/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.md index b06b427..b402ae9 100644 --- a/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.md +++ b/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.md @@ -16,17 +16,21 @@ sort_order: 1 ---
    + -Case file 404-PAP — the hardcoded drift +Case 404-PAP: the hardcoded drift +

    🕵️ Show the full case file →

    Case file · Code Homicide Dept.

    -

    It was a quiet afternoon. Then /recursos walked in and showed nothing. Nothing at all. And I, naive, thought it would be quick.

    +

    It was a quiet afternoon. Then /recursos walked in and showed nothing. Nothing at all. And I, naive, thought it would be quick.

    -
    Case No. 404-PAPClassification: ANTI-PAPStatus: CLOSED
    +
    Case No. 404-PAPClassification: ANTI-PAPStatus: SOLVED
    -
    PAP
    Project's Architecture Principles — the rules and patterns that hold the architecture up: the single source of truth all code must respect.
    -
    anti-PAP
    Code written against those rules. Here: a stub re-listing by hand what routes.ncl already declares.
    +
    PAP
    Project's Architecture Principles — the rules and patterns holding the project's architecture up: the single source of truth all code must respect.
    +
    anti-PAP
    Code written against those rules: a stub that re-lists by hand what routes.ncl already declares, duplicating and contradicting the source of truth.
    -

    A protocol to declare, version and verify all this → ontoref.dev

    +

    The protocol to declare, version and verify this → ontoref.dev

    The double ledger — what it cost, and what it left

    @@ -72,12 +74,14 @@ A case isn't judged only by what it costs. It's judged by what it leaves. From a

    What the crime cost

      -
    • Pure hunt (framework) ~88 min
    • -
    • Release builds 8
    • -
    • Coexisting fingerprints 6
    • +
    • Pure origin hunt (framework only) ~88 min
    • +
    • Release builds launched 8
    • Server restarts ~15+
    • -
    • False leads 8
    • -
    • The fix 8 lines
    • +
    • Build fingerprints coexisting 6
    • +
    • Files of someone else's code read (2 repos) ~20+
    • +
    • Full cargo clean runs (867 MB gone) 2
    • +
    • False leads before the right one 8
    • +
    • Size of the final fix 8 lines
    @@ -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. -

    The weapon — a hardcoded match (anti-PAP)

    +

    The suspects — the false leads

    + + + + + + + + + + +
    The content in site/r“I wasn't in sync.”minor accomplice
    filename ≠ id“The body came back empty, but I'm not the 404 guy.”other crime
    The {# … #} comment“I just slipped in as text. Innocent.”solid alibi
    Staleness & fingerprints“With 6 fingerprints, how could you not doubt the binary?”false lead
    SITE_PUBLIC_PATH / symlink“The dir didn't exist. Symlink for nothing.”false lead
    generated.rsvec!["content"]“I'm the fallback. I don't even run.”decoy
    RBAC“A deny from me is a 302, darling. A 404 isn't my job.”302 alibi
    build_page_generator“That's Leptos. In htmx-ssr I don't even show up.”wrong jurisdiction
    + +

    The weapon — The weapon · a hardcoded match (pre-existing, anti-PAP)

    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 against the source of truth.
     
    -

    The turn — 8 lines that read from the registry

    +

    The turn — PAP-compliant · adding a kind is just NCL again

    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
     
     
    -
    +
    Hardcoded stub shadowing the registryPAP + anti-patterns → grep-able
    Knowledge lost, rediscovered by painqa/howto → content-kind-howto
    Knowledge lost, rediscovered through painqa/howto → content-kind-howto
    Building on the invalid without knowingreview against invariants
    Assuming the unassumable, believing the unbelievabledeclared state → 3 levels
    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.** + +
    +
    +

    Glosario

    + +
    + +
    + +
    + + + + +
    diff --git a/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.ncl b/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.ncl new file mode 100644 index 0000000..c633909 --- /dev/null +++ b/site/site/content/expedientes/en/cases/deriva-hardcoded-anti-pap.ncl @@ -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 = "Project's Architecture Principles — 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 against those rules: a stub that re-lists by hand what routes.ncl 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 min" }, + { label = "Release builds launched", value = "8" }, + { label = "Server restarts", value = "~15+" }, + { label = "Build fingerprints coexisting", value = "6" }, + { label = "Files of someone else's code read (2 repos)", value = "~20+" }, + { label = "Full cargo clean 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 lines", 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 site/r" }, + { name = "filename ≠ id" }, + { name = "The {# … #} comment" }, + { name = "Staleness & fingerprints" }, + { name = "SITE_PUBLIC_PATH / symlink" }, + { name = m%"generated.rsvec!["content"]"% }, + { name = "RBAC" }, + { name = "build_page_generator" }, + ], + + 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%"let kind = match base { + "blog" => Some("blog"), + "activities" | "actividades" => Some("activities"), + // … every kind by hand … except resources … + _ => None, // ← /recursos landed here → 404 +};"%, + }, + + 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%"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() +});"%, + }, + + containment = [ + { facet = "Hardcoded stub shadowing the registry", + mechanism = "PAP + anti-patterns → grep-able", + mechanism_html = "PAP + anti-patterns → grep-able" }, + { facet = "Knowledge lost, rediscovered through pain", + mechanism = "qa/howto → content-kind-howto", + mechanism_html = "qa/howto → content-kind-howto" }, + { 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 → 3 levels" }, + ], + + verdict_close = m%"That differential —an afternoon ↔ eight lines— isn't an anecdote: it's the ROI. ontoref'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 /recursos 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 render_content_or_grid", + "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 deny 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 API returned the items. The content, present. And still, 404 — because a stub decided the truth against 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 against the source of truth.", + fix_caption = "PAP-compliant · adding a kind is just NCL again", + fix_after = "/recursos, /proyectos, /dominios — all of them, bilingual aliases included — resolved from the single source.", + fix_note = "/recursos, /proyectos, /dominios — 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 the map had an unmarked drift: 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 /recursos 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 render_content_or_grid", + "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 API returned the items. The content, present. And still, 404 — because a stub decided the truth against the source of truth.", + fix_caption = "PAP-compliant · adding a kind is just NCL again", + fix_after = "/recursos, /proyectos, /dominios — all of them, bilingual aliases included — resolved from the single source.", + + verdict_lead = "No malpractice. We acted where we said we'd avoid because the chart had an unrecorded lesion: 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 the chart had an unrecorded lesion: 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"], +} diff --git a/site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.md b/site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.md new file mode 100644 index 0000000..2e0695b --- /dev/null +++ b/site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.md @@ -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 +--- + +
    + + + +Case 69/58: the mirror that reverted its own work + +

    🩺 Show the full clinical history →

    + +

    Clinical history · Software Pathology

    + +

    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.

    + +
    History No. 69/58Diagnosis: ANTI-PAP · THE REPORTER DECIDEDStatus: SOLVED
    + +
    +
    +
    PAP
    Project's Architecture Principles — the rules that hold the architecture up: the single source of truth all code must respect.
    +
    anti-PAP
    Code written against those rules. Here: a mirror that decided by direction instead of by ownership.
    +
    the check decides, never the reporter
    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.
    +
    witness
    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.
    +
    +

    The protocol to declare, version and verify this → ontoref.dev

    +
    + +

    The double ledger — what it cost, and what it left

    + +
    +
    +

    What the crime cost

    +
      +
    • What the tool reported "index.json with 69 posts"
    • +
    • What was on the disk 58
    • +
    • ADRs written and never published 11 (059→069)
    • +
    • Window of invisibility 13 Jun → 11 Jul (the ADRs' own dates)
    • +
    • Content that reached production in that time 0
    • +
    • Files the site repo had tracked 1 (README.md)
    • +
    • git_sha stamped on EVERY deploy pack 5619a40 (always the same)
    • +
    • Live lines of work with not one witness 214
    • +
    • Commands away from erasing them 1 (just templates)
    • +
    • Size of the final fix 60 lines
    • +
    +
    +
    +

    What the case left

    +
      +
    • Decision ADR-070 (accepted)
    • +
    • Gates adr-check · templates-check · posts-check
    • +
    • Migration 0044 (anchored paths)
    • +
    • The slot that was missing site/templates-overlay/
    • +
    • Constraints curated on anchoring 29 (88✓ → 117✓)
    • +
    • Ontology the converse in enforcement-vs-emergence
    • +
    +
    +
    + +

    The point of abandonment — what the table doesn't show

    + +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. + +

    Differential diagnosis — what was ruled out

    + + + + + + + +
    The 6 orphans in site/r“Yes, I'm dead content still being served. But I don't stop anything from being published.”symptom, not cause
    gen-adr-pages broken“I work perfectly. I generate all 69 ADRs whenever anyone calls me.”innocent — nobody was calling it
    content_processor doesn't process case files“I do process them. What happened is that you read me with a head -20 and it cut my output off.”solid alibi (the error was the investigator's)
    The index isn't regenerated“I regenerate every time. With 69. What happens afterwards is no business of mine.”innocent — and the key to the case
    The graph generator“I export what's in the content tree. If you file a contract in among the data, I blow up. And rightly so.”innocent — it was right
    + +

    Etiology — the cause — The mirror that decided by direction, not by ownership

    + +
    # 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.
    + +just — the content recipe + +

    Treatment — The mirror is typed by ownership, and the check reads the disk

    + +
    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.
    + +just — the content recipe + +

    Prognosis

    + +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. + + + + + + + + +
    The mirror ran by direction, not by ownership of each filedeclared 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.
    The tool reported success and nobody checked the diskreview 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.
    A generator existed and no recipe ever called it (11 invisible ADRs)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.
    214 lines lived in the one tree the build wipesdeclared 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.
    A contract filed among the data it types (just graph dead)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.
    A check meant one thing or another depending on where you launched it fromreview 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.
    + + +
    +
    +

    Glosario

    + +
    + +
    + +
    + + + + + +
    diff --git a/site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.ncl b/site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.ncl new file mode 100644 index 0000000..80c0971 --- /dev/null +++ b/site/site/content/expedientes/en/cases/espejo-que-revertia-su-trabajo.ncl @@ -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 = "Project's Architecture Principles — 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 against those rules. Here: a mirror that decided by direction instead of by ownership." }, + { 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 = "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." }, + { 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 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." }, + ], + + 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 (just templates)", emphasis = true }, + { label = "Size of the final fix", value = "60 lines", 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 site/r" }, + { name = "gen-adr-pages broken" }, + { name = "content_processor 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%" +# 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. +"%, + }, + + 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 +# 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. +"%, + }, + + 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 → ADR-070: every tree declares its OWNER" }, + { 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 → ADR-066: the check decides" }, + { 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 → ADR-070: the REPRODUCTION, in the chain" }, + { 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 → ADR-070: every tree declares its WITNESS" }, + { 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 → contract-in-the-instance-tree" }, + { 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 → migration 0044: anchored paths" }, + ], + + # 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 no — 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 pointed here. Ontoref 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 content 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 head -20 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 rsync 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: 69 posts. 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 content 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 head -20.", + + 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 head -20 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 rsync 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"], +} diff --git a/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.md b/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.md index 48b75e0..39f9249 100644 --- a/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.md +++ b/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.md @@ -16,68 +16,66 @@ sort_order: 2 ---
    + -Case file 404-PAP-bis — the relapse +Case 404-PAP-bis: the relapse +

    🩺 Show the full clinical history →

    Clinical history · Code Pathology Service

    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.

    -
    History No. 404-PAP-bisDiagnosis: ANTI-PAP (relapse)Status: DISCHARGED
    +
    History No. 404-PAP-bisDiagnosis: ANTI-PAP · relapseStatus: DISCHARGED
    Chief complaint. 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: "after this I'm going to need a visit to the clinic". And here we are.
    -

    Etiology — the same pathogen, another organ

    +
    +
    +
    PAP
    Project's Architecture Principles — the rules and patterns holding the project's architecture up: the single source of truth all code must respect.
    +
    anti-PAP
    Code written against those rules: a stub that re-lists by hand what routes.ncl already declares, duplicating and contradicting the source of truth.
    +
    +

    The protocol to declare, version and verify this → ontoref.dev

    +
    -[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`: - -
    match path {
    -    "/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
    -    // … every page by hand … except /nosotros …
    -    _ => None,   // ← /nosotros fell through here → 404
    -}
    - -Route declared, FTL present, component baked — and still a 404. The same anti‑PAP: a match that duplicates what `routes.ncl` already knows. - -

    Treatment — the same antibiotic

    - -
    _ => 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.
    - -

    Prognosis — and why this relapse wasn't like the first

    +

    The double ledger — what it cost, and what it left

    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. -
    -
    +
    +

    Case 404-PAP · no history

    • Diagnosis ~88 min
    • @@ -85,20 +83,415 @@ The telling part isn't that it relapsed. It's **how long it lasted**. The first
    • False leads 8
    -
    +

    Case bis · with clinical history

    • Diagnosis ~15 min
    • Builds 1
    • False leads 0
    • +
    • Speed-up factor vs. the first case ~30×
    • +
    • Size of the final fix 1 line
    -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”. -

    Discharge and prophylaxis

    +

    Differential diagnosis — what was ruled out

    + + + + + + +
    The binary / the cache“Me again? Markers alone won't do it.”known repeat
    The declared /nosotros route“I'm on record in routes.ncl. Normal vital.”normal vital
    The FTL & the baked component“Present and correct. Negative panel.”negative panel
    pages_htmx::dispatch“Yes… the match that picks the static page was the focus.”the pathogen
    + +

    Etiology — the cause — Twin etiology · a hardcoded match in the static pages

    + +
    match path {
    +    "/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
    +    // … every page by hand … except /nosotros …
    +    _ => None,   // ← /nosotros fell through here → 404
    +}
    + +[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. + +

    Treatment — PAP-compliant · one line, no new branches

    + +
    _ => 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.
    + +

    Prognosis

    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. + + + + +
    The first case, already declared as historycontent-kind-howto → direct lookup
    Recognizing the pathogen without re-interrogatingsame antibiotic → registry
    Closing the relapse on record toostatic-page-howto → the 3rd will take seconds
    + + +
    +
    +

    Glosario

    + +
    + +
    + +
    + + + + +
    diff --git a/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.ncl b/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.ncl new file mode 100644 index 0000000..cc558f2 --- /dev/null +++ b/site/site/content/expedientes/en/cases/recaida-hardcoded-bis.ncl @@ -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 = "Project's Architecture Principles — 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 against those rules: a stub that re-lists by hand what routes.ncl 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 line", emphasis = true }, + ], + + suspects = [ + { name = "The binary / the cache" }, + { name = "The declared /nosotros route" }, + { name = "The FTL & the baked component" }, + { name = "pages_htmx::dispatch" }, + ], + + 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 = "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}", + }, + + 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// reads from the registry: any route whose component\n// ends in \"Page\" → static_page(page_id kebab). Zero new arms.", + }, + + containment = [ + { facet = "The first case, already declared as history", + mechanism = "content-kind-howto → direct lookup", + mechanism_html = "content-kind-howto → direct lookup" }, + { facet = "Recognizing the pathogen without re-interrogating", + mechanism = "same antibiotic → registry", + mechanism_html = "same antibiotic → registry" }, + { facet = "Closing the relapse on record too", + mechanism = "static-page-howto → the 3rd will take seconds", + mechanism_html = "static-page-howto → the 3rd will take seconds" }, + ], + + verdict_close = "The telling part isn't that it relapsed: it's how long it lasted. Same bug, ~30× faster, not because it was easier —it was identical— but because the first was declared. That's ontoref 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 /nosotros 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 pages_htmx::dispatch", + "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 routes.ncl. Iron-clad alibi.", + "Present and correct. Not my thing.", + "Fine… the match 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 match that duplicates by hand what routes.ncl 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 Page resolves from the registry. The twin, and whatever comes next, are no longer touched by hand.", + + verdict_lead = "The pathogen had family: any hardcoded match shadowing the registry. The difference wasn't cleverness, it was prior history:", + # 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 match shadowing the registry. The difference wasn't cleverness, it was prior history:", + }, + + clinical = { + h1_lead = "Pathology: the ", + h1_red = "relapse", + logline = "We thought we were discharged, the picture resolved. And then /nosotros 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 = "Chief complaint. 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: \"after this I'm going to need a visit to the clinic\". 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 pages_htmx::dispatch", + "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 routes.ncl. Normal vital.", + "Present and correct. Negative panel.", + "Yes… the match 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 match that duplicates by hand what routes.ncl 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 Page resolves from the registry. The twin, and the next one, are no longer treated by hand.", + + verdict_lead = "The pathogen had family: any hardcoded match shadowing the registry. The difference wasn't skill, it was prior clinical history:", + 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"], +} diff --git a/site/site/content/expedientes/es/casos/deriva-hardcoded-anti-pap.md b/site/site/content/expedientes/es/casos/deriva-hardcoded-anti-pap.md index 1ef91ff..787e75a 100644 --- a/site/site/content/expedientes/es/casos/deriva-hardcoded-anti-pap.md +++ b/site/site/content/expedientes/es/casos/deriva-hardcoded-anti-pap.md @@ -16,17 +16,21 @@ sort_order: 1 ---
    + -Expediente 404-PAP — la deriva hardcoded +Expediente 404-PAP: la deriva hardcoded +

    🕵️ Mostrar expediente completo →

    Expediente · Depto. de Homicidios de Código

    -

    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.

    +

    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.

    Caso Nº 404-PAPClasificación: ANTI-PAPEstado: RESUELTO
    -
    PAP
    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.
    -
    anti-PAP
    Código escrito en contra de esas reglas. Aquí: un stub que re-lista a mano lo que routes.ncl ya declara.
    +
    PAP
    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.
    +
    anti-PAP
    Código escrito en contra de esas reglas. Aquí: un stub que re-lista a mano lo que routes.ncl ya declara.

    Protocolo para declarar, versionar y verificar esto → ontoref.dev

    @@ -72,20 +74,22 @@ Un caso no se juzga solo por lo que cuesta. Se juzga por lo que deja. De un punt

    Lo que costó el crimen

      -
    • Caza pura (framework) ~88 min
    • -
    • Builds de release 8
    • -
    • Fingerprints coexistiendo 6
    • -
    • Reinicios del server ~15+
    • -
    • Pistas falsas 8
    • -
    • El arreglo 8 líneas
    • +
    • Caza pura del origen (solo framework) ~88 min
    • +
    • Builds de release lanzados 8
    • +
    • Reinicios del servidor ~15+
    • +
    • Fingerprints de build coexistiendo 6
    • +
    • Ficheros de código ajeno leídos (2 repos) ~20+
    • +
    • cargo clean completos (867 MB al garete) 2
    • +
    • Pistas falsas antes de la buena 8
    • +
    • Tamaño del arreglo final 8 líneas

    Lo que el caso dejó

      -
    • Fix registry-driven PAP‑compliant
    • +
    • Fix registry-driven PAP-compliant
    • Conocimiento content-kind-howto
    • -
    • Mecanismo modo generate‑expediente
    • +
    • Mecanismo modo generate-expediente
    • Evidencia proof (positioning)
    • Serie + hub /expedientes
    • Framework desatascado
    • @@ -97,11 +101,22 @@ Esa es la profundidad creíble: la tarde de dolor no terminó en un parche, **te

      El punto de abandono — lo que no sale en la tabla

      -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. +

      Los sospechosos — las pistas falsas

      -

      El arma — un match hardcoded (anti-PAP)

      + + + + + + + + + +
      El contenido en site/r“Yo no estaba sincronizado.”cómplice menor
      filename ≠ id“El body salía vacío, pero no fui yo el del 404.”otro delito
      El comentario {# … #}“Solo me colé como texto. Inocente.”coartada firme
      Staleness & fingerprints“Con 6 fingerprints, ¿cómo no ibas a dudar del binario?”pista falsa
      SITE_PUBLIC_PATH / symlink“El dir no existía. Symlink para nada.”pista falsa
      generated.rsvec!["content"]“Soy el fallback. Ni me ejecuto.”señuelo
      El RBAC“Un deny mío es un 302, encanto. Un 404 no es cosa mía.”coartada 302
      build_page_generator“Eso es de Leptos. En htmx-ssr ni aparezco.”jurisdicción errónea
      + +

      El arma — El arma · un match hardcoded (preexistente, anti-PAP)

      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
       };
      -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 en contra 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. -

      El giro — 8 líneas que leen del registry

      +

      El giro — PAP-compliant · añadir un kind vuelve a ser solo NCL

      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()
       });
      -/recursos, /proyectos, /dominios — 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.

      El veredicto

      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: - - + + - +
      Stub hardcoded que sombrea el registryPAP + anti-patterns → grep-able
      Conocimiento perdido, redescubierto por dolorqa/howto → content-kind-howto
      Stub hardcoded que sombrea el registryPAP + anti-patterns (grep-able)
      Conocimiento perdido, redescubierto por dolorqa/howto (content-kind-howto)
      Construir sobre lo no-válido sin saberloreview contra invariantes
      Asumir lo no-asumible, creer lo no-creíbleestado declarado → 3 niveles
      Asumir lo no-asumible, creer lo no-creíbleestado declarado (3 niveles)
      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.** -
    - - -
    +
    +
    +

    Glosario

    + +
    + +
    + +
    + + + + + +
    diff --git a/site/site/content/expedientes/es/casos/espejo-que-revertia-su-trabajo.ncl b/site/site/content/expedientes/es/casos/espejo-que-revertia-su-trabajo.ncl new file mode 100644 index 0000000..95fb70b --- /dev/null +++ b/site/site/content/expedientes/es/casos/espejo-que-revertia-su-trabajo.ncl @@ -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 = "Project's Architecture Principles — 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 en contra de esas reglas. Aquí: un mirror que decidía por dirección en vez de por propiedad." }, + { 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 = "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ó." }, + { 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 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." }, + ], + + 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 (just templates)", emphasis = true }, + { label = "Tamaño del arreglo final", value = "60 líneas", 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 site/r" }, + { name = "gen-adr-pages roto" }, + { name = "content_processor 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%" +# 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. +"%, + }, + + 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 +# Í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. +"%, + }, + + 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 → ADR-070: todo árbol declara su DUEÑO" }, + { 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 → ADR-066: el check decide" }, + { 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 → ADR-070: la REPRODUCCIÓN, en la cadena" }, + { 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 → ADR-070: todo árbol declara su TESTIGO" }, + { 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 → contract-in-the-instance-tree" }, + { 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 → migración 0044: rutas ancladas" }, + ], + + # 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 no — 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 apuntado aquí. Ontoref 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 content", + "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 head -20 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 rsync 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: 69 posts. 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 content", + "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 head -20.", + + 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 head -20 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 rsync 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"], +} diff --git a/site/site/content/expedientes/es/casos/recaida-hardcoded-bis.md b/site/site/content/expedientes/es/casos/recaida-hardcoded-bis.md index 43d0324..f7a0ab7 100644 --- a/site/site/content/expedientes/es/casos/recaida-hardcoded-bis.md +++ b/site/site/content/expedientes/es/casos/recaida-hardcoded-bis.md @@ -16,68 +16,64 @@ sort_order: 2 ---
    + -Expediente 404-PAP-bis — la recaída +Expediente 404-PAP-bis: la recaída +

    🩺 Mostrar historia clínica →

    Historia clínica · Servicio de Patología del Código

    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.

    -
    Historia Nº 404-PAP-bisDiagnóstico: ANTI-PAP (recaída)Estado: ALTA
    +
    Historia Nº 404-PAP-bisDiagnóstico: ANTI-PAP · recaídaEstado: ALTA
    Motivo de consulta. 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: "después de esto voy a necesitar una visita a la clínica". Y aquí está.
    -

    Etiología — el mismo patógeno, otro órgano

    +
    +
    +
    PAP
    Project's Architecture Principles — las reglas y patrones que sostienen la arquitectura del proyecto: la fuente única de la verdad que todo el código debe respetar.
    +
    anti-PAP
    Código escrito en contra de esas reglas: un stub que re-lista a mano lo que routes.ncl ya declara, duplicando y contradiciendo la fuente de la verdad.
    +
    +

    Protocolo para declarar, versionar y verificar esto → ontoref.dev

    +
    -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`: +

    El doble balance — lo que costó, y lo que dejó

    -
    match path {
    -    "/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
    -    // … cada página a mano … menos /nosotros …
    -    _ => None,   // ← /nosotros caía aquí → 404
    -}
    - -Ruta declarada, FTL presente, componente horneado — y aun así 404. El mismo anti‑PAP: un match que duplica lo que `routes.ncl` ya sabe. - -

    Tratamiento — el mismo antibiótico

    - -
    _ => resolve_static_page(env, path, lang),
    -// … lee del registry: cualquier ruta cuyo componente
    -// acabe en "Page" → static_page(page_id kebab). Cero arms nuevos.
    - -

    Pronóstico — y por qué esta recaída no fue como la primera

    - -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. - -
    -
    +
    +

    Caso 404-PAP · sin historia

    • Diagnóstico ~88 min
    • @@ -85,26 +81,59 @@ Lo revelador no es que recayera. Es **cuánto duró**. El primer caso, sin histo
    • Pistas falsas 8
    -
    +

    Caso bis · con historia clínica

    • Diagnóstico ~15 min
    • Builds 1
    • Pistas falsas 0
    • +
    • Factor de aceleración vs. el primer caso ~30×
    • +
    • Tamaño del arreglo final 1 línea
    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". -

    Alta y profilaxis

    +

    Diagnóstico diferencial — lo que se descartó

    + + + + + + +
    El binario / la caché“¿Otra vez yo? Los marcadores ya no bastan.”reincidente conocido
    La ruta /nosotros declarada“Consto en routes.ncl. Constante normal.”constante normal
    El FTL & el componente horneado“Presente y correcto. Analítica negativa.”analítica negativa
    pages_htmx::dispatch“Sí… el match que elige la página estática era el foco.”el patógeno
    + +

    Etiología — la causa — Etiología gemela · un match hardcoded en las páginas estáticas

    + +
    match path {
    +    "/services" | "/servicios" => Some(static_page::render(env, "services", lang)),
    +    // … cada página a mano … menos /nosotros …
    +    _ => None,   // ← /nosotros caía aquí → 404
    +}
    + +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. + +

    Tratamiento — PAP-compliant · una línea, sin ramas nuevas

    + +
    _ => resolve_static_page(env, path, lang),
    +// lee del registry: cualquier ruta cuyo componente
    +// acabe en "Page" → static_page(page_id kebab). Cero arms nuevos.
    + +

    Pronóstico

    + +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. + + + + + +
    El primer caso, ya declarado como historiacontent-kind-howto → consulta directa
    Reconocer el patógeno sin re-interrogarmismo antibiótico → registry
    Cerrar la recaída también registradastatic-page-howto → el 3º durará segundos
    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. -
    - - -