diff --git a/.harness/scripts/knowledge-okf-project.mjs b/.harness/scripts/knowledge-okf-project.mjs index cb7cd068e..b83c30abf 100644 --- a/.harness/scripts/knowledge-okf-project.mjs +++ b/.harness/scripts/knowledge-okf-project.mjs @@ -26,6 +26,13 @@ * - Reservados: index.md (listado, sin frontmatter), log.md (historial, fechas ISO 8601). * - Todo otro .md es un "concepto": frontmatter YAML parseable con `type` NO vacío (único obligatorio). * - Recomendados: title, description, resource, tags, timestamp. Claves extra permitidas. + * + * Extensión hacia OKF v0.2 (revisada en el ADR-0105, ver okf-spec.lock.json): + * v0.2 supersede `timestamp` por `generated: { by, at }` (§5.2). Emitimos AMBOS: el + * bundle sigue siendo v0.1-conforme (v0.1 tolera claves extra) y un consumidor v0.2 + * obtiene la procedencia real en vez de caer al `timestamp` legado, que no dice quién + * autoró. `generated.by` usa el prefijo `human:` a propósito — ver GENERATED_BY; + * `generated.at` se omite a propósito y está razonado junto a esa constante. * - Cross-links: absolutos desde la raíz del bundle (empiezan con '/') por estabilidad. */ import fs from 'node:fs'; @@ -45,6 +52,37 @@ const DEFAULT_OUT = path.join(KDIR, 'okf'); // Tipo OKF por bucket autoral (spec: "short string identifying the kind of concept"). const AUTHORED_TYPE = { domain: 'Domain Model', glossary: 'Glossary', prompts: 'Prompt' }; +/** + * Actor OKF (§7) que firma `generated.by` en todo concepto proyectado. + * + * Por qué `human:` y no `process:`: el corpus se AUTORA a mano en canonical/*.yaml y + * este proyector solo lo transcribe — no lo escribe. §7 avisa de que los consumidores + * que clasifican confianza (§5.3) se apoyan en el prefijo `human:`, y obliga a los + * productores a usarlo para contenido autorado o confirmado por humanos. Firmar con el + * script degradaría nuestro corpus a "generado por máquina" ante cualquier consumidor + * v0.2, que es justo lo contrario de lo que es. + */ +const GENERATED_BY = 'human:@winston'; + +/** + * `at` se OMITE a propósito, y conviene que siga omitido. + * + * §5.2 define `generated.at` como "the content's last meaningful change", y solo marca + * `by` como REQUIRED dentro de `generated` — así que omitirlo es conforme. Las dos + * fuentes posibles no sirven: + * + * - `asOf` es la fecha de PROYECCIÓN. Emitirla haría que cada re-proyección afirme + * que todo el corpus acaba de cambiar, que es exactamente lo contrario del uso que + * §5.2 le da al campo ("tell a recent edit from a stale fact"). + * - La fecha del commit del fichero canónico sería la correcta, pero es inalcanzable: + * el bundle se commitea EN EL MISMO commit que el cambio canónico y lo gatea + * `--verify`, de modo que `at` necesitaría la fecha de un commit que todavía no + * existe al generar. Cualquier edición canónica dejaría el gate en rojo. + * + * Un dato ausente es conforme; uno inventado engaña al consumidor. Si algún día el + * corpus canónico lleva su propia fecha de último cambio autoral, esa es la fuente. + */ + /** * Sello de procedencia estampado en TODO documento que escribe este proyector. * @@ -135,6 +173,19 @@ export function okfConformance(files) { if (!data) violations.push({ path: f.path, error: 'sin bloque de frontmatter' }); else if (!data.type || String(data.type).trim() === '') violations.push({ path: f.path, error: 'campo `type` ausente o vacío' }); + // `generated` es opcional, pero si está, `generated.by` es REQUERIDO (§5.2) y debe + // ser un actor (§7). Sin esta comprobación el campo podría emitirse a medias y un + // consumidor v0.2 lo clasificaría mal en silencio, que es peor que no emitirlo. + else if (data.generated !== undefined) { + const by = data.generated?.by; + if (!by || String(by).trim() === '') + violations.push({ path: f.path, error: '`generated` presente sin `generated.by` (OKF §5.2)' }); + else if (!/^(human:|process:)|\//.test(String(by))) + violations.push({ + path: f.path, + error: `\`generated.by\` no sigue la convención de actor (OKF §7): ${by}`, + }); + } } return violations; } @@ -206,6 +257,7 @@ export function buildBundle({ index, loadYaml, readText, asOf }) { resource: resourceOf(index.spec.product), tags: ['product', p.role].filter(Boolean), timestamp: asOf, + generated: { by: GENERATED_BY }, owner: product.metadata?.owner, reviewBy: product.metadata?.reviewBy, }, @@ -245,6 +297,7 @@ export function buildBundle({ index, loadYaml, readText, asOf }) { resource: resourceOf(srcRel), tags: [bucket, ps.boundedContext].filter(Boolean), timestamp: asOf, + generated: { by: GENERATED_BY }, owner: src.data?.owner, reviewBy: src.data?.reviewBy, partOf: `/packs/${packSlug}.md`, @@ -314,6 +367,7 @@ export function buildBundle({ index, loadYaml, readText, asOf }) { resource: resourceOf(entry.manifest), tags: [entry.layer, ps.boundedContext, pack.metadata?.status].filter(Boolean), timestamp: asOf, + generated: { by: GENERATED_BY }, owner: pack.metadata?.owner, reviewBy: pack.metadata?.reviewBy, version: pack.metadata?.version, @@ -344,7 +398,14 @@ export function buildBundle({ index, loadYaml, readText, asOf }) { return { path: `refs/${rSlug}.md`, content: renderConcept( - { type: n.type, title: n.title, resource: n.resource, tags: ['reference'], timestamp: asOf }, + { + type: n.type, + title: n.title, + resource: n.resource, + tags: ['reference'], + timestamp: asOf, + generated: { by: GENERATED_BY }, + }, body, ), }; diff --git a/.harness/scripts/knowledge-okf-project.test.mjs b/.harness/scripts/knowledge-okf-project.test.mjs index a178aad46..d45b20fab 100644 --- a/.harness/scripts/knowledge-okf-project.test.mjs +++ b/.harness/scripts/knowledge-okf-project.test.mjs @@ -112,6 +112,42 @@ test('todo concepto no reservado tiene `type` no vacío', () => { } }); +test('todo concepto firma `generated.by` con el actor humano (OKF §5.2/§7)', () => { + for (const f of build()) { + const base = f.path.split('/').pop(); + if (base === 'index.md' || base === 'log.md') continue; + const { data } = parseFrontmatter(f.content); + assert.equal(data.generated?.by, 'human:@winston', `${f.path} sin generated.by`); + // El prefijo `human:` es el que hace que un consumidor v0.2 clasifique el corpus + // como autorado por humano (§5.3); perderlo lo degrada a "generado por máquina". + assert.ok(String(data.generated.by).startsWith('human:'), `${f.path} no usa prefijo human:`); + } +}); + +test('`generated.at` se omite: no hay fuente veraz para "last meaningful change"', () => { + for (const f of build()) { + const base = f.path.split('/').pop(); + if (base === 'index.md' || base === 'log.md') continue; + const { data } = parseFrontmatter(f.content); + // Emitir `asOf` aqui afirmaria que el contenido cambio en cada re-proyeccion, que es + // lo contrario del uso que §5.2 le da al campo. Ausente es conforme; inventado, no. + assert.equal(data.generated.at, undefined, `${f.path} emite un generated.at inventado`); + } +}); + +test('okfConformance rechaza `generated` sin `by` y con actor no convencional', () => { + const withoutBy = [{ path: 'x.md', content: '---\ntype: Concept\ngenerated:\n at: 2026-01-01T00:00:00Z\n---\n\n# X\n' }]; + assert.equal(okfConformance(withoutBy).length, 1); + assert.match(okfConformance(withoutBy)[0].error, /generated\.by/); + + const badActor = [{ path: 'y.md', content: '---\ntype: Concept\ngenerated:\n by: winston\n---\n\n# Y\n' }]; + assert.equal(okfConformance(badActor).length, 1); + assert.match(okfConformance(badActor)[0].error, /convención de actor/); + + const ok = [{ path: 'z.md', content: '---\ntype: Concept\ngenerated:\n by: human:@winston\n---\n\n# Z\n' }]; + assert.deepEqual(okfConformance(ok), []); +}); + test('el concepto rehidrata el cuerpo desde la fuente y preserva provenance', () => { const concept = build().find((f) => f.path === 'concepts/glossary-knowledge.md'); const { data, body } = parseFrontmatter(concept.content); diff --git a/reference/knowledge/okf/concepts/glossary-knowledge.md b/reference/knowledge/okf/concepts/glossary-knowledge.md index 8b7e9da8a..acbbba730 100644 --- a/reference/knowledge/okf/concepts/glossary-knowledge.md +++ b/reference/knowledge/okf/concepts/glossary-knowledge.md @@ -6,6 +6,8 @@ tags: - glossary - ctx.knowledge timestamp: '2026-07-28' +generated: + by: human:@winston owner: '@winston' reviewBy: '2026-10-06' partOf: /packs/knowledge-and-corpus.md diff --git a/reference/knowledge/okf/packs/knowledge-and-corpus.md b/reference/knowledge/okf/packs/knowledge-and-corpus.md index 18c61731b..97603d038 100644 --- a/reference/knowledge/okf/packs/knowledge-and-corpus.md +++ b/reference/knowledge/okf/packs/knowledge-and-corpus.md @@ -8,6 +8,8 @@ tags: - ctx.knowledge - draft timestamp: '2026-07-28' +generated: + by: human:@winston owner: '@winston' reviewBy: '2026-10-06' version: 0.2.0 diff --git a/reference/knowledge/okf/product.md b/reference/knowledge/okf/product.md index 918344bc6..af7da0e88 100644 --- a/reference/knowledge/okf/product.md +++ b/reference/knowledge/okf/product.md @@ -7,6 +7,8 @@ tags: - product - root-authority timestamp: '2026-07-28' +generated: + by: human:@winston owner: '@winston' reviewBy: '2027-01-06' --- diff --git a/reference/knowledge/okf/refs/adr-0069.md b/reference/knowledge/okf/refs/adr-0069.md index a60ae3538..902397d3e 100644 --- a/reference/knowledge/okf/refs/adr-0069.md +++ b/reference/knowledge/okf/refs/adr-0069.md @@ -5,6 +5,8 @@ resource: evolith://adr/ADR-0069 tags: - reference timestamp: '2026-07-28' +generated: + by: human:@winston ---