CreaRack-SL

Front-matter durables: sincronización repo→D1 de metadata (PR#136)

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

CampoDurableMotivo
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)

  1. Entrada: patch con metaUpdates (p.ej. {status: 'active', tags: [...]}) y newBody: null (metadata-only).
  2. Detección: ¿algún campo en metaUpdates está en DURABLE_FM_FIELDS?
    • Sí + !deferCommit + env.GH_PAT disponible → continúa al paso 3.
    • No o defer activado → D1-only (como antes).
  3. Lectura .md actual: obtiene SHA + contenido via GitHub API.
  4. 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.
  5. Sección Véase también: si related cambió, ejecuta ensureSeeAlsoSection(body, newRelated) para actualizar los wikilinks.
  6. Commit directo: escribe el .md actualizado via ghPutFile().
  7. 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 --noEmit limpio (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]]