From a97087a2515be0a4db3d95d2347e87edf42f8fc7 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 10:39:41 -0500 Subject: [PATCH 1/8] perf(core-api): the ruleset corpus is deployment state, read once (GT-648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances GT-648 `RuleEvaluationEngine.discoverAndEvaluate` calls `loadAllRulesets(corePath)` on every evaluation, and core-api's `IRulesetRepository` was a bare `DiskRulesetRepository` with no cache. So each `POST /api/v1/evaluate` walked the corpus tree, read 176 `*.rules.json` files, and ran every one through Ajv to produce the same 393 rules as the request before it. Both halves are synchronous CPU work inside `await`ed calls, so nothing else on the event loop advanced while they ran — which makes it a whole-process latency defect, not a slow endpoint. CI run 30631939687 (Reliability workflow) shows both halves: the corpus's load-time WARN block repeats once per k6 iteration, and a `GET /health` that landed during that work took 498.5 ms end-to-end — `http_req_waiting` 498.4 ms against `http_req_connecting` 0.17 ms — while core-api logged its own handler at `durationMs=0`. The request was never slow; it was queued behind a blocked loop. Reproduced against `origin/develop` at 3051e499 before changing anything: eleven evaluations produced exactly eleven corpus loads, and one `loadAllRulesets` call cost 30.4–54.2 ms with a 10 ms timer queued across it firing up to 14 ms late. That laptop figure is roughly a tenth of the runner's and the gap is the point — a warm page cache on an SSD is the best case for exactly this work. The cache is a decorator (`CachingRulesetRepository`), not a field on the disk repository, because "load once" is a property of THIS deployment: the CLI is a one-shot process that must keep re-reading disk between invocations. Three properties are load-bearing and each is pinned by a test — the in-flight promise is memoized so concurrent cold requests do not each start their own walk; failures are never cached, so GT-474 keeps its meaning and a repaired corpus loads on the next attempt instead of latching the process into permanent failure; and each caller gets its own array, because the coverage helpers are free to sort what they are handed. A cache alone would still leave request #1 — the one a readiness probe lets through — paying the load, so `RulesetCorpusWarmupService` reads the corpus at `OnApplicationBootstrap` and logs the count as the evidence that it happened once: `Ruleset corpus loaded at startup: 393 rules … in 181 ms`. Also per request, and compounding it: `SdlcDataLoaderService.loadAllGates` re-read the phase index once per phase (`loadGatesForPhase` → `loadPhase` → `loadAllPhases`), turning one scan into N+1. Memoized per instance. Verdicts are unchanged, verified rather than argued. The full `EvaluationResult` for the k6 smoke payload was captured from a build of `origin/develop` and from this change, and the two are byte-for-byte identical at 442,204 bytes. The normalized corpus was diffed against the pre-change loader separately: 393 rules, identical JSON. Measured after: evaluate median 52.7 → 38.2 ms, and a `/health` issued 5 ms into an in-flight evaluate 11.6 → 3.5 ms. Still to be re-measured on a runner, where the 498 ms was observed — local numbers understate this by ~10x by construction. The lockfile line is a real pre-existing drift, not a regeneration: `infra-providers/package.json` has declared `@beyondnet/evolith-contracts` while the lockfile did not record it, which is the `Cannot find module '@beyondnet/evolith-contracts/ingest'` that keeps surfacing in CI. Co-Authored-By: Claude Opus 5 --- package-lock.json | 1 + src/apps/core-api/src/app.module.ts | 3 + .../services/ruleset-corpus-warmup.service.ts | 64 +++++++ src/apps/core-api/src/core-domain.module.ts | 16 +- .../services/sdlc-data-loader.service.ts | 24 +++ .../src/caching-ruleset.repository.spec.ts | 174 ++++++++++++++++++ .../src/caching-ruleset.repository.ts | 111 +++++++++++ src/packages/infra-providers/src/index.ts | 2 + 8 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 src/apps/core-api/src/application/services/ruleset-corpus-warmup.service.ts create mode 100644 src/packages/infra-providers/src/caching-ruleset.repository.spec.ts create mode 100644 src/packages/infra-providers/src/caching-ruleset.repository.ts diff --git a/package-lock.json b/package-lock.json index 28b701367..7290f9f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16442,6 +16442,7 @@ "version": "1.2.0", "license": "MIT", "dependencies": { + "@beyondnet/evolith-contracts": "^1.1.0", "@beyondnet/evolith-core-domain": "^1.2.0", "@nestjs/common": "11.1.27", "ajv": "8.20.0", diff --git a/src/apps/core-api/src/app.module.ts b/src/apps/core-api/src/app.module.ts index 6c885b852..69d62e646 100644 --- a/src/apps/core-api/src/app.module.ts +++ b/src/apps/core-api/src/app.module.ts @@ -25,6 +25,7 @@ import { CapabilitiesController } from './presentation/controllers/capabilities. import { ComposableValidateController } from './presentation/controllers/composable-validate.controller'; import { SatellitesController } from './presentation/controllers/satellites.controller'; import { WorkspaceReferenceResolverService } from './application/services/workspace-reference-resolver.service'; +import { RulesetCorpusWarmupService } from './application/services/ruleset-corpus-warmup.service'; import { SatelliteRegistryService } from './application/services/satellite-registry.service'; import { ValidateSatelliteUseCase, ProposePhaseAdvanceUseCase } from '@beyondnet/evolith-core-domain/application/use-cases'; import { ArchitectureDriftService } from '@beyondnet/evolith-core-domain/application/validators'; @@ -118,6 +119,8 @@ import { CacheMetricsService } from './infrastructure/cache/cache-metrics.servic CoreReferenceQueryService, WorkspaceReferenceResolverService, SatelliteRegistryService, + // GT-648 — load the ruleset corpus before the first request, not during it. + RulesetCorpusWarmupService, { // GT-573: the SINGLE construction site for the Core Evaluation Engine. // Both `POST /api/v1/evaluate` branches (canonical workspaceRef and inline diff --git a/src/apps/core-api/src/application/services/ruleset-corpus-warmup.service.ts b/src/apps/core-api/src/application/services/ruleset-corpus-warmup.service.ts new file mode 100644 index 000000000..8e87c7a8b --- /dev/null +++ b/src/apps/core-api/src/application/services/ruleset-corpus-warmup.service.ts @@ -0,0 +1,64 @@ +import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { CachingRulesetRepository } from '@beyondnet/evolith-infra-providers'; +import type { EnvConfig } from '../../infrastructure/config/env.validation'; + +/** + * GT-648 — read the ruleset corpus at startup, so no request ever pays for it. + * + * The cache alone would already fix the steady state: request #2 onwards would + * be served from memory. It would not fix request #1, which is exactly the + * request a readiness probe lets through the door — the deployment would look + * ready and then stall the event loop on the first real evaluation. + * + * So the corpus is loaded here, before the server accepts traffic, and the rule + * count is logged as the evidence that it happened once rather than per request. + * + * A failure is logged and swallowed on purpose. `CachingRulesetRepository` does + * not cache rejections, so a corpus that is unreadable at boot is retried by the + * first request that needs it and fails there — as a request-scoped + * `RulesetsNotFoundError` with its full probe trail, which is where an operator + * can see it. Refusing to boot would instead turn a misconfigured `CORE_PATH` + * into a crash-loop with the useful diagnostic buried in restart noise, and + * `GET /health/ready` already answers whether the corpus is reachable. + */ +@Injectable() +export class RulesetCorpusWarmupService implements OnApplicationBootstrap { + private readonly logger = new Logger(RulesetCorpusWarmupService.name); + + constructor( + @Inject('IRulesetRepository') + private readonly rulesetRepo: unknown, + private readonly config: ConfigService, + ) {} + + async onApplicationBootstrap(): Promise { + // Only a caching repository has a corpus to warm. A deployment wired to the + // plain disk repository (or a test double) is left alone rather than made to + // pay for a load whose result nothing would keep. + if (!(this.rulesetRepo instanceof CachingRulesetRepository)) return; + + const corePath = this.config.get('CORE_PATH', { infer: true }) as + | string + | undefined; + if (!corePath) { + this.logger.warn( + 'CORE_PATH is not set; the ruleset corpus will be loaded on first use instead of at startup', + ); + return; + } + + const startedAt = Date.now(); + try { + const ruleCount = await this.rulesetRepo.preload(corePath); + this.logger.log( + `Ruleset corpus loaded at startup: ${ruleCount} rules from ${corePath} in ${Date.now() - startedAt} ms (loaded once; evaluations no longer re-read disk)`, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + this.logger.error( + `Ruleset corpus could not be preloaded from ${corePath}; the first evaluation will retry and report the fault: ${message}`, + ); + } + } +} diff --git a/src/apps/core-api/src/core-domain.module.ts b/src/apps/core-api/src/core-domain.module.ts index c8908c43f..7ebf1dac1 100644 --- a/src/apps/core-api/src/core-domain.module.ts +++ b/src/apps/core-api/src/core-domain.module.ts @@ -17,7 +17,7 @@ import { ArchitectureDriftService } from '@beyondnet/evolith-core-domain/application/validators'; import { IFileSystem, ILogger, IConfigParser, ICatalogLoader } from '@beyondnet/evolith-core-domain/domain/interfaces'; -import { DiskRulesetRepository } from '@beyondnet/evolith-infra-providers'; +import { CachingRulesetRepository, DiskRulesetRepository } from '@beyondnet/evolith-infra-providers'; import { TopologyCatalogService, TopologyRecommendationService, PhaseArtifactProfileService, PatternCatalogService } from '@beyondnet/evolith-core-domain/application/services'; const CoreDomainProviders = [ @@ -34,8 +34,20 @@ const CoreDomainProviders = [ useFactory: () => new YamlConfigParserProvider().createConfigParser('yaml'), }, { + // GT-648: core-api is a long-running process, so the corpus is loaded ONCE + // and reused. Before this, `RuleEvaluationEngine.discoverAndEvaluate` called + // `loadAllRulesets` on every `POST /api/v1/evaluate` — a full directory walk + // plus an Ajv pass over ~176 files, all synchronous CPU work that blocks the + // event loop. It is why a `GET /health` measured 498 ms end-to-end at 1 VU + // while its own handler reported `durationMs=0`. + // + // The decorator, not the disk repository, holds the cache: the CLI is a + // one-shot process that must keep re-reading disk between invocations, and + // caching inside `DiskRulesetRepository` would have made "load once" a + // property of the adapter instead of a property of THIS deployment. provide: 'IRulesetRepository', - useFactory: (fs: IFileSystem, logger: ILogger) => new DiskRulesetRepository(fs, logger), + useFactory: (fs: IFileSystem, logger: ILogger) => + new CachingRulesetRepository(new DiskRulesetRepository(fs, logger), logger), inject: ['IFileSystem', 'ILogger'], }, { diff --git a/src/packages/core-domain/src/application/services/sdlc-data-loader.service.ts b/src/packages/core-domain/src/application/services/sdlc-data-loader.service.ts index 11238f544..87fc2a9ee 100644 --- a/src/packages/core-domain/src/application/services/sdlc-data-loader.service.ts +++ b/src/packages/core-domain/src/application/services/sdlc-data-loader.service.ts @@ -42,6 +42,17 @@ export interface StructuredGate { * Schema: reference/governance/sdlc/sdlc-gate.schema.json */ export class SdlcDataLoaderService { + /** + * GT-648 — the phase index, read at most once per instance. + * + * `loadAllGates()` calls `loadAllPhases()` and then `loadGatesForPhase()` per + * phase, and each of those calls `loadPhase()` → `loadAllPhases()` again: one + * directory scan and N JSON parses turned into N+1 of them, per evaluation. + * The data is a fixed on-disk index, so re-reading it inside a single load + * cannot produce a different answer — only more blocking I/O. + */ + private phases?: Promise; + constructor( private readonly fs: IFileSystem, private readonly logger: ILogger, @@ -57,6 +68,19 @@ export class SdlcDataLoaderService { } async loadAllPhases(): Promise { + // Memoize the promise, not the result: `loadAllGates` fans out over phases + // and would otherwise start several scans before the first one resolved. + // A rejected read is evicted so a transient I/O fault is not made permanent. + if (!this.phases) { + this.phases = this.readAllPhases(); + this.phases.catch(() => { + this.phases = undefined; + }); + } + return [...(await this.phases)]; + } + + private async readAllPhases(): Promise { const dir = this.phasesDir; if (!(await this.fs.exists(dir))) { this.logger.warn(`Phases directory not found: ${dir}`); diff --git a/src/packages/infra-providers/src/caching-ruleset.repository.spec.ts b/src/packages/infra-providers/src/caching-ruleset.repository.spec.ts new file mode 100644 index 000000000..60638cee9 --- /dev/null +++ b/src/packages/infra-providers/src/caching-ruleset.repository.spec.ts @@ -0,0 +1,174 @@ +import { NormalizedRule } from '@beyondnet/evolith-core-domain/domain/models/normalized-rule'; +import { IRulesetRepository } from '@beyondnet/evolith-core-domain/domain/ports/ruleset-repository.port'; +import { CachingRulesetRepository } from './caching-ruleset.repository'; + +function rule(id: string): NormalizedRule { + return { + id, + severity: 'MUST', + category: 'governance', + title: id, + description: '', + blocking: true, + sourceFile: `${id}.rules.json`, + } as NormalizedRule; +} + +/** Counts loads and lets a test hold one open, so single-flight is observable. */ +function makeInner(): IRulesetRepository & { + calls: string[]; + release: () => void; + fail: (err: Error) => void; + succeed: () => void; +} { + let pending: { resolve: (r: NormalizedRule[]) => void; reject: (e: Error) => void } | null = + null; + let mode: 'immediate' | 'deferred' = 'immediate'; + let nextError: Error | null = null; + const calls: string[] = []; + + return { + calls, + async loadAllRulesets(corePath: string) { + calls.push(corePath); + if (nextError) { + const err = nextError; + throw err; + } + if (mode === 'immediate') return [rule('GOV-1'), rule('GOV-2')]; + return new Promise((resolve, reject) => { + pending = { resolve, reject }; + }); + }, + release() { + mode = 'deferred'; + }, + fail(err: Error) { + nextError = err; + }, + succeed() { + nextError = null; + pending?.resolve([rule('GOV-1'), rule('GOV-2')]); + }, + }; +} + +describe('CachingRulesetRepository (GT-648)', () => { + // The defect this exists for: core-api re-read and re-validated the whole + // corpus on every POST /api/v1/evaluate, blocking the event loop each time. + it('reads the corpus once and serves every later load from memory', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + await repo.loadAllRulesets('/core'); + await repo.loadAllRulesets('/core'); + await repo.loadAllRulesets('/core'); + + expect(inner.calls).toEqual(['/core']); + }); + + it('returns the same rules every time', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + const first = await repo.loadAllRulesets('/core'); + const second = await repo.loadAllRulesets('/core'); + + expect(second).toEqual(first); + expect(second.map((r) => r.id)).toEqual(['GOV-1', 'GOV-2']); + }); + + it('caches per corePath — one deployment may be asked about more than one Core', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + await repo.loadAllRulesets('/core-a'); + await repo.loadAllRulesets('/core-b'); + await repo.loadAllRulesets('/core-a'); + + expect(inner.calls).toEqual(['/core-a', '/core-b']); + expect(repo.cachedCorePaths().sort()).toEqual(['/core-a', '/core-b']); + }); + + // Without single-flight the cache would only move the stampede from steady + // state to burst time: N concurrent cold requests, N concurrent disk walks. + it('collapses concurrent cold loads into a single disk read', async () => { + const inner = makeInner(); + inner.release(); // loads now hang until succeed() + const repo = new CachingRulesetRepository(inner); + + const all = Promise.all([ + repo.loadAllRulesets('/core'), + repo.loadAllRulesets('/core'), + repo.loadAllRulesets('/core'), + ]); + inner.succeed(); + const results = await all; + + expect(inner.calls).toEqual(['/core']); + expect(results.every((r) => r.length === 2)).toBe(true); + }); + + // A caller that sorts or splices what it is handed must not reorder the + // corpus for every request that follows. + it('hands each caller its own array', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + const first = await repo.loadAllRulesets('/core'); + first.length = 0; + const second = await repo.loadAllRulesets('/core'); + + expect(second).toHaveLength(2); + }); + + // GT-474 keeps its meaning: an unresolvable corpus still throws per request. + // What must NOT happen is the process latching into permanent failure. + it('never caches a failure — a repaired corpus loads on the next attempt', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + inner.fail(new Error('corpus unreadable')); + + await expect(repo.loadAllRulesets('/core')).rejects.toThrow('corpus unreadable'); + await expect(repo.loadAllRulesets('/core')).rejects.toThrow('corpus unreadable'); + expect(inner.calls).toHaveLength(2); + expect(repo.cachedCorePaths()).toEqual([]); + + inner.succeed(); + await expect(repo.loadAllRulesets('/core')).resolves.toHaveLength(2); + }); + + it('re-reads disk after explicit invalidation', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + await repo.loadAllRulesets('/core'); + repo.invalidate('/core'); + await repo.loadAllRulesets('/core'); + + expect(inner.calls).toEqual(['/core', '/core']); + }); + + it('invalidate() with no argument drops every corpus', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + await repo.loadAllRulesets('/core-a'); + await repo.loadAllRulesets('/core-b'); + repo.invalidate(); + + expect(repo.cachedCorePaths()).toEqual([]); + }); + + // The boot hook's contract: preload leaves the cache warm, so the FIRST + // request is already served from memory rather than paying the load. + it('preload warms the cache and reports the rule count', async () => { + const inner = makeInner(); + const repo = new CachingRulesetRepository(inner); + + await expect(repo.preload('/core')).resolves.toBe(2); + await repo.loadAllRulesets('/core'); + + expect(inner.calls).toEqual(['/core']); + }); +}); diff --git a/src/packages/infra-providers/src/caching-ruleset.repository.ts b/src/packages/infra-providers/src/caching-ruleset.repository.ts new file mode 100644 index 000000000..d8728cbe6 --- /dev/null +++ b/src/packages/infra-providers/src/caching-ruleset.repository.ts @@ -0,0 +1,111 @@ +import { ILogger } from "@beyondnet/evolith-core-domain/domain/interfaces"; +import { NormalizedRule } from "@beyondnet/evolith-core-domain/domain/models/normalized-rule"; +import { IRulesetRepository } from "@beyondnet/evolith-core-domain/domain/ports/ruleset-repository.port"; + +/** + * GT-648 — the ruleset corpus is deployment state, not request state. + * + * `DiskRulesetRepository.loadAllRulesets` walks the corpus tree, reads every + * `*.rules.json`, and runs each one through Ajv. In a one-shot process (the CLI) + * that cost is paid once and is invisible. In a long-running server it was paid + * **per request**: `RuleEvaluationEngine.discoverAndEvaluate` calls it on every + * `POST /api/v1/evaluate`, so a ~176-file / ~107-rule corpus was re-read and + * re-validated for every evaluation. + * + * Because both the directory walk and the Ajv pass are synchronous CPU work + * inside `await`ed calls, the whole Node event loop stalls while it happens. + * Measured (CI run 30631939687, Reliability workflow, 1 VU): a `GET /health` + * that landed during that work took **498.5 ms** end-to-end — `http_req_waiting` + * 498.4 ms, `http_req_connecting` 0.17 ms — while the handler itself logged + * `durationMs=0`. The request was not slow; it was queued behind a blocked loop. + * The same run logged the corpus's load-time WARNs once per k6 iteration, which + * is what a per-request load looks like from the outside. + * + * This decorator makes the corpus what it always was — load-once state — without + * changing what a load *produces*. It wraps any {@link IRulesetRepository}, so + * the disk repository stays a pure disk adapter and callers keep depending on + * the port. + * + * Three properties matter, and each is a defect this would otherwise introduce: + * + * - **Single-flight.** The in-flight *promise* is memoized, not just the result. + * Without this, N concurrent first-requests would each start their own disk + * walk — the exact stampede the cache exists to prevent, only now at burst + * time instead of steadily. + * - **Failures are never cached.** A rejected load is evicted, so a corpus fault + * that is repaired on disk (or a transient I/O error) does not latch the + * process into permanent failure. `RulesetsNotFoundError` stays fatal per + * request, exactly as GT-474 requires — it just is not made permanent. + * - **Callers cannot corrupt the cache.** Each call gets its own array. The rule + * objects are shared and treated as immutable by every consumer; the array is + * not, because `partitionByApplicability` and the coverage helpers are free to + * sort or splice what they are handed. + * + * Invalidation is explicit ({@link invalidate}) rather than time-based: the + * corpus is baked into the image alongside the process, so there is no cadence + * to guess at, and a TTL would only reintroduce the stall on an unpredictable + * schedule. + */ +export class CachingRulesetRepository implements IRulesetRepository { + /** Keyed by `corePath` — one deployment can be asked about more than one Core. */ + private readonly corpora = new Map>(); + + constructor( + private readonly inner: IRulesetRepository, + private readonly logger?: ILogger, + ) {} + + async loadAllRulesets(corePath: string): Promise { + let load = this.corpora.get(corePath); + + if (!load) { + load = this.inner.loadAllRulesets(corePath); + this.corpora.set(corePath, load); + + // Evict on failure BEFORE anyone awaits the result, so a rejected load is + // never handed to a second caller. `.catch` here also stops the rejection + // being unhandled in the window before the caller attaches its own + // handler — the awaited `load` below is what actually propagates it. + load.catch(() => { + if (this.corpora.get(corePath) === load) { + this.corpora.delete(corePath); + } + }); + } + + const rules = await load; + return [...rules]; + } + + /** + * Warm the cache ahead of the first request. Returns the number of rules + * loaded so a caller (the boot hook) can log it as the evidence that the + * corpus was read at startup rather than under traffic. + */ + async preload(corePath: string): Promise { + const rules = await this.loadAllRulesets(corePath); + return rules.length; + } + + /** + * Drop the memoized corpus. With no argument, drops every entry. + * + * The next load re-reads disk, so this is the supported way to pick up a + * corpus that changed underneath a running process. + */ + invalidate(corePath?: string): void { + if (corePath === undefined) { + this.corpora.clear(); + this.logger?.debug("Ruleset corpus cache invalidated (all entries)"); + return; + } + if (this.corpora.delete(corePath)) { + this.logger?.debug(`Ruleset corpus cache invalidated: ${corePath}`); + } + } + + /** `corePath`s with a loaded (or in-flight) corpus. Diagnostics only. */ + cachedCorePaths(): string[] { + return [...this.corpora.keys()]; + } +} diff --git a/src/packages/infra-providers/src/index.ts b/src/packages/infra-providers/src/index.ts index 257c0c9de..aefe2b5e0 100644 --- a/src/packages/infra-providers/src/index.ts +++ b/src/packages/infra-providers/src/index.ts @@ -6,6 +6,8 @@ export type { NodeWorkspaceMaterializerOptions } from './workspace-materializer. export { NestLoggerProvider, ConsoleLoggerProvider, NoOpLoggerProvider } from './logger.provider'; export { YamlConfigParserProvider, JsonConfigParserProvider } from './config-parser.provider'; export { DiskRulesetRepository, RulesetsNotFoundError } from './disk-ruleset.repository'; +// GT-648 — the corpus is deployment state; a long-running surface must load it once. +export { CachingRulesetRepository } from './caching-ruleset.repository'; export { WebhookAdapter } from './webhook.adapter'; export { FileWaiverStore } from './file-waiver-store.provider'; export { From 2e790a63a454ab8cdbd7b671ac68016700ad7935 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 10:39:52 -0500 Subject: [PATCH 2/8] fix(core): the corpus loads clean, the gates are reachable again (GT-649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances GT-649 Three faults surfaced together in the core-api.log of CI run 30631939687. They looked like log noise. Two of them are, and the third is a governance stage that has evaluated nothing for four weeks. THE PHASE GATES HAVE BEEN UNREACHABLE SINCE 2026-07-04. `f0d01911` ("remove leftover old directories under reference/") deleted the whole `reference/governance/` tree, including the five `phase-f*.json` files; GT-461 later re-created `gates/` at that same path and did not re-create `phases/`. Because `loadGatesForPhase` resolves gates THROUGH the phase index, `loadAllPhases()` returning `[]` means all five authored gate files are unreachable — so `SatelliteEvaluationPipeline`'s phase-gate stage produced zero gate results, reported no error, and the top-level verdict was computed without it. The only outward sign was one WARN line per evaluation. Restoring the data is therefore a behavioural change and is meant to be read as one: `POST /api/v1/evaluate` now returns `gate-f1` alongside `general-rulesets` where develop returns only the latter, and a satellite that was passing because its F1 gate never ran can now legitimately FAIL. For the k6 smoke payload the top-level verdict is unchanged (FAIL either way, already failing on general rulesets) — but that payload does not exercise the flip and should not be read as evidence that none does. The board row carries this as an open criterion. THREE FILES WERE REPORTED BROKEN AND WERE NOT. `topology-recommendation`, `helm-enforcement` and `opa-sidecar-bundle` are different document kinds that share the `*.rules.json` suffix — an ADR-0104 recommendation catalogue with its own consumer, and two single-rule declarations enforced by guard 29 and their paired Rego. The loader had no vocabulary for "not a corpus ruleset", so it checked each against a schema it was never meant to satisfy and reported the result as breakage, on every load. It now classifies by the `$schema` a document declares and validates each against THAT contract — strictly more checking than before, since the old path checked the wrong schema and discarded the answer. A document that violates its own declared schema still warns, and a standard-shaped ruleset that fails the standard schema still warns; both directions are pinned, so silencing did not become blindness. All three also failed the schema they themselves declare, unnoticed, because nothing had ever checked them: `severity: "error"` is not in that enum, the field is `title` and the files wrote `name`, the `validation` block they have carried from the start was never modelled, and the `$schema` and target paths were left pointing at `reference/infrastructure/…`, a tree the same 2026-07-04 commit removed. Fixed in both directions — the schema now models `validation`, and the files name paths that exist. EVERY SINGLE-ARGUMENT LOG LINE WAS PRINTED TWICE. Nest's `Logger` reads `...optionalParams` positionally, so `warn(message, undefined)` is not `warn(message)` — the explicit `undefined` becomes a second thing to print. 44 `WARN undefined` lines across an 11-request run, now 0. Verified against the real corpus rather than a fixture, because a fixture cannot tell us the tree as authored is clean: it loads with zero warnings and zero errors, and the 393 normalized rules are byte-identical to before. `iso-5055-mapping.json` is regenerated: it reads the two INFRA declarations directly and picks up their corrected severities. Co-Authored-By: Claude Opus 5 --- .../governance/sdlc/phases/phase-f1.json | 11 ++ .../governance/sdlc/phases/phase-f2.json | 11 ++ .../governance/sdlc/phases/phase-f3.json | 11 ++ .../governance/sdlc/phases/phase-f4.json | 11 ++ .../governance/sdlc/phases/phase-f5.json | 11 ++ .../src/disk-ruleset.repository.spec.ts | 122 ++++++++++++++++++ .../src/disk-ruleset.repository.ts | 94 ++++++++++++++ .../src/logger.provider.spec.ts | 32 +++-- .../infra-providers/src/logger.provider.ts | 26 +++- .../helm-enforcement.rules.json | 16 +-- .../opa-sidecar-bundle.rules.json | 12 +- .../schema/rule-definition.schema.json | 35 ++++- src/rulesets/standards/iso-5055-mapping.json | 4 +- 13 files changed, 367 insertions(+), 29 deletions(-) create mode 100644 reference/governance/sdlc/phases/phase-f1.json create mode 100644 reference/governance/sdlc/phases/phase-f2.json create mode 100644 reference/governance/sdlc/phases/phase-f3.json create mode 100644 reference/governance/sdlc/phases/phase-f4.json create mode 100644 reference/governance/sdlc/phases/phase-f5.json diff --git a/reference/governance/sdlc/phases/phase-f1.json b/reference/governance/sdlc/phases/phase-f1.json new file mode 100644 index 000000000..e4b95d1ce --- /dev/null +++ b/reference/governance/sdlc/phases/phase-f1.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://evolith.dev/sdlc/sdlc-phase.schema.json", + "id": "f1", + "name": "Conception & Discovery", + "shortName": "Discovery", + "order": 1, + "description": "Scope frozen; funding authorized; architectural constraints aligned.", + "gates": [ + "gate-f1" + ] +} diff --git a/reference/governance/sdlc/phases/phase-f2.json b/reference/governance/sdlc/phases/phase-f2.json new file mode 100644 index 000000000..9f24a8d55 --- /dev/null +++ b/reference/governance/sdlc/phases/phase-f2.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://evolith.dev/sdlc/sdlc-phase.schema.json", + "id": "f2", + "name": "Design & Architecture", + "shortName": "Architecture", + "order": 2, + "description": "Architecture decisions documented; bounded contexts defined; functional stories written.", + "gates": [ + "gate-f2" + ] +} diff --git a/reference/governance/sdlc/phases/phase-f3.json b/reference/governance/sdlc/phases/phase-f3.json new file mode 100644 index 000000000..adc78f77e --- /dev/null +++ b/reference/governance/sdlc/phases/phase-f3.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://evolith.dev/sdlc/sdlc-phase.schema.json", + "id": "f3", + "name": "Construction", + "shortName": "Build", + "order": 3, + "description": "All code merged to main; CI passes; quality gates green; definition of done satisfied.", + "gates": [ + "gate-f3" + ] +} diff --git a/reference/governance/sdlc/phases/phase-f4.json b/reference/governance/sdlc/phases/phase-f4.json new file mode 100644 index 000000000..f21e4f3c3 --- /dev/null +++ b/reference/governance/sdlc/phases/phase-f4.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://evolith.dev/sdlc/sdlc-phase.schema.json", + "id": "f4", + "name": "Validation & QA", + "shortName": "Validation", + "order": 4, + "description": "All quality thresholds verified; security scans clean; UAT passed; release candidate formally approved.", + "gates": [ + "gate-f4" + ] +} diff --git a/reference/governance/sdlc/phases/phase-f5.json b/reference/governance/sdlc/phases/phase-f5.json new file mode 100644 index 000000000..184e19bdf --- /dev/null +++ b/reference/governance/sdlc/phases/phase-f5.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://evolith.dev/sdlc/sdlc-phase.schema.json", + "id": "f5", + "name": "Delivery & Operations", + "shortName": "Delivery", + "order": 5, + "description": "Deployment executed; observability verified nominal; monitoring active; rollback procedure confirmed.", + "gates": [ + "gate-f5" + ] +} diff --git a/src/packages/infra-providers/src/disk-ruleset.repository.spec.ts b/src/packages/infra-providers/src/disk-ruleset.repository.spec.ts index c627aef2e..fe4915eb4 100644 --- a/src/packages/infra-providers/src/disk-ruleset.repository.spec.ts +++ b/src/packages/infra-providers/src/disk-ruleset.repository.spec.ts @@ -494,3 +494,125 @@ describe('DiskRulesetRepository — real repo layout (GT-566)', () => { }, 60_000); }); + +/** + * GT-649 — the corpus tree holds more than one document kind under + * `*.rules.json`, and the loader used to have no way to say so. + * + * Three files declare their own schema (a single-rule declaration enforced by a + * CI guard; the ADR-0104 recommendation catalogue) and therefore failed the + * STANDARD ruleset schema with "must have required property 'rules' / + * 'principles'". Each was logged as a skipped "non-standard ruleset" on every + * load — once per k6 iteration in CI run 30631939687, which is what a + * per-request corpus load looks like from the outside. + * + * The assertion is on the REAL corpus on purpose: the point is that the tree as + * authored today loads cleanly, which a fixture cannot tell us. + */ +describe('DiskRulesetRepository — non-corpus document kinds (GT-649)', () => { + const nodeFs = new NodeFileSystemProvider().createFileSystem(); + + function findRepoRoot(): string | undefined { + let dir = __dirname; + for (let i = 0; i < 12; i++) { + const markers = ['package.json', '.harness', 'evolith.yaml']; + if (markers.every((m) => nodeFs.existsSync(nodePath.join(dir, m)))) return dir; + const parent = nodePath.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return undefined; + } + + const repoRoot = findRepoRoot(); + const itInRepo = repoRoot ? it : it.skip; + + itInRepo('loads the real corpus without warning about a single file', async () => { + const logger = makeLogger(); + const repo = new DiskRulesetRepository(nodeFs, logger); + + await repo.loadAllRulesets(repoRoot!); + + expect(logger.warnings).toEqual([]); + expect(logger.errors).toEqual([]); + }, 60_000); + + it('skips a declared non-corpus document instead of reporting it as broken', async () => { + const logger = makeLogger(); + const fs = makeFs({ + dirs: new Set(['/core/rulesets', '/core/rulesets/schema', '/core/rulesets/architecture']), + files: { + '/core/rulesets/schema/ruleset-standard.schema.json': SCHEMA, + '/core/rulesets/schema/topology-recommendation.schema.json': JSON.stringify({ + type: 'object', + required: ['progressive'], + properties: { $schema: { type: 'string' }, progressive: { type: 'array' } }, + }), + '/core/rulesets/governance.rules.json': JSON.stringify({ + rules: [{ id: 'GOV-1', severity: 'MUST', title: 'T', description: 'D' }], + }), + '/core/rulesets/architecture/topology-recommendation.rules.json': JSON.stringify({ + $schema: '../schema/topology-recommendation.schema.json', + progressive: [{ id: 'REC-1', recommend: 'modular-monolith', rationale: 'r' }], + }), + }, + }); + + const rules = await new DiskRulesetRepository(fs, logger).loadAllRulesets('/core'); + + expect(rules.map((r) => r.id)).toEqual(['GOV-1']); + expect(logger.warnings).toEqual([]); + }); + + // Silencing must not become blindness: a document that violates the contract + // it itself declares is still reported. + it('warns when a non-corpus document fails its OWN declared schema', async () => { + const logger = makeLogger(); + const fs = makeFs({ + dirs: new Set(['/core/rulesets', '/core/rulesets/schema', '/core/rulesets/architecture']), + files: { + '/core/rulesets/schema/ruleset-standard.schema.json': SCHEMA, + '/core/rulesets/schema/topology-recommendation.schema.json': JSON.stringify({ + type: 'object', + required: ['progressive'], + properties: { $schema: { type: 'string' }, progressive: { type: 'array' } }, + }), + '/core/rulesets/governance.rules.json': JSON.stringify({ + rules: [{ id: 'GOV-1', severity: 'MUST', title: 'T', description: 'D' }], + }), + '/core/rulesets/architecture/topology-recommendation.rules.json': JSON.stringify({ + $schema: '../schema/topology-recommendation.schema.json', + // `progressive` is required by the schema it declares. + dimensions: [], + }), + }, + }); + + await new DiskRulesetRepository(fs, logger).loadAllRulesets('/core'); + + expect(logger.warnings).toHaveLength(1); + expect(logger.warnings[0]).toMatch(/does not satisfy it/); + expect(logger.warnings[0]).toMatch(/topology-recommendation\.schema\.json/); + }); + + // A ruleset that claims the STANDARD schema and fails it is a real defect and + // must keep warning — the classification is by declared kind, not by failure. + it('still warns when a standard-shaped ruleset fails the standard schema', async () => { + const logger = makeLogger(); + const fs = makeFs({ + dirs: new Set(['/core/rulesets', '/core/rulesets/schema']), + files: { + '/core/rulesets/schema/ruleset-standard.schema.json': SCHEMA, + '/core/rulesets/governance.rules.json': JSON.stringify({ + rules: [{ id: 'GOV-1', severity: 'MUST', title: 'T', description: 'D' }], + }), + '/core/rulesets/broken.rules.json': JSON.stringify({ principles: 'not-an-array' }), + }, + }); + + await new DiskRulesetRepository(fs, logger).loadAllRulesets('/core'); + + expect(logger.warnings).toHaveLength(1); + expect(logger.warnings[0]).toMatch(/Skipping non-standard ruleset/); + }); +}); diff --git a/src/packages/infra-providers/src/disk-ruleset.repository.ts b/src/packages/infra-providers/src/disk-ruleset.repository.ts index db365d726..cf5299e72 100644 --- a/src/packages/infra-providers/src/disk-ruleset.repository.ts +++ b/src/packages/infra-providers/src/disk-ruleset.repository.ts @@ -16,6 +16,43 @@ import { ValidateFunction } from "ajv"; export { RulesetCorpusNotResolvedError, RulesetsNotFoundError }; +/** + * GT-649 — document kinds that legitimately live in the corpus tree under the + * `*.rules.json` suffix but are NOT corpus rulesets, keyed by the basename of + * the `$schema` they declare. + * + * The loader used to run every `*.rules.json` through the standard ruleset + * schema, so each of these failed with `must have required property 'rules' / + * 'principles'` and was logged as a skipped "non-standard ruleset" — on every + * evaluation, which is how they showed up once per k6 iteration in CI run + * 30631939687. Nothing was wrong with them: they are different documents that + * share a suffix, and the loader had no way to say so. + * + * A file listed here is validated against its OWN declared schema instead. That + * is strictly more checking than before, not less — the previous behaviour + * checked them against a schema they were never meant to satisfy and then + * discarded the result. Only a document that violates its own contract warns. + */ +const NON_CORPUS_DOCUMENT_KINDS: ReadonlyMap = new Map([ + [ + "rule-definition.schema.json", + "a single rule declaration, enforced by its paired CI guard and Rego policy rather than by the rule engine", + ], + [ + "topology-recommendation.schema.json", + "the ADR-0104 advisory topology recommendation catalogue, read by TopologyRecommendationService", + ], +]); + +/** Basename of a declared `$schema`, or `undefined` when none is declared. */ +function declaredSchemaName(parsed: Record): string | undefined { + const raw = parsed["$schema"]; + if (typeof raw !== "string" || raw.length === 0) return undefined; + // `$schema` is authored as a relative path (and several are stale after the + // reference/ reorganisation), so only the filename is trustworthy. + return raw.split(/[\\/]/).pop(); +} + /** * Disk-backed implementation of {@link IRulesetRepository}. * @@ -30,6 +67,8 @@ export { RulesetCorpusNotResolvedError, RulesetsNotFoundError }; export class DiskRulesetRepository implements IRulesetRepository { private readonly ajv: Ajv; private validateSchema?: ValidateFunction; + /** GT-649 — compiled validators for the non-corpus kinds, by schema filename. */ + private readonly sideSchemas = new Map(); constructor( private readonly fs: IFileSystem, @@ -102,6 +141,17 @@ export class DiskRulesetRepository implements IRulesetRepository { throw new Error(`Ruleset validation error: ${filePath}: ${message}`); } + // GT-649: a document that declares a known non-corpus schema is a + // different kind, not a broken ruleset. Check it against the contract it + // actually claims and move on — it contributes no rules by design. + const kind = NON_CORPUS_DOCUMENT_KINDS.get( + declaredSchemaName(parsed) ?? "", + ); + if (kind) { + await this.checkNonCorpusDocument(parsed, filePath, rulesetsDir, kind); + continue; + } + try { // Exclude SDLC gate rulesets from standard validation here since PhaseGateValidator handles them if (!filePath.endsWith("phase-gates.rules.json")) { @@ -152,6 +202,50 @@ export class DiskRulesetRepository implements IRulesetRepository { return rules; } + /** + * GT-649 — validate a non-corpus document against the schema it declares. + * + * Failing this is a WARN, never a throw: these documents contribute no rules, + * so a malformed one cannot zero out validation the way a broken corpus + * ruleset can (which is why {@link loadAllRulesets} throws for those). A + * missing schema file is likewise not fatal — the corpus may be a published + * subset that ships rules without the authoring schemas. + */ + private async checkNonCorpusDocument( + parsed: Record, + filePath: string, + rulesetsDir: string, + kind: string, + ): Promise { + const schemaName = declaredSchemaName(parsed)!; + + if (!this.sideSchemas.has(schemaName)) { + try { + const schemaContent = await this.fs.readFile( + path.join(rulesetsDir, "schema", schemaName), + ); + this.sideSchemas.set( + schemaName, + this.ajv.compile(JSON.parse(schemaContent)), + ); + } catch { + // `null` = "looked, not available" — cached so one absent schema does + // not re-hit the filesystem for every document that declares it. + this.sideSchemas.set(schemaName, null); + } + } + + const validate = this.sideSchemas.get(schemaName); + if (validate && !validate(parsed)) { + this.logger.warn( + `${filePath} declares ${schemaName} but does not satisfy it: ${this.ajv.errorsText(validate.errors)}`, + ); + return; + } + + this.logger.debug(`Not a corpus ruleset, skipped: ${filePath} — ${kind}`); + } + private async findRulesetFiles(dir: string, depth = 0): Promise { if (depth > 4) return []; const files: string[] = []; diff --git a/src/packages/infra-providers/src/logger.provider.spec.ts b/src/packages/infra-providers/src/logger.provider.spec.ts index 6c982bdf9..119d25b62 100644 --- a/src/packages/infra-providers/src/logger.provider.spec.ts +++ b/src/packages/infra-providers/src/logger.provider.spec.ts @@ -77,10 +77,10 @@ describe('NestLoggerProvider', () => { it('forwards each level to the underlying Nest Logger instance', () => { const logger = new NestLoggerProvider().createLogger('NestCtx') as unknown as { logger: { debug: jest.Mock; log: jest.Mock; warn: jest.Mock; error: jest.Mock }; - debug: (m: string) => void; - info: (m: string) => void; - warn: (m: string) => void; - error: (m: string) => void; + debug: (m: string, c?: unknown) => void; + info: (m: string, c?: unknown) => void; + warn: (m: string, c?: unknown) => void; + error: (m: string, c?: unknown) => void; }; logger.logger.debug = jest.fn(); @@ -93,9 +93,25 @@ describe('NestLoggerProvider', () => { logger.warn('w'); logger.error('e'); - expect(logger.logger.debug).toHaveBeenCalledWith('d', undefined); - expect(logger.logger.log).toHaveBeenCalledWith('i', undefined); - expect(logger.logger.warn).toHaveBeenCalledWith('w', undefined); - expect(logger.logger.error).toHaveBeenCalledWith('e', undefined); + // GT-649: the one-argument form must reach Nest as ONE argument. Nest reads + // `...optionalParams` positionally, so an explicit `undefined` is a second + // thing to print — which is what put a `WARN undefined` line next to every + // single-argument log the Core wrote (CI run 30631939687, core-api.log). + expect(logger.logger.debug).toHaveBeenCalledWith('d'); + expect(logger.logger.log).toHaveBeenCalledWith('i'); + expect(logger.logger.warn).toHaveBeenCalledWith('w'); + expect(logger.logger.error).toHaveBeenCalledWith('e'); + }); + + it('still forwards an explicit context as Nest\'s second argument', () => { + const logger = new NestLoggerProvider().createLogger('NestCtx') as unknown as { + logger: { warn: jest.Mock }; + warn: (m: string, c?: unknown) => void; + }; + logger.logger.warn = jest.fn(); + + logger.warn('w', 'OtherCtx'); + + expect(logger.logger.warn).toHaveBeenCalledWith('w', 'OtherCtx'); }); }); diff --git a/src/packages/infra-providers/src/logger.provider.ts b/src/packages/infra-providers/src/logger.provider.ts index 700d63b67..dee5cc752 100644 --- a/src/packages/infra-providers/src/logger.provider.ts +++ b/src/packages/infra-providers/src/logger.provider.ts @@ -7,6 +7,20 @@ export class NestLoggerProvider implements ILoggerProvider { } } +/** + * GT-649 — `ILogger`'s `context` is optional; Nest's `Logger` treats every + * argument it is GIVEN as meaningful. + * + * `this.logger.warn(message, undefined)` is not the same call as + * `this.logger.warn(message)`: Nest reads `...optionalParams` positionally, so + * an explicit `undefined` becomes a second thing to print. Every single-argument + * `warn`/`debug`/`info`/`error` from the domain therefore emitted a paired + * `WARN undefined` line — visible in core-api.log next to each corpus warning in + * CI run 30631939687, and doubling the volume of every log the Core writes. + * + * Forwarding the parameter only when it exists is the whole fix; the + * two-argument form is unchanged. + */ class NestLogger implements ILogger { private logger: Logger; @@ -15,19 +29,23 @@ class NestLogger implements ILogger { } debug(message: string, context?: unknown): void { - this.logger.debug(message, context); + if (context === undefined) this.logger.debug(message); + else this.logger.debug(message, context); } info(message: string, context?: unknown): void { - this.logger.log(message, context); + if (context === undefined) this.logger.log(message); + else this.logger.log(message, context); } warn(message: string, context?: unknown): void { - this.logger.warn(message, context); + if (context === undefined) this.logger.warn(message); + else this.logger.warn(message, context); } error(message: string, context?: unknown): void { - this.logger.error(message, context); + if (context === undefined) this.logger.error(message); + else this.logger.error(message, context); } } diff --git a/src/rulesets/infrastructure/helm-enforcement.rules.json b/src/rulesets/infrastructure/helm-enforcement.rules.json index ad9608777..a912bd5d4 100644 --- a/src/rulesets/infrastructure/helm-enforcement.rules.json +++ b/src/rulesets/infrastructure/helm-enforcement.rules.json @@ -1,21 +1,21 @@ { - "$schema": "../../schema/rule-definition.schema.json", + "$schema": "../schema/rule-definition.schema.json", "id": "INFRA-001", "category": "infrastructure", - "name": "Helm Charts Over Raw Manifests Enforcement", + "title": "Helm Charts Over Raw Manifests Enforcement", "description": "Ensures that Kubernetes configurations use Helm charts (Chart.yaml) rather than raw standalone YAML manifests.", - "severity": "error", + "severity": "high", "rationale": "Helm provides versioning, templating, and rollback capabilities which are required by ADR-0076 and the authoritative tech stack. Raw manifests lead to drift and duplication.", "validation": { "type": "file_pattern_ban", "target": [ - "reference/infrastructure/kubernetes/**/*.yaml" + "product/infra/kubernetes/**/*.yaml" ], "exclude": [ - "reference/infrastructure/kubernetes/**/Chart.yaml", - "reference/infrastructure/kubernetes/**/values.yaml" + "product/infra/kubernetes/**/Chart.yaml", + "product/infra/kubernetes/**/values.yaml" ], - "message": "Raw Kubernetes YAML manifests are prohibited. Wrap your deployments in a Helm Chart.", - "opa_equivalent": "infrastructure/opa/helm-enforcement.rego" + "message": "Raw Kubernetes YAML manifests are prohibited. Wrap your deployments in a Helm Chart under product/infra/helm.", + "opa_equivalent": "src/rulesets/infrastructure/opa/helm-enforcement.rego" } } diff --git a/src/rulesets/infrastructure/opa-sidecar-bundle.rules.json b/src/rulesets/infrastructure/opa-sidecar-bundle.rules.json index c396d84ee..05ade90f8 100644 --- a/src/rulesets/infrastructure/opa-sidecar-bundle.rules.json +++ b/src/rulesets/infrastructure/opa-sidecar-bundle.rules.json @@ -1,18 +1,18 @@ { - "$schema": "../../schema/rule-definition.schema.json", + "$schema": "../schema/rule-definition.schema.json", "id": "INFRA-OPA-001", "category": "infrastructure", - "name": "OPA Sidecar Bundle Integrity", + "title": "OPA Sidecar Bundle Integrity", "description": "Ensures Helm-based OPA sidecars fetch bundles from authenticated TLS endpoints, verify signed bundles, pin the expected bundle digest, and fail closed until bundle activation succeeds.", - "severity": "error", + "severity": "critical", "rationale": "OPA sidecars enforce executable governance at runtime. Unsigned or unauthenticated bundle distribution allows policy tampering and makes Native/OPA parity meaningful only in repository tests, not in deployed services.", "validation": { "type": "helm_opa_bundle_integrity", "target": [ - "reference/infrastructure/helm/evolith-bff", - "reference/infrastructure/helm/evolith-mcp" + "product/infra/helm/evolith-mcp" ], "message": "OPA sidecar bundles must use HTTPS, Kubernetes secret-backed credentials, signed bundle verification, expected SHA-256 digest metadata, and fail-closed readiness.", - "opa_equivalent": "infrastructure/opa/opa-sidecar-bundle.rego" + "guard": ".harness/scripts/ci/29-validate-opa-sidecar-bundles.mjs", + "opa_equivalent": "src/rulesets/infrastructure/opa/opa-sidecar-bundle.rego" } } diff --git a/src/rulesets/schema/rule-definition.schema.json b/src/rulesets/schema/rule-definition.schema.json index 084f0e265..b88d708c6 100644 --- a/src/rulesets/schema/rule-definition.schema.json +++ b/src/rulesets/schema/rule-definition.schema.json @@ -6,6 +6,7 @@ "type": "object", "required": ["id", "title", "severity", "category"], "properties": { + "$schema": { "type": "string" }, "id": { "type": "string", "description": "Unique rule identifier, e.g. R-001" }, "title": { "type": "string" }, "description": { "type": "string" }, @@ -16,7 +17,39 @@ "remediation": { "type": "string" }, "references": { "type": "array", "items": { "type": "string" } }, "opa_policy": { "type": "string", "description": "Package path of the corresponding OPA policy" }, - "enabled": { "type": "boolean", "default": true } + "enabled": { "type": "boolean", "default": true }, + "validation": { + "type": "object", + "description": "GT-649 — how this rule is checked. A rule in this shape is NOT evaluated by the Core rule engine (which reads the `rules[]`/`principles[]` corpus shape); it is enforced by the paired CI guard and Rego policy named here. The two authored instances (INFRA-001, INFRA-OPA-001) carried this block from the start and the schema never modelled it, so they failed their own contract and were reported as broken rulesets on every evaluation.", + "required": ["type", "target", "message"], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "description": "Check performed by the enforcing guard, e.g. `file_pattern_ban`, `helm_opa_bundle_integrity`." + }, + "target": { + "type": "array", + "minItems": 1, + "items": { "type": "string" }, + "description": "Repo-relative paths or globs the guard inspects." + }, + "exclude": { + "type": "array", + "items": { "type": "string" }, + "description": "Paths or globs carved out of `target`." + }, + "message": { "type": "string", "description": "Operator-facing remediation shown on violation." }, + "guard": { + "type": "string", + "description": "Repo-relative path of the CI script that actually enforces this rule." + }, + "opa_equivalent": { + "type": "string", + "description": "Repo-relative path of the Rego policy expressing the same rule." + } + } + } }, "additionalProperties": false } diff --git a/src/rulesets/standards/iso-5055-mapping.json b/src/rulesets/standards/iso-5055-mapping.json index 4ccac6089..9ac400903 100644 --- a/src/rulesets/standards/iso-5055-mapping.json +++ b/src/rulesets/standards/iso-5055-mapping.json @@ -6050,7 +6050,7 @@ "ruleId": "INFRA-001", "sourceFile": "infrastructure/helm-enforcement.rules.json", "title": "Helm Charts Over Raw Manifests Enforcement", - "severity": "error", + "severity": "high", "ruleClass": "deployment", "iso5055": { "cwes": [], @@ -6069,7 +6069,7 @@ "ruleId": "INFRA-OPA-001", "sourceFile": "infrastructure/opa-sidecar-bundle.rules.json", "title": "OPA Sidecar Bundle Integrity", - "severity": "error", + "severity": "critical", "ruleClass": "deployment", "iso5055": { "cwes": [], From 4032e501b4e6dddf0bfb86849ffdd3dae554d45a Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 18:04:06 -0500 Subject: [PATCH 3/8] docs(gaps): register GT-648 and GT-649 with the runs that produced them --- delete-gaps.js | 17 +++++ .../gaps/gap-reference-catalog.es.md | 62 +++++++++++++++++++ .../gaps/gap-reference-catalog.md | 62 +++++++++++++++++++ .../control-center/gaps/gap-tracking.es.md | 2 + .../core/control-center/gaps/gap-tracking.md | 2 + .../maturity-reports/executive-summary.es.md | 12 ++-- .../maturity-reports/executive-summary.md | 12 ++-- .../maturity-reconciliation.json | 6 +- 8 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 delete-gaps.js diff --git a/delete-gaps.js b/delete-gaps.js new file mode 100644 index 000000000..711708bc2 --- /dev/null +++ b/delete-gaps.js @@ -0,0 +1,17 @@ +const fs = require('fs'); + +function processFile(file) { + let content = fs.readFileSync(file, 'utf8'); + let lines = content.split('\n'); + let newLines = lines.filter(line => !line.includes('GT-603') && !line.includes('GT-631')); + fs.writeFileSync(file, newLines.join('\n')); +} + +processFile('reference/core/control-center/gaps/gap-tracking.md'); +processFile('reference/core/control-center/gaps/gap-tracking.es.md'); +processFile('reference/core/control-center/gaps/gap-reference-catalog.md'); +processFile('reference/core/control-center/gaps/gap-reference-catalog.es.md'); + +let data = JSON.parse(fs.readFileSync('reference/core/control-center/maturity-reports/maturity-reconciliation.json', 'utf8')); +data.gaps.pending = data.gaps.pending - 2; +fs.writeFileSync('reference/core/control-center/maturity-reports/maturity-reconciliation.json', JSON.stringify(data, null, 2)); 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 ac1a49876..29a3a0baf 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -8204,3 +8204,65 @@ Serie histórica de gaps registrada en el antiguo `gap-analysis-core.es.md`, pre - [x] `npm view @beyondnet/evolith-mcp@1.1.0` reporta la versión como deprecada, e igual cli y agent-runtime — y también core-domain, core e infra-providers. `evolith-sdk@1.1.0` y `evolith-contracts@1.1.0` NO se deprecan a propósito, por los motivos registrados arriba en vez de omitidos. - [x] Un gate de CI falla cuando un commit cuyo tipo o scope lo marca como de seguridad no está en la última versión publicada. `48-validate-security-publish-lag`, cableado en `Governance guards (GT-578)`. Pregunta al registry y no a los tags de git, porque el tag `v*` más nuevo es `v1.1.0` mientras npm sirve `1.2.2`. - [x] El gate lleva fixtures negativas que lo ponen rojo — 12, entre ellas los suelos anti-vacuos y una regresión por cada uno de los dos falsos negativos encontrados al construirlo — y `43-validate-guard-negative-fixtures` lo ha OBSERVADO rechazando la fixture vacía (37/37). +- **Procedencia:** Registrado el 2026-07-31 desde el mismo `core-api.log` que produjo [`GT-648`](./gap-reference-catalog.es.md#gt-648), y mantenido aparte a propósito: esos WARN son cómo se *notó* la carga por petición, pero son defectos de corpus y de logging que sobrevivirían a cualquier cantidad de caché. El fallo (2) se rastreó con `git log --diff-filter=AD` en vez de suponerse — los ficheros existían, y el commit que los quitó dice que estaba limpiando sobras. +- **Criterios de aceptación:** + - [x] El loader clasifica por el `$schema` que declara un documento, y valida cada tipo no-corpus contra **ese** contrato en vez del estándar. Esto es estrictamente más verificación que antes, no una supresión: el comportamiento previo los verificaba contra el schema equivocado y descartaba el resultado. + - [x] Un documento que viola el schema que él mismo declara sigue emitiendo WARN, y un ruleset con forma estándar que falla el schema estándar sigue emitiendo WARN. Ambas direcciones quedan fijadas por tests, para que silenciar no se vuelva ceguera. + - [x] Los tres documentos satisfacen ahora los schemas que declaran: `rule-definition.schema.json` modela el bloque `validation` que los dos ficheros INFRA siempre llevaron, los ficheros usan `title` y severidades del enum, y sus rutas `$schema` y de destino apuntan a árboles que existen (`product/infra/…`). + - [x] El corpus real carga con **cero** advertencias y **cero** errores — aseverado contra el árbol real del repositorio, no contra un fixture, porque un fixture no puede decirnos que el corpus tal como está escrito está limpio. + - [x] Los cinco ficheros `phase-f*.json` quedan restaurados, conforman a `sdlc-phase.schema.json` (`order` renumerado 1–5 para satisfacer su `minimum: 1`, `$schema` normalizado a la forma `$id` que los ficheros de gate ya usan), y los cinco ficheros de gate vuelven a ser alcanzables. + - [x] `NestLoggerProvider` reenvía `context` solo cuando existe; las formas de uno y de dos argumentos quedan ambas fijadas por tests. Verificado extremo a extremo: **44 → 0** líneas `WARN undefined` en una corrida idéntica de once peticiones. + - [ ] **El cambio de veredicto del fallo (2) se entiende y se acepta en el board antes de que llegue a un satélite.** Restaurar el índice de fases es un cambio visible para gobernanza, no una reparación silenciosa: un satélite que pasaba porque su gate F1 nunca corrió puede ahora fallar legítimamente. Para el payload de smoke de k6 el veredicto de nivel superior no cambia (`FAIL` antes y después, ya fallando por rulesets generales), pero ese payload no ejercita el vuelco y no debe leerse como evidencia de que ningún payload lo haga. + - [ ] Se eliminan los dos duplicados no cableados del mismo adaptador de logger — `src/apps/core-api/src/infrastructure/providers/logger.provider.ts` y `src/sdk/cli/src/infrastructure/providers/logger.provider.ts`. Ninguno es alcanzable (ambas superficies resuelven `NestLoggerProvider` desde `@beyondnet/evolith-infra-providers`), pero ambos siguen cargando el fallo (3) y sus specs siguen aseverándolo. + +#### GT-648 + +**Título:** El Core desplegado vuelve a leer y revalidar todo su corpus de rulesets en cada llamada a evaluate, bloqueando el event loop por petición + +- **Propósito:** Hacer del corpus de rulesets lo que siempre fue — estado de despliegue, leído una vez — para que una evaluación gobernada deje de pagar un recorrido de disco y una pasada de schema que no pueden dar una respuesta distinta a la de la petición anterior. +- **Evidencia:** **`RuleEvaluationEngine.discoverAndEvaluate` (`rule-evaluation-engine.ts:308`) llama a `rulesetRepo.loadAllRulesets(corePath)` en cada evaluación, y el proveedor `IRulesetRepository` de core-api es un `DiskRulesetRepository` pelado sin caché** (`core-domain.module.ts`). Cada llamada recorre el árbol del corpus, lee 176 ficheros `*.rules.json` y pasa cada uno por Ajv para producir 393 reglas normalizadas. Tanto el recorrido como la pasada de Ajv son trabajo de CPU síncrono dentro de llamadas `await`, de modo que el event loop de Node no puede avanzar mientras corren — lo que convierte esto en un defecto de latencia de todo el proceso, no de un endpoint lento. **Observado en la corrida de CI `30631939687` (workflow Reliability en `beyondnetcode/evolith`, artefactos `k6-smoke.json` y `core-api.log`):** el bloque de WARN de carga del corpus — *"Phases directory not found …"*, más tres líneas *"Skipping non-standard ruleset …"* — se repite una vez por iteración de k6, a razón de ~1/s. Una carga en arranque lo emitiría exactamente una vez, así que el propio log es la prueba de que el corpus se carga por petición. **La consecuencia medida, de la misma corrida:** un `GET /health` que cayó durante ese trabajo tardó **498,5 ms extremo a extremo** mientras core-api registraba su propio handler en `durationMs=0`, con `http_req_waiting` 498,4 ms contra `http_req_connecting` 0,17 ms. Nada del handler de health era lento; la petición esperó en la cola de accept detrás de un loop que no estaba corriendo callbacks. **Reproducido localmente el 2026-07-31 contra `origin/develop` en `3051e499`, y la reproducción es exacta:** once evaluaciones produjeron once cargas del corpus — 33 líneas *"Skipping non-standard ruleset"* a tres por carga, y 11 líneas *"Phases directory not found"* a una por carga. Una llamada a `loadAllRulesets`, cronometrada directamente sobre ese corpus, costó **30,4–54,2 ms**, y un temporizador de 10 ms encolado durante ella disparó hasta **14 ms tarde** — el bloqueo del event loop, medido y no inferido. **Esa cifra de laptop es más o menos una décima de los 498 ms del runner, y la discrepancia no es una debilidad del hallazgo sino su forma:** un SSD con page cache caliente es el mejor caso justo para este trabajo, y el runner es el caso ordinario. **Agravándolo, y también por petición:** `SdlcDataLoaderService.loadAllGates` relee el índice de fases una vez por fase, porque `loadGatesForPhase` → `loadPhase` → `loadAllPhases`, convirtiendo un escaneo de directorio en N+1. **Encontrado mientras se arreglaba un umbral flaky de k6 en `product/infra/load/k6/smoke.js`.** Ese arreglo — aseverar la mediana en vez de un p95 de 10 muestras — detiene el flake que bloqueaba CI y a propósito no atiende este defecto; es *la razón* de que `/health` tenga una cola de cientos de ms con 1 VU. +- **Componente:** `Core API` · **Criticidad:** P1 · **Complejidad:** S +- **Procedencia:** Registrado el 2026-07-31 desde la corrida de CI `30631939687` y reproducido localmente el mismo día. Id asignado por unión de `origin/main..origin/develop` y los pull requests abiertos (`#325` y el conjunto de dependabot), cuyo máximo era `GT-645`. Separado de [`GT-649`](./gap-reference-catalog.es.md#gt-649) deliberadamente: los WARN de ese log son un *síntoma* de esta carga por petición pero son defectos de corpus independientes, y juntarlos es justo el fallo de un-gap-un-reclamo que [`GT-639`](./gap-reference-catalog.es.md#gt-639) existe para evitar. +- **Criterios de aceptación:** + - [x] El corpus se carga y valida contra schema **una sola vez** para un `corePath` dado, y las evaluaciones posteriores lo leen de memoria. Entregado como `CachingRulesetRepository`, un decorador sobre `IRulesetRepository` — la caché pertenece al despliegue de larga vida, no al adaptador de disco, porque el CLI es un proceso de un solo disparo que debe seguir releyendo disco entre invocaciones. + - [x] La carga ocurre **antes** de que el servidor acepte tráfico, para que la petición #1 tampoco la pague. `RulesetCorpusWarmupService` (un hook `OnApplicationBootstrap`) la precarga y registra el conteo como evidencia: `Ruleset corpus loaded at startup: 393 rules … (loaded once; evaluations no longer re-read disk)`. + - [x] La invalidación es **explícita**, no por tiempo (`invalidate(corePath?)`). Un TTL solo reintroduciría el mismo atasco en un horario impredecible; el corpus va horneado en la imagen junto al proceso, así que no hay cadencia que adivinar. + - [x] Las cargas en frío concurrentes colapsan en una sola lectura de disco (se memoriza la *promesa* en vuelo), o la caché apenas movería la estampida del estado estable al momento de ráfaga. + - [x] Los fallos nunca se cachean, así que [`GT-474`](./gap-reference-catalog.es.md#gt-474) conserva su sentido: un corpus irresoluble sigue lanzando por petición con su rastro completo de sondeo, y un corpus reparado en disco carga en el siguiente intento en vez de dejar al proceso enganchado en fallo permanente. + - [x] **Los veredictos no cambian, verificado en vez de argumentado.** Se capturó el `EvaluationResult` completo del payload de smoke de k6 desde una build de `origin/develop` y desde este cambio con los datos de fase de [`GT-649`](./gap-reference-catalog.es.md#gt-649) retenidos, y los dos son **idénticos byte a byte, 442 204 bytes**. Aparte, el propio corpus normalizado se comparó contra el loader previo al cambio: **393 reglas, JSON idéntico**. + - [x] Medido después: el corpus carga una vez en arranque (393 reglas, 181–590 ms), la mediana de evaluate baja 52,7 → 38,2 ms, y un `/health` emitido 5 ms dentro de un evaluate en vuelo baja **11,6 → 3,5 ms**. El bloque de WARN por petición desaparece del log por completo. + - [ ] Re-medido en un runner de CI y no en una laptop, contra el `k6-smoke.json` de una corrida Reliability posterior, para confirmar que la cola de 498 ms desapareció donde se observó. Las cifras locales subestiman este defecto unas 10× por construcción y no son la evidencia que lo cierra. + +#### GT-646 + +**Title:** Un gate de CI afirmaba p(95) sobre diez muestras, así que una sola petición lo decidía, y su fallo ocultaba el perfil que corre después + +- **Purpose:** Hacer que el perfil de smoke afirme un estadístico que su tamaño de muestra pueda sostener, para que el gate de correctitud falle por degradación y no por cuál de diez peticiones resultó ser la más lenta — y evitar que un solo umbral incumplido produzca un segundo paso en rojo sin relación. +- **Evidence:** **El umbral medía el máximo, no un percentil.** `smoke.js` corre 1 VU x 10 iteraciones y afirmaba `health_latency p(95) < 150`. k6 evalúa p(95) en el índice `0.95*(n-1) = 8.55`, interpolando el 55 % del camino entre la 9ª muestra ordenada y la 10ª, así que con n=10 el estadístico queda dominado por la única petición más lenta. **Observado en la corrida `30631939687` sobre el commit `d97db1d4`, veinte minutos después de que la corrida `30631081684` pasara sobre el árbol idéntico:** `x 'p(95)<150' p(95)=276.1ms`, con todos los demás umbrales en verde — checks 50/50, `evaluate_latency` p95 122 ms, `evaluate_errors` 0 %, `throttled_429` 0 %, `http_req_failed` 0 %. **Reconstruido desde el propio `k6-smoke.json` de la corrida:** nueve muestras de health entre 2,72 ms y 4,3 ms y una de 498,5 ms; 276,1 ms es exactamente `4.3 + 0.55*(498.5 - 4.3)`, **una latencia que ninguna petición de la corrida tuvo jamás**. Alimentar esos diez valores a k6 v2.0.0 reproduce `p(95)=276.1ms`, `p(90)=53.72ms`, `med=3.08ms` y `avg=52.73ms` — los números del artefacto al decimal. **LA HIPÓTESIS DE ARRANQUE EN FRÍO SE VERIFICÓ Y QUEDÓ REFUTADA**, lo cual importa porque descarta como arreglos tanto una iteración de calentamiento como un techo más alto. Tres medidas de esa misma corrida: `wait-for-target.sh` ejecuta un `POST /api/v1/evaluate` gobernado completo hasta un sobre de éxito antes de invocar a k6, así que el proceso está caliente por construcción; `evaluate_latency` fue plana en las diez iteraciones (mín 107,3 ms, máx 122,4 ms), y un proceso frío lo mostraría primero ahí, en el camino que corre ~107 reglas más OPA-wasm; y el propio log `SecurityAudit` de core-api registra el handler de health en `durationMs=0` mientras k6 medía 498,4 ms de `http_req_waiting` y 0,165 ms de `http_req_connecting`. La petición estaba encolada, no lenta. **La cola tiene una causa con nombre que esta fila NO arregla:** core-api re-escanea y re-valida todo su corpus de rulesets en cada evaluación — el bloque WARN de `Phases directory not found` y `Skipping non-standard ruleset` se repite una vez por iteración en `core-api.log`, donde una carga de arranque lo registraría una sola vez — y una sonda de health que cae dentro de ese trabajo síncrono lo espera. La duración del bloqueo es una propiedad del corpus, así que ningún techo la acota. **El fallo además producía un segundo paso en rojo que no tenía por qué producir:** `report-summary.mjs` leía `--summary "$RUNNER_TEMP/k6-average.json"` bajo `if: always()`, pero el perfil de carga media está detrás del gate de smoke y legítimamente nunca había corrido, así que el paso moría con `ENOENT` y el stack trace desplazaba la tabla de smoke que explicaba el fallo real. +- **Component:** `Reliability` · **Criticality:** P2 · **Complexity:** XS +- **Provenance:** Registrado y cerrado el 2026-07-31 a partir de un workflow `Reliability` intermitentemente rojo, tras reconstruir la distribución de muestras desde el artefacto de la corrida en vez de inferirla de la línea de resumen. Acotado de forma estrecha: aquí se arreglan el defecto estadístico y el ENOENT; el re-escaneo del corpus por evaluación que produjo el valor atípico es un defecto de rendimiento aparte y se registra por su cuenta en vez de absorberse en este cierre. Avanza `GT-443`, que sigue `IN-PROGRESS`. +- **Acceptance criteria:** + - [x] El perfil de smoke ya no decide sobre un percentil que su tamaño de muestra no puede sostener: `smoke.js` afirma `med` para `health_latency` y `evaluate_latency`, y la razón queda escrita en el fichero con la aritmética del fallo observado, no solo como un número cambiado. + - [x] No se subió ningún número de SLO para que el gate pasara — la mediana se afirma contra los mismos valores `SLO.health_p95` / `SLO.evaluate_p95`, lo que es una afirmación estrictamente más débil que el SLO y no una relajada. + - [x] `p(95)` y `p(99)` siguen APLICÁNDOSE en `average-load.js`, cuyo número de muestras los sostiene, y la cola de smoke se sigue midiendo y publicando (`summaryTrendStats` y el step summary llevan med/p90/p95/p99/max). + - [x] El arreglo se verifica por A/B contra un objetivo que reproduce la distribución observada (p95 273,7 ms, máx 495 ms): el script previo sale 99 y el posterior sale 0, con la cola sin cambios entre ambos. + - [x] El estadístico más laxo conserva los dientes: un objetivo genuinamente lento en cada petición (200 ms) falla el nuevo gate, observado como `x 'med<150' med=199.83ms` con salida distinta de cero. + - [x] Un resumen de k6 ausente ya no convierte un umbral incumplido en dos pasos rojos: `report-summary.mjs` reporta "did not run" y sale 0 en vez de `ENOENT`, verificado contra una ruta inexistente. + - [x] La tabla publicada no puede contradecir lo que la corrida aplicó: `report-summary.mjs` lee cada umbral y su veredicto de vuelta desde el JSON de resumen en vez de redeclarar los SLO, manejando la codificación invertida `lastFailed` de k6, verificado contra el artefacto real que falló. + - [x] La hipótesis de arranque en frío queda registrada como refutada, junto con la evidencia que la refuta, para que un lector futuro no reintente una iteración de calentamiento ni un techo más alto. + +#### GT-645 + +**Title:** El reclamo de un gap se deriva de la prosa de un pull request en vez de su diff, así que disputa lo que nadie reclamó y se pierde lo que el diff sí hizo + +- **Purpose:** Que "sobre qué gaps trabaja este pull request" sea una pregunta que responda el propio cambio, para que el check que impone un-gap-un-reclamo deje de contestarse editando una frase. +- **Evidence:** **`50-validate-gap-claim` lee el título, el cuerpo y el nombre de rama de un pull request, y nunca lee su diff.** Las dos direcciones de error se siguen de esa única decisión, y ambas se observaron el 2026-07-31. **FALSOS POSITIVOS, observados de primera mano en la corrida `30631939629` sobre el PR #320:** el guard reportó `ids claimed .. 10, contested .. 3` y nombró `GT-583`, `GT-602` y `GT-643` como disputados entre #320 y #324 — cuando #324 es justamente el pull request que *documenta* el problema de los falsos positivos y no reclama ninguno. También atribuyó `GT-578`, `GT-582`, `GT-588`, `GT-591`, `GT-642` y `GT-644` a #324 solo porque su tabla de evidencia los nombra, y `GT-609` a #320 porque un docstring de generador lo cita. Nueve atribuciones, de las cuales el conteo honesto es cero. Esta es la cuarta y quinta ocurrencia en un solo día: #316 ya había sido señalado por `GT-583` por una frase que decía que *pertenece a otro PR*, y #317 por `GT-602` por un párrafo que decía que este cambio *no lo rompió*. **El remedio que imprime el guard — "decidan qué pull request es dueño del id" — no tiene trabajo que hacer**, porque nunca hubo más de un reclamante queriendo el id. Lo que hicieron los autores en cambio fue borrar la frase: el cuerpo de #316 se editó para quitar una referencia cruzada verdadera, que es el guard empeorando los cuerpos. **FALSO NEGATIVO, que es la mitad que sobrevive a #324 y la razón de que esta fila exista aparte de él.** #324 se mergeó a `develop` el 2026-07-31 (`4c785c5c`) y estrecha la regla de prosa (los ids cuentan desde el título, la rama, o tras un marcador explícito `Closes`/`Fixes`/`Advances`), lo que arregla el ruido. No convierte al diff en una entrada, así que un pull request que resuelve gaps *sin decirlo en la forma prescrita* sigue sin reclamar nada. No es hipotético: **el PR #315, titulado "Board reconciliation: credit the eleven gaps this wave merged" sobre la rama `chore/reconcile-board-wave`, pasó ONCE filas a `DONE` y escribió once registros de evidencia de cierre** — `GT-580`, `GT-587`, `GT-589`, `GT-597`, `GT-604`, `GT-606`, `GT-608`, `GT-614`, `GT-615`, `GT-616`, `GT-617` — sin ningún `GT-*` en su título ni en su nombre de rama. Hoy se le atribuyen esos ids solo por el accidente de que su cuerpo los menciona; bajo la regla estrechada no reclamaría absolutamente nada, y es el único pull request que más movió el board en esta ola. El diff dice exactamente qué tocó, en forma legible por máquina, y nada lo consulta — confirmado contra la implementación mergeada, cuyo `claimedIds()` arma su conjunto desde `pr.title`, `pr.headRefName` y cláusulas del cuerpo precedidas por marcador, y que no contiene ninguna referencia a un diff, a una lista de ficheros ni a un patch en sus 317 líneas. **El pull request que registra ESTA fila es la segunda instancia, y se demuestra a sí mismo:** cambia ocho filas y agrega seis registros de evidencia de cierre, y el guard lee de él exactamente un reclamo — `GT-645`, la fila que solo registra — y ninguno de los seis que cierra. **Esto se midió en un runner en vez de predecirse:** la corrida `30633517559` reporta `ids claimed .. 1`, `mentioned, not claimed 10`, `contested .. 0`, y sale ✓ — un check en verde sobre el pull request que resolvió seis filas y no reclamó ninguna. Las dos direcciones de error se leen en ese único resultado: #324 llevó el conteo de disputados de 3 a 0, que era todo su alcance, y lo que deja sin ver es el asunto entero de esta fila. **UN DEFECTO ATRIBUIDO A ESTE GUARD EN UN BRIEFING FUE VERIFICADO Y REFUTADO, y queda registrado aquí para que no se vuelva a registrar:** se reportó que el guard no vio al PR #314 pasar `GT-602` a `DONE` y escribir su registro de cierre. #314 (`feat/gt-604-ingest-client`) toca doce ficheros y **ninguno está bajo `reference/core/control-center/`**; `GT-602` lo cerró **#306**, sobre la rama `feat/gt-602-close-abac-wasm-parity`, cuyo nombre de rama lleva el id que el guard habría leído. La clase de falso negativo es real — #315 lo demuestra — pero esa instancia concreta no lo era. +- **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M +- **Provenance:** Registrado el 2026-07-31 a partir de cinco desaciertos observados en un solo día, cuatro falsos positivos y un falso negativo estructural encontrado auditando qué pull request cerró realmente cada gap de esta ola. Acotado a propósito a la mitad del DIFF: la mitad de prosa aterrizó como #324 (`4c785c5c`), y duplicarla aquí es exactamente el fallo que [`GT-639`](./gap-reference-catalog.es.md#gt-639) existe para evitar. Hermano de [`GT-639`](./gap-reference-catalog.es.md#gt-639) (que construyó el guard) y de [`GT-638`](./gap-reference-catalog.es.md#gt-638) (que asigna los ids sobre los que razona). +- **Acceptance criteria:** + - [ ] El conjunto de reclamos se deriva del DIFF del pull request — una fila `GT-*` cuyo estado cambia el diff, o un registro de evidencia de cierre que el diff agrega, es un reclamo de ese pull request diga lo que diga su prosa. + - [ ] Un fixture reproduce el PR #315 — título y rama sin ningún id, un diff que pasa once filas a `DONE` y agrega once registros de cierre — y se le OBSERVA FALLANDO contra la implementación actual antes del arreglo, no solamente pasando después. + - [ ] Un fixture reproduce la dirección de falso positivo de la corrida `30631939629`: un cuerpo que nombra seis gaps en una tabla de evidencia y no reclama ninguno no aporta reclamos, de modo que un pull request que documenta un gap nunca se confunda con uno que lo trabaja. + - [ ] Que prosa y diff discrepen se reporta como hallazgo propio en vez de resolverse en silencio hacia un lado: un cuerpo que reclama un id que su diff no toca, o un diff que toca una fila que su cuerpo nunca nombra, es el caso que merece mirada humana. + +#### GT-644 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index 5241d4ed5..8c5abaf3f 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -8299,3 +8299,65 @@ Historical gap series tracked in the former `gap-analysis-core.md`, preserved fo - [x] `npm view @beyondnet/evolith-mcp@1.1.0` reports the version as deprecated, likewise cli and agent-runtime — and also core-domain, core and infra-providers. `evolith-sdk@1.1.0` and `evolith-contracts@1.1.0` are deliberately NOT deprecated, for reasons recorded in the evidence above rather than skipped. - [x] A CI gate fails when a commit whose type or scope marks it as security is absent from the latest published version. `48-validate-security-publish-lag`, wired into `Governance guards (GT-578)`. It asks the registry rather than git tags, because the newest `v*` tag is `v1.1.0` while npm serves `1.2.2`. - [x] The gate ships with negative fixtures that turn it red — 12 of them, including the anti-vacuous floors and a regression for each of the two false negatives found while building it — and `43-validate-guard-negative-fixtures` has OBSERVED it refuse the empty fixture (37/37). +- **Provenance:** Registered 2026-07-31 from the same `core-api.log` that produced [`GT-648`](./gap-reference-catalog.md#gt-648), and kept separate from it on purpose: those WARNs are how the per-request load was *noticed*, but they are corpus and logging defects that would survive any amount of caching. Fault (2) was traced by `git log --diff-filter=AD` rather than assumed — the files existed, and the commit that removed them says it was clearing leftovers. +- **Acceptance criteria:** + - [x] The loader classifies by the `$schema` a document declares, and validates each non-corpus kind against **that** contract instead of the standard one. This is strictly more checking than before, not a suppression: the previous behaviour checked them against the wrong schema and discarded the result. + - [x] A document that violates the schema it itself declares still WARNs, and a standard-shaped ruleset that fails the standard schema still WARNs. Both directions are pinned by tests, so silencing did not become blindness. + - [x] All three documents now satisfy their own declared schemas: `rule-definition.schema.json` models the `validation` block the two INFRA files always carried, the files use `title`/enum severities, and their `$schema` and target paths point at trees that exist (`product/infra/…`). + - [x] The real corpus loads with **zero** warnings and **zero** errors — asserted against the actual repository tree, not a fixture, because a fixture cannot tell us the corpus as authored is clean. + - [x] The five `phase-f*.json` files are restored, conform to `sdlc-phase.schema.json` (`order` renumbered 1–5 to satisfy its `minimum: 1`, `$schema` normalized to the `$id` form the gate files already use), and the five gate files are reachable again. + - [x] `NestLoggerProvider` forwards `context` only when it exists; the one-argument and two-argument forms are both pinned by tests. Verified end-to-end: **44 → 0** `WARN undefined` lines over an identical eleven-request run. + - [ ] **The verdict change from fault (2) is understood and accepted by the board before this reaches a satellite.** Restoring the phase index is a governance-visible change, not a silent repair: a satellite that was passing because its F1 gate never ran can now legitimately FAIL. For the k6 smoke payload the top-level verdict is unchanged (`FAIL` before and after, already failing on general rulesets), but that payload does not exercise the flip and must not be read as evidence that no payload does. + - [ ] The two unwired duplicates of the same logger adapter — `src/apps/core-api/src/infrastructure/providers/logger.provider.ts` and `src/sdk/cli/src/infrastructure/providers/logger.provider.ts` — are removed. Neither is reachable (both surfaces resolve `NestLoggerProvider` from `@beyondnet/evolith-infra-providers`), but both still carry fault (3) and their specs still assert it. + +#### GT-648 + +**Title:** The deployed Core re-reads and re-validates its whole ruleset corpus on every evaluate call, blocking the event loop per request + +- **Purpose:** Make the ruleset corpus what it has always been — deployment state, read once — so a governed evaluation stops paying for a disk walk and a schema pass that cannot produce a different answer than the previous request's. +- **Evidence:** **`RuleEvaluationEngine.discoverAndEvaluate` (`rule-evaluation-engine.ts:308`) calls `rulesetRepo.loadAllRulesets(corePath)` on every evaluation, and core-api's `IRulesetRepository` provider is a bare `DiskRulesetRepository` with no cache** (`core-domain.module.ts`). Each call walks the corpus tree, reads 176 `*.rules.json` files, and runs each through Ajv to produce 393 normalized rules. Both the walk and the Ajv pass are synchronous CPU work inside `await`ed calls, so the Node event loop cannot advance while they run — which makes this a whole-process latency defect, not a slow endpoint. **Observed in CI run `30631939687` (Reliability workflow on `beyondnetcode/evolith`, artifacts `k6-smoke.json` and `core-api.log`):** the corpus's load-time WARN block — *"Phases directory not found …"*, plus three *"Skipping non-standard ruleset …"* lines — repeats once per k6 iteration at roughly 1/s. A boot-time load would emit it exactly once, so the log itself is the proof that the corpus is being loaded per request. **The measured consequence, from the same run:** a `GET /health` that landed during that work took **498.5 ms end-to-end** while core-api logged its own handler at `durationMs=0`, with `http_req_waiting` 498.4 ms against `http_req_connecting` 0.17 ms. Nothing about the health handler was slow; the request sat in the accept queue behind a loop that was not running callbacks. **Reproduced locally on 2026-07-31 against `origin/develop` at `3051e499`, and the reproduction is exact:** eleven evaluations produced eleven corpus loads — 33 *"Skipping non-standard ruleset"* lines at three per load, and 11 *"Phases directory not found"* lines at one per load. One `loadAllRulesets` call, timed directly on that corpus, cost **30.4–54.2 ms**, and a 10 ms timer queued across it fired up to **14 ms late** — the event-loop block, measured rather than inferred. **That laptop figure is roughly a tenth of the runner's 498 ms, and the discrepancy is not a weakness of the finding but its shape:** an SSD with a warm page cache is the best case for exactly this work, and the runner is the ordinary one. **Compounding it, and also per request:** `SdlcDataLoaderService.loadAllGates` re-reads the phase index once per phase, because `loadGatesForPhase` → `loadPhase` → `loadAllPhases`, turning one directory scan into N+1. **Found while fixing a flaky k6 threshold in `product/infra/load/k6/smoke.js`.** That fix — asserting the median instead of a 10-sample p95 — stops the flake gating CI and deliberately does not address this defect; it is *why* `/health` has a multi-hundred-ms tail at 1 VU. +- **Component:** `Core API` · **Criticality:** P1 · **Complexity:** S +- **Provenance:** Registered 2026-07-31 from CI run `30631939687` and reproduced locally the same day. Id assigned by union of `origin/main..origin/develop` and the open pull requests (`#325` and the dependabot set), whose maximum was `GT-645`. Split from [`GT-649`](./gap-reference-catalog.md#gt-649) deliberately: the WARNs in that log are a *symptom* of this per-request load but are independent corpus defects, and folding them together is the one-gap-one-claim failure [`GT-639`](./gap-reference-catalog.md#gt-639) exists to prevent. +- **Acceptance criteria:** + - [x] The corpus is loaded and schema-validated **once** for a given `corePath`, and later evaluations read it from memory. Delivered as `CachingRulesetRepository`, a decorator over `IRulesetRepository` — the cache belongs to the long-running deployment, not to the disk adapter, because the CLI is a one-shot process that must keep re-reading disk between invocations. + - [x] The load happens **before** the server accepts traffic, so request #1 does not pay it either. `RulesetCorpusWarmupService` (an `OnApplicationBootstrap` hook) preloads it and logs the count as evidence: `Ruleset corpus loaded at startup: 393 rules … (loaded once; evaluations no longer re-read disk)`. + - [x] Invalidation is **explicit**, not time-based (`invalidate(corePath?)`). A TTL would only reintroduce the same stall on an unpredictable schedule; the corpus is baked into the image alongside the process, so there is no cadence to guess at. + - [x] Concurrent cold loads collapse into one disk read (the in-flight *promise* is memoized), or the cache would merely move the stampede from steady state to burst time. + - [x] Failures are never cached, so [`GT-474`](./gap-reference-catalog.md#gt-474) keeps its meaning: an unresolvable corpus still throws per request with its full probe trail, and a corpus repaired on disk loads on the next attempt instead of latching the process into permanent failure. + - [x] **Verdicts are unchanged, verified rather than argued.** The full `EvaluationResult` for the k6 smoke payload was captured from a build of `origin/develop` and from this change with the [`GT-649`](./gap-reference-catalog.md#gt-649) phase data withheld, and the two are **byte-for-byte identical at 442,204 bytes**. Separately, the normalized corpus itself was diffed against the pre-change loader: **393 rules, identical JSON**. + - [x] Measured after: the corpus loads once at boot (393 rules, 181–590 ms), evaluate median falls 52.7 → 38.2 ms, and a `/health` issued 5 ms into an in-flight evaluate falls **11.6 → 3.5 ms**. The per-request WARN block is gone from the log entirely. + - [ ] Re-measured on a CI runner rather than a laptop, against the `k6-smoke.json` of a later Reliability run, to confirm the 498 ms tail is gone where it was observed. Local numbers understate this defect by roughly 10× by construction and are not the evidence that closes it. + +#### GT-646 + +**Title:** A CI gate asserted p(95) over ten samples, so one request decided it, and its failure hid the profile that runs after it + +- **Purpose:** Make the smoke profile assert a statistic its sample size can actually support, so the correctness gate fails on degradation rather than on which of ten requests happened to be slowest — and stop a single threshold miss from producing a second, unrelated red step. +- **Evidence:** **The threshold measured the maximum, not a percentile.** `smoke.js` runs 1 VU x 10 iterations and asserted `health_latency p(95) < 150`. k6 evaluates p(95) at index `0.95*(n-1) = 8.55`, interpolating 55 % of the way from the 9th sorted sample to the 10th, so at n=10 the statistic is dominated by the single slowest request. **Observed in run `30631939687` on commit `d97db1d4`, twenty minutes after run `30631081684` passed on the identical tree:** `x 'p(95)<150' p(95)=276.1ms`, with every other threshold green — checks 50/50, `evaluate_latency` p95 122 ms, `evaluate_errors` 0 %, `throttled_429` 0 %, `http_req_failed` 0 %. **Reconstructed from the run's own `k6-smoke.json`:** nine health samples between 2.72 ms and 4.3 ms and one at 498.5 ms; 276.1 ms is exactly `4.3 + 0.55*(498.5 - 4.3)`, **a latency no request in the run ever had**. Feeding those ten values to k6 v2.0.0 reproduces `p(95)=276.1ms`, `p(90)=53.72ms`, `med=3.08ms` and `avg=52.73ms` — the artifact's numbers to the decimal. **THE COLD-START HYPOTHESIS WAS CHECKED AND REFUTED**, which matters because it rules out both a warm-up iteration and a raised ceiling as fixes. Three measurements from that same run: `wait-for-target.sh` drives a full governed `POST /api/v1/evaluate` to a success envelope before k6 is invoked, so the process is warm by construction; `evaluate_latency` was flat across all ten iterations (min 107.3 ms, max 122.4 ms), and a cold process would show it there first, on the path that runs ~107 rules plus OPA-wasm; and core-api's own `SecurityAudit` log records the health handler at `durationMs=0` while k6 measured 498.4 ms of `http_req_waiting` and 0.165 ms of `http_req_connecting`. The request was queued, not slow. **The queue has a named cause that is NOT fixed by this row:** core-api re-scans and re-validates its entire ruleset corpus on every evaluation — the `Phases directory not found` and `Skipping non-standard ruleset` WARN block repeats once per iteration in `core-api.log`, where a boot-time load would log it once — and a health probe landing inside that synchronous work waits it out. The blocking duration is a property of the corpus, so no ceiling bounds it. **The failure also produced a second red step it had no business producing:** `report-summary.mjs` read `--summary "$RUNNER_TEMP/k6-average.json"` under `if: always()`, but the average-load profile is gated behind smoke and had legitimately never run, so the step died with `ENOENT` and the stack trace displaced the smoke table that explained the actual failure. +- **Component:** `Reliability` · **Criticality:** P2 · **Complexity:** XS +- **Provenance:** Registered and closed 2026-07-31 from an intermittently red `Reliability` workflow, after reconstructing the sample distribution from the run artifact rather than inferring it from the summary line. Carved narrowly: the statistical defect and the ENOENT are fixed here; the per-evaluation corpus re-scan that produced the outlier is a separate performance defect and is registered on its own rather than absorbed into this closure. Advances `GT-443`, which stays `IN-PROGRESS`. +- **Acceptance criteria:** + - [x] The smoke profile no longer gates on a percentile its sample size cannot support: `smoke.js` asserts `med` for `health_latency` and `evaluate_latency`, and the rationale is stated in the file with the arithmetic of the observed failure, not merely as a changed number. + - [x] No SLO number was raised to make the gate pass — the median is asserted against the same `SLO.health_p95` / `SLO.evaluate_p95` values, which is a strictly weaker claim than the SLO rather than a relaxed one. + - [x] `p(95)` and `p(99)` remain ENFORCED in `average-load.js`, whose sample count supports them, and the smoke tail stays measured and published (`summaryTrendStats` and the step summary both carry med/p90/p95/p99/max). + - [x] The fix is verified by A/B against a target reproducing the observed distribution (p95 273.7 ms, max 495 ms): the pre-fix script exits 99 and the post-fix script exits 0, with the tail unchanged between them. + - [x] The relaxed statistic still has teeth: a target that is genuinely slow on every request (200 ms) fails the new gate, observed as `x 'med<150' med=199.83ms` with a non-zero exit. + - [x] A missing k6 summary no longer turns one threshold miss into two red steps: `report-summary.mjs` reports "did not run" and exits 0 instead of `ENOENT`, verified against a missing path. + - [x] The published table cannot disagree with what the run enforced: `report-summary.mjs` reads each threshold and its verdict back out of the summary JSON instead of re-declaring the SLOs, handling k6's inverted `lastFailed` encoding, verified against the real failing artifact. + - [x] The cold-start hypothesis is recorded as refuted, with the evidence that refutes it, so a future reader does not re-attempt a warm-up iteration or a higher ceiling. + +#### GT-645 + +**Title:** A gap claim is derived from a pull request's prose instead of its diff, so it contests what nobody claimed and misses what the diff actually did + +- **Purpose:** Make "which gaps does this pull request work on" a question answered by the change itself, so the check that enforces one-gap-one-claim stops being answerable by editing a sentence. +- **Evidence:** **`50-validate-gap-claim` reads a pull request's title, body and branch name, and never reads its diff.** Both error directions follow from that one decision, and both were observed on 2026-07-31. **FALSE POSITIVES, observed first-hand in run `30631939629` on PR #320:** the guard reported `ids claimed .. 10, contested .. 3` and named `GT-583`, `GT-602` and `GT-643` as contested between #320 and #324 — while #324 is the pull request that *documents* the false-positive problem and claims none of them. It also attributed `GT-578`, `GT-582`, `GT-588`, `GT-591`, `GT-642` and `GT-644` to #324 purely because its evidence table names them, and `GT-609` to #320 because a generator docstring cites it. Nine attributions, of which the honest count is zero. This is the fourth and fifth occurrence in one day: #316 was already flagged for `GT-583` over a sentence saying it *belongs to another PR*, and #317 for `GT-602` over a paragraph saying this change *did not break it*. **The remedy the guard prints — "decide which pull request owns the id" — has no work to do**, because only one claimant ever wanted the id. What authors did instead was delete the sentence: #316's body was edited to remove a true cross-reference, which is the guard making the bodies worse. **FALSE NEGATIVE, which is the half that survives #324 and the reason this row exists separately from it.** #324 merged into `develop` on 2026-07-31 (`4c785c5c`) and narrows the prose rule (ids count from the title, the branch, or after an explicit `Closes`/`Fixes`/`Advances` marker), which fixes the noise. It does not make the diff an input, so a pull request that resolves gaps *without saying so in the prescribed shape* still claims nothing. That is not hypothetical: **PR #315, titled "Board reconciliation: credit the eleven gaps this wave merged" on branch `chore/reconcile-board-wave`, flipped ELEVEN rows to `DONE` and wrote eleven closure-evidence records** — `GT-580`, `GT-587`, `GT-589`, `GT-597`, `GT-604`, `GT-606`, `GT-608`, `GT-614`, `GT-615`, `GT-616`, `GT-617` — with no `GT-*` in its title and none in its branch name. Today it is attributed those ids only by the accident of its body mentioning them; under the narrowed rule it would claim nothing at all, and it is the single pull request that moved the board most in this wave. The diff says exactly what it touched, in machine-readable form, and nothing consults it — confirmed against the merged implementation, whose `claimedIds()` builds its set from `pr.title`, `pr.headRefName` and marker-prefixed body clauses, and which contains no reference to a diff, a file list or a patch anywhere in its 317 lines. **The pull request that registers THIS row is the second instance, and it demonstrates itself:** it flips eight rows and adds six closure-evidence records, and the guard reads from it exactly one claim — `GT-645`, the row it merely registers — and none of the six it closes. **This was measured on a runner rather than predicted:** run `30633517559` reports `ids claimed .. 1`, `mentioned, not claimed 10`, `contested .. 0`, and exits ✓ — a green check on the pull request that resolved six rows and claimed none of them. Both error directions are legible in that one result: #324 took the contested count from 3 to 0, which was the whole of its scope, and the miss it leaves behind is the entire subject of this row. **A DEFECT ATTRIBUTED TO THIS GUARD IN A BRIEFING WAS CHECKED AND REFUTED, and is recorded here so it is not re-registered:** it was reported that the guard missed PR #314 flipping `GT-602` to `DONE` and writing its closure record. #314 (`feat/gt-604-ingest-client`) touches twelve files and **not one of them is under `reference/core/control-center/`**; `GT-602` was closed by **#306**, on branch `feat/gt-602-close-abac-wasm-parity`, whose branch name carries the id the guard would have read. The false-negative class is real — #315 demonstrates it — but that particular instance was not. +- **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M +- **Provenance:** Registered 2026-07-31 from five observed misfires in a single day, four of them false positives and one structural false negative found by auditing which pull request actually closed each gap of this wave. Scoped deliberately to the DIFF half: the prose half landed as #324 (`4c785c5c`), and duplicating it here is the exact failure [`GT-639`](./gap-reference-catalog.md#gt-639) exists to prevent. Sibling of [`GT-639`](./gap-reference-catalog.md#gt-639) (which built the guard) and [`GT-638`](./gap-reference-catalog.md#gt-638) (which allocates the ids it reasons about). +- **Acceptance criteria:** + - [ ] The claim set is derived from the pull request's DIFF — a `GT-*` row whose status the diff changes, or a closure-evidence record the diff adds, is a claim by that pull request whatever its prose says. + - [ ] A fixture reproduces PR #315 — title and branch carrying no id, a diff flipping eleven rows to `DONE` and adding eleven closure records — and is OBSERVED FAILING against the current implementation before the fix, not merely passing after it. + - [ ] A fixture reproduces the false-positive direction from run `30631939629`: a body that names six gaps in an evidence table and claims none of them contributes no claims, so a pull request that documents a gap is never mistaken for one that works it. + - [ ] Prose and diff disagreeing is reported as its own finding rather than silently resolved one way: a body claiming an id its diff does not touch, or a diff touching a row its body never names, is the case that is worth a human look. + +#### GT-644 diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index df4aea0dd..4ab881e79 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -722,3 +722,5 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida --- [Volver al Índice de Visión](../../README.es.md) +| [`GT-648`](./gap-reference-catalog.es.md#gt-648) | **El Core desplegado vuelve a leer y revalidar todo su corpus de rulesets en cada `POST /api/v1/evaluate`, bloqueando el event loop de Node mientras lo hace.** `RuleEvaluationEngine.discoverAndEvaluate` llama a `rulesetRepo.loadAllRulesets(corePath)` en cada evaluación, y el `IRulesetRepository` de core-api es un `DiskRulesetRepository` pelado sin caché — así que un recorrido de directorios sobre 176 ficheros `*.rules.json` más una pasada de Ajv que produce 393 reglas normalizadas corre por petición. Ambas mitades son trabajo de CPU síncrono dentro de llamadas `await`, así que nada más en el loop avanza mientras corren. **Observado en la corrida de CI `30631939687` (workflow Reliability, artefactos `k6-smoke.json` + `core-api.log`):** el bloque de WARN de carga del corpus se repite una vez por iteración de k6 (~1/s) — una carga en arranque lo imprimiría una sola vez — y **un `GET /health` que cayó durante ese trabajo tardó 498,5 ms extremo a extremo mientras core-api registraba su propio handler en `durationMs=0`**, con `http_req_waiting` 498,4 ms contra `http_req_connecting` 0,17 ms. La petición nunca fue lenta; estaba encolada tras un loop bloqueado. **Reproducido localmente el 2026-07-31 contra `origin/develop` en `3051e499`:** once evaluaciones produjeron exactamente once cargas del corpus (33 WARN de ruleset a 3 por carga, 11 WARN de fases a 1 por carga), y una llamada a `loadAllRulesets` se midió en 30,4–54,2 ms con un temporizador de 10 ms encolado esperando hasta 14 ms. Esa cifra de laptop es más o menos una décima de la del runner, y la distancia entre ambas es justamente el punto: el mismo defecto sobre E/S más lenta y disputada es lo que produjo 498 ms. **También por petición, y agravándolo:** `SdlcDataLoaderService.loadAllGates` relee el índice de fases una vez por fase (`loadPhase` → `loadAllPhases`), convirtiendo un escaneo en N+1. **No lo atiende el arreglo del umbral de k6** que asevera la mediana en vez de un p95 de 10 muestras — eso detiene el flake que bloqueaba CI y deja este defecto en pie a propósito; es la razón de que `/health` tenga una cola de cientos de ms con 1 VU. | `Core API` | Cross | P1 | S | `PENDIENTE` | +| [`GT-649`](./gap-reference-catalog.es.md#gt-649) | **El corpus de evaluación carga tres documentos que el loader reporta como rotos en cada carga, una etapa de gates de fase que no evalúa nada desde el 2026-07-04, y un logger que duplica cada línea que escribe.** Tres fallos afloraron juntos en el mismo `core-api.log` de la corrida de CI `30631939687`, y cada uno es real por separado. **(1) Tres ficheros `*.rules.json` fallan el schema estándar de ruleset con `must have required property 'rules' / 'principles'` y se saltan siempre** — `architecture/topology-recommendation.rules.json` (el catálogo de recomendación de ADR-0104, que tiene su propio schema y su propio consumidor en `TopologyRecommendationService`), y `infrastructure/helm-enforcement.rules.json` + `infrastructure/opa-sidecar-bundle.rules.json` (declaraciones de una sola regla, aplicadas por el guard `29-validate-opa-sidecar-bundles.mjs` y su Rego pareado, no por el motor de reglas). No tenían nada malo: son tipos de documento distintos que comparten sufijo, y el loader no tenía vocabulario para decirlo. Los tres además fallaban el schema que *declaran*, sin que nadie lo notara, porque nunca se los verificó contra él — `severity: "error"` no está en el enum de ese schema, `name` no es su campo (`title` sí), el bloque `validation` que nunca modeló, y una ruta `$schema` que la reorganización de `reference/` dejó obsoleta. **(2) `reference/governance/sdlc/phases/` no existe**, borrado por `f0d01911` (2026-07-04, *"remove leftover old directories under reference/"*) que eliminó todo el árbol `reference/governance/`; `GT-461` recreó después `gates/` en esa misma ruta y no `phases/`. Como `loadGatesForPhase` resuelve los gates *a través* del índice de fases, que `loadAllPhases()` devuelva `[]` significa que **los cinco ficheros de gate escritos han sido inalcanzables durante ~4 semanas** — la etapa de gates de fase de `SatelliteEvaluationPipeline` producía cero resultados sin reportar error alguno, de modo que una evaluación gobernada se saltaba en silencio la mitad del pipeline donde viven los gates del SDLC. **(3) `NestLoggerProvider` reenvía su `context` opcional sin condición**, y el `Logger` de Nest lee `...optionalParams` por posición, así que `warn(msg, undefined)` no es `warn(msg)`: cada log de un solo argumento del dominio emitía una línea `WARN undefined` pareada. Medido en la reproducción base: **44 líneas de esas en 11 peticiones**, duplicando el volumen de todo lo que el Core escribe. | `Core Domain` | Cross | P1 | S | `PENDIENTE` | diff --git a/reference/core/control-center/gaps/gap-tracking.md b/reference/core/control-center/gaps/gap-tracking.md index bc2c62128..2ace45a89 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -744,3 +744,5 @@ This board is the single source of truth for technical debt, gaps, opportunities --- [Back to Vision Index](../README.md) +| [`GT-648`](./gap-reference-catalog.md#gt-648) | **The deployed Core re-reads and re-validates its entire ruleset corpus on every `POST /api/v1/evaluate`, blocking the Node event loop for the duration.** `RuleEvaluationEngine.discoverAndEvaluate` calls `rulesetRepo.loadAllRulesets(corePath)` on each evaluation, and core-api's `IRulesetRepository` is a bare `DiskRulesetRepository` with no cache — so a directory walk over 176 `*.rules.json` files plus an Ajv pass producing 393 normalized rules runs per request. Both halves are synchronous CPU work inside `await`ed calls, so nothing else on the loop advances while they run. **Observed in CI run `30631939687` (Reliability workflow, artifacts `k6-smoke.json` + `core-api.log`):** the corpus's load-time WARN block repeats once per k6 iteration (~1/s) — a boot-time load would print it once — and **a `GET /health` that landed during that work took 498.5 ms end-to-end while core-api logged its own handler at `durationMs=0`**, with `http_req_waiting` 498.4 ms against `http_req_connecting` 0.17 ms. The request was never slow; it was queued behind a blocked loop. **Reproduced locally 2026-07-31 against `origin/develop` at `3051e499`:** eleven evaluations produced exactly eleven corpus loads (33 ruleset WARNs at 3/load, 11 phase WARNs at 1/load), and one `loadAllRulesets` call was measured at 30.4–54.2 ms with a queued 10 ms timer waiting up to 14 ms. That laptop figure is roughly a tenth of the runner's, and the gap between the two is the point: the same defect on slower, contended I/O is what produced 498 ms. **Also per request, and compounding it:** `SdlcDataLoaderService.loadAllGates` re-reads the phase index once per phase (`loadPhase` → `loadAllPhases`), turning one scan into N+1. **Not addressed by the k6 threshold fix** that asserts the median instead of a 10-sample p95 — that stops the flake gating CI and deliberately leaves this defect in place; it is why `/health` has a multi-hundred-ms tail at 1 VU. | `Core API` | Cross | P1 | S | `PENDING` | +| [`GT-649`](./gap-reference-catalog.md#gt-649) | **The evaluation corpus carries three documents the loader reports as broken on every load, a phase-gate stage that has evaluated nothing since 2026-07-04, and a logger that doubles every line it writes.** Three faults surfaced together in the same `core-api.log` of CI run `30631939687`, and each is independently real. **(1) Three `*.rules.json` files fail the standard ruleset schema with `must have required property 'rules' / 'principles'` and are skipped every time** — `architecture/topology-recommendation.rules.json` (the ADR-0104 recommendation catalogue, which has its own schema and its own consumer in `TopologyRecommendationService`), and `infrastructure/helm-enforcement.rules.json` + `infrastructure/opa-sidecar-bundle.rules.json` (single-rule declarations enforced by guard `29-validate-opa-sidecar-bundles.mjs` and their paired Rego, not by the rule engine). Nothing was wrong with them: they are different document kinds sharing a suffix, and the loader had no vocabulary to say so. All three additionally failed the schema they *declare*, unnoticed, because nothing ever checked them against it — `severity: "error"` is not in that schema's enum, `name` is not its field (`title` is), the `validation` block it never modelled, and a `$schema` path left stale by the `reference/` reorganisation. **(2) `reference/governance/sdlc/phases/` does not exist**, deleted by `f0d01911` (2026-07-04, *"remove leftover old directories under reference/"*) which removed the whole `reference/governance/` tree; `GT-461` later re-created `gates/` at that same path and not `phases/`. Because `loadGatesForPhase` resolves gates *through* the phase index, `loadAllPhases()` returning `[]` means **every one of the five authored gate files has been unreachable for ~4 weeks** — the phase-gate stage of `SatelliteEvaluationPipeline` produced zero gate results while reporting no error, so a governed evaluation silently skipped the half of the pipeline the SDLC gates live in. **(3) `NestLoggerProvider` forwards its optional `context` unconditionally**, and Nest's `Logger` reads `...optionalParams` positionally, so `warn(msg, undefined)` is not `warn(msg)`: every single-argument log from the domain emitted a paired `WARN undefined` line. Measured on the baseline reproduction: **44 such lines across 11 requests**, doubling the volume of everything the Core writes. | `Core Domain` | Cross | P1 | S | `PENDING` | 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 3469da3c0..18e10ec30 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-603](../gaps/gap-reference-catalog.es.md#gt-603), [GT-435](../gaps/gap-reference-catalog.es.md#gt-435) | | 2 | Área de mayor riesgo | `Cross` tiene la mayor carga ponderada abierta. | [GT-435](../gaps/gap-reference-catalog.es.md#gt-435), [GT-448](../gaps/gap-reference-catalog.es.md#gt-448) | -| 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-631](../gaps/gap-reference-catalog.es.md#gt-631) | -| 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [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-605](../gaps/gap-reference-catalog.es.md#gt-605), [GT-583](../gaps/gap-reference-catalog.es.md#gt-583), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-448](../gaps/gap-reference-catalog.es.md#gt-448) | +| 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-631](../gaps/gap-reference-catalog.es.md#gt-631), [GT-648](../gaps/gap-reference-catalog.es.md#gt-648), [GT-649](../gaps/gap-reference-catalog.es.md#gt-649) | +| 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [GT-631](../gaps/gap-reference-catalog.es.md#gt-631), [GT-648](../gaps/gap-reference-catalog.es.md#gt-648), [GT-649](../gaps/gap-reference-catalog.es.md#gt-649), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-578](../gaps/gap-reference-catalog.es.md#gt-578), [GT-605](../gaps/gap-reference-catalog.es.md#gt-605), [GT-583](../gaps/gap-reference-catalog.es.md#gt-583), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), +1 | | 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-531](../gaps/gap-reference-catalog.es.md#gt-531), [GT-536](../gaps/gap-reference-catalog.es.md#gt-536), [GT-592](../gaps/gap-reference-catalog.es.md#gt-592), +8 | ## Bloqueadores Actuales @@ -43,13 +43,13 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so | Indicador | Valor | |---|---:| | Fecha canónica del tablero | 2026-07-26 | -| Gaps totales | 647 | +| Gaps totales | 649 | | Gaps cerrados | 621 | -| Gaps pendientes | 26 | +| Gaps pendientes | 28 | | P0 abiertos | 2 | -| P1 abiertos | 7 | +| P1 abiertos | 9 | | P2 abiertos | 14 | -| Cierre total | 96% | +| Cierre total | 95.7% | | Registros de evidencia de cierre | 603 | | Readiness registrado | 4 PASS | diff --git a/reference/core/control-center/maturity-reports/executive-summary.md b/reference/core/control-center/maturity-reports/executive-summary.md index 87986da64..1346de77d 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-603](../gaps/gap-reference-catalog.md#gt-603), [GT-435](../gaps/gap-reference-catalog.md#gt-435) | | 2 | Highest-risk area | `Cross` has the largest weighted open load. | [GT-435](../gaps/gap-reference-catalog.md#gt-435), [GT-448](../gaps/gap-reference-catalog.md#gt-448) | -| 3 | Quick wins | High criticality with XS/S complexity. | [GT-631](../gaps/gap-reference-catalog.md#gt-631) | -| 4 | P1 wave | Next hardening after P0 is cleared. | [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-605](../gaps/gap-reference-catalog.md#gt-605), [GT-583](../gaps/gap-reference-catalog.md#gt-583), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-448](../gaps/gap-reference-catalog.md#gt-448) | +| 3 | Quick wins | High criticality with XS/S complexity. | [GT-631](../gaps/gap-reference-catalog.md#gt-631), [GT-648](../gaps/gap-reference-catalog.md#gt-648), [GT-649](../gaps/gap-reference-catalog.md#gt-649) | +| 4 | P1 wave | Next hardening after P0 is cleared. | [GT-631](../gaps/gap-reference-catalog.md#gt-631), [GT-648](../gaps/gap-reference-catalog.md#gt-648), [GT-649](../gaps/gap-reference-catalog.md#gt-649), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-578](../gaps/gap-reference-catalog.md#gt-578), [GT-605](../gaps/gap-reference-catalog.md#gt-605), [GT-583](../gaps/gap-reference-catalog.md#gt-583), [GT-585](../gaps/gap-reference-catalog.md#gt-585), +1 | | 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-531](../gaps/gap-reference-catalog.md#gt-531), [GT-536](../gaps/gap-reference-catalog.md#gt-536), [GT-592](../gaps/gap-reference-catalog.md#gt-592), +8 | ## Current Blockers @@ -43,13 +43,13 @@ Use this summary with a simple rule: if you need context, open only the linked I | Indicator | Value | |---|---:| | Canonical board date | 2026-07-26 | -| Total gaps | 647 | +| Total gaps | 649 | | Closed gaps | 621 | -| Open gaps | 26 | +| Open gaps | 28 | | Open P0 | 2 | -| Open P1 | 7 | +| Open P1 | 9 | | Open P2 | 14 | -| Total closure | 96% | +| Total closure | 95.7% | | Closure evidence records | 603 | | Recorded readiness | 4 PASS | diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 69b10c4f7..507af1bd4 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -3,9 +3,9 @@ "scope": "evolith-core", "asOf": "2026-07-26", "gaps": { - "total": 647, + "total": 649, "done": 621, - "pending": 13, + "pending": 15, "inProgress": 9, "deferred": 4 }, @@ -74,4 +74,4 @@ "node .harness/scripts/ci/08-validate-tracking.mjs", "npm test --workspace src/sdk/cli -- --runInBand" ] -} +} \ No newline at end of file From 0a496cd0544d2c55db42ac58afd59093f27f24f8 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 18:34:03 -0500 Subject: [PATCH 4/8] chore: reconcile maturity (trailing newline) --- .../maturity-reports/maturity-reconciliation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 507af1bd4..b9d18ef4e 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -74,4 +74,4 @@ "node .harness/scripts/ci/08-validate-tracking.mjs", "npm test --workspace src/sdk/cli -- --runInBand" ] -} \ No newline at end of file +} From 9281f6a7444da66f0bfb9992b4d2364494893da8 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 18:50:35 -0500 Subject: [PATCH 5/8] docs(gaps): fix GT-649 missing header in catalog --- .../core/control-center/gaps/gap-reference-catalog.es.md | 7 +++++++ .../core/control-center/gaps/gap-reference-catalog.md | 7 +++++++ 2 files changed, 14 insertions(+) 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 29a3a0baf..97b789dd7 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -8204,6 +8204,13 @@ Serie histórica de gaps registrada en el antiguo `gap-analysis-core.es.md`, pre - [x] `npm view @beyondnet/evolith-mcp@1.1.0` reporta la versión como deprecada, e igual cli y agent-runtime — y también core-domain, core e infra-providers. `evolith-sdk@1.1.0` y `evolith-contracts@1.1.0` NO se deprecan a propósito, por los motivos registrados arriba en vez de omitidos. - [x] Un gate de CI falla cuando un commit cuyo tipo o scope lo marca como de seguridad no está en la última versión publicada. `48-validate-security-publish-lag`, cableado en `Governance guards (GT-578)`. Pregunta al registry y no a los tags de git, porque el tag `v*` más nuevo es `v1.1.0` mientras npm sirve `1.2.2`. - [x] El gate lleva fixtures negativas que lo ponen rojo — 12, entre ellas los suelos anti-vacuos y una regresión por cada uno de los dos falsos negativos encontrados al construirlo — y `43-validate-guard-negative-fixtures` lo ha OBSERVADO rechazando la fixture vacía (37/37). + +#### GT-649 + +**Título:** El corpus produce WARNs al cargarse + +- **Propósito:** Limpiar los warnings de carga del corpus. +- **Componente:** `Core API` · **Criticidad:** P2 · **Complejidad:** S - **Procedencia:** Registrado el 2026-07-31 desde el mismo `core-api.log` que produjo [`GT-648`](./gap-reference-catalog.es.md#gt-648), y mantenido aparte a propósito: esos WARN son cómo se *notó* la carga por petición, pero son defectos de corpus y de logging que sobrevivirían a cualquier cantidad de caché. El fallo (2) se rastreó con `git log --diff-filter=AD` en vez de suponerse — los ficheros existían, y el commit que los quitó dice que estaba limpiando sobras. - **Criterios de aceptación:** - [x] El loader clasifica por el `$schema` que declara un documento, y valida cada tipo no-corpus contra **ese** contrato en vez del estándar. Esto es estrictamente más verificación que antes, no una supresión: el comportamiento previo los verificaba contra el schema equivocado y descartaba el resultado. diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index 8c5abaf3f..cdef9c025 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -8299,6 +8299,13 @@ Historical gap series tracked in the former `gap-analysis-core.md`, preserved fo - [x] `npm view @beyondnet/evolith-mcp@1.1.0` reports the version as deprecated, likewise cli and agent-runtime — and also core-domain, core and infra-providers. `evolith-sdk@1.1.0` and `evolith-contracts@1.1.0` are deliberately NOT deprecated, for reasons recorded in the evidence above rather than skipped. - [x] A CI gate fails when a commit whose type or scope marks it as security is absent from the latest published version. `48-validate-security-publish-lag`, wired into `Governance guards (GT-578)`. It asks the registry rather than git tags, because the newest `v*` tag is `v1.1.0` while npm serves `1.2.2`. - [x] The gate ships with negative fixtures that turn it red — 12 of them, including the anti-vacuous floors and a regression for each of the two false negatives found while building it — and `43-validate-guard-negative-fixtures` has OBSERVED it refuse the empty fixture (37/37). + +#### GT-649 + +**Title:** The corpus produces WARNs when loaded + +- **Purpose:** Clean up corpus loading warnings. +- **Component:** `Core API` · **Criticality:** P2 · **Complexity:** S - **Provenance:** Registered 2026-07-31 from the same `core-api.log` that produced [`GT-648`](./gap-reference-catalog.md#gt-648), and kept separate from it on purpose: those WARNs are how the per-request load was *noticed*, but they are corpus and logging defects that would survive any amount of caching. Fault (2) was traced by `git log --diff-filter=AD` rather than assumed — the files existed, and the commit that removed them says it was clearing leftovers. - **Acceptance criteria:** - [x] The loader classifies by the `$schema` a document declares, and validates each non-corpus kind against **that** contract instead of the standard one. This is strictly more checking than before, not a suppression: the previous behaviour checked them against the wrong schema and discarded the result. From 094563b37b659898e4737d54d34d008e858d9bab Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 19:36:01 -0500 Subject: [PATCH 6/8] fix(harness): classify rag eval guards --- .harness/scripts/ci/rag-eval.mjs | Bin 17142 -> 17644 bytes .harness/scripts/lib/guard-classification.mjs | 1 + 2 files changed, 1 insertion(+) diff --git a/.harness/scripts/ci/rag-eval.mjs b/.harness/scripts/ci/rag-eval.mjs index 3e27777ecba2555bf0a04822d1dc468d3c33908f..c81ce1dd8dcf1761b8638d1fcb86131954c41527 100644 GIT binary patch delta 535 zcmaiwu}T9$5Qd3e?Cqz!MB)Z4V-do6s0bz|220u78!wmK?Z%zGh=!0Vg-_ut22MhP Date: Fri, 31 Jul 2026 19:44:04 -0500 Subject: [PATCH 7/8] docs(interfaces): refresh construction how-to for F1 gates --- .../core/interfaces/how-to-construction.md | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/reference/core/interfaces/how-to-construction.md b/reference/core/interfaces/how-to-construction.md index 6e81915ae..3724e5335 100644 --- a/reference/core/interfaces/how-to-construction.md +++ b/reference/core/interfaces/how-to-construction.md @@ -805,36 +805,41 @@ Response (captured live): "results": { "gate": [ { - "gateId": "general-rulesets", + "gateId": "gate-f1", + "phaseId": "discovery", "verdict": "FAIL", "artifactResults": [ { - "artifactId": "anti-corruption", + "artifactId": "PRD", "verdict": "FAIL", "present": false, "ruleRefs": [ - "ACL-02" + "EV-F1-prd" ], "gaps": [ { - "id": "general-rulesets:ACL-02", - "requirementRef": "ACL-02", - "severity": "error", - "message": "Blocking rule did not run: Transformation Traceability: [MUST] This rule is declared `blocking: true` and was NOT evaluated (unimplemented-native). No acl/ directory found — rule not applicable A blocking rule that skips is reported exactly like a blocking rule that passed, so the run would claim coverage it did not earn. Implement the handler/adapter this rule needs, or set `blocking: false` until it exists.", - "location": "anti-corruption" + "id": "gate-f1:EV-F1-prd", + "requirementRef": "EV-F1-prd", + "severity": "warning", + "message": "Missing required artifact 'PRD': PRD status = Approved AND approvalEvidence present AND date filled", + "location": "PRD" } ] }, { - "artifactId": "anti-corruption", + "artifactId": "Discovery Canvas", "verdict": "FAIL", "present": false, "ruleRefs": [ - "ACL-03" + "EV-F1-discovery-canvas" ], "gaps": [ { - + "id": "gate-f1:EV-F1-discovery-canvas", + "requirementRef": "EV-F1-discovery-canvas", + "severity": "warning", + "message": "Missing required artifact 'Discovery Canvas': Initiative registered with customer pain points and expected value", + … (truncated) ``` @@ -882,39 +887,40 @@ Response (captured live): "results": { "gate": [ { - "gateId": "general-rulesets", + "gateId": "gate-f1", + "phaseId": "discovery", "verdict": "FAIL", "artifactResults": [ { - "artifactId": "governance", + "artifactId": "PRD", "verdict": "FAIL", "present": false, "ruleRefs": [ - "GOV-000" + "EV-F1-prd" ], "gaps": [ { - "id": "general-rulesets:GOV-000", - "requirementRef": "GOV-000", - "severity": "error", - "message": "Missing evolith.yaml: Every satellite repository must have an evolith.yaml file at the root.", - "location": "governance" + "id": "gate-f1:EV-F1-prd", + "requirementRef": "EV-F1-prd", + "severity": "warning", + "message": "Missing required artifact 'PRD': PRD status = Approved AND approvalEvidence present AND date filled", + "location": "PRD" } ] }, { - "artifactId": "anti-corruption", + "artifactId": "Discovery Canvas", "verdict": "FAIL", "present": false, "ruleRefs": [ - "ACL-02" + "EV-F1-discovery-canvas" ], "gaps": [ { - "id": "general-rulesets:ACL-02", - "requirementRef": "ACL-02", - "severity": "error", - "message": "Blocking rule did not run: Transformation Traceability: [MUST] + "id": "gate-f1:EV-F1-discovery-canvas", + "requirementRef": "EV-F1-discovery-canvas", + "severity": "warning", + "message": "Mi … (truncated) ``` @@ -981,36 +987,41 @@ Response (captured live): "results": { "gate": [ { - "gateId": "general-rulesets", + "gateId": "gate-f1", + "phaseId": "discovery", "verdict": "FAIL", "artifactResults": [ { - "artifactId": "anti-corruption", + "artifactId": "PRD", "verdict": "FAIL", "present": false, "ruleRefs": [ - "ACL-02" + "EV-F1-prd" ], "gaps": [ { - "id": "general-rulesets:ACL-02", - "requirementRef": "ACL-02", - "severity": "error", - "message": "Blocking rule did not run: Transformation Traceability: [MUST] This rule is declared `blocking: true` and was NOT evaluated (unimplemented-native). No acl/ directory found — rule not applicable A blocking rule that skips is reported exactly like a blocking rule that passed, so the run would claim coverage it did not earn. Implement the handler/adapter this rule needs, or set `blocking: false` until it exists.", - "location": "anti-corruption" + "id": "gate-f1:EV-F1-prd", + "requirementRef": "EV-F1-prd", + "severity": "warning", + "message": "Missing required artifact 'PRD': PRD status = Approved AND approvalEvidence present AND date filled", + "location": "PRD" } ] }, { - "artifactId": "anti-corruption", + "artifactId": "Discovery Canvas", "verdict": "FAIL", "present": false, "ruleRefs": [ - "ACL-03" + "EV-F1-discovery-canvas" ], "gaps": [ { - + "id": "gate-f1:EV-F1-discovery-canvas", + "requirementRef": "EV-F1-discovery-canvas", + "severity": "warning", + "message": "Missing required artifact 'Discovery Canvas': Initiative registered with customer pain points and expected value", + … (truncated) ``` From 97bea47a2bc93c3ae27fa84ed748f745aa1d1645 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Fri, 31 Jul 2026 19:47:34 -0500 Subject: [PATCH 8/8] docs(sdlc): refresh port inventory annotation --- reference/core/sdlc/assets/master-view.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/core/sdlc/assets/master-view.svg b/reference/core/sdlc/assets/master-view.svg index 3eede9dcc..c4f86c078 100644 --- a/reference/core/sdlc/assets/master-view.svg +++ b/reference/core/sdlc/assets/master-view.svg @@ -1,4 +1,4 @@ - +