The project's own immune memory. Every non-trivial bug gets an entry: Symptom / Root cause / Fix / Rule. Check here before debugging anything that smells familiar. Newest last.
- Symptom: bridge dies instantly or client reports protocol errors.
- Root cause: something printed to stdout — stdout is the MCP stream.
Any dependency
console.logis enough. - Fix:
console.logrebound to stderr at bridge startup (stdio.jsfirst line ofstartStdioBridge). - Rule: never
console.login bridge code paths; human output → stderr.
- Symptom:
sparda devthrew immediately after v0.2 refactor. - Root cause: bridge read the key from its own config instead of the
manifest —
sparda.jsonis the single source of truth. - Fix: commit
b95efc1— readmanifest.localKey, explicit USER error with hint when absent. - Rule: anything the router and bridge must agree on lives in
sparda.jsonand nowhere else.
- Symptom: GitHub Actions failures on the Node 18 matrix.
- Root cause: vitest 4.x dropped Node 18.
- Fix: commit
4d449f9— pinvitest: ^3.0.0(see ADR-011). - Rule: dependency upgrades must respect the engine promise (Node ≥ 18).
- Symptom:
spawnSync pythonfails on runners; works locally. - Root cause: runners expose
python3(orpyon Windows), never a guaranteedpython. - Fix: commit
b875f20—setup-pythonin CI + dynamic candidate detection (python3→python→py -3) shared by detect.js and tests. - Rule: never hardcode the python binary name.
- Symptom:
expected undefined to be definedon thenotifications/messageassertion in the stdio bridge test. - Root cause: the mock host counts
/mcp/eventshits to decide baseline-vs-live-event. A new bridge feature (sparda_get_context) also fetches/mcp/events, consumed the mock's baseline slot, and the bridge's own baseline poll then swallowed the live event (lastSeq === null → discard). - Fix: commit
364b826— context-tool assertions moved after the polling assertions in the test. - Rule: any new bridge call that touches
/mcp/eventsshifts the mock host's poll sequence — keep polling assertions first, or make the mock route-aware.
- Symptom: quarantine/disabled/bad-param responses (
{error: ...}, noupstreamStatus) returnedisError: falseover MCP. - Root cause:
isError: payload.upstreamStatus >= 400isfalsewhenupstreamStatusisundefined. - Fix: commit
364b826—isErrorfalls back toBoolean(payload.error)whenupstreamStatusis absent. - Rule: the router has two response shapes (proxied:
upstreamStatus+data; rejected:error+ details). Handle both, always.
- Symptom:
Buffer.compare(modifiedBytes1, modifiedBytes2)= -1 onwindows-latestsince FastAPI support landed; green on ubuntu and locally. - Root cause: two stacked issues. (1) Windows runners check out files as
CRLF (
autocrlf=true, no.gitattributes). (2) The injection regex captured the indent with(\s*)—\smatches\rand\n, so the "indent" swallowed the preceding blank line (\n\r) and was re-injected into the block; the second run (file now partially normalized) captured a different indent → different bytes. Invisible on LF systems because the pollution was stable there. - Fix: indent capture
([ \t]*); injection preserves the file's own EOL (join(eol));.gitattributes(* text=auto eol=lf) makes checkouts deterministic. Regression test: CRLF inject/idempotent/restore cycle in the FastAPI section. - Rule: never use
\sto capture indentation ([ \t]only), and any byte-for-byte promise must be tested against CRLF input too.
- Symptom:
EBUSY: resource busy or locked, rmdir ...sparda-stdio-*in the stdio bridge test'sfinally, windows-latest only. - Root cause:
child.kill('SIGKILL')returns before Windows releases the child's file handles (and the bridge may be mid-write tosparda.jsonviapersistImmune); the immediatefs.rmSynchits a locked directory. - Fix: await the child's
closeevent after kill, andrmSync(..., { maxRetries: 10, retryDelay: 100 }). - Rule: on Windows, killing a process is asynchronous — always await
closebefore deleting anything the child touched.
- Symptom:
NameError: name 'true' is not definedthe moment uvicorn importssparda_router.py. The FastAPI runtime path was broken in every release to date — and all checks were green. - Root cause: the generator pasted
JSON.stringify(tools)into the Python template as a literal. JSON'strue/false/nullare not valid Python (True/False/None).ast.parseandpy_compilecould not catch it:trueis a syntactically valid identifier — it only explodes at import time. - Fix: template does
SPARDA_TOOLS = json.loads(<double-stringified JSON>)— a JSON string literal is also a valid Python string literal, andjson.loadsyields real Python values whatever the content. Caught by the newGenerated FastAPI router (runtime)test (real uvicorn), which failed on its very first run. - Rule: syntax checks prove nothing about importability or behavior — every framework MUST have a real-runtime test (live server, real HTTP). Never inject one language's literals into another language's source.
- Symptom: after
init→remove,git diffshowedM .gitignore(+.sparda/+ a blank line). All injected code came back byte-identical; only the gitignore edit survived. Found by the 2026-06-11 desktop E2E (Phase 1, reconfirmed Phase 3 on a multi-file app). - Root cause:
init'sensureGitignoreappends\n.sparda/\n(or creates the file) butremovenever reverted it —remove.jseven printed "clean (minus a .gitignore line)", normalizing the violation of hard rule #4. - Fix:
ensureGitignorenow returns what it did (created/appended/ null); the manifest records it (gitignorefield, carried across re-init likelocalKey);removereverts the exact edit (deletes the file it created, or strips the exact appended suffix; best-effort line removal if the user edited around it). Pre-fix manifests have no field → no-op, as before. Regression:Remove reverts .gitignoresuite. - Rule: every side effect of
initmust be recorded in the manifest and undone byremove— "almost clean" is a broken promise.
- Symptom:
const PORT = Number(process.env.PORT ?? 4477)(and the||variant, CJS included) detected as port 3000 — the bridge then probes the wrong port out of the box. Found by the desktop E2E (Phase 1, widened framework-wide in Phase 2). - Root cause:
detectExpressPortmatchedPORT = <digits>and.listen(<digits>)but theNumber(...)wrapper broke the pattern, and no rule read the right-hand literal ofprocess.env.X ?? / || <literal>. - Fix: new pattern
process\.env\.\w*PORT\w*\s*(?:\?\?|\|\|)\s*(\d{2,5})tried after the.envlookup and before the genericPORT =rule. Regression:Port detectionsuite. - Rule: port heuristics must be tested against the wrapped/env-fallback forms users actually write, not just bare literals.
- Symptom: healthy and broken apps both exited
0— scripts/CI could not gate onsparda doctor. Found by the desktop E2E (Phase 3). - Root cause:
runDoctorprinted✗lines but never signalled failure;index.jsonly exits non-zero on a thrown error and doctor catches its own failures. - Fix:
runDoctorreturns{ healthy }(false on any critical✗: old Node, no framework, unreachable host, quarantined route, invalid manifest); the CLI setsprocess.exitCode = 1on it. Informational·lines never fail. Regression:Doctor health reportsuite. - Rule: a diagnostic command IS an API — its exit code is the contract, the text is garnish.
- Symptom:
Sentinel synctimed out at vitest's 5000ms default (measured 5242ms) on the owner's desktop clone; green everywhere else. - Root cause: someone had run
npm installinsidetests/fixtures/express-demo/on that machine; the test'sfs.cpSync(..., { recursive: true })then copied a 67-entrynode_moduleson every run. The repo itself ships no fixturenode_modules— this was local pollution, slow Windows I/O finished it. - Fix: explicit 30s timeout on that test (copy cost is environmental),
and: never
npm installinsidetests/fixtures/*— the fixtures must stay dependency-free (the suite resolvesexpressfrom the repo root). - Rule: any test that copies a fixture tree inherits whatever garbage lives in it; keep fixtures pristine and timeouts explicit on I/O-bound tests.
- Symptom: an external black-box test (
sparda-mcp@0.3.0from npm) saw a tool'serrorscounter climb on a plain404 not found— alarming a reader into thinking the route was failing when the AI had merely asked for a missing resource. Cosmetic: the immune system was never affected (quarantine readsconsecutive5xx, already 5xx-gated), so no functional impact. - Root cause:
spardaRecorddidif (status >= 400) errors += 1in both router templates — every 4xx (a valid client answer) inflated the same counter as real 5xx server failures. - Fix:
errorsnow counts 5xx only (true server failure, the number a dev watches for breakage); a newclientErrorscounter holds 4xx separately. Applied identically toexpress-router.txtandfastapi-router.txt(hard rule #6). Regression in the Express runtime test: a 404 invoke incrementsclientErrors, leaveserrorsat 0. Stats are runtime-only (not persisted insparda.json) → no carry-over concern. - Rule: a 4xx is a successful conversation with an unhappy answer, not a failure — never fold client errors and server errors into one number a human reads to judge health.
- Note: the same report flagged a
paramsvsargsmismatch on the raw HTTP/mcp/invokeendpoint. Not reproduced through MCP: the bridge maps JSON-RPCparams.arguments→args(stdio.js), so real clients are unaffected; the endpoint is internal and auth+localhost-gated. Left as-is (optional P3: acceptparamsas an alias + clearer error on the HTTP layer).
- Symptom: Five parser bugs identified during stress testing:
/mcp-analyticsor/mcp-statusblocked (Bug #1).async defroutes ignored in FastAPI (Bug #2).- Modular package imports failed to resolve router symbols (Bug #3).
- Pydantic cross-file models schemas missing/not inferred (Bug #4).
Depends()exposed in query parameters (Bug #5).
- Root cause:
- Prefix check was checking
.startswith('/mcp')instead of exact match or prefixing/mcp/. - AST check only looked at
ast.FunctionDefand skippedast.AsyncFunctionDef. ImportFromhandling matched package directory__init__.pyinstead of checking the specific symbol module (e.g.routers/users.py) first. Additionally,include_routercheck only matchedast.Nameand skipped attribute arguments (e.g.users.router).- Imported Pydantic models were never parsed because non-router files were never read.
- Argument signature check did not inspect default values to filter out
Dependscalls.
- Prefix check was checking
- Fix:
- Tightened
/mcpchecks in both JS and Python routers. - Matched both
ast.FunctionDefandast.AsyncFunctionDeffor routes. - Handled relative imports with correct level dots, resolved imported symbols as files first, and supported
ast.Attributerouter variables ininclude_router. - Added
preload_models()pre-pass walking the AST of all imported files before routes parsing. - Checked default values of function arguments and skipped them if they are
Depends()calls.
- Tightened
- Rule: FastAPI extraction must support async def routes, Depends injections, cross-file Pydantic bodies, and nested routers to parse production APIs.
- Symptom: CI red on
ubuntu-latest/ Node 22 (and every matrix cell, hidden by fail-fast) after the 5b push: 5 failures intests/sparda.test.js— three FastAPI byte-for-byte tests (expected 1 to be +0, i.e.py_compileexited non-zero) and two FastAPI runtime tests timing out at 60s (uvicorn never came up). The compiler error:SyntaxError: f-string expression part cannot include a backslash. Green locally, red in CI — the trap below. - Root cause: the
require_humanbranch oftemplates/fastapi-router.txtbuilt the confirminstructionwith a conditional inside the f-string expression part that contained escaped quotes:f"...{' First call \"' + sibling_name + '\" ...' if sibling_name else ' To'}...". Python < 3.12 forbids both a backslash and the delimiting quote inside an f-string{expression}(PEP 701 lifted this in 3.12). The CI matrix pins Python 3.10, so it caught it; a dev on Python ≥ 3.12 (where the old syntax compiles fine) sees 125/125 green and never notices — this class of bug is structurally invisible to local runs on modern Python. - Fix: pre-compute the segment in a local before the f-string, then
interpolate the plain variable:
sparda_hint = f' First call "{sibling_name}" ...' if sibling_name else ' To'→...touched.{sparda_hint} confirm.... No backslash, no quote reuse in any expression part. (The\"confirm\"later in the same string is in the literal part, which is always legal.) 125/125 stays green on 3.12; CI Python 3.10 turns green. - Rule: generated Python targets the minimum supported runtime (3.9), not
the dev's local one. Never put a backslash or the delimiting quote inside an
f-string
{expression}— build the value in a variable first. The Python 3.10 CI cell is the oracle for this; keep it in the matrix.
- Symptom: on a rare unclean revert (the injection-stripped entry file no
longer parses →
removeInjectionreturns{ok:false}), remove printed "restore from .sparda/backup/" and then deleted.sparda/anyway — erasing the backup in the same run. - Root cause:
commands/remove.jsran the destructive cleanup (fs.rmSync('.sparda', …)) unconditionally, after the per-file results were only logged, never gated on. - Fix: if any file failed to revert, STOP before any deletion — preserve
sparda.json, generated files and.sparda/backup/, setexitCode=1, tell the operator what to restore. Nothing is removed until the tree is known-clean. - Rule: never destroy a recovery artifact on the same path that recommends it. Gate destructive cleanup on the success of every reversible step (rule #4).
- Symptom:
init → removeproduced a non-cleangit diff(one extra blank line) when the marked block sat at the very top of the entry file (insertAt === 0). - Root cause: injection inserts the block as whole lines before an existing line, adding the block + a trailing newline (the leading newline was already the file's). Removal consumed the leading newline instead — byte-perfect for a mid-file block, off-by-one-newline for a top-anchored one. Express and FastAPI each carried their own copy of this regex, free to drift.
- Fix: one shared contract,
src/generator/injection.js—stripForRemovalconsumes the block + its trailing newline (the exact byte inverse of a line splice);stripForReinitkeeps a single separator. Both generators import it; the duplicated markers/regex/escapeRxare gone. Verified byte-identical for mid-file, top-of-file, and CRLF. - Rule: an operation and its inverse must share one definition, or they drift (rule #4). If you splice lines, invert on lines — not on a hand-tuned regex.
- Symptom: the
cfm_single-use token that gates live-app writes was predictable —Math.random()(V8 xorshift128+) is reconstructible from a few outputs. FastAPI already useduuid.uuid4(), so the JS routers were the weak ones (broken parity). - Fix:
spardaNonce()→'cfm_' + globalThis.crypto.randomUUID()in the Express and Next.js templates (Web Crypto is a Node ≥18 / Next global; no new dep, no new placeholder).errorId(log correlation, non-security) left as-is. - Rule: anything that GATES a state change is security-sensitive — mint it
with a CSPRNG, never
Math.random(). Keep the three router templates at parity.
- Symptom: the UBG canonical serialization promised "byte-identical, machine
after machine" (schema.js) but could differ across hosts.
canonicalizeGraphsorted NODES by code unit yet EDGES byString.prototype.localeCompare— whose collation depends on the host ICU/locale. Under a locale collationorder_itemssorts beforeOrders; under code units the reverse. Same graph → different bytes on a differently-localed machine. - Blast radius: not just edge order.
localeComparealso drove graph content decisions — SQL table dedup tie-break (which duplicate definition "wins"), the translator's first-wins helper pick, the state-minimization merge-pair pick — plus stored meta arrays (state-machine transitions, SQL/Prisma invariants). All locale-dependent, so two machines could compile the same code to different graphs, underminingapocalypsebaseline diffs and theverifydeterminism claim across machines (same-machine runs stayed stable, so CI never caught it). - Fix: one exported deterministic comparator
cmp(a,b)(UTF-16 code units) inschema.js, used incanonicalizeGraph(nodes + edges) and every graph-affecting sort:sql.js(table dedup + invariants),prisma.js(invariants),translate.js(helper pick),state-machines.js(transitions),state-minimization.js(merge pick). Verified:Orders/order_itemsedge order now identical underLC_ALL=CandLC_ALL=en_US.UTF-8; 399 tests green. - Rule: determinism that must hold ACROSS machines uses code-unit ordering, never
localeCompare. Report/human-facing sorts may keep locale order; anything that reaches the canonical bytes (or a content decision behind them) must usecmp.
- Symptom: after E-019 switched the confirm-nonce to
globalThis.crypto.randomUUID(), the Node 18 CI cell failed —TypeError: Cannot read properties of undefined (reading 'randomUUID')in the generated Express router (sparda.test.js) and the standalone Next.js route (nextjs.test.js). Node 22 was green, so local runs never caught it. - Root cause:
globalThis.crypto(Web Crypto) only became a default global in Node 19; on Node 18 (in the engines range>=18and the CI matrix) it is undefined. My E-019 comment claimed it was a "Node >=18 global" — wrong. - Fix: split by runtime.
- Express router runs in the host's arbitrary Node ≥18 process, so it must not
depend on a global: added a
__CRYPTO_IMPORT__placeholder renderingimport spardaCrypto from 'node:crypto'/require('node:crypto')(whoserandomUUIDexists since Node 14.17), wired ingenerator/express.jsandtests/router-selftest.cjs.spardaNonce→spardaCrypto.randomUUID(). - Next.js route is web-standard and always runs in a Next runtime that provides
globalThis.crypto(both Node and Edge), so it keepsglobalThis.crypto.randomUUID(); the standalone unit test polyfillsglobalThis.crypto = webcryptoon Node < 19, emulating the runtime it bypasses.
- Express router runs in the host's arbitrary Node ≥18 process, so it must not
depend on a global: added a
- Rule: the engines floor is Node 18 — never use a Node-19+ global unguarded. Web
Crypto as a bare global is 19+;
node:crypto.randomUUIDis the 18-safe CSPRNG. A green local run on Node 22 is not proof; the CI matrix's lowest cell is the oracle.
- Symptom:
tests/mirror-stateful.test.jspassed on Node 22 but everyfetchagainst the mirror timed out (5000ms) on the Node 18 CI cell. curl was always fine. - Root cause: the Mirror VM served HTTP/1.1 keep-alive. Each test spins up an ephemeral server (port 0), and the OS recycles port numbers across tests. Node 18's undici caches a keep-alive socket by origin (host:port) and, on the next test that lands on the same recycled port, reuses that now-dead socket — and hangs. (rawRequest hung the same way under rapid sequential reuse; curl opens fresh, so it never saw it.)
- Fix: the mirror now sends
Connection: closeon every response. A mock has no need for keep-alive, and closing per response means no client — undici, the raw-socket helper, anything — can cache or reuse a socket to a since-recycled port.req.resume()is kept to drain any request body before close. - Rule: an ephemeral test server that a pooling client (undici/fetch) hits across
many short-lived instances must not invite socket reuse — send
Connection: close(or disable keep-alive). A green Node 22 run is not proof; undici's pooling differs by Node/undici version, and the CI matrix's lowest cell is the oracle.
- Symptom: two real public Express repos compiled to 0 nodes — SPARDA emitted no routes. Worse, apocalypse then printed "PROVEN over 0 nodes" and exited 0: a parser-coverage miss silently read as a green proof.
- Root cause (two classes):
- C-001a — inline-require router mounts.
app.use('/users', require('./users/users.controller'))(rootpath-style apps):handleUseonly matched an Identifier router arg, so an inlinerequire()mount was dropped and the controller's routes never scanned. - C-001b — TypeScript DI route loaders.
export default (app) => {…}/routes(app): the router is a function parameter, never anexpress()binding, so there is no literalapp.METHOD(...)call site to anchor on.
- C-001a — inline-require router mounts.
- Fix:
- The risk class is closed for good (the real "never again"):
verdictOfis now provability-aware — a graph with zero entrypoints isprovable: false, which forcessafe/cleanfalse. apocalypse and review print✗ NO PROOFand exit 1 on a blind compile. A coverage miss can no longer masquerade as a proof, on any repo, ever. (src/ubg/apocalypse.jsverdictOf; wired insrc/commands/apocalypse.js+review.js.) - C-001a fixed:
handleUsenow resolves an inline-require()mount viamountTargetFile(src/ubg/express.js). Unlockedcornflourblue(0 nodes → 7 routes, correct PROVEN). Regression fixture:tests/fixtures/ubg-inline-mount/. - C-001b still backlog — but now safe: it yields NO PROOF (exit 1), not a
false PROVEN. Reproduces on
tests/fixtures/ubg-blind/. Widening the parser to follow DI loaders (treat a route-module's first param as a router) is the next coverage item, no longer a correctness risk.
- The risk class is closed for good (the real "never again"):
- Rule: "PROVEN over 0 nodes" is vacuous — a zero-entrypoint compile is a coverage miss, never a pass. Enforce it at the verdict, not per-command: any verdict emitter that can't see a route surface must say NO PROOF and fail CI.
- Symptom:
sparda immunizein a directory that had never been compiled threwENOENT: … open '.sparda/immunity.json.sparda-tmp'and exited 2. It only "worked" in dev when another command (ubg,apocalypse) had already created.sparda/. Caught by a smoke test Gemini added (command-smoke— the test was right, the code was wrong). - Root cause:
runImmunizecalledatomicWrite(outPath, …)without first creating the.sparda/directory.atomicWriteFileSyncwrites a*.sparda-tmpsibling then renames — both fail if the parent dir doesn't exist.apocalypse/serializebothmkdirSync(recursive)first;immunize(new in 0.15.0) forgot to. - Fix:
fs.mkdirSync(path.dirname(outPath), { recursive: true })before the write insrc/commands/immunize.js. - Rule: any command that writes into
.sparda/mustmkdirSync(recursive)the parent first — never assume a prior command created it. A file-writing command must be runnable standalone on a virgin checkout.
- Symptom: E-020 fixed the graph's determinism (in
canonicalizeGraph), but the DERIVED emitters still sorted withString.prototype.localeCompare: apocalypse findings + the per-entrypoint iteration order (→polarity,immunize,reviewoutputs), the emitted OpenAPI spec, the mirror node dump, and theubgreport. For mixed-case / punctuation-leading routes the collation diverges from code units, so a machine in a different ICU/locale emits a different byte stream. Proven:/Users,/_debug,/admin,/userssort in a completely different order undercmpvslocaleCompare('en-US'). (It slipped past earlier because the test fixtures have only lowercase routes, where the two orders happen to agree.) - Root cause: the determinism contract (
cmp, code units) was enforced only atcanonicalizeGraph, not in the artifact emitters downstream of it. - Fix: replaced every output-reaching
localeComparewith the exportedcmpinsrc/ubg/apocalypse.js(entrypoints, findings sort, aggregate-domain sort),src/ubg/openapi-emit.js,src/ubg/mirror.js,src/commands/ubg.js. Regression:tests/determinism.test.jsbuilds a graph whose routes make the two orders diverge and asserts the output followscmp, neverlocaleCompare. - Rule:
cmp(code units), neverlocaleCompare, for ANY ordering that reaches a serialized or printed artifact — not just the canonical graph.localeCompareis specified to be locale-dependent; a green run in one locale is not proof. - Follow-up (logged, not a bug today): several graph-BUILDING sorts (in
ubg/express,nextjs,sql,prisma,link,reach, and thepasses/*) still uselocaleCompare. They feedcanonicalizeGraph, which re-sorts bycmp, so they don't change the finalubg.jsonbytes today — but convert them for defense-in-depth if any ever starts assigning order-dependent ids/ordinals.
- Symptom: NestJS / Medusa / Inversify apps compiled to 0 nodes (NO PROOF).
Routes are
@Get()decorators, notapp.get(); the real write lives in a DI'd service; and Nest parameter decorators (@Body()) even broke the parse. - Fix (ADR-039):
src/ubg/nestjs.js— decorator route table +@UseGuards+ static DI resolution via constructor parameter types (followthis.svc.m()to the service method). Plusextract.jsreadsthis.<field>effects, and the parser usesdecorators-legacy. A Nest app now yields real findings (proof:tests/nestjs.test.js). - Remaining (tracked, not a bug): string-token runtime DI (
resolve('userService')) and file-based routing conventions are the next ingestion rungs; non-JS via--openapi. - Rule: ingestion is a LADDER, not one detector. When a framework hides its routes behind decorators/DI/conventions, add a rung that reads the static signal that IS there (here: constructor types) — never just throw "not supported".
- Symptom: a real
medusajs/medusacheckout still compiled to 0 routes even after the Nest extractor — Medusa has no@Controllerclasses. Routes are a filesystem convention (src/api/<path>/route.ts, verb = exported const name), and the DB write lives in a workflow call, not an ORM call. NestJS's decorator scan found nothing → NO PROOF on the biggest JS commerce app. This is the wall an automated test re-hit. - Fix (ADR-040):
src/ubg/medusa.js— walksrc/api/**/route.{ts,js}; path from the directory ([id]→:id); exportedGET/POST/…= methods; inverted auth convention (export const AUTHENTICATE = falseis the only opt-out, else guarded); and a workflow-verb effect heuristic (create*Workflow→db_write insert,list*→read) sincescanFunctionsees no ORM op in the body. Detected from@medusajs/*+src/api. - Proof: real Medusa (319 route files) went 0 → 476 routes in ~0.5s, 0 skipped —
435 dbwrites, 121 state tables, 474 guards, verdict _provable & clean (honest: Medusa
guards nearly every mutation). Fixture:
tests/medusa.test.js(6). One critical caught on theAUTHENTICATE=falsepublic cart mutation — the inversion works. - Remaining rung: Medusa declares data models in its own DML (not
.sql/.prisma), so O2 (field validation) has no constraint set on Medusa yet. Next rung: DML parsing.
- Symptom: the multi-repo organ stress test found SPARDA printing ✓ PROVEN on immich (281 NestJS routes, 1 effect), GitHub's OpenAPI (1196 routes, 0 effects), and a stock Express boilerplate (8 routes, 0 effects). "No obligations to fault" was reported as a clean bill of health, when the truth was "SPARDA saw the route surface but not what the code does" (a spec has no bodies; DI/external-controller effects weren't followed).
- Fix (ADR-042): the behavior guard —
countObserved(graph)(state + db/http/fs effects; entropy excluded) inapocalypse.js. Routes butobserved===0→ SURFACE ONLY, a distinct third verdict:clean(PROVEN) requires!surfaceOnly, butsafe(the CI gate) does not (unprovable ≠ unsafe → still exit 0). Shared by verdict,buildCapsule,immunize, anddossierso no two artifacts disagree. - Proof: immich + GitHub-OpenAPI flipped to SURFACE ONLY; dub/Medusa unchanged. New
tests/fixtures/ubg-provenis the suite's first genuine PROVEN — the old "clean app" test had been asserting a hollow proven on an effect-less echo app the whole time. - Rule: a behavior compiler that resolved no behavior must not print the same green as one that proved everything. Absence of findings is only a proof when there was something to fault.
- Symptom: the stress test found immich (281 NestJS routes) resolving 1 effect → a hollow PROVEN. Routes were read but the behavior behind them was invisible.
- Root cause (four stacked): (1) immich imports via tsconfig
baseUrl(src/services/x), whichresolveRelImportdidn't handle; (2) the DB write is 2 DI hops down (controller → service → repository), the resolver did 1; (3) the repository is injected in aBaseServicethe serviceextends(inherited DI — the type is imported in the base module); (4) the DB layer is Kysely (db.insertInto) and guards are@Authenticated()not@UseGuards(). - Fix (ADR-043): tsconfig
baseUrl/pathsresolution; recursive bounded DI (followDI); DI map built up theextendschain with each entry tagged by its declaring module (diMapWithMod); Kysely ops in the scanner; guard-by-decorator-name. - Proof: immich → 310 effects, 45 tables, 253 guards, NOT PROVEN with 2 genuine OAuth
findings. Fixture
ubg-nestjs-deep+nestjs-deep.test.js. dub/Medusa/OpenAPI unchanged. - Rule: once you resolve effects deeper, you MUST resolve guards as deep, or precision collapses into false-positive noise (125 → 2 here). Effect depth and guard depth ship together.
- Symptom: a standard Express app (external controllers + services) resolved 0 effects → SURFACE ONLY. Routes read, behavior invisible.
- Root cause (three stacked): (1) the extractor resolved the
controller.methodhandler but not theservice.method()calls inside it; (2) services are imported through a barrel (const { x } = require('./services'), index.js re-exports each); (3) the leaf is Mongoose (Model.create()), unrecognised. - Fix (ADR-044): recursive module-member deep scan (
deepScan/followMembers, express.js); barrel re-export resolution (parseModulerecordsmodule.exports.x = require, destructured imports resolve through it); Mongoose ops in the scanner (Capitalized receiver + known op). - Proof: boilerplate → 9 effects, 2 tables, NOT PROVEN with 3 genuine findings. Fixture
ubg-express-deep+express-deep.test.js. immich/dub/Medusa unchanged. - Rule: the CommonJS
obj.method()chain is the exact analogue of Nest'sthis.dep.method()DI chain — resolve it the same way (recursive, bounded), or the flagship framework stays blind.
- Symptom:
findExpressEntrythrew "Could not locate your Express entry" on apps whose entry isn't namedapp/server/index/main.{ts,js}— e.g. parse-server (src/ParseServer.ts). A real, supported app was rejected before any analysis ran. - Root cause: detection only probed a fixed candidate-filename list; anything else missed.
- Fix (ADR-045): a bounded source-tree fallback (
searchExpressEntry) — scan for a bareexpress()app-factory call, rank a.listen()ing server first, exclude node_modules/tests/ examples, cap at 400 files. Mirrors the existing FastAPIsearchPyFilesfallback. - Proof: parse-server detects as
express @ src/ParseServer.ts(then honest NO PROOF — a library). Fixtureubg-express-weird-entry(entrybootstrap.ts) → detected + 2 routes. - Rule: detection must never hard-fail on a naming convention — probe the fast named
paths, then fall back to the semantic signal (the
express()/FastAPI()call itself).
- Symptom: while hardening guard semantics (ADR-046), treating a bare
throw/next(err)as a deny signal made express-boilerplate flip NOT PROVEN→PROVEN and dub drop 156→152 findings — real unguarded mutations got HIDDEN. - Root cause:
isGuardLike(name, scan)credits anyscan.guardSignals.deniesWithStatusas a guard. A service throwingApiError(400)on bad input then classified as a "guard" on the mutation path → the route read as guarded. - Fix: deny recognition stays auth-specific —
res.status(401|403)/sendStatusonly, never a generic throw. The no-op-guard downgrade (structural) andverifiedprovenance are the safe parts kept. - Rule: a "deny" that feeds guard classification must be auth-specific (a 401/403), not any error path — or validation logic becomes counterfeit auth and masks the very bugs we hunt.
- Symptom: directus (and most real Express apps) compiled to 0 routes / NO PROOF — the
whole app is built inside
export default function createApp() { const app = express(); … app.use('/x', xRouter); return app; }. - Root cause: the extractor walked only
mod.ast.program.body(module top level), so theexpress()var and every mount — one level down inside the function — were invisible. - Fix (ADR-047):
flattenSetupdescends into setup-function bodies + their control-flow blocks (if/for/try/while/block), never into function arguments (handlers stay opaque), and feeds the flattened stream to collectAppVars/collectRouteArrays/the route walk. - Proof: directus 0 → 239 real routes; node-express-boilerplate 8 → 9 (recovered an
if-gated
/v1/docs). Fixtureubg-express-factory+express-factory.test.js. 532 green. - Rule: production apps wrap setup in a function — a top-level-only walk misses the whole app. Descend into setup bodies and control flow; stop at function arguments (handlers).
- Symptom: after ADR-047 recovered its 239 routes, directus still compiled to 0 effects —
surfaceOnly, no real verdict. Every handler builds its service withnew ItemsService(…)and the DB calls live on the base class it extends. - Root cause: the Express deep scanner followed module-member calls and Nest DI, but not
new X()instances; additionally its handlers are inlineasyncHandler(async…)wrappers (blind nodes), and the base-class effects sit behindthis.<m>()/super.<m>()hops andthis.knex('t')builder calls — four independent blinders stacking on the same app. - Fix (ADR-048): unwrap inline wrapped handlers; map
const svc = new X(…); resolvesvc.method()up theextendschain withthisre-dispatch from the instantiated class andsuperfrom the declaring base; readthis.knex('t')as a table op. Memoized per (class, method) — no perf cliff (E-027's lesson applied from the start). - Proof: directus SURFACE ONLY → real verdict with observed effects; corpus unchanged
everywhere else; fixture
ubg-express-instance+ 4 tests. 536 green. - Rule: blindness stacks. When an app reads as SURFACE ONLY, hunt for ALL the idioms in its handler → effect path — fixing one blinder and re-testing per-blinder is how you find the next one, and they usually ship together or not at all.
- Symptom: twenty/formbricks/open-webui/directus all read PROVEN while SPARDA had resolved a small fraction of their behavior (GraphQL, un-followed services, Python depth, dynamic query builders). A green verdict looked identical whether SPARDA saw everything or almost nothing.
- Root cause: the verdict reported what was proven but never quantified what was UNSEEN.
surfaceOnlywas all-or-nothing (0 effects); a partially-blind app fell through as clean. - Fix (ADR-049): the blindspot ledger — opaque-target / blind-mutation / unverified-guard /
skipped-surface, ranked by what each could hide, plus a coverage ratio. Reported under every
verdict (apocalypse), in the dossier, and as
sparda blindspots(exit 1 on high+). Verdicts unchanged — it only makes the blindness visible. - Proof: twenty PROVEN → "coverage 8%, 406 blind"; directus PROVEN → "coverage 13%, 15 high";
dub NOT PROVEN → "99%". Fixture
ubg-blindspots+ 7 tests. 543 green. - Rule: a prover must report the boundary of its own sight. "I proved X" is only honest next to "and here is what I could not see." Measure the unknown; never let green imply omniscient.
- Symptom: after ADR-050, directus still read PROVEN at 13% coverage — the main
/itemsCRUD produced ZERO db effects. - Root cause (two stacked): (1) the table is chosen at the route as a constructor arg
(
new ItemsService(req.collection)), stored onthis.collection, and used deep in inherited methods — a cross-class hop the within-handler resolver couldn't follow; (2) directus puts the business logic in a MIDDLEWARE slot with arespondformatter last, and the translator only attached effects from the TERMINAL chain step, so the real handler's effects were dropped. - Fix (ADR-051): a symbolic
this-environment bound at thenew X()site and threaded through the class-method bundle; both knex builder orders (.knex(t)and.select().from(t)); effects attached from every chain step with a body; collision-aware effect ids so two bindings of one method line coexist. - Proof: directus coverage 13% → 95%, db effects 11 → 344,
:collectionresolving; corpus verdicts/findings byte-identical. Fixtureubg-crossclass-table+ 3 tests. 546 green. - Rule: in real apps the effect is rarely in the last slot of the last function. Follow the value across the class boundary AND scan every chain step — the business logic hides in the middle as often as at the end.
- Symptom:
compileUBGon today's HEAD of immich (server/) and twenty (packages/twenty-server) hard-fails with "Could not locate your Express entry file" — apps that compiled fine in the v0.32.0 baseline runs. - Root cause: NOT a regression (verified via
git stashold-code re-probe — the playbook's oracle; identical failure on both). Upstream drift: both apps now listexpressas a DIRECT dependency, anddetectStackchecksdeps.expressBEFORE@nestjs/*, so the Express branch wins and then hard-fails hunting anexpress()entry that doesn't exist. - Fix: none yet (recorded, out of ADR-054 phase-1 scope — it changes detection
behavior). Candidate: on
findExpressEntryfailure, fall through to the Nest/Medusa checks instead of throwing (mirrors the "unprovable ≠ crash" rule). Workaround for corpus work: force the lowering (probe callsextractNestdirectly) or pin corpus clones by SHA. - Rule: corpus baselines are only comparable at pinned SHAs; before blaming a diff for a corpus change, re-probe the OLD code first (this is the second and third time that rule paid for itself).
- Fix:
detectStackwraps the Express branch: ifdeps.expressis present but noexpress()entry resolves AND the app carries a Nest/Medusa marker, detection falls through to those checks instead of throwing. An app with an express dep and no other marker keeps the original E-028 error. immich full-pipeline reads 281r / NOT PROVEN F=2 and twenty 145r / PROVEN — both at their baseline verdicts. Fixtureubg-nestjs-express-dep+ 2 tests innestjs.test.js.
- Symptom:
compileUBGon open-webui threw "FastAPI UBG extraction failed:" followed by the START of perfectly valid JSON output. - Root cause: deep-scanned route facts (456 routes, every chain step carrying
a merged scan) exceed
spawnSync's default 1 MiBmaxBuffer; Node kills the child mid-write,status != 0, and the wrapper surfaces truncated stdout as the "error" — a resource limit masquerading as a parse failure. - Fix:
extractFastAPIpassesmaxBuffer: 64 MiB(0.34.0). - Rule: when a child process "fails" while printing valid output, check the buffer/timeout limits BEFORE debugging the child. Any subprocess whose output scales with project size needs an explicit maxBuffer.
- Symptom:
sparda apocalypseon Ghost (TryGhost, ~1381 source files) threw "Could not locate your Express entry file" — a genuine Express app, unanalyzable. - Root cause:
searchExpressEntry's tree scan capped at 400 files on an unprioritized walk; Ghost'score/shared/express.jssits past that cap, so it was never read. A bareexpress()app that SPARDA simply never reached. - Fix: entry-named files (
express/app/server/index/main/bootstrap/application/boot) get their OWN scan budget (600), separate from the general 400 — they are rare, so scanning them tree-wide is cheap and finds the entry at any depth. Ghost now detectscore/shared/express.js→ honest NO_PROOF (its custom routing layer is unseen, the correct verdict), not a crash. - Rule: a bounded scan on a giant must be PRIORITIZED, not just capped — cap by category (entry-named vs bulk), never let the bulk starve the signal.
- Symptom: Vendure (312 routes, GraphQL-first) read PROVEN at 0% coverage — 0 writes, 26 reads (its TypeORM-via-custom-connection writes weren't resolved).
- Root cause:
surfaceOnlywas gated onobserved === 0, andobservedcountsdb_read. An app with reads but no writes has observed > 0 → not surface → clean → PROVEN. But every obligation SPARDA discharges (guard, atomicity, reversibility, unbounded-target) is about a MUTATION; reads discharge none. A reads-only PROVEN is vacuous. - Fix:
countProvable(db_write/http_call/fs_write only, read-only state excluded);surfaceOnlyis now gated on it. Reads-only ⇒ SURFACE. Every app with a real write is unaffected (corpus + fixtures byte-identical). - Rule: a positive proof must be ABOUT something. If SPARDA resolved zero state-changing behavior, the honest verdict is SURFACE, never PROVEN — the effect-level twin of the provability guard (ADR-034).
- Symptom:
compileUBGon Ghostfolio'sapps/api(Nx) and Langflow'ssrc/backend/base/langflowthrew "No supported framework found" — both are analyzable apps (34 @Controller files; a FastAPI backend). - Root cause: detection reads only the pointed dir's package.json /
requirements. In an Nx monorepo the app dir has a
project.json, not a package.json (deps at root); in the Python monorepo the pyproject with fastapi sits one directory up. Detection was too LOCAL. - Fix: two structural last resorts before the final throw (only reached when
detection would otherwise fail, so zero effect on apps that detect normally):
(1)
decoratorFrameworkDir(cwd)— a decorator app detected by its @Get/@Post-on- class structure alone (no deps needed); (2)fastAPIUpTree(cwd)— a bounded 4-level up-walk for a requirements/pyproject declaring fastapi. Ghostfolio → NOT PROVEN 116 routes / 75%; Langflow → honest NO_PROOF (detected, routing unseen). - Rule: detection must not assume config is co-located with source. When the local manifest is silent, fall back to STRUCTURE (the source itself) and to a bounded up-tree search — never crash on a real app because of monorepo layout.
- Symptom: dub (Next,
apps/web) read 152 UNGUARDED_MUTATION — 147 of them false. Its routes authenticate through HOC wrappers imported by alias (import { withWorkspace } from "@/lib/auth"), yetmod.importscame back EMPTY for every route: not one@/…import resolved. Not just guards — every cross-module hop through an alias was dead. - Root cause:
readTsconfigstripped JSONC comments with a regex,.replace(/\/\*[\s\S]*?\*\//g, ''). A tsconfigpathsvalue is a glob:"@/pages/*": ["pages/*"]contains/*, and a later["**/*.ts"]contains*/. The block-comment regex matched from the first/*inside a string to the next*/inside another string, deleting the entire span between them — the wholepathsblock.JSON.parsethen threw, and thecatchreturned{ baseDir, paths: {} }(empty), so every alias resolved to null. Silent: a broken config looked exactly like "no aliases." - Fix: replace the regex with
stripJsonc, a string-aware scan that only treats//and/* */as comments outside string literals (plus trailing-comma removal). A regex fundamentally cannot do this — JSONC needs a tokenizer that tracks string state. dub: 152 → 5 UNGUARDED once aliases resolved and the HOC wrappers below became reachable; cal.com verified guards rose too. - Rule: never strip comments from JSONC (or any string-bearing grammar) with a regex. Values can contain the comment delimiters. Scan with string-awareness, and a config that fails to parse must be loud enough to notice, not silently empty.
- Symptom: novu (NestJS CQRS) read 636 db_write effects, 612 of them phantom —
tables like
getworkflowruncommand,builddeliverytrendchartcommand. Its UNGUARDED_MUTATION count and whole verdict were dominated by writes that don't exist. - Root cause: the active-record rule matches a Capitalized receiver with a write op —
User.create(...),Post.save(...). In CQRS/DDD code the SAME shape is a command/query FACTORY:GetWorkflowRunCommand.create({...})constructs a command object and touches no database. The capitalization heuristic can't tell a model from a command class. - Fix: a
NON_MODEL_RECEIVERgate — a capitalized receiver ending in a DI/CQRS infra suffix (Command,Query,UseCase,Handler,Dto,Service,Repository,Controller,Resolver,Gateway, …) is not a model, so its.create()/.save()is not a write. novu: 636 → 24 db_writes, UNGUARDED 21 → 2; dub/twenty/immich/cal.com unchanged; no app flips to a cleaner verdict (no false negative introduced). - Rule (SOUNDNESS Direction 1): removing a db_write is the DANGEROUS direction — a
wrongly-dropped write hides a real mutation. So the exclusion list contains ONLY suffixes
that can never name an ORM model. Ambiguous nouns that CAN be models (
Event,Entity,Schema,Payload) are deliberately KEPT as writes — over-flagging is the safe error, blindness is the unforgivable one.
- novu's 2 residual UNGUARDED findings are
mutates sha256:createHash('sha256').update(x)/createHmac('sha256', k)—builderTableOf'sisBaseCalltreats ANYfunc('str')as a knex table constructor, so the algorithm string becomes a "table". It is the SAFE kind of wrong (over-approximation / noise). The obvious fix — restrictisBaseCallto DB-named receivers — risks the UNSAFE direction (hiding a realmyKnex('t')write behind an aliased connection), so it is NOT rushed. Deferred until a soundness-preserving gate is designed (e.g. a crypto-receiver denylist, symmetric to E-040's NON_MODEL_RECEIVER).
- Symptom: while measuring the BOLA surface, dub's
findUniqueOrThrow({ where: { id, projectId: workspace.id } })— the ownership-scoping fetch that precedes a delete — was invisible, so a properly-scoped route (DELETE /api/webhooks/:webhookId) read as an unscoped BOLA candidate. dub gained +104 db_reads once fixed. - Root cause:
PRISMA_OPSlistedfindUnique/findFirst/createbut not their common variantsfindUniqueOrThrow,findFirstOrThrow,createManyAndReturn,groupBy. The...OrThrowreads are exactly where apps put the authorization fetch; missingcreateManyAndReturnis worse — a WRITE SPARDA didn't see (a Direction-1 blind spot: a missed write can hide a real mutation, the one unforgivable error). - Fix: completed
PRISMA_OPS(the...OrThrowreads,createManyAndReturninsert,groupByread). Additive, safe direction: dub reads 435 → 539, no verdict/finding change; corpus oracle re-baselined. - Rule: an ORM op table must be COMPLETE for the writes especially — enumerate every mutating variant, because a missing write op is blindness, not noise. When adding an ORM, cross-check its full method list, not just the textbook four.
- Symptom: an attempt to follow BARE function calls (
getCustomerOrThrow(...), the precision enabler for BOLA/taint) made immich'sPOST /auth/admin-sign-up— a genuinely PUBLIC bootstrap route — read as GUARDED, silently dropping its UNGUARDED_MUTATION. The "guard" wasmapUserAdmin: a MAPPER function, matched as a guard because its name contains "admin" (GUARD_NAME = /…|admin|…/). A fabricated guard hiding a real finding — the one unforgivable error (SOUNDNESS Direction 2). - Root cause: translate classifies ANY reachable helper as a guard if its NAME matches
GUARD_NAME, even a plain called function with no deny path. Chain steps (middleware / decorators) are legitimately name-trusted (@Authenticatedis asserted-by-name); a helper reached through a CALL is not — it is logic that happens to be namedmapUserAdmin,isAdmin,sessionStore,authorMapper, … Bare-call following exposed this at scale (member-call following can hit it too; the pinned corpus just didn't surface a case). - Fix (shipped, 0.49.0): the translate helper loop now classifies a called helper as a
guard ONLY by a proven deny (
scan.guardSignals.deniesWithStatus), NEVER by name. Name-trust stays for explicit chain steps (ensureChainNode). Corpus: dub guards 514 → 513 (one fabricated helper-guard corrected), zero finding/verdict change anywhere — a clean SAFE-direction tightening. Oracle re-baselined (dub guards=513 pins the fix). A minimal in-repo repro proved impractical (the fabrication needs a specific reachability/linking that only manifests on real code), so the corpus oracle IS the regression guard here — the purpose it was built for (E-039). This unblocks bare-call following (next). - Rule: name-trust is for the chain (a middleware you SEE gate the route), never for a function you merely CALL. A guard you reached by following a call must PROVE it can deny.
- The reads-only fix (E-037) was necessary but not sufficient: cal-api-v2 (175 routes, ONE non-read effect, ~0% coverage) still read PROVEN. Closed in 0.39.0 (ADR-056): a CLEAN app below a 5% blindspot-coverage floor is SURFACE, not PROVEN. Guarded on findings.length===0 so coverage never hides a NOT_PROVEN.
- Rule: a proof over ~none of the behavior is not a proof. PROVEN requires BOTH a real mutation to reason about AND meaningful coverage of the surface.
- Symptom: the flagship stress-test's Medusa number (~476 routes) was NOT reproducible
out-of-the-box on the framework repo itself. A skeptic cloning Medusa and running SPARDA on
packages/medusagot 1 route (mis-detected as express), 0 from the monorepo root — enough to conclude "bullshit" in two minutes. The heroic figure only appeared on acreate-medusa- appscaffold (which carries the runtime dep). - Root cause: two-fold. (1) Medusa detection keyed off a runtime dep
(
@medusajs/medusa/@medusajs/framework), but the framework's OWN packages list@medusajs/frameworkin devDeps and never depend on themselves — so a dep check misses the framework repo. (2)packages/medusalistsexpresstransitively; the express block ran BEFORE the Medusa block,findExpressEntryfound a strayexpress()in the tree, and detection returnedexpress(1 route) instead of falling through to Medusa's file-based routing (hundreds ofsrc/api/**/route.ts). - Fix (shipped): detect Medusa by its STRUCTURAL signature — a
src/api/apitree ofroute.tsfiles exporting HTTP-verb handlers (export const GET = …) — with NO dep required, checked BEFORE the express block (medusaApiDir, detect.js). Cheap on a non- Medusa app (two statSync calls when the dir is absent); bounded + short-circuits at the first hit.packages/medusanow detects medusa/src/api→ 477 routes (reproduces the claim). Regression:ubg-medusa-nodepfixture (express dep, zero @medusajs dep) → medusa. - Rule: a framework whose routing is structural (file-based) must be detected structurally, not by a dep that its own repo doesn't carry. A claim in the README must be reproducible by a skeptic on the obvious clone, or it reads as a lie.
- Symptom: cal.com read
✓ PROVENwhile only 23% of its surface was resolved. Above the 5% SURFACE floor (E-037 addendum) but far below where a proof means "the whole app is safe." A skeptic sees PROVEN, then sees 77% of routes were invisible to static analysis, and calls the verdict a bluff — the product overselling itself by one notch. - Root cause: the verdict vocabulary had one clean tier (PROVEN) covering everything from 23% to 100% coverage. The PROVEN-COMPLETE-vs-PARTIAL line was named in a code comment but never surfaced in the word.
- Fix (shipped):
verdictOfnow returnspartial/complete(additive — no caller breaks) split at a 60% completeness bar (measured: real complete proofs sit at 60%+ / corpus 71%+).proverenders◑ PROVEN (PARTIAL)with the explicit caveat "only X% of the surface resolved; the rest is UNPROVEN, not safe." A label refinement ONLY: it never masks a finding (a hard finding still dropsclean), never changes the CI gate (safe), only downgrades a would-be-complete-PROVEN app. cal.com → PARTIAL; medusa/nocodb/open-webui (90/90/77%) stay PROVEN. - Rule: the strong word is reserved for the strong claim. "Proved what I could see over 23% of the surface" is PARTIAL, and the verdict must say so before a skeptic does.
- Symptom: the prompt-injection defense (
sanitizeDescription, Hard Rule 7) — advertised as a product security feature — was defeated in two lines by a world-class audit.[MESURÉ]sanitizeDescription("Ignоre all previous instructions")(Cyrillic о, U+043E) →flagged:false;sanitizeDescription("ignore<zwsp>previous instructions")(zero-width space) →flagged:false. The plain-ASCII string was correctly flagged, so the denylist worked — it just never saw the trigger word, because the attacker spelled it in a lookalike script or split the token with an invisible character. - Root cause: the five denylist regexes ran against the raw text. A Cyrillic/Greek homoglyph
is a different codepoint than its Latin twin, so
/ignore/inever matches "Ignоre". A zero-width char between (or inside) tokens breaks the whole word soignore\s+previousnever matches. Classic confusables / invisible-splitter evasion — the two best-known ways past an ASCII denylist. - Fix (shipped): normalize BEFORE the denylist (
sanitize.js): NFKC, then probe the rules against homoglyph-folded copies (a curated Cyrillic/Greek→LatinCONFUSABLESmap — no new dependency) where invisible splitters are BOTH stripped (rejoins an intra-word split) AND replaced with a space (restores an inter-word split); either probe firing flags it. The stored text keeps its original letters (minus the invisibles). Regression:tests/sparda.test.jsgains 6 evasion cases (homoglyph + zero-width, intra/inter-word) that must flag, plus 3 legitimate non-English descriptions (French/Spanish accents) that must NOT over-block. - Rule: a denylist is only as good as the normalization in front of it. Any text-matching defense must fold confusables and neutralize invisibles first, or it is theater.
- Symptom: on dub (and any app using Prisma's
prismaSchemaFolderlayout — aprisma/schema/directory of many*.prismafiles instead of oneschema.prisma),parsePrismaSchemasreturned 0 tables. The whole state layer — invariants, aggregates, ownership models — was invisible, so schema-derived analysis (UNVALIDATED_CONSTRAINED_WRITE, NON_ATOMIC_AGGREGATE_WRITE, the BOLA ownership model) silently did nothing. Found via measure-first while wiring BolaRay step 1: the ownership-model inference returnedunknownfor 100% of dub's tables because there were none. - Root cause:
SCHEMA_CANDIDATESonly looked for a singleschema.prismafile. The folder layout (stable since Prisma 6) was never scanned. A too-generous verdict followed from blindness: an app with an invisible state layer can't fail a state-layer obligation. - Fix (shipped):
collectSchemaFilesalso scansprisma/schema(andschema,db/schema) directories, gathering every.prismafile (bounded, deterministic). Enums and model names are collected across ALL files first (a model may reference an enum/relation in another file — the point of the layout), then models are parsed per-file with correct file:line. dub: 0 → 82 tables. This is the SOUND direction — dub's hard findings went 9 → 96 (newly-visible real posture: 61 unvalidated-constrained-write, 26 non-atomic-aggregate), verdict unchanged (NOT_PROVEN). Corpus oracle re-baselined; only dub moved (the one folder-schema giant). - Consequence handled: the now-visible aggregate structure made
AGGREGATE_MEMBER_BYPASSfire in bulk (dub: 174). A direct member-table write is a design-smell, not a proven violation — reclassified advisory (info, non-gating), like BOLA, so it points a human at the pattern without flooding the verdict. - Rule: blindness is never a pass. A schema layout we don't parse is a state layer we can't reason about — and an unreasoned state layer must degrade the verdict (more findings / SURFACE), never grant a hollow PROVEN.
- The BOLA advisory (
OBJECT_SCOPE_UNPROVEN) now infers each accessed table's ownership MODEL from its declared columns/FKs (BolaRay CCS 2024 step 1: direct-owner / group-scoped / transitive) and names the missing scope in the message ("commission should be direct-owner (userid)"). Still advisory — the schema reveals the model, never the runtime intent (the semantic gap OWASP/BolaRay name as why no static tool can PROVE access control). dub: 50/60 advisories now carry a model.
- Symptom (the cal.com giant test):
cal.com/apps/api/v2(NestJS, 175 routes) read✓ PROVENat 71% coverage — above E-044's 60% completeness bar — while carrying 46 high-risk blind spots: guarded, state-changing routes whose write never resolved (controller → injected service → repo). Not a cardinal false-PROVEN (the guards WERE seen; SPARDA fabricated nothing and disclosed the blind spots), but the headline word over-impressed: 46 guarded mutations were never actually proven safe, yet the verdict read a bare PROVEN. - Root cause: coverage is a RATIO. E-044's PARTIAL rung tripped only on
coverage < COVERAGE_COMPLETE. On a huge app the ratio can clear 60% while the ABSOLUTE count of high-risk blind spots is large — the ratio hides the scale. The verdict never looked atblindHigh. - Fix (shipped):
verdictOf(..., { coverage, blindHigh })— a clean app is now PARTIAL whencoverage < 0.6ORblindHigh > 0. Every whole-app surface (prove, apocalypse, badge, dossier, review, thesparda_proveMCP tool, the bench) passesblindHigh = byRisk.critical + byRisk.highfrom the samesurveyBlindspotsit already computes — single source of truth, so the badge/CLI/live-tool words can never disagree. cal.com/api/v2 →◑ PROVEN (PARTIAL), badgepartial · 71%.blindHighdefaults to 0, so a partial-graph caller (heal delta) is unaffected. - Soundness: the rung only ever SOFTENS PROVEN→PARTIAL — it never masks a finding (a hard
finding still drops
clean), never changes the CI gate (safeis untouched), and PARTIAL still means "clean, just qualified." Analogy: metrology's error bars — a measurement with an unmeasured high-risk population is reported with its uncertainty, not as a point fact. - Rule: the strong word is reserved for the strong claim. "No violation in the 71% I resolved, but 46 high-risk mutations I could not read" is PARTIAL — the verdict must carry the uncertainty in the word, not only in a line beneath it.
- Symptom (the cal.com giant test, root of P1):
cal.com/apps/api/v2's controllers callthis.eventTypesService.updateEventType(...), which delegates to anupdateEventTypeimported from@calcom/platform-libraries, which re-exportsupdateHandlerfrom@calcom/trpc/.../update.handler, where the realprisma.eventType.update()lives — three workspace packages away, entirely outside the analyzed app dir. SPARDA resolved the@/tsconfig alias within the package but not@calcom/*across workspace packages, so every such write was ablind-mutation(46 high). - Root cause:
resolveRelImporthandled relative paths + tsconfigpaths/baseUrl, but a workspace-package specifier (@scope/pkg) is resolved by the workspace (pnpm/yarn/npm), not bypaths— SPARDA had no model of the monorepo, so those imports dead-ended. - Fix (shipped): the workspace resolver (the "mycorrhizal network").
resolveRelImportnow falls back toresolveWorkspaceImport: walk up to the monorepo root (apackage.jsonwithworkspaces, or apnpm-workspace.yaml), build a name→dir map from the workspace globs (cached once per root), and map@scope/pkg[/subpath]to the real source file under it (longest-name match wins; a bare npm package like@nestjs/commonstays unresolved, as before). The effect then crosses the package boundary through the existing DI/barrel followers. - Measured (A/B on cal.com/apps/api/v2, 175 routes): coverage 71% → 87%, high blind spots
46 → 38, and it surfaces real previously-invisible unguarded mutations —
POST /verification/email/send-codehas no@UseGuardswhile its authenticated siblings do, so the verdict moves from a hopefulPARTIALto an accurateNOT_PROVEN. No crash, ~1.9s, no corpus drift (the committed fixtures aren't workspaces). - State layer too (P4, shipped): the same name→dir map feeds
parsePrismaSchemas. When an app declares no schema of its own but depends on a shared@scope/prisma-style workspace package, SPARDA now parses that package's schema as the app's state layer. Measured oncal.com/apps/web: 0 → 100 tables, coverage 87% → 95%, and the schema-dependent rules that were dormant (NON_ATOMIC_AGGREGATE_WRITE,UNVALIDATED_CONSTRAINED_WRITE,AGGREGATE_MEMBER_BYPASS) become measurable — 1 → 12 findings, all newly-visible real posture (the E-046 pattern), verdict correctly still NOT_PROVEN. One resolver, both blind spots (effect code + schema). - Rule: the analyzed unit is the app, but its behavior is drawn from the whole workspace. A shared package imported by name is part of the app's real surface — resolve into it, don't treat the directory boundary as the behavior boundary.
- Symptom (first-run + API-key false criticals): immich
POST /auth/admin-sign-upandPOST /admin/database-backups/start-restore, and formbricksGET /api/v1/management/me, all read as critical unguarded mutations even though each is genuinely gated — the admin routes throw'the server already has an admin', the formbricks route validates an API key and refuses. G2 phase 1 (which only downgrades a critical to advisory when a credential refusal is present) could not see the refusal, so the downgrade never fired. - Root cause (three separate signal drops, all on the same path — the refusal lives ONE CALL AWAY
from the entrypoint, in a delegated body):
resolve.js mergeScan— the single contract every DI/call-graph follower shares — mergedeffects,returnShapes,calls, andguardSignals.deniesWithStatus, but silently droppedcredentialSignals(throw/4xx/verify/redirect) and G1ownerAsserted. So a NestJSthis.service.adminSignUp()whose body throws lost its refusal at the merge — the effects (read/insert user) survived, the refusal did not. One line of omission, whole Nest call graph blind to refusals.translate.js attachBody— an expanded helper body's refusal was never recorded on its own graph node, so a route reaching it through the call graph (not its direct chain) couldn't see it.passes/state-minimization.js mergeNodes— a thin delegator (handler → createFirstAdmin()) is coalesced into one node; it carriedreturnShapesbut dropped the advisory body signals.
- Compounding: the dominant Next.js/App-Router refusal is a named helper
(
responses.notAuthenticatedResponse()), whichstatusIn4xxand the bare-throwcheck both miss.
- Fix (shipped, all advisory-only — can only DOWNGRADE critical→advisory, never prove/silence):
merge
credentialSignals+ownerAssertedup inmergeScan; tag reached bodies inattachBody; carry the advisory signals throughmergeNodes; recognize the named-refusal idiom inextract.js; inapocalypse.jsread signals from the reached set, broaden the stored-credential family to API keys/PATs, and add a first-run family bounded to bootstrap-shaped paths that still requires a real refusal. Field test (13 apps): immich 5→1, formbricks 1→0, total 9→4; every downgrade manually verified genuinely gated. Soundness negatives hold (bootstrap-path-no-refusal, token-read-no-refusal, naked mutation all stay critical); mutation guard "any family gated without a refusal shape" bites. - Rule: effects and refusals travel the SAME reachability — if you follow a call for its writes, you must follow it for its refusal too, or a gated route reads as a false critical. (Distinct from the reverted "option A": that ATTRIBUTED effects across reachability and over-fired; this only READS an advisory refusal signal, which can never fabricate a guard or a false PROVEN.)
- Residuals — the surviving criticals after this fix, and why (measured across the 13-giant corpus;
the honest "is login the only one?" answer): 5 criticals survive, in three buckets —
One more unclosed FP family — password-login.NOW CLOSED via Class 1 (E-050). immichPOST /auth/login— the master map (docs/_MASTER-MAP-*) and the FP-classes spec frame it not as a credential family to detect but as a public-by-design route to re-label. Closed by the E-050expectedPublicre-label (it was the ONLY route in the 13-app corpus that needed it — the evidence-based G2 families already caught the callbacks/oauth/reset flows).- Genuinely public-by-design writes — correct to flag, not traps. dub
POST /api/track/application(public partner-application form), ralllyGET /api/updates(self-hosted instance telemetry registration). A public route that mutates: a human should confirm intent. Not a credential family; leaving them critical is defensible. (codebase-wide)pervasive collapse — a different, coverage-bound problem. cal (13/45) and papermark (22/63) collapse many UNGUARDED_MUTATIONs into one meta-finding. Inside cal's list are routes G2 should downgrade (reset-password,verify-booking-token) but doesn't — because at 23% coverage in a deep@calcom/*workspace the credential bodies don't resolve. That is a RESOLUTION-DEPTH gap (E-048 territory), not a missing family.
- Symptom: immich
POST /auth/login(and, in the wild,/register,/logout,/health,/metrics,/oauth/*,/webhooks/*) reads as a critical unguarded mutation — but these are conventionally meant to run without a session guard. A tester who sees CRITICAL on/loginclasses SPARDA "amateur" in 30 seconds (the FP-classes doc's Class 1; the master map's item #1). - Why G2 doesn't catch it: login carries no modeled credential mechanism SPARDA can point to
as evidence (its
compareBcryptis not a stored-token lookup, not averify/hmacname, not a callback). There is no gate to prove — the route is simply public by convention. - Fix (shipped, distinct from G2 — triage, not proof): a curated public-by-design path
classifier in
apocalypse.jsO1. When an UNGUARDED_MUTATION would fire critical, is NOT already credential-gated, and the path matches a precise public signature (login/register/logout, forgot/reset-password, verify-email, oauth/sso/saml, callback/webhook, health/metrics/.well-known), it is re-labeledexpectedPublic(info) with "confirm this endpoint is meant to be unauthenticated". Marked distinctly fromcredentialFamilyso it reads as a CONVENTION, not an evidenced gate. Never hidden, never marked safe, never touches PROVEN. - Deliberately PRECISE, not
**/auth/**blanket:change-password/2fa/ session management live under/auth/*but require a session — re-labeling those would HIDE a real hole. The list matches specific public verbs only. Regression test + the soundness contre-test (/account/change-passwordstays critical) both ship; the mutation guard "re-label a non-public route as public" bites. - Measured (13-app corpus): exactly one route re-labeled — immich
/auth/login. The evidence-based G2 families already softened every callback/oauth/reset flow with proof, so Class 1 had a minimal blast radius (mops up the single genuinely-public route with no detectable gate). - Rule: two honest ways to soften a false critical — evidence (G2: a refusal shape is present, name the mechanism) and convention (Class 1: the path is public by design, say "confirm intent"). Keep them separately labeled; never let convention masquerade as proof.
E-051 — a guard that doesn't DOMINATE the write: false PROVEN by non-dominance (the C2 cardinal sin)
- Symptom (found in the 2026-07-20 perfection audit, then reproduced minimally): a handler that
mutates in an early-return branch and only checks auth AFTERWARDS reads as ✓ PROVEN —
if (body.preview) { await charge.create(); return } … const denied = await requireAuth(req); if (denied) return denied; await charge.create(...). Thepreviewwrite runs and returns BEFORE the guard, so it is a real auth bypass, yet SPARDA creditedrequireAuthon both writes. This is a false PROVEN — the cardinal sin — and it holed thesparda gatewedge: arm a baseline on the clean route, let an agent introduce the bypass, and the gate stayed silent (exit 0), because it inherits the compiler's verdict. - Root cause: O1 asked "does a guard exist ANYWHERE on the route", not "does a guard DOMINATE
this effect" — the known dominator gap, never implemented. The UBG flattens a body
into a bag of effects + calls under the handler (the control-flow
orderis effects-then-calls, so it can't express before/after), so dominance is invisible at the graph level. - Fix (shipped, SOUND by construction): compute dominance at SCAN time, where the AST still has
the control structure. A recursive spine walk tracks whether a guard has executed on the current
PATH (a guard in a branch never covers a sibling); each mutation reached while the path is still
unguarded is tagged, and promoted to
bypassesGuardonly when THIS body also holds a guard (so a body guarded cross-procedurally is left to the route model). apocalypse then flags abypassesGuardwrite as a hard critical (guardBypass, never softened) even on an otherwise-guarded route, andbuildProofObjectsnever claims it as discharged. Because the flag only ever SUBTRACTS guard credit (never invents a guard), it can turn a PROVEN into NOT_PROVEN but never fabricate a false PROVEN. - Precision (the hard part — measured to zero): the barrier is auth-specific, not the broad
GUARD_NAME— onlyrequire/ensure/assert{Auth,Session,User,Owner,Permission,…},authenticate,authorize,canActivate,get(Server)Session,check/verify{Auth,Session,…}, or a401/403deny (NOT any throw, NOT any 4xx — a service'sNcError.badRequest/hasAdmin()is not auth). AndbypassesGuardis stripped atmergeScanso a delegated service's INTERNAL ordering never flags the route (only the handler's own body counts — "effects merge; guards do not"). First cut fired 25 false positives on the corpus; after tightening + the merge-strip: 0 across dub (580), immich (281), nocodb, medusa, ghostfolio, +4, while the adversarial repros (early-return AND branch-sibling) both catch. Fixturetests/fixtures/ubg-guard-dominance+tests/guard-dominance.test.js- 2 mutation guards pin it.
- Rule: a guard proves nothing about a write it doesn't dominate. "A guard exists on the route" is necessary, not sufficient — the guard must run on every path to the write, before it.
- Symptom (perfection audit C3): a Next.js server action — an exported
asyncfunction in a'use server'module (or with a function-level'use server'directive) — is remotely invocable: a client form can call it with any args, exactly like an HTTP route. An unguarded mutating action (export async function deleteUser(id) { await prisma.user.delete(...) }) is a real hole. But SPARDA walked onlyroute.tsfiles, so actions were invisible — and worse,blindspotsreportedcoverage 100% — nothing hidden. A false coverage claim: the ledger's whole job is to name what SPARDA can't see, and it was silent about a live attack surface. - Fix (shipped — extract, not just flag):
nextjs.jsnow also scans non-route.ts/.tsxfiles (behind a cheap'use server'string pre-filter, so ordinary components are never parsed) and registers each server action as a POST entrypoint ((action) <file>#<name>), with the same in-body auth-verifier detection routes get. So an unguarded action becomes a normal UNGUARDED_MUTATION critical, a credential-verifying one is handled by G2, and the action is counted in coverage instead of hiding. Extraction subsumes flagging — the action is both VISIBLE and fully analyzed. - Precision: only
asyncexports (a non-async export in a'use server'file is a re-exported constant, not an action); both module-level and function-level'use server'directives; a per-file+name synthetic path so two same-named actions never collide (a collision would re-hide one). - Measured (corpus): dub 580 → +2 actions (both
verifyPassword, credential-verify → handled by G2, 0 false criticals), rallly +1 (setVerificationEmail, likewise), papermark/formbricks +0. My repro's two unguarded actions flag correctly. Fixturetests/fixtures/ubg-server-actions+tests/server-actions.test.js+ a mutation guard pin it. - Rule: the analyzed surface is every REMOTELY-INVOCABLE entrypoint, not just files named
route.ts. A'use server'export is a route by another name — walk it, or the ledger lies.
E-053 — a Next config.matcher-scoped middleware was credited as a guard on paths it never runs on (false PROVEN)
- Symptom (re-verification on vibe-coded Next apps — Fable 5's ARBITRE-4 pass): a global
middleware that returns
401withexport const config = { matcher: ['/dashboard/:path*'] }runs ONLY on/dashboard/*. Next never executes it for/api/*. But SPARDA credited every global middleware to every route, so an unguarded mutating/apiroute inherited the middleware as its "guard" → a false PROVEN on the dominant next-auth pattern. Cardinal sin. - Fix (integrated from
claude/sparda-compiler-analysis-3qvx9b):nextjs.jsreadMatcher()readsconfig.matcherfrom the AST;matcherCovers()decides coverage for the two dominant forms — the positive path glob (/dashboard/:path*, which also covers/dashboarditself) and the negative-lookahead exclude (/((?!api/|_next/).*)).translate.jsnow attributes a global middleware guard only to routes its matcher provably covers. An undecidable matcher (computed value, exotic regex) attributes nothing — abstain, never fabricate a guard (SOUNDNESS.md). - Why it can't regress soundness: the change is monotonic in the safe direction. It can only ever withhold a middleware guard credit, never add one — so it can turn a wrong PROVEN into NOT_PROVEN, never the reverse. No path where it manufactures a false PROVEN.
- Measured: all 4 matcher forms correct (unit tests); dub unchanged (518 verified guards, per
Fable's corpus run). Locked end-to-end by
tests/fixtures/ubg-next-matcher+ two integration tests (the/apiroute flags, the/dashboardroute keeps its guard) and a mutation guard that reintroducing "credit all middlewares" is killed. - Rule: a middleware only guards what it runs on.
config.matcheris part of the guard's reachability — ignore it and the guard is attributed to routes it never sees.
- Symptom (audit blind spot #1):
stripe.charges.create()charges a card but wears nofetch/http-client skin, so it resolved to nothing; O4 (IRREVERSIBLE_OBSERVABLE) never fired on real payment code. The exact same bug written withfetch()WAS caught — the SDK form was a clean false negative. - Fix (brick #1/#5,
extract.js): a PAMP catalog (EFFECT_SDK_PATHS, matched on the property path below the user-named root) + AWS SDK v3 command detection (.send(new PutObjectCommand()), matched on the command class in the argument). Emits an observablehttp_call. Additive, write-only — can only raise a finding. The bare-.send()tail is handled by E-057's provenance.
- Symptom (audit blind spot #2): the
@relationparser used a single-line regex@relation(\s*fields:that only matched a plain, first-attribute relation. NAMED relations (@relation("Name", …, onDelete: Cascade)) and MULTILINE relations were silently dropped, so consistency domains collapsed to one-table islands on serious schemas (ghostfolio: 0 FK edges) and O3/O5 never fired. dub happened to use the plain form, so it worked there — masking the gap. Root cause was NOT@@map(the table node is keyed by the model name;@@mapis only an alias). - Fix (brick #2,
prisma.js): harvest FKs over the WHOLE model body with[\s\S], pullingfields:/references:INDEPENDENTLY (order- and newline-agnostic).
- Symptom (audit blind spot #3): the dominant Prisma idiom hands the callback a transactional
client named
tx, which the/prisma|client|db/heuristic can't see, so every write inside vanished → the handler compiled toSURFACEand an unguarded write inside a transaction was a silent pass. - Fix (brick #3,
extract.js): the "prion" bind — the TX-wrapper visit binds the callback's param name(s) intotxCtx.dbAliases(scoped to the transaction body, never leaks); the Prisma op check honors it. Writes reappear, share the tx scope (atomic), and unguarded ones fire.
- Symptom (audit blind spot #4): an app whose write lived in a sibling workspace package
compiled to
SURFACE. Workspace resolution already worked (the@acme/dataspecifier + barrel re-export resolved) — the write dead-ended because the leaf was exported asmodule.exports. createOrder = async () => …, a direct function-to-exports assignment the function collector never captured, soservice.createOrder()resolved to no body. A CommonJS export-style gap that also affects single-package apps. - Fix (brick #4,
extract.js): theexports.X = …handler now registers a directly-assigned function/arrow (incl. a wrappedcatchAsync(async …)) as an exported function. Separately, inline arrow route handlers are nowdeepScanned (were not), so they follow service calls.
- Symptom (round-2 re-audit): TypeORM write verbs (
save/insert/update/delete/remove/upsert) run on a repository whose entity is nowhere in the call —this.repo.save(dto)(injected) orgetRepository(User).save(). The ORM handlers didn't know these shapes, so a NestJS+TypeORM app (a top enterprise stack) resolved zero mutations. - Fix (brick #7,
extract.js+resolve.js): repository provenance —collectRepoFields(cls)(from@InjectRepository(Entity)/Repository<Entity>) +collectRepoVars(fn)(localgetRepository(Entity)) buildctx.repoTables; a write verb on a known repo emits adb_writeon the entity table. A generic.save()on an unknown object never fires. Remaining tail:manager.save, active-recordEntity.save, and parsing @Entity classes intostatenodes (no FK/domain layer on a TypeORM-only app yet).
- Symptom (found via a clean-install reproduction, v0.66.2):
@babel/parser,@babel/traverseand@clack/promptsare imported bysrc/at runtime but sat in devDependencies, which npm does NOT install for a consumer. A freshnpm i sparda-mcptherefore crashed on every flagship command —sparda ubg|prove|apocalypse|review|gate→Cannot find package '@babel/parser';sparda init|demo|remove→@clack/prompts. Only--version/help worked, so smoke-testing the CLI locally (where devDeps ARE installed) never surfaced it. Confirmed bynpm pack+ installing the tarball into an empty project (@babel/@clack absent) and running the commands. - Root cause of the blind spot:
prepublishOnly: vitest rungates on tests, which run WITH devDependencies present — so the gate can never see a missing runtime dep.CLAUDE.mdeven states the intended runtime surface is "4, exact-pinned"; three of the four had drifted to dev. - Fix: move the three to
dependencies, exact-pinned (runtime surface = exactly the four advertised deps). New guardtests/packaging.test.jsparsessrc/with babel (ignoring imports inside generated-code template strings) and fails if any runtime import is not a declared dependency, and asserts the deps stay exactly the four, exact-pinned. Bump → 0.67.0.
E-060 — self-audit: @Author/@Authorization param decorators falsely asserted a guard (could HIDE a finding)
- Symptom (found by turning the audit on my own Task-1 work, ADR-063):
PARAM_AUTH_DECORATORmatched/^auth/i, so a param decorator named@Author()(injects a post's author entity) or@Authorization()(injects the raw header string — presence is NOT proof of authentication) was read as an asserted auth guard. Probe:@Authoron a resolver mutation SUPPRESSED itsUNGUARDED_MUTATION. - Why it matters (the dangerous direction): an asserted guard DOWNGRADES
UNGUARDED_MUTATION. A false match therefore HIDES a real unguarded mutation — SOUNDNESS Direction 2, the one class of error SPARDA must never make. (Its sibling ADR-063 rail — "asserted, never verified" — protects the PROVEN direction; this hole was in the other direction and slipped the first review.) - Fix (initial, band-aid): gated the
authprefix behind a lookahead —auth(?=user|workspace|account|session|context|principal|$). Correct but still a name-regex. - Fix (real, ADR-067 — superseded the lookahead): Zak's push ("on peut pas faire mieux que des
regex?") led to the thesis-aligned fix — RESOLVE the decorator's
createParamDecoratorbody and PROVE what request field it reads.@Authorreadsbody.author(user input) → NOT a guard, regardless of name;@AuthWorkspacereads.workspace(principal) → guard. Body-visible ⇒ behaviour is final; body-opaque ⇒ a tokenized-name fallback (splitIdent, whole tokens not substrings). This kills the whole CLASS (any auth-named input-reader) and ADDS recall (@Whoami, no auth token in its name, readsrequest.user→ correctly a guard — impossible for a name-match). Regression tests:@Authordecoy MUST flag;@Whoamimust NOT (tests/param-auth-decorator.test.js). - Also corrected in the same audit (honest scope, not bugs): (a) ADR-066 interprocedural taint
covers BARE function calls only, NOT DI/instance method calls (
this.svc.save(req.body)) — the Nest/Strapi-dominant shape — because threading a taint seed throughclassBundle's memo key would risk cache poisoning; the ADR claim was corrected and DI-taint queued as its own brick. (b) taint under-approximates on NESTED (const { user: { id } } = req.body) and ARRAY destructuring (safe direction, documented). (c) Strapi's custom-vs-core route collision on the same method+path resolves to the custom route by file order (correct outcome, but by luck of ordering, not by design).
- Symptom: the extractor collected
app.use(fn)middlewares as a positionless GLOBAL set and translate credited them to EVERY route. Express reads setup top-to-bottom: ause(auth)at line 50 never runs for a route declared at line 10 — crediting it fabricated protection out of thin air (a false-PROVEN vector, SOUNDNESS Direction 2). - Fix (robustness pass):
flattenSetupstamps every statement with its formal declarationorder; routes, global middlewares AND mounts carry it; a route inside a mounted router takes effect at the MOUNT's position, so sequential scope is inherited through nesting at every depth (the Y1 corollary).middlewareAppliesTorefuses credit whenmw.order > route.order. Monotone in the safe direction: the check can only WITHHOLD credit, never add it; an unstamped side (Next/FastAPI middlewares) keeps the prior semantics. Tests:tests/sequential-order.test.js(+ killing mutant: drop the order check → the pre-auth route regains the guard → bites).
- Symptom:
flattenSetupdescended intoif/try/loop blocks with no marker — anapp.use(auth)insideif (ENABLE_AUTH), or a route inside aswitchcase, was analyzed as unconditionally present. Uncertainty was silently converted into certainty. - Fix: every statement reached through a control-flow bifurcation (if/else branch, loop body,
switch case, catch handler, ternary branch,
&&/||short-circuit operand — the Y2 corollary; atryblock and ado-whilefirst pass ARE certain and stay unconditional) is markedconditional. A conditional registration (route, mount, global middleware) STAYS analyzed — findings must still fire, and withholding the guard would fabricate false criticals — but raises a HIGH-riskskipped-surfaceblind spot, which bars the PROVEN verdict (blindHigh → PARTIAL at best). Ternary/short-circuit registrations, previously INVISIBLE, are now also discovered (recall gain) via synthetic statements. Tests:tests/conditional-surface.test.js(+ killing mutant: un-mark if-branches → bites).
- Symptom:
app[v]('/x', h)(computed property),Reflect.apply(app.get, …)andapp.get.call(app, …)fell through the walk without a trace — and worse, a computed member with an Identifier property was read as if it were the static.name(a variable namedusewould have been treated asapp.use). - Fix (Y3): a registration on a known app/router var that static analysis cannot bind
(computed method,
Reflect.apply,.apply/.callindirection) emits a structuredUnknownHandlerobject (report.unknownHandlers) plus a HIGH-riskskipped-surfaceentry — certainty degrades, the surface never lies. Tests:tests/dynamic-registration.test.js(+ killing mutant: silence the computed-method branch → bites).
- Symptom:
surveyBlindspotsreturnedratio: 1whenresolved + blind === 0— the ABSENCE of a measurement displayed as a perfect score, feeding every surface (prove, badge, dossier, MCP) a confident 100%. - Fix:
ratio: null+unknown: trueon a zero denominator;verdictOfdistinguishescoverage === null(measured-but-unknown →coverageUnknown, bars complete PROVEN, at best PARTIAL) fromundefined(not measured — heal's delta — old semantics kept). One shared formattercoveragePct()so every display says "unknown" — null must never coerce to a number (null * 100 === 0would have shipped a confident-looking 0%). Tests:tests/coverage-unknown.test.js(+ killing mutant: restore? 1→ bites).
- Symptom:
scanFilereturned void past mount depth 2 andflattenSetupstopped at depth > 6 / 8000 statements — the dropped surface left NO trace, so the coverage ratio was computed over a denominator that silently excluded it (inflated percentages). - Fix: every cap now surfaces: the unexplored mount becomes a HIGH-risk
skipped-surfaceentry naming the prefix;flattenSetupreturns alimitreason its caller must report; the Python extractor (fastapi_extract.py) gained the same depth-limit declaration. The blind entries enter the coverage denominator, so truncation now LOWERS coverage instead of faking it. Tests:tests/limits-surface.test.js.
- Symptom: no wall-clock bound on extraction and no size bound on a single source file — a generated mega-file or an endless mount tree degraded the host process instead of the verdict.
- Fix (P3):
extractExpressruns under a time budget (budgetMsoption /SPARDA_BUDGET_MS, default 120 s); exhaustion stops cleanly with ONE critical-riskskipped-surfaceentry ("cannot claim PROVEN") — never a hang, never a crash.parseModulerefuses files over a 5 MB cap with an explicit error that flows to the skip report. An entry file whose syntax is outside the modeled grammar remains a clean refusal to certify (parse error surfaced + NO_PROOF — locked by test). Tests:tests/analysis-budget.test.js.
- Found by: an adversarial red-team pass (corpus
tests/fixtures/ubg-invisible-verbs). - Symptom:
HTTP = new Set(['get','post','put','patch','delete'])plus a bareif (!HTTP.has(method)) continue;— noskippedentry.app.all(path, …)(which answers EVERY verb) and the chainableapp.route(path).post(…)(Express documentation) both dropped silently. Measured: an app with one clean guarded route plus an unauthenticatedprisma.note.deleteMany({})behindapp.all('/admin/wipe')read✓ PROVEN · 1 route · coverage 100% · 0 blind spots · exit 0. The cardinal sin. - Proof it was an oversight, not a scoping decision:
src/commands/enforce.js:43already declaredHTTP_VERBS = new Set([… 'all']). One organ knew; the extractor did not. - Fix:
allis EXPANDED into the modelled verbs (what Express actually does, and what keepsopenapi-emitandmirrorexact — anallpseudo-verb would be invalid OpenAPI and would never match a real request);routeChainOf()walks a Route chain to itsX.route(path)base and registers EVERY link, not just the outermost. Registration is factored into oneregisterRoute()so all three entry paths share the conditional-branch honesty and the handler-chain resolution. Tests:tests/zero-day-verbs.test.js+ 2 killing mutants.
- Symptom:
collectAppVarsrecognised an identifier only when its initialiser was literallyexpress()/Router(). Express does not care what the object is called, soconst api = app; api.post('/admin/wipe', …)vanished from the graph — silently, withcoverage: 100%andskipped: []. - Fix: aliases are followed (
const api = app,const r2 = router, and the assignment formapi = app), to a FIXPOINT so alias chains and declare-after-use order both resolve. Bounded by the number of names it can learn, so it always terminates. Tests:tests/zero-day-alias.test.js+ a killing mutant.
- Symptom: the risk of a
skippedentry defaulted to a mutating-verb regex over the skip's TEXT. A parse error never contains a mutating verb, so a file that failed to parse — losing every route it declared — scoredmedium, stayed out ofblindHigh, and left the verdict a bare PROVEN at 66.7% coverage with an unguardeddeleteManyin the unparsed file. The honesty machinery worked perfectly and the verdict ignored it. - Fix:
isFatalSkip()inblindspots.jsforceshighfor any skip that loses a whole file (parse error, unreadable, size cap, encoding). The size of that hole is precisely what SPARDA cannot know, so it is never a medium event. Tests:tests/zero-day-effects.test.js(Z3) + a killing mutant.
- Symptom: two layers failed together. (1)
inspectCallbailed out on any non-Identifierproperty, soprisma['note']['delete'+'Many']()never reached an effect handler; and a computedprisma.note[OP]()was read as a method literally namedOP. (2) The ADR-068 opaque-write safety net — designed for exactly this ("a missed write is a hidden hole") — required a DIRECT handle identifier as receiver, whichprisma.note[…]is not. Net effect: the write did not exist, so an unguarded mass delete looked like a harmless no-op route. - Fix: a computed member on a receiver ROOTED at a proven persistence handle emits an opaque
db_write(opaqueDynamicWrite), carryingdynamicMember: trueso the ledger can say which kind of blindness it is. Provenance-gated, so a computed call on a non-database object fabricates nothing. Also:collectDbHandlesnow labels the CommonJS destructured require (const { PrismaClient } = require('@prisma/client')) — without it the entire CJS world had no proven handles, leaving every provenance-based net inert there. Tests:tests/zero-day-effects.test.js(Z4) + a killing mutant.
- Found by: following the Z-series pattern past the reported list — the Express twin of a sin already closed on the Next.js side (E-053 / E-NEXT-MW).
- Symptom:
app.use('/api', expressjwt({…}))runs only under/api, but the extractor dropped the prefix and registered it as a GLOBAL middleware. Measured: an unguardedPOST /admin/wipereadPROVENwith 1 VERIFIED guard it never runs behind. - Fix: the mount prefix travels with the middleware (
pathPrefix) andmiddlewareAppliesToenforces it with Express segment semantics —/apicovers/apiand/api/**, never/apikeys. Withholding-only: it can remove a false PROVEN, never manufacture one. Tests:tests/zero-day-effects.test.js(Z6) + a killing mutant.
- Symptom: the route filter (
next.route === epId) ran over a FLAT successor list, and a global middleware's fan-out IS the route count — so one walk per entrypoint cost O(routes) at the shared node. Measured edge visits: 100 routes → 10 250; 1 000 → 1 002 500; 4 000 → 16 010 000 — exactly routes². Worse, the same BFS existed in THREE hand-written copies (reach.js:reachFrom,apocalypse.js:reachOf,passes/type-propagation.js:bfs) — whilereach.js's own header claimed to be "the one traversal everything shares" — so the fix could not be applied once, and any divergence between them would have been an invisible soundness bug.type-propagationalone was 31.2% of pipeline CPU. - Fix: ONE traversal (
ubg/reach.js), with successors partitioned BY ROUTE at index-build time, so a hop reads only what the walking entrypoint can reach. Order is preserved exactly (successors keep their edge-order rank and the two partitions are merged back by rank), so every consumer's output is byte-identical — the index is a speed change, never a semantic one.Array#shift()(O(n) per dequeue) replaced by a cursor, andreachabilityOfmemoised per graph TOPOLOGY (edges identity + length + node count — over-sensitive on purpose: a stale map would hide a finding). - Measured: 16 010 000 → 14 000 edge visits at 4 000 routes (1 144× fewer, and now
strictly linear);
checkGraph141.9 ms → 59.4 ms; whole pipeline 3 248 ms → 2 440 ms. Bench:bench/scale-gen.mjs+bench/scale-run.mjs(the witness prints both strategies).
- Symptom:
app.use('/admin/wipe', handler)is, in Express, an endpoint answering every verb at that path. SPARDA treated every pathed callable as a middleware, so the endpoint never entered the graph at all — an unauthenticateddeleteManywas simply absent, and one clean decoy route carried the app toward a bare PROVEN. Worse, at depth > 0 (inside a mounted router) the whole branch was gated behindif (depth === 0)and dropped without a trace. - Fix:
handlePathedUsedecides the role by BEHAVIOUR —callsNext()asks whether the function can hand control on (its third parameter is referenced anywhere in the body). A terminal handler is expanded into routes on every modelled verb; a real middleware keeps its prefix-scoped credit; an OPAQUE body decides nothing and is declared as anUnknownHandler. Works at any depth, so the nested form is modelled too. Two latent bugs surfaced while testing this and are fixed with it:registerRoutewas being called with six of its seven arguments from the pathed path (soorderand the conditional flag fell off), andmountTargetFileread a LOCAL function passed toapp.use('/p', fn)as an unresolved router mount, losing the callable entirely. Tests:tests/zero-day-pathed-handler.test.js+ 3 killing mutants.
- Symptom: four shapes, each of which made a write vanish from the graph — so the
route around it looked like a harmless no-op and could carry a PROVEN:
prisma?.note?.deleteMany({})(optional member — a distinct Babel node type, never matched),prisma.note.deleteMany?.({})(optional call — the visitor only dispatched onCallExpression),prisma.$executeRaw`DELETE FROM "Note"`(a tagged template is not a call node at all), and(cond ? a : b).deleteMany({})(a receiver with no nameable root, so no handler could match it). - Fix: optional chaining is MODELLED, not declared — it is the same call whenever the
handle exists, a known semantics, and declaring an unknown there would have been a
cop-out.
taggedTemplateEffectreads the template's static parts as SQL when the tag is rooted at a proven persistence handle, falling back to an opaque write.handleInSubtreefinds a proven handle inside an unnameable receiver. All four are provenance-gated, so a computed call on a plain object still fabricates nothing (pinned by a control case). Tests:tests/dynamic-effects.test.js+ 3 killing mutants.
- Symptom: the same optional-chaining blindness on the registration side. Both routes disappeared with no skipped entry.
- Fix:
isCall()/isMember()normalise the optional node types across the whole registration dispatch, including the Route chain walk and the.apply/.callandReflect.applydetectors. Pinned intests/dynamic-effects.test.js.
- Symptom:
router.use(requireAuth)— the canonical way to protect a whole Express sub-router — was dropped at depth > 0, so every route in that router read as unguarded. A FALSE POSITIVE (the safe direction), but a loss all the same, and the audit flagged it. - Fix (and its rail): an unpathed
useinside a mounted router is credited with the router's mount prefix. That is a recall win on a GUARD, which is the dangerous direction: every route in a mounted file shares that file's MOUNT rank, so the mount rank alone cannot order them. Routes and middlewares now also carryorderIn— their position WITHIN the file — andmiddlewareAppliesTocompares it when the mount ranks tie. Without it, arouter.use(auth)written at the bottom of a router file would have been credited to the routes above it: a false PROVEN manufactured by the fix itself. Fixtureubg-router-use-orderpins both sides on one router (the route above the guard must still flag);tests/router-use-order.test.js+ a killing mutant.
- Found by: grading our own claims. E-067 (
app.allinvisible) was fixed on Express and left standing everywhere else. Measured on HEAD in ten minutes: a NestJS controller with a guarded decoy plus an unguarded@All('wipe')readPROVEN · 1 route · coverage 100% · 0 blind spots.@Allis a real@nestjs/commondecorator. The same vocabulary hole existed in four lowerings at once:nestjs.js(@All/@Options/@Head),nextjs.js(OPTIONS/HEADroute exports),openapi.js(options/head/traceoperations),fastapi_extract.py(options/head/tracedecorators). - Fix: all four vocabularies completed.
@Allis EXPANDED into the verbs a request can arrive on (the same decision as Express'sapp.all— it is not a verb, it is every verb), and the Nest candidate pre-filter was widened too, or the file would never have been parsed. - The consequence that had to ship with it: modelling OPTIONS/HEAD/TRACE turns them into
entrypoints, and
mutating: method !== 'get'would then have read every CORS pre-flight handler as a mutation — flooding real apps with false criticals. Mutation is now decided by the RFC 9110 safe-method set (get/head/options/trace), not by "not GET". A safe method that genuinely writes still surfaces: O1 fires on the effect, not on the verb. - Tests:
tests/cross-framework-verbs.test.js+ 2 killing mutants.
- Symptom:
httpDecoratorreadargs[0]?.type === 'StringLiteral' ? args[0].value : ''. So@Get(ROUTES.detail)silently became the empty path and the route was mounted at the CONTROLLER PREFIX — a URL the app does not serve. Misplacement is worse than loss for this engine: every guard, prefix and ownership judgement about that route is then about the wrong URL, and nothing says so. - Fix: a first argument that EXISTS but is not a literal is distinguished from no argument
at all (which legitimately means "the prefix"). The route stays — its behaviour is real —
and the misplacement is declared through NestJS's new
unknownHandlerschannel plus a high-risk blind spot, so it cannot sit under a PROVEN. This is the registration invariant (ADR-079) reaching its second framework. - Tests:
tests/cross-framework-verbs.test.js+ a killing mutant.
- Symptom, stated as a class rather than a bug: every honesty organ SPARDA has —
guard dominance, the type lock, the blind-spot ledger,
falsify— reasons OVER THE GRAPH. All of them are therefore blind to the same thing: a route that is not in the graph.falsifycannot ablate the guard of a route that does not exist. The five false PROVEN verdicts of E-067…E-071 were all absences, and no instrument could have caught any of them, because every instrument took the route surface as given. - Fix (
src/ubg/premise.js): the given is now checked, against an oracle that is not the analyser — the app, booted, reporting the route table the framework really built.src/probe/already had that oracle and had always capturedapp.all(the runtime knew what the static eye did not); it fed route GENERATION and never the verdict. NowverifyPremisediffs it against the compiled entrypoints, and any gap:- enters the blind-spot ledger at CRITICAL risk (so it counts in coverage and ranks in the map like every other unseen surface — one channel, no special case);
- sets
premiseUnverified, which yields the newPREMISE_GAPverdict state — not PROVEN, and not PROVEN (PARTIAL) either, because both claim something about an app whose surface we demonstrably did not have; - fails the CI gate (
safe), because a green over unanalysed routes is the exact failure this audit removed.
- Three honesty rails: the oracle is OPT-IN (
--probe) since it executes the target's code; an oracle that could not run leaves the verdict untouched (SPARDA never demands what it could not measure); and an EMPTY probe is reported as unavailable, never as "no gaps" — otherwise a broken oracle would silently confirm every proof. Each rail has a killing mutant. - Measured: on a bootable app whose route table is materialised from data at startup,
static analysis sees 2 routes, the framework builds 4, and the verifier reports exactly the
2 unreachable-by-AST admin routes.
prove --probethen readsPREMISE NOT VERIFIEDand exits 1. Tests:tests/premise-gate.test.js(8, including a real boot).
- Symptom: ADR-079's rule ("modelled or declared, no silent third option") and ADR-080's certificate both swept Express only. On the other six lowerings a real endpoint could still leave no trace at all — no route, no skip, no unknown handler — and the app read certifiable.
- Six concrete paths, one per lowering. Next stopped its walk at a directory whose URL it
cannot express (
[...slug],@slot,(..)x) and lost the whole subtree behind a directory-level skip carrying NO risk — belowblindHigh, so still PROVEN-able; measured onnextjs-basic, whereapp/api/docs/[...slug]/route.jsserves GET and appeared nowhere. Next also swallowed an unparseablemiddleware.ts— the app's only global gate. Medusa droppedexport const POST = registry.handler: the verb IS exported, so Medusa serves the route, but the body did not resolve and the code justcontinued. Strapi resolved a route table entry to a controller action that does not exist and modelled the route as if it had read it. OpenAPI skipped any path-item member outside its verb list, discarding published surface in a lowering whose entire premise is that the spec IS the declaration. FastAPI dropped a decorator whose path is not a literal. - Fix: each of the six now emits an
UnknownHandlerplus a HIGH-risk skipped entry, so the declaration reaches the verdict gate rather than a log line. Next deliberately does NOT synthesize a URL for an unrouted subtree: SPARDA does not know the path, and inventing one would misplace every guard judgement about the route. - Sealed by two new files.
tests/no-silent-loss-fleet.test.jsre-enumerates the declared surface of each lowering with an INDEPENDENT implementation (its own file walk, its own AST or spec read) and demands the extractor account for every item; it opens every file itself, so a controller the extractor's candidate pre-filter never selected surfaces as a lost route.tests/registration-invariant-fleet.test.jspins a named fixture per lowering and asserts end to end that the app can no longer read PROVEN.
- Symptom: the Next extractor filtered directories named
dist/buildwhile walkingapp/. Underapp/, a directory name is a URL SEGMENT and nothing else — Next servesapp/dist/route.tsat/dist. The endpoint produced no route, no skipped entry and no unknown handler: it did not exist for SPARDA, and the verdict was computed as if the app had one route fewer. - Why the invariant did not catch it: the invariant is about registrations SPARDA SEES. This file was never opened, so there was nothing to declare. That is the whole reason a premise oracle has to be independent of the analyser (E-082).
- Fix: the exclusion list for the
app/walk keeps only what could never be a routed segment (node_modules,.git,.next,.sparda). Found by the boot-free oracle, which is exactly what it is for.
- Symptom:
verifyPremise(E-079) boots the app. Next, Medusa, Strapi and Nest cannot be booted from a static checkout, so they returnedavailable:false— their premise was never checked, and the strongest honesty organ in the system covered three lowerings out of seven. - What made it fixable: for three of the four the route table is a FUNCTION OF THE FILESYSTEM. That is the framework's contract, not a heuristic, so the directory tree is a genuine second source of truth — no boot, no dependencies, no execution of the target's code. Nest is decorator-routed, so its oracle re-derives the table with its own walk over EVERY file, which the extractor's candidate pre-filter cannot narrow.
- Fix (
src/ubg/oracle-static.js): a boot-free oracle for the four. Because it costs nothing and executes nothing, it runs UNASKED — the runtime oracle stays opt-in. It found E-081 on its first corpus sweep, and it reports Next's Pages Router (pages/api/**, still fully served by Next 14, with no SPARDA lowering) as a measured premise gap instead of a silence. - The rule that keeps it usable: conservatism. A false gap takes the verdict away from a healthy app, so every ambiguous convention — Strapi's pluralised core routers, Next's parallel slots, a computed controller prefix — is LEFT OUT rather than guessed at. Measured: 27 convention-routed fixtures, 26 of them healthy, exactly 1 gap — in the one fixture built to have one.
- Symptom: ADR-081/082 built the organ that stops SPARDA certifying an app it did not
fully see, and shipped it inside
proveonly. Measured on the merged tree: of the seven commands that emit a verdict, six did not ask for it — includingapocalypse, whose exit code is the CI deploy gate and which the README pitches first, andbadge, the artifact users paste into a public README. - Why this is worse than not shipping it. A guarantee that holds on one command out of
seven is not a partial guarantee, it is a false one: the docs, the ADR and the release
notes all said "SPARDA no longer certifies what it has not seen", and that sentence was
true of
proveand false of the gate that actually blocks a deploy. - Fix: one shared entry point,
premiseFor(graph, report, {cwd, probe})+withPremiseGaps(report, premise)inpremise.js, called byapocalypse,badge,dossier,reviewandprove. Duplicating the four-line wiring per command is how one of them silently drifts; there is now a single code path. The opt-in boundary is preserved inside the helper: the runtime oracle still needs--probe, the boot-free convention oracle still runs unasked. - Deliberately NOT wired:
enforceandheal. Their verdict is about a DELTA — "did synthesizing this guard introduce anything", "did this replay regress" — not about the app. Feeding a premise gap in would make them refuse to act on any app that has one, which is backwards.proveremains the authority on the app-level word. - Sealed by a STRUCTURAL test, not a list:
tests/premise-wired-everywhere.test.jsscanssrc/commands/and fails if any module that grades a compiled graph does not callpremiseFor, with an explicit two-name allowlist. Pinning today's five commands would only re-prove the fix; pinning the rule is what stops the eighth command repeating it.
- Symptom:
badgeForhad noPREMISE_GAPbranch, so the state fell through to the default${critical + high} findings. An app whose route table was never verified — the strongest negative SPARDA can state — produced a badge reading "0 findings", on the one artifact designed to leave the repository and be believed by strangers. - Fix: an explicit branch,
premise not verified. The colour was already correct (grey, "we could not measure"), which is what hid the bug: the badge looked plausible. - Killing mutant restores the fall-through.
- Symptom:
reviewGraphscalledsurveyBlindspots(candidateGraph)with no report, so the entire skipped-surface channel — unparseable files, declaredUnknownHandlers, premise gaps — was invisible to the PR gate. A pull request that made a whole file unparseable, or that added a route SPARDA cannot bind, reviewed exactly like a PR that changed nothing. - Found while wiring E-083, not by looking for it: plumbing the premise through required plumbing the report, and the report was not there at all.
- Fix: the candidate's report is passed through and folded with any premise gaps. The BASE side stays graph-only, deliberately — the base's blind spots are not this PR's subject, only the direction of travel is.
- Symptom: the structural test sealing E-083 scanned
src/commands/— because the audit that produced it had counted "seven commands". Widening the scan to the whole repository found two graders nobody had counted, both unwired for exactly as long as the rule could not see them:src/server/stdio.js→proveApp, thesparda_proveMCP tool. This is the consumer that acts on the verdict word WITHOUT reading the code: an editing agent asks, getsPROVEN, and commits. It sharesverdictStatewith the CLI verbatim (that invariant held), but it never asked for the premise, so it could hand an agent a word the CLI itself would have refused to print.bench/repro.mjs, which writes a verdict intobench/route-proof.json— a committed evidence file the README points at as the reproducible proof.- and
scripts/corpus-oracle.mjs, the known hole this session set out to close: the only place SPARDA states a verdict over code it did not write.
- Root cause, same shape as E-083 one level up: the fix for "a guarantee wired into one consumer" was sealed by a rule that only looked where that bug had been found. A rule scoped to one directory has the same defect as a guarantee scoped to one command.
- Fix: the scan covers
src/,scripts/,bench/,tools/, and identifies a grader by the IMPORT (a module that importsverdictOf/badgeForfromapocalypse.jsand calls it), soapocalypse.jsis not mistaken for a consumer of itself and no future grader is excluded by name. Exemptions carry a reason and are machine-checked: a module exempted for stating no verdict word fails the suite the moment it starts stating one. - Killing mutants (2) remove the premise from the MCP tool and from the corpus oracle.
- Symptom:
@Post(/auth/google/genTokenByCode)in nocodb — a substitution-free template literal — never reached the graph, while its siblings written with quotes did. The route sets a refresh token and an auth cookie; it is a login endpoint. - Found BY the premise oracle, on the first corpus run that had one:
oracle-static.jsreads a no-substitutionTemplateLiteral(line ~357,expressions.length === 0→quasis.join('')) because the framework definitely serves that path;nestjs.jshas noTemplateLiteralhandling at all, so it dropped it. This is exactly the ADR-082 independence rule paying for itself — an oracle that reused the extractor's walk would have reproduced the omission on both sides of the diff and confirmed the bug. - Arithmetic that confirms the cause: nocodb has 16 backtick decorator paths; 15 carry
${…}substitutions, which the oracle deliberately leaves out (conservatism), and the 16th is this one — matching the single gap reported, exactly. - Status: OPEN, deliberately. The fix belongs in
nestjs.js(accept a substitution-freeTemplateLiteralwherever aStringLiteralis accepted) and is monotone in the safe direction — it can only ADD surface. It is not in this change because it moves corpus numbers a second time: shipping it here would blend an extractor precision change into the premise-wiring measurement, which is the "movement not understood" failure re-baselining exists to prevent. Next brick, with its own fixture, test and killing mutant.
- Symptom: every one of the 7 giants drifted on the first re-run, and the drift was uninterpretable — the snapshot pinned no corpus commit, so "dub 579 → 593 routes" could equally be SPARDA improving or dub landing 14 routes. An uninterpretable drift gets re-baselined by reflex, which is precisely how a regression becomes the new normal.
- Worse: the
nocodbentry pointed at the monorepo ROOT, which stopped detecting upstream (suggestAppDirsnow points atpackages/nocodb). The one entry carrying the repository's onlyPROVENon real code had become anERRORrow, and the tree thatPROVENwas measured on is not recoverable — no commit was ever recorded. - Fix: each entry carries
_pinned: {commit, date}. It is NOT diffed (a giant landing a PR is not SPARDA drifting) but IS printed beside every delta, so drift can be ATTRIBUTED before it is believed.tests/corpus-snapshot.test.jsrequires it on every entry. - Lesson: a regression net whose two inputs both move must record both, or it measures nothing and reassures anyway.
-
Symptom, measured on novu: 1003 guard steps, 71 verified (7 %). The 932 unverified were exactly four decorator names. The largest,
@RequireAuthentication()×340, is NestJS's official composition API:export function RequireAuthentication() { return applyDecorators(UseGuards(CommunityUserAuthGuard), ApiBearerAuth(…)); }
SPARDA matched the name against the auth regex, recorded an ASSERTED guard, and stopped:
guardScanresolves a CLASS, and this symbol is a FUNCTION. ThecanActivatetwo hops away — which extends@nestjs/passport'sAuthGuardand throwsUnauthorizedException— was never opened. The proof chain existed end to end; the first link was unwalkable. -
The A/B that named the cause. Same framework, immich: 459/459 verified. immich registers its guard globally (
{ provide: APP_GUARD, useClass: AuthGuard }), whichdetectGlobalDenyGuardalready handles. novu applies its guard per controller, so no global path existed and the decorator path was blocked. One framework, two idioms, a 7 % vs 100 % proof rate. -
Fix (ADR-084): resolve the decorator NAME to its declaration and read what it applies —
applyDecorators(UseGuards(X))→ X, then the existing class resolution proves X. -
Two traps inside the fix, both found by measurement not by reasoning:
- a constituent is imported by the module that DECLARED the composite, never by the
controller that used it. Resolving
CommunityUserAuthGuardagainst the controller's import map finds nothing, and the expansion degenerates into a rename: the first working version produced 340 guards and 0 proofs. - a monorepo import lands on a BARREL (
export * from './decorators') which declares nothing and records no named import. FollowingstarReexportsis the difference between reading a workspace package's decorators and seeing none of them.
- a constituent is imported by the module that DECLARED the composite, never by the
controller that used it. Resolving
-
Result: novu 1003 guards / 71 verified → 782 / 411. immich byte-identical (459/459) — the non-regression witness.
- Symptom:
@RequirePermissions(...)isSetMetadata(PERMISSIONS_KEY, perms)— a tag a guard reads ELSEWHERE. Its name matched the same auth-ish regex, so 221 novu routes carried a "guard" that gates nothing on its own. - Fix: the same resolution — read the definition, see that every branch is
SetMetadataand none applies a guard, stop counting it. Removing invented protection is SOUNDNESS Direction 2 in the safe direction: it can only ADD findings, never hide one. - THE TRAP, and it nearly shipped. A blanket "SetMetadata is not a guard" rule
deletes immich's entire auth model.
@Authenticated = () => applyDecorators( SetMetadata('authRoute', true))is the dominant Nest idiom: the tag is the route's OPT-IN to an app-wide guard that reads it. Under the blanket rule, 253 verified guards vanish and 253 unguarded routes are invented out of nothing. Caught bytests/nest-global-guard.test.jsgoing red — a test written for a different feature two sessions earlier. - The rule that is actually correct: drop a metadata-only decorator only when the app registers no global guard proven to deny. Where one exists, the tag IS protection.
- A second near-miss in the same function:
sawGuardSourcewas set on anyapplyDecoratorscall rather than on finding aUseGuardsinside it, so a metadata-only composite resolved to "no guards AND not metadata" and vanished from the chain entirely. A composite that resolves to nothing now keeps its original asserted reading — resolution may add understanding, never delete a gate.
- Found by ADR-084's resolution, not by looking for it.
@Acl()is nocodb's access control on every route. It is a hand-rolled decorator: a factory returning an INLINE arrow that callsSetMetadata(...)seven times andUseInterceptors(AclMiddleware)by direct invocation — never throughapplyDecorators, so the resolver reaches the arrow and stops. - Before: the name matched the auth-ish regex and the decorator counted as an asserted guard, silently. After: the unreadable branch is DECLARED at high risk, naming the decorator. The claim moves from "trusted because it is called Acl" to "this is nocodb's ACL layer and SPARDA cannot read it" — which is the whole point of the product.
- Cost: nocodb coverage 40.4 → 40.3 (the declaration enters the ledger, hence the coverage denominator). Its verdict was already PREMISE_GAP and does not move.
- Not fixed here. Reading a decorator that applies its effects by direct invocation
inside an arrow is a distinct shape from
applyDecorators, and it must ship with its own fixture and mutant rather than being bolted onto this one.
-
Symptom: twenty read
NOT_PROVENwith 14 findings, and the brief was "one rule stands between it and a clean verdict". The measurement inverted the brief:before after files parsed (of 6090) 33 128 routes 147 579 guards / verified 441 / 157 1868 / 583 findings (high) 14 (2) 65 (28) SPARDA was seeing 25 % of the app. "One rule" was an artefact of near-total blindness.
-
Cause:
CANDIDATE_RElisted decorator names —@(Controller|RestController|JsonController|Resolver|…). twenty registers 54 GraphQL resolvers as@MetadataResolver/@CoreResolver/@AdminResolverand exactly one as@Resolver. A house brand is the norm, not the exception. The class-admission check had the same defect (decoratorArg(cls.decorators, 'Resolver'), exact name). -
Why nothing complained: a file that is never OPENED produces no route, no skipped entry and no unknown handler. It is the one shape of loss that no self-reported coverage number can show — SOUNDNESS Direction 3, and the reason the premise oracle exists.
-
Fix: match the SUFFIX (
[A-Za-z]*Controller,[A-Za-z]*Resolver), exactly ascontrollerPrefixOfalready did for controllers (ADR-055 — recognise the protocol, not the brand). Deliberately NOT widened toMutation|Query|Subscription: those are also PARAMETER decorators (@Query('id') id: string) in ordinary REST controllers, and on twenty they buy one extra file out of 6090. -
Cost: 33 → 128 files parsed, 4.0 s for the whole 6090-file monorepo. No budget issue.
-
What it surfaced (the point):
POST /graphql/deleteCurrentWorkspace— a real saga hole. It cancels the customer's Stripe subscription (irreversible, outside any transaction) and then soft-deletes the workspace. If the write fails, the customer has no subscription and a live workspace. It sat inworkspace.resolver.tsunder@MetadataResolver, in a file SPARDA had never opened.
- Symptom: the two
highfindings holding twenty's verdict were reported againstPOST /— a route the app does not serve. - Cause:
httpDecoratorreadargs[0], found anArrayExpression, judged it "not a string literal" and fell back to the controller prefix. Nest serves every element of the array; twenty has four such controllers, including@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])— two live URLs from one decorator. - Fix: one route per element. Not
elements.find(isStringLiteral): reading the first path and dropping the rest loses a live endpoint in silence, which is the registration invariant (ADR-079) violated by the very change meant to honour it. A mixed array (one readable element, one not) routes the readable one and DECLARES the other at high risk. - Result: the findings now name their real routes —
POST /webhooks/stripe— instead of a URL that does not exist.
- Symptom, measured on twenty: 28 high findings across 14 routes, and ONE route
carried 12 of them.
POST /graphql/sendEmailresolves, through a provider-strategy DI graph, into Gmail / Microsoft / IMAP-SMTP / email-group senders. Each leaf is its own effect node, andIRREVERSIBLE_OBSERVABLEemitted one finding per node — so 43 % of the app's high findings were a single problem counted twelve times. - Why the existing collapse missed it:
collapseFloods(ADR-071) folds a rule that fires across MANY ROUTES into one codebase-wide summary. It has no notion of the same rule firing many times on ONE route, which is what a DI fan-out produces. - Fix: one finding per (route, rule). Severity is the strongest of the collapsed set,
every call is named in the message, and every node stays in
evidence. The remediation for this rule is per ROUTE — wrap the send and the write, or add an undo — never per leaf, so per leaf was never the honest unit. - It is a CONTRAST fix, not a suppression, and the distinction is what the tests pin: the same routes stay flagged at the same severity and the gate reads exactly as before. Verified on twenty — the 14 flagged routes before and after are identical; only the count changed, 28 → 14. nocodb 22 → 13.
- The rung that had to survive it: innate immunity (ADR-072). A generic external call
is an advisory
info; collapsing several of them may not manufacture ahigh. A route with both kinds reports once at the hard severity, because splitting them would put the same route on two lines saying the same thing twice. Killing mutant included.
- Symptom: PR #30 carried two commits; the merge landed only
ed41931(ADR-084).80591a9(ADR-085) stayed on the branch.mainstill read twenty at 147 routes / 2 high, and the suite at 1111 instead of 1119. - Cause: the merge was requested against a PR head GitHub had not yet refreshed after the push — the merge commit's parent is the OLD head.
- How it was caught: by re-measuring after the merge instead of trusting it.
git merge-base --is-ancestor 80591a9 origin/main→ no. A "merged" report is a claim like any other and deserves the same verification as a green test run. - Fix: cherry-picked onto the current
main, re-verified (1119 tests, twenty at 579 routes / 28 high) and merged as PR #31. - The habit worth keeping: after any merge, check that the commit you care about is an ancestor of the branch you merged into. It costs one command.
- Symptom: for four hours,
sparda-mcp@0.69.0on npm analysed a NestJS app with house decorator brands (@MetadataResolver,@CoreResolver) at a quarter of its size — 147 of 579 routes, with no signal that anything was missing. The repository did not have that bug: ADR-085 had removed it, and ADR-086 was in flight behind it. - Root cause: the release was cut BETWEEN two pull requests. The published tree carried ADR-084 and neither of the two after it. Nothing regressed; the wrong commit was chosen.
- Why nothing caught it:
prepublishOnlyranvitest run, and it was green — green at the commit that was published, correctly. A suite is a statement about a TREE. A release is a statement about a PUBLISHED ARTEFACT. Everything that distinguishes the two was unchecked: no CHANGELOG entry for 0.69.0, no tag pushed since v0.68.0 (two releases with nothing to check out), and no comparison of HEAD againstorigin/main. - Fix:
scripts/release-gate.mjs(ADR-087) onprepublishOnly, with the decisions split intoscripts/release-checks.mjsas pure functions so each one can be handed the exact state 0.69.0 was released from and required to refuse it. Five killing mutants, including one that putsprepublishOnlyback to a barevitest run. - Rule: a green suite licenses a COMMIT, never a RELEASE. Anything that can differ
between the tree you tested and the bytes you publish — which commit, which tag, which
manifest, which changelog entry — is unverified until something checks it. This is the
project's own contract (
"could not measure" ≠ "measured nothing wrong") applied one level above the code it was written for. - The trap inside the fix: the first version of the test asserted the gate had no escape
hatch by grepping its source for
--force. It failed immediately — the gate's own header says the word, in order to refuse it. A hatch is not a STRING, it is an INPUT: the assertion is now that the gate reads noprocess.argvand exactly one environment variable, one that can only make it stricter. A property worth testing is worth testing as behavior; grepping source for a word tests the wording.
- Symptom: novu read
PARTIALwith 0 findings, 52 db writes and 14.8 % coverage. The real number of writes its routes perform is 132. - Root cause:
@novu/daland@novu/application-genericresolve to their entry file, which is a barrel —export * from './repositories/…'sixty times, zero class declarations.classInModuleonly finds a class DECLARED in the module handed to it, soclassBundlereturned null. Measured: 1479 of novu's 2039 constructor-DI hops resolved to nothing,PinoLogger(307) and the repository classes at the top. - Why nothing complained: an unresolved DI hop leaves no trace — no effect, no skip, no
blind spot. A route whose behavior lives entirely behind the barrel therefore resolved to
ZERO behavior, and a route with zero behavior has nothing to flag: it read
SURFACEat coverageunknown(0/0), notblind. Same family as E-092 (a file never opened produces no route, no skip, no unknown handler) and E-091 one level down. - Fix:
resolveExportedClassinextract.js— the class twin ofresolveExportedFunction, which had crossed barrels since thelib/auth/index.tsera. Wired intoclassBundle, memoized per (module, class). Fixture + 2 killing mutants. - Measured: novu PARTIAL → NOT_PROVEN, writes 52 → 132, reads 792 → 1464, findings 0 → 4. twenty / immich / nocodb / ghostfolio byte-identical (immich is the control: same framework, no unbuilt workspace barrels).
- Rule: every lookup that crosses a module boundary must cross barrels too. A monorepo package entry point is a barrel and nothing else; a resolver that stops there stops at the edge of every workspace package. When one such lookup learns the trick, ask immediately which of its siblings did not — the function twin was right and the class twin was wrong for months, in the same file, forty lines apart.
- Symptom: while isolating E-097, cal.com drifted (
routes 175 → 177,coverage 93.6 → 94.3) — with the change and without it. The drift was not mine. - Root cause: cal.com's baseline was taken on 2026-07-22. ADR-084, ADR-085 and ADR-086
all landed after that, and every one of them changed how routes are counted. None of
those sessions had a cal.com clone, so the oracle printed
SKIP cal.com (not present under SPARDA_CORPUS)and the change shipped unmeasured on it. - This is not a bug in the oracle. Skipping an absent app and SAYING SO is correct —
the alternative is pretending to have measured it. The gap is that "SKIP" is a per-run
notice that nothing accumulates: seven apps skipped over four releases leave no standing
record that the snapshot no longer describes
main. - Fix: all six clonable giants pinned to their baselined commits and re-measured in one
run, so every number in
corpus.snapshot.jsonis attributable to a tree that exists. (dub could not be cloned in this environment and remains unmeasured — stated, not hidden.) - Rule: a skipped check is a debt, not a pass. Before a release, re-measure the WHOLE corpus, not the apps that happen to be on disk — and when an app cannot be measured at all, say which one and why, in the release record rather than in scrollback.
- Symptom: twenty reports 139 high blind spots. Every one of them that resolved through
a DI hop points at a line that has nothing to do with it. Worked example: the blind spot
reads
application-development.resolver.ts:21— animportstatement — while thefs_writeit describes isthis.fileStorageService.writeFile(…)atapplication-development.service.ts:202. Same shape on novu:activity.controller.ts:145carryingdriver: buildInteractionTrendChart, a symbol that exists only in the use case two files away. - Cause: the effect node's
loc.fileis the ENTRYPOINT's declaring file, whileloc.linecomes from the body actually scanned. The two halves of the location are taken from different files, so they are individually right and jointly meaningless. - Why it matters more than it looks: the blind-spot ledger is the honesty organ — it is what SPARDA offers INSTEAD of a proof. A ledger of 139 entries whose locations do not point at the code is not usable, so the honest answer degrades to an unusable one, which is how an honest tool gets ignored. It also made twenty's "139 high blind spots" read as a research problem rather than a reporting one.
- Not fixed here — recorded with the reproduction rather than half-fixed. The fix is to
carry the DECLARING file alongside the line through the resolver's merge, the same way
helpersalready recordssourceFile+sourceLine. - What twenty's 139 actually are, once located properly: 55
fs_writeand 41http_callwith computed targets, 34db_writewith an unresolved table (19 through a TypeORMqueryRunner), 7 blind mutations, 2 skipped surfaces. Unlike novu's, these are genuine residual imprecision — SPARDA saw the write and cannot name what it touches — not a resolution bug. Closing them is symbolic target resolution, a project, not a patch.
- Symptom:
SPARDA_CORPUS=… node scripts/corpus-oracle.mjsexited 1 onmainright after PR #35 merged: twentydbWrites813 → 812, nocodbcoverage47.7 → 47.6. Since the gate runs the oracle,npm run release:checkwould have blocked on it. - Cause: ADR-089 (
MiddlewareConsumer.forRoutes()) was measured onlujakob/nestjs-realworld-example-app— a real and well-chosen target, but not one of the seven corpus apps, five of which are NestJS. The oracle printsSKIPfor apps that are not cloned, so the change landed with its effect on the giants simply not taken. E-098 again, one release later: a skipped check is a debt, not a pass. - Verified, not assumed. The drift is a node-ordinal artefact, not a lost effect. Guard
steps are PREPENDED to the chain, so every later node's ordinal shifts and two resolution
paths that used to produce distinct ids now collide. Three measurements, same clone, same
pinned commit,
nestjs.jspermuted:- 509 distinct
file:linewrite locations before AND after — no write left the graph; - 476 routes carry writes before AND after, with identical per-route counts — no route lost a write;
- 31 findings both ways — nothing stopped being flagged.
Only the multiplicity at
auth.resolver.ts:132(7 → 5) and:194(3 → 4) moved.
- 509 distinct
- Fix: snapshot re-baselined with that verification as the justification, not with a shrug.
- Rule: a change to an EXTRACTOR is measured on the corpus before it merges, even when a smaller repository reproduces the bug more clearly. The small app proves the fix; the corpus proves the absence of collateral.
- Symptom:
npm publishon Windows died in the gate atsuite greenwithspawnSync npx ENOENT; namingnpx.cmdexplicitly then gaveEINVAL. - Cause:
npxon Windows isnpx.cmd, a batch wrapper.execFileSyncstarts real executables, not shell scripts, so it cannot launch it either way.gitandnodewere never affected — they are real binaries on every platform. - First fix, and why it was narrowed:
shell: process.platform === 'win32'on the sharedrun()helper. It works, but it puts EVERY call throughcmd.exe, where each argument is re-parsed — includingnpm view ${pkg.name}@${version}, whose two halves come from a file in the tree. A blanket shell puts the repo's own JSON on a command line inside the one script whose entire job is to be trustworthy. - Final fix:
npxis gone. The suite and the mutation harness run throughprocess.execPath— an absolute path to the same node already executing the gate, no shell, no PATH lookup. A shell remains fornpmalone (NEEDS_SHELL), which genuinely needs one on Windows. Both pinned by tests. - Bonus the platform bug exposed:
npx vitestresolves whatever npx finds. The gate could green a release against a different test runner than the lockfile pins. It now runsnode_modules/vitest/vitest.mjs— the vitest this repo installed. - Rule: reach for a shell at the narrowest scope that fixes the problem. "It works now" and "it is still the same command" are different claims, and a gate has to make both.
- Honest limit: this was verified on Linux. The Windows path is argued from the platform's behaviour, not measured here — the next publish from Windows is the real test.
- Symptom: after E-101,
npm publishon Windows still failed. The npm debug log showed onlycommand failed … exit 1fornode scripts/release-gate.mjs— no indication of which check failed, because npm's log file never contains the child's output. - Cause: E-101 removed
npxfromrelease-gate.mjsand stopped there.tests/mutation/run.mjs— which the gate runs as itsmutants deadstep — still spawnedexecFileSync('npx', ['vitest', …]). Same ENOENT, one level down. A step is only as portable as what it spawns, and the fix was applied to the caller instead of to the family. - Fix: the harness runs
process.execPathwithnode_modules/vitest/vitest.mjs, exactly as the gate now does. A test asserts that nothing the gate runs containsnpxeither. - The second, larger bug this exposed. A blocked publish was unreadable after the fact.
The gate printed its verdict, npm captured it, and the log the operator keeps had none of it
— so from where the user stands, the gate said "no" and gave no reason. That is the exact
shape of silence this project exists to refuse, performed by the gate on its own operator.
The verdict is now also WRITTEN to
.sparda-release-gate.log(gitignored, or the next run would fail its own "working tree is clean" check). - Rule: when a fix is about the ENVIRONMENT rather than the logic, grep for the pattern across the repo before calling it done — the platform does not care which file the call is in. And any gate that can refuse must leave its reasons somewhere that outlives the terminal.
- Symptom: every gate run on Windows printed
DEP0190 DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated. - Cause: the
npm view ${pkg.name}@${version}check. npm isnpm.cmdon Windows, so it neededshell: true— and under a shell, Node concatenates arguments rather than escaping them. Both halves of that string are read frompackage.json, a file in the tree. The one script whose entire job is to be trustworthy was assembling a command line out of the repository it was judging. Node was right to complain; E-101 had narrowed the shell to this single call and treated that as sufficient, when the call itself was the problem. - Fix: the question is answered over HTTP.
HEAD https://registry.npmjs.org/<name>/<version>— 200 published, 404 absent, anything else UNMEASURED.encodeURIComponenton the name, so a scoped package cannot split the path and make the registry answer about something else. - What it removed, beyond the warning: the gate now spawns no shell on any platform, and
no longer depends on
npmbeing resolvable at all.gitandnodeare the only two programs it starts, and both are real executables everywhere. Pinned by a test that greps forshell:and fornpmand requires neither. - Rule: when a platform forces a shell, ask whether the command is needed at all. Narrowing the blast radius is the second-best answer; not spawning is the first. A check that is really an HTTP question should be an HTTP question.
-
Found by: an independent agent auditing
docs/BRIEF-FOR-A-STRONGER-MIND.md. Its code was lost to a usage limit before it could be pushed; the reproduction below is ours, and it confirms the claim exactly. -
Symptom, measured on our own fixtures: of the 8 fixtures that read
PROVEN, 7 havepremise.available === false— the oracle never ran. Reason on all seven:runtime oracle not requested (--probe).ubg-proven express premise NOT measured → PROVEN ubg-cqrs-command express premise NOT measured → PROVEN ubg-typelock-verified express premise NOT measured → PROVEN ubg-object-scope express premise NOT measured → PROVEN ubg-ownership-assert express premise NOT measured → PROVEN ubg-express-weird-entry express premise NOT measured → PROVEN ubg-fastapi-deep fastapi premise NOT measured → PROVEN -
Mechanism.
premiseForcorrectly reports{ available: false, gaps: [] }when no oracle ran — the label is honest. But the next line erases the distinction:export function withPremiseGaps(report, premise) { if (!premise?.gaps?.length) return report; // available:false ⇒ gaps:[] ⇒ report UNCHANGED
An oracle that did not run and an oracle that ran and found nothing produce a byte-identical downstream state. The verdict is then computed as if Direction 3 had been verified. This is rule 7 of the contract — "could not measure ≠ measured nothing wrong" — violated in the single highest-stakes place SPARDA has: the word
PROVEN. -
Why nobody noticed. All seven corpus giants are
CONVENTION_ROUTED(nestjs/nextjs), so their premise IS measured on every run —premiseOracle: "convention"is pinned in the snapshot. The hole is exactly in the frameworks the corpus does not contain and the fixtures do: Express and FastAPI, i.e. the most common backends SPARDA is pointed at. The regression net and the field are blind in complementary places. -
NOT a bug in the labelling, and not fixed by touching
premise.jsalone. The independent audit reported the same word leaking throughreview,dossier,enforce,badge,polarity,immunity,speculateandgenome. Any fix must reach every surface that pronounces a verdict, or the branch is green while the word still escapes (ADR-083's rule, applied to a new axis). -
The shape of the fix, as proposed and as we would keep it: asymmetric and non-blocking. An UNMEASURED premise degrades
PROVEN→PARTIALonly — never a gate failure, because PARTIAL already means "proved what was seen" and that is the honest word. A MEASURED premise with real gaps staysPREMISE_GAPand stays blocking. OpenAPI keeps an explicit "declared premise" status, since there the specification analysed is the subject of the proof. -
Expected blast radius, stated before anyone starts: those 7 fixtures move
PROVEN → PARTIAL, so every test assertingPROVENon an Express/FastAPI fixture without--probeturns red. That is the fix working, not the fix breaking. Corpus verdicts should be unchanged (all seven are convention-routed and measured) — and that must be shown by a full A/B, not assumed. -
Rule: an honest LABEL is not an honest SYSTEM.
available:falsewas reported correctly and then consumed by a line that could not tell it fromavailable:true, gaps:0. When a distinction matters, follow it to every consumer — the place it gets flattened is where the lie is told.
Fixed in ADR-091. The premise now carries a basis — measured / declared (OpenAPI) /
unmeasured — and unmeasured is a PARTIAL rung in verdictOf. Measured after: fixtures
reading PROVEN 8 → 1 (that one measured), PROVEN over an unmeasured premise 7 → 0,
corpus 0 drift. enforce announces PARTIAL (ENFORCED) when nothing measured the premise,
while its rollback decision stays premise-blind — the edit is licensed by the delta, the word
by the oracle, and conflating the two is what produced this entry.
- Symptom: none, and that is the entry. Four surfaces reported a positive headline over a
measurement that never happened —
falsifyscore: 1with zero controls,gateok: truewhile abstaining,speculateandimmunizeprinting✓ PROVENfrom a frozen capsule whose premise nobody measured. - How they were found. Not by suspicion. By taking rule 7 — "could not measure ≠ measured nothing wrong" — and enumerating every place a measurement can be ABSENT, then checking whether the absence stayed distinguishable downstream. Four commands, one hour. The same method that found E-104, applied to the rule rather than to a hunch.
- The shape, which is the real finding. In every case the honest field was PRESENT —
note,abstained,(by lookup),available: false. The admission was placed BESIDE the headline instead of INSIDE it, and the headline is what a reader acts on, a dashboard graphs, and a CI job branches on. - A test had CODIFIED one of them.
tests/falsify.test.jscarried a case literally named "an app with no protected mutation routes has nothing to falsify (vacuously 1)", assertingscore === 1. The suite was defending the bug. That is how long a lie survives once it is written down as an expectation. - Fix: ADR-092 —
nullin the headline, capsules carrying their basis of measurement,=== nullchecked FIRST so a falsy collapse cannot re-tell the lie in the safe direction. - Rule: when you find one instance of a contract violation, audit the CONTRACT, not the
neighbourhood. A bug found by suspicion gives you one bug; a bug found by enumerating the
rule gives you the family — and the enumeration is cheap enough to be routine. It is now
mechanized in
tests/unmeasured-is-not-a-pass.test.jsso it is a check rather than a memory.
- Symptom: none, again — and this time not even a wrong output.
sparda immunizeon any Express app printed✓ PROVENexactly as it had before ADR-092, because the three-stateproventhe ADR introduced was unreachable. All four call sites insrc/commands/calledbuildCapsule(canonical)bare, sopremiseBasiswas always its defaultnull, so thepremiseUnmeasuredbranch never fired and the◑ UNMEASURED PREMISEmessageimmunizeprints could not be produced by any input. proveanddossierhad the value in scope.provecomputes the premise, uses it for the verdict word two lines above, and then builds the capsule without it.dossiercallsbuildCapsulethree lines before it computes the premise at all — ordering alone hid it.- The green row.
tests/unmeasured-is-not-a-pass.test.jsassertedbuildCapsule(g, { premiseBasis: 'unmeasured' }).proven === nulland passed from the day it was written. It was true. It was also useless: constructing the UNMEASURED state by hand proves the field can hold it, and says nothing about whether any caller ever puts it there. A registry of honest states, over a dead wire. - How it was found. By continuing the same audit onto the surfaces E-105 had not covered
(
stitch,mirror,timeless,heal,genome) — and reading, for each one, not "does it lie" but "which call path produces its UNMEASURED state".genomehad none: it grades a compiled graph, signs the result with Ed25519 and merges it into a file other people pull, and it had never calledpremiseForat all. Following that back found the other three. - Why the ADR-083 rule did not catch it. It did its job and the job was too narrow. The
structural guard scans for consumers of
verdictOf/badgeFor.buildCapsuleis a second grader — it turns a compiled graph intoproven, the same claim in the artifact that travels — and the rule could not see it. The first version of that rule was scoped to a directory and the amendment widened it to the repo; this one was scoped to a function name. Both times the gap was exactly the size of the scope. - A second bug, found while fixing the first. ADR-092 wrote
proven: premiseUnmeasured ? null : …, which blanks a genuinefalsetonull. The premise bounds the route set; a route missing from the graph cannot rescue one that is in it and exposed, so a NOT-PROVEN verdict needs no premise. Blanking it turns "this app has an unguarded mutation" into "we don't know" — the same lie, pointed the other way. Only the positive is withheld now. - And a third:
immunizegated CI withif (!capsule.proven …).nullis falsy, so the moment the fix worked it would have failed builds because no oracle was available — precisely whatpremise.jsforbids in those words.=== falsenow. - Fix: ADR-093.
basisFrom(premise)is the single source of the basis (nine hand-rolled copies of the same ternary are gone, and its default is'unmeasured'so a caller who forgets falls toward the weaker word); all four capsule call sites pass it;immunizeandgenomecallpremiseFor; the structural rule now names the property (turns a graph into a claim) withGRADERSas the list to extend. - Rule: a test that constructs the honest state by hand is half a test. Every row in the registry owes two assertions — EXPRESSIBLE (the field can hold it) and REACHABLE (a real call path produces it). Without the second, a green suite certifies a wire that is not connected, which is the same failure this project exists to refuse, committed by its own regression net.
- Symptom:
✓ v0.71.0 exists and points at HEAD, over a tag no one else could fetch. The push had been refused by the environment; the gate never asked. Every word it printed was true, and it certified nothing. - Why it read green. The check was
git rev-list -n 1 v<version>— a purely LOCAL question. "The tag exists" and "the tag is published" are different claims, and only the second one is what a release means. That is the v0.69.0 gap again, one artefact over: the local view and the published view diverging with nothing looking at the seam. - Fix: ADR-094 —
remoteAtfromgit ls-remote --tags origin <tag>, compared to the local commit, with the^{}dereferenced line preferred so an annotated tag compares commit to commit. - And the fix had the rule-13 bug in it (fixed in ADR-095):
ls-remotefailing andls-remotereturning nothing were collapsed into one message, so an unreachable network read as "your tag is not pushed" — sending the operator to hunt for a tag that was already there. Both block; only one of them is a measurement. - Rule: a check that reads only local state can only certify local state. Before trusting one, ask which machine's answer it is.
-
Symptom: none, and no test could have had one.
src/ubg/apocalypse.jssat onmaincarryingif (false) routes.push({ id: ep.id, ... }); // guarded, but by trust onlyinside
assertedOnlyMutationRoutes. With that line dead,assertedMutationsis always 0, the PARTIAL rung never fires, and a route guarded only by an UNVERIFIED guard readsPROVEN— the exact false-PROVEN generator ADR-070 exists to remove. It arrived inside a commit whose stated scope was release automation, and was caught by a human reading the diff. -
Nobody wrote it.
if (false)is BYTE-FOR-BYTE thereplof a mutant that has lived intests/mutation/run.mjssince ADR-070 (find: 'if (!guards.some((n) => n.meta.verified === true))'). The same commit ALSO adds a new mutant to that harness — so the harness was being run in that session. This is a residue, not a decision, and that distinction is the whole entry: you do not fix it by telling anyone to be more careful. -
Mechanism. The harness mutates a file, runs one test, restores in a
finally.finallycovers a thrown error; it does not cover a killed process. Ctrl-C, a CI timeout, an OOM — the mutated file stays on disk and the nextgit add -Acommits it. -
Why the suite could not see it. A mutant that SURVIVES is, by construction, a mutation no test detects. The suite was green because the mutation was one the suite is blind to. The only thing that would have caught it is
npm run mutation— which reports⚠ target moved → SURVIVED— and that takes ten minutes and is not what anyone runs before agit commit -a. -
Reproduced during the fix, by accident, which is the best evidence available. A SIGKILL mid-run left
src/ubg/llm-resolve.jsmutated with signal handlers already installed: the harness lives inside a BLOCKINGexecFileSync, so a signal cannot reach JS until the child returns and a SIGKILL never reaches it at all. The new suite-level guard named the exact mutant on its first run. -
Fix: ADR-095 — a journal written before the file is touched and replayed by the next run (recovery that does not depend on the dying process), signal handlers for the polite exits, and
tests/no-mutant-left-behind.test.jsin the ordinary suite, which asks the question the harness cannot ask itself: is a mutation sitting in the tree right now? -
Rule: a cleanup that only runs when the program is healthy is not a cleanup. Write the intent to durable storage before the risky action and heal from it on the next start. And when a tool can leave the repository in a wrong state, detecting that state belongs in the fast suite, not in the tool's own slow mode.
- Symptom:
sparda prove --probeon SPARDA's OWN bundleddemo-app— 5 routes, Express, serving traffic on:3456(verified with curl) — printed--probe: timeout waiting for Express routes; using static floor, leftpremise.basis = unmeasured, and reported the reason as "the app did not boot, or exposes none". The app had booted perfectly. - Root cause, measured not reasoned. The shim intercepts
require('express')by patchingModule._load. On Node 22 an ESMimport express from 'express'never goes throughModule._load. Minimal experiment: with aModule._loadhook installed via--import, an ESM import of express prints nothing; the same hook via--requirewithrequire('express')fires immediately. So the shim installed itself and then intercepted nothing, forever. - The confident comment was the bug.
express-shim-esm.mjsasserted the opposite in its own header: "most Express apps — even those using ES module syntax — still resolve 'express' through the CJS loader … So patching Module._load … correctly intercepts all express requires regardless of whether the entry is .mjs or .js with type:module." False on Node 22 (it may have held on an older Node — the ESM→CJS translation path changed; not verified). Exactly whatBRIEF-FOR-A-BREAKER.mdsays to expect: the bug lives where the confident comment is. - Why it survived. The only live-probe test (
tests/probe.test.js) wrote its fixture withconst express = require('express'). The ESM path had never been exercised once. One fixture, one module system, a whole capability untested. - What it cost, in the product's own terms. ESM is the modern default for Express, and
Express is not convention-routed — the runtime probe is its ONLY oracle. So since ADR-091, an
ESM Express app could never reach
PROVEN, and the user was told to go debug their own app. - Aggravating:
probe.jsdidchild.stderr?.on('data', () => {}). The target's own boot error is written nowhere else, so there was no way to find out why the probe saw nothing. That turned a 2-minute diagnosis into 20. - Fix: ADR-097. express is CJS, so a CJS module reached through the ESM bridge comes from the
SAME
require.cache: the shim now requires express ITSELF, resolved from the entry file's root, before the app runs — the app's laterimportreceives the already-patched instance (verified: a marker set before the import is visible after it). Plus four named probe states, so "SPARDA could not look" stops printing as "your app is broken". - Found by: the first smoke test of the lab that was being built ON this oracle. The lab
would have reported 20 ×
did-not-bootand someone would have concluded "real apps don't boot". The cheapest target found the defect the expensive run would have hidden. - Rule: a capability tested through one module system is tested for one module system.
Where a mechanism depends on the loader (CJS vs ESM,
.jsvs.mjsvs"type": "module"), the fixture matrix IS the test — one spelling proves nothing about the others.
- Symptom: on
demo-app,sparda prove --probereported 3 premise gaps, of which two were false:GET /:idandPOST /for a router mounted at/api/users. The compiler had them right —/api/users/:idand/api/users. OnlyGET /v2/meta, the deliberately dynamic path the static parser refuses to guess, was a real gap. - Cause: the shim emitted a route the moment it was registered.
usersRouter.get('/:id')runs at import time;app.use('/api/users', usersRouter)runs later. At registration the mount point does not exist yet, so no amount of care at that moment could have produced the right path. Thenreconcilediffed the bare path against the compiler's full path and, finding no match, reported a route the app serves that the compiler never saw. - Why it went unnoticed for so long. A false gap is in the SAFE direction for a verdict — it
can only make SPARDA refuse to claim
PROVEN, never grant it. Nothing in the product got worse in a way a test would catch. It is only wrong for a consumer that reads gaps as FINDINGS, and the first such consumer was about to be an overnight lab whose highest-priority rule is exactly "a measured premise gap". Every real Express app mounts routers, so its best signal would also have been its noisiest. - Fix: ADR-098. Routes are STAGED with the object they were registered on;
userecords mount edges ({parent, child, path}); full paths are resolved once the app is wired, atlistenor on idle. A router mounted twice yields both paths — that is not a duplicate, the framework really serves it at both. Measured: demo-app 3 gaps → 1, and the one left is the true one. - Two bugs of my own, in the same change, both caught by running it:
- renaming
record→stageleftmodule.exports = { record, sendDone }referencing a name that no longer existed, so the shim threw at load and the probe silently observed zero routes. The E-109 stderr fix is what surfaced it. - settling on the child's
exitraces the delivery of its last stderr chunk — the test passed alone and failed in the full parallel suite. Settling onclose(stdio drained) is deterministic; verified by three consecutive full runs.
- renaming
- Rule: a fact that is not known yet cannot be recorded correctly, only recorded early. When an observation depends on state established later, buffer it and resolve at the point the state is complete. And a false positive in the safe direction is still a defect — it is simply one whose bill is paid by a consumer you have not written yet.
- Symptom:
## [0.71.1]listed the two probe fixes (E-109, E-110).v0.71.1had already been tagged at29d8169and published to npm; both fixes landed atab0fea0and5d53221, AFTER the tag. So anyone reading the changelog for the version they had installed was promised a runtime oracle that works on ESM Express — which that version does not have. - Cause, mine. I checked "is this version published?" at the start of the work (npm answered 404, so 0.71.1 was the open entry) and appended to it hours later without re-checking. It was published in between. A precondition verified once is not a precondition; it is a memory.
- Shape: exactly v0.69.0's (E-096) — the published artefact and the written record diverge — reached from the opposite direction. There the record was missing; here it over-promised. Both leave a reader unable to know what they are running.
- Fix: the three bullets moved to a
## [0.71.2]entry, with a note in the entry saying they were briefly listed under 0.71.1 and are not in it. Manifests bumped to 0.71.2. The correction is stated rather than quietly rewritten, because a changelog that edits its own history is worth less than one that admits a mistake. - What the gate cannot catch, and why.
changelogChecksverifies a## [<version>]heading EXISTS for the version being released. It cannot verify the heading is TRUE — that every bullet under it is in those bytes. That is not a gap to close with a check: prose has no oracle. What is enforceable, and is the rule below. - Rule: write the CHANGELOG entry for the version you are about to cut, never for the one
you last cut. The moment a tag is pushed, its entry is FROZEN — a later fix opens a new
heading, even for a one-line change. And re-read
npm view <pkg> versionat the moment you write, not at the start of the session.
-
Symptom:
v0.71.2was created withgit tag -a, pushed, and visible on origin. The gate answeredlocal v0.71.2 is not on origin — push it. Every annotated tag would have failed the same way, forever. -
Cause, and it is one character. An annotated tag is TWO objects: the tag object, and the commit it wraps.
git rev-list -n1 <tag>gives the commit;git rev-parse <tag>gives the tag object. Whatls-remoteanswers with depends on how you ask:git ls-remote --tags origin v0.71.2 → 10bc36e refs/tags/v0.71.2 ← tag object ONLY git ls-remote --tags origin → 10bc36e refs/tags/v0.71.2 90627b7 refs/tags/v0.71.2^{} ← the commitFiltering by the exact ref name DROPS the peeled line, because
v0.71.2^{}does not match the patternv0.71.2. The gate asked the first way, got the tag object, and compared it to the commit.v0.71.1had passed only because it happened to be a LIGHTWEIGHT tag, where the two shas are identical — the check had never once been exercised on the shape the playbook tells people to use (git tag -a). -
Diagnosis credit, and a correction. The agent that hit it reported the conclusion correctly ("the comparison always fails for annotated tags") but the mechanism wrongly ("ls-remote does not return the
^{}line") — and its own pasted evidence showed both lines, because it had run the command WITHOUT the pattern. Right answer, wrong reason: the difference is the filter. -
Both proposed fixes were refused. Adding
*to the query treats the symptom. Re-creating the tag as lightweight changes the ARTEFACT to fit a broken CHECK — backwards, and the playbook says so in those words: if a check is wrong, fix the check. Annotated tags carry author, date and message; they are the right thing to publish. -
Fix: ADR-099.
tagCheckstakesatObjectas well asatand accepts the remote sha if it equals EITHER. That depends on no peeling behaviour at all, works for annotated and lightweight, filtered or unfiltered, on any platform. The glob is kept as belt, with the ref name anchored sov0.71.2*cannot matchv0.71.20. -
Rule: a check on a git object must name WHICH object. "The tag" is ambiguous for an annotated tag, and a comparison between two different kinds of sha is not a comparison. When a check has only ever run against one shape of its input, it has been tested for that shape only (the same lesson as E-109, one domain over).
- Symptom:
tests/gossip.test.jsfailed the release gate withENOENTfromfs.cpSynconexpress-demo/.sparda/backup/.... It passed on re-run. It appeared on Windows during a release, never in CI. - Cause, deterministic once seen.
tests/sparda.test.jsruns its injection round-trip IN the shared fixture directory — deliberately, because it provesremoverestores the entry file byte-for-byte in a real tree — sotests/fixtures/express-demo/.sparda/backup/exists for the length of that test and is then deleted. Its cleanup is correct. Butgossip.test.jsis a DIFFERENT file, so vitest runs it in a different worker, in parallel, and it copies that same fixture recursively.cpSyncenumerates the tree and then reads each entry; a file that vanishes between those two moments is anENOENT. Machine speed decides who wins. - Neither test is wrong. That is what makes it worth an entry: there is no culprit to blame, only a coupling to remove.
- Fix:
tests/helpers/copy-fixture.js..sparda/,node_modules/and.git/are GENERATED residue, never fixture input, so nothing that copies a fixture copies them — acpSyncfilter that returns false for a directory never enumerates its subtree at all, which is what makes it immune to whatever another worker is doing in there. Applied to all ten copy sites across nine files, so the CLASS is closed rather than the instance. - Two self-inflicted follow-ons, both caught immediately:
tests/nextjs.test.jsalready had a LOCAL zero-argcopyFixture(), so the mechanical rewrite made it call itself (renamed tofreshFixture); and moving a line inrelease-checks.mjsmoved a mutant's target, whichno-mutant-left-behindreported in the fast suite instead of ten minutes into the mutation run. The guard from E-108 paid for itself here. - Rule: a flaky test is a coupling, not a probability. Re-running until green hides which two things share state. Ask what the test READS that something else WRITES — and fix it on the reader, because that holds no matter who writes next.