# Gina — LLM Reference

Node.js MVC framework. No Express dependency. Built-in HTTP/2. Multi-bundle architecture. Scope-based data isolation. CommonJS throughout (no ESM). Single runtime dependency (`engine.io`) — all other functionality uses Node.js built-ins.

---

## Key concepts

**Bundle** — Gina's unit of deployment. A project contains one or more bundles. Each bundle has its own controllers, models, config, templates, and port. Bundles start independently (`gina bundle:start <bundle> @<project>`).

**Per-bundle framework version** — Each bundle can run under a specific installed gina version, independent of the socket server. Declared as `"gina_version": "0.1.8"` in the bundle's `manifest.json` entry, or overridden at start time with `--gina-version=0.1.8`. The declared version is validated against `~/.gina/main.json` before the bundle process is spawned.

**Scope** — Runtime environment label: `local`, `beta`, `production`, `testing`. Set per-process via `NODE_SCOPE`. Stored on every entity instance as `_scope`. Used to isolate data in shared databases (Couchbase N1QL `AND t._scope = $scope`).

**Short version** — The `major.minor` portion of the Gina semver (e.g. `0.1` from `0.1.9-alpha.2`). Per-version config lives at `~/.gina/${shortVersion}/settings.json`.

---

## Directory layout

```
framework/v${version}/
  core/
    config.js           Config singleton — synchronous, must complete before returning
    gna.js              Bootstrap — injects globals (_/getContext/setContext/requireJSON/getPath)
    server.js           HTTP server lifecycle
    server.isaac.js     Isaac built-in HTTP/HTTP2 engine
    router.js           URL routing (matches routing.json rules)
    controller/
      controller.js     SuperController base class (per-request instances via inherits)
      controller.render-swig.js   HTML rendering (delegate file, hot-reloaded in dev). HTTP/2: uses stream.respond() + stream.end() directly (#H8). Tests: test/core/render-swig.test.js (77 tests — async I/O, error normalization, exit paths, guard patterns, HTTP/2 stream implementation)
      controller.render-json.js   JSON rendering (delegate file)
    model/entity.js     EntitySuper — EventEmitter base for all ORM entities
    connectors/         Couchbase, Redis, SQLite connectors
  lib/
    index.js            Exports all framework libraries
    merge/              Deep object merge
    inherits/           Prototype-based inheritance
    cache/              Memory cache with TTL
    logger/             Multi-stream logging
```

---

## Vendored dependencies (`core/deps/`)

| Package | Version | Notes |
|---|---|---|
| `busboy` | 1.6.0 | Multipart form parsing. Requires `streamsearch` (vendored alongside). |
| `streamsearch` | 1.1.0 | Boyer-Moore-Horspool stream searching. Dependency of `busboy`. |

### Swig — `@rhinostone/swig` (npm dependency)

Maintained fork of the abandoned swig 1.4.2 template engine. Published to npm as `@rhinostone/swig@2.7.2` (npm org: `rhinostone`, maintainer: `beemaster`). Repo: `https://github.com/gina-io/swig`. Declared in `framework/v*/package.json` — installed via `npm install` inside the framework directory. Requires `npm install` after clone, merge, or worktree switch (the framework `node_modules/` is gitignored).

Previously vendored at `core/deps/swig-1.4.2/` — removed in the migration to the npm dep. CVE-2023-25345 parse-time blocklist (`__proto__`/`constructor`/`prototype`) is patched in the fork's `parser.js` and `tags/set.js`. The path traversal boundary check in `controller.render-swig.js` is Gina's own code and is unaffected by this migration.

---

## TypeScript declarations & explicit exports

**TypeScript declarations** — `types/index.d.ts`, `types/globals.d.ts`, `types/gna.d.ts`. Covers `SuperController`, `EntitySuper`, all global helpers, `PathObject`, `uuid`, `ApiError`, config file interfaces (`RoutingConfig`, `ConnectorsConfig`, `AppConfig`, `SettingsConfig`, `ManifestConfig`, `WatchersConfig`, `CronsConfig`), `GinaRequest`/`GinaResponse`, and the `Gna` lifecycle module. `package.json` has `"types": "./types/index.d.ts"` and `"typesVersions"` for `gina/gna`.

