ADR — Lint Bibliotecario en 2 capas: bulk sin LLM + contradictions con Haiku
Contexto
El sistema Supercontexto produce y mantiene un corpus de páginas wiki (concept, feature, entity, decision, runbook, incident). En estado operativo tiene del orden de 90-200 pages activas en D1, con bib_wiki_pages como tabla canónica y los .md en src/content/wiki/ como fuente reproducible.
El corpus se degrada con el tiempo de tres maneras independientes:
- Drift estructural: pages con
last_verifiedvencido, pages huérfanas (sin backlinks víarelated[]ni víadocumentsedges), pages consourcesque apuntan a archivos de código que ya no existen en el repo. - Drift semántico: dos pages activas afirman cosas incompatibles sobre el mismo tema (un ADR dice “siempre async”, una feature dice “siempre sync”; un concept reporta una cifra distinta a la del entity correspondiente).
- Drift de cobertura: pages que existen en
.mdpero no llegaron a D1, o entradas de D1 sin.mdcorrespondiente.
Cada tipo de drift requiere un detector con coste, latencia y frecuencia muy distintos. Los dos primeros caen en el alcance de Lint y son el objeto de esta decisión. El tercero pertenece al ADR de reconcile D1↔repo.
Problema
Una sola implementación de Lint que cubra los tres detectores estructurales (stale, orphans, broken_sources) y además el detector semántico (contradicciones) tiene perfiles de coste incompatibles. Los detectores estructurales son SQL puro sobre D1: ejecutables a diario sin coste de proveedor IA. La detección de contradicciones requiere comparar pages por pares dentro de la misma community o tipo y pedir a un LLM un veredicto, lo que escala como O(N²) sobre la community y consume tokens reales en cada par.
Si todo va por el mismo workflow con la misma cadencia:
- Si la cadencia es diaria y cubre contradicciones full pairwise: el coste Anthropic se dispara y el budget de subrequests de CF Workers (50 en plan Free, ~1000 en paid) revienta cuando el corpus crece.
- Si la cadencia es semanal y sólo entonces se detectan también stales y orphans: las pages que vencen en mitad de la semana quedan estancadas hasta 7 días, y los broken_sources tras refactores grandes pueden sobrevivir días sin alerta.
Además, durante la sesión 10 (commit 2319b6c) un primer intento monolítico contra 60+ pages reventó CF Workers con Too many subrequests by single Worker invocation (60 queries D1 orphan-check + M fetches GitHub) y D1_ERROR: LIKE or GLOB pattern too complex (búsqueda de backlinks con WHERE related LIKE '%"<slug>"%'). Esto exigió rediseñar la parte estructural antes de poder añadir la parte semántica.
Opciones consideradas
1. Lint single-tier sólo bulk (sin LLM) — un único wiki_lint_bulk diario que detecta drift estructural. Cubre stale, orphans y broken_sources. Descartada: deja sin cobertura las contradicciones semánticas, que el AUDIT inicial ya había marcado como deuda explícita (“No hay Lint semántico: detectamos stale pero no contradicciones”).
2. Lint single-tier todo por Haiku — un único workflow que para cada page evalúa todo, incluida la consistencia frente a sus peers, llamando a Haiku N×N veces. Descartada: coste lineal con el corpus al cuadrado, supera el budget de subrequests CF Workers en una invocación, y la mayor parte del trabajo no necesita LLM (un last_verified vencido se ve con una resta de fechas).
3. Lint adversarial / abogado del diablo — un agente LLM que ataca el corpus generando hipótesis de contradicción y validándolas contra pages reales. Descartada por complejidad: añade un componente generativo difícil de acotar en coste y de evaluar en precisión, y el corpus aún no tiene tamaño suficiente para justificar esa sofisticación.
4. Lint two-tier: bulk diario barato + contradictions semanal Haiku — separar los dos perfiles de coste en dos handlers MCP distintos (wiki_lint_bulk, wiki_lint_contradictions) ejecutados por dos crons distintos con cadencias distintas. Seleccionada.
Decisión
Lint dividido en dos capas con responsabilidades disjuntas, dos handlers MCP y dos workflows:
Capa 1 — wiki_lint_bulk (diario, sin LLM)
Handler en functions/api/mcp/handlers/wiki.ts (wikiLintBulk). 6 pasos en una sola invocación CF Workers, sin llamadas a proveedores IA:
- Pass 1: 1 query D1 que trae metadata de todas las pages active.
- Pass 2: reverse-index de backlinks construido en JS sobre el resultado (0 subrequests adicionales).
- Pass 3: code refs en bulk con
INchunked a 90 bind params (footgun D1: límite 100 bind params). - Pass 4: evaluación de staleness y orphans en memoria. Distingue
stalereal (last_verifiedexpirado, olast_verified IS NULLycreated_at> 2× stale_days) denever_verified_recent(page nueva sin oportunidad de verificarse aún) — este matiz evitó el falso positivo masivo “61/61 pages stale” detectado en sesión 10. - Pass 5 (opcional, off por defecto):
check_sourcescon fetch GitHub limitado pormax_source_fetchespara no exceder subrequests. - Pass 6: si
apply_stale=true, marcado bulk condb.batch().
Workflow bibliotecario-lint.yml, cron diario 30 4 * * *, dry_run por defecto.
Capa 2 — wiki_lint_contradictions (semanal full, diario incremental)
Handler wikiLintContradictions. Agrupa pages active por community (community_id si existe, si no fallback a type como pseudo-community). Para cada par dentro del grupo invoca Haiku con dos snippets de 2000 chars y le pide JSON estructurado {verdict, subject, claim_a, claim_b, reason}. Si verdict=contradict, inserta en bib_wiki_contradictions de forma idempotente.
Dos modos:
- Incremental: sólo evalúa pares donde al menos una page es reciente (
recent_days, default 7). Cron diario dentro del mismobibliotecario-lint.ymltras el bulk. - Full: evalúa todos los pares respetando
max_pairs_per_community. Cron semanalbibliotecario-lint-consolidation.yml(0 3 * * 0). Por el límite de subrequests CF Workers, el handler procesa unbatch_sizepor invocación y devuelvenext_offset; el workflow pagina con un loop bash hastanext_offset === null(resuelto en sesión 17, commitde47c6f).
Optimizaciones internas: pre-fetch de snippets en paralelo chunked (SNIPPET_CONCURRENCY=10) y cache por slug para no refetchar GitHub.
Toggle de pausa
Ambos workflows respetan la variable BIBLIOTECARIO_PAUSADO. El bulk no se pausa porque es SQL puro y no consume Haiku; el step de contradictions sí. El permite frenar consumo Anthropic durante planes intensivos sin desactivar el cron entero.
Consecuencias
- Coste predecible: bulk $0/día (sólo SQL D1). Contradictions incremental ~$0,05-0,30/día según pages recientes. Contradictions semanal full ~$0,50-2/run según tamaño del corpus y
max_pairs_per_community. Sin la separación, una versión monolítica diaria habría costado decenas de dólares al mes y reventado el budget CF Workers. - Latencia mixta aceptada como trade-off: el bulk detecta drift estructural en menos de 24 h (catch rápido para stales y broken_sources). Las contradicciones semánticas tienen latencia de hasta 7 días en el peor caso (contradicción introducida lunes, full scan domingo); el modo incremental diario mitiga este caso si al menos una de las dos pages enfrentadas es reciente.
- El bulk no detecta contradicciones: limitación explícita asumida. Si una page lleva años en el corpus y otra page lleva años contradiciéndola sin que ninguna se toque en
recent_days, sólo el full semanal las cazará. - Footgun CF Workers subrequest budget: resuelto en sesión 17 con paginación + chunking. Sigue siendo limitante para corpus muy grandes (>500 pages); habrá que subir
MAX_BATCHESo pasar a plan paid si el corpus crece más allá. - Inserción idempotente:
bib_wiki_contradictionsusa pair ordenado + subject como clave para no duplicar contradicciones detectadas en runs sucesivos. Permite ejecutar conapply=truerepetidamente sin contaminar la tabla. - Default conservador: ambos workflows arrancan con
dry_run=true. La promoción aapply=truees decisión manual tras 3-7 días de observación estable. - Discrepancia conocida: el handler cuenta backlinks sólo desde
related[]YAML en D1, no desde[[wikilinks]]del body. Obsidian lee al revés. Mantener alineados es responsabilidad operativa documentada en NEXT.md.
Status
Accepted. Implementado en sesión 10 (Fase 5 Supercontexto, 2026-04-22), refactor anti-N+1 y anti-LIKE en commit 2319b6c. Refinado en sesión 17 con paginación (de47c6f) y pre-fetch chunked (fc6f0b0). En dry_run por defecto; pendiente subir a apply=true cuando se acumulen 3-7 días de runs estables y el corpus tenga community_id poblado para minimizar falsos positivos del agrupamiento por type.
Véase también
- [[feature—supercontext—bibliotecario-lint-fase-5]] — feature page de la Fase 5 que implementa este ADR
- [[feature—biblioteca—wiki-lint-contradictions-pagination]] — refinamiento de paginación que resolvió “Too many subrequests”
- [[entity—biblioteca—tool—wiki-lint-contradictions]] — entity page del tool MCP
- [[concept—biblioteca—supercontexto]] — concepto raíz del sistema
- [[decision—20260424—supercontexto-operativo]] — ADR paralelo: cierre del plan y paso a modo operativo
- [[decision—20260422—ingest-3-tier]] — ADR paralelo: pipeline de ingest con triage Haiku, complementario al Lint
- [[decision—20260403—multi-tenancy-rls]] — ADR paralelo: contexto de seguridad sobre el que el Lint opera