diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3dea2f422..1cc1c39f1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -147,7 +147,7 @@ jobs: - name: Self-test for the inventory generator run: node --test .harness/scripts/ci/07-generate-inventories.test.mjs - # 21 cases over `09-reconcile-maturity.mjs`, including the GT-576 rule that a + # 24 cases over `09-reconcile-maturity.mjs`, including the GT-576 rule that a # capability may not be marked Validated on an ADR citation alone and the GT-596 # ISO/IEC 33020 threshold rule. Both were closed with "ships with a negative # self-test" as the evidence; the self-test ran nowhere. diff --git a/.github/workflows/maturity-evidence-freshness.yml b/.github/workflows/maturity-evidence-freshness.yml new file mode 100644 index 000000000..1aba5984b --- /dev/null +++ b/.github/workflows/maturity-evidence-freshness.yml @@ -0,0 +1,124 @@ +name: Maturity Evidence Freshness (GT-711) + +# The four runtime checks in `maturity-evidence.json` are valid for 30 days; after that +# `09-reconcile-maturity.mjs --check` rejects them and `Validate documentation` — a REQUIRED +# check — goes red on every open PR, whatever the PR touched. That is by design: evidence +# that ages out is re-taken, not extended. What was not by design is HOW the expiry got +# noticed: twice (2026-08-18, 2026-09-19) by a PR author whose change had nothing to do with +# it, on the day it started blocking merges. The expiry date is known thirty days ahead, so +# this workflow reads it daily and opens ONE issue a week before, with the date and the +# procedure. Nothing here moves the window or touches the evidence. + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +on: + schedule: + # 06:45 UTC daily — after opa-parity (06:00) and the published canary (06:30), so a + # red morning still has one cause per thread. + - cron: '45 6 * * *' + workflow_dispatch: + inputs: + now: + description: 'Ask what the report will say on this day (YYYY-MM-DD); empty = today' + required: false + default: '' + +permissions: + contents: read + issues: write + +jobs: + freshness: + name: Report when the runtime evidence turns stale + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # The evidence is re-observed on `develop` and reaches `main` by promotion, so the + # branch to read is the one where the fix is authored. A shallow checkout is enough: + # `--freshness` reads one JSON file and never calls git. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: develop + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + + - name: Age the four checks against the 30-day window + id: freshness + run: | + node .harness/scripts/ci/09-reconcile-maturity.mjs --freshness \ + ${{ inputs.now && format('--now={0}', inputs.now) || '' }} \ + 2>&1 | tee freshness.log + exit "${PIPESTATUS[0]}" + + # A warning nobody is subscribed to is the failure mode this workflow exists to end + # (the Actions tab was already red for the canary and nobody looked — GT-635). The + # issue is UPDATED rather than duplicated, so seven days of warning is one thread. + - name: Open or update the freshness issue + if: failure() && github.event_name == 'schedule' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs'); + const log = fs.existsSync('freshness.log') ? fs.readFileSync('freshness.log', 'utf8') : '(no log captured)'; + const today = new Date().toISOString().slice(0, 10); + const title = 'Runtime maturity evidence turns stale soon — re-observe it before Validate documentation goes red'; + const body = [ + `Checked on ${today} against \`develop\`.`, + '', + 'From the day named below, `09-reconcile-maturity.mjs --check` rejects the evidence and', + '**`Validate documentation` (required) is red on every PR**, whatever the PR touched.', + '', + '```', + log.slice(-4000), + '```', + '', + '**Procedure** (the one in `3e5aac80` and `2ee3f9a0`): for each of the four checks take a', + 'fresh green run of its workflow, rewrite `observedAt` / `commit` / `source` / `summary` in', + '`reference/core/control-center/maturity-reports/maturity-evidence.json` as a NEW observation', + '(what the run shows, never a date bump), run `node .harness/scripts/ci/09-reconcile-maturity.mjs`', + 'to regenerate the reconciliation, and open a PR to `develop`; promote to `main` afterwards.', + '', + `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + ].join('\n'); + + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, repo: context.repo.repo, + state: 'open', labels: 'maturity-evidence', + }); + if (existing.data.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: existing.data[0].number, body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, repo: context.repo.repo, + title, body, labels: ['maturity-evidence'], + }); + } + + # Once re-observed, the thread says so and closes itself; an issue nobody closes is an + # issue nobody believes. + - name: Close the freshness issue once the evidence is fresh again + if: success() && github.event_name == 'schedule' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, repo: context.repo.repo, + state: 'open', labels: 'maturity-evidence', + }); + for (const issue of existing.data) { + await github.rest.issues.createComment({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, + body: `Fresh again on ${new Date().toISOString().slice(0, 10)} — all four checks are inside the window with more than seven days to spare.`, + }); + await github.rest.issues.update({ + owner: context.repo.owner, repo: context.repo.repo, + issue_number: issue.number, state: 'closed', + }); + } diff --git a/.harness/scripts/ci/09-reconcile-maturity.mjs b/.harness/scripts/ci/09-reconcile-maturity.mjs index 04d22d7ac..1ee928dd3 100644 --- a/.harness/scripts/ci/09-reconcile-maturity.mjs +++ b/.harness/scripts/ci/09-reconcile-maturity.mjs @@ -626,6 +626,82 @@ const OUTPUT = expected('maturityReports', 'maturity-reconciliation.json'); const EVIDENCE_STATUSES = new Set(['PASS', 'BLOCKED', 'RESOLVED']); const REQUIRED_CHECKS = new Set(['cli-baseline', 'coverage', 'documentation', 'release']); +// GT-711: the 0..30 day window on runtime evidence is the point — evidence that ages out +// is re-taken, not extended — but the only thing that ever noticed the window closing was +// the REQUIRED `Validate documentation` check going red on whichever PR happened to be +// open that morning. Twice: a promotion on 2026-08-18 and a README-only PR (#724) on +// 2026-09-19, neither of which had touched the evidence. Expiry is a date known thirty +// days in advance, so it is announced as one: every check inside EVIDENCE_WARN_DAYS of +// turning stale is reported with the day it turns, on every invocation, and +// `--freshness` turns that report into an exit code a scheduled workflow can act on +// BEFORE the day it starts blocking merges. The window itself does not move. +export const EVIDENCE_MAX_AGE_DAYS = 30; +export const EVIDENCE_WARN_DAYS = 7; + +function isoDate(date) { + return date.toISOString().slice(0, 10); +} + +/** + * Age every check against the window and say, per check, when it turns stale. The day it + * turns stale is `observedAt + EVIDENCE_MAX_AGE_DAYS + 1`, the first day on which + * `validateRuntimeEvidence` rejects it — the same arithmetic, not a second opinion. + * `state` is `fresh`, `expiring` (stale within EVIDENCE_WARN_DAYS, today included), + * `stale` (already rejected) or `future` (observedAt after today, also rejected). + */ +export function assessEvidenceFreshness(evidence, now = new Date()) { + const checks = Array.isArray(evidence?.checks) ? evidence.checks : []; + return checks.map((check) => { + const observedAt = /^\d{4}-\d{2}-\d{2}$/.test(check?.observedAt || '') ? check.observedAt : null; + if (!observedAt) return { id: check?.id, observedAt: check?.observedAt, ageDays: null, staleFrom: null, daysLeft: null, state: 'invalid' }; + const observed = new Date(`${observedAt}T00:00:00Z`); + const ageDays = Math.floor((now - observed) / 86400000); + const staleFrom = isoDate(new Date(observed.getTime() + (EVIDENCE_MAX_AGE_DAYS + 1) * 86400000)); + const daysLeft = EVIDENCE_MAX_AGE_DAYS - ageDays; + let state = 'fresh'; + if (ageDays < 0) state = 'future'; + else if (ageDays > EVIDENCE_MAX_AGE_DAYS) state = 'stale'; + else if (daysLeft <= EVIDENCE_WARN_DAYS) state = 'expiring'; + return { id: check?.id, observedAt, ageDays, staleFrom, daysLeft, state }; + }); +} + +/** One line per check, in the shape a human reads in a log or an issue body. */ +export function formatEvidenceFreshness(rows) { + const width = Math.max(...rows.map((row) => String(row.id).length), 2); + return rows.map((row) => { + const id = String(row.id).padEnd(width); + if (row.state === 'invalid') return `❌ ${id} observedAt ${row.observedAt}: not a date`; + if (row.state === 'future') return `❌ ${id} observed ${row.observedAt}: in the future — a date, not an observation`; + if (row.state === 'stale') return `❌ ${id} observed ${row.observedAt}: STALE since ${row.staleFrom} (${row.ageDays} days old) — Validate documentation is red on every PR until it is re-observed`; + if (row.state === 'expiring') return `⚠️ ${id} observed ${row.observedAt}: turns stale on ${row.staleFrom} (${row.daysLeft === 0 ? 'today is the last valid day' : `${row.daysLeft} day(s) left`}) — re-observe it before then`; + return `✅ ${id} observed ${row.observedAt}: ${row.daysLeft} day(s) left, turns stale on ${row.staleFrom}`; + }); +} + +/** + * `--freshness`: read the evidence alone, print its ages, and exit 1 when any check is + * within the warning band or past it. Deliberately cheaper than the reconciliation — no + * board, no git, no ISO audit — so the scheduled workflow that runs it needs a shallow + * checkout and nothing else. + */ +function reportEvidenceFreshness(now = new Date()) { + const evidence = JSON.parse(fs.readFileSync(RUNTIME_EVIDENCE, 'utf8')); + const rows = assessEvidenceFreshness(evidence, now); + console.log(`Runtime maturity evidence on ${isoDate(now)} — window ${EVIDENCE_MAX_AGE_DAYS} days, warning ${EVIDENCE_WARN_DAYS} days before it closes:`); + for (const line of formatEvidenceFreshness(rows)) console.log(line); + const failing = rows.filter((row) => row.state !== 'fresh'); + if (failing.length) { + console.log( + `\n${failing.length} of ${rows.length} check(s) need re-observing. The procedure is the one in commit 3e5aac80 / 2ee3f9a0:\n` + + ' take a fresh green run of each workflow, rewrite observedAt/commit/source/summary as a NEW observation\n' + + ' (never a date bump), then `node .harness/scripts/ci/09-reconcile-maturity.mjs` and commit both files.', + ); + process.exit(1); + } + console.log(`\nAll ${rows.length} checks are inside the window with more than ${EVIDENCE_WARN_DAYS} days to spare.`); +} + function countFiles(directory, pattern, excludePattern) { if (!fs.existsSync(directory)) return 0; return fs.readdirSync(directory, { withFileTypes: true }).reduce((total, entry) => { @@ -686,7 +762,7 @@ export function validateRuntimeEvidence(evidence, board, root = ROOT, now = new errors.push(`${check?.id} has invalid observedAt`); } else { const ageDays = Math.floor((now - new Date(`${check.observedAt}T00:00:00Z`)) / 86400000); - if (ageDays < 0 || ageDays > 30) errors.push(`${check.id} evidence is stale or future-dated`); + if (ageDays < 0 || ageDays > EVIDENCE_MAX_AGE_DAYS) errors.push(`${check.id} evidence is stale or future-dated`); } if (!/^[0-9a-f]{7,40}$/i.test(check?.commit || '') || !commitExists(root, check.commit)) { errors.push(`${check?.id} references an unavailable commit`); @@ -774,6 +850,20 @@ function serialize(snapshot) { } function run() { + // GT-711: the freshness report is its own mode so the scheduled workflow can run it on a + // shallow checkout; it reads one file and never touches the reconciliation. + if (process.argv.includes('--freshness')) { + // `--now=YYYY-MM-DD` asks what the report will say on a given day, so the red path can + // be observed on demand (workflow_dispatch) instead of waited for. + const asOf = process.argv.find((arg) => arg.startsWith('--now='))?.slice('--now='.length); + if (asOf && !/^\d{4}-\d{2}-\d{2}$/.test(asOf)) { + console.error(`❌ --now expects YYYY-MM-DD, got "${asOf}"`); + process.exit(2); + } + reportEvidenceFreshness(asOf ? new Date(`${asOf}T12:00:00Z`) : new Date()); + return; + } + // GT-576/GT-596: prove both rules still bite BEFORE trusting their verdict on the real // document. A guard that has never been observed failing is the defect, not the control. const { assertions } = selfTestValidatedEvidenceRule(); @@ -811,6 +901,20 @@ function run() { ); const expected = serialize(buildSnapshot()); + + // GT-711: buildSnapshot has just accepted the evidence, so nothing here can be stale — + // but it can be about to be. Say so on every run, in the log a PR author actually reads, + // with the date; the scheduled `--freshness` run is what turns this into an issue. + const expiring = assessEvidenceFreshness(JSON.parse(fs.readFileSync(RUNTIME_EVIDENCE, 'utf8'))) + .filter((row) => row.state === 'expiring'); + if (expiring.length) { + console.warn( + `⚠️ ${expiring.length} runtime maturity check(s) turn stale within ${EVIDENCE_WARN_DAYS} days — ` + + 'from that day `Validate documentation` is red on every PR until they are re-observed:\n' + + formatEvidenceFreshness(expiring).map((line) => ` ${line}`).join('\n'), + ); + } + if (process.argv.includes('--check')) { if (!fs.existsSync(OUTPUT) || fs.readFileSync(OUTPUT, 'utf8') !== expected) { console.error('❌ Maturity reconciliation is stale. Run: node .harness/scripts/ci/09-reconcile-maturity.mjs'); diff --git a/.harness/scripts/reconcile-maturity.test.mjs b/.harness/scripts/reconcile-maturity.test.mjs index 0f50ecc09..8a00bddc8 100644 --- a/.harness/scripts/reconcile-maturity.test.mjs +++ b/.harness/scripts/reconcile-maturity.test.mjs @@ -3,6 +3,10 @@ import assert from 'node:assert/strict'; import { parseBoard, validateRuntimeEvidence, + assessEvidenceFreshness, + formatEvidenceFreshness, + EVIDENCE_MAX_AGE_DAYS, + EVIDENCE_WARN_DAYS, auditIsoRatings, auditAssessmentRatings, bandFor, @@ -125,6 +129,51 @@ test('runtime evidence rejects stale and unowned blockers', () => { assert.ok(errors.some((error) => error.includes('Missing required maturity check'))); }); +// --------------------------------------------------------------------------- +// GT-711 — the window is announced before it closes +// --------------------------------------------------------------------------- + +// The real incident, replayed: four checks observed 2026-08-18 were accepted by the develop +// run of 2026-09-15 and rejected by a README-only PR on 2026-09-19. The report has to say +// "turns stale on 2026-09-18" from the day it is within the warning band. +const observedAugust18 = (id) => ({ id, status: 'PASS', observedAt: '2026-08-18', commit: 'abc1234', source: 'https://github.com/o/r/actions/runs/1', summary: 'x' }); + +test('freshness names the first day a check turns stale, and it is the day validateRuntimeEvidence starts rejecting it', () => { + const evidence = { schemaVersion: '1.0.0', asOf: '2026-09-05', checks: [observedAugust18('cli-baseline')] }; + const [row] = assessEvidenceFreshness(evidence, new Date('2026-09-15T17:00:00Z')); + assert.equal(row.staleFrom, '2026-09-18'); + assert.equal(row.ageDays, 28); + assert.equal(row.daysLeft, 2); + assert.equal(row.state, 'expiring'); + + const board = { ...parseBoard('**Last Updated:** 2026-09-05\n| [`GT-1`](./c.md#gt-1) | x | Cross | P0 | M | `DONE` |\n'), content: '' }; + const lastValidDay = validateRuntimeEvidence(evidence, board, process.cwd(), new Date('2026-09-17T23:59:00Z')); + const firstStaleDay = validateRuntimeEvidence(evidence, board, process.cwd(), new Date('2026-09-18T00:00:00Z')); + assert.ok(!lastValidDay.some((error) => error.includes('stale')), 'day 30 is still inside the window'); + assert.ok(firstStaleDay.some((error) => error.includes('stale')), 'day 31 is outside it — the same day the report announced'); +}); + +test('freshness bands: fresh beyond the warning band, expiring inside it (today included), stale past the window, future ahead of it', () => { + const evidence = { schemaVersion: '1.0.0', asOf: '2026-09-05', checks: [observedAugust18('coverage')] }; + const stateOn = (day) => assessEvidenceFreshness(evidence, new Date(`${day}T12:00:00Z`))[0].state; + assert.equal(EVIDENCE_MAX_AGE_DAYS, 30); + assert.equal(EVIDENCE_WARN_DAYS, 7); + assert.equal(stateOn('2026-09-09'), 'fresh', '22 days old: 8 left, outside the band'); + assert.equal(stateOn('2026-09-10'), 'expiring', '23 days old: 7 left, first day of the band'); + assert.equal(stateOn('2026-09-17'), 'expiring', '30 days old: last valid day, 0 left'); + assert.equal(stateOn('2026-09-18'), 'stale', '31 days old: rejected'); + assert.equal(stateOn('2026-08-17'), 'future', 'observed tomorrow: a date, not an observation'); + assert.equal(assessEvidenceFreshness({ checks: [{ id: 'release', observedAt: 'soon' }] })[0].state, 'invalid'); +}); + +test('the freshness report is one line per check and carries the date on the lines that matter', () => { + const evidence = { schemaVersion: '1.0.0', asOf: '2026-09-05', checks: [observedAugust18('cli-baseline'), { ...observedAugust18('release'), observedAt: '2026-09-15' }] }; + const lines = formatEvidenceFreshness(assessEvidenceFreshness(evidence, new Date('2026-09-19T09:00:00Z'))); + assert.equal(lines.length, 2); + assert.match(lines[0], /^❌ cli-baseline .*STALE since 2026-09-18/); + assert.match(lines[1], /^✅ release .*turns stale on 2026-10-16/); +}); + // --------------------------------------------------------------------------- // GT-596 — ISO/IEC 33020:2019 rating scale // --------------------------------------------------------------------------- diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 06c133a56..278ab5828 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -10652,6 +10652,25 @@ "NO EXCEPTION WAS DECLARED. `.harness/config/npm-audit-exceptions.json` still holds zero entries; that file is for advisories with no upstream fix, and both of these had one. The moderate `qs` advisory survives on purpose — the gate does not block below HIGH.", "WHY THE GATE STOPPED NOTHING is registered as its own row rather than as prose here: `Security Audit` is not among the nine required contexts, and eight PRs merged into `main` while it was red. See GT-710." ] + }, + { + "id": "GT-711", + "closedAt": "2026-09-19", + "closureCommit": "30065070", + "dependencyDisposition": "none", + "evidence": [ + ".harness/scripts/ci/09-reconcile-maturity.mjs", + ".harness/scripts/reconcile-maturity.test.mjs", + ".github/workflows/maturity-evidence-freshness.yml" + ], + "validationCommands": [ + "THE WINDOW IS UNCHANGED AND THE RULE IS UNCHANGED. EVIDENCE_MAX_AGE_DAYS = 30 is the same literal the rejection used, now shared with the report, so the day the report names is by construction the day validateRuntimeEvidence starts rejecting -- the self-test proves it at both sides of the boundary on the real incident's dates (observed 2026-08-18: accepted 2026-09-17T23:59Z, rejected 2026-09-18T00:00Z, report says 2026-09-18).", + "THE RED PATH WAS OBSERVED, NOT ASSUMED: `node .harness/scripts/ci/09-reconcile-maturity.mjs --freshness --now=2026-10-13` prints four `turns stale on 2026-10-20 (6 day(s) left)` lines and exits 1; `--now=2026-10-20` prints four `STALE since 2026-10-20` lines and exits 1; with no --now (2026-09-19) it prints four fresh lines and exits 0; `--now=ayer` exits 2.", + "WHAT IS DEFERRED TO THE CALENDAR AND NOT CLAIMED: the scheduled workflow opening the issue. First observable 2026-10-13, the first day inside the band for the evidence observed 2026-09-19. Its issue steps are the published-canary's (GT-671), which have the same not-yet-fired status.", + "node --test .harness/scripts/reconcile-maturity.test.mjs # 24 cases, 3 new, 0 failures", + "node .harness/scripts/ci/09-reconcile-maturity.mjs --check # still green on the same tree; the warning band is empty today", + "node .harness/scripts/ci/40-validate-path-literals.mjs # the workflow's run: body resolves" + ] } ] } diff --git a/reference/core/control-center/gaps/gap-reference-catalog.es.md b/reference/core/control-center/gaps/gap-reference-catalog.es.md index c5e26a3f3..5c857753c 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -10114,3 +10114,43 @@ Los dos se arreglaron de forma estructural y no como correcciones: el rethrow no - [ ] **FALSABILIDAD:** un PR con un advisory ALTA sin declarar sale `BLOCKED` y no `UNSTABLE`, observado y no supuesto. - [~] La decisión sobre `Trivy` y `build-and-test` queda escrita — requeridos también, o registrado por qué no lo son. **DECIDIDO por el dueño el 2026-09-05: requeridos los dos.** Ejecutable solo la mitad, y la otra mitad no es pereza sino un deadlock medido: **`Trivy Container Scan` es seguro de requerir** porque vive en `sdk-cli-ci.yml`, que **no lleva filtro `paths`** —y su comentario explica que no debe llevarlo nunca, por el bloqueo que `CodeQL SAST` causó en el PR #218 al volverse requerido—, así que reporta en todo PR. **`build-and-test` NO puede requerirse tal como está:** vive en `sdk-cli-release.yml`, cuyo disparador `pull_request` sí filtra por `src/sdk/cli/**`, `src/packages/**`, `.github/workflows/sdk-cli-release.yml` y `.harness/**`. Un check requerido detrás de un filtro de rutas **nunca reporta** en un PR que no las toca, y GitHub lee «no reportó» como «no satisfecho»: el PR queda inmergeable para siempre con todo en verde. Es exactamente lo que le habría pasado a [#690](https://github.com/beyondnetcode/evolith_arch32/pull/690), que solo tocó `reference/`. **Precondición, no alternativa:** quitar el filtro `paths` del `pull_request` de `sdk-cli-release.yml` —el mismo arreglo que ya se aplicó a `sdk-cli-ci.yml`— y solo entonces añadirlo a los requeridos. **Nota sobre el nombre:** el check a requerir es `Trivy Container Scan`, el nombre del job; el check `Trivy` a secas que publica `aquasecurity/trivy-action` aparece en `main` pero no en la cabeza de `develop`, así que requerir ese nombre reintroduciría el mismo deadlock por otra vía. - **Estado:** `PENDIENTE` +#### GT-711 + +**Título:** La ventana de la evidencia de madurez se cierra en una fecha conocida con treinta días de antelación, y el primero en enterarse era quien abriera un PR esa mañana + +- **Propósito:** Enterarse de la caducidad de los cuatro checks de runtime una semana antes, en una issue, en lugar de ese día, en un check requerido en rojo sobre un PR ajeno. +- **Evidencia, medida el 2026-09-19 contra el fichero de evidencia y el historial de Actions:** + + | hecho | valor | + |---|---| + | regla | `validateRuntimeEvidence` en `09-reconcile-maturity.mjs` rechaza un check cuyo `observedAt` supera los 30 días | + | dónde corre | `Validate documentation`, contexto requerido en `main` y `develop`, con `--check` | + | los cuatro checks se observaron | 2026-08-18 (`3e5aac80`) | + | último día válido / primer día caducado | 2026-09-17 / 2026-09-18 | + | último verde en `develop` | run 34998373244, 2026-09-15, a 28 días | + | primer rojo | [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724), 2026-09-19, a 32 días — un cambio de posicionamiento en los dos README | + | ocurrencia anterior | 2026-08-18: `documentation` cruzó los 31 días sobre una promoción, consta en el resumen del propio fichero de evidencia | + | qué miraba el calendario antes de ese día | nada | + +- **La ventana no es el defecto.** La evidencia que caduca debe re-tomarse, no estirarse — las dos re-observaciones (`3e5aac80`, `2ee3f9a0`) lo dicen en sus resúmenes y ninguna es un cambio de fecha. El defecto es que una fecha conocida con treinta días de antelación se aprendía el día en que empezaba a bloquear merges, por quien abriera un PR, sin relación alguna entre el cambio y el rojo. +- **Lo que lo cierra, en `30065070`:** + - `assessEvidenceFreshness(evidence, now)` nombra, por check, el primer día en que `validateRuntimeEvidence` lo rechazará — `observedAt + EVIDENCE_MAX_AGE_DAYS + 1`, la misma constante y la misma aritmética, no una segunda opinión — y lo clasifica `fresh`, `expiring` (dentro de `EVIDENCE_WARN_DAYS = 7`, hoy incluido), `stale` o `future`. + - Cada ejecución del reconciliador, `--check` incluido, imprime los checks a punto de caducar con su fecha tras aceptar la evidencia. No bloquea: la ventana no se mueve. + - `--freshness [--now=YYYY-MM-DD]` imprime el informe y sale 1 dentro de la banda o pasada la ventana; `--now` permite observar el camino rojo bajo demanda en vez de esperarlo. + - `maturity-evidence-freshness.yml` lo ejecuta a diario a las 06:45 UTC contra `develop` (la rama donde se redactan las re-observaciones) y abre una issue con etiqueta `maturity-evidence` con el log y el procedimiento; el hilo se actualiza, no se duplica, y se cierra solo cuando los checks vuelven a estar frescos. La misma forma que el canary del artefacto publicado (GT-671), por la misma razón: una pestaña de Actions en rojo a la que nadie está suscrito no es una señal. +- **Casos de uso:** + - Alguien abre un PR de documentación y `Validate documentation` sale en rojo por cuatro entradas JSON que el PR nunca tocó. + - Una promoción a `main` se bloquea la mañana en que la evidencia cruza la ventana, sin que nadie hubiera planificado la re-observación. +- **Impacto:** Dos veces en un mes un check requerido se puso en rojo sobre trabajo ajeno, el día en que empezaba a bloquear, con un arreglo — una re-observación — que nadie había programado. +- **Resultado esperado:** Una issue una semana antes de que se cierre la ventana, con la fecha y los cuatro checks; la re-observación entra antes de que ningún PR vea rojo. +- **Ficheros afectados:** `.harness/scripts/ci/09-reconcile-maturity.mjs`, `.harness/scripts/reconcile-maturity.test.mjs`, `.github/workflows/maturity-evidence-freshness.yml`, `.github/workflows/docs.yml` +- **Componente:** `Infra` · **Criticidad:** P2 · **Complejidad:** S +- **Principal:** `XS` · **Interés:** `MED` · **Base:** `estimate` +- **Procedencia:** Registrado el 2026-09-19 al mergear [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724): el PR cambiaba dos README y salió en rojo en `Validate documentation`; leer el log mostró `cli-baseline evidence is stale or future-dated` cuatro veces, y los resúmenes del propio fichero de evidencia mostraron que lo mismo había pasado el 2026-08-18. +- **Criterios de aceptación:** + - [x] El primer día caducado que nombra el informe es el primer día en que `validateRuntimeEvidence` rechaza el check, probado a ambos lados del borde. **CUMPLIDO** — el self-test reproduce el incidente real: observado el 2026-08-18, aceptado a las 2026-09-17T23:59Z, rechazado a las 2026-09-18T00:00Z, el informe dice `turns stale on 2026-09-18`. + - [x] La banda de aviso tiene sus cuatro bordes probados: último día fresco, primer día en la banda, último día válido (0 restantes), primer día caducado, más fecha futura. **CUMPLIDO** — 24 casos en `reconcile-maturity.test.mjs`, 3 nuevos. + - [x] La ventana y la regla de re-observación no cambian. **CUMPLIDO** — `EVIDENCE_MAX_AGE_DAYS = 30` es el mismo `30` que usaba el rechazo, ahora compartido; el texto del propio informe dice "never a date bump". + - [x] **FALSABILIDAD:** el camino rojo se observó, no se supuso. **CUMPLIDO** — `--freshness --now=2026-10-13` imprime cuatro líneas `turns stale on 2026-10-20 (6 day(s) left)` y sale 1; `--now=2026-10-20` imprime cuatro líneas `STALE since 2026-10-20` y sale 1; hoy sale 0. + - [x] Lo que aún no puede observarse queda escrito como tal, no reclamado: la ejecución programada abriendo la issue es observable por primera vez el 2026-10-13 (primer día dentro de la banda para la evidencia observada el 2026-09-19) y cerrándola tras la re-observación. **CUMPLIDO como declaración de lo que NO se reclama** — la fecha consta aquí y en el registro de cierre; los pasos de issue son los del canary publicado (GT-671), con el mismo estado de aún-no-disparado. +- **Estado:** `COMPLETADO` diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index 02b21a69f..6d8fd1568 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -10207,3 +10207,43 @@ Both were fixed structurally rather than corrected: the rethrow now names BOTH f - [ ] **FALSIFIABILITY:** a PR carrying an undeclared HIGH advisory comes out `BLOCKED` rather than `UNSTABLE`, observed and not assumed. - [~] The decision on `Trivy` and `build-and-test` is written down — required too, or a recorded reason why not. **DECIDED by the owner 2026-09-05: both required.** Only half is executable, and the other half is not laziness but a measured deadlock: **`Trivy Container Scan` is safe to require** because it lives in `sdk-cli-ci.yml`, which carries **no `paths` filter** — and whose comment says it must never carry one, because of the block `CodeQL SAST` caused on PR #218 once it became required — so it reports on every PR. **`build-and-test` CANNOT be required as it stands:** it lives in `sdk-cli-release.yml`, whose `pull_request` trigger does filter on `src/sdk/cli/**`, `src/packages/**`, `.github/workflows/sdk-cli-release.yml` and `.harness/**`. A required check behind a path filter **never reports** on a PR that misses those paths, and GitHub reads "never reported" as "not satisfied": the PR is unmergeable forever with everything green. That is exactly what would have happened to [#690](https://github.com/beyondnetcode/evolith_arch32/pull/690), which touched only `reference/`. **A precondition, not an alternative:** drop the `paths` filter from `sdk-cli-release.yml`'s `pull_request` — the same fix already applied to `sdk-cli-ci.yml` — and only then add it to the required set. **On the name:** the check to require is `Trivy Container Scan`, the job name; the bare `Trivy` check published by `aquasecurity/trivy-action` shows on `main` but not on `develop`'s head, so requiring that name would reintroduce the same deadlock by another route. - **Status:** `PENDING` +#### GT-711 + +**Title:** The maturity-evidence window closes on a date known thirty days ahead, and the first to learn it was whoever opened a PR that morning + +- **Purpose:** Learn the expiry of the four runtime checks a week ahead, in an issue, instead of on the day, in a red required check on an unrelated PR. +- **Evidence, measured 2026-09-19 against the evidence file and the Actions history:** + + | fact | value | + |---|---| + | rule | `validateRuntimeEvidence` in `09-reconcile-maturity.mjs` rejects a check whose `observedAt` is more than 30 days old | + | where it runs | `Validate documentation`, a required context on `main` and `develop`, with `--check` | + | the four checks were observed | 2026-08-18 (`3e5aac80`) | + | last valid day / first stale day | 2026-09-17 / 2026-09-18 | + | last green on `develop` | run 34998373244, 2026-09-15, at 28 days | + | first red | [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724), 2026-09-19, at 32 days — a positioning change to the two READMEs | + | previous occurrence | 2026-08-18: `documentation` crossed 31 days on a promotion, recorded in the evidence file's own summary | + | what looked at the calendar before that day | nothing | + +- **The window is not the defect.** Evidence that ages out is meant to be re-taken, not extended — both re-observations (`3e5aac80`, `2ee3f9a0`) say so in their summaries and neither is a date bump. The defect is that a date known thirty days in advance was being learned on the day it started blocking merges, by whoever happened to open a PR, with no relation between the change and the red. +- **What closes it, in `30065070`:** + - `assessEvidenceFreshness(evidence, now)` names, per check, the first day `validateRuntimeEvidence` will reject it — `observedAt + EVIDENCE_MAX_AGE_DAYS + 1`, the same constant and the same arithmetic, not a second opinion — and classifies it `fresh`, `expiring` (within `EVIDENCE_WARN_DAYS = 7`, today included), `stale` or `future`. + - Every reconciler run, `--check` included, prints the expiring checks with their date after accepting the evidence. It does not block: the window does not move. + - `--freshness [--now=YYYY-MM-DD]` prints the report and exits 1 inside the band or past it; `--now` lets the red path be observed on demand instead of waited for. + - `maturity-evidence-freshness.yml` runs it daily at 06:45 UTC against `develop` (the branch where re-observations are authored) and opens one issue labelled `maturity-evidence` with the log and the procedure; the thread is updated, not duplicated, and closes itself once the checks are fresh again. Same shape as the published-artifact canary (GT-671), for the same reason: a red Actions tab nobody is subscribed to is not a signal. +- **Use cases:** + - A contributor opens a documentation PR and `Validate documentation` is red over four JSON entries the PR never touched. + - A promotion to `main` is blocked on the morning the evidence crosses the window, with nobody having planned the re-observation. +- **Impact:** Twice in a month a required check went red on unrelated work, on the day it started blocking, with the fix being a re-observation nobody had scheduled. +- **Expected outcome:** An issue a week before the window closes, naming the date and the four checks; the re-observation lands before any PR sees red. +- **Files affected:** `.harness/scripts/ci/09-reconcile-maturity.mjs`, `.harness/scripts/reconcile-maturity.test.mjs`, `.github/workflows/maturity-evidence-freshness.yml`, `.github/workflows/docs.yml` +- **Component:** `Infra` · **Criticality:** P2 · **Complexity:** S +- **Principal:** `XS` · **Interest:** `MED` · **Basis:** `estimate` +- **Provenance:** Registered 2026-09-19 while merging [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724): the PR changed two READMEs and came out red on `Validate documentation`; reading the log showed `cli-baseline evidence is stale or future-dated` four times, and the evidence file's own summaries showed the same thing had happened on 2026-08-18. +- **Acceptance criteria:** + - [x] The first stale day the report names is the first day `validateRuntimeEvidence` rejects the check, proven at both sides of the boundary. **MET** — the self-test replays the real incident: observed 2026-08-18, accepted at 2026-09-17T23:59Z, rejected at 2026-09-18T00:00Z, report says `turns stale on 2026-09-18`. + - [x] The warning band has all four edges tested: last fresh day, first expiring day, last valid day (0 left), first stale day, plus future-dated. **MET** — 24 cases in `reconcile-maturity.test.mjs`, 3 new. + - [x] The window and the re-observation rule are unchanged. **MET** — `EVIDENCE_MAX_AGE_DAYS = 30` is the same `30` the rejection used, now shared; the report's own text says "never a date bump". + - [x] **FALSIFIABILITY:** the red path was observed, not assumed. **MET** — `--freshness --now=2026-10-13` prints four `turns stale on 2026-10-20 (6 day(s) left)` lines and exits 1; `--now=2026-10-20` prints four `STALE since 2026-10-20` lines and exits 1; today exits 0. + - [x] What cannot be observed yet is written down as such, not claimed: the scheduled run opening the issue is first observable on 2026-10-13 (the first day inside the band for the evidence observed 2026-09-19) and closing it after the re-observation. **MET as a statement of what is NOT claimed** — the date is recorded here and in the closure record; the issue steps are the published canary's (GT-671), which have the same not-yet-fired status. +- **Status:** `DONE` diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index 4f19ed3f7..6f0c726c1 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -4,6 +4,7 @@ **Estado:** Seguimiento Activo **Responsable:** Evolith Architecture Board +**Última Actualización:** 2026-09-19 (**Un gap registrado y cerrado a partir del rojo que causó: la ventana de la evidencia de madurez se agotó sobre un PR que solo tocaba el README, por segunda vez.** `GT-711` → COMPLETADO. Los cuatro checks de runtime de `maturity-evidence.json` se observaron el 2026-08-18 y caducaron el 2026-09-18; el primer PR en enterarse fue [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724), un cambio de posicionamiento en los dos README, cuando `Validate documentation` (requerido) se puso en rojo en `09-reconcile-maturity.mjs`. La misma forma que el 2026-08-18, cuando tropezó una promoción. La evidencia se re-observó contra runs reales (develop `4d655745`, main `99b53259`) en `2ee3f9a0`, y la ventana ahora **se anuncia en vez de descubrirse**: cada ejecución del reconciliador nombra los checks que caducan en menos de siete días, con la fecha; `--freshness` lo convierte en un código de salida; y un workflow diario abre UNA issue `maturity-evidence` una semana antes. **Lo que vale la pena conservar:** la ventana nunca fue el defecto — una fecha conocida con treinta días de antelación se aprendía el día en que empezaba a bloquear merges, por quien abriera un PR esa mañana.) **Última Actualización:** 2026-09-05 (**Dos filas nuevas de un mismo hilo: una CVE ALTA que llevaba tres días viva en `main` y la razón por la que no detuvo nada.** `GT-709` → COMPLETADO: `Security Audit` estaba rojo desde `b84523b4` no por una dependencia sin arreglo, sino porque `overrides.fast-uri` estaba fijado en `3.1.5`, **exactamente la última versión vulnerable de la rama 3.x**, con el parche en `3.1.6` y dentro del rango que `ajv` declara. Medido con el guard real, `63-validate-npm-audit-gate.mjs`: de **11 filas bloqueantes / 7 altas a 0 y 0**, sin declarar ninguna excepción. `GT-710` → PENDIENTE: ese gate **no es un check requerido**, y en el intervalo en que estuvo rojo se mergearon **ocho** PR a `main`, cuatro de ellos de dependencias npm. **Lo que merece llevarse:** el mecanismo que se usa para cerrar un advisory —el `overrides`— es el mismo que después impide cerrarlo, y el único check que lo ve no bloquea nada.) **Última Actualización:** 2026-08-18 (**Un gap cerrado por el disparador que él mismo había escrito — que se activó dos días después de escribirlo, y nombraba la release equivocada.** `GT-691` → COMPLETADO. La CVE ALTA de `js-yaml` que bloqueaba toda promoción a `main` salió del árbol por una actualización, no por un descarte: `@nestjs/swagger@11.4.7` —un PARCHE sobre la línea `11.4.x` que la fila había dado por agotada, no la 12 estable que decía esperar— declara `"js-yaml": "5.3.0"`, y `package-lock.json` resuelve ahora `node_modules/@nestjs/swagger/node_modules/js-yaml` hacia ella. `npm audit` pasa de 1 alta a 0 altas / 0 críticas; el criterio de falsabilidad de la fila se volvió a medir contra el árbol nuevo en vez de heredarlo, y ninguno de los dos falsadores se disparó. **Lo que merece llevarse es el elemento a vigilar, no la CVE:** la fila identificó bien que había que vigilar `@nestjs/swagger` y no `js-yaml`, y luego ató esa vigilancia a un major que no había salido.) **Última Actualización:** 2026-08-08 (**Un gap cerrado haciendo lo irreversible que el board había diferido a propósito — y lo que vale registrar es la medición que se tomó antes.** `GT-622` → COMPLETADO, y lo que lo cerró es la mitad irreversible que el board había diferido: los 210 análisis huérfanos de `.github/workflows/ci.yml:codeql` están borrados de `refs/heads/main` (201) y `refs/heads/develop` (9), la clave muerta no aparece en ninguna de las dos, y el corpus de alerts es idéntico a través del borrado — 242 abiertos / 82 descartados / 60 corregidos, CodeQL 75 / Scorecard 158 / Trivy 9. **Tres afirmaciones de esa misma fila no sobrevivieron a la re-medición, y la fila ya se había re-medido dos veces sin que ninguna saliera, porque cada pasada comprobó el RECUENTO y ninguna comprobó la AFIRMACIÓN:** el check era `neutral`, no rojo, desde el PR #250; la configuración que nombra el aviso está en `refs/heads/develop`, no en `main`, así que solo los PR contra `develop` seguían arrastrándolo mientras `main` salía limpio por su cuenta desde el PR #420 con los 201 huérfanos todavía puestos; y los 9 análisis de `develop` —los que importaban— nunca se contaron. **El campo que convirtió un juicio del dueño en una decisión fácil no se había leído nunca:** los 210 análisis tienen `results_count: 0`, así que lo descartado son 210 registros de “escaneé y no encontré nada” de una configuración muerta desde junio. La irreversibilidad era real; la pérdida no. Quedan a propósito 22 análisis de la clave muerta en `refs/pull/{4..17}/merge`, refs efímeras por PR que jamás pueden ser base de un PR. **Observado también al medir, y fuera de este cierre:** el conjunto de contextos requeridos en `main` y `develop` es ahora de **8**, tras ganar `Secret Detection (gitleaks)` — la promoción que `GT-653` registraba como su único pendiente. El criterio de cierre que solo podía observarse en un PR contra `develop` se observó en el PR #440, el que trae este mismo cierre, 105s después de abrirlo — escrito tras leer el check, no antes. Contadores recalculados desde las filas: **640 / 653 completados · 3 en progreso · 3 pendientes · 7 diferidos**.) @@ -21,6 +22,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | ID | Gap | En simple | Qué resuelve | Componente | Fase | Criticidad | Complejidad | Estado | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| +| [`GT-711`](./gap-reference-catalog.es.md#gt-711) | **La ventana de la evidencia de madurez se cierra en una fecha conocida con treinta días de antelación, y el primero en enterarse era quien abriera un PR esa mañana.** `validateRuntimeEvidence` en `09-reconcile-maturity.mjs` rechaza cualquiera de los cuatro checks de runtime de `maturity-evidence.json` cuando `observedAt` supera los 30 días, y `Validate documentation` — contexto requerido en `main` y `develop` — lo ejecuta con `--check`. La ventana es correcta: la evidencia que caduca se re-toma, no se estira. Lo incorrecto era que nada miraba el calendario antes del día en que importaba. Medido dos veces: el 2026-08-18 el check `documentation` cruzó los 31 días y puso en rojo una promoción (consta en el propio fichero de evidencia), y el 2026-09-19 los cuatro — observados el 2026-08-18, caducados desde el 2026-09-18 — pusieron en rojo [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724), un cambio de posicionamiento del README, después de que el run de develop del 2026-09-15 hubiera pasado a 28 días. **CERRADO el 2026-09-19** en `30065070`: `assessEvidenceFreshness` nombra, por check, el primer día en que `validateRuntimeEvidence` lo rechazará (misma constante, misma aritmética); cada ejecución del reconciliador avisa con esa fecha cuando un check entra en `EVIDENCE_WARN_DAYS = 7`; `--freshness [--now=YYYY-MM-DD]` sale 1 dentro de la banda o pasada la ventana; y `maturity-evidence-freshness.yml` lo ejecuta a diario a las 06:45 UTC contra `develop`, abriendo una issue `maturity-evidence` que se cierra sola al re-observar. La re-observación en sí entró en `2ee3f9a0` (PR #724). **No se cambió a propósito:** la ventana de 30 días, y la regla de que una re-observación es una observación nueva, nunca un cambio de fecha. | Un reloj de 30 días sobre nuestra propia evidencia se agotó dos veces sobre gente que no la había tocado, el día en que empezaba a bloquear merges. | Enterarse de la caducidad una semana antes, en una issue, en lugar de ese día, en un check requerido en rojo sobre un PR ajeno. | `Infra` | Cross | P2 | S | `COMPLETADO` | | [`GT-710`](./gap-reference-catalog.es.md#gt-710) | **El gate que mide las CVE no es un check requerido, así que ocho merges pasaron por encima de él estando rojo.** Los nueve contextos requeridos de `main` y `develop` son `CodeQL SAST`, `Secret Detection (gitleaks)`, `Services build (GHCR)`, `Test`, `Test core`, `Test core-api`, `Test core-domain`, `Test mcp-server` y `Validate documentation`. **`Security Audit` no está entre ellos**, y tampoco lo están `Trivy` ni `build-and-test`. Medido el 2026-09-05: entre `b84523b4` (02-sep), el commit donde `Security Audit` se puso rojo, y su arreglo en [`GT-709`](./gap-reference-catalog.es.md#gt-709), se mergearon a `main` **ocho pull requests con el gate en rojo** — y cuatro de ellos ([#664](https://github.com/beyondnetcode/evolith_arch32/pull/664), [#665](https://github.com/beyondnetcode/evolith_arch32/pull/665), [#666](https://github.com/beyondnetcode/evolith_arch32/pull/666), [#667](https://github.com/beyondnetcode/evolith_arch32/pull/667)) eran cambios de dependencias npm, exactamente la clase de cambio que ese gate existe para juzgar. GitHub los presenta como `UNSTABLE` y no como `BLOCKED`, así que el flujo normal de revisión los mergea sin fricción. **El propio workflow nombra este modo de fallo por escrito:** el comentario de `sdk-cli-ci.yml` dice que un check siempre rojo enseña a los revisores a descontar el rojo, y luego deja el check fuera de los requeridos, que es la manera más directa de garantizar que eso ocurra. **Aplicado en parte el 2026-09-05:** `Security Audit` ya es requerido en `main` y `develop` (10 contextos, verificado), y el dueño decidió que `Trivy` y `build-and-test` lo sean también. Sigue abierta porque **el falsador de esta fila aún no se ha disparado** —no hay ninguna alta viva que observar quedando `BLOCKED` en vez de `UNSTABLE`— y porque `build-and-test` **no puede requerirse tal como está**: vive en `sdk-cli-release.yml`, cuyo `pull_request` filtra por rutas, y un check requerido detrás de un filtro nunca reporta y deja el PR inmergeable con todo en verde. Se registra aparte de [`GT-709`](./gap-reference-catalog.es.md#gt-709) a propósito: aquella era una CVE con arreglo de dos líneas, esta es la razón por la que la CVE pudo vivir tres días sin detener nada. | Tenemos un chequeo de seguridad que mide bien y no impide nada; ocho cambios entraron con él en rojo. | Que un advisory ALTA sin declarar bloquee el merge en lugar de limitarse a informarlo. | `Infra` | Cross | P1 | S | `PENDIENTE` | | [`GT-709`](./gap-reference-catalog.es.md#gt-709) | **Un `overrides` puesto para cerrar un advisory se convierte en el techo que impide cerrarlo la vez siguiente.** `Security Audit` llevaba rojo en `main` desde `b84523b4` (2026-09-02) por `GHSA-jqff-g426-hqxp`, una CVE ALTA en `fast-uri` — y la causa no era una dependencia sin arreglo publicado, sino **dos pins propios que se quedaron por debajo de la versión parcheada**, que el gate reporta con la misma forma que una advisory ajena. `overrides.fast-uri` estaba fijado en `3.1.5`, **exactamente la última versión vulnerable de la rama 3.x**; el parche es `3.1.6`, dentro del `^3.0.1` que declara `ajv@8.20.0`, así que el arreglo cabía en el pin que ya existía y nadie lo miró porque parecía configuración resuelta. **Medido con `63-validate-npm-audit-gate.mjs`, el mismo guard que corre CI, y no inferido de changelogs:** `origin/main` daba **11 filas bloqueantes / 7 altas**; con `fast-uri` a `3.1.7` caen 9 de las 11 — las cuatro advisories suyas más las cinco filas de la cadena `ajv`/`commitlint` que llegaban *via* `fast-uri`. Las dos restantes eran `browserslist` `4.28.4`, transitivo solo-dev (`ts-jest`→`@babel/core`, `@nestjs/cli`→`webpack`) con arreglo en `4.28.7`: mismo patrón, mismo tipo de override, pineado a `4.28.9`. **Resultado: 0 filas bloqueantes, 0 altas.** La moderada de `qs` sobrevive a propósito — el gate no bloquea por debajo de HIGH. **CERRADA el 2026-09-05** por [#689](https://github.com/beyondnetcode/evolith_arch32/pull/689), merge `eb458372` en `main`. **Lo que merece llevarse no es la CVE sino el modo de fallo:** el mecanismo que se usa para cerrar un advisory es el mismo que después lo mantiene abierto, y no hay nada que vigile los pins. Mismo patrón que [`GT-691`](./gap-reference-catalog.es.md#gt-691), donde la vigilancia quedó atada a un major que no había salido; allí el elemento mal vigilado fue `@nestjs/swagger`, aquí es el propio bloque `overrides`. El defecto de que este gate no bloquee ningún merge queda registrado aparte, en [`GT-710`](./gap-reference-catalog.es.md#gt-710). | El chequeo de vulnerabilidades llevaba tres días en rojo por dos versiones que nosotros mismos habíamos fijado una por debajo del arreglo. | Que el bloque de `overrides` deje de ser el sitio donde una CVE se queda a vivir, y que el chequeo de seguridad vuelva a significar algo. | `Infra` | Cross | P2 | S | `COMPLETADO` | | [`GT-708`](./gap-reference-catalog.es.md#gt-708) | **Un concepto de gobierno que existía solo en prosa, en dos repositorios, y que una compuerta real citaba como precondición.** «KDD» nombraba dos cosas distintas y ninguna llegó a construirse: la **Fase 1.1 — Knowledge-First Discovery**, subfase opcional con su propia compuerta de preparación y siete plantillas de artefacto; y **KDD — Knowledge-Driven Development**, lectura posterior de la sesión con el dueño del 2026-07-04 (`L-009`, `D-004`) que lo convertía en sección opcional *dentro del PRD*, activable por tenant. **Medido en todas las superficies ejecutables, y ausente en todas:** `phase-gates.rules.json` tiene cinco gates para las fases 1..5; ninguno de los siete artefactos KDD está entre los 33 de `artifact-registry.json`; cero ficheros TypeScript con `KDD`/`knowledge-first`/`knowledgeBrief`/`discoveryReadiness`/`storySeed`/`epicCandidate`; el CLI tiene 31 comandos y cero menciones, y su `--phase discovery` mapea a la **fase 1 entera** (`phase-id.ts`: `f1: 'discovery'`), no a la 1.1; el servidor MCP, cero; el Tracker no tiene ni pantalla ni entidad; y `prd.schema.json` no lleva sección KDD, así que `D-004` tampoco llegó nunca a schema. **Aun así la prosa tenía dientes:** `phase-1-business-signoff.es.md` convertía *«el nivel de adopción de la Fase 1.1 ha sido declarado»* en **precondición para abrir el Gate 1**, y tres filas de su tabla de evidencia llevaban cláusulas condicionadas a niveles de KDD — una compuerta que nadie implementa bloqueando una que implementa todo el mundo. **CERRADA el 2026-08-18 por eliminación, por decisión del dueño de que Evolith Core y Tracker dejan de manejar el concepto en cualquier forma.** 16 ficheros borrados (el playbook de la Fase 1.1 y las siete plantillas, EN y ES); eliminadas la precondición del Gate 1 y sus tres cláusulas de evidencia condicionadas a KDD; eliminadas la tabla `Subfase 01.1`, la fila del índice de playbooks y las referencias a Story Seeds / Epic Candidates en el playbook de Fase 2 y en el índice de plantillas; `D-004`/`L-009` reescritas a lo que las sobrevive — el PRD es el piso canónico y el Gate 1 lo exige siempre. **La retirada es el [`ADR-0127`](../../architecture/adrs/core/0127-retire-knowledge-first-discovery.es.md), y el `ADR-0103` queda ENMENDADO por él en vez de editado:** una decisión aceptada del Architecture Board se supersede, no se reescribe, así que su razonamiento se mantiene y lo único que desapareció es su vecino. `CHANGELOG.md` y el `ADR-0103` conservan su texto sobre KDD a propósito — registran lo que era cierto cuando se escribieron, y editarlos falsificaría la historia que este repositorio guarda deliberadamente. **Falsabilidad, comprobada tras el barrido y no inferida de la lista de borrados:** toda referencia a los ocho ficheros borrados no devuelve nada fuera del ADR y del aviso de corrección, y `KDD`/`knowledge-first` solo sobreviven en los seis ficheros citados. **Lo que deja esta fila es la lección, no el barrido:** un concepto puede ser citado como precondición dura por una compuerta que todo el mundo implementa mientras no lo implementa nadie, y seguir así meses, porque nadie contrasta la prosa contra los datos. **CERRADA el 2026-08-18 — aterrizaron las dos mitades.** La del Tracker es `evolith_tracker#153` (`97e1bc8e`): `REQ-DIS-12` y `REQ-DIS-13` eliminados junto con la viñeta de gobierno de la subfase 01.1, la sección del catálogo de artefactos, la viñeta del blueprint, las cláusulas del índice de Discovery y los bloques de `.bmad-core`. **Los dos repositorios tenían la misma forma de dientes con distintas palabras:** el Core convertía *«el nivel de adopción de la Fase 1.1 ha sido declarado»* en precondición para abrir el Gate 1, y el Tracker daba al `REQ-DIS-13` el criterio de aceptación *«un resultado FAIL bloquea la apertura de la compuerta de Business Sign-Off»* — dos documentos, una compuerta inexistente, ambos bloqueando la única compuerta por la que pasa toda iniciativa. Lo que sobrevive es deliberado: `CHANGELOG.md` y el `ADR-0103` en el Core, y las dos filas del board de auditoría del Tracker, todos ellos registros de lo que era cierto cuando se escribieron. | Un concepto que describimos por todas partes y no construimos en ninguna, del que depende una de nuestras compuertas reales. | Que el modelo de cinco fases se lea igual en los documentos que en los datos, y que el Gate 1 deje de depender de una subfase que nadie puede ejecutar. | `Governance` | Cross | P2 | M | `COMPLETADO` | @@ -731,7 +733,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-706`](./gap-reference-catalog.es.md#gt-706) | **Nada asegura que los `exports` que un paquete declara resuelvan dentro de su propio tarball, así que un productor publica una subruta fantasma y solo la descubre un consumidor — una publicación demasiado tarde.** `contracts@1.1.0` declaró una subruta de export que no incluía; el fallo salió en el smoke de sala limpia de `infra-providers@1.2.1`, **después de que `core-domain@1.3.1` ya estuviera irreversiblemente en el registry**, dejando la release a medio entregar y sin despublicar posible pasadas 72 horas. La comprobación que existe es real y tiene la forma equivocada: `npm-release.yml:213` calcula «prometidos» como `[pkg.main, ...bin]`, y **`exports` no está en esa lista**. FALSABILIDAD DEMOSTRADA, OBSERVADA EN VERDE: un paquete de dos ficheros que declara `"./ingest"` con solo `dist/index.js` en disco pasa esa aserción corrida literal — `exit=0`, mientras `require pkg/ingest` responde `MODULE_NOT_FOUND`. El smoke de sala limpia tampoco lo cubre, y no es defecto suyo: resuelve lo que un paquete IMPORTA, así que el fantasma del productor es invisible hasta el turno de un consumidor, que es después del paso irreversible. Exposición: 3 de 8 paquetes publicables declaran **23 subrutas de export**, ninguna asegurada, y dos declaran además un `./*` sin cota. **ARREGLADO 2026-08-16 — `.harness/scripts/ci/67-validate-declared-exports.mjs`, corriendo en tiempo de PR sobre todos los workspaces publicables Y por paquete dentro del bucle de release, justo antes de `npm publish`.** Recoge cada hoja de texto del árbol de condiciones, así que `types` cuenta tanto como `default`, e incluye `main`/`bin`, siendo un superconjunto de la aserción que sustituye. **La propia afirmación de esta fila sobre el registry la refutó el guard en su primera corrida:** «22 de 22 resuelven, 0 fantasmas» excluía las claves con comodín por su propio filtro, y una está MUERTA — `core-domain` declara `./infrastructure/adapters/*` **sin ningún directorio `adapters`**, 0 coincidencias en un packlist de 796 ficheros, `MODULE_NOT_FOUND` en el 1.3.1 publicado, y **ningún commit de este repositorio llevó jamás ese path**. Borrada, no ampliada: nunca hubo nada detrás. Falsabilidad observada por los dos lados — rojo con la fixture `./ingest`, con `core-domain` de verdad, y con un fichero presente en disco pero excluido por `files`; verde con la misma fixture en cuanto se incluye y con el árbol entero, **68 destinos declarados en 9 paquetes**. | Un paquete puede prometer una ruta de import que nunca incluyó, y quien se entera es el siguiente paquete en publicarse. | La release se niega a publicar un manifiesto que miente, antes de que nada sea irreversible. | `Infra` | Cross | P1 | S | `COMPLETADO` | -**Progreso:** 678 / 708 completados · 3 en progreso · 1 pendiente · 26 diferidos +**Progreso:** 679 / 709 completados · 3 en progreso · 1 pendiente · 26 diferidos **Oleada 2026-06-23 (auditoría profunda de Winston III):** Añadidos 14 gaps nuevos `GT-212`…`GT-225` del Winston Audit Playbook que cubren: higiene de estado ADR (GT-212), metadata + presupuestos operativos + corpus de guías por topología (GT-213, GT-217, GT-219), observabilidad + OpenAPI en controladores REST (GT-214, GT-215), paridad de input-schemas OPA + densidad de tests por topología (GT-216, GT-222), plantillas de rollback + on-call de Fase 05 (GT-218), cobertura de ramas CLI + paridad de envelope --format + limpieza de skip-list (GT-220, GT-224, GT-225), audit logging HTTP de MCP (GT-221), y tests e2e de paridad cross-surface (GT-223). diff --git a/reference/core/control-center/gaps/gap-tracking.md b/reference/core/control-center/gaps/gap-tracking.md index 6c18efc55..027fc0e93 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -4,6 +4,7 @@ **Status:** Active Tracking **Owner:** Evolith Architecture Board +**Last Updated:** 2026-09-19 (**One gap registered and closed off the red it caused: the maturity-evidence window ran out on a README-only PR, for the second time.** `GT-711` → DONE. The four runtime checks in `maturity-evidence.json` were observed 2026-08-18 and turned stale on 2026-09-18; the first PR to learn it was [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724), a positioning change to the two READMEs, when `Validate documentation` (required) went red in `09-reconcile-maturity.mjs`. Same shape as 2026-08-18, when a promotion tripped it. The evidence was re-observed against real runs (develop `4d655745`, main `99b53259`) in `2ee3f9a0`, and the window is now **announced instead of discovered**: every reconciler run names each check that turns stale within seven days, with the date; `--freshness` turns that into an exit code; and a daily workflow opens one `maturity-evidence` issue a week before. **What is worth keeping:** the window was never the defect — a date known thirty days ahead was being learned on the day it started blocking merges, by whoever happened to open a PR.) **Last Updated:** 2026-09-05 (**Two new rows off one thread: a HIGH CVE that had been live on `main` for three days, and the reason it stopped nothing.** `GT-709` → DONE: `Security Audit` had been red since `b84523b4` not because of a dependency without a fix, but because `overrides.fast-uri` was pinned at `3.1.5`, **exactly the last vulnerable release of the 3.x line**, with the patch in `3.1.6` and inside the range `ajv` declares. Measured with the real guard, `63-validate-npm-audit-gate.mjs`: from **11 blocking rows / 7 high to 0 and 0**, with no exception declared. `GT-710` → PENDING: that gate **is not a required check**, and in the window it was red **eight** PRs were merged into `main`, four of them npm dependency changes. **What is worth keeping:** the mechanism used to close an advisory — the `overrides` entry — is the same one that later prevents closing it, and the only check that sees it blocks nothing.) **Last Updated:** 2026-08-18 (**One gap closed by the trigger it had written down for itself — which fired two days after it was written, and named the wrong release.** `GT-691` → DONE. The HIGH `js-yaml` CVE that blocked every promotion to `main` left the tree by an upgrade, not a dismissal: `@nestjs/swagger@11.4.7` — a PATCH on the `11.4.x` line the row had declared exhausted, not the stable 12 it said to wait for — declares `"js-yaml": "5.3.0"`, and `package-lock.json` now resolves `node_modules/@nestjs/swagger/node_modules/js-yaml` to it. `npm audit` goes from 1 high to 0 high / 0 critical; the row's falsifiability criterion was re-measured against the new tree rather than inherited, and neither falsifier fired. **What is worth carrying forward is the watch item, not the CVE:** the row correctly identified that the thing to watch was `@nestjs/swagger` rather than `js-yaml`, then tied that watch to a major release that had not shipped.) **Last Updated:** 2026-08-08 (**One gap closed by doing the irreversible thing the board had deliberately deferred — and the measurement taken first is what makes it worth recording.** `GT-622` → DONE, and the irreversible half the board had deferred is what closed it: the 210 orphaned `.github/workflows/ci.yml:codeql` code-scanning analyses are deleted from `refs/heads/main` (201) and `refs/heads/develop` (9), the dead key is absent from both, and the alert corpus is byte-identical across the deletion — 242 open / 82 dismissed / 60 fixed, CodeQL 75 / Scorecard 158 / Trivy 9. **Three of that row’s own claims did not survive re-measurement, and the row had already been re-measured twice without any of them surfacing, because each pass checked the COUNT and none checked the CLAIM:** the check was `neutral`, not red, from PR #250 onward; the configuration the warning names is on `refs/heads/develop`, not `main`, so only PRs into `develop` still carried it while `main` came back clean on its own from PR #420 with all 201 orphans still in place; and the 9 analyses on `develop` — the ones that mattered — were never counted. **The field that turned an owner judgement call into an easy one had never been read:** all 210 analyses carry `results_count: 0`, so what was discarded is 210 records of “scanned, found nothing” from a configuration dead since June. The irreversibility was real; the loss was not. 22 analyses under the dead key are deliberately left on `refs/pull/{4..17}/merge`, ephemeral per-PR refs that can never be the base of a PR. **Also observed while measuring, and not part of this closure:** the required-context set on `main` and `develop` is now **8**, having gained `Secret Detection (gitleaks)` — the promotion `GT-653` recorded as its one remaining item. The closure criterion that could only be observed on a PR into `develop` was observed on PR #440, the PR carrying this very closure, 105s after it opened — written after reading the check, not before. Counters recomputed from the rows: **640 / 653 done · 3 in progress · 3 pending · 7 deferred**.) @@ -21,6 +22,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | ID | Gap | In plain terms | What it fixes | Component | Phase | Criticality | Complexity | Status | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| +| [`GT-711`](./gap-reference-catalog.md#gt-711) | **The maturity-evidence window closes on a date known thirty days ahead, and the first to learn it was whoever opened a PR that morning.** `validateRuntimeEvidence` in `09-reconcile-maturity.mjs` rejects any of the four runtime checks in `maturity-evidence.json` once `observedAt` is more than 30 days old, and `Validate documentation` — a required context on `main` and `develop` — runs it with `--check`. The window is right: evidence that ages out is re-taken, not extended. What was wrong is that nothing looked at the calendar before the day it mattered. Measured twice: on 2026-08-18 the `documentation` check crossed 31 days and turned a promotion red (recorded in the evidence file itself), and on 2026-09-19 all four — observed 2026-08-18, stale since 2026-09-18 — turned [#724](https://github.com/beyondnetcode/evolith_arch32/pull/724) red, a README positioning change, after the develop run of 2026-09-15 had passed at 28 days. **CLOSED 2026-09-19** in `30065070`: `assessEvidenceFreshness` names, per check, the first day `validateRuntimeEvidence` will reject it (same constant, same arithmetic); every reconciler run warns with that date once a check is within `EVIDENCE_WARN_DAYS = 7`; `--freshness [--now=YYYY-MM-DD]` exits 1 inside the band or past it; and `maturity-evidence-freshness.yml` runs it daily at 06:45 UTC against `develop`, opening one `maturity-evidence` issue that closes itself once re-observed. The re-observation itself landed in `2ee3f9a0` (PR #724). **Not changed on purpose:** the 30-day window, and the rule that a re-observation is a new observation, never a date bump. | A 30-day clock on our own evidence ran out twice on people who had touched none of it, on the day it started blocking merges. | Learn the expiry a week ahead, in an issue, instead of on the day, in a red required check on an unrelated PR. | `Infra` | Cross | P2 | S | `DONE` | | [`GT-710`](./gap-reference-catalog.md#gt-710) | **The gate that measures CVEs is not a required check, so eight merges went past it while it was red.** The nine required contexts on `main` and `develop` are `CodeQL SAST`, `Secret Detection (gitleaks)`, `Services build (GHCR)`, `Test`, `Test core`, `Test core-api`, `Test core-domain`, `Test mcp-server` and `Validate documentation`. **`Security Audit` is not among them**, and neither are `Trivy` or `build-and-test`. Measured 2026-09-05: between `b84523b4` (Sep 2), the commit where `Security Audit` turned red, and its fix in [`GT-709`](./gap-reference-catalog.md#gt-709), **eight pull requests were merged into `main` with the gate red** — four of them ([#664](https://github.com/beyondnetcode/evolith_arch32/pull/664), [#665](https://github.com/beyondnetcode/evolith_arch32/pull/665), [#666](https://github.com/beyondnetcode/evolith_arch32/pull/666), [#667](https://github.com/beyondnetcode/evolith_arch32/pull/667)) npm dependency changes, precisely the class of change that gate exists to judge. GitHub renders them `UNSTABLE` rather than `BLOCKED`, so ordinary review merges them without friction. **The workflow names this failure mode in its own words:** the comment in `sdk-cli-ci.yml` says a permanently red check trains reviewers to discount red, and then leaves the check out of the required set, which is the most direct way to guarantee exactly that. **Partly applied 2026-09-05:** `Security Audit` is now required on `main` and `develop` (10 contexts, verified), and the owner decided `Trivy` and `build-and-test` should be too. It stays open because **this row's falsifier has not fired yet** — there is no live high advisory to observe coming out `BLOCKED` rather than `UNSTABLE` — and because `build-and-test` **cannot be required as it stands**: it lives in `sdk-cli-release.yml`, whose `pull_request` trigger is path-filtered, and a required check behind a filter never reports, leaving the PR unmergeable with everything green. Registered separately from [`GT-709`](./gap-reference-catalog.md#gt-709) on purpose: that one was a CVE with a two-line fix, this is why the CVE could live for three days without stopping anything. | We have a security check that measures correctly and prevents nothing; eight changes landed while it was red. | Make an undeclared HIGH advisory block the merge instead of merely reporting it. | `Infra` | Cross | P1 | S | `PENDING` | | [`GT-709`](./gap-reference-catalog.md#gt-709) | **An `overrides` pin added to close an advisory becomes the ceiling that prevents closing it the next time.** `Security Audit` had been red on `main` since `b84523b4` (2026-09-02) over `GHSA-jqff-g426-hqxp`, a HIGH CVE in `fast-uri` — and the cause was not a dependency without an upstream fix, but **two pins of our own left below the patched version**, which the gate reports in the same shape as somebody else's advisory. `overrides.fast-uri` was pinned at `3.1.5`, **exactly the last vulnerable release of the 3.x line**; the patch is `3.1.6`, inside the `^3.0.1` that `ajv@8.20.0` declares, so the fix fitted in the pin that was already there and nobody looked because it read as settled configuration. **Measured with `63-validate-npm-audit-gate.mjs`, the same guard CI runs, not inferred from changelogs:** `origin/main` reported **11 blocking rows / 7 high**; with `fast-uri` at `3.1.7`, 9 of the 11 go — its own four advisories plus the five `ajv`/`commitlint` chain rows that arrived *via* `fast-uri`. The remaining two were `browserslist` `4.28.4`, a dev-only transitive (`ts-jest`→`@babel/core`, `@nestjs/cli`→`webpack`) fixed in `4.28.7`: same pattern, same kind of override, pinned to `4.28.9`. **Result: 0 blocking rows, 0 high.** The moderate `qs` advisory survives on purpose — the gate does not block below HIGH. **CLOSED 2026-09-05** by [#689](https://github.com/beyondnetcode/evolith_arch32/pull/689), merge `eb458372` on `main`. **What is worth keeping is not the CVE but the failure mode:** the mechanism used to close an advisory is the same one that later holds it open, and nothing watches the pins. Same pattern as [`GT-691`](./gap-reference-catalog.md#gt-691), where the watch was tied to a major that had not shipped; there the mis-watched item was `@nestjs/swagger`, here it is the `overrides` block itself. That this gate blocks no merge at all is registered separately, as [`GT-710`](./gap-reference-catalog.md#gt-710). | The vulnerability check sat red for three days because of two versions we had ourselves pinned one release below the fix. | Stop the `overrides` block being the place a CVE settles in, and make the security check mean something again. | `Infra` | Cross | P2 | S | `DONE` | | [`GT-708`](./gap-reference-catalog.md#gt-708) | **A governance concept that existed only in prose, in two repositories, and was cited as a precondition by a gate that does exist.** «KDD» named two different things and neither was ever built: **Phase 1.1 — Knowledge-First Discovery**, an optional subphase with its own readiness gate and seven artifact templates; and **KDD — Knowledge-Driven Development**, a later reading from the 2026-07-04 owner session (`L-009`, `D-004`) that made it an optional section *inside the PRD*, activated per tenant. **Measured across every executable surface, and absent from all of them:** `phase-gates.rules.json` has five gates for phases 1..5; none of the seven KDD artifacts is among the 33 in `artifact-registry.json`; zero TypeScript files match `KDD`/`knowledge-first`/`knowledgeBrief`/`discoveryReadiness`/`storySeed`/`epicCandidate`; the CLI has 31 commands and zero mentions, and its `--phase discovery` maps to **phase 1 entire** (`phase-id.ts`: `f1: 'discovery'`), not to 1.1; the MCP server has zero; the Tracker has no screen and no entity; and `prd.schema.json` carries no KDD section, so `D-004` never reached a schema either. **The prose had teeth anyway:** `phase-1-business-signoff.md` made *"Phase 1.1 adoption level has been declared"* a **precondition for opening Gate 1**, and three rows of its evidence table carried clauses keyed to KDD levels — a gate nothing implements blocking a gate everything implements. **CLOSED 2026-08-18 by removal, on the owner's decision that Evolith Core and Tracker no longer carry the concept in any form.** 16 files deleted (the Phase 1.1 playbook and the seven artifact templates, EN and ES); Gate 1's precondition and its three KDD-keyed evidence clauses removed; the `Subphase 01.1` table, the playbook index row and the Story-Seed/Epic-Candidate references in the Phase 2 playbook and template index removed; `D-004`/`L-009` rewritten to what survives them — the PRD is the canonical floor and Gate 1 always requires it. **The retirement is [`ADR-0127`](../../architecture/adrs/core/0127-retire-knowledge-first-discovery.md), and `ADR-0103` is AMENDED by it rather than edited:** an accepted Architecture Board decision is superseded, not rewritten, so its reasoning stands and only its neighbour is gone. `CHANGELOG.md` and `ADR-0103` keep their KDD text on purpose — they record what was true when written, and editing them would falsify the history this repository keeps deliberately. **Falsifiability, checked after the sweep rather than inferred from the delete list:** every reference to the eight deleted filenames returns nothing outside the ADR and the correction notice, and `KDD`/`knowledge-first` survive only in the six files named above. **What this row leaves behind is the lesson, not the sweep:** a concept can be cited as a hard precondition by a gate that everything implements while being implemented by nothing, and stay that way for months, because nobody diffs the prose against the data. **CLOSED 2026-08-18 — both halves landed.** The Tracker half is `evolith_tracker#153` (`97e1bc8e`): `REQ-DIS-12` and `REQ-DIS-13` removed along with the subphase-01.1 governance bullet, the artifact-catalogue section, the blueprint bullet, the Discovery index clauses and the `.bmad-core` blocks. **Both repositories had the same shape of teeth in different words:** the Core made *"Phase 1.1 adoption level has been declared"* a precondition for opening Gate 1, and the Tracker gave `REQ-DIS-13` the acceptance criterion *"a FAIL result blocks opening the Business Sign-Off gate"* — two documents, one non-existent gate, both blocking the only gate every initiative must pass. What survives is deliberate: `CHANGELOG.md` and `ADR-0103` in the Core, and the Tracker's two audit-board rows, all of them records of what was true when written. | A concept we describe everywhere and have built nowhere, which one of our real gates depends on. | The five-phase model reads the same in the docs as in the data, and Gate 1 stops depending on a subphase nobody can execute. | `Governance` | Cross | P2 | M | `DONE` | @@ -731,7 +733,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-706`](./gap-reference-catalog.md#gt-706) | **Nothing asserts that a package's own declared `exports` resolve inside its own tarball, so a producer publishes a phantom subpath and only a consumer discovers it — one publish too late.** `contracts@1.1.0` declared an export subpath it did not ship; the failure surfaced at `infra-providers@1.2.1`'s clean-room smoke, **after `core-domain@1.3.1` was already irreversibly on the registry**, leaving the release half-shipped with no unpublish available after 72 hours. The check that exists is real and the wrong shape: `npm-release.yml:213` computes "promised" as `[pkg.main, ...bin]`, and **`exports` is not in that list**. PROVEN FALSIFIABLE, OBSERVED GREEN: a two-file package declaring `"./ingest"` with only `dist/index.js` on disk passes that assertion run verbatim — `exit=0`, while `require pkg/ingest` answers `MODULE_NOT_FOUND`. The clean-room smoke does not cover it either, and that is not its defect: it resolves what a package IMPORTS, so a producer's phantom is invisible until a consumer's turn, which is after the irreversible step. Exposure: 3 of 8 publishable packages declare **23 export subpaths**, none asserted, two of them also declaring an unbounded `./*`. **FIXED 2026-08-16 — `.harness/scripts/ci/67-validate-declared-exports.mjs`, run at PR time over every publishable workspace AND per package inside the release loop, immediately before `npm publish`.** It collects every string leaf of the condition tree, so `types` counts as much as `default`, and folds in `main`/`bin`, making it a superset of the assertion it replaces. **The row's own claim about the registry was refuted by the guard on its first run:** "22 of 22 resolve, 0 phantom" excluded wildcard keys by its own filter, and one is DEAD — `core-domain` declares `./infrastructure/adapters/*` with **no `adapters` directory at all**, 0 matches in a 796-file packlist, `MODULE_NOT_FOUND` on the published 1.3.1, and **no commit in this repository ever carried that path**. Deleted, not widened: there was never anything behind it. Falsifiability observed on both sides — red on the `./ingest` fixture, on `core-domain` for real, and on a file present on disk but excluded by `files`; green on the same fixture once it ships and on the whole tree, **68 declared targets across 9 packages**. | A package can promise an import path it never shipped, and the next package to publish is the one that finds out. | The release refuses to publish a manifest that lies, before anything becomes irreversible. | `Infra` | Cross | P1 | S | `DONE` | -**Progress:** 678 / 708 done · 3 in progress · 1 pending · 26 deferred +**Progress:** 679 / 709 done · 3 in progress · 1 pending · 26 deferred **Wave 2026-06-23 (Winston deep audit III):** Added 14 new gaps `GT-212`…`GT-225` from the Winston Audit Playbook covering: ADR status hygiene (GT-212), topology manifest metadata + operational budgets + guidance corpus (GT-213, GT-217, GT-219), REST controller observability + OpenAPI (GT-214, GT-215), OPA input-schema parity + per-topology test density (GT-216, GT-222), SDLC Phase 05 rollback + on-call templates (GT-218), CLI branch coverage + envelope format coverage + skip-list cleanup (GT-220, GT-224, GT-225), MCP HTTP audit logging (GT-221), and cross-surface parity e2e tests (GT-223). diff --git a/reference/core/control-center/maturity-reports/executive-summary.es.md b/reference/core/control-center/maturity-reports/executive-summary.es.md index 744f3ed6a..1aeef5f6d 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.es.md +++ b/reference/core/control-center/maturity-reports/executive-summary.es.md @@ -41,15 +41,15 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so | Indicador | Valor | |---|---:| -| Fecha canónica del tablero | 2026-09-05 | -| Gaps totales | 708 | -| Gaps cerrados | 678 | +| Fecha canónica del tablero | 2026-09-19 | +| Gaps totales | 709 | +| Gaps cerrados | 679 | | Gaps pendientes | 30 | | P0 abiertos | 1 | | P1 abiertos | 9 | | P2 abiertos | 16 | | Cierre total | 95.8% | -| Registros de evidencia de cierre | 660 | +| Registros de evidencia de cierre | 661 | | Readiness registrado | 4 PASS | | Área | Pendientes | P0 | P1 | Primeros IDs | diff --git a/reference/core/control-center/maturity-reports/executive-summary.md b/reference/core/control-center/maturity-reports/executive-summary.md index a68804bbf..a21538a88 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.md +++ b/reference/core/control-center/maturity-reports/executive-summary.md @@ -41,15 +41,15 @@ Use this summary with a simple rule: if you need context, open only the linked I | Indicator | Value | |---|---:| -| Canonical board date | 2026-09-05 | -| Total gaps | 708 | -| Closed gaps | 678 | +| Canonical board date | 2026-09-19 | +| Total gaps | 709 | +| Closed gaps | 679 | | Open gaps | 30 | | Open P0 | 1 | | Open P1 | 9 | | Open P2 | 16 | | Total closure | 95.8% | -| Closure evidence records | 660 | +| Closure evidence records | 661 | | Recorded readiness | 4 PASS | | Area | Open | P0 | P1 | First IDs | diff --git a/reference/core/control-center/maturity-reports/maturity-evidence.json b/reference/core/control-center/maturity-reports/maturity-evidence.json index 8282c890b..c94684bb2 100644 --- a/reference/core/control-center/maturity-reports/maturity-evidence.json +++ b/reference/core/control-center/maturity-reports/maturity-evidence.json @@ -1,6 +1,6 @@ { "schemaVersion": "1.0.0", - "asOf": "2026-09-05", + "asOf": "2026-09-19", "checks": [ { "id": "cli-baseline", diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index b83fed5d5..e900a999a 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -1,16 +1,16 @@ { "schemaVersion": "1.0.0", "scope": "evolith-core", - "asOf": "2026-09-05", + "asOf": "2026-09-19", "gaps": { - "total": 708, - "done": 678, + "total": 709, + "done": 679, "pending": 1, "inProgress": 3, "deferred": 26 }, "evidence": { - "closureRecords": 660, + "closureRecords": 661, "cliPackage": "@beyondnet/evolith-cli@1.3.2", "adrCount": 144, "rulesetCount": 184, diff --git a/src/packages/core-domain/src/application/services/project-scaffolder.service.ts b/src/packages/core-domain/src/application/services/project-scaffolder.service.ts index 2d46f9504..b309582b7 100644 --- a/src/packages/core-domain/src/application/services/project-scaffolder.service.ts +++ b/src/packages/core-domain/src/application/services/project-scaffolder.service.ts @@ -1,6 +1,7 @@ import { IFileSystem } from '../../domain/interfaces'; import { IPlatformProviders } from '../ports/platform-detection.port'; import { InitProjectInput } from './use-case.types'; +import * as path from 'path'; /** * The commit types GIT-08 itself enumerates, in its own `pattern`. Kept in the @@ -185,7 +186,11 @@ evolith sdlc gate-status `; - await this.fs.writeFile(`${projectDir}/${input.name}.csproj`, csproj); + // The .csproj takes its name from the directory the use case already + // validated and built (`${cwd}/${name}`), not from a second read of + // `input.name`: same string, but CodeQL only sees the sanitizer on the + // variable, and this property read kept js/path-injection open on the sink. + await this.fs.writeFile(`${projectDir}/${path.basename(projectDir)}.csproj`, csproj); await this.fs.ensureDir(`${projectDir}/src`); } diff --git a/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.spec.ts b/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.spec.ts index 1bca52413..48a707050 100644 --- a/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.spec.ts +++ b/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.spec.ts @@ -83,10 +83,34 @@ describe('InitializeProjectUseCase · the name is a directory, not a path (CWE-2 }, ); + it('names the .csproj after the validated directory, never a second read of input.name', async () => { + const { fs, root } = await init({ name: 'Billing.Api', runtime: 'dotnet' }); + expect(fs.files.has(`${root}/Billing.Api.csproj`)).toBe(true); + }); + it('keeps accepting the dotted and dashed names npm does', async () => { const { root } = await init({ name: 'acme.billing-api_v2' }); expect(root).toBe('/tmp/acme.billing-api_v2'); }); + + it('scaffolds with the name the guard checked, even if the request object changes its answer afterwards', async () => { + // The guard reads `input.name` once. Every later read must see the SAME string, + // not a fresh read of a caller-controlled object: a getter that answers "fine" + // to the guard and "../escape" to the scaffolder is the shape of a TOCTOU, and + // it is also the flow CodeQL reported (alert #226: `${projectDir}/${input.name}.csproj`). + let reads = 0; + const shifty = Object.defineProperty({ ...INPUT, runtime: 'dotnet' }, 'name', { + enumerable: true, + get: () => (reads++ === 0 ? 'honest' : '../escape'), + }); + const fs = memoryFs(); + const result = await new InitializeProjectUseCase(fs, catalogLoader).execute(shifty as any, '/tmp'); + expect(result.success).toBe(true); + const written = [...fs.files.keys()]; + expect(written).toContain('/tmp/honest/honest.csproj'); + expect(written.some((f) => f.includes('..'))).toBe(false); + expect(result.artifacts.every((a) => a.startsWith('honest/'))).toBe(true); + }); }); describe('InitializeProjectUseCase · GIT-08 — the scaffold enforces what it mandates', () => { diff --git a/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.ts b/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.ts index 8747e03f4..84fdc05cf 100644 --- a/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.ts +++ b/src/packages/core-domain/src/application/use-cases/initialize-project.use-case.ts @@ -65,23 +65,30 @@ export class InitializeProjectUseCase { return { success: false, artifacts, warnings, errors }; } + // The guard above sanitises the local `name`, not the `name` field inside + // `input`: CodeQL tracks the object's field separately, and the scaffolder + // reads `input.name` again to name files (`${projectDir}/${input.name}.csproj`). + // Rebuilding the input from the checked value is what makes every downstream + // read provably the same string the guard accepted — alert #226 on + // node-filesystem.provider.ts:50 was exactly that second read. + const safeInput: InitProjectInput = { ...input, name }; const projectDir = `${cwd}/${name}`; await this.fs.ensureDir(projectDir); - await this.projectScaffolder.scaffoldEvolithYaml(input, projectDir); - artifacts.push(`${input.name}/evolith.yaml`); + await this.projectScaffolder.scaffoldEvolithYaml(safeInput, projectDir); + artifacts.push(`${name}/evolith.yaml`); - await this.projectScaffolder.scaffoldReadme(input, projectDir); - artifacts.push(`${input.name}/README.md`, `${input.name}/README.es.md`); + await this.projectScaffolder.scaffoldReadme(safeInput, projectDir); + artifacts.push(`${name}/README.md`, `${name}/README.es.md`); - await this.projectScaffolder.scaffoldByRuntime(input, projectDir); - artifacts.push(`${input.name}/package.json`); + await this.projectScaffolder.scaffoldByRuntime(safeInput, projectDir); + artifacts.push(`${name}/package.json`); // GIT-08 — after the runtime scaffold, so the commitlint devDependencies // merge into the package.json that scaffold just wrote rather than racing it. - const commitArtifacts = await this.projectScaffolder.scaffoldCommitConventions(input, projectDir); + const commitArtifacts = await this.projectScaffolder.scaffoldCommitConventions(safeInput, projectDir); for (const artifact of commitArtifacts) { - const qualified = `${input.name}/${artifact}`; + const qualified = `${name}/${artifact}`; if (!artifacts.includes(qualified)) artifacts.push(qualified); } if (!input.features.includes('hooks')) { @@ -95,19 +102,19 @@ export class InitializeProjectUseCase { if (input.features.includes('adr')) { await this.fs.ensureDir(`${projectDir}/reference/architecture/adrs`); await this.fs.writeJson(`${projectDir}/reference/architecture/adrs/adr-matrix.json`, { adrs: [] }); - artifacts.push(`${input.name}/reference/architecture/adrs/adr-matrix.json`); + artifacts.push(`${name}/reference/architecture/adrs/adr-matrix.json`); } if (input.features.includes('hooks')) { await this.fs.ensureDir(`${projectDir}/.husky`); await this.fs.writeFile(`${projectDir}/.husky/pre-commit`, '#!/bin/sh\nevolution validate --pre-commit\n'); - artifacts.push(`${input.name}/.husky/pre-commit`); + artifacts.push(`${name}/.husky/pre-commit`); } if (input.features.includes('acl')) { await this.fs.ensureDir(`${projectDir}/rulesets/acl`); await this.fs.writeJson(`${projectDir}/rulesets/acl/anti-corruption-layer.rules.json`, { version: '1.0.0', principles: [] }); - artifacts.push(`${input.name}/rulesets/acl/anti-corruption-layer.rules.json`); + artifacts.push(`${name}/rulesets/acl/anti-corruption-layer.rules.json`); } const platformCheck = await this.projectScaffolder.checkRuntimePlatform(input.runtime);