`types/gna.d.ts` is **auto-generated** from JSDoc via `script/generate_gna_types.js` (#M9). The generator reads the authoritative `GLOBAL_EXPORTS` inventory from `framework/v*/test/unit/gna-exports.test.js` and the per-export JSDoc blocks on `framework/v*/core/gna.js`, then emits either `typeof globalThis.<name>` (when `globals.d.ts` declares the global) or a synthesized function signature. Zero non-core deps. Two npm scripts: `npm run types:gen` rewrites the file; `npm run types:check` exits non-zero if it drifted. A unit test (`framework/v*/test/unit/gna-types-drift.test.js`) re-runs the generator in memory on every test run and fails if the checked-in file is stale — so the JSDoc on `core/gna.js` is the single source of truth for the gna-barrel type surface. Do not hand-edit `types/gna.d.ts`.

**Explicit exports** — every injected global is also a named property of both `require('gina')` (core/gna.js) and `require('gina/gna')` (root barrel):

```javascript
const { getContext, _, onCompleteCall, uuid, SuperController } = require('gina/gna');

// Same surface is available from the primary entry point:
const { setContext, requireJSON, merge, ApiError } = require('gina');
```

Covers context helpers (`setContext`, `getContext`, `joinContext`, `resetContext`, `getConfig` [barrel only — collides with the bundle-bound instance method on core/gna], `getLib`, `whisper`, `define`, `getDefined`, `isWin32`), path helpers (`_`, `setPath`, `getPath`, `setPaths`, `getPaths`, `onCompleteCall`), model helpers (`getModel`, `getModelEntity`), JSON/Data/Text/Console/Task helpers (`requireJSON`, `encodeRFC5987ValueChars`, `formatDataFromString`, `__`, `log`, `run`), env helpers (`getUserHome`, `getEnvVar`, `getEnvVars`, `setEnvVar`, `getProtected`, `filterArgs`, `getLogDir`, `getRunDir`, `getTmpDir`, `getBundleStartingArgv`, `getVendorsConfig`, `setVendorsConfig`, `defineDefault`, `parseTimeout`, `merge`) and `ApiError`. Each export carries JSDoc for IDE navigation and is the static surface consumed by the `.d.ts` generator. The globals are still injected as before — this is additive. Root barrel uses lazy getters; core/gna.js uses direct assignment (all globals are populated by the time the module body completes).

---

## Global helpers (injected by gna.js — no require needed)

```javascript
_(path, isAbsolute)          // PathObject — resolves paths, normalises separators
requireJSON(path)            // JSON require with caching
getPath(name)                // Get named path ('gina', 'bundle', 'project', ...)
setPath(name, pathObj)       // Register named path
getContext(key)              // Global key-value store (survives require.cache eviction)
setContext(key, value)       // Write to global store
define(key, value)           // Property definition helper
getEnvVar(key)               // Read env variable
setEnvVar(key, val, protect) // Write env variable
onCompleteCall(emitter)          // Wraps an EventEmitter .onComplete(cb) into a native Promise — lib/async/src/main.js, also available as global and via lib.async
```

## Bare-module require — `require('lib/<name>')`

Bundle entities, controllers, and middleware can `require()` framework libraries as bare modules from any depth:

```javascript
var uuid    = require('lib/uuid');     // → framework/v<version>/lib/uuid/
var merge   = require('lib/merge');    // → framework/v<version>/lib/merge/
var routing = require('lib/routing');  // → framework/v<version>/lib/routing/
```

`gna.js` injects the framework path into `process.env.NODE_PATH` and calls `require('module').Module._initPaths()` at bootstrap. This mirrors the frontend RequireJS path alias convention and removes the need for relative paths like `require('../../../../framework/v<version>/lib/uuid')`.

Every `lib/<name>/` directory must have a `package.json` with `"main": "src/main"` (see `lib/merge/package.json` for the canonical pattern).

---

## Controller pattern

```javascript
// controllers/controller.content.js
// NO require, NO inherits — the router (router.js) calls inherits(Controller, SuperController)
// automatically before dispatch. All SuperController methods are available via `this` / `self`.

function ApiContentController() {
    var self = this;

    this.home = function(req, res) {
        self.render({ title: 'Home' });           // HTML via Swig
        // self.renderJSON({ ok: true });          // JSON
    };
}

module.exports = ApiContentController;
```

**Rules:**
- Controller files are **plain constructor functions** — no `require`, no `inherits`, no `prototype` assignments. The router does `inherits(Controller, SuperController)` before every dispatch (see `router.js:557-560`). Each request gets a fresh instance with its own `local` closure.
- Name the constructor `${Bundle}${Namespace}Controller` (e.g. `ApiContentController` for bundle `api`, namespace `content`). The name is not enforced but matches the boilerplate convention.
- `inherits` is a Gina global — it is NOT available inside controller files via a require, only as an injected global in other framework files.
- Auth is at `req.session.user` (NOT `req.user`).
- Request data: `req.get` (GET query), `req.post` (POST body), `req.put` (PUT body), `req.patch` (PATCH body — partial update), `req.delete` (DELETE query), `req.head` (HEAD query — body suppressed). `req.body` aliases `req.post`/`req.put`/`req.patch`. Routes declared as GET automatically accept HEAD. PATCH vs PUT: PATCH sends only changed fields; PUT replaces the full resource. OPTIONS is CORS-only, never dispatched to a controller.
- Routes declared in `routing.json`, not in code.
- Always null `local.req/res/next` at response exit (done automatically in render path).
- `createTestInstance(deps)` creates an isolated instance for unit tests without touching production state.
- `async` controller actions are fully supported — the router attaches `.catch()` to any thenable returned by an action and routes rejections to `throwError(response, 500, ...)`. Use `await entity.method()` directly. For PathObject ops and Shell use `await onCompleteCall(_(path).mkdir())`.
- `self.setEarlyHints(links)` — send a 103 Early Hints informational response. Call before the terminal method. `links` is a string or array of `Link` header values. HTTP/2: `stream.additionalHeaders({ ':status': 103 })`; HTTP/1.1: `res.writeEarlyHints()` (Node.js 18.11+). Silent no-op when unsupported. Returns `self`. **Also automatic**: `render()` auto-sends 103 for the bundle's CSS/JS preloads (from `h2Links`) before Swig compilation in HTTP/2 production mode — zero config required.
- `self.renderStream(asyncIterable, contentType)` — stream an `AsyncIterable` as a chunked HTTP response without buffering. `contentType` defaults to `text/event-stream` (SSE). Each yielded string/Buffer becomes `data: {chunk}\n\n` for SSE; raw for other content types. HTTP/2: `stream.respond()` + `stream.write()` + `stream.end()`. HTTP/1.1: automatic chunked transfer-encoding. `x-accel-buffering: no` set automatically for SSE. Fire-and-forget — do not `await`. Required for LLM token streaming via `ai.client` with `stream: true`.

```javascript
// async action example
Controller.prototype.upload = async function(req, res, next) {
    var self = this;
    var user = await self.UserEntity.getById(req.params.id);
    await onCompleteCall( _(self.uploadDir).mkdir() );
    self.renderJSON({ ok: true, user: user });
};
```

```javascript
// 103 Early Hints example
Controller.prototype.home = function(req, res, next) {
    var self = this;
    self.setEarlyHints([
        '</css/app.css>; rel=preload; as=style',
        '</js/app.js>; rel=preload; as=script'
    ]);
    // ... fetch data ...
    self.render({ title: 'Home' });
};
```

---

## Views / Templates

Add HTML template support to a bundle:
```bash
gina view:add <bundle> @<project>
```
This scaffolds `src/<bundle>/templates/` (Swig layout + example content page) and `src/<bundle>/public/` (CSS/JS). There is **no `views/` directory** in Gina.

**Template file resolution** — `self.render(data)` resolves:
```
src/<bundle>/templates/html/content/<namespace>/<route-name>.html
```
- `<namespace>` = the `namespace` field in `routing.json`
- `<route-name>` = the route **key** in `routing.json` (not the action name)

Controlled by `"routeNameAsFilenameEnabled": true` in `templates.json`. Override per-route with `"param": { "file": "custom-name" }` in `routing.json`.

**Template data** — data passed to `self.render({ key: value })` is available as `page.data`:
```html
{% extends 'layouts/main.html' %}
{% set data = page.data %}
{% block content %}
  {{ data.key }}
{% endblock %}
```
`{{ key }}` directly does NOT work — always access via `page.data`.

---

## Entity (ORM) pattern

```javascript
// models/<database>/entities/UserEntity.js
function UserEntity() {}

UserEntity.prototype.getById = function(id) {
    // body injected by connector from sql/User/getById.sql
};

module.exports = UserEntity;
```

**Rules:**
- Entities extend EventEmitter (via EntitySuper).
- Entity methods return a native Promise with `.onComplete(cb)` shim for backward compat.
- `_scope` is set on every entity prototype by the connector from `connectors.json`.
- `_arguments[trigger]` buffers results for late-binding listeners — cleared in dev mode per call. Buffer is unreachable in Option B (Promise path): connector trigger naming mismatch + `_callbacks` array check prevent `setListener` from firing. Dead consumption code removed (#M5); defensive unconditional clear at Option B entry.
- Dependency injection: `new MyEntity(conn, caller, { connector: mockConn })` for tests.

---

## SQL file format

All relational connectors (SQLite, MySQL, PostgreSQL) share the same `sql/` directory
layout and annotation format. Couchbase uses `n1ql/`. ScyllaDB uses `cql/` (CQL prepared
statements; uniform `.sql` extension for sql-parser + editor compatibility).

```sql
/*
 * @param {string}  $1   user id      ← $1/$2/... for Couchbase/PostgreSQL
 * @param {string}  ?    user id      ← ? for SQLite/MySQL
 * @return {object}
 */
SELECT * FROM users WHERE id = $1 AND t._scope = $scope
```

- `@return {object}` → single row (first result or `null`)
- `@return {Array}` → all rows (default for SELECT)
- `@return {boolean}` → `changes > 0` / `affectedRows > 0` / `rowCount > 0` (write) or `rows.length > 0` (SELECT)
- `@return {number}` → first key of first row (COUNT queries)
- `$scope` — Couchbase only: substituted as a string literal, not a positional parameter
- Placeholders: SQLite `?`, MySQL `?`, ScyllaDB `?`, PostgreSQL `$1 $2 …`, Couchbase `$1 $2 …`

---

## Connector config (connectors.json)

```json
{
  "mydb": {
    "connector": "sqlite",
    "database": "mydb",
    "file": ":memory:"
  },
  "mysqldb": {
    "connector": "mysql",
    "host": "127.0.0.1",
    "port": 3306,
    "database": "myapp",
    "username": "root",
    "password": "secret",
    "connectionLimit": 10
  },
  "pgdb": {
    "connector": "postgresql",
    "host": "127.0.0.1",
    "port": 5432,
    "database": "myapp",
    "username": "postgres",
    "password": "secret",
    "connectionLimit": 10
  },
  "cache": {
    "connector": "redis",
    "host": "localhost",
    "port": 6379
  }
}
```

Connectors supported: `couchbase` (v3/v4), `mysql` (mysql2), `postgresql` (pg), `redis`, `sqlite` (v2 ORM + session store), `scylladb` (cassandra-driver, ORM + session store), `mongodb` (official driver, ORM via `pipelines/<Entity>/*.json` + session store via TTL index), `ai` (LLM providers).
All connector clients (`mysql2`, `pg`, `ioredis`, `couchbase`, `mongodb`, `cassandra-driver`, `openai`, `@anthropic-ai/sdk`) are loaded from the project's `node_modules` — zero framework runtime dependency. The driver-name → npm-package mapping lives in `lib/connector-registry/src/main.js` (single source of truth for `connector:add` / `connector:list` install hints).

## AI connector

Declare any LLM provider in `connectors.json` via `"connector": "ai"`. Named protocol shortcuts resolve the SDK and base URL automatically.

```json
{
  "claude":   { "connector": "ai", "protocol": "anthropic://", "apiKey": "${ANTHROPIC_API_KEY}", "model": "claude-opus-4-7" },
  "deepseek": { "connector": "ai", "protocol": "deepseek://",  "apiKey": "${DEEPSEEK_API_KEY}",  "model": "deepseek-chat" },
  "local":    { "connector": "ai", "protocol": "ollama://",    "model": "mimo" }
}
```

| Protocol | SDK | Notes |
|---|---|---|
| `anthropic://` | `@anthropic-ai/sdk` | Anthropic's models |
| `openai://` | `openai` | GPT / o-series |
| `deepseek://` | `openai` | DeepSeek-V3, DeepSeek-R1 — OpenAI-compat by design |
| `qwen://` | `openai` | Alibaba Qwen via DashScope |
| `groq://` | `openai` | Groq LPU inference |
| `mistral://` | `openai` | Mistral AI |
| `gemini://` | `openai` | Google Gemini OpenAI-compat endpoint |
| `xai://` | `openai` | xAI Grok |
| `perplexity://` | `openai` | Perplexity |
| `ollama://` | `openai` | Local models: MiMo, Llama, Phi, Qwen-local, Gemma… |
| `openai://` + `baseURL` | `openai` | Any OpenAI-compatible endpoint / self-hosted vLLM |

`getModel('connectorName')` returns `{ client, provider, model, infer(messages, options) }`.

```javascript
var ai     = getModel('deepseek');
var result = await ai.infer([{ role: 'user', content: 'Hello' }]);
// result: { content, model, usage: { inputTokens, outputTokens }, raw }

// .onComplete() for backward compat
ai.infer(messages).onComplete(function(err, result) { ... });

// raw SDK for streaming, embeddings, function calling
ai.client.chat.completions.create({ stream: true, ... });
```

- System message: pass `{ role: 'system', content: '...' }` in the array OR use `options.system`
- For Anthropic: system message is extracted and passed as the `system` parameter automatically
- No live API ping at startup — zero tokens spent on connector init

---

## Async jobs (#AI6)

Run slow work (e.g. a 1–30s `.infer()`) out-of-band so it doesn't tie up the request pipeline. `lib.job.create(fn, opts)` enqueues a deferred async function on a concurrency-limited worker (default 4) and returns a `jobId` immediately; state moves `pending → running → completed | failed`. Records live in an in-memory store behind a callback-shaped `set/get/remove/list/sweep` seam (connector-backed for multi-pod is a follow-up) with a self-contained unref'd `setInterval` TTL sweep — `lib/cron` is NOT involved.

```javascript
// In a controller action — return immediately, work runs out-of-band:
var jobId = self.startJob(function() {
    return getModel('myModel').infer([{ role: 'user', content: prompt }]);
});
self.renderJSON({ jobId: jobId });

// One-call AI convenience (result trimmed to { content, model, usage }):
var jobId = self.inferAsync([{ role: 'user', content: prompt }], { connector: 'myModel' });
```

- Poll the built-in always-on `GET /_gina/jobs/:id` for `{ id, state, createdAt, updatedAt }` — state-only, never the result/error payload, works on both the Isaac and Express engines. Read a completed job's result from your own authenticated route via `self.jobStatus(id, cb)`.
- Opt-in completion webhook: pass `{ callbackUrl }` to `create` / `startJob` and the framework POSTs `{ id, state, result, error }` on completion — best-effort with retry + exponential backoff (`webhookFailed` recorded after exhaustion, never affecting the job outcome). Set `app.json jobs.webhookSecret` to sign each payload with an `X-Gina-Signature` HMAC-SHA256 header.
- Always-on with sane defaults; tune via `app.json jobs.*` (`maxConcurrency`, `ttl`, `sweepInterval`, `idSize`, `webhook*`). The deferred function runs after the response, so capture plain values — never `req`/`res`.

---

## Route radix trie

`lib/routing/src/radix.js` — built once at startup from `routing.json`. Never mutated at runtime.

- `createNode()` — `{ static: {}, param: null, names: [] }`
- `insert(root, url, name)` — splits URL on `/`, static segments go to `node.static[s]`, `:param` segments to `node.param`
- `lookup(root, pathname)` — O(m) candidate lookup (m = segment count); strips query string before matching; returns `string[]` of route name candidates
- `_match()` — static child has priority over param (more specific), but param is always tried when present; both may appear in results
- **Candidate set**: `lookup()` returns structural matches only; semantic validation (HTTP method, `requirements`, param extraction) remains in `compareUrls()` and `parseRouting()`
- `Routing.buildTrie(routing, bundle)` called in `onRoutesLoaded()` after `config.setRouting()`
- `Routing.lookupTrie(pathname, bundle)` returns `null` when no trie is available (safe full-scan fallback)
- In `handle()`: `_trieCandidateSet = new Set(trieHits)` used with `Set.has(name)` to skip non-candidates in the `for…in` loop
- Falls back silently to the full linear scan when trie is absent or lookup returns no hits

---

## HTTP/2 settings & metrics

**Configurable settings** (via `settings.json` `http2Options` or `settings.server.json` `http2Options`):
```json
{
  "http2Options": {
    "maxConcurrentStreams": 256,
    "initialWindowSize": 655350,
    "maxSessionRejectedStreams": 100,
    "maxSessionInvalidFrames": 1000,
    "maxStreamsPerSecond": 200,
    "enableConnectProtocol": false
  }
}
```
Security-critical settings that are hardcoded and not user-overridable: `maxHeaderListSize: 65536` (HPACK bomb), `enablePush: false`. All of the above apply to both https and cleartext h2c bundles (the full `http2Options` reaches createSecureServer and createServer alike).

**Session metrics** — exposed at `/_gina/info` under `"http2"` key:
```json
{
  "http2": {
    "activeSessions": 3,
    "totalStreams": 142,
    "goawayCount": 0,
    "rstCount": 5
  }
}
```
Counters are live — no restart required. `activeSessions` is bounded by ≥ 0. `rstCount` counts only non-zero RST codes (normal stream close = code 0 = not counted).

**CVE coverage** — see `docs/security.md` for the full table. All mitigations are on by default:
| CVE | Name | Mitigation |
|---|---|---|
| CVE-2023-44487 | Rapid Reset | `maxSessionRejectedStreams: 100` + Node ≥ 20.12.1 |
| CVE-2024-27316 / CVE-2024-27983 | CONTINUATION flood | `maxSessionInvalidFrames: 1000` + Node ≥ 20.12.1 |
| CVE-2019-9514 | RST flood | `maxSessionRejectedStreams: 100` |
| — | HPACK bomb | `maxHeaderListSize: 65536` |
| — | Server push abuse | `enablePush: false` |

**HTTP/2 client resilience** (inter-bundle `self.query()` calls):

`handleHTTP2ClientRequest` retries failed requests up to 2 times with 500ms backoff on 2nd+ retry. Before sending on a cached session, validates freshness via a pre-flight PING (if no PONG received in 3s, evicts session and retries). Protects against silent TCP drops (observed with OrbStack Docker networking) and stale HTTP/2 sessions.

| Constant | Value | Purpose |
|---|---|---|
| `HTTP2_MAX_RETRIES` | 2 | Max retry attempts (3 total tries) |
| `HTTP2_RETRY_DELAY_MS` | 500 | Backoff delay on 2nd+ retry |
| `HTTP2_PREFLIGHT_STALE_MS` | 3000 | Session age threshold before pre-flight PING |
| `HTTP2_PREFLIGHT_DEADLINE_MS` | 1500 | Pre-flight PING timeout |

`GinaHttp2Error` codes: `TIMEOUT`, `PREMATURE_CLOSE`, `STREAM_ERROR`, `ECONNRESET`, `ECONNREFUSED`, `PREFLIGHT_TIMEOUT`, `PREFLIGHT_FAILED`. `ECONNREFUSED` is never retried. `retryCount` tracks attempts; `retriedOnce` is derived from `retryCount > 0`.

**GOAWAY logging** — when a cached HTTP/2 session receives a GOAWAY frame, the handler logs `console.warn('[http2] GOAWAY received — errorCode: X, lastStreamID: Y, session: Z')`. `errorCode` distinguishes clean server restarts (0 = NO_ERROR) from protocol errors (e.g. 1 = PROTOCOL_ERROR, 11 = ENHANCE_YOUR_CALM). `lastStreamID` indicates which streams were processed before the GOAWAY.

---

## Routing (routing.json)

```json
{
  "home": {
    "method": "GET",
    "url": "/",
    "param": { "control": "home" }
  },
  "user-get": {
    "method": "GET",
    "url": "/users/:id",
    "param": { "control": "getUser" }
  }
}
```

---

## OpenAPI spec generation

`gina bundle:openapi <bundle> @<project>` reads `routing.json` and emits an OpenAPI 3.1.0 `openapi.json` in the bundle's `config/` directory. No manual spec writing required.

Mapping from routing.json to OpenAPI:

| routing.json field | OpenAPI equivalent |
| --- | --- |
| `url` (`:param` syntax) | `paths` (`{param}` syntax) |
| `method` | HTTP operations under each path |
| `param.control` | `operationId` |
| `namespace` | `tags` |
| `requirements` (regex) | `parameters[].schema.pattern` |
| `requirements` (pipe-separated) | `parameters[].schema.enum` |
| `_comment` | operation `description` |
| `_sample` | `x-sample-url` extension |
| `param.title` | operation `summary` |
| `middleware` | `x-middleware` extension |
| `cache` | `Cache-Control` response header docs |
| `param.code` + `param.path` (redirects) | 3xx response with `Location` header |

To enrich the generated spec, add `_comment` (description) and `_sample` (example URL) fields to your routes. The `namespace` field groups operations into tags.

---

## MCP tool manifest generation

`gina bundle:mcp <bundle> @<project>` reads `routing.json` and emits a Model Context Protocol tool manifest (`mcp.json`) in the bundle's `config/` directory. Targets MCP spec revision **2025-06-18**. Pair with `gina bundle:mcp-start` (below) to actually serve the manifest over JSON-RPC.

One Tool is emitted per (route × URL variant × HTTP method) combination. Framework-internal routes (`/_gina/*`), HEAD, and OPTIONS are skipped. Cross-bundle proxy routes outside the current emission set are warned and skipped.

Mapping from routing.json to MCP Tool:

| routing.json field | MCP Tool equivalent |
| --- | --- |
| `namespace + "." + param.control` (fallback: routeName) | `name` (required, unique) |
| `param.title` (fallback: humanised routeName) | `title` |
| `method + " " + url` (+ `_comment` suffix) | `description` |
| URL `:param` names | `inputSchema.properties.<name>` (required string) |
| `requirements` (regex) | `inputSchema.properties.<name>.pattern` |
| `requirements` (pipe-separated) | `inputSchema.properties.<name>.enum` |
| Non-GET methods | extra `inputSchema.properties.body` (open object) |
| HTTP method | `annotations.readOnlyHint` / `destructiveHint` / `idempotentHint` |
| `url`, `method`, `namespace`, `param.control`, `_sample`, `scopes`, `middleware`, `middlewareIgnored`, `bundle`, `hostname`, `cache`, `requirements` | `_meta` (with `io.gina.*` prefix, for downstream dispatch) |

Multi-method routes (`"method": "GET, POST"`) emit one tool per method with `#<method>` suffix on `name`. Multi-URL routes (`"url": "/a, /a/:b"`) emit one tool per URL variant with `#<n>` suffix. Redirects are surfaced as read-only tools with a description noting the target.

Source-of-truth is `routing.json` directly — `bundle:mcp` does not read `openapi.json`. Both commands share `lib.routingIntrospect` for URL/method/requirement parsing.

Independent of `bundle:openapi`. No alias.

Output path override: `--output=/path/to/mcp.json`.

---

## MCP runtime server (stdio)

`gina bundle:mcp-start <bundle> @<project>` runs a live Model Context Protocol server for a single bundle over stdio (MCP spec revision **2025-06-18**, JSON-RPC 2.0, newline-delimited UTF-8). It reads `<bundle>/config/mcp.json` (written by `bundle:mcp`) and dispatches incoming `tools/call` requests as real HTTP requests against the running bundle's configured port on localhost.

**Prerequisites.** The bundle must already be running (`gina bundle:start`) and the manifest must exist (`gina bundle:mcp`). The server is stateless — it holds no session, no auth, nothing beyond what lives in `mcp.json` and the bundle's own routing.

**Stdio discipline.** An early intercept in `bin/cli` detects `bundle:mcp-start` in argv and redirects `process.stdout.write` to stderr before any framework module loads, stashing the real write on `process.__ginaMcpStdout`. The MCP wire uses the stashed write; framework logger output, bootstrap noise, and stray `console.log` calls all land on stderr, which MCP clients ignore. Without this, a single log line corrupts the JSON-RPC parser on the client side.

**Methods implemented.** `initialize`, `ping`, `tools/list`, `tools/call`, `notifications/initialized`, `notifications/cancelled`. Unknown methods return `-32601` (METHOD_NOT_FOUND). Argument validation failures return `-32602` (INVALID_PARAMS). Tool execution failures (upstream 4xx/5xx, ECONNREFUSED, timeout) are returned as `{content, isError: true}` per the MCP spec — never as JSON-RPC errors.

**Dispatch.** URL `:param` placeholders are substituted from tool arguments; remaining arguments become the query string for GET/DELETE/HEAD or the JSON body for POST/PUT/PATCH. `application/json` and `application/problem+json` responses become `structuredContent` + a text block. Default timeout 30 seconds.

**Warnings on stderr.** Staleness — `routing.json` mtime newer than `mcp.json` mtime. Session-scoped — any tool whose `_meta["io.gina.middleware"]` contains `auth`/`session`/`login`.

Baseline architecture lives in two new libs: `lib/mcp-server/` (transport-agnostic JSON-RPC + lifecycle) and `lib/mcp-dispatch/` (HTTP loopback). Both are registered in `lib.mcpServer` / `lib.mcpDispatch` — **go through the registry, bare-module resolution does not work in CLI daemon scope**.

Streamable HTTP transport (MCP Phase 2b) is deferred.

---

## Project config files

| File | Location | Purpose |
| --- | --- | --- |
| `manifest.json` | project root | Bundle registry — lists all bundles, their versions, src paths, and optional `gina_version` pins |

```json
{
  "name": "myproject",
  "version": "1.0.0",
  "scope": "local",
  "rootDomain": "localhost",
  "bundles": {
    "api": {
      "version": "0.0.1",
      "gina_version": "0.2.1-alpha.3",
      "src": "src/api",
      "link": "bundles/api",
      "releases": {}
    }
  }
}
```

`gina_version` is optional. When absent the bundle uses whatever version the socket server is running. `bundle:add` writes the current version automatically.

---

## Bundle config files

| File | Purpose |
| --- | --- |
| `app.json` | Bundle settings (port, region, encoding, session, middleware) |
| `routing.json` | URL route definitions |
| `settings.json` | Framework settings (locale, timezone) |
| `connectors.json` | Database connector declarations |
| `app.crons.json` | Scheduled tasks |
| `templates.json` / `statics.json` | Template and static file mappings |

All config files support `$schema` references for editor validation.

---

## CLI entry points

```bash
gina bundle:start <bundle> @<project>               # Start a bundle
gina bundle:start <bundle> @<project> --gina-version=0.1.8  # Start with a pinned framework version
gina bundle:stop <bundle> @<project>    # Stop a bundle
gina bundle:restart <bundle> @<project> # Restart a bundle
gina project:start @<project> --env=dev # Start all bundles in a project
gina project:stop @<project>            # Stop all bundles in a project
gina project:restart @<project>         # Restart all bundles in a project
gina bundle:add <bundle> @<project>     # Scaffold a new bundle (overwrites existing src)
gina bundle:add <bundle> @<project> --import  # Register existing bundle (preserves src)
gina bundle:remove <bundle> @<project>  # Remove a bundle
gina bundle:list @<project>             # List bundles (status, port summary, running state)
gina bundle:list --all                  # List bundles for every registered project
gina bundle:list @<project> --format=json  # JSON payload with ports, running, pid per bundle
gina bundle:status <bundle> @<project>   # Status of one bundle (running/stopped, pid, port, env)
gina project:status @<project>           # Status of every bundle in a project (all projects if omitted)
gina project:status --format=json        # JSON payload: per-project array of bundle status objects
gina service:list                       # List framework-internal services (@gina-only)
gina service:list --format=json         # JSON payload with ports, running, pid per service
gina connector:list                     # List connectors across every registered project (read-only)
gina connector:list @<project>          # List one project (shared + all bundles)
gina connector:list <bundle> @<project> # List the merged shared+bundle view a bundle sees at runtime
gina connector:list @<project> --format=json  # JSON payload with source, driver, installed/version fields
gina connector:add <name> @<project>    # Add entry to shared/config/connectors.json, print install hint
gina connector:add <name> <bundle> @<project>  # Add entry to <bundle>/config/connectors.json
gina connector:add redis @<project> --host=127.0.0.1 --connector-port=6379      # Shared Redis, type inferred
gina connector:add claude @<project> --connector=ai --protocol=anthropic:// --api-key='${ANTHROPIC_API_KEY}'  # AI with env-var secret
gina connector:add session @<project> --connector=redis --driver-version=^5.0.0 --force                       # Pin driver, overwrite
gina connector:rm <name> @<project>     # Remove from shared/config/connectors.json (refuses if any bundle still uses it)
gina connector:rm <name> <bundle> @<project>  # Remove from <bundle>/config/connectors.json (always proceeds; leaves shared)
gina connector:rm <name> @<project> --dry-run # Preview removal + sibling usage hint without touching the file
gina connector:rm <name> @<project> --force   # Remove shared entry even if bundles still reference it
gina connector:migrate @<project>       # Lint every connectors.json (shared + all bundles), dry-run by default
gina connector:migrate <bundle> @<project>    # Lint just the bundle's connectors.json
gina connector:migrate @<project> --fix       # Apply auto-fixable issues (injects missing $schema, preserves order + header)
gina connector:migrate @<project> --format=json  # JSON report with {project, scope, bundle, fixApplied, files[]} envelope
gina bundle:openapi <bundle> @<project> # Generate OpenAPI 3.1.0 spec from routing.json
gina bundle:oas <bundle> @<project>     # Alias for bundle:openapi
gina bundle:openapi @<project> --output=/path/to/spec.json  # Custom output path
gina bundle:mcp <bundle> @<project>     # Generate MCP tool manifest (spec 2025-06-18) from routing.json
gina bundle:mcp @<project> --output=/path/to/mcp.json       # Custom output path
gina bundle:mcp-start <bundle> @<project>   # Serve the manifest as a live MCP server over stdio (JSON-RPC 2.0)
gina view:add <bundle> @<project>       # Add HTML templates to a bundle
gina tail --follow                      # Follow logs
gina framework:set --env=dev            # Set framework setting
gina env:get --<key> @<project>         # Read a bundle/project setting
gina env:set --<key>=<value> @<project> # Write a bundle/project setting
gina port:list <bundle> @<project>      # List allocated ports
gina port:reset @<project> --start-port-from=4200  # Re-scan ports from a new base
gina-container                          # Foreground launcher for Docker/K8s
gina-init                               # Bootstrap ~/.gina/ from env vars (containers)
```

**CLI stubs — commands that appear in docs or help.txt but are NOT implemented (handler files are empty or missing):**

- `project:move` / `project:backup` / `project:restore` — empty or missing
- `framework:update` — empty handler
- `gina --status` / `gina -t` — not in aliases.json, no handler

All stubs are tracked in `ROADMAP.md § CLI` with target versions.

---

## Dev mode behaviour

- `NODE_ENV_IS_DEV=true` enables hot-reload.
- `WatcherService` starts automatically in dev mode and watches `controller.js`, `controller.render-swig.js`, and the bundle's `controllers/` directory.
- `require.cache` is evicted only when a watched file has actually changed (file-change-triggered eviction, not per-request).
- Falls back to per-request eviction when the watcher context is unavailable (production/non-dev env).
- SQL files are re-read from disk on every entity call (Couchbase/SQLite connectors).
- Static files are served with `cache-control: no-cache, no-store, must-revalidate` — 304 is never sent; browser always re-fetches.
- Do NOT rely on module-level state in files that are hot-reloaded.

---

## Ports

| Port | Role |
| --- | --- |
| 8124 | Framework socket server (online commands) |
| 8125 | MQ listener (log tail) |

**Gina infrastructure reserved range: 4100–4199** — never assigned to bundle HTTP servers; `gina port:scan` skips this range automatically (RFC 6335).

| Port | Role |
| --- | --- |
| 4100 | Reserved — future socket server migration (currently 8124) |
| 4101 | Reserved (Inspector moved to `/_gina/inspector/` built-in endpoint) |
| 4102 | engine.io internal transport (moved from 8888, Jupyter conflict) |
| 4103–4199 | Reserved for future Gina infrastructure |

Bundle HTTP ports are allocated per-project in `~/.gina/ports.json`.

**Port scanner window** — the scanner searches `start + max(899, limit + 99)` ports (capped at `maxEnd = 49151`). Default start is `3100`, giving a window of `3100–3999`. If all ports in the window are in use, the scanner fails with "Maximum port number reached". Fix: `gina port:reset @<project> --start-port-from=4200` (not 4100 — that's reserved).

---

## Inspector (formerly "Beemaster")

The Inspector is a dev-mode SPA embedded in every bundle at `/_gina/inspector/`. Served by the bundle's own HTTP server — no separate port, process, or project registration. Same origin as the monitored bundle, so `window.opener.__ginaData` is always accessible.

**Data channels:**

- **`/_gina/agent` SSE** — remote/standalone mode. Activated by `?target=<bundle_url>` query param. Streams `event: data` (ginaData updates) + `event: log` (server log entries) over a single SSE connection. When active, all other channels are skipped. The "No source" overlay provides a manual connect form that navigates to `?target=<url>` — users can type a bundle URL (scheme auto-prefixed) instead of editing the address bar. Outside dev mode the endpoint is closed unless `inspector.agent.enabled` + `inspector.agent.key` are set in settings.json (#INS9b): the key is presented via the `x-gina-inspector-key` header or a `?key=` query param and compared in constant time (the SPA connect form has an optional key field), production-safe toggle; in dev it stays open and keyless, and enabled-but-bad-key returns 401. In production this authenticates server-log streaming + connection identity only — per-request data/query/flow stays dev-gated (#INS10).
- **`window.opener.__ginaData`** — same-origin polling every 2 s (primary). Always works since the Inspector and the monitored page share the same origin.
- **engine.io** — real-time push when the monitored bundle has `ioServer` configured. The Inspector connects to the bundle's own port via `window.location.port`.

**Log channels:**

- **Client-side:** `window.opener.__ginaLogs` — array filled by the `__logsScript` capture script injected in dev mode.
- **Server-side (SSE):** `/_gina/logs` endpoint streams log entries via SSE. Taps `process.on('logger#default')` — no logger modification needed. Dev mode only.
- **Server-side (SSE agent):** `/_gina/agent` endpoint `event: log` — combined stream in standalone mode. Same log format as `/_gina/logs`.
- **Server-side (engine.io):** `{ type: 'log', data: {...} }` WebSocket messages pushed from the `ioServer` connection handler when engine.io is configured.

**Lazy activation** — `process.gina._inspectorActive` is `false` at startup. Set to `true` when the Inspector SPA, `/_gina/logs`, or `/_gina/agent` is first accessed. All profiling infrastructure (timeline init, query log wiring, Inspector payload emission) is gated on this flag. JSON responses stay clean until a developer actually opens the Inspector.

**Dev mode injections** (both added to user pages in dev mode, before `</body>`):
- **`__logsScript`** — patches `console.log/info/warn/error/debug` to push `{ t, l, b, s }` entries to `window.__ginaLogs`. The Inspector reads this via `window.opener.__ginaLogs`.
- **`__gdScript`** — `window.__ginaData = { gina: {...}, user: {...} }`. `__gdPayload` also stored on `self.serverInstance._lastGinaData` for engine.io. Also emits `process.emit('inspector#data', __gdPayload)` for `/_gina/agent` SSE clients.

**JSON API coverage** — `render-json.js` emits the same `process.emit('inspector#data')` event and stores `_lastGinaData` when `_inspectorActive` is true. Environment is built from `getContext('gina')` + `local.options.conf` (since `data.page` doesn't exist in the JSON path). The JSON response body itself is NOT modified — Inspector data travels only via the SSE/engine.io channel.

**`statusbar.html` link** — simple relative `/_gina/inspector/` (opens in new tab, same origin).

**Serving** — `server.js` `onRequest()` matches `GET /_gina/inspector/*` in dev mode (engine-agnostic — works with both Isaac and Express). Files served from `core/asset/plugin/dist/vendor/gina/inspector/` with `no-cache` headers. Requests for `/_gina/inspector/` or `/_gina/inspector` serve `index.html`. MIME types: `html` → `text/html`, `js` → `application/javascript`, `css` → `text/css`, `svg` → `image/svg+xml`.

**Source** (`core/asset/plugin/src/vendor/gina/inspector/`): organized by type — `html/` (index.html, statusbar.html), `js/` (inspector.js), `sass/` (inspector.scss), `css/` (compiled intermediate), `img/` (logo.svg). SCSS source uses nesting and CSS custom properties for theming. Build script Phase 3 compiles SCSS and copies to flat dist. Inspector SASS is excluded from Phase 2 auto-discovery — CSS is served separately at `/_gina/inspector/inspector.css`, not concatenated into `gina.min.css`.

**Dist** (`core/asset/plugin/dist/vendor/gina/inspector/`): flat layout — `index.html`, `inspector.js`, `inspector.css`, `logo.svg`.

**Unit tests:** `test/core/inspector.test.js` (renamed from `beemaster.test.js`).

**Query Instrumentation (QI):**

Dev-mode query instrumentation captures every database query tied to the current HTTP request and surfaces them in the Inspector "Query" tab.

- **Per-request isolation** — `process.gina._queryALS` (`AsyncLocalStorage`) binds the query log to the request's async context via `enterWith()` in `setOptions()`. Created once in `controller.js`, survives dev-mode cache busts.
- **Connector-level interception** — instrumented in each connector's query execution path, not in `entity.js`. Connector-generated methods use closure variables — entity wrappers can't see them. Query entry shape: `{ type, trigger, statement, params, durationMs, resultCount, resultSize, error, source, origin, connector, indexes }`.
- **Supported connectors** — Couchbase (`type: 'N1QL'`, `connector: 'couchbase'`), MySQL (`type: 'MySQL'`, `connector: 'mysql'`), PostgreSQL (`type: 'PG'`, `connector: 'postgresql'`), SQLite (`type: 'SQL'`, `connector: 'sqlite'`). SQLite uses synchronous `execute()` (try/catch) instead of async callbacks.
- **`origin` tagging** — `infos.bundle` (set on `Entity.prototype.bundle` at registration). **`connector` tagging** — hardcoded string per connector.
- **Cross-bundle propagation** — when bundle A calls B via `self.query()`, B's queries travel back as `__ginaQueries` in the JSON response (embedded by `render-json.js`). A's `query()` callback extracts, merges into its own `local._queryLog`, and deletes the field before data reaches the controller action.
- **Index reporting (Couchbase)** — `extractIndexes(profile)` walks `meta.profile.executionTimings` tree (`~child`/`~children` recursion) to find `IndexScan3`/`PrimaryScan3`/`ExpressionScan`/`KeyScan` operators and extract index names. Two extraction paths: `meta.profile` (fast, SDK v3) and EXPLAIN fallback (SDK v4 — C++ binding doesn't surface `meta.profile`). `_explainCache` Map caches per unique statement. `USE KEYS` queries detected via `ExpressionScan`/`KeyScan` operators. Enabled by `queryOptions.profile = 'timings'` in dev mode for SDK v3+. Three visual states: green (secondary), amber (primary scan), red (no index). Grey N/A badge for unsupported connectors.
- **Index reporting (SQL connectors — #QI1 Phase A)** — `sql-parser.js` provides `parseCreateIndexes(src)` (parses `CREATE [UNIQUE] INDEX` statements → `{ tableName: [{ name, primary }] }` map) and `extractTargetTable(queryString)` (extracts primary table from SELECT/INSERT/UPDATE/DELETE). Each SQL connector reads an optional `indexes.sql` from the bundle's `sql/` directory at init time, builds an in-memory `_knownIndexes` map. QI resolves `_queryEntry.indexes` per query: `null` when no `indexes.sql` (N/A badge), `[]` when file exists but no index covers the table (red badge), `[{name, primary}]` when matched (green badge).
- **Live index introspection (#QI1 Phase B)** — `/_gina/indexes` endpoint (dev-mode only, both `server.js` and `server.isaac.js`) queries actual databases for their indexes. Emits `process.emit('inspector#indexes', callback)` — each SQL connector registers a listener inside its constructor closure and responds with live index data. MySQL queries `INFORMATION_SCHEMA.STATISTICS`, PostgreSQL queries `pg_indexes`, SQLite uses `sqlite_master` + `PRAGMA index_list`. Collector pattern with 2-second timeout. `_liveIntrospected` flag prevents re-querying. Since #QI Phase C.2 the live data also carries each index's columns (STATISTICS `COLUMN_NAME`/`SEQ_IN_INDEX`, `pg_indexes.indexdef` parse, `PRAGMA index_info`), so column-level coverage survives a refresh. Inspector SPA calls the endpoint lazily (only when N/A badges are present), caches result, and re-renders Query tab with resolved indexes. Eliminates the need for manual `indexes.sql` files.
- **Inspector UI** — split trigger badge (`entity` | `method`), SQL syntax highlighting, params table (`$1`/`$2` → value), free-text search bar with 200ms debounce, per-card weight color-coding (`bm-stat-*`), filter persistence (lang/connector/bundle in localStorage), pagination (20 cards default, "Show all" button).

**UX enhancements:**

- **Logo watermark** — Gina logo displayed as a fixed-position watermark (bottom-right, 0.045 opacity) on content panes. `filter: invert(1)` for dark theme. SVG served at `/_gina/inspector/logo.svg`.
- **Window geometry persistence** — Inspector popup position/size saved to `localStorage.__gina_inspector_geometry` on resize (debounced) and `beforeunload`. `statusbar.html` restores geometry on next open via `window.open()` features string.
- **Env panel height persistence** — resizable environment panel height saved to `localStorage.__gina_inspector_env_height`.
- **Drag-to-select log rows** — `mousedown` → `mousemove` → `mouseup` builds a range selection in real time. Plain click (no movement) copies the single row with a green flash + left accent feedback.
- **Copy badge fade-out** — after copy, badge shows "Copied", fades out (400ms opacity transition via `setTimeout`), then clears the selection. `transitionend` was unreliable — replaced with `setTimeout`.
- **Selection styling** — 3px left amber accent (`::before` pseudo-element), subtle amber background, rounded corners (6px) on first/last rows of contiguous groups. CSS `:has()` and `+` sibling combinators detect group boundaries.
- **Tab layout presets** — segmented control (joined buttons with SVG icons) in settings panel, split from other settings by a thin divider. Four presets: Balanced (default: Data, View, Logs, Forms, Query, Flow), Backend (Data, Query, Flow, Logs, View, Forms), Frontend (View, Data, Forms, Logs, Query, Flow), Custom (user-defined drag-to-reorder). Color-coded preview pills below the control show the active order at a glance. `applyTabLayout()` reorders DOM nodes (preserving listeners). `renderLayoutPreview()` rebuilds the pill row. Persisted in `localStorage.__gina_inspector_tab_layout`. Custom order persisted separately in `localStorage.__gina_inspector_tab_layout_custom` as a JSON array. Custom mode adds `.bm-drag-mode` to the tab bar — tabs become draggable with grab cursor, 2px amber drop indicator, and 4px drag threshold.
- **Performance anomaly alerts** — View tab dot indicator (8px circle, heartbeat animation) activates when page metrics exceed thresholds (load > 3s warn / 10s critical, transfer > 1MB / 5MB, FCP > 2.5s / 4s, query total > 500ms / 2s, query count > 20 / 50). Affected badges get `bm-perf-warn` (amber border) or `bm-perf-critical` (red border) class. Tooltip shows threshold details. `checkPerfAnomalies(metrics, queries)` runs on every poll cycle.

---

## Client-side plugins (`core/asset/plugin/`)

**Source:** `src/vendor/gina/` — AMD modules bundled into `dist/vendor/gina/js/gina.min.js` via RequireJS + Closure Compiler.

**Popin** (`src/vendor/gina/popin/main.js`) — client-side dialog/modal component. Manages popup lifecycle (register, load, open, close, destroy), XHR content loading, event wiring, and `<dialog>` element support.

- **Performance:** `_nextId()` counter replaces `crypto.randomUUID()`; `querySelectorAll` replaces `getElementsByAttribute`/`getElementsByTagName` for DOM scanning; `classList` API replaces `className` string manipulation; cached `RegExp` for click handlers; DOM-injected `<script>`/`<link>` elements replace XHR+`eval()` for asset loading; per-load XHR creation prevents concurrent state sharing; `popinBind` dedup guard avoids redundant binding.
- **`popinDestroy(name)`** — full teardown: closes if open, removes DOM element, removes all event listeners (loaded/ready/open/close), cleans up `instance.$popins` and `registeredPopins`, resets `activePopinId`, fires `destroy` event.
- **`$popin.$headers`** — tracks injected `<script>` and `<link>` element IDs. Cleaned up in `popinClose`.
- **`ginaToolbar` integration** — 13 refs; `popinClose` is the only `restore()` call site. `update()` calls sync XHR overlay data to the Inspector.

**Events** (`src/vendor/gina/utils/events.js`) — core event system with XHR lifecycle management (`setupXhr`, `handleXhr`). Bundled into `gina.min.js`.

**Binding** (`src/vendor/gina/helpers/binding.js`) — processes binding descriptor arrays (call/handler/payload). AMD module.

**Unit tests:** `test/core/popin.test.js` (65 tests — perf optimizations, DOM injection, dedup guard, per-load XHR, popinDestroy, registeredPopins, events.js regex/typo, binding.js precedence, dist verification, CSP-safe close, showModal dev/prod parity, opt-in `preOpen` skeleton pre-open).

---

## Home directory (~/.gina/)

```
~/.gina/
  ${shortVersion}/
    settings.json       Per-version framework settings (port, env, scope, locale)
  projects.json         Registered projects
  main.json             Framework install metadata
  ports.json            Port allocations by protocol/scheme/bundle
  ports.reverse.json    Reverse map port → bundle
  run/                  PID files for running bundles
  log/                  Framework log files
```

---

## Common gotchas

1. **`services/` is gitignored** — built-in bundles not published to npm.
2. **Commit messages**: no AI references, no co-author footers. Imperative or gerund style.
3. **"services" = bundles** — when asked to start/stop/restart services, interpret as Gina bundles.
4. **`inherits(Child, Parent)`** calls both constructors on a fresh `this` — not the same as `Object.create`.
5. **`getConnection()` in tests**: pass `{ connector: mockConn }` as the third arg to EntitySuper.
6. **`super()` does not exist** — use `Parent.call(this, ...)` for constructor chaining.
7. **Roadmap sync**: any roadmap change must be applied to both `ROADMAP.md` and `~/Sites/gina/docs/repo/docs/roadmap.md`.
8. **`${variable}` convention in all docs** — Every path, filename pattern, and inline variable reference in Gina documentation uses `${variable}` (with the leading `$`), never bare `{variable}`. This matches the `whisper()` interpolation syntax. Breaking this convention makes variables look like plain text. Applies to paths (`~/.gina/${shortVersion}/`), filename patterns (`connectors.${env}.json`), framework paths (`${core}/controller/`), and cache paths (`${cache.path}/${bundle}/`). Always scan existing docs before writing new content to verify consistency.
9. **`gina_version` in `manifest.json`** — optional per-bundle pin. When set, that bundle's spawned process gets `GINA_VERSION`, `GINA_FRAMEWORK_DIR`, and `GINA_CORE` overridden in its context. The socket server is unaffected. CLI flag `--gina-version` takes priority over the manifest declaration.
10. **`self.render(err)` with object errors**: `render-swig.js` normalises `data.page.data.error` and `data.page.data.message` to strings before building the error response — object fields (e.g. `ApiError` instances, `{message, code}` objects) no longer render as `[object Object]` (#Q2). The normalized string is also used in the `console.error` log that fires before `throwError`.
12. **Static file caching — production vs dev**: In production (`NODE_ENV_IS_DEV` not set), `handleStatics` sends `ETag` (`"<size>-<mtime>"`) and `Last-Modified` on every 200 response. If the browser resends `If-None-Match` or `If-Modified-Since`, a **304 Not Modified** (no body) is returned. `If-None-Match` takes precedence. In dev mode (`isCacheless=true`), 304 is never sent — all statics are served fresh with `cache-control: no-cache, no-store, must-revalidate`. The ETag format matches Express/serve-static: `"<byteSize>-<mtimeMs>"`. Both HTTP/1.x and HTTP/2 paths implement the same logic.
13. **`docs/repo` is a separate git repository** — `~/Sites/gina/docs/repo` cannot be staged or committed from within `gina-dev`. Always `cd ~/Sites/gina/docs/repo` before adding/committing docs changes. `git add ~/Sites/gina/docs/repo/...` from inside `gina-dev` throws "outside repository".
14. **Sidebar icon key = `sidebar_label.toLowerCase()`** — `Root.js` `NAV_ICONS` map in the Docusaurus site uses the **lowercase** value of the page's `sidebar_label` frontmatter as the map key. If an icon doesn't render, verify the key matches exactly (e.g. `sidebar_label: Security` → key `'security'`).
21. **`throwError(res, code, stringMsg)` preserves the status code** — calling `self.throwError(res, 404, 'Not found')` sends 404. Previously the string-message branch unconditionally replaced `code` with `res.status || 500`. Fixed in `core/controller/controller.js`. If you previously worked around this by wrapping your message in `new Error(...)`, that still works unchanged.
22. **Dev mode (cacheless) reloads the controller module on every request** — `isCacheless()` returns `true` in dev, so `require.cache` for the controller file is deleted before each dispatch. Module-level `var store = {}` resets on every request. For in-process persistence (state that survives hot-reloads but resets on `bundle:restart`), attach to `global`: `if (!global.__myStore) global.__myStore = {}; var store = global.__myStore;`. For durable persistence across restarts, use a database or a file.
25. **Framework edits in `gina-dev` have no effect on a running bundle until the live install is also patched** — bundles always load from `~/.npm-global/lib/node_modules/gina/` (the main worktree). Editing `gina-dev/framework/…` only affects the next `git merge --ff-only` into `develop`. When testing a framework fix mid-session, apply the same edit to both `gina-dev` and `~/.npm-global/lib/node_modules/gina/` in parallel, then restart the affected bundle.
26. **`throwError` call-signature reference** — four valid forms: `(errorObj)` (1-arg object/Error); `(res, string)` (2-arg, code inferred from `res.status || 500`); `(res, code, Error)` (3-arg with Error object); `(res, code, string)` (3-arg with string message, code preserved as of this fix). Always `return` immediately after calling `throwError` in a controller action.
28. **Connector code is NOT hot-reloaded** — `core/connectors/*/index.js` modules are loaded once at bundle startup via entity registration, not via `lib/index.js`. `refreshCore()` does not evict them. Any change (new QI fields, `extractIndexes()`, `profile: "timings"`) requires `gina bundle:stop` + `gina bundle:start` or `docker restart <container>`.
29. **Couchbase SDK v4 `meta.profile` is always undefined** — the C++ binding (`couchbase-cxx-client`) does not forward the `profile` field from the N1QL server response. `meta.profile` is `undefined` even with `profile: 'timings'` in the request. The EXPLAIN fallback (`explainForIndexes()`) runs `EXPLAIN <statement>` asynchronously and caches results per unique statement. First request for a new statement may show N/A; subsequent requests hit the cache. `USE KEYS` queries use `ExpressionScan`/`KeyScan` operators (no `index` field) — detected separately by `extractIndexes()`.
30. **Test lookback constants are fragile** — `inspector.test.js` section 08 scans backward from `_devLog.push(_queryEntry)` by byte distance looking for `envIsDev`. Adding helper functions or fields between the guard and the push increases the distance. Current constant: 1800 chars. Always check after adding code near the connector QI guard.
107. **Controller files must NOT contain `require` or `inherits`** — the router (`router.js:557-560`) calls `inherits(Controller, SuperController)` automatically before every dispatch. Writing `var SuperController = require('../../../core/controller/controller')` or `Controller = inherits(Controller, SuperController)` inside a controller file is wrong and will break on path changes. The correct pattern is a plain constructor function with `this.action = function(req, res) {}` closure-style methods — matching the boilerplate in `core/template/boilerplate/bundle/controllers/controller.content.js`. Never use `Controller.prototype.method` either; actions are defined as properties on `this` inside the constructor.
31. **Inspector (formerly Beemaster) is a built-in dev inspector, not a service or separate bundle** — served at `/_gina/inspector/` by `server.js` `onRequest()` in dev mode (engine-agnostic — works with both Isaac and Express). Runs inside the bundle's own process — no project registration, no separate port, no auto-start spawn. The SPA files live in `core/asset/plugin/dist/vendor/gina/inspector/` (framework tree). Same origin as the monitored bundle — `window.opener.__ginaData` always works. Only served when `isDev` is true; production bundles never expose the Inspector.
32. **`/_gina/*` are built-in endpoints, not services** — all `/_gina/*` handlers (healthcheck, assets, cache/stats, info, inspector, logs) run inside the bundle's own HTTP server process. No separate process or port. Isaac is the source of truth, but every handler must also work with Express. New `/_gina/*` endpoints go in `server.js` `onRequest()`. Isaac-specific fast-paths (e.g. `readSync`, `server._cached`) can stay in `server.isaac.js` but the base functionality must exist in `server.js`. Existing Isaac-only handlers (healthcheck, assets/routing.json, cache/stats, info) are a known gap being ported incrementally.
33. **`query()` callback — `false` is the success sentinel** — `self.query()` calls `callback(false, data)` on success, not `callback(null, data)`. Use `if (err)` as the error guard — it works because `false` is falsy. Never check `err === null` (always false on success). Never use `err = merge(err, {...})` on a real Error object — `merge()` destroys `message` and `stack`. Assign extra properties directly: `err.session = userSession`.
34. **`server.js` is NOT hot-reloaded** — `refreshCore()` evicts `lib/index.js` and `plugins/index.js` from `require.cache`. It does NOT evict `server.js`, `server.isaac.js`, or `server.express.js`. Changes to these files require a full bundle stop + start (or container restart). A page refresh is not enough.
37. **`handle()` is async without `.catch()`** — `handle(req, res, next, bundle, pathname, config)` at `server.js:3095` is called without `await` or `.catch()`. Any unhandled throw becomes a silent unhandled promise rejection. When debugging routing crashes, wrap the body in try-catch temporarily.
38. **Inspector CSS: `<select>` and `<input>` need separate rules** — macOS native `<select>` ignores `line-height` for vertical text centering; use explicit vertical `padding` (e.g. `4px 8px`) instead. `padding: 0` + `line-height: 26px` works for `<input>` but leaves `<select>` text cramped/clipped. Keep `--sans` for selects and `--mono` for search inputs — mono is wider and looks awkward in native option lists. When unifying toolbar styles across Inspector tabs, always split `select` and `input` CSS rules.
39. **`getAssets()` warns on inline `<script>` tags** — `server.js:811` regex matches all `<script>...</script>` tags in the rendered HTML, including inline ones. Inline scripts have no `src` attribute, so the `urlArr` match at line 927 returns `null`, causing `urlArr.length` to throw into the catch block that prints `"Problem with this asset"`. Fix: skip `<script>` tags without `\ssrc\s*=` after the tag detection block. The `\s` before `src` prevents false matches on `data-src`. Inline scripts are not external assets — they have no URL to catalog.
40. **`queryParams` vs `queryOptions` at `conn._cluster.query()`** — the Couchbase connector's `conn._cluster.query()` must receive the full `queryOptions` object (which includes `parameters: queryParams`, `profile`, `scanConsistency`, `adhoc`), NOT the raw `queryParams` array. Passing `queryParams` silently drops all non-parameter options because the SDK only reads named properties from the options object. This bug existed at lines 892 and 934 for years, invisibly disabling `profile: 'timings'`, `scanConsistency`, and `adhoc: false` on every SDK v3+ query.
41. **Option B is the active dispatch path in the Couchbase connector** — `register()` in `readSource()` has two paths: Option A (`self._isRegisteredFromProto === true`) and Option B (`!self._isRegisteredFromProto`). Option B is always active for all connector-generated entity methods. When adding debug logging or QI instrumentation to the query execution path, place it in Option B — Option A is dead code.
42. **Validator touched-field-only error display** — the validator runs a global validation pass on every field blur to determine submit button state (`isFormValid`). The global pass must NOT display errors for untouched fields — only for `event.target.name` (the field the user actually touched). Two call sites in `validator/src/main.js`: ~line 3337 (within the `isFormValid && gResult.error` block) and ~line 4865 (same pattern in the second validation path). The global pass still correctly disables/enables the submit button.
108. **Docker `node_modules` isolation** — Docker containers using `node_modules` anonymous volumes have their own copy of all npm packages. Edits to `node_modules/` on the host are invisible inside the container. To debug SDK-level behaviour, either `docker exec` and edit inside the container, or add logging to the framework connector code (which IS bind-mounted) rather than the SDK itself.

50. **Leak-scan & public-surface discipline — tooling, sidecar pattern, recovery** — covers four leak surfaces (commit messages, changie YAML bodies, JSDoc in shipped source, tracked-script body content); five layered defenses (`.gitignore` → `.npmignore` → pre-commit → CI scan → publish prepack); `.gitignore` and `.npmignore` are independent files and patterns are NOT auto-synced (every new gitignored file must be cross-checked against both); `package.json files` whitelist bypasses `.npmignore` for nested paths (#S4 audit, 80MB → 6.9MB tarball decision); sidecar pattern (`script/.private-tokens.json` + shared loader) for tooling that names what it scans for; `git filter-repo` operational gotchas (prior-run prompt, origin removal, committer-date SHA churn, local-only-tag false positive, `--replace-text` ≠ `--replace-message`); GitHub branch protection `allow_force_pushes: false` blocks ALL force-pushes including admin (subfield endpoint returns 404; web UI toggle has been observed to silently no-op); cwd persists across the local-tool harness's Bash tool calls (one chained `cd` affects subsequent calls — silent wrong-repo trap); doc-rule → harness hook escalation when a documented HARD RULE leaks twice on the same surface within weeks.

54. **Bundle `config/*.json` shared → bundle overlay is key-level deep merge, not whole-file replace** — `core/config.js:1752-1757` runs `merge(sharedMain, jsonFile, true)` AFTER the bundle file loads. Shared-only keys are preserved; bundle wins on conflicting keys. Applies to `connectors.json`, `env.json`, `settings.json`, and every other per-bundle config file that also has a `shared/config/<same-file>.json` sibling. Reading only the bundle file for config inspection is a silent half-picture — any CLI or tool that inspects per-bundle config must reproduce the overlay merge.

57. **`schema/connectors.json` — `connector` field is the driver-type identifier, not the logical key** — entries are keyed by logical name (e.g. `"sessionStore"`, `"cache"`, `"primary"`); the driver is identified by the `connector` enum field inside each entry (`couchbase` / `mysql` / `postgresql` / `sqlite` / `redis` / `ai`). When the logical key name differs from the driver, the `connector` field is the authoritative signal. Do not introduce a separate `driver` field — it would be redundant. The `version` field (added 2026-04-21, #CN10 session 2) is optional; holds a semver range or exact version used by `connector:add --driver-version=…` for the `npm install` hint and the future `--install` flag.

61. **Negative-invariant tests enforce "we did NOT do X"** — source-inspection unit tests usually assert presence (this function exists, this regex fires). The mirror form — `assert.ok(!/require\(.*connector\/migrate/.test(src))` against `core/config.js` and `bin/cli` — locks in architectural decisions that must not be silently reversed. Use when a session deliberately does NOT ship a feature, does NOT add an integration point, or a file MUST NOT import from a specific path. The test message should name the future release where the change is expected (e.g. "deferred to 0.4.0 alongside #CN8") so a future session removing the negative invariant does so consciously. Don't enforce stylistic preferences as negative invariants — only real architectural rules. Precedent: `test/lib/connector-migrate.test.js` "framework hook absent" block (2026-04-21).

64. **MCP Streamable HTTP default security posture — loopback bind, Origin allowlist, bearer optional, OAuth out of scope** — when `bundle:mcp-start --transport=http` lands, the canonical security model is (1) bind `127.0.0.1` by default via the framework's existing `host_v4` convention (same chain as the MQ listener and CLI daemon socket — `GINA_HOST_V4` env from `~/.gina/<shortVersion>/settings.json > host_v4`); (2) `Origin` allowlist with a built-in loopback set (`http(s)://localhost`, `127.0.0.1`, `[::1]` on any port) + DNS-rebinding-mitigation 403 on mismatch; (3) static `--auth-token` bearer optional (constant-time via `crypto.timingSafeEqual` with length-mismatch short-circuit — `timingSafeEqual` throws on unequal-length buffers); (4) **OAuth 2.1 is deliberately out of scope** — the community pattern is a reverse proxy (oauth2-proxy, Traefik ForwardAuth, nginx `auth_request`) that handles the OAuth dance and forwards a static-bearer or no-auth request to gina. Traps: (a) WHATWG `URL` returns IPv6 hostnames bracketed (`[::1]`, not `::1`) — loopback check must accept both forms or `http://[::1]:8080` silently 403s; (b) `res.writeHead(status, {...})` passes headers via object — use `setHeader` if you want setHeader + writeHead to merge, or merge manually in one writeHead call; (c) 403 on disallowed Origin must NOT echo CORS (would defeat the check) but 401 on missing/invalid bearer MUST echo CORS (the client is legitimate, it just needs to auth — without ACAO the browser hides the error body); (d) preflight bypasses bearer (browsers cannot carry `Authorization` on an OPTIONS preflight); (e) `http.Server.close()` waits for keep-alive timeout (Node default ~5s) even when no request is in flight — set `keepAliveTimeout = 1` + call `closeIdleConnections()` on stop so SIGTERM shutdowns drain promptly. Library: `lib/mcp-http/`. CLI surface: `--transport=stdio|http`, `--http-host`, `--http-port`, `--max-in-flight`, `--auth-token`, `--cors-origin`. Each flag has a `mcp.json > server > <field>` manifest fallback. Precedent: `#AI8 Phase 2b` (sessions 1–4, commits `79ed1fec5`, `13f767cff`, `1c6d39de3`, `<S4>`, 2026-04-22).

65. **Template engines — `lib/swig-resolver` + `lib/nunjucks-resolver` + per-request env mutation invariants** — process-cached resolver-based opt-in for both engines: `core/server.js:initSwigEngine` / `initNunjucksEngine` load via `lib.swigResolver.load()` / `lib.nunjucksResolver.load()` during bundle init; `controller.js this.render()` reads `local.options.conf.content.settings.render.engine` and dispatches to `controller.render-swig.js` (default) or `controller.render-nunjucks.js`. Both delegates receive the same `deps` object (`self`, `local`, `getData`, `hasViews`, `headersSent`, `setResources`, etc.); `process.gina._swig` / `process.gina._nunjucks` survive `refreshCoreDependencies()` evictions. Both: dev-mode mtime hot-swap on project's `package.json`, first-wins standalone-mode contract, `[<engine>-resolver]` warning logged on safety-gate fallback. **Swig**: framework fallback enabled (always-installable), version floor `DEFAULT_MIN = '2.0.0'` (bump in same commit as fork-API dependence — see global "Swig version sync" rule), three safety gates (package-name allowlist preventing CVE-2023-25345 re-entry, same-major rule, min-version floor), Phase 7 build copies upstream esbuild output to `dist/swig.min.js` (no longer Closure-compiles `bin/swig.js` — swig 2.0.0's `swig-core` lazy require breaks Closure's static analysis). **Nunjucks**: NEVER framework-bundled (resolver only looks in `<projectPath>/node_modules/nunjucks/`), `load()` throws `NUNJUCKS_NOT_INSTALLED` with no fallback (bundle startup fails loud, not mid-render). `new nunjucks.Environment(loader, ...)` cached per template root on `process.gina._nunjucksEnvs`; dev-mode `FileSystemLoader({ noCache: true })` for `.njk` template hot-edit. **Render-nunjucks.js implementation invariants**: per-request `addFilter → render` mutation of cached env is safe ONLY because `env.render()` is synchronous (Node's event loop serialises requests; an async-render port would need a per-request env, no cache); pre-render `setResources()` + post-render `injectAssets()` cooperate via exact-substring user-placement detection (#NJ2) — pre-only loses auto-injection, post-only double-injects on opt-in templates; both wrap bodies in try/catch (must NOT 500 the page); `injectAssets` runs BEFORE `injectInspectorScripts` (Inspector payload stays last `<script>` before `</body>`); `isWithoutLayout` filters via `Collection.find({isCommon:false},{isCommon:true,name:'gina'})` on a `JSON.clone(localTemplateConf)` to keep common-gina assets while dropping common-other. Test-author traps (both resolvers): mtime collision within same wall-clock ms requires monotonic counter; macOS `os.tmpdir()` symlink requires `fs.realpathSync` for `require.cache` key parity.

72. **Vendored-deps + Socket / supply-chain visibility — enumeration, peerDependencies, vendored sub-`package.json`, build-bundle orphans** — Socket's category flags are boolean per package (one site fires for the whole tarball); enumerate via `npm view <pkg>@<ver> dist.tarball` + `curl` + `tar tzf` (NOT `npm pack --dry-run` — triggers `prepare_version.js`'s "Prerelease update" commit). Post-#SCS1+SCS1b+SCS1d+SCS1e live count: 12 eval sites in `framework/v*/` (15 of 24 cleared); load-bearing circular-require workarounds in `lib/logger`/`file/index.js`/`mq/index.js` are FIRST suspects for any `eval`/`new Function` site wearing a "publishing hack" or "needed for unit tests" comment. **`peerDependencies` aggregate into the dep graph regardless of `optional: true`** — Socket / Dependabot / `npm audit` all read peerDeps unconditionally; fix pattern is to remove from `peerDependencies` entirely and move version ranges to a lib-local registry (gina's `lib/connector-registry/`). **Vendored sub-`package.json` files leak declared externals through dep-graph edges** (`busboy → streamsearch`) — but this is BY DESIGN and load-bearing for CVE visibility; do NOT strip (incorrect strip in `4a29ca0c` reverted in `e5d5d0a2` alpha.6). **Reachability probe before adding eval sites to a session scope**: confirm file path matches one of the 5 bundled-source patterns enforced by `.githooks/pre-commit`. **Security-tagged source commits MUST rebuild the bundle in the same commit** (`.githooks/pre-commit` + `.github/workflows/bundle-freshness.yml` enforce). **SASS auto-discovery** in `core/asset/plugin/build` is independent of `build.json` JS alias map — three-probe orphan check (a/b in JS aliases vs c in `sass/` dir) catches CSS-only orphans like the toolbar 22.5KB stale CSS shipped pre-`69fb32fc`. **"Kept in sync by hand" anti-pattern** across N≥2 files always drifts; cure is a shared lib + supply-chain cleanup as forcing function.

76. **Docusaurus pages with `id: <slug>` frontmatter are served at `/<ns>/<id>`, not `/<ns>/<filename>` — external references using filename-form land on 404.** Diagnostic: if a doc URL 404s, probe sibling pages in the same subsection — all siblings 404 + section root (`/<ns>/`) 200 = the `id:` trap, not a missing page. Section-index pages (`index.md`) are immune (no `id:` override). Fix (minimum blast radius): add `slug: /<ns>/<filename>` to each page's frontmatter — keeps the `id:` that other pages reference in cross-links, overrides only the URL. Dropping `id:` also works but requires sweeping every `prereqs`/doc-id reference across the site. Precedent: 2026-04-24 link-health audit surfaced 11 × 404 across `/docs/cli/*` at filename-form URLs (all pages resolve at `/docs/cli/cli-<filename>` because of `id: cli-<name>`) — filed as gina-io/docs#11.

77. **Session cookies in gina are NOT framework-owned — they are issued by the bundle's own `app.use(session({...}))` call via `express-session`, so framework-level cookie hardening cannot be transparent without regressing intentional bundle choices.** The framework's `lib.SessionStore(session)` factory is a thin wrapper that receives the bundle's `express-session` module reference, reads `connectors.json[session.name].connector`, and returns a connector-specific Store class. It never holds a reference to the cookie options, and it runs once per bundle boot — long after the bundle's `index.js` has already captured `var session = require('express-session')` as a local. Nothing in `core/server.js`, `core/server.isaac.js`, or `core/server.express.js` writes `Set-Cookie` — those three files have zero grep matches on `cookie` and `set-cookie`. **Chokepoint analysis trap**: the single shared response entry at `core/server.js:2324` (`self.instance.all('*', function onInstance(request, response, next) { ... })`) does look like the natural place to wrap `response.setHeader` to post-process every `Set-Cookie` value, and Isaac + Express both route through it. But Set-Cookie strings carry no provenance: `Set-Cookie: sessionid=abc; Path=/; Secure` could be a bundle that never thought about `HttpOnly` (safe to add) OR a bundle that explicitly set `httpOnly: false` because a client-side validator or toolbar has to read `document.cookie` (adding `HttpOnly` breaks it). Inspecting three real bundles (`~/Sites/<consumer-app>/src/<bundle>/index.js`) showed all three deliberately set `httpOnly: false` and comment out `sameSite` — a transparent wrap would have silently regressed every one. This is exactly the failure mode the global "Don't strip what you haven't surveyed" rule warns about, in its dual form: "don't ADD what the source didn't ask for either". **The correct shape for a cookie-hardening feature** is an opt-in plugin at `core/plugins/lib/session/src/main.js` that wraps `expressSession(options)` with a factory: reads `settings.json > session.cookie.{sameSite, httpOnly, secure}`, merges defaults into `options.cookie` only for flags the caller did NOT set (guarded by `Object.prototype.hasOwnProperty.call(caller, key)`), validates the browser-parity invariant (`SameSite=None` without `Secure` throws), and passes through. Adoption is a one-line swap in the bundle bootstrap: `var session = require('gina').plugins.Session(require('express-session'))`. Existing bundles that don't adopt continue working exactly as before; adopting is explicit and visible in the diff. **The measurement step that surfaced the trap**: before writing any code, grep real bundle code for cookie flag patterns — `grep -nE 'httpOnly|sameSite|secure[\s:]' ~/Sites/<project>/src/*/index.js`. If any bundle has deliberate `httpOnly: false` or `sameSite` commented out, transparent wrapping is off the table and the feature must be opt-in. Precedent: #CSRF1 (2026-04-24, shipped in `0.3.7-alpha.8`) — initial design pivoted from per-request `response.setHeader` wrap to opt-in `gina.plugins.Session` plugin after the consumer-app measurement showed three bundles with deliberate `httpOnly: false`. The plugin shape also becomes the natural seam for #CSRF2 (signed double-submit token middleware, planned for `0.3.8`), which needs a session-aware injection point — session hardening and CSRF token plumbing share the same mount scope.

80. **CSRF — three-phase protection (`#CSRF1` Session plugin / `#CSRF2` signed double-submit token / `#CSRF3` Origin pre-filter)** — Session plugin hardens `express-session` cookie defaults from `settings.json > session.cookie.{sameSite, httpOnly, secure}` (opt-in, one-line bundle bootstrap swap, never transparent because bundles legitimately set `httpOnly: false`); signed token middleware uses `crypto.timingSafeEqual` HMAC-SHA256 bound to `req.session.id` with per-route `routing.json > "csrfExempt": true` opt-out (see `routing-and-http2.md § "Per-route flags"` for `req.routing.csrfExempt` two-step propagation: `lib/routing` extracts → `core/server.js` hoists, top-level on `req.routing` not under `param.*`); Origin pre-filter folded INSIDE `gina.plugins.Csrf()` BEFORE token verify on mutating methods, allowlist via `settings.json > csrf.allowedOrigins` (empty defaults to `[bundleHostname]` auto-derived). Negative-invariant lock: matching token + mismatching Origin still 403s — token layer ≠ Origin layer. Implementation traps from the trilogy: plan-vs-shipped attribute drift (downstream commits must `grep -n '<attribute>' <upstream-source-file>` before referencing — source on develop wins, not the plan); CI flake-vs-regression triage (same-message-different-SHA pairs need `git diff --stat A..B` first); source-inspection `indexOf` matches the function DEFINITION before the call site (use unique assignment LHS like `requestOrigin = parseRequestOrigin(req)`); `Origin: "null"` is a real browser value (sandboxed iframes / `file://`) and needs an explicit `s === 'null'` guard so the parser falls through to Referer instead of 403'ing for "origin not allowed".

90. **Socket's "Obfuscated code" alert on `gina.min.js` is a verified false positive (Mozilla Public Suffix List data table from `psl@^1.15.0`, byte-identical-data-shape to the standalone `psl@1.15.0` package which Socket scores 100/100) — dismiss per-version on Socket UI with the standing justification text in the local-tool playbook's Socket section. DO NOT attempt the build-pipeline lazy-load (r.js `exclude` of `vendor/gina/psl` + standalone PSL chunk + runtime AMD path config in `core.js`) — it breaks the consumer sync-require contract: gina-consuming bundles routinely call `require('gina/link')` synchronously inside their `onGinaReady` callback, and that contract cannot be satisfied once `lib/domain → vendor/gina/psl` becomes an async XHR (`lib/domain` never resolves → `gina/link` never resolves → consumer hits `Module name "gina/link" has not been loaded yet for context: _`). Verified failure during browser smoke against a representative consumer bundle on 2026-05-05.** A source-level lazy-load (modify `lib/domain/src/main.js` to drop `vendor/gina/psl` as an AMD dep and defer `require(['vendor/gina/psl'])` into the `Domain(cb)` constructor's already-async callback path) would work but the engineering cost (4-8 hrs + cross-route smoke across each of the consumer's nginx static docroots + ongoing standalone-chunk deploy sync) doesn't pay back the per-version dismiss friction (~5-25 min/year). The standing decision is to dismiss per-version. The post-publish dismissal step is wired into both Alpha and Stable Release Checklists in the local-tool playbook.
93. **Reverse-proxy path-prefix awareness via `X-Forwarded-Prefix` — the bundle's internal `server.webroot` stays `/`, but the value templated into `gina.config.webroot` carries the proxy's mount path so client-side root-relative URLs (`/_gina/assets/routing.json`, the `gina.min.css` link injection, etc.) target the correct upstream through the proxy instead of routing to whichever bundle answered bare `/`.** Standard header used by Spring Boot, Traefik, FastAPI, ASP.NET Core, NestJS, Quarkus. Capture site: `core/server.isaac.js` proxy-detection block (sibling to the `X-Forwarded-Host` / `X-Forwarded-Proto` reads); normalisation rules — leading slash added if missing, trailing slashes stripped, empty / `"/"` dropped; stored on `process.gina.PROXY_PREFIX`. Composition site: `core/controller/controller.js` at the `set('page.environment.webroot', ...)` call (~line 513) — when `PROXY_PREFIX` is set, public webroot becomes `prefix + bundle.server.webroot` (slash-normalised), then templated into `gina.onload.min.js`. Internal disk-path resolution and asset-rewrite call sites (e.g. `controller.js:927,956`) still use the bundle's native `server.webroot` — only the browser-facing `gina.config.webroot` carries the prefix; the proxy strips the prefix when forwarding upstream so the bundle's static handlers receive native paths. Symptom shape that surfaces this gap: browser fetches `/_gina/*` URLs that nginx routes to the wrong upstream (the bundle that owns bare `/`, not the bundle that rendered the page); inspector indicator names one bundle but `routing.json` contents come from another. Server-side asset URL rewriting at `controller.js:927,956` and the `linkTo` family also write URLs intended for browser consumption — same prefix-awareness gap, deferred follow-up. The bare `/_gina/assets/routing.json` handler exists only in `server.isaac.js` with no engine-agnostic equivalent in `server.js`; bundles using the Express engine don't have this endpoint at all — independent of this slice but worth noting on the next `/_gina/*` parity sweep. Established 2026-05-06 after the symptom surfaced from a multi-bundle nginx deployment where a sub-path bundle's pages were fetching the root bundle's `routing.json`.

94. **ScyllaDB / Cassandra connector — `cassandra-driver` wrapper + fictitious `@scylladb/scylla-driver` registry placeholder + CQL ergonomics + `USING TTL` session store** — The Node.js ecosystem has no first-party shard-aware ScyllaDB driver (Python / Java / Go / Rust drivers are first-party shard-aware; Node.js is not), so the framework `scylladb` connector wraps `cassandra-driver` (Apache Software Foundation, `>=4.0.0` requires Node >=20, eval-safe per #SCS1 posture). Pre-#CN5 `lib/connector-registry/src/main.js` pinned `@scylladb/scylla-driver@>=1.0.0` as a placeholder — that npm package does not exist (404), which would have failed at install time the moment `connector:add primary --connector=scylladb --driver-version=...` printed its install hint. **Always `npm view <pkg>` to confirm the package exists** when populating registry-style mappings — same shape as the #I18N2 `intl-messageformat@9.13.0` non-existent-version trap. **CQL ORM** mirrors the SQL connector shape: entity files at `models/<keyspace>/cql/<Entity>/*.sql` (distinct dir like Couchbase's `n1ql/`, uniform `.sql` extension), `?` positional placeholders, `client.execute(query, args, { prepare: true })` returning Promise + `.onComplete()` shim, `@param` casting for CQL types (text/int/bigint/uuid/timeuuid/timestamp/boolean/blob/list/set/map/decimal/double/inet), `@return` coercion handling SELECT rows / LWT `[applied]` / write defaults. **CQL session store** has no Redis-EXPIRE equivalent — `set()` issues `INSERT … USING TTL <ttl>` (atomic write + expiry); `touch()` issues `UPDATE … USING TTL <ttl> SET sess = ? WHERE sid = ?` (rewrites data with fresh TTL); `clear()` uses CQL `TRUNCATE` (heavy cluster op — the only full-table delete CQL allows). Required schema: `CREATE TABLE sessions (sid TEXT PRIMARY KEY, sess TEXT) WITH default_time_to_live = 86400`. Promise → callback safety mirrors post-#CB-BUG-4: write methods MUST call `fn(null)` explicitly inside `.then()`, never `.then(fn)` directly. Established 2026-05-09 (#CN5). **MongoDB connector (#CN6)** — wraps the official `mongodb` driver (`>=7.0.0`); JSON op files at `models/<db>/pipelines/<Entity>/*.json` with `{"$arg":N}` / `{"$oid":hex}` / `"$scope"` placeholders + `castParam` BSON coercion (`objectid`/`int`/`long`/`double`/`date`/etc.); self-contained TTL-index session store auto-creates `{expiresAt:1}` (`expireAfterSeconds:0`, one-shot `_ttlReady` guard, `IndexOptionsConflict` warn-and-continue) and filters `{expiresAt:{$gt:now}}` because Mongo's TTL monitor lags up to 60s (vs Scylla's server-side `USING TTL` reap — same intent, different mechanism). Both connectors' registry pins must EXIST and be CURRENT (`npm view <pkg>` — the `@scylladb/scylla-driver` 404 + stale `mongodb>=5` traps). Established 2026-05-09 (#CN6).

96. **Layout cache concurrent-render ENOENT race — atomic temp+rename closes the gap window between `rmSync` and the post-priming read.** `controller.render-swig.js` primes a per-template layout cache file at `<cachePath>/<bundle>/swig/<subFolder>/layout.html` from the `{% extends "..." %}` directive, then reads it back ~340 sync-code lines below to inline assets. In dev mode (`_cacheIsEnabled !== 'true'`) the priming block USED to delete-then-rewrite (`fs.rmSync(newLayoutFilename)` followed by `await fs.promises.writeFile(newLayoutFilename, buffer)`), which opened a gap window: a concurrent render of the same URL could `rmSync` the file AFTER the first render's `writeFile` completed but BEFORE its later `readFile` syscall opened the file, leaving the first render with `Error: ENOENT: no such file or directory, open '<cache>/<bundle>/swig/.../layout.html'`. Symptom shape: intermittent 500s on parallel POST/GET bursts to the same URL in dev mode (production unaffected — cached mode skips the rmSync entirely). **Fix** — collapse `rmSync + writeFile(target)` into `writeFile(temp) + rename(temp → target)` at BOTH cache-write sites (the priming block AT THE TOP and the post-asset-injection rewrite ~640 lines below). POSIX `rename(2)` is atomic on the same filesystem, so concurrent readers always observe either the prior content or the new content, never an absent file. Per-process unique temp names (`<target>.tmp.<pid>.<Date.now()>.<random>`) avoid TOCTOU on the temp itself. The CVE-2023-25345 `{% extends %}` path-traversal boundary check at the priming block is preserved verbatim. **Reproduction** — a tight in-process Promise.all(50) misses the race because all attempts march in lockstep through awaits; the natural staggering needed for the race to fire comes from the pre-block request-lifecycle awaits in a real HTTP server (routing/middleware/controller setup). A unit-level harness can amplify the race by adding `for (k=0; k<5; k++) await Promise.resolve()` BEFORE the racy block plus a ~10ms sync CPU sink between writeFile and readFile and 0-50ms setTimeout stagger across attempts — that recipe surfaced 205/1000 ENOENT failures pre-fix, 0/1000 post-fix. **Test surface** — `test/core/render-swig.test.js` § 11: four source pins (rmSync removed, writeFile+rename pair at both cache-write sites, temp name embeds pid/time/random) + CVE-2023-25345 preservation pin + a behavior test that exercises 200 concurrent atomic writes and asserts 0 ENOENT. Generalises to any "delete then rewrite a shared file under concurrent renders" pattern — atomic temp+rename is the canonical resolution wherever the read window can outlive the rewrite cycle. Established 2026-05-11.

97. **`String.prototype.match` + `indexOf(match)` is unsafe positional anchoring when match strings can be substrings of each other** — short regex matches (e.g. a bare `//` separator) collide on `indexOf` with longer matches earlier in the haystack (a `//` inside a URL string on a prior line that already passed the guard). Manifested at `helpers/json/src/main.js:63-78` where the comment-stripping loop did `commentsWithSlashes = jsonStr.match(/\/\/(.*)?/g)` + per-match `jsonStr.indexOf(m)` + `charAt(pos-1)` URL-guard: the bare `//` separator's `indexOf` re-found the URL's `//`, the guard re-fired against the URL's `:`, and the real separator was never stripped — `JSON.parse` then threw `Expected double-quoted property name` on every bundle config combining a URL string with a bare `//` line. **Use `matchAll` (returns `match.index` per occurrence) when positional iteration is needed, or restructure to per-line scanning.** Related secondary trap: greedy regex-and-loop has implicit per-line scope (`(.*)?` consumed rest of line so only the FIRST `//` per line was guarded); naively replacing it with a non-greedy single-pass lookbehind `(?<![:"\\])\/\/[^\n]*/g` flattens this to per-position and would strip from mid-string `//`-bearing URLs like `"http://host//:rest"` — 8 fixtures under `lib/collection/test/data/` (Couchbase travel-sample data) carry exactly this pattern, caught by `test/lib/collection.test.js` § 05's `assert.deepStrictEqual(result, mocks)` where `mocks` is `requireJSON(...)`. **Final fix shape**: `split('\n')` + per-line leftmost `//` + `:` / `"` / `\` char-before guard — mirrors the original greedy heuristic without the substring-collision class of bug. Established 2026-05-13 (commit `2210ab17`); full incident narrative in the project's post-mortem store.

98. **Secrets resolver — `${secret:KEY}` placeholder substitution in bundle JSON configs at config-load time** — `lib/secrets` walks the merged per-bundle config and substitutes `${secret:KEY}` placeholders from `process.env[KEY]` before `self.envConf[bundle][env]` is finalised in `core/config.js::loadBundleConfig`. Anchored regex `^\${secret:([A-Z_][A-Z0-9_]*)\}$` matches ONLY when the entire string value is the placeholder — mixed strings (`"prefix-${secret:K}-suffix"`) pass through unchanged (deliberate scope choice: removes the "is `{` substitution or literal?" ambiguity). Non-string scalars are walked but never mutated; nested objects and arrays descend recursively. **Fail-closed**: unset / empty env var throws `Error('Secret resolution failed')` (no key name in the message — the key is attached as a non-enumerable `_ginaSecretKey` property for debug-only logging; consumer surfaces never see the key name). Resolution happens once per config-load cycle (in-place mutation; second pass finds nothing). Cache invalidation: `config.refresh()` re-runs the resolver, but secret rotation needs a process restart (the supervisor inherits its env from container init, not from the running framework). **Pluggable backend interface** — `secrets.resolve(config, backend?)` accepts a `{resolve(key) → string|throw}` object; only the `process.env` backend ships in this iteration, but the API is stable for future plug-ins (Vault, SOPS, K8s Secrets). **Path tracking via `WeakMap`** — `secrets.getResolvedPaths(config)` returns the dotted paths (`'db.password'`, `'items[0]'`) the resolver substituted; storage is GC'd when the config is collected and never serialised. Groundwork for a future log-redaction wrapper at any merged-conf print site (`Inspector` payload, debug-export tools); no such print site exists in `core/config.js` today (existing `console.debug` calls interpolate scalar identifiers, not the merged object). **Consumers** — `gina.plugins.Csrf()` reads `settings.csrf.secret` (placeholder-resolved) with precedence `opts.secret` > `settings.csrf.secret` > `process.env.GINA_CSRF_SECRET` (back-compat); `gina bundle:mcp-start` re-runs the resolver on the parsed `mcp.json` immediately after `requireJSON()` so `server.authToken` etc. resolve before downstream readers; bundle scaffolding (`project:add` / `bundle:add`) recommends the placeholder shape in `core/template/conf/settings.json` and `core/template/boilerplate/bundle/index.js`. Established 2026-05-13 (#SECRETS1, commits `8aa2ec82` resolver + wiring, `c1b09ed5` syntax rename `{secret:KEY}` → `${secret:KEY}`, `7a8b0f36` csrf.secret slot, `7d513638` mcp.json route, `33f34b23` scaffolding).

99. **Probe-first protocol for removing load-bearing fallback code** — when a defensive fallback (eval fallback, try/catch shim, recovery branch) has documented history of being load-bearing despite "looking like dead code", apply the structural fix at the cause-side FIRST, then instrument the fallback with a stderr probe BEFORE removing it, then run the full test suite to observe whether the probe fires. Only remove the fallback (and the probe) AFTER observing zero probe fires across all sites. This turns "I reasoned the structural fix breaks the trigger condition" into "the test suite empirically confirms the fallback never executes after the structural fix". **Worked example** — #M22 logger circular-require (2026-05-14). The 3 eval fallbacks at `lib/logger/src/main.js:69` + `containers/file/index.js:16` + `containers/mq/index.js:10` had bitten a prior session (#SCS1c, commit `ae932a5e`, 2026-04-23) — the evals were removed WITHOUT the structural fix and every logger-consuming test crashed with `TypeError: merge is not a function at init`. The 2026-05-14 protocol: (1) apply ONLY the structural fix in `lib/merge/src/main.js` (`require('../../../helpers')` → direct `require(__dirname + '/../../../../../utils/prototypes.json_clone')`, same `JSON.clone` population without going through the for-loop helper loader). (2) Add `process.stderr.write('[m22-probe] eval fallback fired at <file>:<line>\n')` at each fallback site, KEEPING the eval intact. (3) Run full local suite, capture stderr, `grep -c '\[m22-probe\]'`. **Zero fires** = empirical evidence the structural fix worked. (4) Only THEN remove the probes + evals. (5) Re-run suite to verify clean. ~10 minutes of work; the equivalent "reason about it and hope" approach was the failure mode of #SCS1c. **Detection signal that the protocol is warranted**: any session prompt or proposal phrased as "X is load-bearing because the prior attempt failed when X was removed". When you see that shape, surface the probe-first protocol BEFORE any code deletion. **Reflex check**: if your proposed protocol is "apply X subset first, then the rest" without a measurement justification for the subset choice, you're hedging, not measuring. The user's "is this your measured recommendation?" reflex catches this — answer is to surface positive evidence (probe never fires) rather than just smaller-blast-radius framing. Sister rule: the global "No fix without measurement" + the "Verification has THREE outcomes" expansion. Established 2026-05-14 (#M22, commit `2247e22e`).

101. **Docs-site `.md` JSX-leak + `plugin-client-redirects` vs `onBrokenLinks` non-composition** — Docusaurus' `markdown.format: 'detect'` does NOT reliably promote a `.md` file to MDX from JSX usage alone, so manual `import` / `<JSX>` blocks in `.md` source leak as visible body text on the rendered page (e.g. `import DocMeta from '@site/src/components/DocMeta';` literally rendered as a `<p>` paragraph above body content). Convention: doc authors put metadata in frontmatter, the `readingTimePlugin` remark plugin auto-injects `<DocMeta>` into every `/docs/` page at build time; never write `import` / JSX in `.md` source (rename to `.mdx` if you need genuine JSX). Separately, `@docusaurus/plugin-client-redirects` does NOT satisfy `onBrokenLinks: 'throw'` at build time — redirect pages generate at build-OUTPUT stage, AFTER the broken-link checker runs against the source-derived doc tree, so internal links must use the canonical URL form (e.g. `/cli/cli-bundle` for CLI pages with `id: cli-<file>` frontmatter) not redirect aliases (`/cli/bundle`). Audit methodology lesson: body-link sweep extends prereq-frontmatter sweep — body links use the same anti-patterns and break the build identically. Detection greps: `grep -rln "^import " ~/Sites/gina/docs/repo/docs --include="*.md"` (JSX-in-md leaks) and `grep -rnE "\]\(/cli/(bundle|cache|connector|env|framework|port|project|protocol|scope|service|view)([#)/]|$)" ~/Sites/gina/docs/repo/docs/` (filename-form CLI body-link anti-pattern). Established 2026-05-14 (#D9 audit, commits `e3e21b6` secrets.md broken-link fix + `57a19d3` connector.md/service.md DocMeta cleanup).

103. **A `gina start` framework server left running during a session can hang the full test suite AND bundle boot — shared root is `~/.gina/procs.json` staleness forcing the server onto a non-canonical port.** Two symptoms observed 2026-05-14 (0.3.13 #R6/#H9 session): (1) the full `node --test` suite hung ~40 minutes on `test/core/entity-arguments.test.js` — that test connects to the MQSpeaker on `:8125`, and a *degraded* framework server (alive but not on the canonical `:8124`) leaves the connect pending forever because the host's node flags carry `--test-timeout=0` (no per-test timeout → infinite hang, the run never completes, no green/red verdict). Killing the framework server makes the same test pass in ~67ms — a fast `ECONNREFUSED` on `:8125` instead of a hang, the test logs a warn and proceeds. (2) `gina bundle:start` AND `bin/gina-container` both hang during bundle boot right after the MQSpeaker connect, before binding the HTTP port. **Root cause for both:** `~/.gina/procs.json` accumulates stale entries from long-dead gina versions (pids that no longer exist still claiming `:8124`); `gina start` for the current version then binds a *different* port (observed `:3199`), and anything expecting the framework server at the canonical `:8124` hangs or misbehaves. **Practical rules:** before running the full local suite, ensure no `gina start` server is alive (`lsof -nP -iTCP:8124 -sTCP:LISTEN`) and add `--test-timeout=120000` to the `node --test` invocation as a safety net so a single hung test fails fast instead of stalling the whole run (healthy full-suite runtime is ~6s). If bundles will not boot on a dev host, suspect stale `procs.json` first — and before assuming a code change is at fault, run a zero-content subtract test: scaffold a bundle with `bundle:add` only (no `view:add`) and confirm it hangs identically. Established 2026-05-14 during the gina 0.3.13 #R6/#H9 value-add session.

104. **`require.cache[path]` must hold a `Module` instance — `require.cache[path] = require(path)` poisons the slot.** Node reads `.exports` off each `require.cache` entry on every subsequent `require()`; assigning the bare exports object into the slot (no `.exports` key) makes the next plain `require()` return `undefined`, surfacing downstream as `Cannot read properties of undefined (reading '<X>')`. Correct hot-reload shapes: `delete require.cache[require.resolve(path)]` then use the `require()` return value directly, **or** `require.cache[c].exports = require(path)` (keeps the `Module`, swaps only `.exports`) — never `require.cache[path] = require(path)`. Fixed in `refreshCore()` (`core/server.isaac.js`, `add6655e`); the 13 `|| require.cache[require.resolve(...)]` workaround sites across the controller layer + connectors are now redundant dead code. **`router.js` had the same antipattern at two sites** (`refreshCoreDependencies()` ~L116-127 and the controller-require path ~L617-627) — latent because router.js was the slot's only consumer and read it back self-consistently. Fixed 2026-05-16 (#B18) by dropping the `require.cache[path] = require(path)` line at both sites and reading `SuperController = require(path)` directly; the preceding `delete require.cache[require.resolve(path)]` already evicts the slot so the next `require()` builds a fresh `Module`. Test coverage in `test/core/router.test.js § 10` (8 tests: source pins enforcing the antipattern is gone, both fix sites preserve the delete-before-require shape, plus a pure-logic replica showing why poisoning breaks plain `require()`). Established 2026-05-14; fix closed 2026-05-16.

105. **`Controller::throwError` JSON wire — fail-closed `stack` strip outside local scope.** `core/controller/controller.js` (~L5138, after the L5129-5137 fallback initializer and before the L5139 serialization) carries `if (!_isLocalScope && errorObject && errorObject.stack) delete errorObject.stack;` so server-side internals (file paths, library versions, internal stack frames) don't leak to API clients in beta, testing, production, or any unset scope. Local scope (`NODE_SCOPE_IS_LOCAL=true`) preserves the stack on the wire because the dev toolbar's `data-xhr` panel reads `XHRData.stack` from the parsed JSON body (`events.js:394` `JSON.parse(xhr.responseText)` → `ginaToolbar.update('data-xhr', XHRData)` at `events.js:463/498/590`). **Fail-closed shape, not fail-open**: the gate is `!_isLocalScope` (strip unless local) rather than `_isProdScope` (strip only if production explicitly set) — a missing or unset `NODE_SCOPE_IS_LOCAL` still strips, so env-var slip on a fresh prod deployment can't reintroduce the leak. The single intervention sits after both construction paths converge (the L5006-5050 if/else-if chain AND the L5129-5137 fallback initializer) so one delete covers both the L5141 catch-all `JSON.stringify(errorObject)` path and the L5143-5149 explicit-whitelist `stack: errorObject.stack || null` path. Same scope-gating shape as the `#R7` Inspector reveal endpoint (local-scope-only) and consistent with the `#P19` module-load env-cache pattern (`_isLocalScope` is read once from `process.env.NODE_SCOPE_IS_LOCAL` at controller.js:69, which `gna.js:304` sets at bootstrap from the configured scope). The L5029-5031 explicit-extract comment ("reach the client error dialog") remains accurate for the local-scope path only. **Out of scope, separate slices**: (a) HTML error-page branch at L5292-5333 `<pre class="stack">` still leaks in non-local scopes when no custom error template is configured; (b) consumer-side leaks where `err.stack` is passed directly as the `msg` argument and surfaces in the `error` string instead of the gated `stack` field — needs per-call-site sanitization on the consumer side. Server-side log line at L5153 (`console.error(errOutput)`) intentionally mirrors the wire — what is sent is what is logged. Established 2026-05-16. (Note: this number collides with the pre-existing entry #105 at L760 — known duplicate, awaiting comprehensive numbering pass.)

110. **`Controller::throwError` HTML branch — fail-closed `<pre class="stack">` strip outside local scope.** Symmetric companion to the JSON wire entry above (at L845 in this file). The fallback HTML error page constructed inside `controller.js` (~L5294+, when no custom error template is configured) now gates both render sites on `_isLocalScope`: the msg-shape `if (msg.stack && _isLocalScope)` (~L5311) and the generic-shape `if (stack && _isLocalScope)` (~L5349) — so the `<pre class="...stack">` block never reaches non-local viewers. Title / error / message `<pre>` blocks continue to render unconditionally. Custom error templates dispatched via `renderCustomError` at L5266-5281 stay consumer-owned — what a consumer view renders from `req.params.errorObject` (e.g. `{% if data.stack %}{{ data.stack }}{% endif %}`) is the consumer's call. With both the JSON wire gate and this HTML branch gate in place, the framework's two error-response wire shapes are scope-gated atomically; only the consumer-side leak shape remains (callsites that pass `err.stack` as the msg argument and surface it in the `error` STRING — needs per-callsite sanitization, the framework can't reach it). Test coverage in `test/core/controller.test.js § 13` mirrors § 12's shape: source pins on both gates + position-relative-to-msgString-anchor + pure-logic replicas (msg-shape + generic-shape × local/non-local × stack/no-stack). Number jumps from #105 → #110 to avoid the existing duplicate cluster at L760-775 (#105/106/107/108) — comprehensive numbering pass deferred. Established 2026-05-16.

111. **#S7 — `/_gina/info` and `/_gina/cache/stats` IP-allowlist gate (admin axis, mirrors #OBS1 `/_gina/metrics`).** Both endpoints expose process state (`process.memoryUsage` / `uptime` / `version` / HTTP/2 session counters at `/_gina/info`, full cache contents at `/_gina/cache/stats`) — admin-grade information that should not reach public callers. The gate lives at `server.isaac.js` module scope as `isAdminClientAllowed(req)` and is invoked at the top of each handler (~L664 for `/_gina/info`, ~L707 for `/_gina/cache/stats`) returning a 403 JSON `{error:'forbidden',message:'...client IP not in app.json admin.allowFrom'}` with cache-control headers on deny. Allowlist is configured via the new `admin.allowFrom` block in `app.json` (schema/app.json declares it as a sibling of `metrics`); defaults to `['127.0.0.1', '::1']` (loopback) when omitted; empty array `[]` is explicit deny-everyone. `core/gna.js` reads the block in the existing `server.on('started')` callback (right after the metrics init block) and caches it on `process.gina._adminAllowList` so `server.isaac.js` can read it without a per-request `gna.getConfig` call. Five invariants mirror the `lib.metrics.isClientAllowed` pattern: (1) IP is read from `req.socket.remoteAddress` only — `X-Forwarded-For` is NEVER trusted because reverse proxies could spoof it; (2) `::ffff:IPv4` is normalised to `IPv4` so listing `127.0.0.1` matches both forms; (3) `req.connection.remoteAddress` is a fallback for older Node versions; (4) empty allowlist denies everyone (explicit lockdown); (5) missing `process.gina._adminAllowList` falls back to loopback (defensive against init order). `/_gina/health/check` is intentionally NOT gated (k8s liveness probes need it open). `/_gina/metrics` keeps its own separate `metrics.allowFrom` gate — separate axis from `admin.allowFrom`, so bundles that disable metrics still get the admin lockdown. Test coverage in `test/core/server.isaac.test.js § 08` (source structure pins on the helper + both handler invocations + gna.js init + schema declaration) + `§ 08b` (10-case pure-logic replica covering loopback/non-loopback × IPv4/IPv6/IPv6-mapped × empty-allowlist × `req.connection` fallback × missing-socket × X-Forwarded-For spoof). The shape is reusable for any future admin-grade `/_gina/*` endpoint — copy the gate check + add to the handler. Established 2026-05-16.

117. **Mermaid sequence-diagram parser (current version in Docusaurus) treats `;` inside message bodies as a statement separator → parse error on the next line.** Symptoms: diagram fails to render with `Parse error on line N: ..., 'xxx'); next() end App->>C` in the browser console; page shows a "Try again" placeholder where the diagram should have been. The `;` in a message like `Plugin->>App: res.setHeader('x-content-type-options', 'nosniff'); next()` gets parsed as a statement separator, splitting the line; mermaid then misreads the following `end` (alt/else block close) and `App->>Ctrl:` (next arrow) as malformed continuations of the prior arrow. Fix: replace `; next()` (or any `; <next-statement>` pattern) with `, then next()` (or any non-`;` separator). Same visual meaning, no `;` to confuse the parser. Generalises to: never use `;` in sequence-diagram message bodies (or note bodies). Use `,` / `then` / `→` / `+` instead. Precedent: 2026-05-17 — `docs/guides/security-headers.md:34` + `docs/guides/csp.md:44` both had the bug; pre-existing on Phase 1 docs but only surfaced after a Mermaid library upgrade in Docusaurus made the parser stricter. Older Mermaid was more permissive about `;` in messages; current is not. The bug doesn't trip per-file Mermaid linters since `;` is technically a valid character in messages — only the runtime parser rejects. Established 2026-05-17.

119. **Test count estimation drift — never write an "N unit tests" claim before running the test; count from `node --test` output, don't estimate from the section count.** Estimating from section count (e.g. "6 sections × ~5 tests = ~30") consistently undercounts because behaviour sections (token validation, factory + middleware end-to-end) tend to be 10-15 tests, not 5. The corrective is mechanical: write the test file → run `node --test test/core/<plugin>.test.js 2>&1 | tail -10` → copy the `ℹ tests N` line → write that exact N in the roadmap docs (public + internal) + the changie YAML. Substitute `TODO_test_count` placeholders in drafts and fill from the actual output before staging. Precedent: 2026-05-17 #HDR8-12 implementation — claimed counts 30 / 41 / 33 / 32 in roadmap + changie drafts; actual `node --test` results were 37 / 57 / 37 / 35. Required a corrective edit pass on 5 plugin commits before staging — 3 Edit tool calls per plugin × 5 plugins = 15 unnecessary edits at ~10 sec each. Cost of the fix: 1 Bash call per plugin (the actual test run that would have happened anyway). Generalises to any feature-shipping work where the roadmap entry includes a test count (every #HDR / #CSRF / #CN / #SCS slice). Established 2026-05-17.

123. **Framework-attribution leak sweeps must cover Mermaid `participant` declarations as a separate surface from prose blocks and inline code examples.** When correcting the standalone-Express idiom across a docs family (per llms.txt #118), the prose-sweep grep targets `require('express')` / `var app = express();` in JS code blocks. That grep does NOT match Mermaid `participant App as express()` lines — diagram syntax is structurally different from prose patterns. The canonical sibling cross-check (csrf.md, the gate prescribed by #118) also fails to reveal the gap because csrf.md's own diagram doesn't have an "App" participant at all (it uses middleware-component participants — `Browser`, `Csrf middleware`, `Controller action` — directly). So a prose-only sweep can leave Mermaid diagrams carrying the wrong adoption-shape indefinitely. Mitigation: when running the #118 sweep, add a SECOND grep over the docs tree: `grep -rn 'participant.*express\|App as express' docs/`. Fix shape: drop the `App` participant entirely and route arrows Client → Plugin → Ctrl directly, matching csrf.md's convention (one less intermediate participant; the `next()` call doesn't bounce through a separate App layer at the diagram level). For diagrams that carry a `Note over App,Plugin: ...` caption, reattach to `Note over Plugin: ...` since App is removed. Precedent: 2026-05-17 — `docs/guides/security-headers.md:24` + `docs/guides/csp.md:32` both had `participant App as express()` (carried forward from helmet's docs since HDR1 + HDR5 first shipped). Fixed in `gina-io/docs` commit `b4095ec` (2 files, 7 ins / 13 del). User-caught via "Why are we still talking about express in the diagrams" mid-session — the #118 sweep had landed earlier the same day and missed both. Generalises: any future framework-attribution correction (rename, vocabulary shift, adoption-convention update) should sweep BOTH prose patterns AND Mermaid `participant` declarations, since they're separate grep surfaces with separate idioms. Cost of the extra Mermaid sweep: 1 grep call. Cost of skipping: visible bug carries forward into the next docs reader's view. Established 2026-05-17.

125. **CLI handler authoring & operations — 12 lessons consolidated** (replaces individual entries #17, #23, #24, #55, #56, #58, #59, #60, #62, #63, #102, #112):
- **CLI stubs** — `project:move/backup/restore`, `framework:update`, `gina --status/-t` appear in help.txt or docs but have empty/missing handler files; tracked in `ROADMAP.md § CLI` with target versions; never suggest to users without checking the handler file first. `bundle:status` / `project:status` / `minion:list` / `minion:kill` / `protocol:remove` / `bundle:copy` (+ `cp` alias) / `bundle:rename` shipped 0.4.1-alpha.2. Both minion commands are run-dir-driven process-truth (the "minion" abstraction is half-wired — nothing sets `process.isMinion` or writes `*minion*.pid`, so a minion == any running bundle child-process): `minion:list` lists every live `<bundle>@<project>.pid` grouped by project via `lib.cmdStatusFormat`; `minion:kill @<project>` reaps them (hybrid kill-set = run-dir pidfiles + a `ps -ef | grep 'gina: ...@<project>'` sweep for pidfile-less orphans bundle:stop misses), SIGTERM→grace→SIGKILL escalation, `--dry-run` preview, unlinks stale/killed pidfiles, never touches mount symlinks or its own PID. `protocol:remove <bundle> @<project>` reverts a bundle to the project default protocol by deleting ONLY its `server.protocol/scheme/allowHTTP1` override from the bundle's `settings.json` (config.js `:1003-1011` auto-defaults an absent protocol to `def_protocol`/`def_scheme`); it deliberately does NOT mutate the shared `ports*.json` — `project:add` pre-allocates the full protocol×scheme×env matrix, so the default-protocol port already exists and pruning the set's port would be wrong; a per-env port-presence guard refuses (unless `--force`) when the default-protocol port is missing; `--dry-run` preview, header-preserving JSON rewrite (connector:rm pattern). `bundle:copy <source> <new> @<project>` (+ `cp`) duplicates a bundle under a new name in the SAME project: copies the `src/<source>` tree, then word-boundary-rewrites the name footprint (PascalCase `<Src>`→`<Dst>` for controller class names + lowercase whole-word `<src>`→`<new>` for the gina require-var / `app.json` name / webroot path, `.js`/`.json` files only — embedded tokens like `apiClient` are untouched; a first-bundle webroot `/` is repointed to `/<new>`), allocates a fresh FULL protocol×scheme×env port matrix via the shared `setPorts` (a single-port insert would later emerg in `config.js`, which expects the complete matrix), and clones+repoints the source's manifest entry (`src`/`link`/`releases` target paths). `--dry-run` previews every rewrite site before writing; `--force` overwrites an existing target (its `removeDest` mirrors `bundle:remove`'s deletions). TWO positionals leave `self.name` null (CmdHelper sets it only for a single positional), so the handler reads `self.bundles[0]`/`[1]` directly — which also slips past the `cmd.name`-gated existence guard so the not-yet-registered new name isn't rejected. `bundle:rename <old> <new> @<project>` is the move-sibling — it renames a bundle IN PLACE in the same project (`fs.renameSync` move, NOT a copy) reusing the same `inc/name-rewrite.js` engine but with `fixWebroot:false` (rename moves the only bundle, so there's no first-bundle/collision case; a name-derived `/<old>` webroot is still rewritten by the lowercase pass). Its ports are REKEYED, not reallocated — port NUMBERS are preserved: `ports.json` rewrites the `<old>@<project>/` owner prefix back into the SAME `[protocol][scheme][portKey]` slot (avoiding the two `project/rename.js` bugs: the wrong `[protocol][portKey]` slot, and a project-wide owner replace), then `ports.reverse.json` is rekeyed (`pr[new]=pr[old]; delete pr[old]`) and flipped LAST as the canonical existence record. It REFUSES a running bundle with NO `--force` bypass (`--force` only overwrites an existing dest); the whole multi-surface mutation (symlink → renameSync dir → rewrite tree → env → manifest → ports → ports.reverse) is snapshot-guarded (the `bundle:add` rollback model) so any post-move failure reverses the dir move and restores env/manifest/ports/ports.reverse from in-memory snapshots. NOTE: the ROADMAP's "fix the help.txt remouve typo" item was stale — no such typo existed.
- **Two-guard `@<project>` requirement asymmetry in `lib/cmd/helper.js`** — a `project:*` command that should run without `@<project>` must be exempted in BOTH guards: the early task-shape guard (`!/^project\:(list|help|status)/`) AND the later projectName-resolution guard (`!/\:list$/.test(cmd.task)` → widened to also allow `^project:status$`). Exempting only the first leaves the second falling through to cwd-project-inference and a "No project name found" error before the handler runs. `project:list` is the working precedent that null `projectName` survives `loadAssets()`.
- **Single-bundle CLI handlers read `cmd.name` / `self.name`, NOT `self.bundle`** — CmdHelper sets `cmd.name = cmd.bundles[0]` only when exactly one bundle positional is present (`lib/cmd/helper.js`, the `cmd.bundles.length == 1` branch); `cmd.bundles` is the array for bulk operations. There is no `self.bundle` property. Precedent: `bundle:stop` / `bundle:start` / `bundle:status` all read `self.name`. (Worked example: a sub-agent investigation reported the slot as `self.bundle`; a single read of `helper.js` refuted it before the handler shipped — verify agent-relayed property names against source.)
- **`gina start` does not need sudo with a user-prefix install** — `npm install -g --prefix ~/.npm-global` (standard Gina setup) runs as the current user and doesn't touch `/var/run/`. The "Needs to be launched as [sudo]" comment in `lib/cmd/framework/start.js` only applies to system-wide installs.
- **`gina start` exits with code 1 even on success** — the startup script detaches the daemon; the shell child exits 1 as part of detachment. Always use `gina status` to confirm rather than the exit code. Sibling: a `gina start` framework server left running can hang the full test suite AND bundle boot via stale `~/.gina/procs.json` forcing the server onto a non-canonical port (see `cli-handlers.md § 11`).
- **CLI reserved flags — `--port` and `--version` must be renamed in subcommands** — both consumed by the framework's global CLI parser (`utils/helper.js::filterArgs` + `bin/cli:74-119`) before the subcommand sees argv. Use domain-prefixed forms: `--connector-port=`, `--driver-version=`. Un-prefixed forms are silently swallowed.
- **CLI scope grammar — positional-absence, not flags** — for commands that apply to either a single bundle or the whole project, use `[<bundle>] @<project>` and signal project-wide by omitting `<bundle>`. Never reintroduce `--scope=bundle` (`scope` is reserved for data isolation: local/beta/production/testing) or `--shared`.
- **`connector:rm` never runs `npm uninstall`** — a driver removed from one bundle's `connectors.json` may still be needed by another bundle in the same project (npm resolves `node_modules/<driver>/` once per project). Handler prints a driver-retention hint naming siblings (shared + other bundles) that still reference the same driver; sqlite exempt. Project-level `connector:rm` consults a usage guard — pass `--force` to bypass.
- **`raw.indexOf('{')` is comment-blind — unsafe for preserving comment headers on JSON rewrite** — `indexOf('{')` matches any `{`, including one inside `//` or `/* */` comments. The "header" gets cut mid-comment; the surviving tail re-emits above the rewritten JSON. Fix: strip comments into a scratch buffer, `indexOf('{')` on that, map offset back; or scan state-machine style. Any new CLI rewriting comment-headed JSON config must use the comment-aware primitive.
- **Defer the boot-path hook until a concrete schema delta exists** — when shipping a "migrate old config shape to new" tool, the canonical v1 shape is CLI-only, opt-in, dry-run-by-default — NOT a `Config.load()` auto-migrate hook. A stale config must never silently mutate in production. Pin the boot-path hook to the specific release introducing the first breaking delta; pair with a negative-invariant source-inspection test so a future session can't accidentally wire the hook.
- **Lockfile-probe package manager detection — ordered table, first match wins, no heuristics** — for an install-a-package CLI step (e.g. `connector:add --install`), detect PM by walking a fixed lockfile list: `bun.lockb` → `pnpm-lock.yaml` → `yarn.lock` → `package-lock.json` (fall back to npm). Never infer from `package.json.packageManager`, `process.env.npm_execpath`, or `which <pm>`. Pair with 3-tier install-range resolution (explicit user pin → project deps → framework peerDeps).
- **Argv `--<key>=<value>` parsing — split on the first `=` only, never on every `=`** — both `utils/helper.js::filterArgs` and `framework/v*/lib/cmd/helper.js::getParams` parse `--key=value` tokens; both used to call `.split(/=/)` with no limit, silently truncating any value containing `=` (signed tokens, version ranges like `">=5.3.0 <6.0.0"`). Correct: `var _eq = raw.indexOf('='); arr = (_eq > -1) ? [raw.substring(0, _eq), raw.substring(_eq + 1)] : [raw]`. `split(/=/, 2)` is NOT a valid replacement (drops everything after second `=`). Locked by negative-invariant tests in `test/lib/cli-arg-parsing.test.js`.
- **PWA scaffold delivery path — `view:add`, not `bundle:add`, carries `bundle_public/` + `bundle_templates/`** — `bundle:add` copies just `boilerplate/bundle/` (JSON-API bundle, no HTML layout). `view:add`'s `copyFolder()` (`lib/cmd/view/add.js:297-314`) copies `bundle_public/` → `<bundle>/public/` AND `bundle_templates/` → `<bundle>/templates/` together. Files under `bundle_public/` are NOT token-substituted — only `add.js` does `${bundle}` substitution. Filename is `manifest.webmanifest`, not `manifest.json`.
- **`--force` on rm commands tolerates partial-breakage states** — `CmdHelper.loadAssets()` recognises `/\:remove$/.test(cmd.task) && cmd.params['force']` and stubs `cmd.projectData = { project: cmd.projectName, version: '0.0.0', bundles: {} }` instead of reading from disk; `project/remove.js init()` warns and proceeds to ports cleanup + `~/.gina/projects.json` delete when the project folder is missing AND `--force` is set. Without `--force`, both sites preserve `console.error` + `process.exit(1)` so typos still surface. By-design overshoot: the `/\:remove$/` regex matches ANY `:remove` task — `bundle:remove --force` would inherit the same tolerance.

126. **Release pipeline failure modes & recovery — 9 lessons consolidated** (replaces individual entries #15, #16, #43, #45, #46, #66, #84, #100, #113):
- **Post-merge state check required after every `git merge --ff-only`** — `post_install.js` does NOT run on a git pull/merge. When the version changes (especially a `shortVersion` bump like `0.2 → 0.3`), three state stores go stale: `~/.gina/main.json` (`def_framework`, `frameworks["${shortVersion}"]`, all metadata keys), `~/.gina/${shortVersion}/settings.json` (missing entirely), `gina.db kv_store` (`main` + `settings/${shortVersion}` keys). Running `npm install` from inside the repo also fails (symlink error); patch the state files directly.
- **Merge frequency / sync cadence** — merge `dev/wip` → `develop` after every feature/fix. Exception: hold at version boundaries (`0.3.x → 0.4.x`, `0.x → 1.x`) until the full release is prepared, because `git mv framework/v*/` during merge is a live deployment of the globally-installed CLI. Mid-version, the two branches must remain fast-forwardable to each other — never let both branches gain independent commits.
- **Release pipeline (`script/post_publish.js` chain) — failure modes** — `self.*` chain ordering (gates prepend; non-fatal wrap for polish steps); `syncDocs` alpha-misclass + nested-lifecycle red herring (`publishAlpha`'s `npm publish --tag alpha` triggers a NESTED full lifecycle that legitimately logs `[syncDocs] Alpha release — skipping docs sync`); docs-repo lockfile-mismatch race after stable publish (first defense: `script/retry_lockfile_sync.js` `retryWithBackoff`, ~80s ceiling, schedule `[5,15,30,30]`s; when the retry is exhausted — registry lag past ~80s, e.g. the v0.4.0 cut, 3rd recurrence after 0.3.9/0.3.11 — FAILS CLOSED via `script/sync_docs_deps.js` `readLockedGina`+`resolveDocsDepState`: reverts the docs `devDependencies.gina` to the version still pinned in the unregenerated `package-lock.json` so the committed pair stays consistent and docs CONTENT still deploys (only the badge lags), or skips the develop→main merge via a `docsMergeSafe` gate if the locked version is unreadable — never ships a mismatched pair, so `develop`↔`main` stay ff-only; established 2026-05-30); README freshness code-gate (touch-since-tag check); `bumpVersion` `git mv framework/v<old>/ v<new>/` requires state-store pre-sync across `~/.gina/main.json` + `~/.gina/<short>/settings.json` + `gina.db kv_store`; `framework/v*/package.json` gitignored-but-CI-needed (heredoc-derive); lockfile-regen primitive `npm install --package-lock-only --ignore-scripts`; single try/catch around sequential git silently skips downstream work; early-return footgun on multi-side-effect functions; release-session tooling traps (`git filter-repo --replace-text` rewrites blob content only — pair with `--replace-message`; GitHub branch protection `allow_force_pushes: false` blocks ALL force-pushes including admin; full-payload PUT required to toggle; web UI toggle has been observed to silently no-op).
- **README.md is NOT auto-updated by any release script** — "What's in X.Y.Z" heading and Features-table Swig version reference are hand-maintained. Before `npm publish`: `grep -nE "What's in|Swig [0-9]" README.md`. README freshness is now a code-level gate in `prepare_version.js` (touch-since-tag check, established 2026-05-05 after three skipped manual-checklist steps in a row).
- **Docs-repo WIP during release merge: stash, don't file-overwrite** — when merging `develop → main` in `~/Sites/gina/docs/repo` to recover from a `syncDocs` miss, with significant in-flight work use `git stash push -u -m "<purpose>"` → `git checkout main` → `git merge --ff-only develop` → `git push origin main` → `git checkout develop` → `git stash pop`. The `-u` is required for untracked files (e.g. new `.mdx` files).
- **changie body strings — single-quote by default and escape `'` as `''`** — unquoted body containing ` #` (space + hash) → YAML treats it as a comment marker, silently truncating the body; unquoted body containing `:` breaks as a mapping; quoted body with unescaped literal `'` closes the scalar early. Author rule: every new changie entry uses `body: '...'` (single-quoted), every literal `'` inside becomes `''`. Verified by `.githooks/pre-commit` running `script/check_changie_entries.js` against staged `.yaml` files. `changie new -k <Kind> -b <body>` doesn't consistently auto-quote — `head -3 .changes/unreleased/<file>.yaml` after every `changie new` to confirm.
- **Local-tool doc files at gitignored paths drift independently across worktree splits** — git merges don't carry gitignored content, so each worktree's local docs must be maintained separately. Auto-update on the primary worktree: version-bump scripts read `script/.local-sync-targets.json` (gitignored — lists relative paths), regex-replace `v<old>` → `v<new>` with `(?![\\d.])` lookahead. Tracked source code names only the generic config file. Constraint: regex requires literal `v` prefix; bare version mentions (`0.3.7`, `## What's in 0.3.7`, third-party version strings like `@rhinostone/swig 1.5.0`) are invisible to the mechanism. Auto-rewriting bare versions would be strictly worse — silently relabelling old content as new masks staleness; touch-since-tag freshness gate is the right primary mechanism.
- **`gh run list --commit <sha>` returns empty intermittently — use `--branch` instead** — observed 2026-05-14: `gh run list --commit <sha>` returned empty even after CI completed green; `--branch <branch>` showed the runs immediately for the same commit. Detection signal: an `until` poll loop on `--commit` hangs indefinitely (empty input never matches success regex). Workaround: poll `--branch <branch> --limit <N>` or skip the poll entirely (CI typically runs 15-60s after push).
- **Stable cut friction cluster — three recovery patterns (v0.3.14, 2026-05-16)** — (A) `npm publish` aborts on prerelease without `--tag`: runbook step "manual `package.json version + main` bump from `0.3.X-alpha.N` to `0.3.X`" is hard-required (`prepare_version.js` reads `version` but doesn't mutate `package.json`; npm reads `package.json` BEFORE `prepare` runs). Recovery: bump manually, leave UNCOMMITTED, re-run `npm publish`. (B) `syncDocs git merge develop` (no `--ff-only`) conflicts on `docusaurus.config.js` when docs `main` has any divergent commits since last cut (git's line-based ancestry-aware conflict detection flags the `ginaVersion` line even when both sides converge to the same value). Recovery: `git merge --abort`, `git pull --ff-only main`, re-merge, resolve to develop-side, complete merge, push, `git checkout develop`, re-run `node script/post_publish.js`. (C) `tagAndMerge git tag` not idempotent: after recovering from a mid-chain halt, re-run hits `fatal: tag 'v0.3.X' already exists`. Recovery: `git tag -d v0.3.X` LOCAL only, re-run post_publish.js. Deferred code-side fixes for all three.

127. **Web Security Headers (`#HDR` plugin family) — 10 lessons consolidated** (replaces individual entries #114, #115, #116, #118, #120, #121, #122, #124, #141, #157):
- **HTTP security response-header plugins follow the Csrf middleware-return shape, NOT the Session factory-wrap shape** — they emit a header on the response rather than wrapping an upstream module factory. Csrf returns an express middleware directly; each HDR plugin does the same. Settings.json convention stays flat top-level (each plugin gets its own top-level key, sibling of `session.cookie.*` / `csrf.*`). Idempotent-by-default: every header plugin checks `res.getHeader(name)` first and skips writing if a value is already present. No `enabled: false` shortcut — registration IS opt-in; not registering IS opt-out. Throw-on-invalid at factory call time (NOT request time). Multi-field cross-invariant validation in `_resolveOptions` helpers (e.g. #HDR4 `preload=true` requires `includeSubDomains=true` AND `maxAge>=31536000`). Spec-deviation documentation pattern: name the spec section + offsetting receiver behaviour + design tradeoff + operator escape hatch (e.g. #HDR4 RFC 6797 §7.2 vs §8.1). Per-plugin commit shape: 9 touchpoints on gina (src + package.json + README + registry entry + settings template + boilerplate adoption block + ROADMAP row + changie YAML + tests) + 3 on docs-repo (guide + migration + roadmap) in a SEPARATE commit per session.
- **CSP per-response nonce (#HDR16)** — `gina.plugins.Csp({ useNonce: true })` (opt-in, default off) generates a fresh per-response nonce on the per-request carrier `req._ginaCspNonce` (NOT `res.locals` / `process.gina.*` — the latter would leak across concurrent requests), appends `'nonce-<v>'` to `script-src` (fallback `default-src`; the factory THROWS at call time if neither directive is present), and the swig/nunjucks render delegates stamp it on framework-injected inline scripts and expose `{{ page.cspNonce }}` / `{{ cspNonce }}` so application templates can mark their own inline `<script>`s. The static (no-nonce) path precomputes the header once and writes no `req` slot — byte-identical + zero per-request allocation for non-opt-in bundles.
- **CSP report-only-inert directive omission (#HDR5)** — in `reportOnly` mode `Csp` omits directives browsers ignore there (`REPORT_ONLY_IGNORED_DIRECTIVES`, currently just `sandbox` — the only directive ignored in report-only by every engine: CSP2 spec text, MDN's Report-Only page, Chromium source, WebKit) and warns once naming what was dropped; `frame-ancestors` is deliberately KEPT — its report-only behaviour is engine-divergent (the CSP3 spec, Gecko and Blink evaluate it and send violation reports; WebKit alone ignores it with a console warning and no report, retaining the CSP2 ignore-when-monitoring rule that CSP3 dropped), so omitting it would lose the Chrome + Firefox observation-phase signal — WebKit-heavy consumers can leave it out of their own report-only set; the configured `directives` keep `sandbox`, so an enforcing factory from the same config still emits it; throws if every directive is report-only-inert. The opt-in `reportOnlyOmit: ['frame-ancestors']` packages the consumer-side omission for engine-divergent directives: named directives are omitted from the report-only header (factory warn with wording distinct from the browser-inert omission — these are NOT browser-ignored; the consumer is forgoing the Gecko/Blink report signal) and emitted again automatically at the enforce flip, so one directive set covers both modes; entries are validated against the same CSP3 whitelist (unknown names throw); silent-inert under reportOnly:false (carrying it there is the expected lifecycle state, never warned); throws if the omissions empty the report-only set or omit the useNonce target directive.
- **Helmet-parity audit pattern: WebFetch the upstream README before claiming feature parity** — memory of vendor surfaces goes stale fast. When assessing parity, `WebFetch` the canonical README URL before claiming. Triage gaps on 4 axes: (a) modern-and-default-on-in-upstream → strong candidate to add; (b) modern-and-opt-in → moderate; (c) legacy-but-default-on → defense-in-depth-only; (d) legacy-and-opt-in-everywhere → likely skip. Audit cost: 1 WebFetch + ~5 min triage. Cost of skipping: parity-narrative gap a consumer will notice. Precedent: 2026-05-17 #HDR7 (`Origin-Agent-Cluster`) caught via helmet README audit; would have been silently missed otherwise.
- **Wrapper / orchestrator plugins inherit "batteries-included" default behaviour from sibling wrappers (Session, Csrf, SecurityHeaders), NOT the "register to opt in" rule that single-concern per-feature plugins follow** — `gina.plugins.SecurityHeaders()` with no opts mounts the 7 non-footgun headers (HDR1/2/3/4/7/13/14) with per-plugin defaults. CSP (#HDR5) and COEP (#HDR6) are opt-in-only WITHIN the orchestrator because of known footguns (CSP throws on missing directives; COEP `require-corp` BREAKS embeds without CORP). Default to batteries-included with the "safe" subset of children mounted-by-default; opt-in-only the children with known footguns; preserve the individual-plugin escape hatch via the idempotent first-writer-wins pattern.
- **gina-bundle plugin adoption ALWAYS uses `myapp.onInitialize(function(event, app){ app.use(<plugin>); event.emit('complete', app); })` — NEVER the standalone-Express idiom** — bundle authors do NOT `require('express')` or call `express()` themselves; gina builds the Express app and passes it via the `onInitialize` callback's `app` parameter. The standalone-Express idiom (`var express = require('express'); var app = express();`) is the recurrence-prone failure mode — universally familiar so it propagates by static-analogy when new docs are added mirroring wrong examples. When adding to an existing docs guide family, cross-check the adoption convention against sibling guides (`csrf.md`, `sessions.md`) AND against the boilerplate (`core/template/boilerplate/bundle/index.js`) BEFORE mirroring an existing example. Sibling sweep needed for Mermaid `participant` declarations: `grep -rn 'participant.*express\|App as express' docs/` — prose-only sweeps miss diagram syntax.
- **Wrapper-integration test stub composition — when extending a wrapper to include a new sub-plugin that uses a `res.X` method the shared `makeRes()` stub doesn't implement, the wrapper's integration test PASSES without exercising the new sub-plugin's behaviour** — the sub-plugin's typeof guard (correct defensively) skips the call when the stub lacks the method. Mitigation: (a) identify what `res.X` methods the sub-plugin calls; (b) if the shared `makeRes()` stub doesn't have them, either extend the shared stub OR inline a test-local stub; (c) verify the assertion would FAIL with a deliberately-broken sub-plugin (subtract-my-contribution test from UI smoke discipline). Precedent: #HDR15 wrapper test's `makeRes()` lacked `removeHeader`, silently asserted `null === null`.
- **Helmet-API divergence shape — when gina deliberately diverges from a vendor middleware's option-key naming, the silent-fallback shape is a footgun for migrators** — gina's per-header convention uses `{ value: <enum> }` for single-token enum headers; helmet uses per-plugin idiosyncratic names (`{ allow: boolean }`, `{ permittedPolicies: <enum> }`). When a migrator passes the helmet-shape, `merged.value` is undefined → factory uses the default. Header IS emitted (default value, NOT the helmet semantic the migrator intended); no error. Mitigation: README mapping table (helmet → gina, side-by-side, explicit silent-fallback callout) + negative-invariant test pinning the silent-fallback shape so a future refactor is visible at review time. Internal-consistency wins over migration-friendliness IS the gina design choice; the documentation + test pin make the divergence visible.
- **Framework-level header-emission gate — closure-helper inside request-handler-factory scope cleanly wraps N object-literal `writeHead` headers blocks; use inline `if` for sibling `setHeader` sites** — pattern: define `var _setX = function(headers) { if (!options.X) { headers['Y'] = value; } return headers; }` once in the request-handler-factory scope (closing over `options`). Wrap each object-literal site via `var foo = _setX({...other keys...});`. DRY (N reads of `options.X` collapsed to 1 closure capture), single point of truth, headers var declaration shape preserved. For non-object-literal sibling sites (`response.setHeader(...)`) use inline `if (!options.X) { ... }` gate. Test pattern: 3-section (source-structure pins / pure-logic replica / docs cross-references). Precedent: #HDR8 Phase 2 `_setPoweredByHeader(headers)` inside `onPath`, 14 object-literal sites wrapped + 1 routing.json asset `setHeader` site gated inline.
- **Wrapper-into-namespace collapse — when a wrapper plugin's PascalCase JS name is derived from its parent namespace dir while the namespace dir already hosts per-feature siblings, the wrapper sub-dir is structurally redundant; collapse INTO the namespace dir to restore dir-name ↔ JS-name symmetry without flattening the siblings** — Node's CommonJS resolver natively handles the resulting shape (namespace dir becomes both a package AND parent of N child-packages). Workflow: 3 `git mv` calls + `rmdir wrapper/` + edit plugin-registry index + refresh test PLUGIN constant + registration-pin regex + JSDoc `@module` tag. **Critical gotcha**: the relative-require math is NOT "strip-N-prefixes-from-OLD-path" — it's recomputed from the file's NEW depth. The collapsed `src/main.js` is still INSIDE `src/`, so requires to siblings at the namespace root need `'../<sibling>/src/main.js'` (one `../`), NOT `'./<sibling>/src/main.js'` (which would resolve to `<namespace>/src/<sibling>/...` and crash with `MODULE_NOT_FOUND`). Verification: full local suite is invariant under a pure-rename refactor.

128. **Routing configuration (`routing.json` semantics) — 7 lessons consolidated** (replaces individual entries #19, #20, #27, #35, #36, #105-first, #106):
- **`namespace` is required to target a namespace controller** — omitting `"namespace": "content"` makes the router look in `controller.js` instead of `controller.content.js`. Error surface is `"control not found: list"` pointing at `controller.js`, which is confusing. Always set `namespace` when the action lives in a namespace controller file.
- **URL params need `"id": ":id"` in `param` to reach `req.params`** — declaring `"url": "/notes/:id"` alone is not enough. You MUST also add `"id": ":id"` inside the `param` block so the router binds the segment to `req.params.id`. `requirements` are optional (any non-empty segment is accepted when omitted); the `param` binding is not.
- **`fitsWithRequirements` requires `"slug": ":slug"` in `param` even when `requirements` is set** — the router only increments the route-match score (and populates `req.params`) for a URL parameter if that parameter key exists in the `param` block. Having `requirements: { "slug": "..." }` alone is not enough — the route 404s without the binding. The `requirements` block adds regex validation on top of the binding; it does not imply the binding.
- **`req.routing.param.<key>` ≠ URL value — use `req.params.<key>`** — `req.routing.param` holds the raw routing config object from `routing.json`. For URL parameter bindings like `"id": ":id"`, the value stored there is the literal placeholder string `":id"`, not the actual URL segment. The actual captured value is in `req.params.id` (always available) and `req.get.id` / `req.delete.id` etc. (method-specific). Static values declared in `param` (e.g. `"code": 302`) ARE correctly available on `req.routing.param.code`. Rule of thumb: use `req.routing.param` only for static metadata, never for captured URL params.
- **Routing loop cross-route contamination** — `fitsWithRequirements()` mutates `request.params` and `request[method]` during each route comparison. Leftover values from non-matching routes cause `parseRouting` lines 396–407 to inject phantom URL segments, compounding work exponentially. Fix: `req.params = Object.assign({}, _origParams)` + `delete req[method]` before each `compareUrls()`. Never `delete req.params` — `fitsWithRequirements` gates on `typeof(request.params) != 'undefined'`. Never use `JSON.clone` on request state in the routing loop — `Object.assign` is sufficient and safe.
- **`server.isaac.js:812` initializes `req.params`** — `request.params = {}` and `request.params[0] = url` are set before `handle()` runs. Any code that deletes `req.params` breaks `fitsWithRequirements` which checks `typeof(request.params) != 'undefined'` before setting param values.
- **internal-bundle `setting-get-one-design` was a latent crash** — `coreapi` routing has `"url": "/settings/get/design/:designId"` with no `requirements` block. Before the `fitsWithRequirements` fix, every GET to that URL threw a 500 TypeError. The fix makes it work correctly. Worth testing that route after the next ff-only merge.

129. **`env.json > response.header` config defaults are asymmetric across engines and can collide with framework `setHeader` calls — always trace the full emit chain before claiming a config default reaches the wire.** `core/server.js` (Express engine) reads `conf.server.response.header` into `resHeaders` at L1391 and loops `response.setHeader(h, headerValue)` over each key at L1481-1534 — so any entry in the env.json `response.header` block IS applied on Express. But `core/server.isaac.js` does NOT read `server.response.header` anywhere (grep `serverResponseHeaders` / `server\.response\.header` against `server.isaac.js` returns zero matches) — Isaac builds its headers from the framework's own primitives (`_setPoweredByHeader()` for X-Powered-By, fixed setHeader calls for cache/CORS at the `/_gina/*` handlers), so any env.json `response.header` default is silently ignored on Isaac bundles. Layered on top: framework code that emits its own canonical value via a later `setHeader` call in the Express request lifecycle OVERWRITES whatever env.json set earlier. The combination means an env.json `response.header` config default can look load-bearing in the template while being functionally dead at runtime — applied-then-overwritten on Express, ignored on Isaac. Operator rule: before claiming a `response.header` config key reaches the wire, (a) grep both engines for reads of `server.response.header` (the Isaac silence is the dead-giveaway), and (b) grep for framework `setHeader('<that-key>', ...)` calls that would overwrite. Precedent: 2026-05-17 — the `X-Powered-By: "Gina I/O - v${version}"` env.json default at `core/template/conf/env.json:91` was structurally dead (overwritten by `server.js:2425` on Express, never read on Isaac); dropped in favour of the canonical `Gina/<version>` shape emitted by `server.js:2425` + `_setPoweredByHeader()` and suppressed by `gina.plugins.HidePoweredBy()` middleware + `settings.json > server.hidePoweredBy: true` gate respectively. The handoff that prompted the cleanup had framed the env.json default as a "third emit path that neither plugin nor gate reaches" — wrong in both directions (Express middleware DID remove the overwritten value cleanly; Isaac never read the env.json key at all). Established 2026-05-17.

130. **`framework/v*/lib/validator.js` and `framework/v*/core/plugins/lib/validator/src/form-validator.js` are TWO different validators with overlapping `is*` rule names — pick the right one before any edit.** `lib/validator.js` is a 534-line standalone backend-only fluent validator (`new Validator(data).field.isString()` chained style); `core/plugins/lib/validator/src/form-validator.js` is the 2062-line form-rule SDK consumed by routing's `"validator::{ ... }"` requirements AND by the browser bundle (single source, branched by `isGFFCtx` at line 17 — `module.exports` defined → backend path, else → frontend AMD path). Both files have their own copies of `isString` / `isInteger` / `isEmail` / `isRequired` / `isBoolean`, but the form-validator additionally has `isJsonWebToken` / `is` / `isApiError` shapes that `lib/validator.js` lacks, plus the dispatcher-friendly `arguments`-collection pattern needed for array-valued rule configs (the routing dispatcher at `lib/routing/src/main.js:545` `apply()`-spreads array values, so rules like `isInList: ["a", "b"]` arrive as positional args). **When the task is form-rule work** — adding a new rule consumed via `routing.json > requirements > "validator::{ ... }"`, fixing form-validation behavior, debugging client-side form errors via the bundled SDK — the target is form-validator.js, and its single shared source covers BOTH server + client in one edit. `lib/validator.js` is unrelated and should not be touched. Conflating the two is a recurring disambiguation cost: the v3 → gina `isInList` handoff (2026-05-17) framed the target as `framework/v*/lib/validator.js` and required an agent investigation roundtrip to correct the path + discover the shared-file cost-collapse before the actual edit could land. **Test-side gotcha**: `FormValidatorUtil` cannot be directly instantiated in `node:test` because its runtime dependency chain (`lib/routing` → `lib/logger` → `setContext`/`getContext`/`requireJSON` globals injected by `gna.js` at framework boot) is not wired in test scope — the established workaround is the SCS-series test-local replica pattern (`test/lib/validator-scs1{e,f,g,h,i}.test.js` + `test/lib/validator-isinlist.test.js`): mirror the rule body as a test-local function, run behavioural assertions against the replica, anchor production-source-shape via regex pins on `fs.readFileSync`'d source text. Established 2026-05-17.

131. **`npm install -g` global-vs-local detection in `script/post_install.js` — three-signal stack to survive `npm_config_global` propagation failures.** Primary signal `process.env.npm_config_global` (line 121) is propagated by npm on most setups but not all — npm wrappers, version managers, certain `.npmrc` configs silently drop it. When missing/false the script lands in the local-install branch on a `npm install -g` invocation; `createGinaFile` then tries to create `<pkg>/gina → <pkg>/node_modules/.bin/gina` — the source doesn't exist (gina's own node_modules has no `.bin/gina` entry; npm only auto-creates that file in consuming projects) and `helpers/path.js::symlinkSync` throws `the path does not exist`. Fallbacks: (a) `process.argv` scan for `-g`/`--global` (lines 148-152) was never effective — the post-install subprocess's argv is `node post_install.js`, not the user's npm CLI; (b) `__dirname` path-shape check added 2026-05-19 `cb0e11aa` — global installs put gina at `<prefix>/lib/node_modules/gina/` so post_install runs from `…/lib/node_modules/gina/script` (POSIX-only — Win32 is unreachable via the early `isWin32()` throw at line 117). Plus a defensive `existsSync(source)` early-return inside `createGinaFile` (lines 397-406): if the link source path is missing, log + return success instead of crashing — belt-and-suspenders against any future regression. **Variable-naming gotcha**: post_install.js's local `source` holds the LINK PATH (to be created); `helpers/path.js::symlinkSync(source, destination)` expects `source` to be the EXISTING FILE pointed to (Node's `fs.symlinkSync(target, path)` convention). The helper's `existsSync(source)` check therefore fires on the link-path-to-create rather than the existing file — surfaces env-var-propagation bugs as a "path does not exist" error. Generalises: any install-time script doing global-vs-local routing should check `__dirname` shape, not trust `npm_config_global` alone. Established 2026-05-19.

132. **`throwError`'s 2-arg polymorphic form `throwError(statusCode, Error|string)` dispatches via a function-top normalization shift; the 2-arg-with-errorObj form is intentionally NOT shifted and flows through the existing `else if (arguments.length < 3)` branch.** Before the 2026-05-19 fix at `core/controller/controller.js:4979`, calling `throwError(404, new Error())` silently fell back to HTTP 500 because the existing L4998 IF branch's status-coercion read `String(code)` (which was `String(Error)`, never digit-matching against `/^\d{3}$/`) and `res.status` (the number has no `.status`), so explicit codes other than 500 were lost. The narrowed shift only fires for Error/string 2nd args (where the L4998 IF is the buggy path); 2-arg errorObj falls through to L5072 cleanly because that branch sets `code = res || 500` correctly, reaching the explicit code. Including errorObj in the shift detection would have broken the errorObj case: after the shift `arguments.length` is still 2 < 3, so L5072 fires unconditionally and overwrites the shifted `msg` (with the numeric `code`) and `code` (with `local.res`, a truthy object). The shift detection must match exactly the cases where the L4998 IF mis-coerces — Error and string second args only. Test pattern: `test/core/controller.test.js` section 14 (5 source-structure pins + 8 inline replicas covering Error / string / errorObj / null / empty-obj / 1-arg / 3-arg / number-first-arg guards) mirrors the existing section 12/13 throwError test discipline. Rule for future polymorphic-overload work on `throwError`: any new call shape needs its branch + status-source verified end-to-end via inline-replica before shipping; the L5072 else's `code = res || 500` shape is load-bearing for the 2-arg-with-errorObj case and must not be inadvertently triggered for shapes the L4998 IF should handle. Established 2026-05-19, commit `5492826c`.

133. **SQL Query-Instrumentation index reporting does per-query column-level coverage (#QI Phase C) — heuristic, declared-index-vs-WHERE, NOT a planner verdict.** `sql-parser.js` gained two exports beyond Phase A's `parseCreateIndexes`/`extractTargetTable`: `extractWhereColumns(queryString)` (heuristic — bare lowercase identifiers immediately left of a comparison operator inside the WHERE clause; strips table/alias qualifiers; ignores ORDER BY/GROUP BY/HAVING; functional predicates like `LOWER(email)=?` yield no column, conservatively) and `annotateCoverage(tableIndexes, queryString)` (clones each descriptor, adds a `covers` boolean via the **leftmost-prefix rule** — an index covers the filter only if its FIRST column is among the WHERE columns, mirroring real B-tree usability; never mutates the shared `_knownIndexes`). `parseCreateIndexes` now captures each index's `columns` array (regex extended to consume the `(col, ...)` body plus an optional `USING <method>`; ASC/DESC/opclass/prefix-length reduced to the bare leading identifier). Each SQL connector's QI block (mysql/postgresql/sqlite) calls `annotateCoverage` and stores `_queryEntry.whereColumns` + per-index `covers`. The Inspector Query tab adds a 4th badge state `.bm-idx-uncovered` (amber-gold, dark `#e6b800` / light `#8a6d00`, label "no index for filter") that fires only when the table HAS indexes AND a WHERE filter parsed AND none cover it (so there is no false "uncovered" when the filter is unparseable or absent); the existing green/amber/red/N-A states are unchanged. **SQL-connector-only** — Couchbase reads the actually-used index from the query execution profile (accurate), so column inference is moot there and its path is untouched. Heuristic limits (documented deliberately): regex WHERE-parsing with no AST, no selectivity / range-vs-equality / functional-index awareness, and `indexes.sql` is a declaration that can drift from the live DB — the badge says "no index for filter", NOT "index unused". Phase B live-introspection (#QI Phase C.2, 2026-05-20) now ALSO captures each index's columns — MySQL adds `SEQ_IN_INDEX`/`COLUMN_NAME` to the STATISTICS query, PostgreSQL parses `indexdef` via the new `sql-parser.parseIndexDefColumns()` export, SQLite adds a per-index `PRAGMA index_info` — so live descriptors carry the same `{ name, primary, columns }` shape as Phase A and column-level coverage survives the opt-in `/_gina/indexes` refresh (subsequent queries annotate correctly instead of a false `uncovered` badge; server-side only, no SPA/dist change since coverage is computed server-side per query). Tests: `test/core/inspector.test.js` §61 (21, Phase C) + §62 (12, Phase C.2). Established 2026-05-20.

134. **#QI Phase C.3 — the Inspector SPA computes live-index coverage client-side, so SQL bundles WITHOUT an `indexes.sql` get the "no index for filter" badge on the first render too.** Phases C/C.2 compute per-query `covers` server-side, but only inside `if (_knownIndexes !== null)` — i.e. only when an `indexes.sql` declared the indexes. The common no-`indexes.sql` case carries `indexes:null` (N/A badge) until the SPA lazily back-fills from the `/_gina/indexes` live-introspection endpoint; before C.3 that back-fill rendered a plain green/amber "index present" badge regardless of whether the index could actually serve the query's filter — an optimistic state that disagreed with the indexes.sql path for the identical query. Two changes: (a) the mysql/postgresql/sqlite QI blocks now emit `whereColumns` + `table` UNCONDITIONALLY (moved out of the `_knownIndexes` gate; `annotateCoverage` stays gated so `indexes` is still `null` without indexes.sql — the N/A badge AND the SPA's live-resolution trigger are preserved); (b) `inspector.js` `resolveLiveIndexes(q)` now CLONES each cached descriptor (never mutating the shared `_liveIndexes` cache — two queries on one table can filter different columns) and stamps a per-query `covers` via the same leftmost-prefix rule as `sql-parser.annotateCoverage` (`whereColumns.indexOf(cols[0]) > -1`). The existing render branch then surfaces `bm-idx-uncovered` for the live path, matching the declared-index path. Value is narrow-but-real: the NEXT request self-heals server-side anyway (C.2 made annotation correct once introspection populated `_knownIndexes`), so C.3 only corrects the FIRST render's already-on-screen queries — but that is exactly when the Query tab is used to audit indexes, and in practice few bundles ship an `indexes.sql`, so the live path is the common path. SQL-connector-only (Couchbase reads the live plan). Cost: one duplicated line of leftmost-prefix logic on the client (trivial) + a prod `dist` rebuild (inspector.js is a src→dist copy, no Closure). Gotcha for future connector-QI edits: the `_devLog.push`-within-2000-bytes-of-`envIsDev` proximity tests (§40/41/42) trip when you add lines to the QI block — keep additions terse (this slice trimmed a 4-line comment to 1) or widen the constant. Tests: `test/core/inspector.test.js` §63 (8). Established 2026-05-20.

135. **Secrets introspection CLI — `gina secrets:scan` / `secrets:check`** — read-only companion to the #98 `${secret:KEY}` resolver; answers "which secrets does this bundle need?" without the fail-closed one-key-at-a-time crash-loop you'd otherwise use to discover them. **Lib primitive**: `secrets.getRequiredKeys(config)` (in `lib/secrets/src/main.js`, exported beside `resolve`/`getResolvedPaths`/`SECRET_RE`) is a non-throwing structural sibling of the resolver's walk — same array/object recursion, same anchored `SECRET_RE`, but it COLLECTS key names instead of resolving, so it never calls a backend, never mutates the config, and never fails on unset/empty. It returns a sorted, de-duplicated key list and reports exactly the keys `resolve()` would substitute (mixed-content strings like `"https://${secret:K}/v1"` are NOT reported, mirroring `resolve()`'s bare-placeholder-only rule). **`secrets:scan`** walks each bundle's `<src>/config/*.json` + the project's `shared/config/*.json` and aggregates KEY → originating config file(s); text or `--format=json`. **`secrets:check`** runs the same enumeration, cross-references the current `process.env`, prints each key `SET`/`UNSET`, and **exits non-zero when any required key is unset** (CI pre-deploy gate; JSON still prints the full report). "Set" means a non-empty string — the exact condition under which the env backend resolves — so an `UNSET` is precisely a key that would throw at bundle start. **Config-dir resolution follows `bundle:openapi`, NOT `i18n:scan`**: the bundle config dir is `<project.path>/<manifest.bundles[name].src>/config/` (falling back to the bundle name when `src` is absent), because the standard project layout puts bundles under `src/<bundle>/` and the manifest `src` field is the authoritative pointer — `i18n:scan`'s `path.join(projectPath, bundleName)` shortcut is wrong for that layout. Every `.json` in those dirs is read via `requireJSON` (comment-tolerant), matching `loadBundleConfig`'s glob-the-dir behaviour (not a fixed `files` whitelist); dotfiles and `* copy` siblings are skipped. **Honest caveats** (documented in `help.txt`): `scan` reports the placeholders AUTHORED on disk, not the merged runtime config; `check` validates THIS CLI process's env, not a detached/running bundle's container env (real value is a CI step that exports the secrets then runs `secrets:check`, or a shell that sourced the same env file). Offline command: `'secrets:'` added to `bin/cli` `allowedOffline`; handlers consume the primitive via the global `lib.secrets` registry (the bare `require('lib/secrets')` form does NOT resolve in cmd daemon scope — the `NODE_PATH` shim runs at bundle runtime, not in `bin/cli`/`bin/cmd`). This is **Option A (introspection-only)**; a storage-managing `secrets:set/get/rm` (Option B) was rejected by design — the framework resolves, the deployment layer (K8s Secrets / SOPS / Vault / platform secret-injection) stores and populates `process.env`. **Per-scope introspection** (`--scope=<s>`, added 2026-05-21): `scan`/`check` read-only deep-merge the sibling `config_<scope>/` dirs (e.g. `shared/config_production/`) over the base via `merge(JSON.clone(scopeContent), base)` — scope wins on leaf collisions, base back-fills omitted keys — then enumerate the EFFECTIVE keys for that scope, mirroring how a deploy applies per-scope config. The scope comes from the framework's reserved `--scope` flag (read as `self.params.scope`, validated against registered scopes by CmdHelper), and the **runtime config loader stays scope-agnostic** — per-scope selection is a deploy-time concern, NOT a runtime read (a runtime `config/<scope>/` loader was prototyped and reverted as too high-blast-radius: the core merge flow processes config types heterogeneously, e.g. `settings.json` is rebuilt late at `config.js:~2266`). `secrets:check --env-file=<path>` validates against a `.env`-style file (decrypted SOPS export / CI-exported env) instead of the live `process.env`. **Merge-direction gotcha** (verified against `lib/merge`): scope content must be the merge TARGET under the default `override=false` so scope wins on leaves AND base sub-keys survive; `merge(base, scope, true)` is WRONG — `override=true` shallow-replaces nested objects, silently dropping base sub-keys the scope omits. Tests: `test/core/config-secrets-resolver.test.js` §12 (getRequiredKeys) + `test/lib/secrets-scan.test.js` (source-inspection + pure-logic replicas across both handlers + group files, incl. §09 scope-overlay + env-file). Established 2026-05-21.

136. **The Couchbase connector picks its SDK major from the project's installed `couchbase` npm version, NOT from a `connectors.json` field — and SDK v2 is removed as of #CN8 (0.4.0).** `core/connectors/couchbase/lib/connector.js` (and the parallel `session-store.js`) read `package.json` `dependencies.couchbase`, take the leading major (`^3.2.1` → `3`), and `require('connector.v<major>.js')`; each `connector.vN.js` stamps `conn.sdk = { version: N }`, and `index.js` branches on that `sdkVersion`. So `conn.sdk.version` is install-derived, never a config key — the old pre-removal v2 deprecation message that told users to "set sdk.version to 3 or 4 in connectors.json" was wrong: the migration is a driver bump (`npm install couchbase@^4`). #CN8 deleted `connector.v2.js` + `session-store.v2.js` and every `sdkVersion <= 2` / `> 2` v2 branch in `index.js` (consistency setup with the `N1qlQuery.Consistency` arm, the `N1qlQuery.fromString` prepared-statement builder, FTS substitution, the `register()` dispatch + its call sites, and the `bulkInsert` build/dispatch), kept the `sdkVersion == 3` split (still distinguishes v3 from v4 in param shaping), and flipped the resolver + `index.js` defaults to v3. Both resolvers now THROW a clear "SDK v2 no longer supported — upgrade couchbase@^3/^4" error when the resolved major is `<= 2` or the connector file is missing — previously a silent no-op fallback that then crashed with an opaque `MODULE_NOT_FOUND`. Generalises: when a connector's behavior-version is derived from an installed dependency rather than config, the migration path is a dependency bump and the resolver should fail fast (not silently fall back) once the installed major drops below the supported floor. Established 2026-05-22.

137. **HTTP/2 response trailers (#H10) — opt-in via `self.sendTrailers(fields)` → stashed on `local._trailers`, wired in every render delegate's HTTP/2 BODY path as `stream.respond(headers, _trailers ? { waitForTrailers: true } : undefined)` + `stream.once('wantTrailers', () => stream.sendTrailers(_trailers))`.** `self.sendTrailers(fields)` (controller.js) only RECORDS fields — strips `:`-prefixed pseudo-headers (forbidden in a trailing HEADERS frame), stashes the clean object on `local._trailers`, returns self for chaining. Each delegate captures `var _trailers = (local._trailers && typeof === 'object') ? local._trailers : null` near its other per-request captures and arms the flow only when set. **Node trailer ordering contract**: with `waitForTrailers: true`, `stream.end()` no longer auto-closes — Node fires `wantTrailers`, and `stream.sendTrailers()` sends the trailing HEADERS frame AND closes the stream; call it exactly once (`.once`), inside the event, in a try/catch (best-effort — never fail the response). If `waitForTrailers` is ever set without a `wantTrailers`→`sendTrailers` handler the stream hangs forever, so gate both on the same `if (_trailers)`. **Skip**: HEAD branches (no body), HTTP/1.1 (the `res.*` path has no HTTP/2 trailer mechanism — silent no-op), destroyed/closed streams, and the framework 500 fallthrough (not an app response). **Single-conditional-arg form, NOT an if/else with two respond calls**: passing the options object conditionally as the 2nd arg keeps exactly one `stream.respond(` per path, so render-swig's source-pin counts (`5 active stream.respond()`, 5 `getHeaders`/`headersSent` paths, 4 dynamic `:status`) stay stable — an if/else (2 respond calls per body site) would trip them. (render-stream, the first slice, uses the if/else form since it has no count pin; the buffered delegates — render-json, render-swig cache-hit + cache-miss, render-nunjucks Case 3 — use the single-arg form.) **Gotcha for future body-path edits**: adding lines to a delegate body path shifts byte-offset lookback test windows (render-swig.test.js cache-miss HEAD window needed 3000→3400) and the `wantTrailers`-callback `stream.destroyed` guards add to source-pin counts (render-swig `stream.destroyed` 3→5; render-engine-dispatch.test.js `stream.respond(_streamHeaders)` regex widened to `_streamHeaders,\s*_trailers`) — recalibrate the affected #H8 pins. Tests: render-stream.test.js §10 (execution: waitForTrailers set + sendTrailers fires with fields + opt-out no-op), controller.test.js §15-16 (sendTrailers source pin + pure-logic replica), render-json §02 / render-nunjucks §03 / render-swig §13 (source pins). Established 2026-05-22.

138. **#INS9b — opt-in API-key auth gate on the dev-mode `/_gina/agent` Inspector SSE endpoint, reachable outside dev mode behind a key (production-safe toggle); plus the `$`-anchored-gate-regex gotcha caught in cold-read.** The agent stream (combined `event: data` + `event: log` SSE, #INS9a) was dev-only (`NODE_ENV_IS_DEV` in server.js / `isCacheless` in server.isaac.js). #INS9b widens the gate to `(dev || process.gina._inspectorAgentEnabled) && GET && <agent-url>` and, when NOT in dev, requires a valid key before opening the stream (else `401`). Config: `settings.json > inspector.agent.{enabled,key}` (default disabled; `key` is `${secret:KEY}`-capable, resolved at config-load). Capture: `gna.js` reads `getConfig('settings').inspector.agent` at `server.on('started')` onto `process.gina._inspectorAgent{Enabled,Key}` (mirrors the #S7 admin-allowlist capture — both engines read one `process.gina` slot because Isaac's handler `options` lacks `content.settings`). Key check: a file-scope `_agentKeyValid(req)` per engine reads the `x-gina-inspector-key` header OR a `?key=` query param (browser `EventSource` can't set headers, so the SPA's `tryAgent()` + connect form thread `?key=`), compared with `crypto.timingSafeEqual` + a length guard; **fail-closed** — no configured key returns false even when `enabled`. The Isaac 401 deny goes through `_setPoweredByHeader` (16th #HDR8 wrapped site). Dev stays open + keyless (#INS9a unchanged). **Value boundary**: in production this authenticates remote server-log streaming + connection identity ONLY — the render-side `inspector#data` emit (per-request data/query/flow) stays `isCacheless`-gated and never fires in prod, so enabling data capture in prod remains #INS10's separate concern. Stayed on SSE; the WebSocket transport in #INS9b's original wording is deferred (only needed for bidirectional commands). **Gate-regex gotcha (caught in a cold-read review, NOT by the 27 new tests):** `/_gina/*` gate regexes are `$`-anchored (`/\/_gina\/agent$/`); the instant the endpoint accepts a query param, `/_gina/agent?key=…` stops matching (the `$` sees `…key=abc`, not `agent`) and the handler silently never fires — auth path dead. Fix: widen to `/\/_gina\/agent(?:\?|$)/` (still rejects `/_gina/agentx`, `/_gina/agent/`, `/_gina/agent/foo`). Source-structure pins AND an isolated `_agentKeyValid` replica both passed while the integration was dead; only a cold read against the cumulative diff surfaced it. **Rule: when a previously-query-less endpoint gains a query param, exercise the GATE regex against the query'd URL, not just the helper that parses the param** — and keep the access condition (dev/enabled) FIRST in the gate so the dev-check stays inside the source-pin locality window. Tests: `test/core/inspector.test.js` §64-67 (gate source-pins both engines, behavioral key-compare replica, client `?key=` threading, `?key=` URL-match guard). Established 2026-05-24. **Built on this gate:** **#INS8** adds a WebSocket transport over the same `/_gina/agent` (`ws@^8.20.1`, `noServer:true`, ONE `upgrade` listener on the unified raw server — both engines, no isaac mirror; client `tryAgentWS()` is the DEFAULT transport, SSE the fallback) + optional `inspector.agent.allowedOrigins` + dev auto-start (`gina service:start <service>` and a `gna.js server.on('started')` hook gated `isDev && projectName !== 'gina'` — the self-spawn guard). **#INS10** opens per-request query+flow capture in a time-boxed, SEPARATELY-keyed PROD window (`POST /_gina/instrument`, `process.gina._inspectorWindowUntil`, `lib/instrument`, `HARD_CAP_SECONDS=3600`); egress is remote-only over the authenticated SSE — channel AUTH, not redaction, protects raw query text — including a separate HTML-free per-delegate emit (`core/controller/inspector-window-emit.js`) for server-rendered pages. Established 2026-05-26 / 2026-06-02.

142. **`bin/cli` resolves the framework dir from `GINA_VERSION` (env/persisted) falling back to `package.json` version, then `require()`s `framework/v<version>/lib/generator` — now guarded by `fs.existsSync(frameworkPath)` BEFORE that require.** A `GINA_VERSION` pointing at a non-installed version (a stale pin, or a bind-mounted dev tree at a different version) otherwise throws `MODULE_NOT_FOUND` that the surrounding `try/catch` mislabels as `gina: could not load [ package.json ]` (package.json actually loaded fine), and the legitimate `existsSync` guard sits AFTER the require so it never fires — every CLI command then fails opaquely (in a container, `project:import` silently fails, so bundles report "not registered" on start). The guard now fails fast with a clear `gina framework:install <version>` message via `process.stderr.write` (`console` is not reassigned to the framework logger until later, so `console.alert` is undefined there). Diagnostic tell: `could not load package.json` followed by `Cannot find module '…/framework/v<X>/lib/generator'` means framework version `<X>` is not installed, not that package.json is broken. (`8fa9c278`, 2026-05-30)

143. **`lib/cmd-status-format` — shared run-state/port display primitive for the status/list CLI handlers (`pad` / `pickPreferredPort` / `readPidfile`).** `bundle:list`, `service:list`, `bundle:status`, and `project:status` each carried byte-identical inline copies of three helpers; they now consume `lib.cmdStatusFormat` (`var fmt = lib.cmdStatusFormat;` — the ambient registry global; the bare `require('lib/cmd-status-format')` form does NOT resolve in `bin/cli`/`bin/cmd` daemon scope, the same constraint as `routing-introspect`/`connector-registry`, because the `NODE_PATH` shim runs at bundle runtime). The module is **pure** — requires only node `fs`/`path`, reads no framework globals — so it is unit-testable by a direct require. The load-bearing design move: `readPidfile` was generalised from the inline `readPidfile(bundleName, projectName)` (which read the `GINA_HOMEDIR` + `_` globals) to `readPidfile(runDir, bundleName, projectName)` built with `path.join`; callers pass `GINA_HOMEDIR + '/run'`, and `service:list`'s hardwired `@gina` case becomes `fmt.readPidfile(GINA_HOMEDIR + '/run', name, 'gina')`. `pad` / `pickPreferredPort` were already pure and moved verbatim. **Test-extraction pattern**: the four handler test files were source-inspection suites pinning the inline *definitions*; extraction invalidates those pins, so they were rewritten to consume-the-primitive assertions (`/fmt\.readPidfile\(/` + `doesNotMatch /var readPidfile = function/`) and the behavioural coverage moved to `test/lib/cmd-status-format.test.js` (require-by-path, 15 tests) — the handlers' tests stay thin, the primitive is the behavioural surface. `lib/cmd/connector/list.js`'s byte-identical inline `pad(s, width)` was given the same treatment in `79d71abf` (call site routed through `fmt.pad`, test source-pin re-aimed — the new `var fmt` pin tolerates the column-aligned declaration via `/var fmt\s+= /`); `lib/cmd/cache/stats.js`'s `pad(str, len)` is a different signature and stays inline. Commits `fa0e0c1d`..`45459fa9` (+ `79d71abf` connector:list). Established 2026-05-31.

144. **`self.setTemplate(file, ext)` — runtime template override (controller action API, shipped `0.4.1-alpha.2`).** A controller action resolving its template dynamically (e.g. a catch-all dispatcher) calls `self.setTemplate(file, ext)` AFTER `setOptions()` to override the rule's default; it stashes `local.options._templateOverride = {file, ext}` (ext leading-dot-normalised; non-string args ignored; bails if `local.options` unset). BOTH render delegates read it and resolve the override **verbatim under the templates root with NO namespace prefixing**: render-swig in the path-resolution block ahead of the `namespace` branch, gated `!isRenderingCustomError` (swig's block is shared with custom-error rendering, so a leftover override must not hijack the framework error template); render-nunjucks via an early `return ovFile` in `resolveTemplatePath()` (its custom-error path uses `errOptions`, which carries no override, so no gate needed). **Inert-API gotcha — the writer alone is a no-op:** #27 shipped the writer with ZERO readers (it set `_templateOverride` but no delegate consumed it; source-inspection tests on the writer passed while the feature did nothing). When adding any 'the delegates read X' API, grep the delegates for the reader first and back it with a BEHAVIOURAL test that the rendered output changes. Commits `9f2471f0` (#27 writer + swig reader) + `61211bfb` (#29 nunjucks reader). Established 2026-06-01.

145. **`bin/gina-container` (the daemonless Docker/K8s launcher) defines only a SUBSET of the `GINA_*` globals the daemon defines, so a BARE `GINA_*` read in the request/render path throws `ReferenceError` (HTTP 500) on every view render under it — read injected globals via `getEnvVar('NAME') || <fallback>`, never as a bare identifier.** A bundle child's bare-global namespace is exactly the keys of `ctxObj.envVars` (`gna.js` `defineDefault(envVars)` → `helpers/context.js` `define()` → `Object.defineProperty(global, NAME)`); a name absent from `envVars` is never declared, so a bare read throws while `getEnvVar('NAME')` returns `undefined` safely. The normal `bundle:start` path (`bin/cli` → `framework:init` + `framework/start.js`) seeds ~36 keys incl. `GINA_PID` and `GINA_CULTURE`/`GINA_CULTURES`/…; `gina-container` is daemonless (no socket server, no `framework:init`) and defines only the ~20 it explicitly `setEnvVar`s, omitting `GINA_PID`, `GINA_CULTURE`, et al. Two bare reads in `setOptions`' `hasViews()` block — `controller.js` `GINA_PID` and `GINA_CULTURE` — therefore 500'd every HTML route under `gina-container` (the first masked the second); both now use `getEnvVar('GINA_PID') || String(process.pid)` and `getEnvVar('GINA_CULTURE') || 'en_CM'`, matching the safe siblings (`render-json.js`, `inspector-window-emit.js`) and the culture default (`config.js`, `init.js`). The `String(process.pid)` fallback runs in the bundle child, yielding the child's own pid — exactly what the daemon's `opt.pid` provides; JSON responses were never affected (render-json already used the guarded reader). Established 2026-06-01.

146. **#H11 Alt-Svc HTTP/3-advertisement header — opt-in `server.http3Advertisement` makes `completeHeaders` (`core/server.js`) emit `Alt-Svc: h3=":443"; ma=86400` on every routed (user-facing) response, BOTH engines; advertise-only (Gina implements no QUIC).** Gate: `if (conf.server.http3Advertisement && typeof(response.getHeader)=='function' && !response.getHeader('alt-svc')) response.setHeader('alt-svc','h3=":443"; ma=86400')` — idempotent first-writer-wins (an upstream/proxy Alt-Svc is never clobbered), off by default (zero behaviour change when unset). **Why `completeHeaders`, not the #HDR8 `/_gina/*` closure-helper:** a framework header's natural home is wherever the responses it must reach are built. #HDR8's X-Powered-By lives on the Isaac `/_gina/*` `writeHead` sites because that is the ONLY place Isaac emits it (Isaac user pages carry no X-Powered-By); Alt-Svc must reach USER-facing pages (that is where browsers cache the h3-upgrade), whose builder is `completeHeaders` — and it covers BOTH engines (Isaac routed requests reach it via the engine-agnostic `composeHeadersMiddleware` drain that `gna.js` registers with `instance.use()` → `instance.completeHeaders`; the render delegates then fold `response.getHeaders()` into the HTTP/2 `stream.respond`). So one gate covers user pages on both engines; the `/_gina/*` framework endpoints (health / metrics / inspector) are NOT browser-h3-upgrade-relevant and deliberately don't carry it (avoids the 17-site `_setPoweredByHeader`-style fan-out + a parallel test pin for zero functional gain). **`:443` is the EDGE's public QUIC port, NOT gina's own listen port** — a QUIC-capable edge (Caddy / nginx-QUIC / Cloudflare) terminates HTTP/3 on :443; advertising gina's internal port (e.g. :3142) would point clients at a port gina does not serve QUIC on (a bare boolean with a fixed `:443` is therefore more correct than deriving from the bound port). Settings: `settings.json > server.http3Advertisement` (sibling of `hidePoweredBy`) + a commented boilerplate `settings.server.json` doc. Tests: `server.test.js` source-pins + a pure-logic replica (present-when-on / absent-when-off / first-writer-wins / typeof-guard). Empirically smoke-confirmed via `bin/gina-container` on a disposable starter bundle (Isaac engine, http/1.1: flag ON → `alt-svc: h3=":443"; ma=86400` on the wire, OFF → absent). Established 2026-06-02.

147. **Layoutless (`renderWithoutLayout`) renders expose controller `userData` BOTH at top level AND under `page.data`; the stash-before-merge order is circular-ref-critical (shipped 0.4.2).** The swig `isWithoutLayout` branch in `controller.render-swig.js` copies each `userData` key into `data.page.data` BEFORE `data = merge(userData, data)`, so a layoutless fragment template resolves bare `{{ var }}` (top level) AND `data.X` / `page.data.X` (via `{% set data = page.data %}` — the data path popins rely on). **The copy MUST precede the merge:** `lib/merge` returns its first argument, so after `merge(userData, data)` `data === userData`; writing `data.page.data = merge(userData, data.page.data)` afterwards would make `data.page.data` alias `data` → a circular structure that throws in the layoutless XHR-data `JSON.stringify` (the `gina-without-layout-xhr-data` hidden input) → HTTP 500. 0.4.1's 'top-level variables' fix had populated ONLY the top level (locked by a negative-invariant test FORBIDDING `page.data`); 0.4.2 restored the `page.data` copy to reach parity with the nunjucks engine (which already populated both) and flipped that test to the both-contract. **Process lesson:** a patch framed as a 'regression fix' that makes a FAILING test pass is suspect — a test that FORBIDS the patched behaviour means the prior choice was deliberate + test-locked; verify the prior decision's intent and treat the test flip as a sanctioned (approval-gated) test change, not a silent restore. Backed by a real-`merge` behavioural replica + a SUBTRACT proving the stash-after-merge cycle in `render-swig.test.js`. Commit `cd690698`. Established 2026-06-02.

149. **Client-bundle plugins must NOT inject inline event-handler attributes (`setAttribute('onclick', …)`, `el.onX = …`) — they trip CSP `script-src-attr` under nonce-based policies; suppress the default action with an `addEventListener('click', e => e.preventDefault())` listener instead (popin close + link + validator submit-triggers fixed in `0.4.3-alpha.2`).** Setting an inline event-handler attribute compiles an inline handler governed by CSP `script-src-attr` (which falls back to `script-src`); once a `'nonce-…'` source is present in `script-src`, `'unsafe-inline'` is disabled for inline handlers (CSP Level 3), so the injection is reported in report-only mode and blocked in enforce mode — exactly what stops a nonce-adopting bundle from flipping its CSP to enforce. Three plugins injected `onclick="return false;"` at bind time: the popin close-binding (`popin/main.js`), the link binding (`link/main.js`), and the form-validator anchor submit-trigger binding (`core/plugins/lib/validator/src/main.js`). **The CSP-safe replacement is element-shape-dependent:** (a) popin already had a DIRECT `addEventListener('click', …)` whose `cancelEvent()` (preventDefault + stopPropagation) fires unconditionally, so removing the inline onclick was a pure, behaviour-neutral removal (the "click scrolls to #" worst-case never materialises — the existing listener's `preventDefault` runs regardless); (b) link + validator default-prevention runs through an id-gated DELEGATION listener (link: document-level; validator: form-level `clickProxyHandler`) that misses child-clicks, so each needed a NEW per-element listener — and it MUST be `preventDefault`-only, NOT `cancelEvent`, because `cancelEvent`'s `stopPropagation` would stop the event reaching the delegation listener and break the link AJAX trigger / validator submit. A `preventDefault`-only listener sets `event.defaultPrevented` exactly as the inline handler did (so the validator's `clickProxyHandler` `if (event.defaultPrevented) return` short-circuit still fires identically) and covers direct AND child clicks. In all three, the dead/live `else if` append branch was dropped and the injection gates preserved. **Rule:** replace an inline `on*` attribute with an `addEventListener` listener; use `preventDefault`-only (NOT `cancelEvent`/`stopPropagation`) whenever the element participates in event delegation or bubbling that other handlers depend on. **Browser-verified** (gina-starter harness, real `Content-Security-Policy-Report-Only: script-src-attr 'none'`): the inline-onclick mechanism raises a `script-src-attr` report violation on execution; the `addEventListener`+`preventDefault` mechanism raises none; both prevent default; gina loads clean. Tests: `popin.test.js` §14 + `csp-inline-handler.test.js` (source pins: no `setAttribute('onclick'` in any of the three + the preventDefault listeners + the rebuilt-dist whole-bundle zero-injection invariant). Established 2026-06-03.

150. **#A11Y1 — `FormValidator` reflects each managed field's validity into `aria-invalid`, so `aria-errormessage` associations actually reach assistive technology; done at the rule-agnostic error-display chokepoint `handleErrorsDisplay` (`core/plugins/lib/validator/src/main.js`).** Per WAI-ARIA an `aria-errormessage` (or error `aria-describedby`) association is inert unless the field also carries `aria-invalid="true"`, which the validator never set. Now the chokepoint sets `aria-invalid="true"` on a committed error (error-set + refresh branches, gated `!isWarning` so soft live-check warnings while typing are NOT asserted) and `"false"` on clear — but on clear it MIRRORS the native `ValidityState` (`$el.willValidate && $el.validity && !$el.validity.valid` ⇒ keep `"true"`) so it never disagrees with the `:user-invalid` styling already shown for natively-constrained fields. Hidden fields (`$el.type=='hidden'`) are skipped. **No-duplicate-message + auto-wire:** a `_hasConsumerErrMsg` probe hoisted before the branch chain suppresses the injected `form-item-error-message` div when the field already references its own error element via `aria-errormessage`; legacy forms with no association keep the injected div and gain an `aria-errormessage` wire to it — a deterministic `gina-errormessage-<form>-<field>` id (sanitised), marked `data-gina-aria-errormessage` so a gina-owned wire is distinguished from a consumer's, removed on clear, and the refresh branch preserves the owned id. **Submit + blur announcement:** on a failed submit (`onValidate`) focus moves to the first DOM-order invalid field (hidden / unfocusable / zero-count skipped) so AT announces it; blur-time committed errors are announced through a lazily-created per-form visually-hidden `aria-live="polite"` region (`announceA11yError`), gated on the per-field path (`fieldName` set) once focus has LEFT the field — a CSS `:user-invalid` toggle is not reliably announced and on blur the field's own aria state isn't re-read; submit-time errors use focus, not the region. **Scope nuance:** the per-field blur/`input` passes fire only when the form opts into `data-gina-form-live-check-enabled` (the warning branch is gated on it); the always-on submit pass covers `aria-invalid` + focus regardless, so a non-live form still gets submit-time exposure. Rule-agnostic (the chokepoint operates on `errors[name]`, not per rule — `isRequired` / `isEmail` / `isInList` / every rule covered). No public API change; existing `form-item-error` / `form-item-warning` / `form-item-error-message` / `data-gina-form-errors` classes + submit-button toggling unchanged. Real-AT smoke (VoiceOver / NVDA announcing the field + message) is the manual acceptance step — the automated coverage exercises the DOM attributes that drive AT. Shipped as four atomic commits. Tests: `test/core/validator-aria-invalid.test.js` (42 — jsdom DOM-behaviour replicas + source pins). Established 2026-06-03.

151. **`templates.json` pre-process pass in `core/config.js` (right after the `hasViews` line, BEFORE the routing↔template GET auto-vivify) expands two additive section-key shapes once per bundle — gina-io/gina#8 comma-separated keys + #10 `_common.config`.** #8: a comma-separated section key (`"a, b": {…}`) is split on `/\s*,\s*/` (each name `.trim()`med, empty segments skipped) and the block is replicated under each named section, MERGING into any section that already exists so a section's own keys win — `merge(existing, JSON.clone(block))`, and `lib/merge` keeps its FIRST argument on a leaf collision (`override=false` default). #10: an optional `_common.config` block is `merge(_common, _common.config)`-flattened back into `_common` then deleted, so the existing `_common.*` read sites are unchanged and a direct `_common.X` overrides `_common.config.X`. **Placement is load-bearing:** the pass must run before the GET auto-vivify `files['templates'][rule.toLowerCase()] = {}` (which keys off CLEAN route names from routing.json) — otherwise a comma key leaves the real route names "missing" → empty `{}` sections get minted AND the comma key survives to produce a dead `"a, b@bundle"` route. **Both are no-ops when absent** (no comma → single-element split → identical; no `_common.config` → untouched), so existing bundles are byte-identical — verified zero comma keys across known consumer + gina fixture `templates.json` before shipping. **#7 (a Swig-like `{ "inherit": … }` directive) was closed un-shipped**, so #8 is NOT redundant: `_common` shares to ALL routes, #8 shares a chosen SUBSET — a gap nothing else fills. Collect-then-mutate (gather comma keys first) avoids changing the object mid-`for…in`. Tests: `test/core/config-templates-preprocess.test.js` (source pins incl. placement-before-auto-vivify + a real-`merge` pure-logic replica: split / union-merge / own-keys-win / trim / empty-segment / flatten / no-op). Established 2026-06-05.

152. **#M12a — opt-in structured (JSON) logging resolved once into `opt.format` (env `GINA_LOG_FORMAT=json`; `text` default).** `lib/logger/src/main.js` init resolves the render format with precedence `GINA_LOG_FORMAT=json|text` > `GINA_LOG_STDOUT` truthy ⇒ json (back-compat alias for the #K8s3 flag) > `text`, BEFORE `loadContainers()` `JSON.clone()`s `opt` into each container — so `containers/default/index.js` reads `opt.format` (falling back to the `GINA_LOG_STDOUT` env when `opt.format` is absent) instead of re-testing env. JSON line = `{ts, level, bundle, message, group, msg}`: `bundle`/`message` are the canonical #M12 keys; `group`/`msg` are retained as back-compat aliases for the shipped #K8s3 `{ts,level,group,msg}` shape — ADDITIVE, non-breaking (a rename would have failed the existing `logger-log-integrity.test.js §17.07` subprocess test, which asserts `typeof entry.{group,msg}==='string'`; the additive choice kept it green). The raw `console.log` path (`self.log`, `main.js` ~L814) bypasses the container dispatch, so it ALSO honours `opt.format==='json'` (wraps as `{ts, level:'info', bundle:opt.name, message, group, msg}`) — without this, JSON mode would interleave JSON + plain lines and break a collector. Default `text` keeps `docker`/OrbStack `docker logs` output byte-identical (the chokepoint for levelled output is the default container; `self.log` is the only other stdout writer — both covered). **M12b deferred** — automatic per-request `requestId`/`durationMs` stamping needs an always-on per-request `AsyncLocalStorage` context = #M14 (the Inspector `_queryALS` store at `controller.js:282-283` is dev/window-gated and carries no id; `durationMs` is computable from the metrics `response.on('finish')` hook but only when metrics is enabled). Tests: `test/lib/logger-render.test.js` (9, behavioural + source-pin). Established 2026-06-05.

153. **Couchbase entities expose a public `getCluster()` accessor (wired onto both the model-entity and N1QL-entity prototypes in `core/connectors/couchbase/index.js`) returning the underlying SDK Cluster handle, so applications can use SDK-level features the entity layer does not wrap — notably multi-document ACID transactions via `cluster.transactions().run(...)` — without reaching into private `_*` connection internals.** The dual connection-shape lookup (`conn._cluster` for the entity-query path vs `conn._scope._bucket._cluster` for the bulk-insert/scope-collection path) is now centralised in ONE internal `resolveCluster(conn)` helper shared by `explainForIndexes`, `bulkInsert` and `getCluster()` — previously each path re-implemented it; it throws a clearly-coded `GINA_COUCHBASE_CLUSTER_UNRESOLVED` error (not a cryptic `undefined.query()` TypeError) when neither shape resolves. `getCluster()` calls `this.getConnection()` then `resolveCluster()`; it is Couchbase-specific so it lives on the connector's entity decoration (reachable wherever `bulkInsert` is), NOT on the generic `EntitySuper`/`getModel()` surface (a SQL/Redis/Mongo entity has no cluster). `explainForIndexes` consumes the helper in a try/catch to stay fire-and-forget non-fatal (warn + skip — it must never break the live query). gina does not pin the `couchbase` driver (resolved from the project's `node_modules`), so `.transactions()` availability depends on the project's installed SDK (3.2+/4.x); `getCluster()` guarantees only the handle. Tests: `test/core/couchbase-connector.test.js` (source pins + pure-logic replicas, neutral fixtures); the pre-existing `inspector.test.js` block-49 cluster-resolution pins were re-pointed from `explainForIndexes` to the extracted `resolveCluster()`. Established 2026-06-05. **MongoDB `getClient()`** is the sibling accessor (`core/connectors/mongodb/index.js`) returning the driver `MongoClient` for `client.startSession().withTransaction(...)` / change streams / `bulkWrite`; unlike Couchbase it needed a one-line plumbing step — `onReady` decorates the yielded `Db` with a `_client` back-reference (the `MongoClient` lived a level up, closure-private) — read via `resolveClient()` (throws coded `GINA_MONGODB_CLIENT_UNRESOLVED`). Both accessors are connector-specific (NOT on generic `EntitySuper`) and guarantee only the handle (gina pins neither driver; transactions need Couchbase SDK 3.2+/4.x, and Mongo a replica-set/sharded deployment). Design test: add an accessor only when `getConnection()` returns a CHILD of the needed handle, not when it already returns the handle itself. Established 2026-06-06.

155. **#M12b — opt-in per-request `requestId` + `durationMs` in JSON logs, built on a narrow `AsyncLocalStorage` (`process.gina._reqALS`, parked like `_queryALS` so it survives dev `require.cache` busting).** Gated on JSON logging ONLY: `_reqCtxLogging` in `core/server.js` mirrors the logger's `opt.format` env precedence (`GINA_LOG_FORMAT=json` > `GINA_LOG_STDOUT`). Text mode renders no id field, so running the ALS there would be pure overhead with no reader — gating on JSON ties the cost to the operator's existing structured-logging opt-in and keeps the default text path byte-identical/zero-cost (this also sidesteps the unmeasured always-on throughput question). `onInstance` (`server.js`) stamps `request._ginaReqStartMs` + `request._ginaReqId` at request entry; the id resolver honours a SANITISED inbound `X-Request-Id` (charset `/^[\w.\-]{1,128}$/`, regenerate-on-violation to kill log-forging/injection) else `crypto.randomUUID()`. The `.run({requestId,startMs})` wrap is at `handle()` — NOT `onInstance` — because the `request.on('end')` boundary between them loses async context whereas `handle()`'s awaits preserve it (original body renamed `_handleDispatch`; `handle` became a thin wrapper; call sites untouched). HTTP/2 gets per-stream scoping for free: streams flow through the same `handle()` via Node's per-stream compat `'request'` event, NOT `session.on('stream')`. The logger's two JSON-assembly sites (default container + the raw `self.log`/`console.log` path) optionally read `process.gina._reqALS.getStore()` and add `requestId` + `durationMs = Date.now() - startMs` (per-line elapsed) when a store is active; absent for CLI/boot/off-request logs (graceful guard). `.run()` NOT `enterWith()` (enterWith bleeds sideways across siblings). The always-on-regardless-of-format throughput PoC (#M14's go/no-go for un-gating) stays deferred — needs a bootable env. Tests: `test/lib/logger-render.test.js §06` (behavioural store→fields + source pins) + `test/core/server.test.js #M12b` (resolver sanitisation, env precedence, ALS-propagation-through-await + concurrent isolation, source pins). Established 2026-06-06.

156. **#TPL1 Slice 1 — opt-in async template-loader extension point for swig, implemented as a SEPARATE render delegate (`controller.render-swig-async.js`), NOT an inline branch in `render-swig.js`.** A bundle configures `settings.template.swig.loader` (connector-style: a named `type` + type-specific flat keys; Slice 1 ships `type:"memory"` with an inline `templates` map — `lib/template-loaders`). `core/server.js initSwigEngine` builds + validates the loader at bundle startup via `lib.templateLoaders.build(cfg)` — fail-fast on bad config, NO network probe at boot (mirroring `initNunjucksEngine`/`NUNJUCKS_NOT_INSTALLED`) — and stashes `{loader, autoescape}` on `process.gina._swigLoaders[dir]` keyed by `conf.content.templates._common.html`. `controller.js this.render` dispatch routes to the async delegate when `process.gina._swigLoaders[<templateRoot>].loader.async === true` (the SAME key expression on all three sides), else the byte-identical filesystem `render-swig.js`. **Why a separate delegate, not an inline `isAsyncLoader` fork:** render-swig's FS coupling is far deeper than the page self-read + `{% extends %}` pre-resolution — it ALSO `fs.existsSync(path)`-404s a missing template, FS-resolves + reads the layout, and `getAssets()`-scans that layout — so an inline fork would thread ~8-10 guards through ~1825 lines + two duplicated send blocks and break the byte-unchanged guarantee; a sibling delegate (the proven render-nunjucks pattern) leaves render-swig.js untouched. **Per-bundle isolation crux:** swig is a process-singleton (`lib/swig-resolver` caches ONE module on `process.gina._swig`), so a per-bundle `swig.setDefaults({loader})` would collide (last bundle wins). The delegate instead builds an ISOLATED `new swigMod.Swig({loader, autoescape, cache:false})` per bundle in `process.gina._swigEngines[templateRoot]`, owner-guarded on `_swigEnginesOwner !== swigMod` (drops the registry when dev-mode hot-swaps the swig module — mirrors render-nunjucks `_nunjucksEnvsOwner`). Verified by spike: `new Swig()` fully isolates its `options.loader`/`cache`/`filters`/`tags`. **Loader contract** (`{resolve(to,from)→id, load(id,cb), async:true}`): gina wraps the user loader with a CVE-2023-25345 segment-guard run on EVERY `resolve()` (rejects `..` segments + absolute paths → covers the whole transitive extends/include chain, stronger than render-swig's page+first-extends-only guard), re-exposing `load` via `.bind()` so `load.length` (arity ≥2) survives — load-bearing because swig's `getTemplate` picks the callback-load path on `load.length>=2`; routing-to-async is gated separately by the loader's `async===true` property. **Render path:** the delegate SKIPS the FS self-read/extends machinery and calls `await engine.getTemplate(name)` (returns `Promise<fn>`; swig forces `cache:false` for async-compiled templates) then `(await fn(data)).output` — swig's async codegen drives `resolve→load` for the page AND its transitive `extends`/`include` through the loader, so templates can live off-disk (remote/CDN/object-storage/in-memory). **MVP scope:** isolation + loader pipeline + per-request gina-filter registration (`engine.setFilter`) + render + HTTP/1.1 & HTTP/2 send (cloned from render-nunjucks `sendHtmlResponse`) + **post-render asset injection** (gina client bundle / CSS / JS injected onto the `</head>`/`</body>` anchors via a verbatim `injectAssets()` port + the gina-bootstrap `whisper()` placeholder pass; `setResources` output reaches `data` via the render-swig.js:609 `data = merge(data, getData())` "needed !!" re-fetch; per-request CSP nonce honoured on the injected bootstrap — so an off-disk full page ships the client runtime and is production-usable). Deferred to follow-up slices (mirroring render-nunjucks N2→#NJ): Inspector payload, static HTML cache writes, error-template routing, Early Hints; the Tier-2 compiled-fn cache (Slice 4). The HTTP(S)-fetch loader + Tier-1 source cache shipped in Slice 2. Default (no-loader) bundles are byte-identical to pre-#TPL1. Tests: `test/lib/template-loaders.test.js` (factory/guard/memory contract + behavioural render-through-swig: extends+include + two-instance isolation + traversal block) + `test/lib/render-engine-dispatch.test.js §03b-03e` (dispatch branch, schema/lib/server wiring, delegate shape + negatives: no FS self-read, no string `compile`; §03e asset-injection / setResources port — source pins + behavioural `injectAssets` eval). **#TPL1 Slice 3 extends the same separate-delegate architecture to nunjucks** (`controller.render-nunjucks-async.js`, opt-in `settings.template.nunjucks.loader` — a verbatim-shape sibling of `template.swig.loader`): `initNunjucksEngine` builds + stashes the loader on `process.gina._nunjucksLoaders[<templateRoot>]` keyed by the same `conf.content.templates._common.html`, and `controller.js` dispatch adds an `_njAsync` sub-check inside the `engine==='nunjucks'` branch (`_njAsync ? '/controller.render-nunjucks-async' : '/controller.render-nunjucks'`). The adapter is a `nunjucks.Loader.extend({async:true, resolve:(from,to)=>to, getSource(name,cb)})` subclass wrapping the gina loader — `resolve` overridden to IDENTITY so the gina loader's CVE-2023-25345 segment-guard is the single path authority on every transitive `extends`/`include` hop; `getSource` returns the nunjucks `{src,path,noCache}` source shape. **Two nunjucks-specific divergences from the swig-async delegate:** (1) a **per-request `new nunjucks.Environment(adapter)`** (the Slice-3 default) — NOT the cached `_nunjucksEnvs` registry the sync `render-nunjucks.js` reuses — originally believed §8.1-mandated for race-safety, but **Tier-2 found that rationale wrong** (a per-request env isolates only the filter name→fn table, NOT the process-global context singleton the filters read — so nunjucks-async raced too; see the Tier-2 close below): it is really just the cache-OFF default, and Tier-2 adds an opt-in shared env; (2) **promisified callback-form `env.render(name,ctx,cb)` is MANDATORY** — a sync `env.render` on an uncached template under an async loader returns `null` SILENTLY (empirically verified, nunjucks 3.2.4). Helpers (`resolveTemplatePath`/`registerGinaFilters`/`sendHtmlResponse`/`injectAssets`/whisper/userData a/b/c merge + the Bug-J `data.data` alias) ported verbatim from `render-nunjucks.js`; the post-await `headersSent()` re-check + the render-swig.js:609 `data = merge(data, getData())` re-fetch come from the swig-async structure. MVP scope matches swig-async (Inspector / writeCache / error-template / Early Hints deferred). nunjucks added as a **ROOT devDependency only** (never `framework/v*/package.json` — the negative invariant) to gate a behavioural §03j adapter test that renders a real transitive `extends`+`include` through the gina memory loader + asserts the CVE guard fires at the gina resolve boundary. Tests: `render-engine-dispatch.test.js §03f-03j` (dispatch, schema/lib/server wiring, delegate shape incl. the negative per-request-env lock `does NOT reference process.gina._nunjucksEnvs` + the `_njAsync` ternary, asset-injection port, gated behavioural adapter). **Tier-2 SHIPPED (2026-06-07, closes #B25):** the KEY FINDING is the per-request filter-context race was NEVER swig-only — BOTH async delegates read per-request context off a PROCESS-GLOBAL filter singleton (`SwigFilters.instance._options` / `NunjucksFilters.instance._options`), which a per-request nunjucks env does NOT isolate (only the name→fn table); the sync delegates are safe only because their render is synchronous. The fix: **context-free filters** that read `process.gina._renderALS.getStore()` at call time (an inner `getRenderCtx()`; a singleton fallback keeps the sync path byte-identical) + an **UNCONDITIONAL** `getRenderALS().run({options,isProxyHost,throwError,req,res}, …)` wrap around each async render (ALS propagates context across every await, closing #B25 whether or not the cache is opted in). Tier-2 compiled-template reuse then rides on top, opt-in via `settings.template.{swig,nunjucks}.loader.cache` (boolean, default off, dev-disabled): swig ALWAYS shares its per-bundle engine (filters registered once) + an opt-in per-template compiled-fn memo (caches the `Promise<fn>`, evicts on reject — swig-core's forced async `cache:false` limits reuse to the top-level compile, transitive children recompile); nunjucks is two-mode (shared owner-guarded `process.gina._nunjucksAsyncEnvs` env when on, fresh per-request env when off). Tests: `render-engine-dispatch.test.js §03k` (source pins + the headline #B25 concurrent-load behavioural for swig + gated nunjucks — two interleaved renders through ONE shared engine/env each read their OWN context — + a pure-logic ALS-isolates-vs-singleton-bleeds subtract; full suite 7643/7643). Established 2026-06-06; Tier-2 2026-06-07. **Slice 2 — `http` built-in loader** (`lib/template-loaders/src/loaders/http.js`, registered in `BUILTINS` alongside `memory`): fetches swig/nunjucks templates over HTTP(S) from a configured `origin`+`basePath` with a Tier-1 source cache (`process.gina._cache`, resolved LAZILY at load-time — the loader is built before the server's shared cache Map exists), absolute TTL (default 60s, `0`=until-evicted), and opt-in ETag `If-None-Match` revalidation (`revalidate:true` → 304 refresh TTL + serve cached, 200 replace, network-error serve stale rather than 500). `resolve()` containment-checks every mapped URL stays under `origin`+`basePath` — a second boundary beyond the CVE-2023-25345 segment-guard that already rejects `..`/absolute identifiers. Established 2026-06-06.

159. **Dialog popins open as native modals in EVERY env (dev/prod parity) — the dev-only non-modal downgrade was removed (`popin/main.js`, shipped `0.4.6-alpha.2` develop, commit `4d59cb86`).** `popinOpen` now calls `$el.showModal()` unconditionally (was `if (gina.config.envIsDev) $el.show(); else $el.showModal();`), and the three `.gina-popins-overlay` gates (overlay creation, `cancelOnOverlayClick` bind, activation) dropped their `|| gina.config.envIsDev` disjunct so the manual overlay survives ONLY for `!useDialogMode` (non-dialog) mode — dialog popins use the native `::backdrop` in all envs (removes the dev double-dim; matches prod, which already rendered overlay-less native modals). **Two-layer correctness rule (why the earlier "Option F" attempt was reverted):** `popinOpen` guards its `showModal()` with `!$el.getAttribute('open')`, so it SKIPS the call when the dialog is already open. A consumer that preemptively opens the dialog (skeleton-loading `MutationObserver` before the XHR returns) must therefore ALSO use `showModal()` (not `show()`); otherwise the dialog is born non-modal, gina's removed-overlay leaves nothing positioning it, and it renders inline-at-bottom with no backdrop (the exact Option-F regression — overlay removed while the consumer still pre-opened non-modally). Making BOTH layers `showModal()`-only keeps the dialog born-modal. **Trade-off accepted:** a native modal inerts the in-page dev Inspector statusbar while the popin is open (no in-dialog launcher). **Verification gotcha:** a gina-starter smoke is FALSELY GREEN (no preemptive open → `showModal()` always runs → modal looks fine); the regression manifests only in a real preemptive-open consumer, so verification MUST use one. Verified live in a real preemptive-open dev render: `:modal`=true, real form, centered, ZERO `.gina-popins-overlay` (the live causation proof that the gate change is the running code — a stale dev-gina would have created+activated the overlay), no console errors. Tests: `popin.test.js §15` (source + dist pins). Established 2026-06-07. **Opt-in skeleton pre-open (`preOpen` + `loadingShell`, commit `92eadb88`):** a popin registered with `preOpen:true` is filled with a loading skeleton and opened BEFORE the XHR returns (born-modal `showModal()` in dialog mode, active container + `.gina-popins-overlay` in div mode); `popinBind` swaps in the real content on completion and `popinOpen`'s already-open guard then skips. A closure-private `showLoadingShell()` runs at BOTH `data-gina-popin-loading` write sites, idempotent via a `hasAttribute('open') || .gina-popin-is-active` guard — NOT `getAttribute('open')`, because `showModal()` sets the `open` attribute to the EMPTY STRING (falsy), so a getAttribute-truthiness check would re-inject + re-`showModal()` and throw. Default skeleton is a gina-namespaced `.gina-popin-skeleton` shimmer (`prefers-reduced-motion` opt-out); pass `loadingShell` HTML to override. Off by default — existing popins byte-identical. Verify with a REAL preemptive-open (a no-pre-open smoke is falsely green). Tests: `popin.test.js §16`. Established 2026-06-07.

160. **#TPL2 — gina's default Swig render path keeps swig-core's CVE-2023-25345 loader confinement ON (no `allowOutsideRoot` opt-out), by eliminating the two trusted out-of-root resolutions it used to make.** swig-core 2.7.1 confines the filesystem loader to its `basepath` (= the bundle templates root) and rejects any `{% include %}`/`{% extends %}`/`{% import %}` resolving outside it. gina had opted out (`swig.loaders.fs(dir, 'utf8', true)` at `core/server.js` initSwigEngine + `core/controller/controller.js` per-request setDefaults — interim commit `b7a022e9`) because its render path produced two trusted out-of-root paths: (1) the processed-layout cache — `render-swig.js` rewrites the page's `{% extends %}` to an absolute path under a SIBLING `cache/` tree — and (2) the dev inspector statusbar, injected as an `{% include %}` of an absolute framework-core path. #TPL2 removes BOTH so both loader sites revert to the bare confined `swig.loaders.fs(dir)`: (1) the layout cache is RELOCATED IN-ROOT to `<templates.html>/.gina-layout-cache/...` (the rewritten `{% extends %}` now resolves inside the loader basepath — only the `cachePath` base assignment changes; the seed-read/write + processed-write file-I/O flow is byte-identical, just relocated), and (2) the statusbar leaf template (only `{% if page.cspNonce %}` + `{{ }}`, no nested directives) is INLINED — its body `fs.readFile`-read and spliced into the layout string in place of the `{% include %}`. The in-root cache is kept INVISIBLE to a consumer's git by an auto-dropped self-ignoring `.gitignore` (`*`) written once at the cache root (the old out-of-root cache lived under the project `cache/` dir consumers already ignored, so no migration burden); its leading-dot name also rides gina's existing dotfile-skip (the `config.js` public/errors/forms scans) and the templates html tree is never a static docroot nor recursively scanned, so the cache is never served nor enumerated. **Enumeration was the crux** (an in-root/confined fix is only correct if EVERY out-of-root resolution is found): error templates (`renderCustomError`/`errorFiles` build only from in-root `/errors` dirs; the framework `50x.html` fallback never reaches the loader) and popin/XHR partials (only string-manipulate the layout, inject no directives) were verified NOT to add out-of-root paths; the `render-swig-async.js` async delegate (#TPL1) + nunjucks are separate engines with their own loaders (out of scope). **Residual is now effectively zero** — an untrusted include cannot escape the templates root at all (a `../` traversal or out-of-root absolute is rejected by swig-core before any read), strictly stronger than the prior interim opt-out + Layer-2 page+first-extends-only guards. Tests: `test/core/swig-loader-allowoutsideroot.test.js` flipped from opt-out-present pins to confinement-active — behavioural: the confined loader ACCEPTS the in-root cache shape and REJECTS both the old out-of-root sibling-cache path and a `../` traversal, plus a real `swig.compile` extends chain (in-root renders, out-of-root rejected). Live-verified: a gina-starter dev render returns HTTP 200 with the statusbar inlined, the in-root `.gina-layout-cache` created, and `git status` clean. Established 2026-06-08. **Floor now `^2.7.2` (2026-06-09) + trustedRoots opt-out REVERTED:** swig-core 2.7.1's confinement carried a relative-basepath regression — a RELATIVE loader `basepath` made it wrongly reject EVERY in-root include/extends (the resolved template path is always absolute and can never be prefixed by a basepath that was only normalized, not resolved); swig-core 2.7.2 resolves the basepath to absolute before the root check, fixing it (absolute-basepath behaviour + the escape/prefix-bypass rejections unchanged). gina's own loader basepath is ABSOLUTE (`templates._common.html` = `${executionPath}/bundles/<bundle>/templates/html`, executionPath = the registered-absolute project root), so gina was never hit — the floor bump just ships the fix to any relative-basepath consumer. A per-bundle `trustedRoots` opt-out to this confinement (`lib/swig-trusted-loader`, develop-only, never released; introduce `24c7c851` + re-home `040b304d`) was briefly added then REVERTED (2026-06-09): out-of-root sibling includes (`{% include "../shared/x" %}`) are intentionally NOT supported — that is the exact CVE-2023-25345 traversal pattern the confinement protects against, so keep shared assets inside the templates root; the relative-basepath case that appeared to motivate the opt-out is fixed by 2.7.2 directly, not by a confinement opt-out. Verified empirically (install 2.7.2 + loader probe): under 2.7.2 a relative basepath now resolves in-root paths (rejected under 2.7.1) while `../` escapes still throw; under an absolute basepath in-root works and out-of-root `../sibling` is still rejected (unchanged 2.7.1→2.7.2). DO NOT re-add trustedRoots — the design decision is to keep confinement absolute with no out-of-root opt-out.

161. **`gina version` (and `gina framework:version`) reports the framework-bundled template engine — `Template engine: <name>@<version>` (e.g. `@rhinostone/swig@2.7.2`), beneath the middleware line.** Built in `lib/cmd/framework/version.js`: reads `framework/v<version>/node_modules/@rhinostone/swig/package.json` (name + version) and injects the line into the `msg.json` `basic[4]` banner via a `%engine%` placeholder placed between the middleware and copyright lines (`%engine%%copyright%`, so an empty token leaves no blank line). The read is wrapped in try/catch, so a framework dir without the engine package installed (e.g. a fresh clone before `npm install`) simply OMITS the line — the "when available" contract — and `version --short=true` is unaffected (prints the bare number only). **Scope constraint (durable):** `gina version` is a context-free global command — no bundle/project config is loaded — so it can only report the framework DEFAULT engine, which is always swig. Nunjucks is per-bundle, project-only and opt-in (`render.engine: 'nunjucks'` in a bundle's `settings.json`) and is therefore NOT visible to this command; reporting a bundle's actual engine (which may be nunjucks) belongs to a future bundle-scoped command, not `version`. Tests: `test/lib/framework-version.test.js` (source pins on version.js + msg.json placeholder ordering + pure-logic token-resolution and banner-assembly replicas incl. the omit-when-empty path). Shipped on develop (0.4.6-alpha.2). Established 2026-06-09.

162. **`self.query()` and the `#B28` body parser are now self-consistent for `application/json` bodies — the built-in HTTP client sends raw JSON, and the server tolerantly accepts a percent-encoded body as a fallback.** The server-to-server client (`this.query` in `core/controller/controller.js`) historically serialized PUT/POST `application/json` bodies as `encodeRFC5987ValueChars(JSON.stringify(data))` (percent-encoded), while `#B28` had switched the server parser (`processRequestData` in `core/server.js`) to read `application/json` verbatim via `JSON.parse(request.body)` — so the framework emitted bodies its own parser rejected: every internal `self.query()` POST/PATCH with a non-empty body returned HTTP 500, and PUT silently dropped the body. Empty-data calls were unaffected (no body sent). Two-sided fix. **Client:** `this.query` now sends `queryData = JSON.stringify(data)` raw for the json/text/x-www-form PUT/POST branch (the wire content-type is forced to `application/json` anyway) — RFC5987 value-encoding is defined for HTTP header values (`filename*=`), not request bodies; the empty-data short-circuit and the urlencoded-into-path GET-style branch (per-key `encodeRFC5987ValueChars`, correct for URL params) are untouched. **Server:** each of the three `application/json` branches (POST/PUT/PATCH) tries the verbatim `JSON.parse(request.body)` FIRST and falls back to `JSON.parse(decodeURIComponent(request.body))` only when that throws — so a genuine raw-JSON body (incl. a literal `%XX` inside a string value) is never double-decoded (`#B28` intent preserved) while an already-deployed encoded sender is still accepted (version-skew-safe). Error disposition unchanged: POST/PATCH `throwError(500)` only when BOTH attempts fail, PUT warn-only. No dist rebuild — `controller.js` is not in the browser bundle and `encodeRFC5987ValueChars`'s definition is untouched (only its use in `this.query` changed). Tests: `test/core/http-methods.test.js` §14 (server tolerant-parse source pins + behavioural replica: encoded body parses, raw parses, `%XX` preserved via verbatim-win, malformed throws, POST/PATCH-500-vs-PUT-warn disposition) + §15 (client raw-JSON source pins + round-trip replica, incl. the legacy-encoded-sender still round-tripping); §13's `decodeURIComponent`-absence pin was tightened to verbatim-first / decode-only-as-fallback. Established 2026-06-09.

163. **`request.rawBody` — the exact unparsed request body is snapshotted before parsing, for inbound-webhook HMAC verification.** The non-multipart end-of-stream handler in `core/server.js` (`request.on('end', onEnd)`) now sets `request.rawBody = (typeof request.body === 'string') ? request.body : ''` immediately BEFORE `processRequestData(request, response, next)` mutates `request.body` into the parsed object. Signed-webhook authentication computes an HMAC over the exact raw request bytes; by the time `app.use(...)` middlewares run the stream is drained and `request.body` is the parsed object, so a consumer middleware could not otherwise capture the raw bytes (consume-then-`next()` hangs the request; a passive `data`/`end` listener sees nothing). The snapshot is a reference assignment (zero-copy) and always-on — no opt-in flag. It is gated to the non-multipart branch only: the `multipart/form-data` path uses Busboy (`request.pipe(busboy)`) and never reaches `onEnd`, so uploads are unaffected. Empty bodies yield `''` (never the `{}` request-init object — `request.body` is initialised to `{}` and only becomes a string once the data handler appends a chunk, so the `typeof === 'string'` guard maps both empty and object cases to `''`). **Engine coverage:** `processRequestData` and this end-of-stream handler live solely in `server.js` and are the shared body pipeline for BOTH engines — `server.isaac.js` has no separate general body accumulation (its only `req.on('data')` is the bounded `/_gina/instrument` control reader), so the isaac engine is covered without an engine-specific mirror. Tests: `test/core/http-methods.test.js` §16 (source pins: snapshot-before-`processRequestData` ordering, the string guard, single-site / after-`request.pipe(busboy)` placement; behavioural replica of multi-chunk accumulation + snapshot; empty-body → `''`; an HMAC round-trip recomputed over `req.rawBody`; a subtract-my-contribution case proving a re-stringified parse breaks verification). Established 2026-06-09.

164. **#B30 — a request with a malformed percent-escape in its URL or query string crashed the bundle (unauthenticated single-request DoS); fixed by crash-safe decode helpers + dropping a redundant double-decode.** Both `decodeURIComponent` AND `decodeURI` throw `URIError: URI malformed` on a malformed escape (a bare `%`, `%zz`, a truncated `%E0%A`); `proc.js`'s `uncaughtException` handler emits an `[ FRAMEWORK ][ uncaughtException ]` emerg and SIGTERM-shuts-down the bundle on a generic uncaught error, so any unguarded decode of attacker-controllable input on the request path was a single-request crash (the production trace: `processRequestData` GET branch → `onEnd` emit → emerg → SIGTERM → forced exit). Two defect classes, two fixes. **(1) Redundant double-decode (GET/HEAD)** — `processRequestData`'s GET/HEAD branches did `formatDataFromString(decodeURIComponent(request.query.inheritedData))` / `(bodyStr)`, but `request.query` is already percent-decoded once by the engine query parser AND `formatDataFromString` self-guards its own internal decode, so the explicit decode was a redundant SECOND decode that crashed on a literal `%` surviving the first decode (e.g. inheritedData `{"x":"50%off"}` → `%of`) and silently double-decoded valid data; DROPPED at all four sites (robust regardless of engine — `formatDataFromString` supplies the single guarded decode; behaviour byte-identical for the no-`%`-in-data case). **(2) Genuine first-decode (cannot drop)** — added `safeDecodeURIComponent` / `safeDecodeURI` DataHelper globals (`try { return decode…(str) } catch { return str }`, mirroring the POST/PUT/PATCH body branches) and routed every first-decode of attacker input through them: `server.isaac.js` query parser (`a[1]` ×2), `server.js` static path (`handleStatics` filename + `getAssetFilenameFromUrl` url), `helpers/data parseBody` (`arr[i]`/`el[0]`/`el[1]`), AND — the class the first pass MISSED (caught by adversarial review) — every `decodeURI` site (a SEPARATE function that throws the same `URIError`, used with a `/// avoid %20` comment on the routing + error paths): `server.js` ×3 (trie lookup, route params, `throwError`), `lib/routing/src/main.js` ×4 (cached params, `request.routing.path` ×2, linear-scan params), `controller.js` ×1 (error path). The `throwError` / `controller.js` error-path sites were worst — a malformed-`%` URL to a missing asset (`GET /assets/%E0%A.css`) reached `throwError` via the 404 path and crashed FROM the error handler (turning a would-be 404 into a crash); the async route-matching `decodeURI` sites instead rejected the dispatch promise → `unhandledRejection` (logged) → a *hung* request, now a clean 404. **NOT done:** no broadening of `proc.js`'s handler into a swallow-everything net (only known-benign TCP/HTTP codes are continued; generic errors still SIGTERM — swallowing arbitrary uncaught exceptions masks state corruption). **Deferred:** `formatDataFromString` still single-decodes an already-decoded value, so a literal `%20` in data still decodes to a space (no crash); fully fixing needs a no-decode variant (own consumer survey). **Lesson:** when sweeping a throwing-decode crash class, sweep BOTH `decodeURIComponent` AND `decodeURI`. Tests: `http-methods.test.js` §17 (GET/HEAD drop + first-decode pins, comment-stripped negatives) + §18 (the 8 `decodeURI` guards across server.js / lib/routing / controller.js), `server.isaac.test.js` §10 (query-parser safe-decode + `?x=%` replica), `format-data-from-string.test.js` (`safeDecodeURIComponent`/`safeDecodeURI` behavioural against the real loaded helper + subtract proving the pre-fix shape throws). Empirical repro (real DataHelper) reproduces the exact production `URIError` pre-fix and the clean result post-fix. Established 2026-06-10.

165. **The dev-mode Inspector statusbar (and its launch link) vanished on content-heavy pages because `String.replace(/re/, str)` expanded dollar-patterns in the replacement STRING (`0ba469d0`, 2026-06-10).** `render-swig.js` splices its inline dev scripts before `</body>` via `layout/htmlContent.replace(/<\/body>/i, content + '...')`. With a STRING replacement, `String.prototype.replace` expands dollar-sequences found in `content`: dollar-backtick = the text BEFORE the match (prematch), dollar-quote = AFTER (postmatch), `$&` = the match, `$1`/`$2` = capture groups (empty when the regex has none). The inlined #TPL2 statusbar body legitimately carries a dollar-backtick (in a regex-explaining comment) and a dollar-quote (in a regex literal), so the prematch spliced the ENTIRE document before `</body>` INTO the statusbar `<script>` → `SyntaxError` → the statusbar IIFE never ran (no statusbar host, no `ginaToolbar` shim, no link). Volume-dependent: a near-empty page (gina-starter) has a tiny prematch and renders fine — the bug only manifests on a real content-heavy render, so a gina-starter smoke is falsely green. The two sibling flow-patch splices (cache-hit + cache-miss) had the same latent exposure via SQL `$1`/`$2` placeholders captured in the flow `detail`. **Fix: function replacers** — `html.replace(/<\/body>/i, function () { return frag + '</body>'; })` inserts the return value verbatim with no dollar-expansion (escaping `$`→`$$` works too, less clear). The popin-XHR splices were already safe — `encodeRFC5987ValueChars` percent-encodes `$`→`%24`. **General rule: any `String.replace(re, X + ...)` where X is dynamic (JSON, user data, an inlined template, captured query/flow text) must use a function replacer or escape `$`** — a string replacement is a latent injection. render-swig.js is server-side (not in the browser bundle) so the fix needs no dist rebuild; it hot-reloads per render in dev/cacheless mode (so a running dev bundle picks it up on the next render). Tests: `render-swig.test.js §18` (3 source pins locking the function replacers + a pure-logic replica proving the old string form duplicates the document while the function replacer does not + a guard that the shipped statusbar actually carries a dollar-sequence). Established 2026-06-10.


166. **`project:add`'s gina-symlink step (`linkGina`) no longer shells out to a PATH-resolved `gina` binary, and a failed link now fails the command instead of printing success (`2966a823`, 2026-06-10).** The project's `node_modules/gina` symlink was created by spawning `gina link @<project>` resolved from the PATH; on hosts where no `gina` is on PATH — a repo checkout where `npm install --ignore-scripts` skipped bin linking (CI), or any non-global install — the spawn failed, the failure was routed through the SUCCESS callback (`onSuccess(err)`; the `onError` parameter was dead code), `project:add` printed "Project has been added", and the scaffolded bundle later crashed at boot with `MODULE_NOT_FOUND` on its framework require (the daemonless container-boot integration test was red on CI from its very first run there — never a regression of the commits it first ran against). Three-part fix in `lib/cmd/project/add.js::linkGina`: (a) spawn the running install's OWN CLI — `[process.execPath, <gina root>/bin/cli, 'link', '@<project>']`, the root resolved from the handler's own location (the same self-resolution rationale `framework/link.js` documents for its symlink source; `bin/cli` rewrites a bare `link` task to `framework:link`, which is offline-allowed, so no daemon is needed); the ARRAY form of `Shell.run` matters because the string form is split on spaces with no shell; (b) verify the actual POSTCONDITION (`fs.existsSync` on the project's `node_modules/gina`) instead of trusting the Shell's err channel — the Shell helper reports ANY stderr output as an error even on success, so postcondition-wins is what keeps stderr noise from failing a healthy link; (c) route genuine failures through `onError` so `project:add` exits non-zero. **Two general lessons: never self-invoke your own CLI via a PATH-resolved name (resolve from your own location — PATH may carry no copy or a different version), and when a spawn wrapper's error channel is noisy, assert the operation's postcondition rather than the channel.** Repro recipe for the class: re-run the failing flow with `PATH=/usr/bin:/bin` — a green-locally/red-on-CI integration test whose CI log shows `MODULE_NOT_FOUND` from generated code is the signature. Tests: `test/lib/project-add-link.test.js` (spawn-shape + postcondition + onError source pins, comment-stripped negative on the old PATH form, resolved-CLI-exists check against the real tree, onComplete decision replica); the container-boot integration test is the end-to-end CI guard. Established 2026-06-10.

167. **Two follow-on rules from completing the PATH-resolved self-invocation sweep (`884ee7cd` + `96ca2d03`, 2026-06-10).** (a) **Never guard `execSync` with `instanceof Error`** — `execSync` THROWS on non-zero exit and never returns an Error, so the shape `err = execSync(...); if (err instanceof Error) {...}` is dead code: the "handled" branch can never fire and a real failure escapes as an uncaught throw instead of reaching the command's error path. Wrap in try/catch, and prefer `err.stderr.toString().trim()` for the operator-facing message when present (execSync attaches the child's stderr to the thrown error). Three live instances of this dead shape were removed (framework:link's stale-node_modules repair and CmdHelper's auto-link step). (b) **When self-invoking, pick the entry point that preserves spawn semantics:** plain CLI commands (`link`, `link-node-modules`) are invoked through `bin/cli` directly, but daemon-lifecycle commands (`start`, `stop`, `bundle:restart` inside framework start/restart) must go through the `bin/gina` wrapper — it is the daemonizing "fake daemon" entry point and `start` relies on its detached spawn; `bin/gina` self-locates its cli via its own directory, so an absolute-path invocation stays PATH-independent end-to-end. Deliberate exceptions kept out of the sweep: deriving an npm install PREFIX from `which gina` for project:import registry metadata (semantically "where is gina installed on this machine" — meaningless on a repo checkout, and a wrong self-derived guess would corrupt project metadata; kept with its existing try/catch), and `$(which npm)` (a third-party binary — PATH is the normal resolution). Tests: `test/lib/project-add-link.test.js` §05-07. Established 2026-06-10.

168. **Never hardcode `framework/v<version>` paths in test fixtures, test harnesses, or CI workflows — derive the dir from `package.json` at runtime; enforced by `test/lib/e2e-no-version-pins.test.js`.** The framework directory is renamed at every release cut (the stable rename plus two alpha bumps), so a version-pinned path is green on every CI run between cuts and breaks exactly at the release — polluting the cut's CI signal — and the tag's tree (merged to master, which only moves via tag merges) then carries a deterministic red until the NEXT tag. Incident shape (v0.4.6): an e2e fixture linked the built stylesheet via a literal `framework/v0.4.6-alpha.2/...` href; both post-rename pushes went red with CSS-initial-value failures (`overflow: visible` where the scroll-lock expects `hidden`, `transition-property: all` where the enter transition expects `opacity`) — the diagnostic tell that the page's JS ran but its stylesheet didn't load. Fix: serve fixtures through `test/e2e/runtime-server.js`, which resolves `framework/v<version>` from `package.json` (the same idiom as the CI workflows' `FW_DIR="framework/v${VERSION}"`). The guard test sweeps `test/e2e/**`, `.github/workflows/**`, and `playwright.config.js` for `framework/v<digit>` literals in the gated suite, so a reintroduced pin fails at commit time instead of at the cut; the regex is digit-anchored so `framework/v<version>` prose, `v${VERSION}` interpolation, and `v*` glob pathspecs never match, and it carries per-directory non-empty-sweep pins so a dir move cannot hollow it into a silent always-green no-op. `gna.js` and the root `package.json` `main` field carry the literal BY DESIGN — the release scripts rewrite them atomically with the dir rename (dir name == `package.json` version at every commit), so they are maintained surfaces, deliberately not swept. A post-rename pre-publish Playwright smoke was measured and declined: with zero literals enforced, every committed tree is rename-consistent by construction, and dist staleness (the other tag-tree drift class) is the bundle-freshness gate's job. Established 2026-06-11 (commits `37281177` + `26ff8e83`).

169. **WebSocket over HTTP/2 shipped (develop 2026-06-11, target `0.5.0`; commits `a1bf4d95` transport + `a5f29e5f` codec + `f6ebca90` bridge/API).** Three layers, each with a measured lesson. (1) Transport (Isaac engine): strict `=== true` opt-in `http2Options.enableConnectProtocol` — a boolean settings key must NEVER reuse the `|| default` idiom of its numeric siblings (the string `"true"` or `1` must not flip a SETTINGS advert); the key lives in the `http2Options.settings` literal, which reaches `createSecureServer` (https) and the cleartext `createServer` branches alike since the h2c flood-defense parity fix (2026-06-11 — before it, the cleartext branches passed a bare `{ allowHTTP1 }` literal, dropping the SETTINGS advert AND the session flood caps `maxSessionRejectedStreams`/`maxSessionInvalidFrames`, so h2c bundles ran protocol-default limits — effectively unlimited concurrent streams, server push enabled — and silently ignored their settings.json `http2Options` overrides; passing `http2Options` verbatim is safe because TLS material only merges under `/https/` scheme gates). **Extended-CONNECT streams must be handled on the compat `connect` event, not `session.on('stream')`** — Node's http2 compat layer auto-responds 405 to every CONNECT stream and its internal listener is attached at session setup, before any userland session-level listener, so a stream-handler design loses the race with `ERR_HTTP2_HEADERS_SENT`. Registering a `connect` listener suppresses that auto-405 for ALL CONNECT, so every handler path must terminate the stream (an unanswered CONNECT hangs forever), including the HTTP/1.1 CONNECT signature (`(req, socket, head)`, no `request.stream`) the same event receives under `allowHTTP1`. Refusals are HTTP statuses (405 plain-CONNECT byte-parity — the compat default is just `:status`+`date`; 501 unclaimed; 404 unregistered path), not `close(NGHTTP2_REFUSED_STREAM)`: measured client-side, the RST shape yields an opaque stream error with no loggable status and carries retry semantics. With the flag off, nghttp2 rejects extended CONNECT before any app event — default-off deployments are byte-identical. (2) `lib/ws-framing`: dependency-free RFC 6455 codec (UTF-8 via core `buffer.isUtf8`; maxPayload enforced from the DECLARED frame length before buffering; close-code table mirrors the ws predicate). Write-own was chosen after measuring that ws's `exports` map blocks deep-requiring its internals and that conforming vendoring is whole-tarball-only — which would ship a second copy of a package that is already a framework dependency. The maturity gap is covered by a differential oracle in the tests: ws's root export exposes `Receiver`/`Sender` publicly, so frames are cross-checked in both directions and both parsers must refuse unmasked client frames. (3) `lib/ws-session` + `app.onWebSocket(path, handler)`: `onInitialize`'s `app` IS the raw engine server, so the registration API needs zero routing-layer changes; the dispatcher installs lazily (zero registrations keep the 501-unclaimed refusal); handler exceptions are contained (1011 close, never an uncaughtException); sessions register a closer in the SIGTERM drain registry so shutdown sends `1001 going away` instead of blocking the drain — and since the WS shutdown-drain fix (2026-06-11) the pre-existing engine.io and `/_gina/agent` WS sockets register there too (engine.io with a graceful `socket.close()` — its API takes no status code; the agent WS with `ws.close(1001, 'server shutting down')`), each closer added at socket setup and removed in the socket's own close handler, so no live socket surface blocks SIGTERM until the hard timeout anymore. A routing.json-declared WS surface was measured and deferred: the route matcher's allowed-methods list and exact-method gate sit on the hot path and reject CONNECT, and controller dispatch is render-lifecycle-only — every streaming endpoint bypasses routing, and so does this one. Established 2026-06-11.

170. **ESM compatibility layer shipped (#M10, develop 2026-06-11, commit `58bd2570`): strict `exports` map + default-export-only `.mjs` wrappers; four design constraints worth keeping.** (1) The map is STRICT — `.`, `./gna`, `./package.json` only — because a measured survey found nothing else Node-resolved against the package (client-side RequireJS IDs like `gina/validator` are not Node subpaths and are untouched by `exports`); a `"./*"` wildcard would NOT have preserved legacy behaviour anyway, since exports-pattern resolution disables extension-adding and directory-index resolution. (2) Each entry carries a `types` condition FIRST: once `exports` exists, TypeScript's node16/bundler resolution ignores `typesVersions`, so omitting the conditions would silently break declaration resolution for TS consumers. (3) Both wrappers are default-export-only ON PURPOSE — `gna.js` (the `gina/gna` helper module) exposes getter properties that resolve at ACCESS time after framework boot, and static named ESM re-exports would freeze `undefined` pre-boot; `index.mjs` resolves the core via `require(require('./package.json').main)` (createRequire), so it carries no version-pinned path and is NOT a bump-chain member, while the version-pinned `exports['.'].require` IS kept in lockstep with `main` by both bump scripts (prepare_version.js + post_publish.js set it explicitly — package.json is rewritten as a parsed object there, so a generic content-regex never covers it) and the lockstep is test-pinned (`exports['.'].require === main + '.js'`). (4) Test design: `require('gina')` boots the framework and throws outside a spawned bundle child, so the behavioural ESM test stubs the CJS `require.cache` at the resolved core path before `import('gina')` (the wrapper's createRequire shares the global cache); and only the NEGATIVE assertion (`ERR_PACKAGE_PATH_NOT_EXPORTED` on an undeclared subpath) proves the map is active — positive resolutions also succeed via the legacy node_modules walk + `main`.

171. **Per-template-extension engine dispatch + nunjucks Inspector parity shipped (#M11, develop 2026-06-11, commits `18cd093a` + `b2f652cd`).** (1) Dispatch: the effective extension (setTemplate override ext → templates.json section `ext` → the `.html` default — the delegates' OWN precedence, deliberately with no filename sniffing, so the dispatch can never disagree with the file the delegate resolves) keys the engine at `controller.js` `this.render`: `.njk`→nunjucks, `.swig`→swig, anything else follows the bundle-level `render.engine`; one bundle mixes engines with zero new config keys, and existing bundles are byte-unchanged because `.html` follows the setting. `initNunjucksEngine` also runs when any templates.json section declares a `.njk` ext (dotted/dotless/case-insensitive), keeping the boot-time NUNJUCKS_NOT_INSTALLED fail-fast for mixed bundles; a pure-runtime `setTemplate('.njk')` on a bundle with no `.njk` config surfaces the resolver's explicit get()-before-load() error. Auto-detect-on-`.njk`-presence was measured and DROPPED — with ext-keyed dispatch its only residual value was magic ext-defaulting, which explicit per-section `ext` config covers. (2) Inspector parity: `data.page.queries` is piped from `local._queryLog` (same gate shape as render-swig) and the dev statusbar ships for nunjucks — statusbar.html is a LEAF template valid in both engines, but the nunjucks injection point runs AFTER the engine pass, so the body is rendered through the resolver module's `renderString()` and spliced before `</body>` with a $-safe FUNCTION replacer; converting that splice also fixed the latent dollar-expansion hazard the old string-replacement form shared with the pre-#TPL2 swig splice (`$'`/`$\``/`$n` in statusbar source or user-data JSON would splice document fragments into the script). `data.page.flow` had already shipped (#FI) — the roadmap row and the delegate's own header comment claiming otherwise were both stale; verify-before-inheriting caught them.
