diff --git a/.github/workflows/sdk-cli-release.yml b/.github/workflows/sdk-cli-release.yml index 7a67c7f9..7996984d 100644 --- a/.github/workflows/sdk-cli-release.yml +++ b/.github/workflows/sdk-cli-release.yml @@ -189,7 +189,11 @@ jobs: # GATE 3: Publish to NPM # ============================================ publish-npm: - needs: build-and-test + # `release-gate` was referenced by this job (needs.release-gate.outputs.version) without + # being declared here, and `needs.` only resolves for jobs listed in `needs`. So the + # version interpolated to the EMPTY STRING and the confirmation step announced + # "Published @beyondnet/evolith-cli@ to NPM". + needs: [release-gate, build-and-test] runs-on: ubuntu-latest permissions: contents: read @@ -211,15 +215,67 @@ jobs: working-directory: ${{ env.CLI_DIR }} run: npm run build + # ASK THE REGISTRY BEFORE PUBLISHING (#569). + # + # This job used to run a bare `npm publish`. Any re-run against an already-published + # version — a retry after an unrelated flake, a workflow_dispatch, a re-pushed tag — + # died on "You cannot publish over the previously published versions", and that failure + # reached `failure-notification`, which files a "Release Pipeline Failed" issue. Three + # of those accumulated (#492, #552, #553) before anyone read one. A harmless re-run + # manufactured a bug report. + # + # Idempotence belongs BEFORE the publish, as a question with an answer — not after it, + # as a swallowed error. `continue-on-error` or `|| true` on the publish itself would + # make a genuine failure indistinguishable from this one, which is the whole defect + # wearing a different hat. The `|| true` below is on a QUERY, where a non-zero exit + # simply means "not found", and its answer is then compared explicitly. + # + # npm-release.yml has solved this since `plan-npm-release.mjs`; this workflow predates + # it and never adopted it. + - name: Is this version already on the registry? + id: plan + working-directory: ${{ env.CLI_DIR }} + run: | + set -euo pipefail + NAME=$(node -p "require('./package.json').name") + VERSION=$(node -p "require('./package.json').version") + { + echo "name=$NAME" + echo "version=$VERSION" + } >> "$GITHUB_OUTPUT" + if [ "$(npm view "$NAME@$VERSION" version 2>/dev/null || true)" = "$VERSION" ]; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "::notice::$NAME@$VERSION is already on the registry. Skipping the publish — this is not a failure. Bump the version to ship a change." + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "$NAME@$VERSION is not on the registry — publishing." + fi + - name: Publish to NPM + if: steps.plan.outputs.published != 'true' working-directory: ${{ env.CLI_DIR }} run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Confirm Publication + # An echo is not a confirmation. The previous version of this step printed a success + # message it had no evidence for — and printed it with an empty version, at that. + # Verify against the registry, which is the only authority on what was published. + - name: Confirm the registry holds it run: | - echo "✅ Published @beyondnet/evolith-cli@${{ needs.release-gate.outputs.version }} to NPM" + set -euo pipefail + NAME='${{ steps.plan.outputs.name }}' + VERSION='${{ steps.plan.outputs.version }}' + for attempt in 1 2 3 4 5; do + if [ "$(npm view "$NAME@$VERSION" version 2>/dev/null || true)" = "$VERSION" ]; then + echo "confirmed on registry: $NAME@$VERSION" + exit 0 + fi + echo "not visible yet (attempt $attempt/5), waiting" + sleep 10 + done + echo "::error::$NAME@$VERSION is NOT on the registry after this job ran. The publish reported success and the registry disagrees." + exit 1 # ============================================ # GATE 4: Package Binaries @@ -361,7 +417,11 @@ jobs: # GATE 6: Upload Release Assets # ============================================ upload-assets: - needs: [package-binaries, smoke-test, smoke-test-functional] + # Same dangling reference as publish-npm had, and this one is worse: the step below + # interpolates needs.release-gate.outputs.tag_name into the GitHub Release's `tag_name` + # and `name`, so without release-gate declared here it publishes a Release named + # "Release " against an empty tag. + needs: [release-gate, package-binaries, smoke-test, smoke-test-functional] runs-on: ubuntu-latest steps: - name: Checkout diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 1e4ccfc1..6b7c8814 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -10081,6 +10081,25 @@ "dependencyDisposition": "none", "dependencyRationale": "No dependency added or changed, and none could be: the change deletes a dependency rather than adding one. The test previously depended on git AND on the remote-tracking ref origin/develop being present and pointing at the pre-fix artifact — an external, mutable input. It now reads a file committed in this repository, using only node:fs, which the file already imported. The fixture itself is inert data with no imports. Nothing is imported by relative path from domain code: this is a harness test reading a harness fixture, and the corpus JSON under src/rulesets is untouched." }, + { + "id": "GT-671", + "closedAt": "2026-08-16", + "closureCommit": "213cc718", + "dependencyDisposition": "none", + "evidence": [ + ".github/workflows/published-canary.yml", + ".harness/scripts/ci/published-artifact-canary.mjs", + "src/sdk/cli/examples/gate-verdict.assert.js" + ], + "validationCommands": [ + "WHAT THE ROW CLAIMED AND WHAT MEASUREMENT NARROWED: 'nothing re-verifies the published artifact' is false as written -- one check existed, `evolith-cli --help` at release time (sdk-cli-release.yml:328-332). It is a weak oracle, MEASURED: every published version answers `--version` with exit 0, INCLUDING the one GT-625 recorded as broken. A check that cannot tell a working artifact from a broken one is not a check, so the row was scoped to what that check cannot see.", + "THE CANARY MEASURES THE REGISTRY, NOT THE TREE. Packages install into a throwaway npm prefix and are driven from a temp directory; the checkout carries only the assertion and the script, deliberately off the resolution path -- GT-625 shipped an uninstallable CLI precisely because the workspace symlink hid it from every suite.", + "PROVEN FALSIFIABLE WITH A FIXTURE THAT WAS MEASURED, NOT ASSUMED. The row named cli@1.2.0 as the red fixture; measured, that version installs, runs `init` and returns a valid envelope. The real red fixture is cli@1.1.0, on three independent counts: no `evolith` bin (only `evolith-cli`), `init --name` writes a subdirectory so no evolith.yaml appears where the canary looks, and `validate --format json` truncates its own envelope through a pipe at 65,386 bytes of a 163,622-byte document. Green against latest, red against 1.1.0, BOTH OBSERVED.", + "AC3 WAS THE LAST ONE OPEN AND EXPIRED BY ITSELF. Its exemption was keyed on `installedPackageShipsNoCorpus()` -- a property of the INSTALLED TARBALL, not a version number and not a date. GT-705 shipped as mcp@1.3.2; run 31987205590 (published-canary.yml, cli@1.3.1 + mcp@1.3.2) reports 'the published MCP server answers a tools/call with a real gate verdict -- verdict asserted by gate-verdict.assert.js', and its log contains ZERO occurrences of 'exempt'. Nobody had to remember to delete anything. A blanket skip would still be green today with the defect fixed by accident.", + "SAME RUN, SAME INSTALL: `validate --format json` returns an ADR-0073 envelope with verdict `failed`, 41/159 rules checked -- a real denominator, so an install shipping no rulesets fails even when it exits 0.", + "node .harness/scripts/ci/published-artifact-canary.mjs --version 1.3.1 --mcp-version 1.3.2" + ] + }, { "id": "GT-677", "closedAt": "2026-08-14", 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 05dfd3cf..8adb9c76 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -8941,10 +8941,10 @@ La declaración tiene un hueco — un pack que no declara — y el directorio lo - **Criterios de aceptación:** - [x] Un workflow programado instala el CLI desde el registro en un entorno limpio sin checkout del repositorio en la ruta de resolución, y ejecuta `init` y luego `validate`. **CUMPLIDO** — `.github/workflows/published-canary.yml`, a diario a las 06:30 UTC más `workflow_dispatch` con versión como entrada. El checkout solo aporta la aserción y el script; los paquetes se instalan en un prefijo desechable y se ejecutan desde un directorio temporal, que es justo el punto: `GT-625` publicó un CLI no instalable porque el symlink del workspace lo ocultaba a todas las suites. - [x] La aserción es un envelope ADR-0073 real con veredicto, no un código de salida cero. **CUMPLIDO** — el canario exige `data.status ∈ {passed, failed, warning}` Y `rulesTotal > 0`, así que una instalación sin rulesets falla aunque salga con 0. Un exit distinto de cero se trata como veredicto, no como fallo: un satélite con hallazgos bloqueantes sale con 2 por diseño. - - [ ] La misma corrida ejercita el servidor MCP publicado por stdio y asegura un veredicto de gate real, reutilizando `gate-verdict.assert.js`. **NO CUMPLIDO, y por eso esta fila sigue abierta.** El canario sí lo ejercita por stdio y sí llama a ese oráculo, pero el paquete publicado no puede producir un veredicto: `@beyondnet/evolith-mcp@1.2.2` declara `files: ["dist/", "README.md", "LICENSE"]` y **no incluye corpus de rulesets**, así que `evolith-gate-evaluate` responde `RULESET_NOT_FOUND` y `evolith-validate` «no pudo localizar el corpus», mientras `evolith-metrics` funciona. Separado como [`GT-705`](./gap-reference-catalog.es.md#gt-705). La aserción se CONSERVA y solo se exime ante ese síntoma exacto, así que cualquier otro fallo sigue tumbando la corrida y la aserción pasa a sostener el peso el día que `GT-705` se entregue — un salto en bloque habría sido el gemelo permanentemente-verde del workflow permanentemente-rojo de `GT-635`. + - [x] La misma corrida ejercita el servidor MCP publicado por stdio y asegura un veredicto de gate real, reutilizando `gate-verdict.assert.js`. **CUMPLIDO 2026-08-16, y la exención caducó sola en vez de borrarse.** `@beyondnet/evolith-mcp@1.3.2` — la build que lleva [`GT-705`](./gap-reference-catalog.es.md#gt-705) — llegó al registry, y la corrida del canario contra ella (`published-canary.yml`, run `31987205590`, `cli@1.3.1` + `mcp@1.3.2`) reporta **«el servidor MCP publicado responde un tools/call con un veredicto de gate real — veredicto asegurado por gate-verdict.assert.js»**. La exención estaba anclada a `installedPackageShipsNoCorpus()`, una propiedad del TARBALL INSTALADO y no un número de versión ni una fecha, así que incluir el corpus dejó de casarla sin nada que recordar: el log de la corrida contiene **cero** apariciones de «exempt». Esa es la diferencia entre una exención y un salto en bloque — el segundo habría sido el gemelo permanentemente-verde del workflow permanentemente-rojo de `GT-635`, y hoy seguiría verde con el defecto arreglado por accidente. Misma corrida, misma instalación: `validate --format json` devuelve `failed, 41/159 reglas comprobadas` desde un prefijo npm desechable sin repositorio en la ruta de resolución. - [x] Un canario en rojo aparece en algún sitio que una persona lee. **CUMPLIDO** — una corrida programada fallida abre una issue `published-canary`, o comenta en la abierta en vez de duplicarla, y una corrida verde comenta y la cierra. Un hilo que nadie cierra es un hilo que nadie cree. - [x] Falsabilidad probada, OBSERVADA en rojo. **CUMPLIDO, con un fixture medido y no supuesto.** El criterio nombraba `cli@1.2.0`; medido, esa versión instala, hace `init` y devuelve un envelope válido por este camino, así que no es el fixture roto que la fila creía. `cli@1.1.0` sí lo es, por tres motivos independientes: no publica el binario `evolith` (solo `evolith-cli`, con lo que toda invocación documentada falla), su `init --name` escribe un subdirectorio y no aparece `evolith.yaml` donde el canario mira, y `validate --format json` **trunca su propio envelope por tubería** — 65 386 bytes de un documento que a fichero ocupa 163 622, mientras `1.2.2` escribe 69 465 por la misma tubería y parsea. Verde contra `latest`, rojo contra `1.1.0`, ambos observados. -- **Estado:** `EN-PROGRESO` +- **Estado:** `COMPLETADO` #### GT-672 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index 9ef1ed8f..339493d9 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -9035,10 +9035,10 @@ The declaration has one hole — a pack that does not declare — and the direct - **Acceptance criteria:** - [x] A scheduled workflow installs the CLI from the registry in a clean environment with no repository checkout on the resolution path, and runs `init` then `validate`. **MET** — `.github/workflows/published-canary.yml`, daily at 06:30 UTC plus `workflow_dispatch` with a version input. The checkout carries only the assertion and the script; the packages are installed into a throwaway prefix and driven from a temp directory, which is the point — `GT-625` shipped an uninstallable CLI because the workspace symlink hid it from every suite. - [x] The assertion is a real ADR-0073 envelope with a verdict, not a zero exit code. **MET** — the canary requires `data.status ∈ {passed, failed, warning}` AND `rulesTotal > 0`, so an install that shipped no rulesets fails even when it exits 0. A non-zero exit is treated as a verdict, not a failure: a satellite with blocking findings exits 2 by design. - - [ ] The same run exercises the published MCP server over stdio and asserts a real gate verdict, reusing `gate-verdict.assert.js`. **NOT MET, and it is why this row stays open.** The canary does exercise it over stdio and does call that oracle, but the published package cannot produce a verdict: `@beyondnet/evolith-mcp@1.2.2` declares `files: ["dist/", "README.md", "LICENSE"]` and ships **no ruleset corpus**, so `evolith-gate-evaluate` answers `RULESET_NOT_FOUND` and `evolith-validate` "could not locate the Evolith ruleset corpus", while `evolith-metrics` works. Split out as [`GT-705`](./gap-reference-catalog.md#gt-705). The assertion is KEPT and exempted only on that exact symptom, so any other failure still fails the run and the assertion becomes load-bearing the day `GT-705` ships — a blanket skip would have been the permanently-green twin of `GT-635`'s permanently-red workflow. + - [x] The same run exercises the published MCP server over stdio and asserts a real gate verdict, reusing `gate-verdict.assert.js`. **MET 2026-08-16, and the exemption expired by itself rather than being deleted.** `@beyondnet/evolith-mcp@1.3.2` — the build carrying [`GT-705`](./gap-reference-catalog.md#gt-705) — reached the registry, and the canary run against it (`published-canary.yml`, run `31987205590`, `cli@1.3.1` + `mcp@1.3.2`) reports **"the published MCP server answers a tools/call with a real gate verdict — verdict asserted by gate-verdict.assert.js"**. The exemption was keyed on `installedPackageShipsNoCorpus()`, a property of the INSTALLED tarball rather than a version number or a date, so shipping the corpus stopped it matching with nothing to remember: the run log contains **zero** occurrences of "exempt". That is the difference between an exemption and a blanket skip — the latter would have been the permanently-green twin of `GT-635`'s permanently-red workflow, and would still be green today with the defect fixed by accident. Same run, same install: `validate --format json` returns `failed, 41/159 rules checked` from a throwaway npm prefix with no repository on the resolution path. - [x] A red canary surfaces somewhere a human reads. **MET** — a failing scheduled run opens a `published-canary` issue, or comments on the open one rather than duplicating it, and a green run comments and closes it. A thread nobody closes is a thread nobody believes. - [x] Proven falsifiable, OBSERVED red. **MET, with a fixture that was measured rather than assumed.** The criterion named `cli@1.2.0`; measured, that version installs, runs `init` and returns a valid envelope on this path, so it is not the broken fixture the row believed. `cli@1.1.0` is, on three independent counts: it publishes no `evolith` binary (only `evolith-cli`, so every documented invocation fails), its `init --name` writes a subdirectory so no `evolith.yaml` appears where the canary looks, and `validate --format json` **truncates its own envelope through a pipe** — 65 386 bytes of a document that is 163 622 to a file, while `1.2.2` writes 69 465 through the same pipe and parses. Green against `latest`, red against `1.1.0`, both observed. -- **Status:** `IN-PROGRESS` +- **Status:** `DONE` #### GT-672 diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index a3bdfb5e..199c9d16 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -48,7 +48,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-687`](./gap-reference-catalog.es.md#gt-687) | **El cable de ingest lleva el bit de waiver pero no el registro de la adjudicación, y el bit no distingue a un humano de una máquina.** `IngestViolation.frozen` está documentado como bandera de baseline/waiver y lo fijan tanto un waiver aprobado (`drift-gate.ts:174-185`) como una línea base de máquina (`policy-baseline.ts:99`), mientras el array `waived[]` que la compuerta ya calcula —`waiverRef`, `waiverVersion`, `expiresAt`— más aprobador y motivo se caen en el cable: `grep -an -i "waived" src/packages/contracts/src/ingest/evaluation-ingest.ts` → **0 hits** para el array; la única aparición de `waiv` en 860 líneas es el comentario sobre `frozen`, que el oráculo exige y que gobierna `blockingViolationCount`. La pata de vuelta tampoco existe: `grep -rn -iE "http\|url\|fetch"` sobre `calibrate.command.ts` → **0**, el comando lee `--labels ` de disco local. **Cuatro encuadres fueron REFUTADOS:** el cable no está libre de adjudicación; el endpoint va Core→Tracker, así que enriquecerlo no «viaja de vuelta al Core»; una decisión humana ya cruza en otro sitio (`tracker-approval.http-client.ts:16`); y `violations[].fingerprint` ya se transporta. **Bloqueado por [`GT-677`](./gap-reference-catalog.es.md#gt-677)** — hoy ningún llamante de producción aporta waivers, así que no hay nada que el miembro enriquecido pueda llevar hasta que eso aterrice. | Al enviar resultados al Tracker se pierde quién aprobó una excepción y por qué. | El Tracker distingue decisión humana de automática, lo que habilita la medición. | `Core Domain` | Cross | P2 | S | `DIFERIDO` | | [`GT-678`](./gap-reference-catalog.es.md#gt-678) | **No hay suavizado por regla para el tenant: los ids duplicados cargan de forma aditiva y un `enabled: false` escrito por el tenant lo acepta el esquema y lo tira el loader.** La selección por pack existe y está cableada (`--select`, `ProfileConfig.select`, `GT-659`/`GT-660`/`GT-661`), así que el hueco es de granularidad y dirección, no de existencia. **Medido contra el `DiskRulesetRepository` real** sobre un corpus donde un pack de tenant redefine la regla Core `ACL-02` como `severity: warning, blocking: false, enabled: false`: `TOTAL RULES LOADED: 2` — la copia Core sigue `MUST`/`blocking: true` y la del tenant `SHOULD`/`blocking: false` — con `enabled` **ausente en ambas**, descartado en vez de leído como falso. El esquema lo permite: `definitions.rule` no lleva `additionalProperties: false`, a diferencia de `definitions.enforce` a su lado. **Y la superficie de override por tenant que parece existir no existe:** `src/rulesets/tenants/**` embarca esquema, ejemplo y README con cero consumidores de código (`grep -rnE "overridesRef\|tenant-override" --include='*.ts'` → 0), y su README afirma que `multi-tenancy.rego` lo aplica, lo cual es falso — `MTN-01..08` leen `input.satellite.multiTenancy.*`. Separado a propósito de [`GT-669`](./gap-reference-catalog.es.md#gt-669) (escribir una regla) y de [`GT-673`](./gap-reference-catalog.es.md#gt-673) (que el upgrade la sobrescriba): mecanismos distintos, arreglos distintos. Una clave descartada en silencio es peor que una rechazada: parece configuración. | No se puede suavizar ni apagar una sola regla; intentarlo la duplica. | Adopción granular: aceptar el riesgo de una regla sin renunciar al pack entero. | `Core Domain` | Cross | P2 | M | `DIFERIDO` | | [`GT-682`](./gap-reference-catalog.es.md#gt-682) | **Todo comando de medición y apelación de gobernanza existe solo en el CLI: siete comandos no tienen herramienta MCP ni ruta REST.** `calibrate`, `audit verify`, `waiver`, `enforce`, `standards`, `history` y `profile` son comandos CLI registrados, y un bucle sobre esos nombres contra `src/packages/mcp-server/src/tools/*.ts` casa con **0 ficheros en todos ellos**, frente a 52 herramientas `evolith-*` registradas. La única superficie REST de `waiver` es de entrada (`WaiverFactDto`) más dos campos de metadatos. Así que un agente puede disparar una evaluación y no puede verificar el ledger en el que acaba de escribir, ni pedir un waiver por el veredicto que acaba de recibir, ni leer un informe de calibración. **El encuadre del candidato fue REFUTADO y ampliado:** la medición no es «el único sitio donde nunca se entregó paridad» — `enforce`, el propio comando de aplicación, es igual de inalcanzable, así que es un déficit general de al menos siete comandos y la afirmación de paridad del README de interfaces es más estrecha de lo que se lee. **Condicionado por [`GT-677`](./gap-reference-catalog.es.md#gt-677):** portar `waiver` antes de que los waivers surtan efecto llevaría una operación de solo escritura a una segunda superficie, algo que el registro de cierre debe desmentir o declarar por escrito. | Siete comandos —medir, apelar, verificar— existen solo en el CLI. | Un agente o el Tracker miden y apelan sin invocar el CLI por debajo. | `Evolith MCP` | Cross | P2 | M | `DIFERIDO` | -| [`GT-671`](./gap-reference-catalog.es.md#gt-671) | **Nada revolvía a comprobar el artefacto PUBLICADO después del día en que se publicaba, y la única comprobación post-publicación era `--help`.** `sdk-cli-release.yml:328-332` era la única instalación desde el registro, y el smoke funcional de encima corría el BINARIO descargado, no la instalación de npm; solo dos workflows tenían `schedule:`. **EN CURSO el 2026-08-16 — el canario está construido, programado y verde, y su primera corrida real encontró dos defectos del artefacto publicado.** Que `--help` es un oráculo débil quedó medido: **todas** las versiones publicadas — 1.1.0, 1.2.0, 1.2.2 — responden `--version` con exit 0, incluida la que `GT-625` registró como rota. La falsabilidad se prueba con un fixture MEDIDO y no supuesto: la fila nombraba `cli@1.2.0`, que instala y valida bien por este camino, así que el fixture rojo es **`cli@1.1.0`**, rojo por tres motivos — no publica el bin `evolith` (solo `evolith-cli`), su `init --name` escribe un subdirectorio y no aparece `evolith.yaml`, y `validate --format json` **trunca su propio envelope por tubería** en 65 386 bytes de un documento que a fichero ocupa 163 622 (`1.2.2` escribe 69 465 por la misma tubería y parsea). **El AC3 NO se cumple y por eso sigue abierta:** el paquete MCP publicado trae `files: ["dist/"…]` sin corpus de rulesets, así que `evolith-gate-evaluate` responde `RULESET_NOT_FOUND` y `evolith-validate` «no pudo localizar el corpus» — separado como `GT-705`. La aserción de gate se conserva y solo se exime ante ese síntoma exacto, así que empieza a morder el día que `GT-705` se entregue. | Nada comprobaba que lo que instalan los clientes siguiera funcionando; ahora algo lo hace, a diario. | Una rotura del paquete publicado se caza a la mañana siguiente en vez de descubrirla un usuario. | `Infra` | Cross | P1 | S | `EN-PROGRESO` | +| [`GT-671`](./gap-reference-catalog.es.md#gt-671) | **Nada revolvía a comprobar el artefacto PUBLICADO después del día en que se publicaba, y la única comprobación post-publicación era `--help`.** `sdk-cli-release.yml:328-332` era la única instalación desde el registro, y el smoke funcional de encima corría el BINARIO descargado, no la instalación de npm; solo dos workflows tenían `schedule:`. **COMPLETADO el 2026-08-16 — el canario está construido, programado y verde, su primera corrida real encontró dos defectos del artefacto publicado, y su última exención ya caducó.** Que `--help` es un oráculo débil quedó medido: **todas** las versiones publicadas — 1.1.0, 1.2.0, 1.2.2 — responden `--version` con exit 0, incluida la que `GT-625` registró como rota. La falsabilidad se prueba con un fixture MEDIDO y no supuesto: la fila nombraba `cli@1.2.0`, que instala y valida bien por este camino, así que el fixture rojo es **`cli@1.1.0`**, rojo por tres motivos — no publica el bin `evolith` (solo `evolith-cli`), su `init --name` escribe un subdirectorio y no aparece `evolith.yaml`, y `validate --format json` **trunca su propio envelope por tubería** en 65 386 bytes de un documento que a fichero ocupa 163 622 (`1.2.2` escribe 69 465 por la misma tubería y parsea). **El AC3 era el último abierto y ya está CUMPLIDO.** El paquete MCP publicado traía `files: ["dist/"…]` sin corpus de rulesets, así que `evolith-gate-evaluate` respondía `RULESET_NOT_FOUND` — separado como `GT-705`. La aserción de gate se conservó y solo se eximía ante ese síntoma exacto, anclada a `installedPackageShipsNoCorpus()`, **una propiedad del tarball instalado y no una versión ni una fecha**. `GT-705` se entregó como `mcp@1.3.2`, y la corrida del canario contra ella (`published-canary.yml`, run `31987205590`) asegura un **veredicto de gate real** con **cero** apariciones de «exempt» en su log: la exención caducó sola, sin nada que acordarse de borrar. Un salto en bloque seguiría verde hoy con el defecto arreglado por accidente. | Nada comprobaba que lo que instalan los clientes siguiera funcionando; ahora algo lo hace, a diario. | Una rotura del paquete publicado se caza a la mañana siguiente en vez de descubrirla un usuario. | `Infra` | Cross | P1 | S | `COMPLETADO` | | [`GT-670`](./gap-reference-catalog.es.md#gt-670) | **La única adjudicación orgánica que el producto ya captura — un waiver aprobado — nunca se convierte en la etiqueta de calibración que ese instrumento espera.** `evolith waiver` registra el `correlationId` del veredicto que se exime (`waiver.command.ts:229`) y el `Waiver` de dominio lleva el `fingerprint` de la violación suprimida más `reason`, `requestedBy`, `approvedBy`, `approvedAt` y un `expiresAt` duro (`domain/waiver.ts:29-48`), persistidos por `FileWaiverStore`. **Un waiver APROBADO es un humano decidiendo que una violación bloqueante no debió bloquear** — exactamente `humanBlocked: false` para esa regla sobre ese sujeto, que es la etiqueta que consume `evolith calibrate report` (`{ subject, rulesetId, gateBlocked, humanBlocked }`, `calibrate.command.ts:56`). Nada los conecta: `grep -rn "waiver" src/sdk/cli/src/commands/calibrate/` no devuelve coincidencias. [`GT-585`](./gap-reference-catalog.es.md#gt-585) está DIFERIDO por «no hay corpus orgánico de etiquetas hasta que algo corra en producción»; **esta fila es el canal que falta, no un segundo instrumento** — el registro de waivers ES ese corpus y nadie lo lee. Registrado el 2026-08-14 desde el benchmarking de Facility, cuyo watchtower une cada recibo de ejecución con su resultado posterior. La primera formulación («nada mide si la compuerta acertó») fue REFUTADA por el comando calibrate existente y se estrechó a esto. **El criterio sutil es la etiqueta confirmatoria:** un waiver rechazado debe exportarse como `humanBlocked: true`, o el corpus solo contiene desacuerdo y toda tasa da 100 %. | Cuando una persona aprueba una excepción, esa decisión no se guarda como dato. | Permite publicar con qué frecuencia se equivoca una regla, con decisiones reales y no opiniones. | `Governance` | Cross | P1 | M | `DIFERIDO` | | [`GT-669`](./gap-reference-catalog.es.md#gt-669) | **Un hallazgo recurrente no tiene camino hasta una regla determinista escrita por el tenant, así que el ratchet es práctica privada nuestra y no capacidad del producto.** Este repositorio convierte sus propios hallazgos en guardas — 112 ficheros bajo `.harness/scripts/ci/`, cada uno trazable a la fila del board que lo originó — y nada de ese bucle es alcanzable por un tenant. La única superficie de CLI orientada a reglas es `evolith rulesets`, cuya propia descripción es «List the ruleset packs this Core can evaluate» (`rulesets.command.ts:39-40`): solo lectura, 138 líneas, sin camino de autoría, validación ni publicación. El corpus vive en `src/rulesets/**` dentro del repositorio Core y `evolith upgrade` lo copia HACIA el satélite, así que hoy la única forma de que un tenant añada una regla es editar un fichero que el siguiente upgrade sobrescribe ([`GT-673`](./gap-reference-catalog.es.md#gt-673)). Registrado el 2026-08-14 desde el benchmarking de Facility, que embarca este bucle como capacidad de cabecera — feedback de revisión repetido graduado a comprobación determinista. **Sus guardas son scripts sin dependencias; la nuestra es política compilada con oráculo de builtins wasm ([`GT-644`](./gap-reference-catalog.es.md#gt-644)) — el activo está de nuestro lado y el camino al tenant no.** Es la forma producto de «el modelo propone, el verificador decide», y sin él «el cliente selecciona su nivel» solo vale para los packs que escribimos nosotros. | Un cliente no tiene forma soportada de convertir un problema recurrente en una regla suya. | El catálogo crece al ritmo del cliente, sin esperar a una release nuestra. | `Governance` | Cross | P1 | L | `DIFERIDO` | | [`GT-674`](./gap-reference-catalog.es.md#gt-674) | **El puerto de evidencia de ejecución de IA y su adaptador de Langfuse se embarcan sin consumidor: ninguna regla lee coste ni tokens, y ninguna superficie puede aportar una traza.** `ObservabilityEvidence` modela id de traza, modelo, nombre y versión de prompt, `costUsd`, `latencyMs`, `totalTokens`, llamadas a herramientas y puntuaciones (`domain/observability-evidence.ts:20-35`), y `LangfuseEvidenceAdapter` implementa `IObservabilityEvidenceSource` (`langfuse-evidence.adapter.ts:26`) y se exporta desde el barrel del paquete. **Nada consume ninguno de los dos símbolos** — fuera de esos dos ficheros, sus specs y el `index.ts`, un grep sobre `src` no encuentra nada: ni handler de regla, ni registro de DI en `core-api` ni en `mcp-server`, ni entrada de CLI o MCP que acepte una traza. Así que ninguna regla de gobernanza puede decidir nada sobre el coste, el modelo o la versión de prompt de una ejecución de IA, que es toda la clase de evidencia que el puerto existe para transportar. Registrado el 2026-08-14 desde el benchmarking de Facility, que sí decide con estos datos (presupuestos por proyecto en un gateway de modelos, atribución de coste por agente y tarea) — lo que hace que cablear el puerto merezca discusión en vez de borrarse por defecto. **La fila candidata más amplia fue REFUTADA antes de registrar:** no falta el conector, falta el consumidor. Un puerto sin consumidor se lee como capacidad embarcada en todo recuento de símbolos, así que la alternativa honesta — borrarlo — es aquí un criterio. | Modelamos el coste y los tokens de ejecuciones de IA y nadie lo consume. | Los límites de gasto pasan a ser regla evaluable — o borramos una capacidad aparente. | `Core Domain` | Cross | P2 | S | `DIFERIDO` | @@ -724,7 +724,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-705`](./gap-reference-catalog.es.md#gt-705) | **El servidor MCP publicado no traía corpus de rulesets Y adivinaba dónde estaba Core, así que 48 de sus 50 herramientas no podían gobernar nada desde una instalación limpia.** Encontrado por el canario de `GT-671`. **ARREGLADO el 2026-08-16 — dos causas independientes, y arreglar solo una no cambiaba nada.** (1) `files: ["dist/"…]` no llevaba corpus y ninguna dependencia lo aportaba; el paquete empaqueta ahora **los dos** árboles que el servidor necesita — corpus de rulesets y definiciones de gate SDLC. Que hacían falta ambos se OBSERVÓ, no se predijo: con solo el corpus, `evolith-validate` funcionaba y `evolith-gate-evaluate` seguía sin hacerlo. (2) `path.join(process.cwd(), '..', 'evolith')` — un directorio hermano con el nombre de este monorepo — en **9 sitios de 5 ficheros de mcp-server y 4 servicios de core-domain**, la capa que comparten las tres superficies. Ahora hay un solo resolutor: llamante → `EVOLITH_CORE_PATH` → subir desde el satélite → corpus empaquetado; `process.cwd()` no aparece. La búsqueda cualifica **por contenido**, lo que además cierra `GT-566` en esas cuatro copias — buscaban un directorio LLAMADO `rulesets` y este repo tiene un `rulesets/agents` que comparte nombre y no tiene reglas. DE PUNTA A PUNTA desde una instalación npm limpia y sin repositorio en disco: `evolith-validate` `INTERNAL_ERROR` → **veredicto `failed`**; `evolith-gate-evaluate` `RULESET_NOT_FOUND` → **veredicto `failed`, gate `business-sign-off`**. Tres specs aseguraban el contrato viejo y se reescribieron — uno se llamaba *«falls back to the sibling ../evolith convention»*. | El servidor MCP que instalas de npm anunciaba 50 herramientas y solo respondía las que no necesitan reglas. | Un agente conectado al servidor publicado puede gobernar de verdad. | `MCP Server` | Cross | P1 | M | `COMPLETADO` | -**Progreso:** 671 / 703 completados · 3 en progreso · 2 pendientes · 27 diferidos +**Progreso:** 672 / 703 completados · 2 en progreso · 2 pendientes · 27 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 680ecd25..08dde4fd 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -48,7 +48,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-687`](./gap-reference-catalog.md#gt-687) | **The ingest wire carries the waiver bit but not the adjudication record, and the bit cannot distinguish a human from a machine.** `IngestViolation.frozen` is documented as the baseline/waiver flag and is set both by an approved waiver (`drift-gate.ts:174-185`) and by a machine policy baseline (`policy-baseline.ts:99`), while the `waived[]` array the gate already computes — `waiverRef`, `waiverVersion`, `expiresAt` — plus the approver and reason are dropped at the wire: `grep -an -i "waived" src/packages/contracts/src/ingest/evaluation-ingest.ts` → **0 hits** for the array; the only `waiv` occurrence in 860 lines is the doc comment on `frozen`, which the oracle requires and which drives `blockingViolationCount`. The return leg does not exist either: `grep -rn -iE "http\|url\|fetch"` over `calibrate.command.ts` → **0**, the command reads `--labels ` off local disk. **Four framings were REFUTED:** the wire is not adjudication-free; the endpoint runs Core→Tracker so enriching it does not "travel back to the Core"; a human decision already crosses elsewhere (`tracker-approval.http-client.ts:16`); and `violations[].fingerprint` is already carried. **Blocked by [`GT-677`](./gap-reference-catalog.md#gt-677)** — no production caller supplies waivers today, so there is nothing for the enriched member to carry until that lands. | Sending results to the Tracker drops who approved an exception and why. | The Tracker can tell a human decision from an automatic one, which unlocks the measurement. | `Core Domain` | Cross | P2 | S | `DEFERRED` | | [`GT-678`](./gap-reference-catalog.md#gt-678) | **No per-rule tenant softening: duplicate ids load additively and an authored `enabled: false` is accepted by the schema and dropped by the loader.** Pack-level selection exists and is wired (`--select`, `ProfileConfig.select`, `GT-659`/`GT-660`/`GT-661`), so the gap is granularity and direction, not existence. **Measured against the real `DiskRulesetRepository`** over a corpus where a tenant pack redefines the Core rule `ACL-02` as `severity: warning, blocking: false, enabled: false`: `TOTAL RULES LOADED: 2` — the Core copy still `MUST`/`blocking: true` and the tenant copy `SHOULD`/`blocking: false` — with `enabled` **absent from both**, dropped rather than read as false. The schema permits it: `definitions.rule` carries no `additionalProperties: false`, unlike `definitions.enforce` beside it. **And the tenant-override surface that appears to exist does not:** `src/rulesets/tenants/**` ships a schema, an example and a README with zero code consumers (`grep -rnE "overridesRef\|tenant-override" --include='*.ts'` → 0), and its README claims `multi-tenancy.rego` enforces it, which is false — `MTN-01..08` read `input.satellite.multiTenancy.*`. Separated deliberately from [`GT-669`](./gap-reference-catalog.md#gt-669) (authoring a rule) and [`GT-673`](./gap-reference-catalog.md#gt-673) (upgrade overwriting it): different mechanisms, different fixes. A silently dropped key is worse than a rejected one — it looks like configuration. | A single rule cannot be softened or switched off; trying it duplicates the rule instead. | Granular adoption: accept one rule's risk without giving up the whole pack. | `Core Domain` | Cross | P2 | M | `DEFERRED` | | [`GT-682`](./gap-reference-catalog.md#gt-682) | **Every governance measurement and appeal command exists only on the CLI: seven commands have no MCP tool and no REST route.** `calibrate`, `audit verify`, `waiver`, `enforce`, `standards`, `history` and `profile` are registered CLI commands, and a loop over those names against `src/packages/mcp-server/src/tools/*.ts` matches **0 files for every one of them**, against 52 registered `evolith-*` tools. The only REST `waiver` surface is input-side (`WaiverFactDto`) plus two reference-metadata fields. So an agent can trigger an evaluation and cannot verify the ledger it just wrote to, cannot request a waiver for the verdict it just received, and cannot read a calibration report. **The candidate's framing was REFUTED and widened:** measurement is not "the one place parity was never delivered" — `enforce`, the enforcement command itself, is equally unreachable, so this is a general parity deficit of at least seven commands and the interfaces README's parity claim is narrower than it reads. **Gated by [`GT-677`](./gap-reference-catalog.md#gt-677):** porting `waiver` before waivers take effect would ship a write-only operation to a second surface, which the closure record must either disprove or state in writing. | Seven commands — measure, appeal, verify — exist only on the CLI. | An agent or the Tracker can measure and appeal without shelling out to the CLI. | `Evolith MCP` | Cross | P2 | M | `DEFERRED` | -| [`GT-671`](./gap-reference-catalog.md#gt-671) | **Nothing re-verified the PUBLISHED artifact after the day it was published, and the one post-publish check was `--help`.** `sdk-cli-release.yml:328-332` was the only registry install in the repository, and the functional smoke above it ran the downloaded BINARY, not the npm install; only two workflows carried a `schedule:`. **IN PROGRESS 2026-08-16 — the canary is built, scheduled and green, and its first real run found two defects in the published artifact.** `--help` proved to be a weak oracle by measurement: **every** published version — 1.1.0, 1.2.0, 1.2.2 — answers `--version` with exit 0, including the one `GT-625` recorded as broken. Falsifiability is proven with a fixture that was MEASURED rather than assumed: the row named `cli@1.2.0`, which installs and validates fine on this path, so the red fixture is **`cli@1.1.0`**, red on three counts — it publishes no `evolith` bin (only `evolith-cli`), its `init --name` writes a subdirectory so no `evolith.yaml` appears, and `validate --format json` **truncates its own envelope through a pipe** at 65 386 bytes of a document that is 163 622 to a file (`1.2.2` writes 69 465 through the same pipe and parses). **AC3 is NOT met and is why this stays open:** the published MCP package ships `files: ["dist/"…]` with no ruleset corpus, so `evolith-gate-evaluate` answers `RULESET_NOT_FOUND` and `evolith-validate` "could not locate the ruleset corpus" — split out as `GT-705`. The gate assertion is kept and exempted only on that exact symptom, so it starts biting the day `GT-705` ships. | Nothing checked that what customers install still works; now something does, daily. | A break in the published package is caught the next morning instead of by a user. | `Infra` | Cross | P1 | S | `IN-PROGRESS` | +| [`GT-671`](./gap-reference-catalog.md#gt-671) | **Nothing re-verified the PUBLISHED artifact after the day it was published, and the one post-publish check was `--help`.** `sdk-cli-release.yml:328-332` was the only registry install in the repository, and the functional smoke above it ran the downloaded BINARY, not the npm install; only two workflows carried a `schedule:`. **DONE 2026-08-16 — the canary is built, scheduled and green, its first real run found two defects in the published artifact, and its last exemption has now expired.** `--help` proved to be a weak oracle by measurement: **every** published version — 1.1.0, 1.2.0, 1.2.2 — answers `--version` with exit 0, including the one `GT-625` recorded as broken. Falsifiability is proven with a fixture that was MEASURED rather than assumed: the row named `cli@1.2.0`, which installs and validates fine on this path, so the red fixture is **`cli@1.1.0`**, red on three counts — it publishes no `evolith` bin (only `evolith-cli`), its `init --name` writes a subdirectory so no `evolith.yaml` appears, and `validate --format json` **truncates its own envelope through a pipe** at 65 386 bytes of a document that is 163 622 to a file (`1.2.2` writes 69 465 through the same pipe and parses). **AC3 was the last one open and is now MET.** The published MCP package shipped `files: ["dist/"…]` with no ruleset corpus, so `evolith-gate-evaluate` answered `RULESET_NOT_FOUND` — split out as `GT-705`. The gate assertion was kept and exempted only on that exact symptom, keyed on `installedPackageShipsNoCorpus()`, **a property of the installed tarball rather than a version or a date**. `GT-705` shipped as `mcp@1.3.2`, and the canary run against it (`published-canary.yml`, run `31987205590`) asserts a **real gate verdict** with **zero** occurrences of "exempt" in its log: the exemption expired by itself, with nothing to remember to delete. A blanket skip would still be green today with the defect fixed by accident. | Nothing checked that what customers install still works; now something does, daily. | A break in the published package is caught the next morning instead of by a user. | `Infra` | Cross | P1 | S | `DONE` | | [`GT-670`](./gap-reference-catalog.md#gt-670) | **The one organic adjudication the product already captures — an approved waiver — is never turned into the calibration label that instrument is waiting for.** `evolith waiver` records the `correlationId` of the verdict being waived (`waiver.command.ts:229`) and the domain `Waiver` carries the `fingerprint` of the suppressed violation plus `reason`, `requestedBy`, `approvedBy`, `approvedAt` and a hard `expiresAt` (`domain/waiver.ts:29-48`), persisted by `FileWaiverStore`. **An APPROVED waiver is a human deciding that a blocking violation should not have blocked** — exactly `humanBlocked: false` for that rule on that subject, which is the label `evolith calibrate report` consumes (`{ subject, rulesetId, gateBlocked, humanBlocked }`, `calibrate.command.ts:56`). Nothing connects them: `grep -rn "waiver" src/sdk/cli/src/commands/calibrate/` returns no matches. [`GT-585`](./gap-reference-catalog.md#gt-585) is DEFERRED on "no organic label corpus until something runs in production"; **this row is the missing channel, not a second instrument** — the waiver ledger IS that corpus and nothing reads it. Registered 2026-08-14 from the Facility benchmark, whose watchtower joins each run receipt to its eventual outcome. The first formulation ("nothing measures whether a gate was right") was REFUTED by the existing calibrate command and narrowed to this. **The subtle criterion is the confirming label:** a rejected waiver must export as `humanBlocked: true`, or the corpus contains only disagreement and every rate reads 100 %. | When a person approves an exception, that decision is never kept as data. | Lets us publish how often a rule is wrong, from real decisions instead of opinion. | `Governance` | Cross | P1 | M | `DEFERRED` | | [`GT-669`](./gap-reference-catalog.md#gt-669) | **A recurring finding has no path to a tenant-authored deterministic rule, so the ratchet is our private practice and not the product's capability.** This repository ratchets its own findings into guards — 112 files under `.harness/scripts/ci/`, each traceable to the board row that produced it — and none of that loop is reachable by a tenant. The only rule-facing CLI surface is `evolith rulesets`, whose own description is "List the ruleset packs this Core can evaluate" (`rulesets.command.ts:39-40`): read-only, 138 lines, no author, validate or publish path. The corpus lives in `src/rulesets/**` inside the Core repository and `evolith upgrade` copies it INTO the satellite, so the only way a tenant adds a rule today is to edit a file the next upgrade overwrites ([`GT-673`](./gap-reference-catalog.md#gt-673)). Registered 2026-08-14 from the Facility benchmark, which ships this loop as a headline capability — repeated review feedback graduated into a deterministic check. **Their guards are zero-dependency scripts; ours is compiled policy with a wasm builtin oracle ([`GT-644`](./gap-reference-catalog.md#gt-644)) — the asset is on our side and the path to the tenant is not.** This is the productised form of "the model proposes, the verifier decides", and without it "the client selects its level" holds only for packs we authored. | A customer has no supported way to turn a problem that keeps recurring into a rule of their own. | The rule catalogue grows at the customer's pace instead of waiting for our releases. | `Governance` | Cross | P1 | L | `DEFERRED` | | [`GT-674`](./gap-reference-catalog.md#gt-674) | **The AI-execution evidence port and its Langfuse adapter ship with no consumer: no rule reads cost or tokens, and no surface can supply a trace.** `ObservabilityEvidence` models trace id, model, prompt name and version, `costUsd`, `latencyMs`, `totalTokens`, tool calls and evaluation scores (`domain/observability-evidence.ts:20-35`), and `LangfuseEvidenceAdapter` implements `IObservabilityEvidenceSource` (`langfuse-evidence.adapter.ts:26`) and is exported from the package barrel. **Nothing consumes either symbol** — outside those two files, their specs and `index.ts`, a grep across `src` matches nothing: no rule handler, no DI registration in `core-api` or `mcp-server`, no CLI or MCP input that accepts a trace. So no governance rule can decide anything about an AI execution's cost, model or prompt version, which is the entire evidence class the port exists to carry. Registered 2026-08-14 from the Facility benchmark, which gates on exactly this data (per-project budgets at a model gateway, cost attribution per agent and task) — which is what makes wiring the port worth arguing about rather than deleting by default. **The broader candidate row was REFUTED before registration:** the connector is not missing, the consumer is. A port with no consumer reads as shipped capability in every inventory that counts symbols, so the honest alternative — delete it — is a criterion here. | We model the cost and tokens of AI runs and nothing consumes it. | Spend limits become an evaluable rule — or we delete an apparent capability. | `Core Domain` | Cross | P2 | S | `DEFERRED` | @@ -724,7 +724,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-705`](./gap-reference-catalog.md#gt-705) | **The published MCP server shipped no ruleset corpus AND guessed where Core was, so 48 of its 50 tools could not govern anything from a clean install.** Found by `GT-671`'s canary. **FIXED 2026-08-16 — two independent causes, and fixing either alone changed nothing.** (1) `files: ["dist/"…]` carried no corpus and no dependency supplied one; the package now bundles **both** trees the server needs — the ruleset corpus and the SDLC gate definitions. That both were required was OBSERVED, not predicted: with only the corpus, `evolith-validate` worked and `evolith-gate-evaluate` still did not. (2) `path.join(process.cwd(), '..', 'evolith')` — a sibling directory named after this monorepo — in **9 places across 5 files of mcp-server and 4 services of core-domain**, the layer all three surfaces share. One resolver now: caller → `EVOLITH_CORE_PATH` → walk up from the satellite → bundled corpus; `process.cwd()` is absent. The walk qualifies **by content**, which also closes `GT-566` in those four copies — they probed for a directory NAMED `rulesets` and this repo has a `rulesets/agents` that shares the name and holds no rules. END TO END from a clean npm install with no repository on disk: `evolith-validate` `INTERNAL_ERROR` → **verdict `failed`**; `evolith-gate-evaluate` `RULESET_NOT_FOUND` → **verdict `failed`, gate `business-sign-off`**. Three specs asserted the old contract and were rewritten — one was named *"falls back to the sibling ../evolith convention"*. | The MCP server you install from npm announced 50 tools and could only answer the ones needing no rules. | An agent connecting to the published server can actually govern something. | `MCP Server` | Cross | P1 | M | `DONE` | -**Progress:** 671 / 703 done · 3 in progress · 2 pending · 27 deferred +**Progress:** 672 / 703 done · 2 in progress · 2 pending · 27 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 6440dcb1..734d432e 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.es.md +++ b/reference/core/control-center/maturity-reports/executive-summary.es.md @@ -27,8 +27,8 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so |---:|---|---|---| | 1 | Bloqueadores P0 | Impiden afirmar readiness productivo o release mayor. | [GT-435](../gaps/gap-reference-catalog.es.md#gt-435) | | 2 | Área de mayor riesgo | `Governance` tiene la mayor carga ponderada abierta. | [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), [GT-672](../gaps/gap-reference-catalog.es.md#gt-672), [GT-689](../gaps/gap-reference-catalog.es.md#gt-689), [GT-588](../gaps/gap-reference-catalog.es.md#gt-588), +2 | -| 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-671](../gaps/gap-reference-catalog.es.md#gt-671), [GT-684](../gaps/gap-reference-catalog.es.md#gt-684) | -| 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [GT-671](../gaps/gap-reference-catalog.es.md#gt-671), [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-680](../gaps/gap-reference-catalog.es.md#gt-680), [GT-681](../gaps/gap-reference-catalog.es.md#gt-681), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), +1 | +| 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684) | +| 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-680](../gaps/gap-reference-catalog.es.md#gt-680), [GT-681](../gaps/gap-reference-catalog.es.md#gt-681), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), [GT-448](../gaps/gap-reference-catalog.es.md#gt-448) | | 5 | P2/P3 | Solo después de estabilizar seguridad, CI, reglas y contratos. | [GT-444](../gaps/gap-reference-catalog.es.md#gt-444), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-674](../gaps/gap-reference-catalog.es.md#gt-674), [GT-685](../gaps/gap-reference-catalog.es.md#gt-685), [GT-686](../gaps/gap-reference-catalog.es.md#gt-686), [GT-687](../gaps/gap-reference-catalog.es.md#gt-687), +12 | ## Bloqueadores Actuales @@ -43,21 +43,21 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so |---|---:| | Fecha canónica del tablero | 2026-08-08 | | Gaps totales | 703 | -| Gaps cerrados | 671 | -| Gaps pendientes | 32 | +| Gaps cerrados | 672 | +| Gaps pendientes | 31 | | P0 abiertos | 1 | -| P1 abiertos | 9 | +| P1 abiertos | 8 | | P2 abiertos | 18 | -| Cierre total | 95.4% | -| Registros de evidencia de cierre | 653 | +| Cierre total | 95.6% | +| Registros de evidencia de cierre | 654 | | Readiness registrado | 4 PASS | | Área | Pendientes | P0 | P1 | Primeros IDs | |---|---:|---:|---:|---| | `Governance` | 8 | 0 | 3 | [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), [GT-672](../gaps/gap-reference-catalog.es.md#gt-672), +4 | | `Cross` | 3 | 1 | 1 | [GT-435](../gaps/gap-reference-catalog.es.md#gt-435), [GT-448](../gaps/gap-reference-catalog.es.md#gt-448), [GT-651](../gaps/gap-reference-catalog.es.md#gt-651) | -| `Infra` | 6 | 0 | 2 | [GT-671](../gaps/gap-reference-catalog.es.md#gt-671), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-685](../gaps/gap-reference-catalog.es.md#gt-685), +2 | | `MCP Server` | 3 | 0 | 3 | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-680](../gaps/gap-reference-catalog.es.md#gt-680), [GT-681](../gaps/gap-reference-catalog.es.md#gt-681) | +| `Infra` | 5 | 0 | 1 | [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-685](../gaps/gap-reference-catalog.es.md#gt-685), [GT-691](../gaps/gap-reference-catalog.es.md#gt-691), +1 | | `Core Domain` | 4 | 0 | 0 | [GT-674](../gaps/gap-reference-catalog.es.md#gt-674), [GT-687](../gaps/gap-reference-catalog.es.md#gt-687), [GT-678](../gaps/gap-reference-catalog.es.md#gt-678), [GT-704](../gaps/gap-reference-catalog.es.md#gt-704) | ## Fuente y Regla de Actualización diff --git a/reference/core/control-center/maturity-reports/executive-summary.md b/reference/core/control-center/maturity-reports/executive-summary.md index 3d8a258d..0d7cf194 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.md +++ b/reference/core/control-center/maturity-reports/executive-summary.md @@ -27,8 +27,8 @@ Use this summary with a simple rule: if you need context, open only the linked I |---:|---|---|---| | 1 | P0 blockers | They prevent production-readiness or major-release confidence. | [GT-435](../gaps/gap-reference-catalog.md#gt-435) | | 2 | Highest-risk area | `Governance` has the largest weighted open load. | [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), [GT-672](../gaps/gap-reference-catalog.md#gt-672), [GT-689](../gaps/gap-reference-catalog.md#gt-689), [GT-588](../gaps/gap-reference-catalog.md#gt-588), +2 | -| 3 | Quick wins | High criticality with XS/S complexity. | [GT-671](../gaps/gap-reference-catalog.md#gt-671), [GT-684](../gaps/gap-reference-catalog.md#gt-684) | -| 4 | P1 wave | Next hardening after P0 is cleared. | [GT-671](../gaps/gap-reference-catalog.md#gt-671), [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-680](../gaps/gap-reference-catalog.md#gt-680), [GT-681](../gaps/gap-reference-catalog.md#gt-681), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), +1 | +| 3 | Quick wins | High criticality with XS/S complexity. | [GT-684](../gaps/gap-reference-catalog.md#gt-684) | +| 4 | P1 wave | Next hardening after P0 is cleared. | [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-680](../gaps/gap-reference-catalog.md#gt-680), [GT-681](../gaps/gap-reference-catalog.md#gt-681), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), [GT-448](../gaps/gap-reference-catalog.md#gt-448) | | 5 | P2/P3 | Only after security, CI, rules, and contracts stabilize. | [GT-444](../gaps/gap-reference-catalog.md#gt-444), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-674](../gaps/gap-reference-catalog.md#gt-674), [GT-685](../gaps/gap-reference-catalog.md#gt-685), [GT-686](../gaps/gap-reference-catalog.md#gt-686), [GT-687](../gaps/gap-reference-catalog.md#gt-687), +12 | ## Current Blockers @@ -43,21 +43,21 @@ Use this summary with a simple rule: if you need context, open only the linked I |---|---:| | Canonical board date | 2026-08-08 | | Total gaps | 703 | -| Closed gaps | 671 | -| Open gaps | 32 | +| Closed gaps | 672 | +| Open gaps | 31 | | Open P0 | 1 | -| Open P1 | 9 | +| Open P1 | 8 | | Open P2 | 18 | -| Total closure | 95.4% | -| Closure evidence records | 653 | +| Total closure | 95.6% | +| Closure evidence records | 654 | | Recorded readiness | 4 PASS | | Area | Open | P0 | P1 | First IDs | |---|---:|---:|---:|---| | `Governance` | 8 | 0 | 3 | [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), [GT-672](../gaps/gap-reference-catalog.md#gt-672), +4 | | `Cross` | 3 | 1 | 1 | [GT-435](../gaps/gap-reference-catalog.md#gt-435), [GT-448](../gaps/gap-reference-catalog.md#gt-448), [GT-651](../gaps/gap-reference-catalog.md#gt-651) | -| `Infra` | 6 | 0 | 2 | [GT-671](../gaps/gap-reference-catalog.md#gt-671), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-685](../gaps/gap-reference-catalog.md#gt-685), +2 | | `MCP Server` | 3 | 0 | 3 | [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-680](../gaps/gap-reference-catalog.md#gt-680), [GT-681](../gaps/gap-reference-catalog.md#gt-681) | +| `Infra` | 5 | 0 | 1 | [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-685](../gaps/gap-reference-catalog.md#gt-685), [GT-691](../gaps/gap-reference-catalog.md#gt-691), +1 | | `Core Domain` | 4 | 0 | 0 | [GT-674](../gaps/gap-reference-catalog.md#gt-674), [GT-687](../gaps/gap-reference-catalog.md#gt-687), [GT-678](../gaps/gap-reference-catalog.md#gt-678), [GT-704](../gaps/gap-reference-catalog.md#gt-704) | ## Source and Refresh Rule diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 8e44b68d..b9f19e16 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-08-08", "gaps": { "total": 703, - "done": 671, + "done": 672, "pending": 2, - "inProgress": 3, + "inProgress": 2, "deferred": 27 }, "evidence": { - "closureRecords": 653, + "closureRecords": 654, "cliPackage": "@beyondnet/evolith-cli@1.3.1", "adrCount": 141, "rulesetCount": 181, diff --git a/src/packages/mcp-server/src/main.spec.ts b/src/packages/mcp-server/src/main.spec.ts index 72a53cd1..54ad9452 100644 --- a/src/packages/mcp-server/src/main.spec.ts +++ b/src/packages/mcp-server/src/main.spec.ts @@ -37,4 +37,56 @@ describe('parseArgs', () => { it('recognizes the version command', () => { expect(parseArgs(['node', 'main', 'version'], {}).command).toBe('version'); }); + + // The defect these were written against, measured on the published 1.3.2: + // `command` was `args.find((a) => !a.startsWith('-')) ?? 'serve'`, so every + // flag spelling fell through to 'serve' and `evolith-mcp --version` started + // the MCP server. With stdin closed it exited 0 printing nothing; with stdin + // open it never returned. The positional `version` worked the whole time, + // which is why nothing noticed. + it.each(['--version', '-v', '-V'])('treats %s as the version command, not serve', (flag) => { + expect(parseArgs(['node', 'main', flag], {}).command).toBe('version'); + }); + + it.each(['--help', '-h'])('treats %s as the help command, not serve', (flag) => { + expect(parseArgs(['node', 'main', flag], {}).command).toBe('help'); + }); + + it('does not fall back to serve for a flag-shaped command', () => { + expect(parseArgs(['node', 'main', '--version'], {}).command).not.toBe('serve'); + expect(parseArgs(['node', 'main', '--help'], {}).command).not.toBe('serve'); + }); + + it('answers the version even when transport flags are also present', () => { + // A probe does not curate its argv, and booting a server because one was + // present is the behaviour being removed. + expect(parseArgs(['node', 'main', '--transport', 'http', '--version'], {}).command).toBe('version'); + }); + + it('still serves when only real flags are given', () => { + expect(parseArgs(['node', 'main', '--transport', 'http', '--port', '8080'], {}).command).toBe('serve'); + expect(parseArgs(['node', 'main', 'serve', '--allow-no-auth'], {}).command).toBe('serve'); + }); + + // Found by the test above, which failed on the FIXED parser for a reason that + // had nothing to do with --version: `http` is the first token not starting + // with `-`, so it was read as the command and `evolith-mcp --transport http` + // exited 1 with `Unknown command: http`. + it('does not mistake a flag value for the command', () => { + expect(parseArgs(['node', 'main', '--transport', 'http'], {}).command).toBe('serve'); + expect(parseArgs(['node', 'main', '--port', '8080'], {}).command).toBe('serve'); + expect(parseArgs(['node', 'main', '--api-key', 'secret'], {}).command).toBe('serve'); + expect(parseArgs(['node', 'main', '-t', 'http'], {}).command).toBe('serve'); + }); + + it('still parses the values it skipped over', () => { + const cli = parseArgs(['node', 'main', '--transport', 'http', '--port', '8080', '--api-key', 'k'], {}); + expect(cli).toMatchObject({ command: 'serve', transport: 'http', port: 8080, apiKey: 'k' }); + }); + + it('does not read a flag value as a version request', () => { + // `--api-key -v` is a strange key, but the value belongs to the flag. + expect(parseArgs(['node', 'main', '--api-key', '-v'], {}).command).toBe('serve'); + expect(parseArgs(['node', 'main', '--api-key', '-v'], {}).apiKey).toBe('-v'); + }); }); diff --git a/src/packages/mcp-server/src/main.ts b/src/packages/mcp-server/src/main.ts index 64afe546..3a5938cb 100644 --- a/src/packages/mcp-server/src/main.ts +++ b/src/packages/mcp-server/src/main.ts @@ -48,7 +48,40 @@ const USAGE = /** Parse argv + environment into normalized start options. */ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv): CliArgs { const args = argv.slice(2); - const command = args.find((a) => !a.startsWith('-')) ?? 'serve'; + + // `--version` and `--help` are flags by shape and commands by intent. Without + // this, the `find` below skips anything starting with `-`, `command` falls back + // to 'serve', and `evolith-mcp --version` BOOTS THE MCP SERVER instead of + // answering. Measured against the published 1.3.2: + // + // stdin closed -> exit 0, stdout EMPTY (the stdio transport takes EOF and leaves) + // stdin open -> never returns (timed out at 10s, printed nothing) + // + // The second is what a terminal, a doctor script or a CI probe actually does, + // and asking a binary its version is the first thing anyone does after + // installing it. `evolith-mcp version` already worked; only the flag spelling + // did not, which is the spelling everyone reaches for first. + // A second defect the tests for the first one exposed: the old `find` also + // matched a FLAG'S VALUE. `evolith-mcp --transport http` resolved command to + // 'http' and died with `Unknown command: http`, because `http` is the first + // token not starting with `-`. So classify once, skipping the value each + // value-taking flag consumes, and read both answers off that. + const VALUE_FLAGS = new Set(['--transport', '-t', '--port', '-p', '--api-key']); + const free: string[] = []; + for (let i = 0; i < args.length; i++) { + if (VALUE_FLAGS.has(args[i])) { + i++; // its value is not a command, and not a version flag either + continue; + } + free.push(args[i]); + } + + const asCommand = free.some((a) => a === '--version' || a === '-v' || a === '-V') + ? 'version' + : free.some((a) => a === '--help' || a === '-h') + ? 'help' + : undefined; + const command = asCommand ?? free.find((a) => !a.startsWith('-')) ?? 'serve'; const flag = (long: string, short?: string): string | undefined => { for (let i = 0; i < args.length; i++) { @@ -157,6 +190,11 @@ async function bootstrap(): Promise { return; } + if (cli.command === 'help') { + process.stdout.write(`${USAGE}\n`); + return; + } + if (cli.command !== 'serve') { process.stderr.write(`Unknown command: ${cli.command}. ${USAGE}\n`); process.exitCode = 1; diff --git a/src/sdk/cli/scripts/check-install-smoke.mjs b/src/sdk/cli/scripts/check-install-smoke.mjs index 576c7526..e92c62f4 100644 --- a/src/sdk/cli/scripts/check-install-smoke.mjs +++ b/src/sdk/cli/scripts/check-install-smoke.mjs @@ -267,14 +267,55 @@ function verifyTree(treeDir, packageName, boot, declaredSiblingDeps = null) { const bin = join(packageDir, 'dist', 'main.js'); if (!existsSync(bin)) fail([`entry point missing from the installed package: ${bin}`]); - const res = run(process.execPath, [bin, '--version'], { cwd: treeDir }); + + // The exit status alone is not evidence that `--version` answered. + // + // This check used to assert only `status === 0` and then PRINT the stdout it + // never inspected. Release run 31986300098 logged, verbatim: + // + // ✓ the installed binary boots: --version prints + // + // — an empty observable, reported as a pass. `@beyondnet/evolith-mcp@1.3.2` + // was ignoring the flag and booting the MCP server; the stdio transport read + // EOF from the closed stdin this spawn gives it, exited 0, and the gate agreed. + // With stdin held open the same command never returns at all. + // + // So: a timeout, because a binary that hangs must fail rather than hang CI, and + // an assertion that the output actually carries the version the manifest + // declares. That last part is what makes it non-vacuous — it would still pass + // on any non-empty string otherwise, including an error message. + const res = run(process.execPath, [bin, '--version'], { cwd: treeDir, timeout: 30_000 }); + if (res.error && res.error.code === 'ETIMEDOUT') { + fail([ + '`node dist/main.js --version` did not answer within 30s from a clean install.', + 'A binary that hangs on --version is one that never parsed the flag: asking a tool', + 'its version is the first thing anyone does after installing it.', + ]); + } if (res.status !== 0) { fail([ `\`node dist/main.js --version\` exited ${res.status} from a clean install.`, ...String(res.stderr || res.stdout || '').split('\n').slice(0, 6).map((l) => ` ${l}`), ]); } - console.log(`✓ the installed binary boots: --version prints ${String(res.stdout).trim()}`); + const printed = String(res.stdout || '').trim(); + const expected = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')).version; + if (!printed) { + fail([ + '`--version` exited 0 and printed NOTHING on stdout.', + `Expected output carrying ${expected}. An exit code is not an answer: a binary that`, + 'ignores the flag and starts a server exits 0 too, which is exactly how this went', + 'unnoticed until it was already on the registry.', + ]); + } + if (!printed.includes(expected)) { + fail([ + `\`--version\` printed ${JSON.stringify(printed)}, which does not contain ${expected}.`, + 'The reported version must match the manifest being published, or the binary is', + 'answering for a different build than the one in this tarball.', + ]); + } + console.log(`✓ the installed binary answers --version: ${printed} (matches manifest ${expected})`); } function main(argv) { @@ -306,7 +347,15 @@ function main(argv) { const existingTree = opt('--tree'); if (existingTree) { - verifyTree(resolve(existingTree), packageName, boot, declaredSiblingDeps); + // In --tree mode the artifact under test is the TREE, not this workspace. + // `boot` above is decided from `pkgRoot/dist/main.js` — the local build — so + // on a checkout that has not been built it came out false, the boot check + // never ran, and the guard still printed a pass. The strongest signal it has + // must not depend on unrelated local state. + const treeDir = resolve(existingTree); + const treeBoot = !has('--no-boot') + && existsSync(join(treeDir, 'node_modules', ...packageName.split('/'), 'dist', 'main.js')); + verifyTree(treeDir, packageName, treeBoot, declaredSiblingDeps); console.log(`✓ ${GUARD} passed (offline, existing tree).`); return; }