La plataforma de la documentación de Adhoc: un motor de búsqueda, un
núcleo de MCP, un gate de acceso y un guard de fuga, consumidos
pineados por los repos de contenido (oba-docs, odumbo-docs, adhoc-docs).
Antes de esto, las cuatro piezas vivían forkeadas en los tres repos: el mismo
archivo con tres dialectos, y cada fix propagado a mano — o no propagado. La
medición está en docs/unificacion/: lib/mcp/indice.mjs tenía 41
diferencias entre las tres copias, y 17 eran fixes que un repo tenía y los
otros dos no. El caso más caro: el guard de fuga era byte-idéntico en dos
repos y no existía en el tercero.
- ADR 0006 de
knowledge-management— un repo por cuerpo de contenido, y la plataforma como paquete aparte: el contenido y el motor tienen ciclos de vida distintos y dueños distintos. - ADR 0007 de
knowledge-management— el gate y el guard de fuga son de la plataforma, no de cada sitio: una protección que cada repo reimplementa es una protección que algún repo no tiene. - Etapa A de la spec
arquitectura-plataforma-docs: este paquete, con los dos contratos versionados y el drift-check que hace visible el rezago del pin.
npm i --ignore-scripts github:ingadhoc/doc-platform#v0.1.0Pin exacto, siempre por tag. No ^, no main, no ramas: el pin es lo que
evita que un fix de la plataforma rompa tres sitios a la vez, y es lo que
permite un rollback de una línea. Un rango hace fallar el docs-drift-check
a propósito — un pin que no pinea no es un pin.
--ignore-scripts recomendado. Este paquete no tiene ningún script de
install y no lo va a tener; el flag es para todo el árbol, porque esto corre en
el buildCommand de sitios públicos. La misma razón por la que el paquete
tiene una sola dependencia (minisearch, que la necesita el motor de
búsqueda) y cero devDependencies: superficie mínima en el build.
Lo que el consumidor ya tiene y este paquete no declara: mcp-handler y
zod, que importa lib/mcp/mcp-handler.mjs. Son dependencias del repo, a
propósito: el repo decide con qué versión del framework MCP se deploya, y el
paquete no le impone una. Los tres repos las tienen hoy.
Después del npm i, el repo consumidor queda con tres líneas de pegamento:
// api/mcp.mjs
import { crearMcp } from '@ingadhoc/docs-platform/mcp-handler';
import { crearFeedback } from '@ingadhoc/docs-platform/feedback';
import * as indice from '@ingadhoc/docs-platform/indice';
import { config } from '../docs.mcp.config.mjs';
const { handler } = crearMcp({
config,
indice,
crearIssue: crearFeedback(config.feedback),
});
export default handler; // sin default export Vercel no encuentra el handler// middleware.js — en la RAÍZ del repo (Vercel lo exige ahí)
import { next } from '@vercel/functions';
// Por RUTA RELATIVA, no por especificador de paquete: el bundler del edge
// rechaza `@ingadhoc/docs-platform/gate` cuando el repo consumidor no es
// `"type": "module"` (Docusaurus lo impide) — "unsupported modules".
// Y ojo con renombrar a middleware.mjs: el deploy queda VERDE y SIN
// middleware (la ausencia silenciosa del gate). Hallazgo del piloto
// odumbo-docs, deployment 3mXWLwPHPgcwasEji49pGEg7Lyuv.
import { decidir } from './node_modules/@ingadhoc/docs-platform/lib/mcp/gate.mjs';
const AUDIENCIAS = ['publico', 'interno']; // adhoc-docs: ['interno']
export default function middleware(request) {
return decidir(request, process.env, { audiencias: AUDIENCIAS }) ?? next();
}El && no es cosmético: es lo que aborta el deploy cuando el guard sale con 1.
No lo cambies por ;.
| Import | Qué es |
|---|---|
@ingadhoc/docs-platform/indice |
motor de búsqueda: buscar(), leer(), mapa() sobre el índice que emite el build. Es el único que usa minisearch |
@ingadhoc/docs-platform/mcp-handler |
crearMcp({config, indice, crearIssue}): las tools, sus schemas por eje, el Bearer y el transporte |
@ingadhoc/docs-platform/gate |
decidir(request, env, {audiencias}) / crearGate(config): la decisión del middleware del edge |
@ingadhoc/docs-platform/auth |
comparación de tokens en tiempo constante (usa node:crypto: solo del lado de la función) |
@ingadhoc/docs-platform/tokens |
la gramática de DOCS_MCP_TOKENS, una sola vez, compartida entre el edge y la función |
@ingadhoc/docs-platform/feedback |
crearFeedback(config): la tool que abre el issue de docs-feedback |
@ingadhoc/docs-platform/config |
cargarConfig() / validarConfig(): el validador del docs.config.json |
@ingadhoc/docs-platform/guard-fuga |
correrGuard(), si querés llamarlo desde tu build en vez del bin |
@ingadhoc/docs-platform/middleware |
el middleware.js de referencia (el que va en la raíz del consumidor) |
@ingadhoc/docs-platform/docusaurus-plugin |
el plugin de Docusaurus: registra los componentes MDX y el CSS del theme (ver La capa de theme) |
@ingadhoc/docs-platform/video-url |
parsearUrlVideo() / idDeYoutube(): el parseo que decide si una URL rinde embed o botón |
bin docs-guard-fuga |
el guard de fuga, para el buildCommand |
bin docs-drift-check |
el drift-check, para el CI del consumidor |
Hasta acá el paquete era todo backend: motor de búsqueda, MCP, gate, guard. La
capa de theme es lo que le permite además poner componentes y estilos en el
sitio de los tres repos — el mismo argumento del ADR 0007 aplicado al front:
un componente que cada repo reimplementa es un componente que en algún repo
está roto (y hay precedente: los <video> del contenido migrado, que
escribían autoplay en minúscula y React descartaba en silencio).
Se activa con una línea en el docusaurus.config.js del consumidor:
// site/docusaurus.config.js
plugins: [
require.resolve('@ingadhoc/docs-platform/docusaurus-plugin'),
// … los plugins que el repo ya tenía
],require.resolve y no el string pelado: el config vive en site/ y el paquete
se instala en la raíz del repo. Y el plugin es .cjs a propósito — este paquete
es "type": "module" y el config de los tres repos es CommonJS.
Con eso el consumidor gana:
- Los componentes MDX en el scope global, sin
importen el markdown. Un import arriba de un.mdno es una opción para este contenido: el cuerpo entero de cada artículo viaja al índice para agentes (emitAgente()), y esa línea le llega al MCP como ruido. - El CSS del theme (
getClientModules), que entra después de infima y antes delcustom.cssde cada repo: la identidad de cada sitio sigue ganando.
<Video url="https://youtu.be/9YpzkZ8Q5Ns" title="Procesar transferencias en lote"/>
<Video url="https://drive.google.com/file/d/1AbC/view" title="Grabación de la demo"/>| atributo | ||
|---|---|---|
url |
requerido | YouTube (watch?v=, youtu.be/, /embed/, /live/, /shorts/) o cualquier otra URL. Sin URL utilizable el componente no pinta nada |
title |
opcional, recomendado | es el nombre accesible: aria-label del botón y title del iframe. Sin él, "Ver video" |
- YouTube: en el load se pinta la miniatura real (
img.youtube.com/vi/<id>/hqdefault.jpg) con un botón de play encima, en una caja 16:9,loading="lazy"y cero iframes. Un embed de YouTube trae ~1 MB de JS que se paga aunque nadie mire el video. Al click, el<iframe>reemplaza la miniatura conautoplay=1— legítimo porque hubo gesto del usuario, y el video arranca solo. - Cualquier otra URL (Drive, Loom, un
.mp4): un botón de infima (button button--primary) que abre la URL en otra pestaña. No se intenta un embed: Drive lo rompe cuando el archivo no es público y el modo de falla es una caja gris sin explicación.
El parseo de la URL es una función pura y exportada
(@ingadhoc/docs-platform/video-url), aparte del componente, para poder
testearlo con node --test sin meterle React ni un transpilador al paquete:
tests/video-url.test.mjs.
El CSS del theme sube el h2 a 2.25rem y el h3 a 1.7rem (infima da 2 y 1.5),
scopeado a .theme-doc-markdown.markdown. El h1 no se toca: infima ya le da
3rem al título del artículo — --ifm-h1-font-size: 2rem es la del h1 genérico,
no la del título, y "subirlo a 2.5rem" lo habría achicado. El h4 pasa de 1rem a
1.15rem porque a 1rem es exactamente el tamaño del cuerpo. Navbar y sidebar
quedan afuera del scope.
paises: en el frontmatter de un artículo es una faceta, no un eje: no
multiplica el build, no bifurca la URL y no oculta bloques (no existe
:::solo-pais). Lo único que agrega en el sitio son dos marcas:
- Arriba del h1, un badge "Solo Argentina" / "Solo Chile y Uruguay"
(
lib/docusaurus-theme/DocItem/Content.js, wrapper de@theme-init/DocItem/Content). Los nombres salen de un mapa AR/CL/UY: el manual lo lee un contador, no el build. Una página sinpaises:no lleva badge: la ausencia significa "todos los países" y un cartel en el 95% de las páginas es ruido. - En el árbol, el código ISO en chico al final de la fila, vía
::aftersobre las clasespais-AR/pais-CL/pais-UY. Las estampa el build del repo de contenido considebar_class_namecuando la página tiene un solo país; el paquete sólo pone el CSS. Con dos países no se estampa nada: la sigla no entra y el badge de arriba ya lo dice. Ojo con el selector: Docusaurus ponesidebar_class_nameen el<li>, no en el<a>, así que el::afterva sobre.menu__list-item.pais-XX > .menu__link.
Las páginas de localizaciones/<país>/ no llevan badge: ahí el país lo
deriva el build del path y no está en el frontmatter del fuente, así que el
componente no lo ve — que es lo correcto, la página ya vive bajo
Localizaciones › Chile. El país derivado sí viaja al índice del agente, donde
nadie ve la ruta.
El filtro del MCP lo enciende metadata.paises del docs.config.json (el
vocabulario del corpus, ISO alpha-2 en mayúsculas). Su semántica es dura y
asimétrica a propósito: el tag excluye, la ausencia nunca oculta.
- La carpeta se llama
lib/docusaurus-theme/. El webpack de Docusaurus no transpila nada denode_modulessalvo lo que matchee/docusaurus(?:(?!node_modules).)*\.jsx?$/(lib/webpack/base.js,excludeJS). Sin la palabradocusaurusen la ruta, el JSX llega crudo al bundler y el build revienta. - Los wrappers del theme (
MDXComponents.js,DocItem/Content.js) envuelven con@theme-init, no con@theme-original. Los dos alias existen;@theme-originallo reescribe cada theme que se registra, así que desde un plugin apunta a sí mismo. El síntoma no dice "ciclo": diceCannot access '__WEBPACK_DEFAULT_EXPORT__' before initializationyCannot read properties of undefined (reading 'jsx')en cada página.
Los dos llevan schemaVersion, y los dos lectores tiran si el emisor
declara una versión más nueva de la que saben leer — o si no la declara. Nada
de degradar en silencio: un índice equivocado que responde mal es peor que uno
que no responde.
- config ↔ plataforma:
docs.config.json, con schema publicado enschema/docs.config.schema.jsony validador enlib/config.mjs(propio, sin dependencias:ajvno entra al build de un sitio público). El diseño de cada campo, con la evidencia medida, está endocs/unificacion/diseno-eje.md; los tres configs actuales traducidos, enmapeo-configs.md. - índice ↔ motor: lo emite
tools/build.mjsde cada repo y lo leelib/mcp/indice.mjs. Está especificado endocs/unificacion/contrato-indice.md.
El corpus declara un eje como objeto: { tipo, default?, valores[] }.
eje.tipo |
corpus | param en las tools | leer() sin valor |
comodín (artículos fuera del eje) |
|---|---|---|---|---|
version |
oba-docs | version |
elige el default y lo dice (elegidoPor) |
sí (relacion/ aplica a todas) |
project |
adhoc-docs | project |
ambigüedad estructurada (no declara default) |
no |
none |
odumbo-docs | (no se expone) | — | — |
La regla de leer() es una y no tiene un if por tipo de eje: elige sólo
cuando el config declaró a quién elegir. Lo que cambia el comportamiento es la
presencia de eje.default, no el tipo — y hay un test que lo prueba poniéndole
un default a un corpus con eje project.
npm install && npm test # 227 casosbloques necesita un repo de contenido (corre su tools/build.mjs de verdad
sobre los fixtures de incidentes) y se skipea con motivo si no hay:
DOCS_REPO=~/repositorios/oba-docs node --test tests/bloques.test.mjsLa franja del handler HTTP de mcp.test.mjs (16 casos) también se skipea con
motivo si el checkout no tiene mcp-handler/zod, que son dependencias del
consumidor y no de este paquete. Con las dos instaladas, mcp da 57.
Un caso sin su capability se skipea explícitamente; no se corre degradado.
Lo que este ensamble no resuelve solo. Los tres primeros son de
diseno-eje.md §7 y comprometen el contrato; los demás salieron de los cuatro
análisis y siguen vivos después de unificar.
schemaVersion: 1 admite un eje por config, y hoy alcanza para los tres
repos. El día que un corpus necesite project × version a la vez, el schema no
lo expresa y la salida es un schemaVersion: 2 con ejes: [...] (plural).
Recomendación del diseño: aceptar el techo explícitamente y que la necesidad
real lo reabra con evidencia (mismo criterio que la alarma de bumps de la
Etapa B). Es tu llamada porque compromete el major.
Hoy sólo adhoc-docs tiene types, y sus 6 valores se parecen mucho a un
estándar de knowledge-management (concepto, referencia, procedimiento,
troubleshooting, guia, indice). Si el vocabulario es de Adhoc, no va en el
config de cada repo: va en el paquete, y el config sólo dice si lo exige. Es una
decisión de gobernanza de contenido, no de schema; mientras no se lauda, el
schema lo deja como lista por corpus (compatible con las dos salidas).
El schema obliga a declarar deploy.guardDeFuga, así que la omisión
silenciosa ya no es posible. Quedan las dos salidas, las dos defendibles:
{"activo": false, "motivo": "…"} (ese repo no tiene build público: su gate es
incondicional, y el guard protege contra la fuga al build público), o entra el
guard igual, como cinturón. El motivo que hoy está en mapeo-configs.md dice
literal "PENDIENTE DE FIRMA (jjs)".
Y hay una parte técnica que no se arregla copiando el archivo (DUDA 1 de
analisis-04-seguridad.md): adhoc-docs no tiene bloques :::interno, no
emite site/generated.json con audiencia y no tiene mapa deploy.proyectos.
Con el guard activo tal cual, su build falla de arranque por "no existe
site/generated.json". La estricta es que emita esas dos cosas.
docs.config.json → audiences y middleware.js → AUDIENCIAS tienen que
coincidir, y no hay forma de evitar la duplicación: el edge no lee del
filesystem. Es exactamente el tipo de drift silencioso donde empezó el fork.
Falta un caso del CI que las compare (el docs-drift-check de hoy mide el pin,
no esa coherencia).
DOCS_AUDIENCEen los tres environments de cada proyecto de Vercel (Production, Preview y Development) antes del merge que adopta el paquete. Con el fail-closed, un proyecto sin la variable devuelve 503. Es la dirección segura, pero no es gratis.--esperadaen losbuildCommandactuales: ahora el guard lo rechaza corriendo en Vercel. Si algún buildCommand lo pasa hoy, ese deploy empieza a fallar. No se pudo verificar desde los snapshots.- El GET del MCP devuelve 503 si el deployment no declara audiencia servible. Es un cambio observable para el consumidor: el preflight de Claude Code recibe 503 en vez del cartel cuando el deployment está mal configurado.
- El fail-closed del preprocesador emite y después falla. Con una directiva
mal escrita (
::: interno),build.mjsescribesite/docs/**con la línea interna adentro y después sale con 1. Hoy no fuga porque elbuildCommandencadena con&&: la protección está en el operador, no en el programa. Está comotododeclarado entests/bloques.test.mjs, y lo arregla la unificación debuild.mjs— que no entró en esta etapa. tests/bloques.test.mjsescribe en<repo>/site/porque en oba y odumbo la salida del build está hardcodeada. Después de correr la suite hay que regenerar connpm run gen.- Límites del enfoque léxico del guard: números y strings de menos de 5
caracteres nunca tienen sonda (una clave
4821, una sigla), las imágenes no se escanean, y una fuga dentro deapplyBlocksno genera sonda. Está en el header del guard; lo repito acá porque es la parte que se puede confundir con cobertura. serverInfo.versionsigue hardcodeada en'1.0.0'en el handler. Debería salir delpackage.jsondel paquete pineado, así un cliente MCP puede reportar contra qué versión de la plataforma habló. No se cambió: sería inventar comportamiento.- El comodín es propiedad del
tipode eje, no del corpus. Un corpus con ejeprojectno puede tener un documento transversal (eje: nullqueda invisible a cualquier filtro). Si algún día hace falta, la salida estricta es que el contrato del índice lo prohíba mientras el comodín esté apagado, para que la contradicción falle en el build y no en runtime. - La spec dice "vitest" como convención de tests de la Etapa A, y ninguno de
los tres repos usa vitest: la convención real —y la de este paquete— es
node:testnativo. Vale corregir esa línea antes de que alguien instale vitest para cumplirla.