Resumen ejecutivo
Problema: Cuando wiki_update_page cambiaba metadata (status, tags, related, supersedes, last_verified), los cambios se guardaban solo en D1, no en el .md del repo. El reconcile nocturno repo→D1 leía el .md rancio y revertía silenciosamente esos cambios. Impacto: revirtió 3 promociones en la misma sesión (bug s195).
Solución (opción B, elegida por Edu): Campos durable (status, tags, related, supersedes, last_verified) ahora se sincronizan al front-matter del .md de forma quirúrgica — reemplazo línea por línea sin re-serializar el cuerpo, minimizando diffs y riesgo.
Seguridad: La automatización nocturna (lint/curator/drift-check) usa SQL directo, no wiki_update_page, así que el volumen de commits de esta escritura es bajo (solo promociones/ediciones deliberadas de metadata).
Arquitectura de la solución
Campos durables vs. D1-only
| Campo | Durable | Motivo |
|---|---|---|
status, tags, related, supersedes, last_verified | ✓ | Cambios deliberados del usuario que deben persistir en repo |
last_updated_by | ✓ | Se sincroniza junto a los durables para audit trail |
Cambios en content (body) | — | Siempre se escriben en .md (obviamente) |
utility_score (solo drift-check) | ✗ | Recalcul nocturno → D1-only para no spamear commits |
Flujo en wiki_update_page (handler MCP)
- Entrada: patch con metaUpdates (p.ej.
{status: 'active', tags: [...]}) ynewBody: null(metadata-only). - Detección: ¿algún campo en metaUpdates está en
DURABLE_FM_FIELDS?- Sí +
!deferCommit+env.GH_PATdisponible → continúa al paso 3. - No o defer activado → D1-only (como antes).
- Sí +
- Lectura .md actual: obtiene SHA + contenido via GitHub API.
- Reemplazo quirúrgico: función
applyFrontMatterLines(fmBlock, lines):- Para cada campo durable tocado, busca la línea existente con regex (
^field:.*$). - Si existe → reemplaza solo esa línea.
- Si no existe → inserta antes del cierre
---(nuevo campo). - NO re-serializa otros campos ni cambia el orden.
- Para cada campo durable tocado, busca la línea existente con regex (
- Sección Véase también: si
relatedcambió, ejecutaensureSeeAlsoSection(body, newRelated)para actualizar los wikilinks. - Commit directo: escribe el
.mdactualizado viaghPutFile(). - D1 update: después, actualiza D1 como siempre (con el SHA del commit).
Funciones nuevas
applyFrontMatterLines(fmBlock: string, lines: Record<string, string>): string
- Entrada: bloque front-matter actual + dict de
{field: value_serializado}. - Operación: reemplazo línea por línea con regex.
- Salida: front-matter actualizado, mismo orden, mínimo diff.
- Nota: valores ya serializados (JSON para arrays, string plano para escalares).
DURABLE_FM_FIELDS (constante)
- Array de campos que disparan sincronización al .md.
- Hoy:
['status', 'tags', 'related', 'supersedes', 'last_verified']. - Abierta a extensiones futuras (p.ej.
owner, cuando sea caso de uso frecuente).
Impacto en el ciclo de vida de metadata
Antes (bug)
User edits page metadata (UI de D1) → D1 updated
↓
[Nightly reconcile] reads .md (stale) → Overwrites D1 with stale values
↓
User's edit lost silently
Después (fix)
User edits page metadata (UI de D1) → D1 updated → .md actualizado (línea por línea)
↓
[Nightly reconcile] reads updated .md → Confirms D1 state
↓
User's edit persists durably
Decisiones de diseño
¿Por qué no full rewrite del .md?
- Riesgo alto: re-serializar el YAML podría cambiar orden, formateo, comentarios.
- Diff ruidoso: cada edición genera diff grande → pollutes commit history.
- Difícil rollback: si algo falla, la culpa es menos clara.
Solución: reemplazo quirúrgico línea por línea (regex) → mínimo diff, reversible fácilmente.
¿Por qué last_verified es durable ahora?
En la v1 era D1-only. Pero si un user manualmente last_verified: 2026-07-03 (para refrescar una entity), ese cambio debe persistir. Así que pasa a durable.
Nota: El refresh nocturno de last_verified suelto (sin otros campos) sigue siendo D1-only para no crear commits por cada check automático.
¿Por qué no owner?
Potencial use case (reasignar propietario de page). Pero hoy no hay UI ni patrón frecuente. Si necesario: agregar a DURABLE_FM_FIELDS + actualizar la regla de detección.
Código de referencia
Archivo modificado: functions/api/mcp/handlers/wiki.ts
Snippet principal
// Campos de metadata "durables" cuyo cambio SÍ debe reflejarse en el front-matter
const DURABLE_FM_FIELDS = ['status', 'tags', 'related', 'supersedes', 'last_verified'];
// Reemplazo QUIRÚRGICO de líneas concretas del bloque front-matter
function applyFrontMatterLines(fmBlock: string, lines: Record<string, string>): string {
let out = fmBlock;
for (const [field, rhs] of Object.entries(lines)) {
const newLine = `${field}: ${rhs}`;
const re = new RegExp(`^${field}:.*$`, 'm');
out = re.test(out) ? out.replace(re, newLine) : out.replace(/\n---\n$/, `\n${newLine}\n---\n`);
}
return out;
}
// En wikiUpdatePage(), tras verificar metaUpdates:
if (newBody === null && !deferCommit && env.GH_PAT) {
const durableTouched = Object.keys(metaUpdates).some((k) => DURABLE_FM_FIELDS.includes(k));
if (durableTouched) {
const fc = await getFileShaAndContent(page.file_path, env.GH_PAT);
const fmBlock = fc ? extractFrontMatter(fc.content) : '';
if (fc && fmBlock) {
const fmLines: Record<string, string> = { last_updated_by: actor };
for (const f of DURABLE_FM_FIELDS) {
if (f in metaUpdates) fmLines[f] = metaUpdates[f];
}
const newFm = applyFrontMatterLines(fmBlock, fmLines);
let body = fc.content.slice(fmBlock.length);
if ('related' in metaUpdates) {
try {
body = ensureSeeAlsoSection(body, JSON.parse(metaUpdates.related) as string[]);
} catch {
/* related malformado → no tocar el body */
}
}
const res = await ghPutFile(page.file_path, `${newFm}${body}`, commitMessage, env.GH_PAT, fc.sha);
if (res.ok) {
commitSha = res.commit_sha;
fsStatus = 'committed';
} else {
fsStatus = `fs_error:${res.error}`;
}
}
}
}
Verificación
- ✅
tsc --noEmitlimpio (PR#136). - ✅ Handles metadata-only patches sin tocar el body (salvo `
Véase también
- [[feature—supercontext—reconcile-d1-repo]]
- [[concept—workspace—supercontexto-02-bibliotecario]]
- [[concept—workspace—supercontexto-03-wiki]]
- [[decision—20260424—supercontexto-operativo]]
- [[feature—supercontext—i5-plataforma-wiki-mcp-fix]]