Skip to content

build(deps): NestJS 12 — the framework is pure ESM, so apps/api becomes ESM - #113

Merged
Arcanada merged 19 commits into
mainfrom
feat/nestjs-12
Sep 13, 2026
Merged

Arcanada merged 19 commits into
mainfrom
feat/nestjs-12

Conversation

@Arcanada

@Arcanada Arcanada commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Closes #59, closes #102 — once the two blockers below clear. The PR is open with a red CI and the remainder written down, not hidden.

Why the two Dependabot pull requests could never go green

@nestjs/core@12 ships "type": "module" and no CommonJS entry:

$ npm view @nestjs/[email protected] type
module

They are not version bumps. Bumping the packages without switching the module system leaves an application Node refuses to load — which is exactly why both sat open. Ten @nestjs/* packages move to ^12 here.

What the switch cost

Change Count
Relative imports given the extension nodenext requires 504 in 133 files
Test files importing jest from @jest/globals 25
Files where a CommonJS name had to be renamed 27
Harness scripts converted CommonJS → ESM 3
require() of local modules turned into imports 39

Two findings worth keeping

jest is imported for the runtime, typed from @types/jest. ESM has no injected globals, so the import is required — verified by deleting it and watching the suite fail. But @jest/globals ships a stricter generic whose bare jest.fn() infers never, which reds 416 otherwise-correct lines across the 339 existing call sites. Value from one, type from the other.

Never declare a CommonJS name in an ESM module. const require = … and const __dirname = … make TypeScript emit a CommonJS module; the failure surfaces as ReferenceError: exports is not defined pointing at an unrelated line. It cost two debugging rounds. The bindings are nodeRequire / thisDir / scriptDir.

Verification

Check Result
tsc --noEmit 0
pnpm lint 0 errors, 3 warnings — the same 3 as main
pnpm build 0
Suite in CI (with PostgreSQL) 1329 passed of 1332

Progress across CI runs: 10 failing tests → 3; locally 170 failing on main → 125 on this branch. assembly.generator now passes where main fails it.

The two blockers, measured and NOT resolved

1. @nestjs/throttler does not support NestJS 12 — external, not fixable here.

6.5.0 is the latest release; there is no ESM build and its peer range is ^7 || ^8 || ^9 || ^10 || ^11 — no ^12. It is CommonJS and require()s @nestjs/common, which is now ESM. Node itself handles this fine (import('@nestjs/throttler') resolves and exports load), but jest's resolver refuses: Must use import to load ES Module. That single package fails 4 e2e suites, and the resulting 151 import after the Jest environment has been torn down messages spill onto whichever suites run alongside. transformIgnorePatterns + a .js transform were tried and do not help — transformation cannot undo what the dependency's own manifest declares.

2. mutation-results.json is stale — needs a run this environment cannot give.

3 tests fail on it. 52 mutant hash pairs, the binding aggregate and supplementalGit all shift because src/ moved. The harness refuses to regenerate against a red baseline — correctly — and the baseline is red locally only because there is no PostgreSQL. regenerate-derived-artefacts.yml can do it but is gated on dependabot/* branches, so it will not fire here. The harness itself is verified working: --list and --verify-structure both execute and report truthfully.

Everything else in the impact set of this change is green. These two are stated as open, with what each needs.

…es ESM

GOVERNANCE-CHANGE: regenerate-derived-artefacts.yml invokes the mutation harness
by path. NestJS 12 is pure ESM, so apps/api declares "type": "module", which makes
Node read that CommonJS script as ESM and refuse it; Node 24 removed
--experimental-default-type, so the .cjs extension is the only remaining lever and
the workflow must name the moved path. No policy, permission or trigger changes.

Closes #59, closes #102.

`@nestjs/core@12` ships `"type": "module"` and no CommonJS entry (measured:
`npm view @nestjs/[email protected] type` = module). The two Dependabot pull requests
could therefore never go green on their own — bumping the packages without
switching the module system leaves an application Node refuses to load. Ten
@nestjs/* packages move to ^12 here; @nestjs/throttler stays on 6.5.0, which is
its own current line and not a laggard.

What the switch actually costs, measured rather than assumed:

  504 relative imports in 133 files gain the extension `nodenext` requires.
  The one `require()` in src reads package.json with readFileSync instead.
  The two JSON imports are read from disk: `with { type: 'json' }` is ESM-only
  syntax, and a source file that must also compile under the test transpile
  cannot carry it.

The test suite runs on jest in native ESM (`--experimental-vm-modules`). The
CommonJS half-measure was tried first and is internally contradictory: one
source file cannot be ESM for the build and CommonJS for the tests while it
contains ESM-only syntax. Two consequences worth naming:

  `jest` is imported from @jest/globals for the RUNTIME — ESM has no injected
  globals — but typed from @types/jest, which the 339 existing jest.fn() call
  sites are written against. @jest/globals ships a stricter generic whose bare
  jest.fn() infers `never` and would red 416 otherwise-correct lines.

  NEVER declare a CommonJS name in an ESM module. `const require = …` and
  `const __dirname = …` make TypeScript emit a CommonJS module, and the failure
  surfaces as `ReferenceError: exports is not defined` pointing at an unrelated
  line. Cost me two debugging rounds. The bindings are `nodeRequire`/`thisDir`.

Verification. `pnpm install --frozen-lockfile` clean, `pnpm lint` 0 errors,
`pnpm build` 0, `tsc --noEmit` 0. In CI, with the PostgreSQL service: 1322 tests
pass, 10 fail across 6 suites. Those ten are NOT dismissed — they are two real
defects this change introduces, recorded here rather than hidden:

  4 e2e suites raise `import after the Jest environment has been torn down`:
  the Nest application is not closed before the suite ends, and under ESM its
  deferred imports resolve after teardown. Was invisible under CommonJS.

  assembly.generator + mutation-evidence: the .cjs harness scripts cannot
  `require()` the now-ESM dist build. The scripts need to become ESM themselves
  (13 require, 11 __dirname, 2 module.exports) rather than stay CommonJS.

Both are in the impact set of this change and neither is measured as fixed.
…rting after teardown

CI on the first push surfaced two defects a laptop without PostgreSQL could not:

1. Four e2e suites raised 'import after the Jest environment has been torn down'
   — 137 of the 162 messages came from app-module-boot, whose two `await import`
   calls inside the test body were still resolving when jest tore the environment
   down; the noise landed on whichever suites ran alongside it. Both are static
   imports now.

2. Renaming the three harness scripts to .cjs fixed how NODE loads them and broke
   what they can load: dist/ is ESM under "type": "module", and a CommonJS
   script cannot require() an ES module. Holding them in CommonJS was the wrong
   direction. They are ESM now (13 require, 11 __dirname, 2 module.exports), so
   the extension returns to .js — which also means mutation-results.json and the
   admission receipts keep the paths they already record.

Two traps worth naming, both mine:
  `__filename` was missed by the first pass, which only looked for `__dirname`.
  The harness writes its own path into the evidence, so it failed at
  canonicalInvocation with a ReferenceError, and the caller saw only status 1.
  `nodeRequire('./mutation-harness.js')` in mutation-evidence (6 sites) and
  assembly.f10 (2 sites) had to become imports: 'Must use import to load ES Module'.

Measured: tsc 0, lint 3 warnings / 0 errors — the SAME 3 as unmodified main.
Suite 1183 passed, 9 suites failing, and those nine are a subset of the ten that
fail on main here. assembly.generator now passes where main fails it.

NOT MEASURED, and it is the one open item: mutation-results.json is stale against
this change — 52 mutant hash pairs, the binding aggregate and supplementalGit all
shift because src/ moved. Regenerating it is a full 85-mutant run. The harness
itself is verified working (--list, --verify-structure both execute and report
truthfully); it is its recorded evidence that has not been refreshed.
…uilt app

Found by loading dist/app.module.js directly rather than trusting the suite:

  ENOENT … dist/migration/status-map/status-map-v1-rev2.json

`tsc` copies no JSON. While the test transpile was still CommonJS the `with
{ type: 'json' }` attribute would not compile, so I switched those two imports to
readFileSync — which moved the lookup to runtime, next to the built module, where
the file does not exist. The previous CommonJS build inlined the data through the
import; this build shipped a boot crash instead.

The test transpile is ESM now, so the attribute is legal on both paths and the
import goes back. Verified the way the defect was found: tsc 0, build 0, and
`import('./dist/app.module.js')` resolves to AppModule.

That same probe settles the throttler question: the full AppModule — ThrottlerModule
included — loads in the ESM runtime without complaint. @nestjs/[email protected] is
CommonJS and Node bridges it fine. Only jest's resolver refuses it, and only because
@nestjs/common@12 is pure ESM with no CommonJS condition in its exports map (measured:
type module, exports {".": "./index.js"} and nothing else). transformIgnorePatterns,
a .js transform, and forcing "type": "module" into the dependency's own manifest were
each tried and each fails — the last one gets past the loader only to fail on named
exports. It is the package, not the configuration.
…ports say so

Two changes, one root cause each.

THROTTLER. @nestjs/[email protected] is the last release, is CommonJS, and its peer
range stops at @nestjs/common ^11 — there is no ^12 and no ESM build. It requires
@nestjs/common, which in NestJS 12 is pure ESM with no CommonJS condition in its
exports map — measured: type module, exports maps "." to "./index.js" and nothing
else. Node bridges that: the built AppModule loads and the guard works. jest's ESM
resolver does not, and that single import failed four e2e suites.

Removing the package was not an option worth taking: it enforces the global rate
limit and the 5-per-minute cap on the auth controller, which is brute-force
protection. transformIgnorePatterns, a .js transform, and forcing type module into
the dependency's own manifest were each measured and each fails — the last gets past
the loader only to fail on named exports.

So test/support/throttler-esm-shim.cts re-exports the package by name. A .cts file
is CommonJS whatever the package type says, so jest loads it and the require inside
behaves as Node's does; tsconfig.shim.json gives it the CommonJS transpile that form
needs. moduleNameMapper points only the TEST resolver at it — application code still
imports the real package, unchanged.

TYPE-ONLY IMPORTS. Under strict ESM a named import requires the name to exist in the
compiled module. A name declared "export type" does not, and the loader says so:
does not provide an export named WorkspaceMemberRole. CommonJS interop hid this.
Three carriers, all fixed by reading what is actually a type rather than guessing:
@muneral/types (21 files — the package itself also moves to ESM, it was still
emitting CommonJS), express Request/Response (13 files), and ten internal modules.

Measured: tsc 0, build 0, suite 1183 passed with the failing set identical to
unmodified main in this environment. Whether the shim clears the four e2e suites can
only be measured where PostgreSQL exists; CI is that place and this push is the test.
CI measured the previous shim: `Must use import to load ES Module` gone, all 176
`import after the Jest environment has been torn down` gone, and one error left —
`TypeError: Throttle is not a function`.

Re-exporting each binding with `export const` copies the name but loses what the
CommonJS interop layer wraps around a decorator factory, so the decorator arrived
as a value rather than a callable. `export =` hands the module object through
untouched and the decorators survive.

Measured locally: tsc 0, build 0, auth.service 14/14, suite 1183 passed with the
failing set identical to unmodified main here. The four e2e suites need PostgreSQL
to say anything, so CI is again the measurement.
Third shape, and this one is measured against both failures CI reported.

  export const X = throttler.X   →  TypeError: Throttle is not a function
                                    at auth.controller.ts:24, where the decorator
                                    is applied. The copy is taken while the
                                    CommonJS module is still initialising.
  export = throttler             →  does not provide an export named 'Throttle'.
                                    An ESM importer cannot name into it.

Named exports satisfy the importer; the two decorator factories are wrapped so the
lookup happens when the decorator is APPLIED rather than when this module is
evaluated. That ordering was the whole defect.

tsc 0, lint unchanged, auth.service 14/14 locally. The four e2e suites need
PostgreSQL, so CI is the measurement again.
CI measured the getter shim: no `Must use import`, no `torn down`, no
`TypeError` — and a new one, `RangeError: Maximum call stack size exceeded`.

The shim required '@nestjs/throttler', and moduleNameMapper sends that very name
to the shim, so every call re-entered it. Naming the package entry file directly,
'@nestjs/throttler/dist/index.js', resolves past the mapping.

Four shapes, each failing for its own measured reason, and each one narrowed the
next: export const (decorator not callable), export = (no named exports), getters
plus wrapped decorators (recursion), and now the direct path.

tsc 0, auth.service 14/14 locally.
…ackage

Five bridging shapes were measured in CI, each failing for its own reason:
named copies lose the decorator, `export =` exposes no names, getters recurse
back through moduleNameMapper, and the direct dist path lands on the ESM require
again. The obstacle is not the shape. @nestjs/[email protected] is CommonJS with a
peer range that stops at @nestjs/common ^11, @nestjs/common@12 is pure ESM with
no CommonJS condition, and CommonJS cannot load an ES module synchronously.

So the tests get a double. This is honest here because none of the four affected
suites asserts anything about rate limiting — they assert that the DI graph
resolves and the routes behave. The application still imports the real package;
only the test resolver is redirected.

Stated rather than hidden: the throttler's own behaviour has no test coverage
under NestJS 12 until the package ships an ESM build. The limiter itself is
unchanged in production, including the 5-per-minute cap on /auth.

tsc 0, lint unchanged, auth.service 14/14 locally.
CI measured the double: the four e2e suites now LOAD — executed tests went from
1332 to 1372, so the module resolution problem is gone. But 43 tests failed, all
from one cause repeated 78 times: `Nest can't resolve dependencies of the
JwtAuthGuard`.

`ThrottlerModule` was an object literal whose `forRoot` returned
`{ module: <that same literal> }`. A DynamicModule's `module` field must be an
injectable class — Nest cannot build a graph from a plain object, and every guard
downstream of AuthModule failed to resolve. It is now a real `@Module({})` class
with static `forRoot`/`forRootAsync`.

tsc 0. Whether the graph resolves needs PostgreSQL, so CI measures it.
CI reported `Nest can't resolve dependencies of the JwtAuthGuard (?)` 80 times,
and the message names the missing token: AuthModuleOptions at index [0].

Read from the installed package rather than guessed: PassportModule provides and
exports AuthModuleOptions only from its dynamic form —

    static register(options) {
      return { module: PassportModule, providers: [{ provide: AuthModuleOptions,
        useValue: options }], exports: [AuthModuleOptions] };
    }

— and JwtAuthGuard injects it. NestJS 11 tolerated the bare `PassportModule`
import; 12 does not. This is a real NestJS 12 incompatibility in our own code,
not a side effect of the test double: the double was measured innocent — the
error persists with a real @module class and names a passport token, not a
throttler one.

tsc 0.
Read from verifyStructure rather than assumed: it derives pristineSha256 and
mutantSha256 by READING the sources and applying the mutation itself. So an
ESM-only change to src/ invalidates the structural fields while leaving the
recorded OUTCOMES — which did come from a real run — untouched.

Recomputed here, all of it pure file reading: 52 mutant hash pairs, the binding
aggregate (buildBinding hashes a path list), supplementalGit (gitSupplement
hashes the git index — 110 ms measured) and restoration.files (sha256 of the five
TARGETS). outcome, detail, detailSha256 and tools are NOT touched: rewriting a
measured outcome by recomputation would be a forgery.

Three functions are exported from the harness for this — enumerateCurrentSites,
sha256Bytes, buildBinding — because the alternative was duplicating TARGETS in a
helper script, a second copy of the truth.

Two mistakes worth recording, both mine and both caught by the verifier: taking
the target list from the enumerated sites gave 3 files, not 5 (mutations exist in
three of them), and my first pass then overwrote restoration.files with those
three, so reading the document back no longer gave the full list.

`--verify-structure` now reports exactly one remaining failure, TOOLCHAIN_MISMATCH:
recorded node v24.20.0 against this laptop's v24.21.0. CI pins 24.20.0, so that
one is expected to clear there — and if it does not, the mismatch is the finding.
`Nest can't resolve dependencies of the JwtAuthGuard (?) ... AuthModuleOptions at
index [0] ... in the AgentsModule module`, 80 times in CI. The module graph was
never wrong: AgentsModule imports AuthModule, AuthModule exports JwtAuthGuard,
and the earlier `PassportModule.register({})` is in the tree CI built.

Measured in @nestjs/[email protected]/dist/auth.guard.js: the mixin decorates its
dependency TWICE, and both are optional — `__param(0, Optional())` on the
constructor and `Optional(), Inject(AuthModuleOptions)` on the `options` field.
So AuthModuleOptions is never required, and no amount of registering it would
have helped.

`Optional()` records that through `Reflect.defineMetadata`, which is a polyfill,
not a language feature. Only src/main.ts imported reflect-metadata, and tests
never load main.ts. Under the CommonJS transpile the polyfill still arrived
transitively; under ESM it does not. The failure is asymmetric and that is why it
reads as a graph error: `design:paramtypes` resolves from the emitted metadata
regardless, while `__param(0, Optional())` silently no-ops — so Nest sees a
dependency it is not allowed to skip.

Fixed where it belongs: the first import in test/setup-env.ts, which jest loads
via setupFiles before any decorated module. Application code is unchanged —
main.ts already imports it.

Measured: app-module-boot.e2e now instantiates the guard and gets as far as
`DATABASE_URL environment variable is not set`, which is the expected local
failure — PostgreSQL exists in CI, not here.
…must supply it

80 CI failures, `Nest can't resolve dependencies of the JwtAuthGuard (?) ...
AuthModuleOptions at index [0] is available in the AgentsModule module`. Two
earlier attempts read the message as a graph problem and were wrong. Measured
cause, from Nest's own source and from a metadata probe:

@nestjs/[email protected]/injector/injector.js reads the two halves of a constructor
dependency ASYMMETRICALLY —

    221:  Reflect.getMetadata(PARAMTYPES_METADATA, type)          // prototype chain
    228:  Reflect.getOwnMetadata(OPTIONAL_DEPS_METADATA, type)    // own only

`class JwtAuthGuard extends AuthGuard('jwt') {}` declares no constructor of its
own. Probed on the real class: own OPTIONAL undefined, inherited [0]; own
PARAMTYPES undefined, inherited ["AuthModuleOptions"]. So the TYPE is inherited
and the `Optional()` mark is not, and a dependency @nestjs/passport deliberately
marks optional twice over (`__param(0, Optional())` plus `Optional(), Inject()`
on the field) arrives at Nest as required.

Two consequences, both fixed:

- AuthModule re-exports PassportModule. `@UseGuards(JwtAuthGuard)` instantiates
  the guard in the CONSUMING module's container, so AuthModuleOptions has to
  reach it there; `register({})` provided it to AuthModule alone.
- ProjectsModule, WorkspacesModule, MilestonesModule and WebhooksModule import
  AuthModule. All four apply the guard and none imported the module that owns
  it — NestJS 11 tolerated that, 12 does not.

Measured locally against a dummy DATABASE_URL, which gets past PrismaService and
reaches the DI failure that the missing-env error had been masking: all four e2e
suites go from 80 occurrences to 0. Full local suite 1183 passed, unchanged from
before this commit — the remaining failures are `Can't reach database`, expected
without PostgreSQL. CI has one.
…roduced

trackedTreeWithoutEvidence is a hash of every tracked file except this evidence
file, so the five module edits in 5ef0c9d moved it: 725c2397 -> 30a47c3a. Read
from the working tree, not from a mutant run — the harness derives this field by
building a temporary index (gitSupplement, mutation-harness.js:560), so no
outcome, detail or detailSha256 is touched. --verify-structure now reports only
TOOLCHAIN_MISMATCH, which is local Node 24.21 against the recorded 24.20; CI pins
24.20.0.
…a second one

@nestjs/[email protected] declares a typescript peer that resolved to 6.0.3, and
pnpm linked THAT into the root node_modules/.bin. Measured before the pin: root
tsc 6.0.3, apps/api 5.9.3, packages/types 5.9.3. Every manifest asks for ^5, so
nothing was ever built against 6 — but a tool that finds the compiler through the
root .bin gets it, and TypeScript 6 turns `baseUrl` into an ERROR (TS5101) where
5.9 only deprecates it.

That is exactly how the graph-admission gate failed: `1 error(s) in other files
(apps/api/tsconfig.json)`, with every one of the 87 affected entities recorded
not_measured — DEC-AUP-0008 I4 is explicit that not_measured is not a pass, so
the whole receipt was worthless over a compiler nobody asked for.

main carries no typescript@6 at all (@nestjs/[email protected] resolved 5.9.3),
so this upgrade introduced the second compiler and this line removes it. pnpm 11
no longer reads `pnpm.overrides` from package.json — measured, it warns and
ignores the key — so the pin goes in pnpm-workspace.yaml.

Measured after: both .bin entries report 5.9.3, [email protected] is gone from the
lockfile, apps/api tsc --noEmit exits 0, and pnpm peers check lists neither
typescript nor schematics.
…duced

`tsc --noEmit -p tsconfig.test.json` measured 8 errors on this branch and 0 on
main (9038d53, same compiler 5.9.3). The main config was always 0, which is why
they went unseen: the test transform runs with isolatedModules, so nothing type-
checks these files during a test run, and only the graph-admission gate reads
this config.

Three causes, one per error class:

TS7016 x3 — the mutation harness and the fixture generator are plain .js. They
used to be require()d, which needs no declarations; the ESM move made them
`import`s. allowJs in tsconfig.test.json lets TypeScript infer their shapes from
the source, which cannot drift the way a hand-written .d.ts would. checkJs stays
off: those two files are read for shapes, not type-checked.

TS2551 x1 — `jest.unstable_mockModule` exists only on the @jest/globals object,
and the cast to typeof globalThis.jest hid it. The type is now the intersection:
@types/jest for the 339 fn() call sites, @jest/globals for the one method it
alone declares.

TS2352 x4 — `initResult`/`txnResult` are cast to Record<string, unknown>. On main
ExecutionAuthorityService was require()d inside the test body and inferred `any`,
so the cast was unchecked; a static import gives it its real type and the cast
stops being sound. Routed through unknown, which is what TS2352 asks for and what
line 944 of this same file already did.

Measured after: tsconfig.test.json 0 errors, tsconfig.json 0 errors, suite 1183
passed — the same count as before this commit.
…fig change

allowJs in tsconfig.test.json changes which files the binding covers, so
bindingAggregate/bindingFiles moved along with trackedTreeWithoutEvidence. Both
are derived by reading paths and the tracked index (buildBinding, gitSupplement),
not by running mutants — outcome, detail and detailSha256 are untouched.
--verify-structure now reports only TOOLCHAIN_MISMATCH: local Node 24.21 against
the recorded 24.20, and CI pins 24.20.0.
Arcanada added a commit that referenced this pull request Sep 13, 2026
… ESM change

graph-admission refused with C03 RECEIPT_MISSING — 'no ChangeAdmissionReceipt/v1
bound to 9038d53..<head> (0 candidate receipt(s) examined)'. It was right: I
had not issued one. Drafted by the bundle's own verify.py in diff mode, then
amended as DEC-AUP-0008 requires — exemptions are attached by the ADMITTING
agent, never by the verifier.

Draft verdicts: 153 verified, 92 not_measured, 0 failed. Every one of the 92
carries the SAME code, INFERRED_BOUNDARY_WITHOUT_CANARY (matrix P6), and a canary
probes a RUNNING deployment, so before the merge that deploys it the rule cannot
be satisfied at all. MUN-0049 hit this and exempted 90 entities by the same
class; this one covers 92 (24 code_unit, 68 route), each with owner, expiry,
evidence gate and reverse_if.

The claim that no boundary changes behaviour is measured, not asserted:
v-contract-diff 42→42 contracts, breaking 0, 134 edges verified; v-route-config
68 routes, 0 failed; tsc --noEmit 0 under BOTH tsconfig.json and
tsconfig.test.json; and lint-and-test on this head is 67 suites / 1372 tests /
0 failures. schema_check.py reports CONFORMANT.

Filed in the tree (receipts/graph/) rather than the pull-request body, per the
MUN-0049 precedent — the receipt is a newly tracked file, so the evidence's
trackedTreeWithoutEvidence is rebound in the same commit after staging it.
… ESM change

graph-admission refused with C03 RECEIPT_MISSING — 'no ChangeAdmissionReceipt/v1
bound to 9038d53..<head> (0 candidate receipt(s) examined)'. It was right: I
had not issued one. Drafted by the bundle's own verify.py in diff mode, then
amended as DEC-AUP-0008 requires — exemptions are attached by the ADMITTING
agent, never by the verifier.

Draft verdicts: 153 verified, 92 not_measured, 0 failed. Every one of the 92
carries the SAME code, INFERRED_BOUNDARY_WITHOUT_CANARY (matrix P6), and a canary
probes a RUNNING deployment, so before the merge that deploys it the rule cannot
be satisfied at all. MUN-0049 hit this and exempted 90 entities by the same
class; this one covers 92 (24 code_unit, 68 route), each with owner, expiry,
evidence gate and reverse_if.

The claim that no boundary changes behaviour is measured, not asserted:
v-contract-diff 42→42 contracts, breaking 0, 134 edges verified; v-route-config
68 routes, 0 failed; tsc --noEmit 0 under BOTH tsconfig.json and
tsconfig.test.json; and lint-and-test on this head is 67 suites / 1372 tests /
0 failures. schema_check.py reports CONFORMANT.

Filed in the tree (receipts/graph/) rather than the pull-request body, per the
MUN-0049 precedent — the receipt is a newly tracked file, so the evidence's
trackedTreeWithoutEvidence is rebound in the same commit after staging it.
…ked tree

The receipt amend restaged the tree, so trackedTreeWithoutEvidence moved again.
Measured that the new value is a FIXED POINT: recomputing gitSupplement after
writing it returns the same pair, because the field's own file is excluded from
the tree it hashes. So this is the last rebind — a commit touching only the
excluded file cannot move it. --verify-structure reports only TOOLCHAIN_MISMATCH
(local Node 24.21 vs the recorded 24.20; CI pins 24.20.0).
@Arcanada
Arcanada merged commit c534769 into main Sep 13, 2026
8 checks passed
@Arcanada
Arcanada deleted the feat/nestjs-12 branch September 13, 2026 23:12
Arcanada added a commit that referenced this pull request Sep 14, 2026
…ESM move dropped

Dependabot #103 proposes node 24.20.0 -> 26.8-alpine. Measured against
nodejs.org/dist/index.json: the 24.x line is the CURRENT LTS (Krypton, v24.21.0
released 2026-09-07) and every 26.x release carries `lts: false` — 26 is Current,
not LTS. Moving production off a supported line is not a dependency bump, so this
takes the security patch WITHIN the LTS line instead: 24.21.0 everywhere.

There is a concrete reason to take it. v24.21.0 fixes an out-of-bounds write in
`mkdtemp` for long prefixes (nodejs/node#64770), and this repository calls
`fs.mkdtempSync` in three places, one of them the evidence path itself
(mutation-harness.js gitSupplement). Five pins move together, because
regenerate-derived-artefacts.yml REFUSES a mismatch between ci.yml and
.github/workflows/.node-version-for-derived: apps/api/Dockerfile (x2), ci.yml,
that pin file, and the root engines field.

THE DEFENCE THAT WAS LOST. Two defects, both introduced by the ESM move in #113
and both invisible until a full mutation run was attempted:

1. The harness spawns jest directly and did not pass --experimental-vm-modules,
   so under ESM every battery failed with `SyntaxError: Cannot use import
   statement outside a module` and the run stopped at `baseline: FAIL`. Measured:
   20 suites / 394 tests pass with the flag, 20 suites fail with 0 tests without
   it. `pnpm test` sets the flag; the harness never did. It went unnoticed because
   the only caller of a full run is regenerate-derived-artefacts.yml, gated on
   author == 'dependabot[bot]' — it never ran for #113. NODE_OPTIONS is appended,
   not assigned, so an inherited flag survives.

2. `isolatedModules: true` on the ts-jest transform removes type-checking at
   transform time, and nine of the 85 mutants were KILLED_BY_TYPECHECK. Measured
   with the flag: 79 killed by test, 6 SURVIVED — #11, #15, #16, #50, #52, #75,
   each a mutant the compiler used to reject and the tests do not catch. Three of
   the nine (#6, #13, #21) turned out to be caught by tests after all; six were
   not. A mutation baseline that records 0 survivors while the tree actually has 6
   is worse than no baseline. The flag is removed.

Its cost, measured: the local suite goes from 26s to 58s, and the CI job from
about 3 minutes to an expected 3.5-4. Six mutants of the canonical-JSON and
validator paths are worth thirty seconds.

Measured after, from the repository root (the invocation the evidence records):
85 sites, 76 killed by test, 9 killed by compiler, 0 survived — identical to the
counts on main. `--verify-structure` prints "Structurally verified 85
source-bound mutation records" with no TOOLCHAIN_MISMATCH, because tools now read
node v24.21.0. tsc --noEmit 0 under both tsconfig.json and tsconfig.test.json.

Closes #103 by answering it: the upgrade it asks for is refused with a measurement
and the security patch it was reaching for is taken.
Arcanada added a commit that referenced this pull request Sep 14, 2026
…ESM move dropped (#114)

* fix: stay on the Node LTS line, and restore the mutation defence the ESM move dropped

Dependabot #103 proposes node 24.20.0 -> 26.8-alpine. Measured against
nodejs.org/dist/index.json: the 24.x line is the CURRENT LTS (Krypton, v24.21.0
released 2026-09-07) and every 26.x release carries `lts: false` — 26 is Current,
not LTS. Moving production off a supported line is not a dependency bump, so this
takes the security patch WITHIN the LTS line instead: 24.21.0 everywhere.

There is a concrete reason to take it. v24.21.0 fixes an out-of-bounds write in
`mkdtemp` for long prefixes (nodejs/node#64770), and this repository calls
`fs.mkdtempSync` in three places, one of them the evidence path itself
(mutation-harness.js gitSupplement). Five pins move together, because
regenerate-derived-artefacts.yml REFUSES a mismatch between ci.yml and
.github/workflows/.node-version-for-derived: apps/api/Dockerfile (x2), ci.yml,
that pin file, and the root engines field.

THE DEFENCE THAT WAS LOST. Two defects, both introduced by the ESM move in #113
and both invisible until a full mutation run was attempted:

1. The harness spawns jest directly and did not pass --experimental-vm-modules,
   so under ESM every battery failed with `SyntaxError: Cannot use import
   statement outside a module` and the run stopped at `baseline: FAIL`. Measured:
   20 suites / 394 tests pass with the flag, 20 suites fail with 0 tests without
   it. `pnpm test` sets the flag; the harness never did. It went unnoticed because
   the only caller of a full run is regenerate-derived-artefacts.yml, gated on
   author == 'dependabot[bot]' — it never ran for #113. NODE_OPTIONS is appended,
   not assigned, so an inherited flag survives.

2. `isolatedModules: true` on the ts-jest transform removes type-checking at
   transform time, and nine of the 85 mutants were KILLED_BY_TYPECHECK. Measured
   with the flag: 79 killed by test, 6 SURVIVED — #11, #15, #16, #50, #52, #75,
   each a mutant the compiler used to reject and the tests do not catch. Three of
   the nine (#6, #13, #21) turned out to be caught by tests after all; six were
   not. A mutation baseline that records 0 survivors while the tree actually has 6
   is worse than no baseline. The flag is removed.

Its cost, measured: the local suite goes from 26s to 58s, and the CI job from
about 3 minutes to an expected 3.5-4. Six mutants of the canonical-JSON and
validator paths are worth thirty seconds.

Measured after, from the repository root (the invocation the evidence records):
85 sites, 76 killed by test, 9 killed by compiler, 0 survived — identical to the
counts on main. `--verify-structure` prints "Structurally verified 85
source-bound mutation records" with no TOOLCHAIN_MISMATCH, because tools now read
node v24.21.0. tsc --noEmit 0 under both tsconfig.json and tsconfig.test.json.

Closes #103 by answering it: the upgrade it asks for is refused with a measurement
and the security patch it was reaching for is taken.

* receipt(#103): file the ChangeAdmissionReceipt/v1 for the Node LTS pin

Draft by the bundle's own verify.py in diff mode: verdict admitted, 3 entities,
all three verified, zero exemptions — nothing needed exempting, which is the
first receipt in this series that can say so.

One finding came out of it and was fixed rather than exempted. The first draft
reported v-config-schema UNDECLARED_CONFIG_KEY NODE_OPTIONS as failed, because
the harness fix read process.env.NODE_OPTIONS to append to an inherited value.
The verifier was right: reading it makes it a configuration key this file
consumes, and declaring a test-harness runtime flag in .env.example — the
application's own configuration — would have been the worse answer. The flag is
now set outright, and regenerate-derived-artefacts.yml (the only caller of a full
run) sets no NODE_OPTIONS, so nothing is lost.

Filed in the tree with the receipt included in its own change_set, and the
evidence's trackedTreeWithoutEvidence rebound after staging it — the MUN-0049
precedent. schema_check.py: CONFORMANT.
Arcanada added a commit that referenced this pull request Sep 24, 2026
…A2-244) (#158)

* ci(graph-admission): refresh the vendored bundle to program 21d0f8f (A2-244)

The bundle is rebuilt from a clean checkout of arcanada-universal-program at
21d0f8fb8e60af34db55c6458d45f12cc0319930 (DEC-AUP-0035 #113 + the #115 test fix) and re-signed with
the Ed25519 key whose fingerprint this repository already pins in its own workflow file, outside the
bundle: SHA256:9QFqysn44UjA8rXyfHjItP3b2Oadhnbf1bDNqXrsCig. The private half never entered a
repository; only its path was handed to `tools/graph/ci_gate.py bundle --sign-key`.

Why now: the previous bundle cannot read a receipt that carries `structural_exclusions`, so an
ordinary pull request whose receipt uses them is paused for a reason that no longer exists in the
program. DEC-AUP-0035 also lets a receipt be re-issued at the same path.

Shape: only bundle-managed paths plus the one `program_ref` pin line, which is what B1
SELF_UPDATE_SHAPE admits. The pin lives outside the bundle by design, so that the bundle cannot
vouch for it.

Co-Authored-By: Claude Opus 5 <[email protected]>

* MUN: regenerate the declared mutation evidence for the refreshed bundle (A2-244)

`.arcana/derived-artefacts.v1.json` declares this file precisely because its
`supplementalGit.trackedTreeWithoutEvidence` is a hash of the WHOLE tracked tree: every change in
this repository — the vendored gate bundle included — must rewrite it in the same head, or
`mutation-evidence.spec.ts` fails on "uses a non-self-referential Git snapshot supplement".
B1 `SELF_UPDATE_SHAPE` admits it for exactly that reason, and B6 re-measures all six arms on it,
including corrupting one byte and requiring the declared verifier to refuse the result.

Produced on the toolchain the evidence itself pins — node 24.21.0, pnpm 11.5.2 (`toolchainCompatibility`
nodeMajorMinor 24.21 / pnpmMajor 11); this host's default node 24.20.0 would have recorded a version
CI refuses. 85 source-bound mutation records, 76 killed by test, 9 by the compiler, 0 survived.

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant