diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index f054bce5..b238560b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -445,10 +445,22 @@ jobs: - name: No security fix is committed but unpublished run: node .harness/scripts/ci/48-validate-security-publish-lag.mjs --verbose + # GT-638: a gap id is chosen by reading the highest GT-* on whichever branch + # you are on, so two parallel sessions allocate the same number and find out + # at merge time. `8449af3d` had to renumber GT-634 -> GT-637 for exactly this. + # The comparison needs the BASE branch, which a shallow checkout does not + # have — fetch it explicitly rather than letting the guard fail for the wrong + # reason. + - name: One gap id, one gap + run: | + git fetch --no-tags --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}" + node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose + - name: Self-tests for the governance guards run: | node --test .harness/scripts/ci/42-validate-guard-denominators.test.mjs node --test .harness/scripts/ci/48-validate-security-publish-lag.test.mjs + node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs node --test .harness/scripts/ci/41-validate-evidence-commands.test.mjs node --test .harness/scripts/ci/43-validate-guard-negative-fixtures.test.mjs node --test .harness/scripts/ci/44-validate-adr-implementation-status.test.mjs diff --git a/.harness/scripts/ci/49-validate-gap-id-allocation.mjs b/.harness/scripts/ci/49-validate-gap-id-allocation.mjs new file mode 100644 index 00000000..dc8c32ad --- /dev/null +++ b/.harness/scripts/ci/49-validate-gap-id-allocation.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +/** + * GT-638 — a gap id must not mean two different things on two branches. + * + * ## The defect + * + * A gap id is chosen by reading the highest `GT-*` on whichever branch you happen + * to be on, and nothing contrasts that with any other branch. Two sessions working + * in parallel therefore allocate the same number, and one of them finds out at + * merge time. It happened on 2026-07-30: `8449af3d` carries the message + * "renumber the ratchet fix GT-634 -> GT-637, ID collision with develop". + * + * The renumber is manual and LOSSY, which costs more than the clash: an id lives + * in the board row, the catalog anchor, the closure-evidence record, + * cross-references from other rows in both languages, commit messages and PR + * bodies — and only the first three are mechanically checkable. + * + * `08-validate-tracking` cannot help, and not for want of trying: it validates + * EN/ES parity, closure records and counters WITHIN ONE WORKING TREE, and branches + * are outside its world by construction. This guard is the part that needs two. + * + * ## What it checks + * + * For every `GT-*` id declared in the catalog on HEAD, compare its **Title:** + * against the title the same id carries on the BASE branch: + * + * - id absent from base -> newly allocated, fine + * - id present, same title -> the same gap, edited; fine + * - id present, DIFFERENT title -> COLLISION: one number, two gaps + * + * The title is the discriminator on purpose. "The id already exists on base" is + * the normal case for 600-odd rows and says nothing; what makes it a defect is the + * id naming a different gap on each side. Evidence, status and criteria are all + * expected to change on a branch — a title change on an id that exists on both + * sides is either a collision or a deliberate retitle, and both deserve a human + * looking at them. + * + * ## Anti-vacuous pass + * + * Zero ids parsed on either side is a hard failure: a moved file or a reshaped + * heading must not read as "no collisions". So is a base branch that cannot be + * resolved — unable to answer is not the same as nothing to report, and a guard + * that quietly skips is how the id collision reached `main` in the first place. + * + * USAGE + * node .harness/scripts/ci/49-validate-gap-id-allocation.mjs + * node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --base origin/develop + * node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --root --verbose + * + * EXIT CODES + * 0 every id on HEAD names the same gap it names on the base branch + * 1 a collision, an unresolvable base, or a vacuous scan + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const GUARD = '49-validate-gap-id-allocation'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '../../..'); + +/** The catalog is where a row's title lives; the board carries prose, not a title. */ +export const CATALOG = 'reference/core/control-center/gaps/gap-reference-catalog.md'; + +/** + * The base to compare against, in the order CI and a laptop actually mean it. + * + * On a pull request the base is `GITHUB_BASE_REF`; locally the honest default is + * the default branch. Both are resolved through the remote, because a local + * `main` can be arbitrarily stale and comparing against a stale base is how a + * collision passes. + */ +export function defaultBase(env = process.env) { + if (env.GITHUB_BASE_REF) return `origin/${env.GITHUB_BASE_REF}`; + return 'origin/main'; +} + +// --------------------------------------------------------------------------- +// Pure core +// --------------------------------------------------------------------------- + +/** + * `GT-NNN -> title`, from a catalog document. + * + * @param {string} markdown + * @returns {Map} + */ +export function parseCatalogTitles(markdown) { + const titles = new Map(); + const sections = String(markdown).split(/^#### (GT-\d+)\s*$/m); + // split yields [preamble, id, body, id, body, ...] + for (let i = 1; i < sections.length; i += 2) { + const id = sections[i]; + const body = sections[i + 1] ?? ''; + const m = /^\*\*Title:\*\*\s*(.+?)\s*$/m.exec(body); + if (m) titles.set(id, m[1]); + } + return titles; +} + +/** + * Ids that name a DIFFERENT gap on each side. + * + * @param {Map} base + * @param {Map} head + * @returns {Array<{id: string, baseTitle: string, headTitle: string}>} + */ +export function findIdCollisions(base, head) { + const collisions = []; + for (const [id, headTitle] of head) { + const baseTitle = base.get(id); + if (baseTitle === undefined) continue; // newly allocated + if (baseTitle !== headTitle) collisions.push({ id, baseTitle, headTitle }); + } + return collisions.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** Ids this branch allocates that the base has never seen. */ +export function newlyAllocated(base, head) { + return [...head.keys()].filter((id) => !base.has(id)).sort(); +} + +// --------------------------------------------------------------------------- +// I/O edges +// --------------------------------------------------------------------------- + +function fail(lines) { + console.error(`\n✗ ${GUARD}: ${lines[0]}`); + for (const l of lines.slice(1)) console.error(` ${l}`); + process.exit(1); +} + +function readAtRef(root, ref, file) { + try { + return execFileSync('git', ['show', `${ref}:${file}`], { + cwd: root, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + return null; + } +} + +function main(argv) { + const rootIdx = argv.indexOf('--root'); + const root = rootIdx !== -1 ? path.resolve(process.cwd(), argv[rootIdx + 1]) : REPO_ROOT; + const baseIdx = argv.indexOf('--base'); + const base = baseIdx !== -1 ? argv[baseIdx + 1] : defaultBase(); + const verbose = argv.includes('--verbose'); + + const headPath = path.join(root, CATALOG); + if (!fs.existsSync(headPath)) { + fail([ + `the catalog is not where this guard looks: ${CATALOG}`, + 'A moved file must not read as "no collisions".', + ]); + } + const headDoc = fs.readFileSync(headPath, 'utf8'); + + const baseDoc = readAtRef(root, base, CATALOG); + if (baseDoc === null) { + fail([ + `cannot read the catalog at the base ref \`${base}\`, so no comparison was made.`, + ' In CI the base comes from GITHUB_BASE_REF; locally it defaults to origin/main.', + ' Fetch it first: git fetch origin', + '', + ' Unable to answer is not the same as nothing to report. A guard that skipped', + ' quietly here is how an id collision reached main in the first place.', + ]); + } + + const headTitles = parseCatalogTitles(headDoc); + const baseTitles = parseCatalogTitles(baseDoc); + + if (headTitles.size === 0 || baseTitles.size === 0) { + fail([ + `parsed ${headTitles.size} id(s) on HEAD and ${baseTitles.size} on ${base} — at least one side yielded NOTHING.`, + 'The `#### GT-NNN` / `**Title:**` shape moved. Fix this parser rather than deleting the check.', + ]); + } + + const collisions = findIdCollisions(baseTitles, headTitles); + const fresh = newlyAllocated(baseTitles, headTitles); + + console.log(`${GUARD} — one gap id, one gap`); + console.log(` base ................ ${base}`); + console.log(` ids on base ......... ${baseTitles.size}`); + console.log(` ids on HEAD ......... ${headTitles.size}`); + console.log(` newly allocated ..... ${fresh.length}${fresh.length ? ` (${fresh.join(', ')})` : ''}`); + console.log(` collisions .......... ${collisions.length}`); + + if (verbose && fresh.length) { + for (const id of fresh) console.log(` · ${id} is new here — check it against every OTHER open branch, not just this base`); + } + + if (collisions.length > 0) { + fail([ + `${collisions.length} gap id(s) name a DIFFERENT gap on each side:`, + ...collisions.flatMap((c) => [ + ` • ${c.id}`, + ` on ${base}: ${c.baseTitle}`, + ` on HEAD: ${c.headTitle}`, + ]), + '', + ' Two sessions allocated the same number, or a row was retitled. If it is a', + ' collision, renumber the NEWER row and update every place the id appears:', + ' the board row, the catalog anchor, the closure-evidence record and the', + ' cross-references in BOTH languages. If it is a deliberate retitle, say so', + ' in the commit — this guard cannot tell the two apart, and should not guess.', + '', + ' Allocate a new id from the UNION of ids across branches, never the maximum', + ' on one:', + ' { git show origin/main:; git show origin/develop:; } \\', + ' | grep -oE "GT-[0-9]{3}" | sort -u | tail -1', + ]); + } + + console.log(`\n✓ ${GUARD}: every one of ${headTitles.size} id(s) names the same gap it names on ${base}.`); + return 0; +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) process.exit(main(process.argv.slice(2))); diff --git a/.harness/scripts/ci/49-validate-gap-id-allocation.test.mjs b/.harness/scripts/ci/49-validate-gap-id-allocation.test.mjs new file mode 100644 index 00000000..8d559fe4 --- /dev/null +++ b/.harness/scripts/ci/49-validate-gap-id-allocation.test.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node + +/** + * GT-638 — fixtures for the gap-id allocation guard. + * + * The case that matters is the one this repository lived through: two branches + * allocate `GT-634`, each for a different gap, and nothing says so until the + * merge. If that fixture ever goes green the guard is decoration — which is why + * GT-638's third criterion asks for a fixture that has been OBSERVED red rather + * than a guard someone believes works. + * + * The integration fixtures build real git repositories, because the comparison is + * `git show :` and a mock of that would only prove the mock. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync, execFileSync } from 'node:child_process'; + +import { + parseCatalogTitles, + findIdCollisions, + newlyAllocated, + defaultBase, + CATALOG, +} from './49-validate-gap-id-allocation.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const GUARD = resolve(__dirname, '49-validate-gap-id-allocation.mjs'); + +let sandbox; +before(() => { sandbox = mkdtempSync(join(tmpdir(), 'gt638-')); }); +after(() => { if (sandbox) rmSync(sandbox, { recursive: true, force: true }); }); + +const run = (root, extra = []) => { + const r = spawnSync(process.execPath, [GUARD, '--root', root, ...extra], { + encoding: 'utf8', timeout: 120000, + }); + return { status: r.status, out: `${r.stdout}\n${r.stderr}` }; +}; + +const catalog = (entries) => + '# Catalog\n\n' + + entries + .map(([id, title, evidence = 'something']) => `#### ${id}\n\n**Title:** ${title}\n\n- **Evidence:** ${evidence}\n`) + .join('\n'); + +// --------------------------------------------------------------------------- +// Pure core +// --------------------------------------------------------------------------- + +describe('parseCatalogTitles', () => { + it('reads one title per id', () => { + const t = parseCatalogTitles(catalog([['GT-001', 'first'], ['GT-002', 'second']])); + assert.equal(t.size, 2); + assert.equal(t.get('GT-002'), 'second'); + }); + + it('yields nothing when the shape moves — the caller turns that into a failure', () => { + assert.equal(parseCatalogTitles('### GT-001\n\nTitle: no bold, no match\n').size, 0); + }); +}); + +describe('findIdCollisions', () => { + const base = new Map([['GT-001', 'a'], ['GT-002', 'b']]); + + it('THE CASE: same id, different gap', () => { + const found = findIdCollisions(base, new Map([['GT-002', 'something else entirely']])); + assert.equal(found.length, 1); + assert.equal(found[0].id, 'GT-002'); + assert.equal(found[0].baseTitle, 'b'); + }); + + it('an id edited but not renamed is NOT a collision', () => { + assert.deepEqual(findIdCollisions(base, new Map([['GT-001', 'a']])), []); + }); + + it('a newly allocated id is not a collision, and is reported separately', () => { + const head = new Map([['GT-001', 'a'], ['GT-003', 'new']]); + assert.deepEqual(findIdCollisions(base, head), []); + assert.deepEqual(newlyAllocated(base, head), ['GT-003']); + }); +}); + +describe('defaultBase', () => { + it('uses the PR base in CI', () => { + assert.equal(defaultBase({ GITHUB_BASE_REF: 'develop' }), 'origin/develop'); + }); + + it('falls back to the remote default branch, never a local one', () => { + // A local `main` can be arbitrarily stale, and comparing against a stale base + // is how a collision passes. + assert.equal(defaultBase({}), 'origin/main'); + }); +}); + +// --------------------------------------------------------------------------- +// Integration, over real git history +// --------------------------------------------------------------------------- + +/** A repo with the catalog committed on `base`, then rewritten on the checked-out branch. */ +const repoWith = (name, baseEntries, headEntries) => { + const root = join(sandbox, name); + mkdirSync(join(root, dirname(CATALOG)), { recursive: true }); + const g = (...args) => execFileSync('git', args, { cwd: root, stdio: 'ignore' }); + + g('init', '-q', '-b', 'base'); + g('config', 'user.email', 'f@example.com'); + g('config', 'user.name', 'f'); + writeFileSync(join(root, CATALOG), catalog(baseEntries)); + g('add', '-A'); + g('commit', '-q', '-m', 'base catalog'); + + g('checkout', '-q', '-b', 'work'); + writeFileSync(join(root, CATALOG), catalog(headEntries)); + g('add', '-A'); + g('commit', '-q', '-m', 'branch catalog'); + return root; +}; + +describe('the collision this guard exists for', () => { + it('THE FIXTURE: two branches allocate GT-634 for different gaps — RED', () => { + const root = repoWith( + 'collision', + [['GT-633', 'a guard that compared the snapshot with itself'], ['GT-634', 'the dead-reference ratchet is stuck']], + [['GT-633', 'a guard that compared the snapshot with itself'], ['GT-634', 'the published CLI resolves a stale SDK']], + ); + const { status, out } = run(root, ['--base', 'base']); + assert.equal(status, 1, out); + assert.match(out, /1 gap id\(s\) name a DIFFERENT gap on each side/); + assert.match(out, /GT-634/); + // It must name BOTH sides, or resolving it is a search. + assert.match(out, /the dead-reference ratchet is stuck/); + assert.match(out, /the published CLI resolves a stale SDK/); + // And it must not guess which one is wrong. + assert.match(out, /this guard cannot tell the two apart, and should not guess/); + }); + + it('a branch that only ADDS an id is green', () => { + const root = repoWith('new-id', [['GT-633', 'x']], [['GT-633', 'x'], ['GT-634', 'y']]); + const { status, out } = run(root, ['--base', 'base', '--verbose']); + assert.equal(status, 0, out); + assert.match(out, /newly allocated \.+ 1 \(GT-634\)/); + // The one thing this guard cannot see, said out loud rather than implied. + assert.match(out, /check it against every OTHER open branch/); + }); + + it('a branch that edits a row without retitling it is green', () => { + const root = repoWith('edited', [['GT-633', 'x', 'first telling']], [['GT-633', 'x', 'rewritten evidence, same gap']]); + assert.equal(run(root, ['--base', 'base']).status, 0); + }); +}); + +describe('anti-vacuous floor', () => { + it('refuses a tree where the catalog is not where it looks', () => { + const root = join(sandbox, 'no-catalog'); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'placeholder.txt'), 'x'); + const { status, out } = run(root, ['--base', 'base']); + assert.equal(status, 1, out); + assert.match(out, /must not read as "no collisions"/); + }); + + it('refuses a base ref it cannot read, rather than skipping', () => { + const root = repoWith('bad-base', [['GT-001', 'a', 'before']], [['GT-001', 'a', 'after']]); + const { status, out } = run(root, ['--base', 'origin/nope']); + assert.equal(status, 1, out); + assert.match(out, /cannot read the catalog at the base ref/); + assert.match(out, /Unable to answer is not the same as nothing to report/); + }); + + it('refuses a catalog whose heading shape moved', () => { + const root = join(sandbox, 'reshaped'); + mkdirSync(join(root, dirname(CATALOG)), { recursive: true }); + const g = (...args) => execFileSync('git', args, { cwd: root, stdio: 'ignore' }); + g('init', '-q', '-b', 'base'); + g('config', 'user.email', 'f@example.com'); + g('config', 'user.name', 'f'); + writeFileSync(join(root, CATALOG), catalog([['GT-001', 'a']])); + g('add', '-A'); g('commit', '-q', '-m', 'base'); + g('checkout', '-q', '-b', 'work'); + writeFileSync(join(root, CATALOG), '### GT-001\n\nTitle: the shape moved\n'); + g('add', '-A'); g('commit', '-q', '-m', 'reshaped'); + + const { status, out } = run(root, ['--base', 'base']); + assert.equal(status, 1, out); + assert.match(out, /at least one side yielded NOTHING/); + assert.match(out, /Fix this parser rather than deleting the check/); + }); +}); diff --git a/.harness/scripts/lib/guard-classification.mjs b/.harness/scripts/lib/guard-classification.mjs index 44b7bc0b..8b69ba19 100644 --- a/.harness/scripts/lib/guard-classification.mjs +++ b/.harness/scripts/lib/guard-classification.mjs @@ -40,6 +40,14 @@ export const CALLS_COVERAGE = /\b(?:assertScanned|assertScannedPerSource|scanned * deleting the check and leaving the exemption behind. */ export const SELF_GUARDED = [ + { + file: '49-validate-gap-id-allocation.mjs', + proof: /at least one side yielded NOTHING/, + reason: + 'GT-638 gap-id allocation guard; refuses a missing catalog, a base ref it cannot read ' + + '(unable to answer is not "nothing to report" — a quiet skip is how the collision reached ' + + 'main), and a parse that yields zero ids on either side', + }, { file: '48-validate-security-publish-lag.mjs', proof: /resolved ZERO publishable packages — nothing was scanned/, diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 38a7a6ba..2638ccfa 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -8917,6 +8917,31 @@ ], "dependencyDisposition": "accepted-scope", "dependencyRationale": "TWO things are left open on purpose. (1) evolith-cli@1.2.1 and evolith-mcp@1.2.1 are NOT deprecated: they resolve the superseded SDK major, which is a stale contract and not an exposure, and deprecating a version one patch old needs an owner's call rather than mine. (2) evolith-contracts@1.1.0 remains this package's only published version, so there is still no upgrade path to point a deprecation at; it is recorded here rather than forced." + }, + { + "id": "GT-638", + "closedAt": "2026-07-30", + "closureCommit": "b75522ff", + "evidence": [ + ".harness/scripts/ci/49-validate-gap-id-allocation.mjs", + ".harness/scripts/ci/49-validate-gap-id-allocation.test.mjs", + ".harness/scripts/lib/guard-classification.mjs", + ".github/workflows/ci-cd.yml", + "reference/core/control-center/gaps/gap-reference-catalog.md" + ], + "validationCommands": [ + "node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose -> 515 ids on base, 515 on HEAD, 0 newly allocated, 0 collisions against origin/main.", + "IT FOUND A SECOND COLLISION ON ITS FIRST REAL RUN, which nobody knew about. Against origin/develop it reports GT-633: on develop that id is `evolith init` scaffolds a repository that cannot pass its own governance on the first run (c8270e48, 2026-07-29); on main it is the tautological-guard row (c3221276, 2026-07-30). Two gaps, one number, and the merge had not surfaced it yet.", + "THE DISCRIMINATOR IS THE TITLE, deliberately. `the id already exists on base` is the normal case for 500-odd rows and says nothing; what makes it a defect is the id naming a DIFFERENT gap on each side. Evidence, status and criteria are all expected to change on a branch.", + "It refuses to guess which side is wrong -- a retitle and a collision are indistinguishable from outside -- so it names both titles and both refs, and says in the failure that it should not guess.", + "node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs -> 13/13, including the GT-634 collision reproduced over real git repositories rather than a mock of `git show`.", + "ANTI-VACUOUS: a missing catalog, a base ref that cannot be read, and a parse yielding zero ids on either side are all hard failures. `unable to answer` is not `nothing to report` -- a quiet skip is how the original collision reached main.", + "node .harness/scripts/ci/43-validate-guard-negative-fixtures.mjs -> 38/38 exercised guards refused the empty fixture, up from 37; this guard has been OBSERVED failing.", + "node .harness/scripts/ci/42-validate-guard-denominators.mjs -> 61 guards classified, none silently unprotected.", + "Wired into Governance guards (GT-578) with an explicit `git fetch` of the base branch: a shallow CI checkout does not have it, and a guard that failed for THAT reason would teach people to ignore it." + ], + "dependencyDisposition": "accepted-scope", + "dependencyRationale": "What this guard cannot see is stated rather than left to be assumed: an id allocated on a branch from which no pull request has been opened. It compares HEAD against ONE base, so two feature branches that both allocate GT-640 stay invisible to each other until the first of them merges. The `--verbose` output says so on every newly allocated id instead of implying coverage it does not have. Closing that remainder needs the open-PR claim set described in GT-639, which is why it is tracked there and not bolted on here." } ] } 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 6ca7dafa..7eafcf44 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -7860,7 +7860,7 @@ Serie histórica de gaps registrada en el antiguo `gap-analysis-core.es.md`, pre **Title:** Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces - **Purpose:** Que un gap que ya se está trabajando sea descubrible ANTES de que una segunda sesión lo empiece, y no en el merge. -- **Evidence:** **El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse.** (1) [`GT-633`](./gap-reference-catalog.es.md#gt-633) se arregló dos veces: un script de captura aterrizó en `main` como #276 y un renderer independiente dentro del spec aterrizó en `develop` como #279. Los dos eran correctos, los dos recapturaron los mismos números — y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una TERCERA sesión gastó una reconciliación entera (#294, #295, #296, #297) en dejar un solo renderer. (2) El parser del guard de evidencias se arregló dos veces: `d1ea72a3` en `main` ("stop the evidence guard blaming the evidence for two parser defects") y `5acb29ed` en `develop` ("the ratchet was stuck by two bugs in itself"). (3) Un id de GT se asignó dos veces, forzando la renumeración que registra [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge.** Es el trabajo duplicado que ocurrió antes de que nadie llegara al conflicto, más la reconciliación que hizo falta después — y esa reconciliación introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible durante un día porque ambas estampaban la misma fecha). Tres duplicaciones, una sesión de reparación y un bug nuevo: ésa es la factura real. **Por qué el tablero no lo ve hoy:** una fila lleva estado (`PENDIENTE`, `EN-PROGRESO`) pero no QUIÉN la trabaja ni DÓNDE. Dos sesiones pueden leer ambas `GT-633 · PENDIENTE` y empezar ambas, correctamente. `08-validate-tracking` valida dentro de un único árbol de trabajo por construcción, así que no puede saber que existe otra rama. **Esto no es una convención que falte — la convención existe.** La regla de un solo driver para las olas de gaps es práctica establecida; lo que falta es cualquier mecanismo que la haga observable, así que en un día cargado se degrada en silencio y nadie se entera hasta el merge. Direcciones de arreglo: exigir una reclamación (rama o PR) en la fila que pasa a `EN-PROGRESO`, y derivar el conjunto de reclamaciones de los PR abiertos para que no pueda quedarse rancio; después, fallar en PR cuando un id `GT-*` esté reclamado por más de una rama abierta, nombrando a las dos. +- **Evidence:** **El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse.** (1) [`GT-633`](./gap-reference-catalog.es.md#gt-633) se arregló dos veces: un script de captura aterrizó en `main` como #276 y un renderer independiente dentro del spec aterrizó en `develop` como #279. Los dos eran correctos, los dos recapturaron los mismos números — y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una TERCERA sesión gastó una reconciliación entera (#294, #295, #296, #297) en dejar un solo renderer. (2) El parser del guard de evidencias se arregló dos veces: `d1ea72a3` en `main` ("stop the evidence guard blaming the evidence for two parser defects") y `5acb29ed` en `develop` ("the ratchet was stuck by two bugs in itself"). (3) Un id de GT se asignó dos veces, forzando la renumeración que registra [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge.** Es el trabajo duplicado que ocurrió antes de que nadie llegara al conflicto, más la reconciliación que hizo falta después — y esa reconciliación introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible durante un día porque ambas estampaban la misma fecha). Tres duplicaciones, una sesión de reparación y un bug nuevo: ésa es la factura real. **Por qué el tablero no lo ve hoy:** una fila lleva estado (`PENDIENTE`, `EN-PROGRESO`) pero no QUIÉN la trabaja ni DÓNDE. Dos sesiones pueden leer ambas `GT-633 · PENDIENTE` y empezar ambas, correctamente. `08-validate-tracking` valida dentro de un único árbol de trabajo por construcción, así que no puede saber que existe otra rama. **Esto no es una convención que falte — la convención existe.** La regla de un solo driver para las olas de gaps es práctica establecida; lo que falta es cualquier mecanismo que la haga observable, así que en un día cargado se degrada en silencio y nadie se entera hasta el merge. Direcciones de arreglo: exigir una reclamación (rama o PR) en la fila que pasa a `EN-PROGRESO`, y derivar el conjunto de reclamaciones de los PR abiertos para que no pueda quedarse rancio; después, fallar en PR cuando un id `GT-*` esté reclamado por más de una rama abierta, nombrando a las dos. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese mismo id lleva en la rama base: ausente en base es id nuevo, presente con el mismo título es el mismo gap editado, presente con título DISTINTO es un número nombrando dos gaps. El título es el discriminador a propósito — "el id ya existe en base" es el caso normal de 500 y pico filas y no dice nada. Corrido contra `origin/develop` reporta **`GT-633`**: en develop es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main es la fila del guard tautológico (`c3221276`, 2026-07-30). Dos gaps, un número, y el merge todavía no lo había sacado a la luz. **El guard se niega a adivinar qué lado está mal** — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos y lo dice. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene y un guard que fallara por ESO enseñaría a ignorarlo. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). **Lo que sigue sin ver, dicho y no insinuado:** un número asignado en una rama de la que nadie ha abierto PR. La salida `--verbose` lo advierte en cada id recién asignado en vez de dejar que el lector suponga una cobertura que no tiene. - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M - **Provenance:** Registrado el 2026-07-30 a partir de tres instancias observadas en un solo día, todas en el merge `develop` → `main` que las hizo visibles. Relacionado pero distinto de [`GT-638`](./gap-reference-catalog.es.md#gt-638): aquella fila trata de asignar un NOMBRE dos veces, ésta de hacer el TRABAJO dos veces. La misma carencia de coordinación produce ambas, y la colisión de id es su síntoma más barato. - **Acceptance criteria:** @@ -7878,9 +7878,9 @@ Serie histórica de gaps registrada en el antiguo `gap-analysis-core.es.md`, pre - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** S - **Provenance:** Registrado el 2026-07-30 tras la colisión que describe, que obligó a renumerar en `develop`. El id de ESTA fila se asignó tomando la unión de ids entre `main` y `develop` en vez del máximo de una — que es justo el apaño que el arreglo debería volver innecesario. - **Acceptance criteria:** - - [ ] Registrar una fila en una rama no puede reutilizar en silencio un id que ya existe en la rama base. - - [ ] La comprobación corre en los pull requests y nombra el id en conflicto junto con los dos sitios donde se usa, para que resolverlo no sea una búsqueda. - - [ ] Incluye una fixture negativa OBSERVADA en rojo, no sólo declarada capaz de fallar. + - [x] Registrar una fila en una rama no puede reutilizar en silencio un id que ya existe en la rama base — `49-validate-gap-id-allocation` compara los títulos por id contra la base y falla si difieren. + - [x] La comprobación corre en los pull requests y nombra el id en conflicto junto con los dos sitios donde se usa, para que resolverlo no sea una búsqueda — cableada en `Governance guards (GT-578)`, con la base traída explícitamente porque un checkout superficial no la tiene. + - [x] Incluye fixtures negativas OBSERVADAS en rojo — 13, entre ellas la colisión de GT-634 reproducida sobre historia real de git y tres suelos anti-vacuos, y `43-validate-guard-negative-fixtures` la cuenta en 38/38. #### GT-635 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index 5d871a1f..640d6c79 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -7969,13 +7969,13 @@ Historical gap series tracked in the former `gap-analysis-core.md`, preserved fo **Title:** The board has no id allocator, so two parallel branches hand out the same GT number - **Purpose:** Make a gap id unique by construction rather than by whoever registered it last reading the right branch. -- **Evidence:** **A gap id is chosen by reading the highest `GT-*` on the branch you happen to be on, and nothing checks that against any other branch.** So two sessions working in parallel allocate the same number, and one of them finds out at merge time. It has already happened: `8449af3d` on `develop` carries the message *"chore(board): renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"* — that row and [`GT-634`](./gap-reference-catalog.md#gt-634) on `main` were allocated the same id within hours of each other, by sessions that could not see each other. **The renumber is manual and lossy**, which is the part that costs more than the clash: an id appears in the board row, the catalog anchor, the closure-evidence record, cross-references from other rows in both languages, commit messages and PR bodies — and only the first three are mechanically checkable. `08-validate-tracking` cannot help, and not for want of trying: it validates EN/ES parity, closure records and counters **within one working tree**, and branches are outside its world by construction. **Why it is worth a row rather than a convention:** the board is the single source of truth for 635 gaps, and an id that means two different things in two branches makes every reference to it ambiguous — including the ones in `gap-closure-evidence.json`, which is machine-validated data. This is also not an isolated slip. In the `develop` → `main` merge of 2026-07-30 the same convergent-duplication pattern appears **three times**: GT-633 was fixed twice by two sessions, the evidence guard's parser was fixed twice, and this id was allocated twice. Fix: allocate ids from something that cannot be read stale — a reserved high-water mark committed on the default branch, or a PR-time check that fails when a branch introduces an id already present on its base or in another open PR, naming both users of it. +- **Evidence:** **A gap id is chosen by reading the highest `GT-*` on the branch you happen to be on, and nothing checks that against any other branch.** So two sessions working in parallel allocate the same number, and one of them finds out at merge time. It has already happened: `8449af3d` on `develop` carries the message *"chore(board): renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"* — that row and [`GT-634`](./gap-reference-catalog.md#gt-634) on `main` were allocated the same id within hours of each other, by sessions that could not see each other. **The renumber is manual and lossy**, which is the part that costs more than the clash: an id appears in the board row, the catalog anchor, the closure-evidence record, cross-references from other rows in both languages, commit messages and PR bodies — and only the first three are mechanically checkable. `08-validate-tracking` cannot help, and not for want of trying: it validates EN/ES parity, closure records and counters **within one working tree**, and branches are outside its world by construction. **Why it is worth a row rather than a convention:** the board is the single source of truth for 635 gaps, and an id that means two different things in two branches makes every reference to it ambiguous — including the ones in `gap-closure-evidence.json`, which is machine-validated data. This is also not an isolated slip. In the `develop` → `main` merge of 2026-07-30 the same convergent-duplication pattern appears **three times**: GT-633 was fixed twice by two sessions, the evidence guard's parser was fixed twice, and this id was allocated twice. Fix: allocate ids from something that cannot be read stale — a reserved high-water mark committed on the default branch, or a PR-time check that fails when a branch introduces an id already present on its base or in another open PR, naming both users of it. **CLOSED 2026-07-30 — and on its first real run it found a SECOND collision nobody knew about.** `49-validate-gap-id-allocation` compares every id's **Title:** on HEAD against the title the same id carries on the base branch: absent on base means newly allocated, present with the same title means the same gap edited, present with a DIFFERENT title means one number naming two gaps. The title is the discriminator on purpose — "the id exists on base" is the normal case for 500-odd rows and says nothing. Run against `origin/develop` it reports **`GT-633`**: on develop it is "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29), on main it is the tautological-guard row (`c3221276`, 2026-07-30). Two gaps, one number, and the merge had not surfaced it yet. **The guard refuses to guess which side is wrong** — a retitle and a collision look identical from the outside — so it names both and says so. Wired into `Governance guards (GT-578)` with an explicit `git fetch` of the base, because a shallow CI checkout does not have it and a guard that failed for THAT reason would teach people to ignore it. Observed red by `43-validate-guard-negative-fixtures` (38/38). **What it still cannot see, stated rather than implied:** a number allocated on a branch nobody has opened a PR from. The `--verbose` output says so on every newly allocated id instead of leaving the reader to assume coverage it does not have. - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** S - **Provenance:** Registered 2026-07-30 after the collision it describes forced a renumber on `develop`. The id for THIS row was allocated by taking the union of ids across `main` and `develop` rather than the maximum on one — which is the workaround the fix should make unnecessary. - **Acceptance criteria:** - - [ ] Registering a row on a branch cannot silently reuse an id that already exists on the base branch. - - [ ] The check runs on pull requests and names the colliding id together with both places it is used, so the resolution is not a search. - - [ ] It ships with a negative fixture that has been OBSERVED red, not merely declared able to fail. + - [x] Registering a row on a branch cannot silently reuse an id that already exists on the base branch — `49-validate-gap-id-allocation` compares titles per id against the base and fails on a mismatch. + - [x] The check runs on pull requests and names the colliding id together with both places it is used, so the resolution is not a search — wired into `Governance guards (GT-578)`, with the base fetched explicitly because a shallow checkout does not have it. + - [x] It ships with negative fixtures that have been OBSERVED red — 13 of them, including the GT-634 collision reproduced over real git history and three anti-vacuous floors, and `43-validate-guard-negative-fixtures` counts it at 38/38. #### GT-635 diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index f6227671..9f9f6cfe 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -14,7 +14,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | ID | Gap | Qué significa | Ejemplo | Componente | Fase | Criticidad | Complejidad | Estado | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| | [`GT-639`](./gap-reference-catalog.es.md#gt-639) | **Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces.** El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse: [`GT-633`](./gap-reference-catalog.es.md#gt-633) arreglado por un script de captura en `main` (#276) Y por un renderer independiente dentro del spec en `develop` (#279) — los dos correctos, los dos recapturando los mismos números, y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una tercera sesión gastó una reconciliación entera en dejar uno; el parser del guard de evidencias arreglado dos veces (`d1ea72a3` en main, `5acb29ed` en develop); y un id de GT asignado dos veces, forzando la renumeración que hay detrás de [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge** — es el trabajo duplicado antes de que nadie llegara a él, más la reconciliación, que introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible un día porque ambas estampaban la misma fecha). Una fila lleva estado pero no QUIÉN la trabaja ni DÓNDE, así que dos sesiones pueden leer ambas `PENDIENTE` y empezar ambas, correctamente. **La convención de un solo driver ya existe; lo que falta es un mecanismo que la haga observable**, así que en un día cargado se degrada en silencio. | | | `Governance` | Cross | P2 | M | `PENDIENTE` | -| [`GT-638`](./gap-reference-catalog.es.md#gt-638) | **El tablero no tiene asignador de ids, así que dos ramas paralelas reparten el mismo número GT.** Un id se elige leyendo el `GT-*` más alto de la rama en la que uno está, y nada lo contrasta con otra rama — así que dos sesiones en paralelo asignan el mismo número y una se entera en el merge. Ya pasó: `8449af3d` en `develop` dice *"renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"*; esa fila y [`GT-634`](./gap-reference-catalog.es.md#gt-634) en `main` tomaron el mismo id con horas de diferencia, desde sesiones que no podían verse. **La renumeración es manual y con pérdidas**, que cuesta más que el choque: un id vive en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, referencias cruzadas en dos idiomas, mensajes de commit y cuerpos de PR — y sólo los tres primeros son comprobables mecánicamente. `08-validate-tracking` no puede ayudar por construcción: valida dentro de un único árbol de trabajo, y las ramas quedan fuera de su mundo. Tampoco es un desliz aislado — en el merge `develop` → `main` del 2026-07-30 el mismo patrón de duplicación convergente aparece tres veces: GT-633 arreglado dos veces, el parser del guard de evidencias arreglado dos veces, este id asignado dos veces. | | | `Governance` | Cross | P2 | S | `PENDIENTE` | +| [`GT-638`](./gap-reference-catalog.es.md#gt-638) | **El tablero no tiene asignador de ids, así que dos ramas paralelas reparten el mismo número GT.** Un id se elige leyendo el `GT-*` más alto de la rama en la que uno está, y nada lo contrasta con otra rama — así que dos sesiones en paralelo asignan el mismo número y una se entera en el merge. Ya pasó: `8449af3d` en `develop` dice *"renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"*; esa fila y [`GT-634`](./gap-reference-catalog.es.md#gt-634) en `main` tomaron el mismo id con horas de diferencia, desde sesiones que no podían verse. **La renumeración es manual y con pérdidas**, que cuesta más que el choque: un id vive en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, referencias cruzadas en dos idiomas, mensajes de commit y cuerpos de PR — y sólo los tres primeros son comprobables mecánicamente. `08-validate-tracking` no puede ayudar por construcción: valida dentro de un único árbol de trabajo, y las ramas quedan fuera de su mundo. Tampoco es un desliz aislado — en el merge `develop` → `main` del 2026-07-30 el mismo patrón de duplicación convergente aparece tres veces: GT-633 arreglado dos veces, el parser del guard de evidencias arreglado dos veces, este id asignado dos veces. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese id lleva en la rama base; un título distinto para el mismo id es un número nombrando dos gaps. Contra `origin/develop` reporta **`GT-633`**: allí es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main es la fila del guard tautológico (`c3221276`, 2026-07-30). El merge no lo había sacado a la luz. Se niega a adivinar qué lado está mal — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). Sigue sin ver un id asignado en una rama sin PR abierto, y su salida `--verbose` lo advierte en cada id nuevo. | | | `Governance` | Cross | P2 | S | `COMPLETADO` | | [`GT-635`](./gap-reference-catalog.es.md#gt-635) | **El ratchet de referencias muertas está por encima del presupuesto en main desde el 2026-07-28, así que el job de gobernanza está en rojo permanente.** `41-validate-evidence-commands --strict --max-dead 305` reporta **309 referencias muertas en un checkout limpio**, así que `Governance guards (GT-578)` sale 1 en cada corrida de `ci-cd.yml` — observado en las corridas `30482497995`, `30470648146` y `30466280865`, todas en `main` y sin nada por mergear. El presupuesto se fijó en `4a1b92d6` "donde se midió"; desde entonces diez commits han editado `gap-closure-evidence.json`, cada uno añadiendo `validationCommands`, y la cuenta se fue por encima. El propósito entero del ratchet es que las referencias muertas no CREZCAN, y está reportando exactamente eso mientras se lo ignora, porque un job en rojo permanente se lee como ruido de fondo — el mismo modo de fallo que [`GT-622`](./gap-reference-catalog.es.md#gt-622) registra para la configuración huérfana de CodeQL. El próximo cambio que añada de verdad una referencia muerta será indistinguible de las cuatro que ya están. **Lo que no hay que hacer:** subir el presupuesto a 309, que convierte un ratchet incumplido en uno ratificado. | | | `Governance` | Cross | P2 | S | `COMPLETADO` | | [`GT-634`](./gap-reference-catalog.es.md#gt-634) | **El CLI y el MCP publicados resuelven una build del SDK anterior a la ola de seguridad, porque un rango caret bajo 1.x la fija.** `@beyondnet/evolith-cli@1.2.1` y `@beyondnet/evolith-mcp@1.2.1` declaran ambos `@beyondnet/evolith-sdk: ^1.1.0`, y eso resuelve **exactamente `1.1.0`** — el SDK publica `1.0.0`, `1.1.0` y `2.0.0` y nada en medio, y un caret no cruza un mayor. Las fechas son el hallazgo: `sdk@1.1.0` se publicó el 2026-07-18, `sdk@2.0.0` el 2026-07-27 como parte de la release que cerró [`GT-570`](./gap-reference-catalog.es.md#gt-570), y ambos consumidores el 2026-07-28 — **después** de que 2.0.0 existiera, y seguían en la línea 1.x. Así que la exposición que GT-570 declaró cerrada para "quien instale `latest`" no está cerrada para esta dependencia. Dicho con precisión, porque la afirmación fuerte no está verificada aquí: lo medido son las fechas y la resolución, no el contenido — esta fila no afirma que `sdk@1.1.0` cargue una vulnerabilidad concreta. Encontrado ejecutando el criterio de deprecación de [`GT-624`](./gap-reference-catalog.es.md#gt-624), que es lo que dejó el rango a la vista: deprecar `sdk@1.1.0` avisaría en cada instalación limpia de la línea actual sin ningún sucesor 1.x que ofrecer. **CERRADO el 2026-07-30 — 1.2.2 está publicada, y el marco de seguridad de esta fila era ERRÓNEO.** Una instalación limpia de `@beyondnet/evolith-cli@latest` resuelve ya `@beyondnet/evolith-sdk@2.0.0` con el binario arrancando. Pero verificar el criterio 2 refutó la premisa: **ninguno de los siete commits de la ola toca `src/packages/sdk-client` — cero ficheros** — y el propio commit de release de la ola nombra los tres paquetes afectados (mcp, cli, agent-runtime). El SDK se republicó con ellos, no se arregló. Lo real es un MAYOR rancio fijado por un caret bajo 1.x más un lockfile que anidaba el tarball del registry tapando el enlace del workspace — la clase de `GT-625`, no una exposición de seguridad. La cautela escrita al abrir la fila es lo que abarató la corrección. | | | `Infra` | Cross | P1 | S | `COMPLETADO` | | [`GT-633`](./gap-reference-catalog.es.md#gt-633) | **El guard comparaba el snapshot contra seis números copiados del propio snapshot, así que solo podía pasar — y el archivo que no estaba vigilando se estampa en 388 filas de un artefacto mayor.** `native-evaluability-snapshot.json` declaraba en su propia cabecera que era "un SNAPSHOT CAPTURADO, no la fuente de verdad". **No existía ningún script de captura.** Se mantenía a mano y derivó: fijaba `documentation-only: 129` mientras Core fijaba 136, y seguía llamando `unimplemented-native` a reglas que ya tenían handler. Su guard en `iso-5055-mapping.test.mjs` asertaba los seis conteos de clase del snapshot contra seis números escritos a mano en el test — los mismos seis literales que el snapshot ya contenía. **El valor esperado y el valor real eran copias el uno del otro**, así que la aserción se sostenía mientras el archivo no cambiara, fijara Core lo que fijara; informaba de una concordancia que nunca comprobó, lo cual es peor que no tener guard, porque la fila que protege se lee como verificada. **La deriva no se queda donde empieza.** `build-iso-5055-mapping.mjs` estampa `nativeEvaluability` en TODAS las filas del mapeo ISO/IEC 5055 desde ese archivo — 388 tras la recaptura, 381 antes — así que una sola clase rancia se blanquea en un artefacto derivado cinco veces mayor que su entrada, y sobredimensiona el backlog de handlers que es el entregable entero de [`GT-598`](./gap-reference-catalog.es.md#gt-598). El orden recaptura→reconstrucción no estaba escrito en ningún sitio, y el guard del propio mapeo no corría en **ningún workflow**. **CORREGIDO el 2026-07-29:** un script de captura que ejecuta el triage REAL de Core vía `ts-node` en vez de reimplementar la clasificación, la medición extraída del spec de jest a `test/rule-corpus-triage.ts` para que un script pueda alcanzarla — esa inalcanzabilidad es la razón de que el archivo se mantuviera a mano —, aserciones snapshot-contra-triage-fresco en ambas direcciones, un guard del job de documentación que lee los conteos que Core fija LEYENDO el spec y **lanza excepción** cuando el literal falta o cambia de forma, y ambos pasos declarados como eslabones de la cadena de artefactos derivados de [`GT-630`](./gap-reference-catalog.es.md#gt-630). **Recapturado el 2026-07-29 tras mergear el fix en la línea actual, y la deriva había crecido mientras estaba en vuelo:** `native-handler` 139 → 151, `documentation-only` 129 → 136, el backlog de handlers 60 → **48**, corpus 379 → 386, mapeo 381 → 388 filas. Doce de esos handlers los cerraron [`GT-595`](./gap-reference-catalog.es.md#gt-595) y [`GT-632`](./gap-reference-catalog.es.md#gt-632) mientras el snapshot mantenido a mano seguía reportándolos como backlog — el defecto demostrándose una vez más, sobre números que nadie escribió dos veces. | | | `Governance` | Cross | P1 | S | `COMPLETADO` | @@ -652,7 +652,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-246`](./gap-reference-catalog.es.md#gt-246) | Implementar experimentos Chaos Mesh/Litmus | | | `QA` | Cross | P3 | L | `COMPLETADO` | -**Progreso:** 596 / 637 completados · 14 en progreso · 23 pendientes · 4 diferidos +**Progreso:** 597 / 637 completados · 14 en progreso · 22 pendientes · 4 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 cac63d05..38faafe5 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -14,7 +14,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | ID | Gap | What it means | Example | Component | Phase | Criticality | Complexity | Status | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| | [`GT-639`](./gap-reference-catalog.md#gt-639) | **Nothing makes in-flight work visible across branches, so the same gap gets fixed twice.** On 2026-07-30 the same work was done twice, three separate times, by sessions that could not see each other: [`GT-633`](./gap-reference-catalog.md#gt-633) fixed by a standalone capture script on `main` (#276) AND by an independent in-spec renderer on `develop` (#279) — both correct, both recapturing the same numbers, and two generators for one artifact is GT-633's own defect one level up, so a third session spent a full reconciliation collapsing them; the evidence guard's parser fixed twice (`d1ea72a3` on main, `5acb29ed` on develop); and a GT id allocated twice, forcing the renumber behind [`GT-638`](./gap-reference-catalog.md#gt-638). **The cost is not the merge conflict** — it is the duplicated work before anyone reached it, plus the reconciliation, which itself introduced a defect (an order-sensitive comparison that rewrote the capture date every run, invisible for a day because both runs stamped the same date). A row carries a status but not WHO is working it or WHERE, so two sessions can both read `PENDING` and both start, correctly. **The single-driver convention already exists; what is missing is any mechanism that makes it observable**, so on a busy day it degrades silently. | | | `Governance` | Cross | P2 | M | `PENDING` | -| [`GT-638`](./gap-reference-catalog.md#gt-638) | **The board has no id allocator, so two parallel branches hand out the same GT number.** An id is chosen by reading the highest `GT-*` on the branch you happen to be on, and nothing contrasts that with any other branch — so two sessions in parallel allocate the same number and one finds out at merge time. It already happened: `8449af3d` on `develop` reads *"renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"*; that row and [`GT-634`](./gap-reference-catalog.md#gt-634) on `main` took the same id within hours, from sessions that could not see each other. **The renumber is manual and lossy**, which costs more than the clash: an id lives in the board row, the catalog anchor, the closure-evidence record, cross-references in both languages, commit messages and PR bodies — and only the first three are mechanically checkable. `08-validate-tracking` cannot help by construction: it validates within one working tree, and branches are outside its world. Not an isolated slip either — in the 2026-07-30 `develop` → `main` merge the same convergent-duplication pattern appears three times: GT-633 fixed twice, the evidence guard's parser fixed twice, this id allocated twice. | | | `Governance` | Cross | P2 | S | `PENDING` | +| [`GT-638`](./gap-reference-catalog.md#gt-638) | **The board has no id allocator, so two parallel branches hand out the same GT number.** An id is chosen by reading the highest `GT-*` on the branch you happen to be on, and nothing contrasts that with any other branch — so two sessions in parallel allocate the same number and one finds out at merge time. It already happened: `8449af3d` on `develop` reads *"renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"*; that row and [`GT-634`](./gap-reference-catalog.md#gt-634) on `main` took the same id within hours, from sessions that could not see each other. **The renumber is manual and lossy**, which costs more than the clash: an id lives in the board row, the catalog anchor, the closure-evidence record, cross-references in both languages, commit messages and PR bodies — and only the first three are mechanically checkable. `08-validate-tracking` cannot help by construction: it validates within one working tree, and branches are outside its world. Not an isolated slip either — in the 2026-07-30 `develop` → `main` merge the same convergent-duplication pattern appears three times: GT-633 fixed twice, the evidence guard's parser fixed twice, this id allocated twice. **CLOSED 2026-07-30 — and on its first real run it found a SECOND collision nobody knew about.** `49-validate-gap-id-allocation` compares every id's **Title:** on HEAD against the title the same id carries on the base branch; a different title on the same id is one number naming two gaps. Against `origin/develop` it reports **`GT-633`**: there it is "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29), on main it is the tautological-guard row (`c3221276`, 2026-07-30). The merge had not surfaced it. It refuses to guess which side is wrong — a retitle and a collision look identical from outside — so it names both. Wired into `Governance guards (GT-578)` with an explicit `git fetch` of the base, since a shallow CI checkout lacks it. Observed red by `43-validate-guard-negative-fixtures` (38/38). Still cannot see an id allocated on a branch with no open PR, and its `--verbose` output says so on every newly allocated id. | | | `Governance` | Cross | P2 | S | `DONE` | | [`GT-635`](./gap-reference-catalog.md#gt-635) | **The dead-reference ratchet has been over budget on main since 2026-07-28, so the governance job is permanently red.** `41-validate-evidence-commands --strict --max-dead 305` reports **309 dead references on a clean checkout**, so `Governance guards (GT-578)` exits 1 on every run of `ci-cd.yml` — observed on runs `30482497995`, `30470648146` and `30466280865`, all on `main` with nothing unmerged. The budget was set in `4a1b92d6` "where it was measured"; ten commits have edited `gap-closure-evidence.json` since, each adding `validationCommands`, and the count drifted past it. The ratchet's whole purpose is that dead references must not GROW, and it is reporting exactly that while being ignored, because a permanently red job reads as background noise — the same failure mode [`GT-622`](./gap-reference-catalog.md#gt-622) records for the orphaned CodeQL configuration. The next change that genuinely adds a dead reference will be indistinguishable from the four already there. **What not to do:** raise the budget to 309, which converts a breached ratchet into a ratified one. | | | `Governance` | Cross | P2 | S | `DONE` | | [`GT-634`](./gap-reference-catalog.md#gt-634) | **The published CLI and MCP resolve an SDK build that predates the security wave, because a caret range under 1.x pins it.** `@beyondnet/evolith-cli@1.2.1` and `@beyondnet/evolith-mcp@1.2.1` both declare `@beyondnet/evolith-sdk: ^1.1.0`, and that resolves to **exactly `1.1.0`** — the SDK publishes `1.0.0`, `1.1.0` and `2.0.0` and nothing in between, and a caret cannot cross a major. The dates are the finding: `sdk@1.1.0` was published 2026-07-18, `sdk@2.0.0` on 2026-07-27 as part of the release that closed [`GT-570`](./gap-reference-catalog.md#gt-570), and both consumers on 2026-07-28 — **after** 2.0.0 existed, still on the 1.x line. So the exposure GT-570 declared closed for "anyone installing `latest`" is not closed for this dependency. Stated precisely, because the stronger claim is unverified here: what is measured is the dates and the resolution, not the contents — this row does not assert that `sdk@1.1.0` carries a specific vulnerability. Found while executing [`GT-624`](./gap-reference-catalog.md#gt-624)'s deprecation criterion, which is what exposed the range: deprecating `sdk@1.1.0` would warn on every fresh install of the current line with no 1.x successor to offer. **CLOSED 2026-07-30 — 1.2.2 is published, and the security framing of this row was WRONG.** A clean install of `@beyondnet/evolith-cli@latest` now resolves `@beyondnet/evolith-sdk@2.0.0` with the binary booting. But verifying criterion 2 refuted the premise: **none of the seven security-wave commits touches `src/packages/sdk-client` — zero files** — and the wave's own release commit names the three packages it affected (mcp, cli, agent-runtime). The SDK was republished alongside them, not fixed. What was real is a stale MAJOR pinned by a caret under 1.x plus a lockfile that nested the registry tarball where it shadowed the workspace link — the `GT-625` class, not a security exposure. The hedge written in when the row was opened is what made the correction cheap. | | | `Infra` | Cross | P1 | S | `DONE` | | [`GT-633`](./gap-reference-catalog.md#gt-633) | **The guard compared the snapshot against six numbers copied out of the snapshot, so it could only ever pass — and the file it was failing to guard is stamped onto 388 rows of a larger artifact.** `native-evaluability-snapshot.json` declared in its own header that it was "a CAPTURED SNAPSHOT, not the source of truth". **No capture script existed.** It was hand-maintained and it drifted: it pinned `documentation-only: 129` while Core pinned 136, and went on calling rules `unimplemented-native` after they had handlers. Its guard in `iso-5055-mapping.test.mjs` asserted the snapshot's six class counts against six numbers typed into the test — the same six literals the snapshot already contained. **Expected value and actual value were copies of each other**, so the assertion held for as long as the file was unchanged, whatever Core actually pinned; it reported an agreement it never checked, which is worse than no guard, because the row it protects reads as verified. **The drift does not stay where it starts.** `build-iso-5055-mapping.mjs` stamps `nativeEvaluability` onto EVERY row of the ISO/IEC 5055 mapping from that file — 388 rows after the recapture, 381 before — so one stale class is laundered into a derived artifact five times the size of its input, and overstates the handler backlog that is [`GT-598`](./gap-reference-catalog.md#gt-598)'s entire deliverable. The recapture→rebuild order was written down nowhere, and the mapping's own guard ran in **no workflow at all**. **FIXED 2026-07-29:** a capture script that runs Core's REAL triage through `ts-node` instead of reimplementing the classification, the measurement extracted out of the jest spec into `test/rule-corpus-triage.ts` so a script can reach it — that unreachability is why the file was maintained by hand — snapshot-vs-fresh-triage assertions in both directions, a documentation-job guard that reads Core's pinned counts OUT of the spec and **throws** when the literal is missing or reshaped, and both steps declared as links in the [`GT-630`](./gap-reference-catalog.md#gt-630) derived-artifact chain. **Recaptured on 2026-07-29 after merging the fix into the current line, and the drift had grown while it was in flight:** `native-handler` 139 → 151, `documentation-only` 129 → 136, the handler backlog 60 → **48**, corpus 379 → 386, mapping 381 → 388 rows. Twelve of those handlers were closed by [`GT-595`](./gap-reference-catalog.md#gt-595) and [`GT-632`](./gap-reference-catalog.md#gt-632) while the hand-maintained snapshot went on reporting them as backlog — the defect demonstrating itself one more time, on numbers nobody typed twice. | | | `Governance` | Cross | P1 | S | `DONE` | @@ -652,7 +652,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-246`](./gap-reference-catalog.md#gt-246) | Implement Chaos Mesh/Litmus experiments | | | `QA` | Cross | P3 | L | `DONE` | -**Progress:** 596 / 637 done · 14 in progress · 23 pending · 4 deferred +**Progress:** 597 / 637 done · 14 in progress · 22 pending · 4 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 c2446cf3..18d3fb61 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.es.md +++ b/reference/core/control-center/maturity-reports/executive-summary.es.md @@ -29,7 +29,7 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so | 2 | Área de mayor riesgo | `Evolith Core` tiene la mayor carga ponderada abierta. | [GT-583](../gaps/gap-reference-catalog.es.md#gt-583), [GT-589](../gaps/gap-reference-catalog.es.md#gt-589), [GT-614](../gaps/gap-reference-catalog.es.md#gt-614), [GT-587](../gaps/gap-reference-catalog.es.md#gt-587), [GT-591](../gaps/gap-reference-catalog.es.md#gt-591), [GT-590](../gaps/gap-reference-catalog.es.md#gt-590), +2 | | 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-597](../gaps/gap-reference-catalog.es.md#gt-597), [GT-631](../gaps/gap-reference-catalog.es.md#gt-631) | | 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [GT-597](../gaps/gap-reference-catalog.es.md#gt-597), [GT-631](../gaps/gap-reference-catalog.es.md#gt-631), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-578](../gaps/gap-reference-catalog.es.md#gt-578), [GT-580](../gaps/gap-reference-catalog.es.md#gt-580), [GT-584](../gaps/gap-reference-catalog.es.md#gt-584), [GT-605](../gaps/gap-reference-catalog.es.md#gt-605), [GT-606](../gaps/gap-reference-catalog.es.md#gt-606), +6 | -| 5 | P2/P3 | Solo después de estabilizar seguridad, CI, reglas y contratos. | [GT-622](../gaps/gap-reference-catalog.es.md#gt-622), [GT-444](../gaps/gap-reference-catalog.es.md#gt-444), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-614](../gaps/gap-reference-catalog.es.md#gt-614), [GT-616](../gaps/gap-reference-catalog.es.md#gt-616), [GT-617](../gaps/gap-reference-catalog.es.md#gt-617), +14 | +| 5 | P2/P3 | Solo después de estabilizar seguridad, CI, reglas y contratos. | [GT-622](../gaps/gap-reference-catalog.es.md#gt-622), [GT-444](../gaps/gap-reference-catalog.es.md#gt-444), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-614](../gaps/gap-reference-catalog.es.md#gt-614), [GT-616](../gaps/gap-reference-catalog.es.md#gt-616), [GT-617](../gaps/gap-reference-catalog.es.md#gt-617), +13 | ## Bloqueadores Actuales @@ -46,13 +46,13 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so |---|---:| | Fecha canónica del tablero | 2026-07-26 | | Gaps totales | 637 | -| Gaps cerrados | 596 | -| Gaps pendientes | 41 | +| Gaps cerrados | 597 | +| Gaps pendientes | 40 | | P0 abiertos | 4 | | P1 abiertos | 14 | -| P2 abiertos | 20 | -| Cierre total | 93.6% | -| Registros de evidencia de cierre | 578 | +| P2 abiertos | 19 | +| Cierre total | 93.7% | +| Registros de evidencia de cierre | 579 | | 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 873c0a1a..af9fd624 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.md +++ b/reference/core/control-center/maturity-reports/executive-summary.md @@ -29,7 +29,7 @@ Use this summary with a simple rule: if you need context, open only the linked I | 2 | Highest-risk area | `Evolith Core` has the largest weighted open load. | [GT-583](../gaps/gap-reference-catalog.md#gt-583), [GT-589](../gaps/gap-reference-catalog.md#gt-589), [GT-614](../gaps/gap-reference-catalog.md#gt-614), [GT-587](../gaps/gap-reference-catalog.md#gt-587), [GT-591](../gaps/gap-reference-catalog.md#gt-591), [GT-590](../gaps/gap-reference-catalog.md#gt-590), +2 | | 3 | Quick wins | High criticality with XS/S complexity. | [GT-597](../gaps/gap-reference-catalog.md#gt-597), [GT-631](../gaps/gap-reference-catalog.md#gt-631) | | 4 | P1 wave | Next hardening after P0 is cleared. | [GT-597](../gaps/gap-reference-catalog.md#gt-597), [GT-631](../gaps/gap-reference-catalog.md#gt-631), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-578](../gaps/gap-reference-catalog.md#gt-578), [GT-580](../gaps/gap-reference-catalog.md#gt-580), [GT-584](../gaps/gap-reference-catalog.md#gt-584), [GT-605](../gaps/gap-reference-catalog.md#gt-605), [GT-606](../gaps/gap-reference-catalog.md#gt-606), +6 | -| 5 | P2/P3 | Only after security, CI, rules, and contracts stabilize. | [GT-622](../gaps/gap-reference-catalog.md#gt-622), [GT-444](../gaps/gap-reference-catalog.md#gt-444), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-614](../gaps/gap-reference-catalog.md#gt-614), [GT-616](../gaps/gap-reference-catalog.md#gt-616), [GT-617](../gaps/gap-reference-catalog.md#gt-617), +14 | +| 5 | P2/P3 | Only after security, CI, rules, and contracts stabilize. | [GT-622](../gaps/gap-reference-catalog.md#gt-622), [GT-444](../gaps/gap-reference-catalog.md#gt-444), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-614](../gaps/gap-reference-catalog.md#gt-614), [GT-616](../gaps/gap-reference-catalog.md#gt-616), [GT-617](../gaps/gap-reference-catalog.md#gt-617), +13 | ## Current Blockers @@ -46,13 +46,13 @@ Use this summary with a simple rule: if you need context, open only the linked I |---|---:| | Canonical board date | 2026-07-26 | | Total gaps | 637 | -| Closed gaps | 596 | -| Open gaps | 41 | +| Closed gaps | 597 | +| Open gaps | 40 | | Open P0 | 4 | | Open P1 | 14 | -| Open P2 | 20 | -| Total closure | 93.6% | -| Closure evidence records | 578 | +| Open P2 | 19 | +| Total closure | 93.7% | +| Closure evidence records | 579 | | Recorded readiness | 4 PASS | | Area | Open | P0 | P1 | First IDs | diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 50d77bd5..fb60472d 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -4,13 +4,13 @@ "asOf": "2026-07-26", "gaps": { "total": 637, - "done": 596, - "pending": 23, + "done": 597, + "pending": 22, "inProgress": 14, "deferred": 4 }, "evidence": { - "closureRecords": 578, + "closureRecords": 579, "cliPackage": "@beyondnet/evolith-cli@1.2.2", "adrCount": 139, "rulesetCount": 175,