# 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, mysql, postgresql, sqlite, redis, scylladb, mongodb, ai
  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:639-642`). 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`.

**Layoutless renders** (`self.renderWithoutLayout(data)`) expose the controller's data BOTH at top level (`{{ var }}`) AND under `page.data` — fragment templates (e.g. popin/dialog content) rely on the `{% set data = page.data %}` path, both engines. (Implementation invariant: the `page.data` copy precedes the userData merge — the deep-merge returns its FIRST argument, so reversing the order aliases `page.data` to the whole context and the fragment's XHR-data serialization throws on the circular structure → HTTP 500.)

---

## 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 …`
- Dynamic field-path key (Couchbase): a `$N` in field-path position (`<field>.$N`, e.g. `SET doc.flags.$2 = $3`) is interpolated as a literal identifier at **every** occurrence, while value placeholders (`= $N`, `LIMIT $N`) still bind positionally; `%N` is not supported as a field-path key

---

## 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), stream(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) { ... });

// streaming (token-by-token) — a fresh EventEmitter per call (start/delta/done/error + .onComplete)
ai.stream(messages)
    .on('delta', function(d) { process.stdout.write(d.text); })
    .on('done',  function(r) { /* { content, model, usage, latencyMs } */ });

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

- 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 behind a callback-shaped `set/get/remove/list/sweep` store seam — in-memory by default, or connector-backed via `app.json`'s `jobs.store` (SQLite for single-host durability, MongoDB or Redis for multi-pod visibility: records survive bundle restarts and are readable cross-process/pod — a job created on one pod is pollable from another — while the deferred function still runs only in the creating process) — 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`, `retryBackoffMs`, `webhook*`). The deferred function runs after the response, so capture plain values — never `req`/`res`.
- Failed-job retry (opt-in): pass `{ maxAttempts: N }` to `create`/`startJob` — a failed attempt is retried on the CREATING process with exponential backoff (`app.json jobs.retryBackoffMs`, default 1000 ms, doubling per attempt). Between attempts the record returns to `pending` with the last error and an informational `nextRetryAt` visible, and it can never be purged by the TTL sweep (`expiresAt` stays null until terminal); `failed`/`completed` are strictly terminal and the completion webhook fires exactly once, after the final attempt. Only the origin pod retries — the deferred function exists only in the process that created the job; other pods just read the shared record. The default `maxAttempts` of 1 keeps the exact runs-once behavior.
- Durable (connector-backed) records: set `app.json` `jobs.store` to a `connectors.json` entry name — e.g. `"jobs": { "store": "jobsDb" }` with `"jobsDb": { "connector": "sqlite", "file": "/data/jobs.db" }` (single host), `"jobsDb": { "connector": "mongodb", "host": "127.0.0.1", "port": 27017, "database": "gina_jobs" }` (multi-pod — same `uri`/`host`/`database` keys as the MongoDB session store; the `mongodb` driver is resolved from the consuming project's `node_modules`, so `npm install mongodb` there), or `"jobsRedis": { "connector": "redis", "host": "127.0.0.1", "port": 6379 }` (multi-pod — same `host`/`port`/`db`/`password`/`tls`/`cluster` keys as the Redis session store; driver `ioredis`, resolved like the session store with a project-`node_modules` fallback, so `npm install ioredis` in the project works under every install topology). Each entry follows its OWN connector's config-key conventions: for sqlite use `file` for the path — the model layer also scans every connectors.json entry at boot and treats `database` as a NAME, so a path in `database` fails the boot before the job store is built; for mongodb `database` IS the (required) name; a redis entry needs no database name at all (keys are namespaced under a `prefix`, default `jobs:`). A configured store that cannot be built (bad entry name, missing implementation, unopenable file, missing driver) aborts the boot with a clear error — never a silent fall-back to memory; an absent `jobs.store` keeps the in-memory store, unchanged. Store `get`/`list` deliberately do NOT filter expired records — expiry acts only at sweep time, matching the memory store; the MongoDB store therefore never configures server-side TTL reaping (which would purge outside the seam — and the record's epoch-ms `expiresAt` is a number the Date-indexed TTL monitor would ignore anyway), and the Redis store never sets a per-key TTL for the same reason.
- Redis job-store design: the record rides as a JSON string at `<prefix><id>`, with per-state id SETs (`<prefix>idx:state:<state>`) and a terminal-only expiry sorted-set (`<prefix>idx:expires`, score = epoch-ms `expiresAt`) maintained atomically with every write in one MULTI — so `list({state})` reads only matching ids, `sweep` reads only already-expired ids, and neither ever SCANs the keyspace. Redis Cluster works via a hash-tagged prefix (default `{jobs}:` in cluster mode; a custom cluster prefix without a `{tag}` fails fast at construction instead of CROSSSLOT errors at runtime). Also fixed alongside: a `"connector": "redis"` connectors.json entry previously aborted the bundle boot at the model scan (the connector shipped no boot connector file) — even the documented Redis session-store configuration could not boot; the connector now includes a no-op boot connector that opens no connection and requires no driver.

---

## 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`, `BAD_GATEWAY`, `PREFLIGHT_TIMEOUT`, `PREFLIGHT_FAILED`. `ECONNREFUSED` is never retried. `retryCount` tracks attempts; `retriedOnce` is derived from `retryCount > 0`.

**Exhausted retryable errors must SURFACE, never fall through to the success path (#B34, 2026-06-13).** Every retryable failure (timeout / stream-error / premature-close / preflight) builds a typed `GinaHttp2Error` on exhaustion and reports it via `callback(err)` / `emit('query#complete', {status, error})`. The 502 path was the lone exception: its retry guard (`httpStatus === 502 && retryCount < HTTP2_MAX_RETRIES`) was the ONLY consumer of the captured upstream transport status (`httpStatus`), so once retries were spent the guard simply declined and control fell through to normal response handling — `callback(false, data)` — delivering the 502 body to the caller as SUCCESS (a JSON-shaped 502 body even hit the legitimate "undefined body-status → 200" fallback and was relabelled `status: 200`; an HTML 502 page was passed through as the "data"). The bug is the gap between the TRANSPORT status (`httpStatus`, from the `:status` HEADERS frame) and the BODY status (`data.status`, from the JSON payload) — they were never reconciled. Fix: an `else if (httpStatus === 502)` branch surfaces a `BAD_GATEWAY` / `status: 502` `GinaHttp2Error` (truthful upstream status; `_swallowIfNonCritical` still applies). Measured by driving the real `query()` against an always-502 h2c server (both JSON-body and HTML-body variants → success pre-fix, error post-fix). Tests: `controller.test.js §24`.

**Retries are gated on HTTP-method idempotency (#B53, folds former #198).** All FIVE retry gates — the four HTTP/2 branches (stream timeout / stream error / premature close / upstream 502) and the HTTP/1.1 catch-all error gate (which also fires post-send: its request timeout destroys after the body write) — require a SAFE method (GET/HEAD/OPTIONS/TRACE, case-normalised) before re-sending the stashed body: a POST/PUT/PATCH/DELETE the upstream already executed is never silently re-run when only the RESPONSE was lost (idle-keepalive session eviction, a TCP drop, a GOAWAY, an upstream 502 — all post-send). A caller opts a non-safe method back in per request with `retryUnsafe: true` (default `false`; stripped from outgoing HTTP/2 headers). The default is the SAFE set, NOT the wider RFC-7231 idempotent set — transport idempotency (PUT/DELETE) is not "no side effects". Pre-flight PING retries stay ungated (genuinely pre-send).

**Settled streams are released at EVERY non-retry terminal (#B52, folds former #193).** The stream listeners capture the caller's callback → the per-request controller → its per-request config clone; on a non-retry terminal an idempotent finalize helper (cancel the stream timeout, remove the listeners, close the stream — each op try/catch'd so cleanup never breaks the response path) runs at all five terminals BEFORE the error-swallow check. Without it the settled stream — pinned by the cached, TTL-less HTTP/2 session — stranded the controller + its config clone until the session died: in dev mode (a distinct controller per request) sustained bursts piled clones toward the heap limit (~512 MB live heap from 300 requests in 7 s; ~95% removed by the fix; query-driven and render-agnostic). Retry branches destroy the whole session instead (which tears the stream down), and the pre-flight PING swallow sites have no request stream yet. Any new non-retry terminal in an HTTP/2 client handler must finalize AT the terminal, not on eventual socket death.

**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) shipped in 0.3.7 — `bundle:mcp-start --transport=http` (library `lib/mcp-http`; flags `--http-host`/`--http-port`/`--auth-token`/`--cors-origin`/`--max-in-flight`).

---

## 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 bundle:mcp-start <bundle> @<project> --transport=http --http-port=<n>  # Serve over Streamable HTTP instead of stdio (+ --http-host/--auth-token/--cors-origin/--max-in-flight)
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 bundle:copy <source> <new> @<project>   # Duplicate a bundle in the same project (name-footprint rewrite + fresh port matrix)
gina bundle:rename <old> <new> @<project>    # Rename a bundle in place (port numbers preserved/rekeyed; refuses while running)
gina protocol:remove <bundle> @<project>     # Revert a bundle to the project default protocol
gina minion:list                             # List live bundle child processes (run-dir process-truth)
gina minion:kill @<project> --dry-run        # Preview/reap a project's bundle processes (SIGTERM → grace → SIGKILL)
gina project:move @<project> --to=/new/path  # Relocate a project tree (atomic rename; refuses while bundles run)
gina project:backup @<project> --out=/dir    # Archive a project to a timestamped zip (symlink-safe, node_modules excluded)
gina project:restore @<name> <archive.zip> --to=/path  # Extract + re-register a backup (ports re-allocated per bundle)
gina framework:add <version>                 # Install a published framework version side-by-side (bundles pin via --gina-version)
gina framework:list --all                    # Reconcile installed / archived / registered framework versions
gina framework:remove <version>              # Remove a side-by-side version (refuses the active default)
gina framework:update --to-version=<v> --fix # Reconcile ~/.gina state to the installed version (dry-run by default)
gina framework:reset                         # Factory reset: clear ~/.gina, rebuilt on next command (shorthand: gina reset)
gina framework:man                           # Render the framework man page in the terminal
gina i18n:scan [<bundle>] @<project>         # Translation coverage per culture (--format=json)
gina i18n:add <culture> [<bundle>] @<project>          # Seed bundle/locales/<culture>.json catalogs
gina i18n:export <culture> [<bundle>] @<project> --format=po|csv --output=<path>  # Export catalogs for translators
gina i18n:import <culture> @<project> --file=<path>    # Import a translated file (format auto-detected: .po/.csv/.json)
gina secrets:scan @<project>                 # Which ${secret:KEY} placeholders the configs need (read-only)
gina secrets:check @<project> --env-file=<f> # SET/UNSET per key; non-zero exit when any is unset (CI gate)
gina connector:test [@<project>] [--connect] # Validate connector config/driver/secrets offline; --connect adds an AI live probe
gina connector:infer <connector> [<bundle>] @<project> [--stream] [--raw]  # One-off AI-connector inference from the CLI
gina connector:models <connector> [<bundle>] @<project>  # List an AI connector's provider models
gina image:build [<bundle>] @<project> [--emit] [--stream]  # Synthesize a Containerfile + build an OCI image via buildah
gina port:set <bundle> @<project> --protocol=<p> --scheme=<s> --port=<n> --env=<e> [--force]  # Pin a port; --force evicts the holder
gina help [<group>]                          # Global or per-group command help
gina version                                 # Version banner (+ Middleware / Template engine lines); also -v / --version
gina-container                          # Foreground launcher for Docker/K8s
gina-init                               # Bootstrap ~/.gina/ from env vars (containers)
```

**Not implemented:** `gina --status` / `gina -t` (no alias, no handler). Everything else listed above ships (the former stubs `project:move`/`project:backup`/`project:restore` and `framework:update` shipped in 0.5.5).

---

## 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.
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.
107. **Controller files must NOT contain `require` or `inherits`** — the router (`router.js:639-642`) 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.
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`.
37. **`handle()` is async without `.catch()`** — `handle(req, res, next, bundle, pathname, config)` (defined at `server.js:4823`) is called at `server.js:3968` and `:4512` without `await` or `.catch()`. Any unhandled throw becomes a silent unhandled promise rejection. When debugging routing crashes, wrap the body in try-catch temporarily.
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:1766` 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.

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** — `bundle:mcp-start --transport=http` (shipped 0.3.7) enforces this security model: (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`, 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.

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".

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 per-request on `request._ginaProxyPrefix`. Composition site: `core/controller/controller.js` at the `set('page.environment.webroot', ...)` call (~`controller.js:603`) — when the per-request prefix slot 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:1057,1086`) 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:1057,1086` 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`. **Sibling fix — reverse-proxy HOST context request-scoped (`#B65`, 2026-07-04):** the same one-shot `!isProxyHost` gate froze the `PROXY_HOST` / `PROXY_HOSTNAME` worker-globals (derived from a port-less `Host` or `X-Forwarded-Host`) at the FIRST proxied request's value, and internal `Controller::query()` forwards read that frozen global. `core/server.isaac.js` now request-scopes them — per-request `request._ginaIsProxyHost` / `_ginaProxyHost` / `_ginaProxyHostname` slots (mirroring `_ginaProxyPrefix`), and the worker-global is refreshed on EVERY proxied request so it can never freeze (and, being written ONLY from proxied requests, can never freeze to a raw `host:port`); `core/controller/controller.js` forwards THIS request's proxied host from the slot (worker-global fallback for req-less callers: released-response / ws-query). Classification: a request is proxied when its inbound `Host` is port-less OR it carries `X-Forwarded-Host` — which separates all three topologies safely: RAW `host:port` (never classified proxied → URL builders keep the static config host, unchanged), single-hop (port-less Host → host = Host), multi-hop (`X-Forwarded-Host` wins). `setContext('isProxyHost', true)` stays monotonic (cert-skip readers unaffected). Server-side only, NO dist rebuild; the SERVER-side serve-time rewrites are S2b (shipped 2026-07-04 — see the end of this entry), and the browser-bundled `lib/routing` `toUrl` worker-global proxy read (#B66 S2c) was MEASURED INERT and CLOSED (2026-07-04, no code — inert in single-public-host all-proxied topologies; only mixed-worker / multi-host contaminate; a server-side caller-override fix needs NO dist rebuild — see the end of this entry). Tests: `server.isaac.test.js §15/§15b` (three-topology + freeze-fix + gate-removal subtract), `controller.test.js §30/§30b` (slot-wins-over-stale-global forward, raw-no-forward, req-less→global fallback). **Follow-up — browser-side residual fixed (`#B66`, 2026-07-04):** #B65 fixed the SERVER-side host context; the BROWSER still received every bundle's INTERNAL `scheme://host:port` via two surfaces — the inline `gina.config.hostname` whisper (`controller.js` ~556) AND the boot-static `/_gina/assets/routing.json` blob (`server.isaac.js`). #B66 closes both, SERVER-SIDE, gated on the SAME per-request `request._ginaIsProxyHost` slot (RAW byte-identical, NO dist rebuild): **S1** whispers `page.environment.hostname = local.req._ginaProxyHostname` (host-only, port-less, forwarded scheme) when proxied — the internal `hostname` var + `_proxyHostname` stay byte-identical (grep-confirmed no server-side reader of `page.environment.hostname` besides the whisper template); **S2** boot-builds a SECOND host-stripped `routing.json` (drops each route's `host`+`hostname`, KEEPS `webroot`, still `.br`/`.gz`), served instead of the full blob when proxied with `Cache-Control: private` (a shared cache must not cross-serve variants). The host-only whisper is what flips the browser routing self-check (`lib/routing/src/main.js:1034` — it can't strip a port/webroot the whispered value carries, so an internal `host:port(+webroot)` leaves it false), after which cross-bundle `toUrl` (`:1189/1193`) resolves same-origin instead of the unreachable internal host. Measured (daemonless 2-bundle + reverse-proxy-header repro, real `getRoute().toUrl()` 4-state matrix): S1 ALONE fixes the function (route.hostname intact → public origin); stripping `route.hostname` is SAFE (the design's "degrades to a same-origin URL, no throw" holds) and matters ONLY in states where S1 hasn't flipped the self-check, which are UNREACHABLE in production because S1+S2 are COUPLED by the same per-request gate (both fire when proxied, neither when raw). Precision correction to the design's raw-form claim: in that unreachable strip-without-S1 state the RAW `toUrl` return carries an `"undefined"` segment — `''+this.hostname` with the host/hostname key absent → `"undefined/webroot/path"`, which the browser resolves RELATIVE to the current origin (same-origin, wrong path — i.e. the design's "relative same-origin" is functionally right; its "no undefined" summary just missed the raw segment); `"no throw"` holds because `webroot` is kept. The one out-of-scope edge is a mode-2 proxied deploy whose PUBLIC url carries an explicit port (self-check needs `!location.port`), already broken today. Tests: `controller.test.js §31/§31b`, `server.isaac.test.js §16/§16b` (+ the §09 fixed-window X-Powered-By pin converted to a structural ordering pin — the handler insert displaced it, sanctioned test change). **S2b shipped (2026-07-04, server-side, NO dist rebuild):** the 4 serve-time host rewrites in `controller.js` — the setOptions routing-clone gate + the `page.environment.proxyHost/Hostname` whisper, `getNodeRes`'s proxied-host build, `redirect`'s target host, and `getConfig`'s hostname/host override — now read THIS request's per-request slot (`local.req._ginaIsProxyHost` / `_ginaProxyHost` / `_ginaProxyHostname`) with the worker-global as a MANDATORY fallback (the Express engine — the default when `engine` is unset — never sets the slots, and `getConfig` is reachable req-less: ws-query / released-response / async health probe). Closes the mixed-worker/multi-tenant/concurrent-request residual — a direct or interleaved request on a worker that just served a proxied one no longer resolves the stale last-proxied host. Measured non-leaks left out of scope: the isSpecialCase `:host != PROXY_HOST` comparisons (a cross-bundle-link heuristic a stale global can't flip) and `query()`'s `PROXY_SCHEME` (boot-static, never per-request-refreshed). §12 reconcile — the getConfig re-point is neutral-or-better for the Origin-header false-positive: `_ginaIsProxyHost` is classified in `server.isaac.js` (`:1704`) BEFORE the Origin→Host rewrite (`:2089`), so a same-origin direct POST classifies `false` and getConfig no longer overwrites the config hostname. Live before/after on the REAL getConfig (mixed proxied-A → raw → proxied-B interleave: 2 fails → 5/5). Tests: `controller.test.js §32/§32b` (source pins on all 4 sites + pure-logic replicas + subtract). The browser-bundled `lib/routing` `toUrl`/`getRoute` worker-global proxy read (#B66 S2c) was MEASURED INERT and CLOSED (2026-07-04, no code) — inert in the single-public-host all-proxied topology (only mixed-worker / multi-host contaminate); a server-side caller-override fix (NO dist rebuild) is the ready recipe if a future topology needs it. **#B67 fixed (2026-07-05, commit `439a49a1`):** the SERVER-side *proxied* cross-bundle `getRoute('<route>@<child>').toUrl()` redirect — a genuine residual the "S2c inert" claim did NOT cover (a direct `getRoute().toUrl()` has no `url`-filter override, so it emitted an unreachable host+webroot blend `<parent-internal-host>:<port>/<parent-webroot>//<child-webroot>` instead of `<public-host>/<child-webroot>`) — is now fixed engine-agnostically WITHOUT touching getRoute (NO dist rebuild): `core/router.js` refreshes the host-only worker-global `PROXY_HOSTNAME` at the engine-agnostic routing point (right after `setContext('isProxyHost', …)`) on every request classified proxied by THIS request's Host (`port-less Host || X-Forwarded-Host` — the #B65 gate, not the sticky latch, so no freeze), and `core/controller/controller.js:622` writes a host-ONLY `_proxyHostname` (was host+webroot), so getRoute's `PROXY_HOSTNAME || _proxyHostname` fallback reads the public host for BOTH engines and the double-webroot blend is gone. The MIXED-worker raw-after-proxied edge stays #B66 S2c (inert; ready escalation = the Approach-A ALS above — a req-less singleton like getRoute needs an ALS OR a reliably-refreshed engine-agnostic worker-global, never a per-request slot it can't read). Tests: `router.test.js §12` (3-topology × both-engine composition + subtract) + `controller.test.js §33`. **Redirect cache hardening (2026-07-05):** framework-emitted redirects (controller `redirect` calls and route-declared `control: "redirect"` routes) now fold the dev-mode no-store header set (`Cache-Control: no-cache, no-store, must-revalidate` + `Pragma` + `Expires`) into the response whenever the emitting request is classified proxied — a proxied redirect's target host is proxy-context-derived, and a browser-cacheable `301` pins a transient value permanently (a pre-fix wrong-host `301` keeps replaying from client caches AFTER a fix ships, masquerading as "fix didn't work"; verify redirect fixes from a fresh profile / private window). The set is folded into `headInfos` — not the `writeHead` call — so the inter-bundle query 3xx forward intercepts, which replay `{ status, headers }` verbatim, inherit it too (they previously forwarded bare even in dev). Direct (non-proxied) production redirects are byte-identical to before; the `301` default and route-declared `param.code` are untouched. Tests: `controller.test.js §34` (gate matrix + forward-inheritance replica + subtract). **#B75 (2026-07-06) extends the same no-store to the XHR/popin redirect JSON exits** — `redirect()`'s two `renderJSON({isXhrRedirect,…})` exits (the popin `popin.url` and the plain-XHR `location`) carry a proxy-context-derived target host just like the writeHead Location, but shipped BARE (no cache directives) — the sibling gap #B68's writeHead fold left open. They now `setHeader` the same trio under the same `self.isCacheless() || isProxyHost` gate, set on `local.res` so render-json's HTTP/2 `getHeaders()` fold and the HTTP/1.1 path both carry it; direct-prod byte-identical. Measured BENIGN in Chrome (the popin XHR does not cache-bust so the URL is cache-stable, and no gina plugin sets Cache-Control, yet a validator-less 200 JSON is refetched every time — no stale-host replay), so this is defense-in-depth against a non-Chrome heuristic cache / misconfigured intermediary rather than an observed bug. Same commit guards a session-less bundle's `req.session.user` deref on an XHR redirect carrying params (was a 500 with no Session plugin mounted). Tests: `controller.test.js §35`.

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).

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). **#B42 (2026-06-14, commit `341acc4f`):** the config-load catch in `core/config.js::loadBundleConfig` now actually SURFACES that `_ginaSecretKey` — on a resolution failure it logs `console.debug('[CONFIG][loadBundleConfig] Secret resolution failed for \`<KEY>\` in \`<bundle>/<env>:<scope>\` configuration')` before propagating, so an operator with an unset placeholder learns WHICH key and WHICH bundle/config (previously the catch discarded the key and only the generic `Secret resolution failed` message bubbled to the top-level handler, naming neither). Debug level only — the propagated error and the top-level log stay generic, so the key never reaches a louder channel; a `<unknown>` fallback covers a future backend that throws without attaching the key.

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`).

125. **CLI handler authoring & operations — consolidated** (replaces individual entries #17, #23, #24, #55, #56, #58, #59, #60, #62, #63, #102, #112; plus #161, #166, #167, #181, #182, #183, #185, #199, #201, #202, #203 folded 2026-07-05):
- **CLI stubs** — `gina --status` / `gina -t` appear in help.txt or docs but have no handler (not in `aliases.json`); tracked in `ROADMAP.md § CLI`; 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; `project:move` / `project:backup` / `project:restore` / `framework:update` / `framework:man` (+ `project:man` / `bundle:man` / `service:man`) — the **CLI Tier 3** finals — shipped 0.5.x (full coverage in the two CLI Tier 3 sub-bullets below). 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 `:1014/:1020` 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:301`) 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 — shipped as `lib/json-config-header` (`firstStructuralBraceIndex`/`splitHeader`: the first STRUCTURAL brace, skipping `//` + block comments), consumed by `connector:add`/`connector:rm`/`connector:migrate --fix` (0.5.9). The live incident: the scaffolded connectors template's own `// "couchbase": {` example comment was the corrupting match — the naive split commented out the JSON body's opening brace, and every later read of the file failed.
- **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. **Sibling robustness (#B59, 2026-07-01, `helper.js`):** a corrupt `projects.json` registration no longer emerg-crashes EVERY command. Three degrade-not-throw points, all inside `isCmdConfigured`'s try (whose catch does `console.emerg(stack)` + `exit(1)`): the `:262` registry loop pre-checks each `.path` against `_()`'s reject set (undefined / `''` / length≤2) and `console.warn`+`continue`s a corrupt entry (mirrors #B24's degrade-and-continue) instead of letting `new _('')` throw; the targeted deref (the `else if` ~`:366`) now requires a truthy length>2 `.path`, so an empty targeted path falls to the cwd fallback exactly like an already-handled undefined one and reaches the clean guard; and a THIRD `else if ( ! fs.existsSync(cmd.projects[name].path) )` — a SIBLING of the `/\:remove$/ && force` branch above, NOT a guard atop the recreate `else` (so that branch stays byte-identical and its source-inspection test passes unchanged) — clean-exits with `path no longer exists — re-add / remove --force` instead of the auto-manifest `createFileFromDataSync` ENOENT (the generator does no `mkdir -p`; use `fs.existsSync`, not `_()`, so an empty path is tested without throwing). Verified via an isolated-home A/B smoke (PRE-FIX all 3 cases stack-dump; FIXED clean); tests `helper.test.js §20`. **Sibling loud-reject (#B69, 2026-07-05, same file):** an `@` token whose first char falls outside `[a-z0-9_.]` (e.g. `@Myproject`, a bare `@`) was silently dropped by the argv detection loop — the command then ran against the cwd project / all projects with exit 0 (a mutating task like `bundle:add` could write to the wrong project while reporting success). Such tokens now reject loudly (`is not a valid project name`, exit 1) via a catch-all `else if (/^\@/)` after the detection branch; the detection regex and `isValidName` both test only the FIRST char, so the pre-existing `!isValidName` reject inside the detection branch is unreachable dead code. The family's `@<project>`-not-registered path was separately measured to already exit 1 (pipe + PTY, Node + Bun) — when checking CLI exit codes, read `$?` directly, never through a pipeline.
- **CLI Tier 3 — project-tree relocate / archive / restore (`project:move` slice 1 `1c492241`, `project:backup` slice 2 `4fed7de8`, `project:restore` + `lib/archiver.decompress()` slice 3 `68563976`; shipped 0.5.x)** — `project:move @<p> --to=/new/path` atomically `renameSync`-moves the source tree and rewrites ONLY the registry `path` (inverse of `project:rename`); refuses while any bundle runs (no `--force` bypass), refuses cross-filesystem (`EXDEV` → use `project:import`). **THE load-bearing gotcha — `--path` is unusable as a "must-not-exist target" for ANY `project:` command:** the shared `CmdHelper` bootstrap ("Creating project location if not existing") pre-`mkdirSync`s `cmd.params.path` for every `project:` command AND points `projectLocation` at it, so a move/restore target passed via `--path` is auto-created and the handler's "target must not exist" check then refuses on the bootstrap's own dir — take the target via a flag the bootstrap never reads (`--to`), whitelisted in `project/arguments.json` (this DIRECTLY bites `project:restore`, whose help advertised `--path`). Three facts make a path-only move minimal: `ports.json` is NAME-keyed (`"<port>": "<bundle>@<project>/<env>"`), so a name-unchanged move never touches `ports*.json` (a negative `self.ports`-absence pin locks this); the `projects.json` path-derived fields (`bundles_path`/`releases_path`/…) live under `~/.<project>` (homedir, name-keyed), so only the `path` field is rewritten; use `renameSync` (atomic, symlink-preserving, throws `EXDEV` cross-fs), NOT `folder.mv` (cp+rm, dereferences symlinks). `project:backup @<p> [--out=/dump]` archives the SOURCE to `<out>/<p>-<YYYYMMDD-HHMMSS>.zip`, read-only on the registry. **THE archiver gotcha** (`lib/archiver` was COMPLETELY UNUSED before this — "compress() works" was an unverified inherited claim): the directory form `compress(dir, target)` is BROKEN for a project tree — `browse()` walks with `fs.statSync`, which FOLLOWS symlinks, so it recurses the project's absolute `node_modules/gina` symlink into the entire framework install AND leaks absolute host paths; use the ARRAY form `compress([{input,output},…], target, {name,method:'gzip',level:9})` (deterministic `<target>/<name>.zip`, entries at the `output` relative paths, no wrapper folder) with a self-walk that EXCLUDES `node_modules` by name and SKIPS symlinks via `dirent.isSymbolicLink()`. `--with-password` is REJECTED fail-closed — every encryption hook is an empty stub and JSZip can't make encrypted zips, so returning a plaintext archive for an encryption request is a false-security footgun. `project:restore @<name> <archive.zip> --to=/path [--force]` extracts then RE-REGISTERS so the project is immediately startable: `lib/archiver.decompress()` (was an empty stub; now JSZip-3.x — `JSZip.loadAsync` → per-entry `entry.async('nodebuffer')`, skip `entry.dir`, **zip-slip guard** rejecting any entry whose resolved dest escapes the target) → self-invoke `project:add @name --path=<to>` (self-resolved `bin/cli` via `process.execPath`, NEVER a PATH-resolved `gina`) → loop `bundle:add <b> @name --import` over `manifest.bundles` (the only path that allocates a bundle's ports — bare `project:add` allocates 0; `project:import` REFUSES an unregistered name). Uses the explicit `@<name>` CLI shape (cheaper than the no-`@` form help advertised — only the not-found guard needs a `restore` exemption vs 3 guard exemptions otherwise — and `@name` IS the rename-on-restore target). **Sync-emit-before-listener bug (probe-caught, generalizable):** a synchronous early-return emit (missing src / sync throw) fires BEFORE the caller's `.onComplete` attaches its listener, so the listener misses it (hang) — defer synchronous emits via `process.nextTick` (`compress()` has the same latent shape). `lib/archiver` is server-only (fs/zlib) → no plugin rebuild. **Positive-evidence lesson:** all 26 of `project:move`'s source-pin/pure-logic tests PASSED while it was functionally BROKEN by the `--path` collision — only a live `add→move→verify` HTTP-200 smoke caught it, so a new CLI command needs a live happy-path smoke before "done".
- **CLI Tier 3 — `framework:update` state reconcile (slice 4 `93e1798a`) + `<group>:man` inline man pages (slice 5 `ae34b7ec`); shipped 0.5.x, completes CLI Tier 3** — `framework:update [--to-version=<v>] [--fix|--apply] [--dry-run] [--format=json]` is a **state-migration** command, NOT the "npm self-update" the ROADMAP once advertised (that was declined — can't update the running process, PATH/sudo hazards; deferred behind a future `--self-update`). It reconciles `~/.gina/main.json` (`def_framework` + `frameworks[<short>]`) and `<short>/settings.json` (`version` + `def_framework`) to `GINA_VERSION` (or `--to-version`), automating the manual Post-merge state check; dry-run by default, `--fix`/`--apply` writes. **Writes MUST go through `lib.generator.createFileFromDataSync(obj, path)`** — that routes a `StateStore.isStatePath` match to `StateStore.write` → SQLite key + atomic JSON sidecar IN LOCKSTEP (so reading the sidecar via `requireJSON` while writing via `createFileFromDataSync` repairs BOTH a stale `def_framework` AND a prior `gina.db`-vs-sidecar drift in one shot); a raw `fs.writeFileSync` is the `post_install.js` bug that drifts `gina.db` behind the sidecar. No-CmdHelper handler (models `framework/set.js`/`version.js`, reads bare `GINA_HOMEDIR`/`GINA_VERSION` globals) — the right shape for a no-project framework command, and it sidesteps the `--path` bootstrap-mkdir gotcha entirely (no project arg). Never-regress guard reuses `script/version_compare.isStrictlyOlder` via `require('../../../../../script/version_compare')` (the relative depth is invariant across framework-dir renames, and `version_compare.js` ships to npm). **Is `def_framework` runtime-load-bearing? — outcome (c), runtime-read but NARROW:** `helper.js` keys `mainConfig.protocols[short]`/`schemes[short]` off it to default protocols/schemes for projects that pin no `framework` AND declare none of their own, but scopes/envs/cultures default off `GINA_SHORT_VERSION` not `def_framework`, and the drift only bites at shortVersion (0.4→0.5) not patch granularity; per-shortVersion metadata-map seeding stays `framework:init`'s job (the command WARNS when absent, locked by a negative-invariant test). `gina framework:man` / `project:man` / `bundle:man` / `service:man` **runtime-render** the group's ronn `.1.md` page to the terminal (design fork over a build-time roff generator + `package.json "man"` field — no new markdown/man dependency, none is installed), falling back to that group's `help.txt` with a one-line notice when the `.1.md` is absent (only `gina-framework.1.md` exists today, so `project`/`bundle`/`service` fall back). The cross-group engine lives at `lib/cmd/man-render.js` (sibling to `helper.js`, required as `require('../man-render')` — NOT a per-group `inc/`, because it serves 4 groups; the group comes from `opt.task.topic`, so a future `<group>:man` falls back automatically), and each `lib/cmd/<group>/man.js` is a one-line `module.exports = require('../man-render')`. **Lazy `console` keeps the module require-testable** — `man-render.js` reads `getPath`/`GINA_VERSION`/`lib.logger` lazily INSIDE the handler body, never at module top, so a unit test can `require()` it to exercise the pure helpers (`substitute`/`stripRonn`/`renderMan`) without throwing `lib is not defined` (a top-level `var console = lib.logger` would).
- **`gina version` reports the framework DEFAULT template engine only** — `version` is a context-free global command (no bundle/project config loads), so its `Template engine:` banner line (a `%engine%` msg-token read from the framework-dir engine package, try/catch-omitted when the package is absent) always names the default engine; a bundle's per-bundle opt-in engine is not visible to it. `--short` is unaffected.
- **Never self-invoke the CLI via a PATH-resolved binary; assert postconditions, not a noisy err channel** — spawn the running install's OWN entry point (`process.execPath` + the self-located `bin/cli`; daemon-lifecycle commands go through the daemonizing `bin/gina` wrapper to keep detached-spawn semantics); PATH may carry no copy or a DIFFERENT install. The Shell helper reports ANY stderr output as an error, so decide success on the operation's POSTCONDITION (e.g. the created symlink exists), and route genuine failures through the error callback so the command exits non-zero. Never guard `execSync` with `instanceof Error` — it THROWS on non-zero exit and never returns an Error, so that branch is dead code; wrap in try/catch and prefer `err.stderr.toString().trim()` for the operator-facing message. Deliberate exceptions: deriving an npm install PREFIX from `which gina` (registry metadata, not an invocation) and `$(which npm)` (a third-party binary — PATH is the normal resolution). **A self-invoked child is also HOME-BLIND unless the env is re-exported:** the CLI bootstrap sweeps every `GINA_*`/`VENDOR_*`/`USER_*` key out of `process.env` into the framework context (read back via `getEnvVar`), so a spawned CLI child inherits NONE of them — under a `GINA_HOMEDIR` override (CI scaffolds, isolated smokes) the child resolved the DEFAULT home, missed the just-registered project, and `project:add`'s link step failed with exit 1 after the registration itself had landed. Fix shape: the Shell helper takes an opt-in `env` option (undefined = inherit, existing callers unchanged) and the link spawn passes `Object.assign({}, process.env, { GINA_HOMEDIR })`; on a postcondition failure, append the tail of the child's captured stdout to the error — the Shell temp logs are deleted on close, so without it the child's real failure reads as an empty `error: false`. **The sweep blinds EVERY spawned CLI child — the full spawn-site set now re-exports the home:** the auto-link commands run on project/bundle start/stop/restart, the `project:start/stop/restart` bundle delegations (whose command token is substituted from the parent's own argv — already self-resolved, but the exec options carried no env), and `project:add`'s `--scope`/`--env` children. The scope/env children were doubly broken besides the blindness: they shelled out to a PATH-resolved `gina` (a second install or a bare CI PATH resolves wrong — now the self-resolved CLI), and `execSync` with inherited stdio returns null, so reading `.toString()` off that return threw INSIDE the try after the child had SUCCEEDED — every `--scope`/`--env` run misreported `could not be set`, exited 1, and never printed the success message, override or not. Measured pre-fix under a home override: the scope/env children registered the scope in the DEFAULT home's config (propagating into every project registered there), and the home-blind link children silently fabricated scaffold artifacts — a manifest/env "fix" plus node_modules links — under the default home and the invoking cwd, with exit 0.
- **Port pinning beats allocation** — `port:reset` allocates alphabetically and count-sensitively, so hardcoded per-container ports DRIFT whenever the project's bundle set changes; pin each container's own bundle with `port:set <bundle> @<project> --protocol=<p> --scheme=<s> --port=<n> --env=<e> --force` (`--force` evicts the prior holder from BOTH the forward and reverse port maps; without it an in-use port still rejects, byte-identically; idempotent on re-runs; eviction-not-swap — the displaced bundle re-pins itself from its own context). `bundle:add --ignore-ports=<csv>` excludes ports from the scan — the values stay STRINGS end-to-end because the scan's skip is a string `indexOf` compare; `parseInt`-ing them silently breaks the skip.
- **Framework-connection flags are scoped to framework-scoped commands** — `--port`/`--mq-port`/`--host-v4`/`--hostname`/`--debug-port` are hoisted + persisted into the framework's own settings ONLY for bare `start`/`stop`/`restart` and `framework:*` commands; a sub-topic command's `--port` stays on argv for that command's own parser. Pre-fix they hoisted for EVERY command, so a bundle-scoped `--port=N` corrupted the framework command-socket port (8124 → N) — and because the daemon binds the PERSISTED `settings['port'] || 8124` directly (no port-scan), clearing runtime state does NOT recover it: correct the persisted `port` key in BOTH state surfaces (JSON sidecar + its database mirror), keyed by shortVersion `major.minor`. The `--inspect`/`--debug` splice stays unconditional. Measurement lesson: instrument the WRITE (a stack-dump on the settings write) to find the real hoister — reading the hoisters mis-identified the site once.
- **The daemon command socket accumulates-and-guard-parses** — TCP may split or coalesce the client's single JSON argv write, so the socket handler keeps a per-connection accumulator, parses inside a SILENT try/catch (a partial chunk legitimately fails until complete), and shape-guards the result (`Array.isArray`) before use; a bare `JSON.parse(chunk)` was a crash vector (SyntaxError → uncaughtException → SIGTERM daemon teardown).
- **`tail --follow`'s auto-restart treats log-derived identifiers as UNTRUSTED** — the bundle/project parsed from a crash log line rejects path separators + `..` traversal and confines the resolved saved-argv path under the tmp dir; the saved start command re-executes via `execFileSync(bin, args)` (no shell), gated on the argv containing `bundle:start`. Any auto-action deriving a path or command from network/log-sourced input takes both halves of that rule.
- **`framework:reset` (`gina reset`) is the runtime factory reset** — wipes `~/.gina` so the NEXT command rebuilds it from defaults; the install-lifecycle `--reset` flag can't work under a package manager that blocks dependency lifecycle scripts. It deliberately does NOT re-bootstrap in-process (this process's require cache holds the just-wiped state files — an in-process rebuild would read stale config). It refuses while the daemon or any bundle runs unless `--force`, liveness-probed via `process.kill(pid, 0)` — never a `ps` shell-out: on ps-less minimal images, every `ps -p` throw made the pidfile sweeper prune LIVE bundles' pidfiles on every command (bundles reported stopped, the reset guard blinded).
- **`framework:add` / `framework:list` / `framework:remove` manage side-by-side installed versions** — an install ships exactly ONE framework tree; side-by-side versions live in a user-home archive store symlinked into the install (re-linked on every install), and a bundle pins one via `--gina-version` / manifest `gina_version`. `add` (pack → extract → archive → own-deps install → symlink-unless-a-real-dir → register) NEVER writes the default-version key — adding ≠ defaulting; `remove` HARD-refuses the active default + the real shipped dir (only symlinked versions are removable) and SOFT-refuses a project manifest pin (`--force` overrides); `list` reconciles the three surfaces (install dir real-vs-symlink, archive store, registry). `project:status` / `bundle:status --format=json` carry a per-project `framework` field + a per-bundle `gina_version` field — the effective per-bundle version is `gina_version || framework`.

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 12 non-footgun headers (HDR1/2/3/4/7/8/9/10/11/12/13/14 — the Phase-1.5 helmet-parity HDR8-12 were folded into the safe set 2026-05-17) 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` ~line 419 to inject phantom URL segments, compounding work exponentially. Fix: `req.params = Object.assign({}, _origParams)` + restore `req[method]` from `_origReqMethod` (`req[method] = Object.assign({}, _origReqMethod)`, `server.js:4976`) before each `compareUrls()` — NEVER `delete req[method]` (that destroys the parsed POST/PUT body) and 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:1954` 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** — an internal bundle's routing had `"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 `server.js:1944` and loops `response.setHeader(h, headerValue)` over each key at `server.js:2034` — 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:3038` on Express, never read on Isaac); dropped in favour of the canonical `Gina/<version>` shape emitted by `server.js:3038` + `_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.

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.

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.

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.

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:add <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) **Sibling (2026-07-03) — same guard-before-dynamic-require rule, applied to command DISPATCH.** `lib/cmd/framework/init.js` `run()` — the single chokepoint that `require()`s `/cmd/<topic>/<action>.js` for BOTH offline framework AND online bundle commands — now `fs.existsSync`-guards the handler path BEFORE requiring it. A known group (listed in `bin/cli`'s `allowedOffline`) with an UNKNOWN ACTION previously threw `Cannot find module .../cmd/<group>/<action>.js`, dumped as a raw stack by `run()`'s catch: `gina framework:connector` (connector is its OWN top-level group), `gina connector` / `gina connector --help` (auto-prefixed to the same `framework:connector` by `bin/cli:373`), and `gina -V` (→ `framework:-V`; only lowercase `-v` and `--version` are aliased) all hit it. The guard now prints a clean `'<group>:<action>' is not a valid command.` + a `gina help` / `gina help <group>` pointer, plus a did-you-mean when the action names a real command group (gated on the SAME `/^[a-z][a-z0-9-]*$/` + `<action>/help.txt`-exists test `gina help <group>` uses), exits 1, and mirrors the message to `opt.client` on the socket path; the try/catch still keeps the stack for errors thrown INSIDE a resolved handler (real crash vs. typo cleanly split by the `existsSync` check). `bin/cli:426-432` already rejected an unknown GROUP cleanly — this closes the known-group/unknown-action gap. Rule: `fs.existsSync`-guard every dynamic handler require and emit a clean unknown-command message; reserve the stack for errors from inside a resolved handler. Tests `test/lib/cmd-unknown-command.test.js`; server-side (no dist rebuild).

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.

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.

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.

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:660 `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:660 `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.

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.

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 layer then shipped as a follow-up slice: a route with `method:"ws"` + `param.wsHandler` auto-registers `bundle/channels/<name>.js` at bundle bootstrap (a `core/server.js` loop just before the `configured` emit) by calling the engine's own `onWebSocket(url, handler)` — feeding the SAME registry/dispatcher as the programmatic API (programmatic wins on a path collision, which `onWebSocket` warns on) and staying OFF the render router (a `ws`-method route never matches an HTTP request: the `server.js:4816` method filter + the method-keyed route cache reject it and the radix-trie candidate is method-filtered; WS connects are CONNECT, caught in the `connect` handler). The handler is a plain `(session, request)` module — deliberately NOT a gina controller (no render lifecycle / `req` / `res`, which is what makes it session-safe) — and reaches the backend via process-globals: `getModel('<bundle>','<Model>')` + `.onComplete` (req/res-free, connection wired at bundle-init) and `require('lib/<name>')` (the `gna.js` `NODE_PATH`/`_initPaths` injection is process-wide). `channels/` is the bundle dir (sibling of `controllers/`/`models/`); "handlers" was rejected (in gina it means a frontend templates concept). A missing/invalid channel module fails the bundle boot loudly; no `config.js` change was needed (a `ws` route normalizes cleanly). Slice 1 was exact-path only; **slice 2 (2026-06-22) added `:param` segments** entirely in the isaac dispatcher (server.js unchanged — a `:param` URL flows through the registration loop as a plain string): `onWebSocket` splits exact-vs-param via `/\:/.test(path)` (the unchanged `_wsHandlers` map vs an ordered `_wsParamHandlers` array of `{pattern,segments,handler}`), and the dispatcher tries the exact map FIRST then a strict-segment-count param scan that captures `request.params[name]=decodeURIComponent(seg)` (colon-stripped key + value → `request.params`, the same shape `lib/routing` writes for an HTTP controller), set on the CONNECT `Http2ServerRequest` BEFORE `wsSession.accept` (an exact match yields `request.params={}`, never undefined). Precedence: exact beats `:param`, then FIRST-registered (declaration order) wins among overlapping equal-length patterns — MEASURED to mirror gina's HTTP router, which is first-match-by-declaration-order (`server.js:5035-5041` breaks on the first matching route; the `fitsWithRequirements` score is an internal single-route gate, NOT a cross-route selector). Out of scope (→ later slices): per-segment `requirements`/regex, trailing-slash normalization, the HTTP `params[0]` artifact, and a cross-bundle `self.query()` backend context for channel handlers (shipped as slice 3b). **Slice 3a (2026-06-22) added per-route `param.wsOptions`** — a route's optional `param.wsOptions` (`maxPayload`/`protocol`/`closeTimeout`, any subset) is read by the `core/server.js` registrar (present-but-not-an-object fails the bundle boot loudly; absent → null), threaded as a new 3rd arg to `engine.instance.onWebSocket(url, handler, options)`, stored in a parallel exact-path `server._wsHandlerOptions` map (kept separate so `_wsHandlers` stays a pure path→handler map for the §12c collision guard) and an `options` field on each `_wsParamHandlers` entry; the dispatcher resolves `_wsOpts` (exact map first, then the matched param entry) and threads it to `lib.wsSession.accept(request, _wsOpts || undefined)`, which already honors `protocol` (→ `sec-websocket-protocol` response header), `maxPayload` (inbound declared-frame cap → 1009 close) and `closeTimeout`, degrading silently on malformed values (no per-value validation). Programmatic `app.onWebSocket(path, handler, opts)` gets the 3rd arg for free; last-write-wins overwrites handler AND options. Server-side only (no dist rebuild). **Slice 3b (2026-06-22) added cross-bundle `session.query()`** for channel handlers — `session.query(options[, data])` → Promise (+ optional trailing callback), mirroring a controller's `self.query()`, attached by the isaac dispatcher to each accepted session AFTER `lib.wsSession.accept` (so `lib/ws-session` stays controller-free) via `lib.wsQuery.build(server, server._wsBundle, server._wsEnv)`. **Option A** (`lib/ws-query`, a pure factory): reuse the framework controller's hardened HTTP/1+HTTP/2 client per call — a FRESH `new Controller` per query (query() owns one `query#complete` listener a shared controller would collide on), NOT a re-implementation; `controller.serverInstance = app` reuses the live server's WARM session cache (`app` IS the live serverInstance — isaac returns `{instance:server}`, `server.js:986/1000` stamps `instance._cached` + wires it, `gna.js:1600` hands the same `instance` to `onInitialize`); `controllerOptions.conf = JSON.clone(config[bundle][env])` (resolved fresh per query) so query()'s UNGUARDED `conf.server.coreConfiguration.{mime,statusCodes}` derefs resolve; result via the promisify/callback path. **Measured correction to the approved plan: `setOptions(null,…)` CRASHES inside setOptions**, not just at the documented `:3064` — it calls `getParams(req)` (→ `null.getParams=…` at `controller.js:4732`, unconditional) and reads `null.headers` at `:292` (dev); query()'s null-hardening guards its OWN `local.req` reads but NOT setOptions's, so the fix is a minimal `{headers:{}}` synthetic req (a bare `{}` still crashes at `:3061` `local.req.headers['user-agent']`; `res=null` is fine — result via callback, redirect-intercepts are `local.res != null`-guarded, getSession/isHaltedRequest `typeof`-guard `local.req.session`). The served bundle/env are captured ONCE at registration (a server serves one bundle): the `core/server.js` registrar sets `engine.instance._wsBundle/_wsEnv = self.appName/self.env` (guaranteed-correct at boot) BEFORE `onWebSocket`, and a purely programmatic `app.onWebSocket` falls back to `getContext('bundle'/'env')` — never a lazy connect-time read where the shared `ctx.bundle` is rewritten by every getConfig/getLib stack-walk. Same-bundle and cross-bundle both work (query() supports both); server-side only (no dist rebuild). All four slices live-boot verified by a real daemonless `gina-container` h2c bundle (exact `/live` + `:param` `/live/:room` extended-CONNECT round-trips against the real dispatcher — slice 3a proved a `param.wsOptions.protocol:"chat"` route echoes `sec-websocket-protocol: chat` on the wire; slice 3b proved a channel handler `await session.query`s a SECOND http2 target and streams `{ok:true,via:"stub-target",path:"/data"}` back over the WS stream — the source-pin/replica tests never boot a bundle). Tests: `server-ws-routes.test.js` + `server.isaac.test.js §12/§12c/§12d/§12e/§12f/§12g/§12h/§12i` + `ws-session.test.js §08/§09/§10/§11` + `ws-query.test.js`. Established 2026-06-11; declarative slices 1+2 2026-06-22; slice 3a (per-route wsOptions) 2026-06-22; slice 3b (cross-bundle session.query) 2026-06-22.

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.

174. **`throwError` — call signatures, status-code preservation, render-error interception, and fail-closed error-response `stack` hygiene** (replaces individual entries #10, #21, #26, #105, #110, #132) — `self.throwError` accepts four shapes: `(errorObj|Error)` 1-arg; `(code, Error|string)` 2-arg, dispatched via a function-top normalization shift that preserves the explicit code (`throwError(404, new Error('not found'))` sends 404 — earlier releases fell back to 500, and the `new Error(...)` wrapping workaround from that era still works unchanged); `(code, errorObj)` 2-arg, intentionally NOT shifted — it flows through the `arguments.length < 3` branch whose `code = res || 500` reaches the explicit code, and including errorObj in the shift detection would break exactly that case; `(res, code, string|Error)` 3-arg (explicit code preserved). Always `return self.throwError(...)` immediately in a controller action — it is a terminal response, and any later response-API call runs against a released response (guarded to warn + no-op since 0.5.1-alpha.2 instead of crashing the bundle). Calling `self.render(err)` with a non-2xx `data.page.data.status` and a defined `data.page.data.error` is intercepted before template rendering and routed through `throwError` automatically; object-valued `error`/`message` fields are normalised to strings first (no `[object Object]`), and the same normalized string feeds the server-side `console.error('[render] ...')` log, so wire and log carry identical readable text. Error responses are scope-gated FAIL-CLOSED: unless `NODE_SCOPE_IS_LOCAL` is explicitly `true`, the server-side `stack` is stripped from BOTH the JSON error body AND the fallback HTML error page's `<pre class="stack">` block — the gate is strip-unless-local, not strip-only-if-prod, so an unset scope on a fresh deployment still strips and internals (file paths, frames, library versions) never leak; local scope keeps the stack on the wire for the dev toolbar's data-xhr panel. Custom error templates remain consumer-owned (a view rendering the error object's `stack` is the consumer's call), and passing `err.stack` as the message argument surfaces it in the un-gated `error` STRING — sanitize that at the call site.

175. **Dev-mode hot-reload & module lifecycle — what reloads, what doesn't, and the `require.cache` poisoning antipattern** (replaces individual entries #22, #25, #28, #34, #104) — in dev mode (`NODE_ENV_IS_DEV` set; `isCacheless()` true) the framework hot-reloads code on every HTTP request via two functions: `refreshCoreDependencies()` (`core/router.js`) evicts and re-requires the controller pair, and `refreshCore()` (`core/server.isaac.js`) re-exports core-path modules and re-requires `lib/index.js` + `plugins/index.js`. Consequence for controllers: module-level state (`var store = {}`) resets on every request — for in-process state that survives hot-reloads (but resets on `bundle:restart`) attach to `global` (`if (!global.__myStore) global.__myStore = {}; var store = global.__myStore;`); for durable state use a database or file. NOT hot-reloaded (a full `gina bundle:stop` + `bundle:start` or `docker restart` is required): `server.js` / `server.isaac.js` / `server.express.js` (loaded once at process start, never evicted); connector code (`core/connectors/*/index.js`, loaded once via entity registration, outside the refresh scope); and bundle-registered plugin middleware — the `onInitialize` → `app.use(gina.plugins.X(...))` factories run ONCE at bootstrap, so a plugin config change needs a bundle restart in BOTH dev and prod, despite the misleading per-request refresh cue. Correctness invariant for any eviction code: `require.cache[path]` must hold a `Module` instance — `require.cache[path] = require(path)` poisons the slot by storing the bare exports object (no `.exports` key), so the next plain `require()` of that path returns `undefined`, surfacing as `Cannot read properties of undefined (reading '<X>')` after a hot reload; use `delete require.cache[require.resolve(path)]` + the `require()` return value, or swap `require.cache[c].exports = require(path)` on the existing Module — never the bare assignment. **The eviction cycles also leaked the whole module graph (#B32, folds former #173):** Node pushes every cache-miss require's fresh Module onto the REQUIRING module's `children` array and dedupes only on cache hits, so the per-request delete-and-re-require cycles accumulated one dead Module per eviction on long-lived parents — each pinning its entire evaluated exports graph (~1.8 MB post-GC live heap per request on a minimal dev bundle; heap-limit OOM/SIGABRT at ~2400 requests, presenting upstream as HTTP/2 PING timeouts, then ECONNREFUSED, then a supervisor respawn loop that keeps every process cold). Fixed by a `pruneDeadModuleChildren()` sweep at the end of BOTH eviction cycles — `children` is diagnostic metadata (nothing in Node resolution reads it), so pruning never unloads a module still referenced elsewhere; prod was never affected (no eviction + cache-hit dedup). The sweep walks `require.cache` keys ONLY, so a second residual of the same class existed OFF-cache: hot-evicted leaf/singleton libs captured at gen-0 by load-once modules and re-required per request pushed dead children onto the evicted-but-retained gen-0 parent, prune-blind — those libs are plain-`require`d now (never evicted → cache-hit → children deduped), and a completeness audit closed the gen-0-binding class. Rules: any new delete-`require.cache` + re-require cycle in a long-lived process must end with the prune sweep; a hot-evicted lib captured as a gen-0 binding by a load-once module leaks PAST the prune — plain-require leaf/singleton libs that don't need hot reload.

176. **Inspector & `/_gina/*` built-in endpoints — in-process architecture, admin IP-allowlist, agent-stream auth, live index coverage** (replaces individual entries #31, #32, #38, #111, #134, #138) — the dev Inspector (formerly Beemaster) is a built-in SPA served at `/_gina/inspector/` inside the bundle's own process: no project registration, no separate port, no auto-start spawn; dev-mode only (production bundles never expose it), and same-origin with the monitored bundle so `window.opener.__ginaData` always works. Every `/_gina/*` route (healthcheck, assets, cache/stats, info, inspector, logs, agent, indexes, reveal, instrument, metrics) is a handler in the same HTTP server process; the Isaac engine is the source of truth and may carry fast-paths, but base functionality belongs in the engine-agnostic dispatcher so Express bundles get the same endpoints. Admin-grade endpoints exposing process/cache internals (`/_gina/info` — memory/uptime/version/HTTP-2 session counters; `/_gina/cache/stats` — full cache contents) are IP-allowlisted via the `admin.allowFrom` block in `app.json`: the client IP is read from the socket only (the spoofable `X-Forwarded-For` is never trusted), `::ffff:`-mapped IPv4 is normalised, the list defaults to loopback (`127.0.0.1`, `::1`) when omitted, an empty list denies everyone, and denied callers get a 403 JSON error; `/_gina/health/check` stays deliberately open for liveness probes, and `/_gina/metrics` keeps its own separate `metrics.allowFrom` axis. The `/_gina/agent` stream (combined data + log events) is dev-only by default but can be enabled outside dev behind an API key (`settings.json > inspector.agent.{enabled, key}`, `${secret:KEY}`-capable, constant-time compared, fail-closed when no key is configured); browsers pass `?key=` as a query param because `EventSource` and WebSocket handshakes cannot set custom headers — and any `$`-anchored endpoint gate regex must become `(?:\?|$)` the moment its endpoint accepts a query param, or the handler silently stops matching the query'd URL. A time-boxed, separately-keyed production instrumentation window (`POST /_gina/instrument`, hard-capped at one hour) can stream per-request query + flow capture over that authenticated channel — channel AUTH, not redaction, is what protects raw query text (redaction masks only secret-NAMED fields, never the statement or its positional params). The Query tab computes live index coverage client-side, so bundles WITHOUT an `indexes.sql` get a correct "no index for filter" badge on the first render too (cached live-index descriptors are cloned per query before stamping coverage — the cache is shared across queries that filter different columns). Inspector toolbar CSS: native macOS `<select>` ignores `line-height` — use explicit vertical padding, and keep `select` (sans font) and `input` (mono font) on separate CSS rules. **SPA + statusbar hardening (folds former #165/#190/#191/#196):** every per-bundle `/_gina/*` consumer in the SPA derives its base URL via the shared 3-fallback `resolveBundleBase()` (`?target=` → opener pathname → path strip) — a bare `window.location.pathname` strip misroutes to the proxy's default bundle in reverse-proxy multi-bundle setups. The Inspector binds to its opener tab via a per-tab `BroadcastChannel` (`?ch=<tabId>`): pages sending COOP `same-origin` sever `window.opener` for the popup, and the bundle-global fallbacks (the shared localStorage slot + the worker-wide agent SSE) reflect whichever render last touched them — a diagnostic channel that must track ONE page needs a per-tab transport (the statusbar publishes that tab's data at its existing write points and answers a request/reply handshake; no `?ch=` keeps the prior behaviour, and `?target=` agent mode keeps priority). Structured-localStorage reads need a SHAPE check on top of the try/catch parse (a tampered key holding a JSON primitive otherwise breaks the un-guarded consumer), and every value interpolated into `innerHTML` goes through an HTML-escape helper — untrusted model/app text renders via `.textContent` instead. Dev inline-script splices before `</body>` use FUNCTION replacers: `String.replace(/re/, str)` expands `$`-sequences in a STRING replacement (`` $` ``/`$'`/`$&`), so dynamic content carrying a stray dollar-sequence spliced the whole document into the statusbar `<script>` (SyntaxError → statusbar and launch link vanish) on content-heavy pages while a near-empty smoke page stayed falsely green — any `String.replace(re, dynamicX)` with dynamic content must use a function replacer or escape `$`. Inspector SPA files are copied VERBATIM to dist (no minification) — an inspector-only edit rebuilds only those dist files, never the main bundle artifacts.

177. **Couchbase connector — install-derived SDK resolution (v2 removed), `connectors.json` semantics, `getCluster()`, dev-mode index reporting** (replaces individual entries #29, #40, #41, #57, #136, #153) — connectors are keyed in `schema/connectors.json` by LOGICAL name (`primary`, `sessionStore`, `cache`, …) with the driver selected by the `connector` enum field (`couchbase`/`mysql`/`postgresql`/`sqlite`/`redis`/`ai`/…) — never introduce a separate `driver` field; the optional `version` field carries a semver range used by `connector:add --driver-version=…` for the npm-install hint. The Couchbase SDK major is derived from the project's INSTALLED `couchbase` npm version — the leading major of `dependencies.couchbase` selects `connector.v<major>.js`, which stamps `conn.sdk = { version: N }` — never from a config key, so migrating SDK majors is a driver bump (`npm install couchbase@^4`), not a config edit. SDK v2 is REMOVED as of 0.4.0: the resolver now throws a clear "SDK v2 is no longer supported — upgrade couchbase@^3/^4" error when the installed major is ≤ 2 or the connector file is missing (previously a silent fallback that crashed later with an opaque MODULE_NOT_FOUND); the v3-vs-v4 split remains for param shaping. Generalises: when a connector's behavior-version derives from an installed dependency rather than config, fail fast once the installed major drops below the supported floor. Couchbase entities expose a public `getCluster()` (on both the model-entity and N1QL-entity prototypes) returning the underlying SDK `Cluster` handle for features the ORM doesn't wrap — chiefly multi-document ACID transactions (`cluster.transactions().run(...)`, needs SDK 3.2+/4.x) — without touching private `_*` internals; it throws a coded `GINA_COUCHBASE_CLUSTER_UNRESOLVED` error when neither connection shape resolves. Dev-mode index reporting: the SDK v4 C++ binding never populates `meta.profile` despite `profile: 'timings'` being sent (confirmed on v4.6.0), so an async `EXPLAIN <statement>` fallback with a per-process per-statement cache supplies the plan instead (the first request for a new statement may show N/A; subsequent requests hit the cache), and `USE KEYS` plans surface as "KV lookup" via `ExpressionScan`/`KeyScan` operator detection. Two historical traps locked by tests: `conn._cluster.query()` must receive the full `queryOptions` object, not the raw params array (the raw form silently dropped `profile`/`scanConsistency`/`adhoc` from every query), and of the connector's two `register()` dispatch paths, Option B (`!_isRegisteredFromProto`) is the ALWAYS-active one — instrumentation or logging added to Option A never executes.

178. **HTTP/2 query paths must tolerate a released response — retry re-entries and late upstream responses run after terminal exits (#B33, 2026-06-12, commit `9c2d802a`).** Terminal exits release the per-request refs, and `redirect()` releases them and THEN calls next(), so an inter-bundle HTTP/2 query can outlive its own request in three measured ways, each previously an uncaughtException → SIGTERM bundle kill: (1) every retry re-entry (the 502/timeout/stream-error/preflight setTimeout paths) re-executes the header-forward prep block, which read the released request's headers — null deref from a timer callback outside all try/catch; (2) a late upstream response's success path calls isHaltedRequest → getSession, which read the released request's session from the stream end handler; (3) a parsed upstream payload claiming a 3xx status routed into the redirect intercepts, which wrote headers to the released response (the emitter-mode intercept uncaught; the callback-mode one contained only via a fragile catch chain). All sites null-guarded: forwards and intercepts no-op on a released request (the same options object travels through retries, so options.headers already carries the attempt-1 values — nothing is lost), getSession reports no session, and the emitter-mode intercept falls through to the query#complete emit so listeners still learn the outcome. Live-request behaviour is byte-identical. Runtime-verified by driving the REAL query() through a standalone controller harness against local h2c servers (six probe modes, crash reproduced pre-fix and clean post-fix per site). Sibling unguarded request-header reads exist in OTHER lifecycle functions; the §23 guard pin is block-scoped to the fixed functions for exactly that reason. **Follow-up #B35 (2026-06-13, `714d816f`+next):** the 5 directly-callable SYNCHRONOUS siblings were MEASURED (standalone harness: createTestInstance → renderTEXT() releases the triplet → call → confirmed `uncaughtException`-class crash) and guarded with top-of-function early-returns — `isPopinContext` → false, `setRequestMethod` → null, `setRequestMethodParams` → (void), `getRequestMethodParams` → the cached value, `getFormsRules` → {} (tests `controller.test.js §25`). **#B36 (`c5eaeeb7`+next):** `renderJSON()` is the same shape — its delegate reads `local.res.stream` synchronously before any `headersSent` guard, measured (standalone harness driving the real `render-json.js` delegate: CONTROL live rendered, RELEASE after `renderTEXT()` threw `reading 'stream'`) to crash a released response → SIGTERM; guarded with a top-of-function `if (local.res == null) return` (tests `render-json.test.js §03`). The render-swig / render-nunjucks delegates share the same read but are the NON-FATAL async class (measured 2026-06-13, initially NOT guarded — GUARDED as of #B45, see below): their `render()` is `async` and `this.render` returns the delegate promise un-awaited, so a released-instance read rejects → `unhandledRejection` → `gna.js:726` logs it (no SIGTERM). This is the canonical severity-axis example — a SYNCHRONOUS released read (render-json `#B36`, the `#B35` helpers) is a SIGTERM bundle-kill worth guarding; the same read in an ASYNC delegate is a logged unhandledRejection not worth editing the hot render path for. **#B45 (2026-06-14, `d9bfc5af`) reversed that for the render delegates:** production surfaced exactly the predicted `unhandledRejection` (a controller firing several parallel `self.query()` calls against a downed upstream — the first failure callback renders+releases the triplet, a later callback re-enters `render()` at `local.res === null` → render-swig.js:259 `res.stream` throws), which is the #B36 "revisit only if the log noise proves operationally costly" trigger. All four async delegates (render-swig / render-nunjucks + both async variants) now carry the same top-of-function `if (local.res == null) return` guard as render-json/render-stream — one null check at the top, byte-identical on live requests; the rarer in-flight #M1 `setResources` race (render-swig.js:613 → controller.js:896, caught + #B31-guarded) is unchanged. The severity-axis lesson still holds (sync → SIGTERM → always guard; async → unhandledRejection → guard only once the noise is shown operationally costly) — #B45 is the worked example of the async "guard-when-costly" branch firing (tests `render-swig.test.js §19` / `render-nunjucks.test.js §08` / `render-engine-dispatch.test.js §08`). `redirect` GUARDED too (**#B37**, `a03e6f84`+next): measured synchronous, so a released second-call (redirect-then-redirect, or render-error-then-redirect) crashed `reading 'originalMethod'` → SIGTERM; top-of-function `if (local.req == null) return` (tests `controller.test.js §26`). **#B38 (2026-06-13) — the #B37 "SIGTERM class CLOSED" claim was premature: an exhaustive sweep of EVERY synchronous controller surface found SIX more lethal sync residuals, each measured (CONTROL no-throw / RELEASE positive crash, then no-throw post-guard) and guarded top-of-function with the same `if (local.req|res == null) return <default>` shape:** `downloadFromLocal` (`reading 'setHeader'`), the inner `start` of `store` (`reading 'files'` — reached SYNCHRONOUSLY through the documented `store(target).onComplete(cb)` wrapper, which calls `start` OUTSIDE the async `store` body; the #B35 probe had only tried `store('t')`, which returns the wrapper WITHOUT calling `start`, so `store` was wrongly filed as async-deferred here), `renderStream` (`reading 'stream'` — its controller wrapper AND the delegate are both synchronous, and the read precedes the delegate's headersSent guard, mirroring the #B36 render-json placement), `push` (`reading 'method'`), `pauseRequest` (`reading 'url'`), `resumeRequest` (`reading 'session'`/`'method'`) — tests `controller.test.js §27` + `render-stream.test.js §11`. Genuinely document-skipped (measured NON-FATAL, by the same async-boundary reasoning — the render delegates themselves since GUARDED, see #B45 above): `downloadFromURL` (its reads sit inside an `async function`, so any throw is a rejected promise → `unhandledRejection`), and the render-path inner fns `setResources` / `getNodeRes` (sync, but invoked ONLY from the async render delegates, so a throw rejects the delegate promise — and the captured-req fix-shape was declined because the captured req is itself null in the released-response case). **Rule: lethality follows the NEAREST async boundary, not the function's own sync/async keyword — a sync read with no async function between it and the dispatcher is a SIGTERM bundle-kill (guard it); the same read reached only through an async function degrades to a logged unhandledRejection (skip it). Probe-asymmetry corollary: "no throw" is NOT proof of safety — feed inputs that actually REACH the deref (`resumeRequest({})` dodged its `req.session` read via an `&&` short-circuit + an early `throwError` return and looked safe until re-measured with a deref-reaching input).** **#B44 (2026-06-14, commit `d95ac2fe`) — closes the one throwError-OWN residual the #B38 sweep flagged, and is the worked example of the measurement-scope-gap.** `throwError` reads `res.stream` (the HTTP/2 protocol branch) and then builds the error object from `res.error`/`res.stack`/`res.fallback` (~10 reads) BEFORE its OWN #B31 guard. For the 2-arg `throwError(code, Error|string)` and 3-arg `throwError(local.res, code, msg)` shapes `res` is already the released `local.res` (null) at those reads, so a released response crashed at `res.stream` on HTTP/2 bundles and at `res.error` on EVERY bundle (HTTP/1.1 reaches it because the `res.stream` read short-circuits off-h2). The 1-arg shape is unaffected (its `res` stays the truthy errObj until reassigned just before the guard — why #B31 sufficed for ITS scenario). Fixed with an up-front `if (!res) { warn; return false; }` early guard before any deref (tests `controller.test.js §28`); severity LOW (only the async `downloadFromURL` path reaches it today → non-fatal `unhandledRejection`), but it lifts the #B38 qualification for the synchronous 2-arg/3-arg surface on both protocols. **Measurement-scope-gap lesson: the FIRST-proposed fix (guard the `res.stream` read only) was INSUFFICIENT — it does NOTHING on HTTP/1.1 (already short-circuits there; the crash is at the `res.error` build) and merely RELOCATES the HTTP/2 crash to that same build. The prior repro was VERBATIM-TRUNCATED at the first crash line (it ran through the `res.stream` read, saw the TypeError, STOPPED), so it proved "the crash exists" but never executed the NEXT deref and so couldn't see that guarding one site just moves the crash. A repro that stops at the first crash cannot validate a "falls through to the guard" claim — match the repro's SCOPE to the claim's scope (the "match the measurement's scope to the claim's scope" rule, applied to a runtime repro). A per-site `&& res` is whack-a-mole here (~10 derefs before the guard); one early guard covers them all.** **The family's origin (#B31, folds former #172):** the first two guarded entry points were `throwError` — it normalizes every 1-/2-arg call shape to `res = local.res`, then read `typeof(res.getHeaders)` off the null — and `headersSent()` (read `typeof(_res.stream)`); both were uncaughtException → SIGTERM bundle kills with no application frames. Field shape: an auth middleware that 301-redirects unauthenticated requests and lets the chain continue makes the crash deterministic on every unauthenticated hit, and the crash-respawn loop keeps every process cold — masquerading as a separate "valid sessions never authenticate" bug (each request lands on a freshly-respawned process whose session-store connector hasn't warmed). Now `headersSent()` reports a released response as already-sent (a single chokepoint — every `!headersSent()` caller no-ops) and `throwError` logs the serialized late error (naming what previously died opaque) and returns false; live-response paths are byte-identical. Reusable repro recipe: `controller.js` loads standalone — inject the framework dir into NODE_PATH + `Module._initPaths()`, require the framework helpers (injects the path/JSON globals), `setPath('gina', { core: <fw>/core })`, then `SuperController.createTestInstance({req, res, next, options})` with a minimal mock response; `renderTEXT()` is the lightest terminal exit to reproduce any released-response sequence against the real class.

179. **A `Date.now()`-prefixed ID needs a LONG random suffix — `Date.now().toString(36) + '-' + uuid()` with the 4-char default is a birthday-paradox collision (and an intermittent test flake).** In a tight insert burst many IDs share the same millisecond prefix, so uniqueness rests on the random suffix; the lib/uuid 4-char default (62^4 ≈ 14.7M) collides at ~0.02% per 100 IDs (measured). The storage plugin's record `_id` used this shape and was widened to `uuid(16)` (62^16; collision ~1e-25), mirroring the lib/collection size=16 opt-in — `_id` is opaque (written once, never parsed), so it was a safe drop-in; the storage module is bundled into the browser bundle, so the dist was rebuilt (CI tool flags, baseline-build-first → only the 4 JS bundle files changed). Rule: any `Date.now()`-prefixed ID whose uniqueness matters uses `uuid(16)`, not the default. **CI corollary — diagnose flake-vs-merge BEFORE reacting:** a Tests red that appears ONLY on the post-release `Merge tag` run on master, on the FASTER Node version, with the SAME tree green on develop, is almost always a timing-dependent flaky test (this uniqueness check — faster loop packs more calls into the same ms → higher collision odds; or the container-boot integration test's intermittent boot crash), NOT a merge problem. A job rerun (same tree → green) confirms it, and the master post-merge run fires AFTER publish so such a red never blocks the release.

180. **`process.exit(non-zero)` TRUNCATES async stdout/stderr on a PIPE — a diagnostic printed right before exit is LOST under a container log collector or a piped test harness (the empty-log crash signature).** Node makes `process.stdout`/`process.stderr` synchronous for a TTY/file but ASYNCHRONOUS for a pipe, and `process.exit()` tears the process down without draining the async buffer — so `console.emerg(msg); process.exit(1)` (or `process.stdout.write(msg); process.exit(1)`) prints `msg` on a local TTY yet emits NOTHING when stdout is a pipe. This was the container-boot CI flake's empty-log signature (exit 1, zero captured output): the daemonless `bin/gina-container` launcher AND every framework boot exit site (gna.js abort + mount-symlink, server.js ServerEngine catch, server.isaac.js https-credentials, proc.js invalid-proc-name) all used the antipattern, so a real boot crash in a piped/containerised deployment exited silently. Fix: flush the reason SYNCHRONOUSLY with `fs.writeSync(1|2, msg)` (blocks until the bytes are handed to the pipe) BEFORE `process.exit` — the launcher routes every diagnostic through an `out()` helper (fd 1); each framework site keeps its `console.emerg`/`console.error` and adds a guaranteed `fs.writeSync(2, …)` before the exit. Behaviourally proven: under a piped stdout an early-exit message now survives (`test/core/boot-exit-flush.test.js`, both a launcher spawn and an fd-2 child); happy-path boot unchanged. **Meta-lesson: a diagnostic added at the OBSERVER (a test capturing the child's piped output, e.g. a `bootDiagnostics()` dump) cannot fix truncation that happens at the SOURCE (the crashing process) — the bytes never reach the pipe, so the observer still sees empty. Fix the flush at every `*.exit()`-after-write site, not at the reader.** Rule: any `write/log → process.exit(non-zero)` on a boot / request / CLI path that may run with piped stdio (containers, spawned children, CI) must flush synchronously first — never rely on the async writer draining before the exit. **Completion (2026-06-21): the original sweep was INCOMPLETE — a full `process.exit` enumeration of the boot path found it had MISSED `core/config.js` (the primary boot-config-failure path: 5 sites — core-content read, bundle-config load, `getServerCoreConf`, the portsReverse protocol/scheme inconsistency, bad-routing-syntax) and `lib/proc.js:319` (the `uncaughtException` handler → `dismiss(SIGTERM)`), all async-`console.emerg`/`error`→exit. Flushed too (same `fs.writeSync(2,…)`; tests `boot-exit-flush.test.js §03`). Caveat learned: the gina logger writes via async `process.stdout.write`, which completes SYNCHRONOUSLY only when the pipe buffer has room — so a missed site SURVIVES locally (warm pipe) and truncates only under CI load (the probe-asymmetry trap: "survived locally" ≠ "safe"). Verify a flush site by MECHANISM (async-write-before-exit) + a source pin, not by a local non-truncation run. Sibling fact: a port-collision EADDRINUSE on the isaac engine is SWALLOWED by `server.isaac.js`'s `server.on('error')` → the server never binds → exit 0 (not 1, not 143) — so a port collision is never the exit-1 crash it superficially resembles.**

184. **`~/.gina` state files must be written ATOMICALLY (temp + rename) — a truncate-in-place `fs.writeFileSync` lets a concurrent boot read a torn/empty file and crash (#B43, 2026-06-14, commit `519788ea`).** The five state files (`main.json`/`projects.json`/`settings.json`/`env.json`/`locals.json`) were written with a bare in-place `fs.writeFileSync(target, data)` at the two write sites — the StateStore JSON sidecar (`lib/state.js` `this.write`) and the legacy fallback (`lib/generator/index.js` `createFileFromDataSync`, the single chokepoint for all ~150 state-file writes). `writeFileSync` opens with `O_TRUNC` (target → 0 bytes) then streams, so a concurrent reader catches either an EMPTY file (the post-truncate window, payload-size-independent) or a PARTIAL one (mid-`write()`, widening with file size). Every state-file READ path is FATAL on a parse failure: `requireJSON` does `console.emerg` + `process.exit(1)` for non-`/controllers/` paths, and the more common plain `require('*.json')` boot reads (`gna.js`, `config.js`, framework `start.js`, six `init.js` sites) throw an uncaught `SyntaxError` → process death. So a concurrent fleet boot (many bundles/containers booting against one `~/.gina`) intermittently crashed when a reader caught a writer mid-write; the supervisor respawned and a later non-concurrent read succeeded, presenting as an intermittent crash. MEASURED with a concurrent reader/writer race on the exact `fs.writeFileSync` primitive (empty + partial reads → `JSON.parse` `SyntaxError`; the empty-read window is always present, the partial window widens with file size). Fix: both sites write a same-dir temp (`<target>.<pid>.<seq>.tmp`) then `fs.renameSync` — `rename(2)` is atomic within a filesystem, so a reader sees the complete old file or the complete new one, never a torn one; `chmod` the temp before rename, unlink-on-failure + rethrow. The SQLite `INSERT` was already atomic — only the JSON sidecar that readers consume was torn. Because `createFileFromDataSync` is the single write chokepoint, the two edits close the window for ALL readers (`requireJSON`, plain `require`, `gina-container`'s raw `JSON.parse`). A reader-side retry in `requireJSON` was DECLINED — it covers only the `requireJSON` minority (not the plain-`require` majority), defends a window the write fix already closes, and pollutes the hot general-purpose helper while masking genuine corruption. Rule: any `~/.gina` state-file write goes through the atomic temp+rename, never a bare in-place `writeFileSync`. Tests: `test/lib/state-atomic-write.test.js` (block-scoped source pins on both sites + fs-spy behavioural on the real generator legacy + StateStore sidecar paths + the deterministic torn-read consequence).

189. **A render delegate must hold ALL per-request state FUNCTION-scoped — in prod the delegate module is a shared singleton across concurrent requests, and any module-scoped capture races (#INS10 `8674c514` 2026-06-17; sweep completed by #B61/#B62/#B63 `1b3abd97`/`2a838c1b`/`1bd0bcf1`, 2026-07-03).** Module-scoped deps are reassigned by every incoming call, and two overlapping renders BOTH suspend at their first await before either resumes, so the first deterministically resumes with the second's bindings. #INS10 function-scoped `self`/`local` in the swig delegate (the post-await Inspector emit carried the other request's `_queryLog`/`_timeline`) but deliberately left getData/hasViews/setResources/SwigFilters/headersSent/cachePath module-scoped, claiming they are "read before the first await" — MEASURED FALSE: the setResources call and the `merge(data, getData())` restore run AFTER the template-read await, so request A's resume executed request B's closures and gap-filled B's page data (session card included) into A's render context (#B61; the engine ref was also an implicit global). The streaming delegate's fire-and-forget IIFE read module `self`/`local` after its `for await`: a finishing stream nulled the CONCURRENT request's `local.req/res/next` mid-stream (routing its later renders into the released-response guards) while never releasing its own, and reported stream errors through the wrong controller (#B62). The JSON delegate's writeCache resumed from its writeFile await reading module `self`, routing a cache-config error through the concurrent request's controller (#B63 — writeCache now takes `req`/`res`/`cacheIsEnabled`/`throwError` as parameters, the same threaded shape as the swig cache writer, and the write-only module `cachePath` is removed as dead). All live delegates are now free of module-scoped per-request state (nunjucks + the async ALS variants always were; the legacy v1 delegate keeps the old pattern but is dead code — never dispatched); each delegate's test file pins the discipline (module prefix declares no per-request vars) plus an interleaved-replica subtract reproducing the measured asymmetry. All three server-side only — no dist rebuild. Rule: in a shared-singleton delegate, per-request state is function-scoped, PERIOD — never adjudicate per-variable "only read pre-await" inertness (that claim rotted once and measured wrong); module-level helpers take render-scoped values as parameters. Sibling render-cache discipline (folds former #96): the dev-mode per-template layout cache primes with ATOMIC temp+rename at BOTH cache-write sites — the old delete-then-rewrite opened a window where a concurrent render of the same URL caught an absent file (intermittent ENOENT 500s on parallel bursts to one URL; production unaffected — cached mode skips the delete). Any "delete then rewrite a shared file under concurrent renders" pattern takes the same fix; per-process unique temp names avoid TOCTOU on the temp itself. Tests: `render-swig.test.js` §21, `render-stream.test.js` §12, `render-json.test.js` §04.

192. **The default Express engine now mirrors the admin `/_gina/info` + `/_gina/cache/stats` endpoints — they were Isaac-only (Express 404'd them), a `/_gina/*` endpoint-sync-rule violation (#S7 express-mirror, 2026-06-17, commit `52ee55e9`).** `server.isaac.js` served both admin-grade endpoints (process state — memory/uptime/version/HTTP-2 counters at `/_gina/info`; cache contents at `/_gina/cache/stats`) behind an IP allowlist, but the engine-agnostic `server.js` never registered them, so Express bundles 404'd both (fail-closed, but a parity gap — the binding rule is "Isaac is the source of truth, but every `/_gina/*` handler must also work under Express"). Fixed by duplicating the `isAdminClientAllowed(req)` helper VERBATIM into `server.js` module scope (byte-identical, source-pinned in `test/core/server.test.js` to stay in sync with the Isaac copy; a shared `lib.admin` extraction is a clean deferred follow-up — the duplicate matches the existing per-engine handler-mirror pattern, e.g. `/_gina/metrics` + `/_gina/jobs/:id`), and adding both handlers right after the metrics mirror, always-on + admin-gated (loopback default via `process.gina._adminAllowList`, which `gna.js` already seeded but `server.js` never consumed). Express uses the `response.setHeader`/`statusCode`/`end` idiom (not Isaac's `response.stream`/`_setPoweredByHeader`); the `/_gina/info` `http2` block stays `if (..._h2Metrics)`-guarded → omitted under Express (no HTTP/2 session metrics, correct degradation); `/_gina/cache/stats` builds a fresh `new lib.Cache()`, calls `.from(self.instance._cached)`, returns `.stats()`. `server.js` is server-side (not in the browser bundle) → no dist rebuild. Rule: a new always-on `/_gina/*` endpoint must land in BOTH engines, and admin-grade ones carry the IP-allowlist gate (never trusting X-Forwarded-For; `::ffff:` normalised; `[]` = deny-all; missing list → loopback). Tests: `server.test.js` #S7 (source pins + byte-identical sync guard + a behavioral replica mirroring `server.isaac.test.js` §08b). **Follow-up (2026-06-17, commit `d41483c1`):** the byte-identical duplicate was extracted into a shared `lib.admin` (a stateless pure-function leaf — plain `require('./admin')` in `lib/index.js`, NOT `_require`, per the #B32-residual precedent of merge/uuid/Collection); both engines now call `lib.admin.isClientAllowed(request)`, the helper-body coverage + a registration pin moved to `test/lib/admin.test.js`, and the cross-engine byte-identical sync-guard is retired (one source now). `isClientAllowed(req)` reads `process.gina._adminAllowList` internally (drop-in, zero `gna.js` change); an exported `_isAllowedWithList(req, list)` seam gives branch coverage without mutating the global. No behavior change (no changie).

204. **The Bun CI gate is the `--bun` container SMOKE, not the unit suite run under `bun test` — gina runs correctly under Bun, but its `node:test` suite is not wholesale-runnable by Bun's foreign test runner (#bun-smoke-not-suite, 2026-06-21).** Measured: the spawn-free unit suite under `bun test` (`./test/core/*.test.js ./test/lib/*.test.js`, 179 files) gave 8337 pass / 76 fail — and every one of the 76 failures is a Bun-test-RUNNER incompatibility, NOT a gina runtime defect: the `(_, done)` node:test callback style (Bun's runner never passes `done` → `TypeError`; the dominant cluster, owned by the async connector/session-store tests), `mock`/`skip` unimplemented in Bun (explicit `not yet implemented` errors, oven-sh/bun#5090), node error-MESSAGE pins (gina throws identically but Bun phrases the `TypeError` text differently, so `assert.throws(…, /node-wording/)` regexes miss), and a few Bun runtime-behavior differences (`process.execPath` read-only, exports-map subpath enforcement, cyclic-JSON message text). gina itself is proven to RUN correctly under Bun three independent ways — the green `--bun` container smoke (install → boot → HTTP 200, daemonful + daemonless), live host boots, and the 8337 passing assertions — so wiring `bun test` as a CI leg is a dead end (it would need upstream Bun fixes or a large secondary-runtime test rewrite, both out of proportion for a secondary runtime). The shipped Bun CI validation is therefore the `--bun` smoke leg on a pinned `oven/bun` image, promoted to a blocking gate (a Bun regression fails the workflow like the Node legs). Rule: when validating a SECONDARY runtime whose test runner lacks the primary runner's features, gate on a behavioral SMOKE (does it install/boot/serve?), not on the unit suite under the foreign runner — a foreign-runner red is a harness-compat signal, not a defect signal.

205. **AI token streams surface live in the dev Inspector via a per-request signal threaded through the query-attribution ALS, delivered both live (`inspector#token` SSE/WS) and as an end-of-request snapshot (`user.aiStream`), and shown in a new SPA "AI stream" tab (#AISTREAM, 2026-06-24).** The AI connector's `getModel('<name>').stream(messages, options)` — a fresh EventEmitter per call (ordered `start`/`delta`/`done`/`error` + an `.onComplete(cb)` shim; `infer()` stays byte-identical) — gained a gated instrumentation layer that, under `NODE_ENV_IS_DEV` OR an open `process.gina._inspectorWindowUntil`, grabs the request's `_devAiLog` buffer (a SIBLING of `_devQueryLog` on the SAME `process.gina._queryALS` store, seeded at `controller.js` `setOptions`), pushes one entry, and attaches emitter listeners that mutate it via closure AND `process.emit('inspector#token', frame)` per phase — keeping the provider paths + `infer()` pure. Delivery: a `server.isaac.js` SSE forwarder (`event: token`) + a `server.js` WS forwarder (`{event:'token', data:frame}`), both deregistered on close; the `user.aiStream` snapshot is attached at 5 render sites (render-json + inspector-window-emit on `_gdUser`, render-swig cache-hit/miss + render-nunjucks on `data.page.aiStream`). The SPA tab (`inspector.js`): a NEW `appendTokenDelta(frame)` accumulates a SINGLE-SLOT module buffer keyed by `frame.id` (reset on a new id — the snapshot carries every stream of the request, so the live tail only needs the current one), wired at all 3 agent receive sites (the WS `onmessage` reads `frame.data` directly; the two SSE paths `JSON.parse(ev.data)`); a `renderTab` `case 'stream':` renders the snapshot, and the `!ginaData` early-return is special-cased so the live buffer still renders before any snapshot exists. **Three carry-forward rules:** (1) untrusted model output (live frames AND the snapshot's `prompt`/`text`) is rendered via `.textContent`, NEVER `.innerHTML` — XSS-safe by construction, no escHtml; (2) `token` is the SSE/WS event NAME, never a payload KEY — `lib/inspector-redact` wholesale-redacts a bare `token` key, so counts ride as `outputTokens`/`promptTokens` and chunk text as `deltaText`; (3) the prompt + chunk text ride the wire ONLY when `settings.inspector.ai.captureText` is true (default off, seeded onto `process.gina._inspectorAiCaptureText` at boot) — metadata is always captured. Inspector SPA files are COPIED verbatim to `dist/` (no minify, no `.br`/`.gz`) so an inspector-only src edit cannot drift `gina.min.*`; rebuild prod with the CI sass/csso/rjs flags and the dist diff is just `inspector/{inspector.js,index.html}`. Adding a tab means updating the 3 `TAB_LAYOUTS` presets — which trips the §44 exact-tab-set pins (now 7 tabs) — and the SPA-side `tryAgentWS` token branch shifts §77's fixed `wsFnBlk()` window (converted to an end-anchor on the next function). Tests: `test/core/ai-stream-inspection.test.js` (§07 SPA wiring + §08 markup/dist propagation) + `ai-connector.test.js §08-§11`.

206. **Observable application events surface in the dev Inspector via a per-request signal that mirrors #AISTREAM — `self.emitEvent(name, metadata)` (controller) or `lib/inspector-events.emit()` (model/service code) pushes a `{type:'event',id,name,t[,meta]}` entry and emits a live `inspector#event` frame, delivered as an `event: event` SSE/WS frame plus a `user.events` end-of-request snapshot, shown in a new SPA "Events" tab (#EVTBUS, 2026-06-29).** The per-request buffer `_devEventLog` is a THIRD key on the shared `process.gina._queryALS` store (sibling of `_devQueryLog`/`_devAiLog`, seeded at `controller.js` `setOptions`); `emit()` reaches it via `getStore()` so the snapshot is captured from any code in the request's async context (outside one — a background job / lifecycle hook — the live frame still fires but no snapshot is pushed, per Slice 2b below; a closed gate or invalid name is a no-op). Capture is gated on `NODE_ENV_IS_DEV` OR an open `process.gina._inspectorWindowUntil` (identical to the query/AI gates). The event NAME + framework stamps always ride the wire; the caller's `metadata` VALUES ride ONLY when `settings.inspector.events.captureArgs` is true (default off, seeded onto `process.gina._inspectorEventsCaptureArgs` at boot) — `lib/inspector-redact` matches secret-NAMED keys only and cannot sanitise arbitrary arg VALUES, so the gate + opt-in + authenticated channel are the protection, never redaction (same contract as #AISTREAM `captureText`). Delivery: a `server.isaac.js` SSE forwarder (`event: event`) + a `server.js` WS forwarder (`{event:'event'}`), registered/deregistered beside the data/log/token listeners; the `user.events` snapshot is attached at the SAME 5 render sites as `user.aiStream` (render-json + inspector-window-emit on `_gdUser`, render-swig cache-hit/miss + render-nunjucks on `data.page.events`), gated on `local._eventLog.length`. **Three carry-forward facts:** (1) the SPA `appendAppEvent` ACCUMULATES a capped rolling buffer (NOT single-slot-reset like `appendTokenDelta` — events are discrete, not one token stream), renders via `.textContent` (untrusted app text), and the `renderTab` `case 'events':` prefers the live buffer then falls back to the request snapshot; (2) the live `event: event` frame rides isaac-SSE + server.js-WS, and (#AISTREAM/#EVTBUS parity gap CLOSED 2026-06-29) the server.js HTTP/1 SSE `/_gina/agent` handler now forwards token + event too via `response.write` (mirroring isaac's `_agWrite` shape) — all three live transports now carry all four frame types and `event-inspection.test.js §08` is a positive pin; (3) `event` is NOT an EventSource-reserved name (unlike open/message/error) so `event: event` is collision-free. Adding the 8th SPA tab updates the 3 `TAB_LAYOUTS` presets → trips `inspector.test.js §44`'s exact-tab-set pins (now 8 tabs, updated with approval). `lib/inspector-events` registered via `_require` (stateless). There are zero pre-existing app/domain events in the framework (the only `process.emit` namespaces are `inspector#`/`logger#`/`gina#`, all internal), so the emit API is the headline — without it the signal surfaces nothing. Slice 2a (SHIPPED 2026-06-29): a curated allow-list (`settings.inspector.events.topics`, default `[]`) bridges entity-trigger emits onto the live signal via a gated block in `entity.js`'s `emit` chokepoint — skips `error`, gated on a non-empty allow-list FIRST (the opt-in IS the flood control), ships a framework-controlled `{ok,error}` summary (never raw rows; captureArgs-gated like all metadata — name+source always ride, the `{ok,error}` summary reaches the wire only when captureArgs is on, default off) tagged `source:'framework'` (a new optional 3rd arg to `inspector-events.emit` + an exported `matchTopics` helper: exact / single leading-or-trailing `*`); reached via `require('lib/inspector-events')`; request-scoped so it reuses the MVP live+snapshot delivery (no new transport/ALS key/tab). The chokepoint catches every entity emit with ZERO per-instance `.on()` / `ENTITY_MAX_LISTENERS` use (the original deferred note had conflated flood with listener-exhaustion). Only the couchbase connector emits named CRUD (`N1QL:<entity>#<method>`); the other 5 don't, and CRUD is largely redundant with the Query tab — so 2a's genuinely-new value is custom (non-query) entity methods. Slice 2b (SHIPPED 2026-06-29): an emit refactor SPLITS the store-gated buffer push from an always-on live emit — once the gate passes the live `inspector#event` frame fires store-or-not (a no-store background-job / lifecycle caller now reaches the stream; only the per-request snapshot needs a store), return `true`⟺live-frame-emitted / `false` only for gate-closed or invalid-name; this flipped §01 (`:58-63`) and CHANGED the no-op-outside-request contract, deliberately diverging from #AISTREAM (whose live `inspector#token` emit is itself store-gated at `ai/index.js` `if(_aiLog)`). On top of it a connector-lifecycle bridge surfaces a connector's recurring `ready` emit (re-fired on every reconnect) via a single additive `connector.on('ready')` at the construct-once site in `core/model/index.js this.connect` (attached once in the cache-miss branch → no per-reconnect accumulation; the existing `onReady` consumer is a one-shot `once('ready')`; couchbase is the only connector emitting `ready` today, the other 5 use a direct onReady callback; redis session-store connect/disconnect — the only other recurring pair — has no framework seam so it is deferred), live-only (lifecycle events have no request context → no snapshot, exactly the no-store path the refactor unblocked), reusing the same `_inspectorEventTopics` allow-list + `matchTopics` + `source:'framework'` tag + `{ok,error}` summary (no new config key). Tests: `test/core/event-inspection.test.js` (§01 real-module behaviour incl. the no-store live-emit, §02-§09 server wiring, §10-§11 SPA + dist propagation, §12-§14 Slice 2a: source/matchTopics + entity-bridge source-pins/replica + topics seed, §15 Slice 2b connector-lifecycle bridge: model/index.js source-pins + replica). Slices A1a `b2157113` / A1b `bb3103f1` / A1c `4f844bd6`; Slice 2b emit refactor `ea55a489` + connector-lifecycle bridge. Slices 2a + the 2b always-on-emit substrate LIVE-VERIFIED e2e 2026-06-30 (entity op → `inspector#event` frame + `user.events` snapshot; a no-store background emit → live frame), which also corrected the captureArgs note above; the couchbase-specific `couchbase#ready` reconnect→frame e2e is source + unit-replica only (no self-controlled couchbase env).

207. **A `DELETE` route dispatch 500'd with "Invalid regular expression: … Unterminated group" whenever the route-matching scan reached a `requirements` regex ending `)/i` — an off-by-one in `parseRouting()`'s DELETE-only un-delimit (#delete-route-requirement-undelimit, 2026-06-30).** `parseRouting()` (`lib/routing/src/main.js`) has a DELETE-specific branch (gated `/^(delete)$/i.test(method) && uRe.length === uRo.length`) that, for each `:param` segment of every segment-count-matching candidate route, strips the `/…/<flags>` delimiters off the route's `requirements[<param>]` regex and compiles it via `new RegExp(condition).test(urlSegment)` to score the match. The strip used `condition.substring(1, condition.lastIndexOf('/') - 1)` — the `- 1` over-stripped the FINAL body char: a requirement ending `…)/i` (a group-close immediately before the flag, e.g. `/(^null$|^[0-9A-Za-z]{6}$)/i`) lost its closing `)` → unterminated group → `new RegExp` threw `SyntaxError`, 500ing the ENTIRE DELETE dispatch — independent of which route the DELETE actually targeted, because the branch compiles every scanned candidate's requirement (a `DELETE` to a GET-only route 500'd too, as long as the scan reached a `)/i` route). A `…)$/i` requirement (group-close, then `$`, then the flag) merely lost its `$` end-anchor and matched too loosely, silently. The bug stayed latent because no consuming app had exercised a `DELETE` route whose scan reached a `)/i` requirement (apps mutated via POST/PUT); the first such DELETE route exposed it. **Fix:** the body's end index is `lastIndexOf('/')` (exclusive), NOT `lastIndexOf('/') - 1` — `condition.substring(1, condition.lastIndexOf('/'))` keeps the full body (and restores the dropped `$` anchor for the `)$/i` shape). Un-delimit semantics are otherwise unchanged (the closing `/<flags>` is still stripped). **Flag-parity follow-up (same session):** the trailing `/<flags>` segment is now PRESERVED too — extracted via `condition.substring(condition.lastIndexOf('/') + 1)` and passed as `new RegExp(condition, conditionFlags)`, mirroring the GET path (`fitsWithRequirements`'s `new RegExp(re, flags)`), so a `/…/i` requirement matches case-insensitively on DELETE as well; before the follow-up the DELETE branch dropped the flags (matching case-sensitively) while GET honoured them — a silent DELETE-vs-GET mismatch (tests: `routing-fixes.test.js §06`). Although `parseRouting` runs server-side, `lib/routing/src/main.js` is AMD-aliased in `build.json` (browser-bundled), so the fix REQUIRED a prod dist rebuild — the 4 `gina.*` artifacts (baseline-build-zero-drift confirmed first); a source-only commit fails the bundle-freshness "Verify dist matches source" gate. Tests: `test/lib/routing-fixes.test.js §05` (source pins that the `- 1` over-strip is gone + the corrected `lastIndexOf('/')` form is present; pure replicas of the un-delimit + `new RegExp` proving a `)/i` requirement no longer throws and a `)$/i` requirement keeps its `$`, plus SUBTRACT controls reproducing the old `Unterminated group` throw and the dropped-anchor looser match). Rule: when un-delimiting a `/…/<flags>` literal to its body, the end index is the last `/` (exclusive) — `lastIndexOf('/')`, never `- 1`.

208. **Multipart uploads decode the Content-Disposition `filename=` param as UTF-8, not busboy's latin1 default (#upload-filename-charset, 2026-06-30).** `core/server.js` constructs the upload parser as `Busboy({ headers: request.headers, defParamCharset: 'utf8' })`. The vendored busboy (`core/deps/busboy-1.6.0`) builds its Content-Disposition param decoder from `cfg.defParamCharset`, which defaults to latin1 — so a UTF-8 filename sent in the PLAIN `filename=` param (the shape the gina client SDK emits: raw UTF-8 bytes, no RFC 5987 `filename*`) was mojibaked: `Accusé de réception.pdf` reached the `busboy.on('file', (fieldname, file, filename, …))` handler as `AccusÃ© de rÃ©ception.pdf`, and that mangled name was persisted as the stored original filename. `defParamCharset:'utf8'` makes the param decoder UTF-8; the field `name` param is ASCII (UTF-8 decode is identity) and the RFC 5987 `filename*` form is self-describing (preferred over `filename`, decoded per its own embedded charset), so both are unaffected — the change only corrects the plain-`filename=` path. Server-side only (no dist rebuild). Tests: `test/core/upload-filename-charset.test.js` (a source pin on the constructor + a behaviour replica fed straight to the vendored busboy: latin1 mojibakes, utf8 decodes, ASCII unaffected, `filename*` unaffected). Established 2026-06-30.

209. **FormValidator — engine disambiguation, string inputs, a11y reflection, and live-check message visibility (consolidates former #42/#130/#150/#186/#197).** The live form/data rule engine is `core/plugins/lib/validator/src/form-validator.js` (single source, `isGFFCtx`-branched: runs server-side via `backendInit` AND compiled into the browser bundle) with the client orchestration in `validator/src/main.js` — `framework/v*/lib/validator.js` is a DEAD standalone fluent validator with overlapping `is*` rule names; never edit it for form-rule work (tell them apart fast: the live engine's `isRequired` rejects whitespace-only input, the dead one passes it). Rule bodies must handle STRING inputs — both contexts feed strings (`.value` + urlencoded bodies) — so a typed/numeric rule coerces or parses explicit components, never assumes a typed JS value: `isFloat` coerces via `Number()` (#B46); `isDate` builds from explicit mask components + a round-trip check so non-ISO slash masks aren't US-misparsed and impossible dates still reject (#B47); `isDate` returns the FIELD again on its valid path (#B48, 0.5.4 — parsed `Date` preserved on the field's `.value`, the `isDate(mask).format(...)` idiom unchanged), so rule chaining works. An empty value is adjudicated by `isRequired` ALONE (#B78): the per-rule empty-bypass became unconditional on empty (`if (this.value == '')`, its old `!errors['isRequired']` gate dropped) for `isEmail`/`isJsonWebToken`/`isFloat`/`isInList` — each regating `this.valid = isValid && !errors['isRequired']` — and `isString` keeps the field invalid without recording a second message, so a required-empty field shows ONE message (`is required`) not two, optional empty fields still pass, a filled-but-invalid value still reports its own error, and custom `is` (coercion-sensitive) is untouched by #B78 - but a cross-field `is` (`"$a === $b"`) no longer THROWS when the referenced field is empty (#B82): the client dynamised-rules substitution (`getCastedValue` in `main.js`) now renders an empty referenced operand as a quoted `""` (it was spliced RAW, leaving a dangling `"7654321" === ` that `is()`'s binary-comparison grammar `_SCS_BINARY_RE` rejected -> an uncaught throw that aborted the whole-form validity pass and left the submit trigger ungated on an invalid form, breaking the documented `is`+`isRequired` value-confirmation pattern while the confirm field was blank), mirroring `getDynamisedRules`' own sibling substitution default (`: '\"\"'`); `null`/`undefined` stay raw (already valid operands). Hardening: `is()`'s grammar mismatch now FAILS-THE-FIELD (`console.warn`+`isValid=false`) instead of throwing, so a per-keystroke live check can never abort the gate on a residually-unparseable condition (e.g. a field literally valued `"NaN"`, which the root fix leaves raw). Browser-bundled -> prod dist rebuilt; the `#SCS1e`/`#SCS1h` eval-safety pins target the untouched `_SCS_BINARY_RE`/`_scsParseOperand`/regex-literal constructs, so the hardening flips none of them. The form's validity comes from `getErrors().count()` (the surviving `isRequired` error), never the per-field `.valid` flag (whose only error-dropping reader, `setErrors`, is dead). A rule-body edit needs a prod dist rebuild AND flips the section-locked characterization tests by design. Editing trap: `form-validator.js` embeds hidden NO-BREAK SPACE bytes (U+00A0) where a normal space appears inside several `||`/ternary sequences, so a literal-space find/replace spanning one silently fails — patch such regions with a byte-scoped script over clean-ASCII substrings, not a space-spanning match. The blur-time global validation pass sets submit-button state but renders errors ONLY for the touched field — untouched invalid fields stay quiet until interacted-with or submit. Accessibility (#A11Y1): the rule-agnostic chokepoint `handleErrorsDisplay` reflects committed errors into `aria-invalid="true"` (gated on committed-not-warning; `"false"` on clear mirrors native `ValidityState` so it agrees with `:user-invalid`; hidden fields skipped), auto-wires `aria-errormessage` to a gina-owned message div UNLESS the consumer provided their own, focuses the first DOM-order invalid field on a failed submit, and announces blur-time errors via a per-form visually-hidden `aria-live="polite"` region; the per-field aria passes fire only under live-check — the always-on submit pass covers every bound form regardless. Error-MESSAGE visibility has THREE write paths (create / refreshWarning's un-hide / the refresh re-create) and the re-create runs LAST in the live-check pass, so it owns the steady state: it is focus-aware — message hidden while the edited field is the active element, revealed on blur (soft warning border while typing). Rule: when an element is written by multiple paths in a single validation pass, guard the LAST writer — an earlier-writer fix is silently overridden.

210. **Multipart upload config is ENFORCED — groups, destination, and limits (consolidates former #187/#188; #B49/#B50/#B51, 2026-06-16).** Every uploaded file must map to a CONFIGURED upload group: the resolved group (no/empty → the default `untagged`) must exist in `settings.json upload.groups` or the request is rejected 400 BEFORE the temp file is created, and `untagged` obeys its own config like any named group (the old gate ran the extension/multiplicity checks only for defined non-`untagged` groups — an allow-list bypass where any unconfigured tag streamed through unchecked; the shipped `untagged` default flipped to `isMultipleAllowed: true` to keep the previously-effective behaviour, and its default `allowedExtensions: '*'` is the honest residual — configure `untagged` restrictively or a client can route around a named group's allow-list). The destination + limit keys are honoured: files stream to `uploadDir || tmpPath || os.tmpdir()` with a per-group `path` override and an mkdir-if-missing BEFORE `createWriteStream` (the write-stream error handler only attaches later, so a missing custom dir would otherwise crash the bundle mid-stream); `maxFields` caps the per-request file COUNT (400 past the cap; 0/unset disables); `maxFieldsSize` parses its unit suffix (B/KB/MB/GB; bare number = MB back-compat) into bytes — `parseInt("512K")` silently meaning 512 MB was the pre-fix shape. Rules: a per-group restriction is only a control if the UNCONFIGURED/default case is denied or constrained, never waved through; a documented config key is only real if a code path reads it — grep the consumer before assuming a setting works. Server-side only. Tests: `test/core/upload-groups.test.js` + `upload-config.test.js`.

211. **Request-body parsing contract — verbatim JSON, `req.rawBody`, and crash-safe decodes (consolidates former #162/#163/#164; #B28/#B30/#B64).** `application/json` bodies (POST/PUT/PATCH) parse VERBATIM first — `JSON.parse(request.body)`, with a `decodeURIComponent` fallback ONLY when that throws — so the client's exact types and string contents survive (no double-decode, no `"true"/"false"/"on"/"null"` coercion, no bracket-key expansion; urlencoded/absent content-types keep the legacy decode+coerce path; error disposition unchanged — POST/PATCH 500 only when both attempts fail, PUT warns). The framework's own server-to-server client (`self.query()`) sends raw `JSON.stringify(data)` for JSON bodies (RFC5987 value-encoding is for header values, not request bodies), and the inter-bundle HTTP/2 proxy no longer copies the INCOMING request's Content-Type onto the outbound JSON body it serialized itself — a urlencoded label re-routed raw JSON through the receiving parser's urlencoded branch and corrupted string values (the only incoming-CT→outbound copy framework-wide; kept for non-JSON bodies). `request.rawBody` snapshots the exact unparsed body (reference assignment, always-on, non-multipart only, `''` for empty) immediately BEFORE parsing mutates `request.body` — inbound-webhook HMAC verification needs the raw bytes, which middleware cannot recover after the stream drains; both engines share this single `core/server.js` pipeline. Malformed percent-escapes (`%`, `%zz`, truncated `%E0%A`) in a URL or query no longer kill the bundle (#B30 — an unauthenticated single-request DoS: any unguarded `decodeURIComponent` OR `decodeURI` on the request path threw `URIError` → uncaughtException → SIGTERM): redundant SECOND decodes on the GET/HEAD branches were DROPPED (the engine query parser + the data helper each already decode once, guarded), and every genuine first decode of attacker-controllable input routes through `safeDecodeURIComponent`/`safeDecodeURI` (try/decode/fallback-to-raw) — including the ERROR paths, where a malformed-% URL to a missing asset previously crashed FROM the error handler, turning a would-be 404 into a bundle kill. Sweep rule: `decodeURIComponent` and `decodeURI` are the same throwing family — sweep BOTH; fix the throw sites, never widen the uncaughtException net. Sibling #B64 (0.5.7): the two static-file resolvers confine the resolved filename to the matched mapping target (`path.resolve` both sides + separator-aware containment), so `../` / `%2F` / `%2e%2e` cannot escape to sibling files — an escape 404s exactly like a missing file.

212. **Structured (JSON) logging — `GINA_LOG_FORMAT=json` + per-request `requestId`/`durationMs` (consolidates former #152/#155; #M12a/#M12b).** The logger resolves its render format ONCE at init into `opt.format` (precedence `GINA_LOG_FORMAT=json|text` > `GINA_LOG_STDOUT` truthy ⇒ json > `text` default) BEFORE containers are cloned; a JSON line is `{ts, level, bundle, message, group, msg}` — `bundle`/`message` canonical, `group`/`msg` retained as additive back-compat aliases — and the raw `console.log` path honours the format too (otherwise JSON mode would interleave plain lines and break a collector); the `text` default keeps container logs byte-identical. Per-request `requestId` + `durationMs` ride JSON logs via a NARROW `AsyncLocalStorage` (`process.gina._reqALS`, parked on `process.gina` so it survives dev require-cache busting), gated on JSON logging ONLY (text renders no id field, so the ALS would be pure overhead — the text path stays byte-identical/zero-cost): the id resolver honours a SANITISED inbound `X-Request-Id` (`/^[\w.\-]{1,128}$/`, regenerate-on-violation kills log forging) else `crypto.randomUUID()`; the `.run({requestId, startMs})` wrap sits at `handle()` — NOT the request-entry handler — because the `request.on('end')` boundary between them loses async context while `handle()`'s awaits preserve it (the original body became `_handleDispatch`; `handle` is a thin wrapper); HTTP/2 gets per-stream scoping free via Node's per-stream compat `'request'` event, NOT `session.on('stream')`; the JSON-assembly sites read `getStore()` and add `requestId` + per-line `durationMs`, gracefully absent for CLI/boot/off-request logs. `.run()`, never `enterWith()` (enterWith bleeds sideways across siblings).

213. **Client-plugin discipline — CSP-safe listeners, native-dialog parity, preload coalescing (consolidates former #149/#159/#200).** (1) 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 defaults with `addEventListener('click', e => e.preventDefault())`, and keep it `preventDefault`-ONLY (no stopPropagation) when the element participates in event delegation other handlers depend on — a `preventDefault`-only listener still sets `event.defaultPrevented` exactly as the inline handler did. (2) Dialog popins open as native modals (`$el.showModal()`) in EVERY env — the dev-only non-modal downgrade + manual overlay are gone for dialog mode (native `::backdrop`); a consumer that PRE-OPENS the dialog (skeleton loading) must also use `showModal()` or it is born non-modal with nothing positioning it; the opt-in `preOpen`/`loadingShell` skeleton is idempotent via `hasAttribute('open')` — NOT getAttribute truthiness, because `showModal()` sets `open` to the EMPTY string; a native UA close (Escape, `<form method="dialog">`) fires the element's `close` event WITHOUT the plugin's own close path, so a once-per-element `close` listener (the event is `close`, not `cancel` — `cancel` is Escape-only) routes it through the plugin close, keeping open-state/listeners/toolbar consistent — a UA-closed shared popin otherwise re-opens "one render behind" (#B58). (3) A popin/dialog click fired TWO identical GETs because the hover/`focusin` preload — `focusin` is part of the click gesture — was never reused: an in-flight preload registry lets the click-time consume ADOPT the in-flight fetch (waiter woken with the body, caller's own load on failure) instead of fetching again, and the legacy click path consumes preloads at all now (#B54); only the initial GET is ever preloaded. The consume path bypassing the full load tail (redirect/JSON handling) was ASSUMED acceptable — but a hover/focus-WARMED trigger whose GET returns a redirect/JSON response (`application/json` `{isXhrRedirect,location}`) had that raw JSON blind-injected as the popin body via `applyContent` (`$el.innerHTML`), the `_self` tunnel never firing — IDENTICAL symptom to the #B77 `_self` timer race but a DIFFERENT mechanism (the consume path, regression `2c61c494` v0.5.5), so #B77 fixed only the UNWARMED click-time path and the WARMED path persisted until #B80 (2026-07-06, `37e6829c`): `preloadFetch`'s `onreadystatechange` now caches a 2xx response ONLY when its Content-Type is NOT `application/json` (mirrors `popinLoad`'s own `isJsonContent` detection), firing the in-flight waiters with `null` (→ `consumePreload`'s `onMiss` = the click-time `doLoad`) and leaving the cache empty (→ a ready consume returns false → `doLoad`), so a JSON-returning trigger falls through to the click-time `popinLoad`'s full redirect/JSON tail exactly as a non-preloaded click (one extra GET on click — the hover-warm GET is not reused; measured 2 GETs). Only a genuine HTML fragment is preloaded+injected. Browser-bundled → prod dist rebuilt; VERIFIED in a real browser (a warmed legacy `data-gina-popin-url` redirect trigger now shows the tunnel target, not the raw JSON; the decline path positively exercised via resource-timing) — jsdom is falsely green. RESIDUAL (flagged, separate follow-up): `preloadFetch` still FIRES the trigger's GET on hover, so a NON-IDEMPOTENT trigger (e.g. a `pauseRequest`-style snapshot) executes its side-effect on mere hover — a broader "which triggers are safe to preload" design question. (4) A proxied/tunnelled redirect JSON response (`isXhrRedirect`+`location`, default `_self`) used to `$popin.load()` then arm a blind `setTimeout(50, () => !$popin.isOpen && $popin.open())`; a follow-up load slower than 50 ms opened the popin against a not-yet-injected (skeleton/empty) target → intermittent unhandled-deref crash. Removed as vestigial (v0.1.0, 2021) — the already-armed `loaded.<id>` listener opens CONTENT-FIRST (injects the body via popinBind/handleLoadedBody, THEN popinOpen), so the load alone suffices; the `_self` branch's `return;` still guards the `window.open` fall-through (non-`_self` targets only) (#B77). Event contract: `open.<id>` = a fresh open, `loaded.<id>` = reload/redirect content injected — a consumer needing "content ready on every load" must listen to BOTH. A structurally identical blind timer survives in the SIBLING validator `Validator::Popin now redirecting` path (`core/plugins/lib/validator/src/main.js`) — a DIFFERENT popin whose `loaded.<id>` listener may not be armed, so its timer may be load-bearing — deliberately NOT removed (the served bundle still carries exactly that one timer by design). All four are browser-bundled — prod dist rebuild required; verify (2), (3) and (4) with a REAL preemptive-open / preload / redirect-tunnel consumer — a minimal smoke page is falsely green.

214. **Per-request routing-clone correctness — narrow the defensive clone, and propagate mutations onto returned clones (consolidates former #194/#195; #B52-residual, 2026-06-18).** The router's per-request `options.conf = JSON.clone(conf)` — a multi-MB deep clone of the ENTIRE bundle config per matched request, held for the request's whole query+render window — was narrowed to: shallow-copy the top level + `conf.content`, deep-clone ONLY `conf.content.routing`, the one subtree actually MUTATED per request (audited: every other per-request write is a whole-subtree REASSIGNMENT a shallow copy isolates, and plugins/connectors/models read config via their own clones, never through the request's) — removing a dev/high-load heap high-water-mark proportional to config size (measured 163→62 MB peak under 150 concurrent slow requests, drains on idle). Rule: clone only the subtrees mutated per request and share the immutable remainder by reference — but audit EVERY per-request writer first, or a missed mutate-into-a-shared-subtree reintroduces cross-request contamination. Sibling: `getRouteByUrl` (server-side redirect/relative-route resolution AND browser-bundled) aliased the shared route config's `param` by REFERENCE while the matcher rewrites `param.{path,namespace,file,title}` in place for `:placeholder` routes — request A's substitution contaminated request B (server) and repeat navigations (client). The fix is TWO coordinated edits, not just "clone the alias": clone `param` per request AND propagate the substituted param onto the returned route object — the function returns a fresh clone of the SINGLETON, so cloning alone returns the un-substituted `:placeholder` and regresses even the first request. Rule: when a matcher both aliases a shared config object and returns a re-clone of that object, isolating the alias is insufficient — propagate the per-request mutations onto the returned clone. The sibling is browser-bundled → prod dist rebuild.

215. **AI-connector runtime CLI — `connector:infer`, `connector:test`, `connector:models` (0.5.6-0.5.8).** All three resolve the merged shared+bundle connector config + `${secret:KEY}` credentials (never echoed) and instantiate the AI connector DIRECTLY — no bundle boot, no daemon socket. The AI connector core is deliberately CLI-loadable standalone (it requires the lib registry directly, not the framework bootstrap that exits outside a booted bundle; a spawn smoke locks that guarantee), and the buffered + streaming runners share one `loadAiCore(projectPath)` helper. `connector:infer <connector> [<bundle>] @<project>` runs a one-off inference and prints the normalised result (`--format=json` → `{content, model, usage}`; `--raw` passes the provider payload through); `--stream` emits NDJSON token frames (`start`/`delta`/`done`/`error`, honest `null` counters when a provider omits usage); stdout/stderr flush via `fs.writeSync`, so piped output never truncates at the pipe buffer. `connector:test` is the CI readiness gate: validate-only by default and fully OFFLINE (config shape + driver-installed + secret resolution), exits non-zero on any failure; bare → every project, `@<project>` → every connector; `--connect` adds a live probe (AI connectors only, via `client.models.list()` — zero generation tokens). `connector:models <connector> [<bundle>] @<project>` lists the provider's live model catalogue (one id per line; `--format=json` passes provider entries through VERBATIM — shapes differ per provider, only `id` is guaranteed; honest `{supported: false, reason}` + non-zero exit when a client lacks `models.list` — never a fabricated empty list). Config hardening underneath: the shared resolver returns a DEEP COPY of the selected entry (a nested secret resolve-in-place can never poison the cached config), and an `"ai": {…}` entry with no explicit `connector` field is recognised by its entry key.

216. **`gina image:build [<bundle>] @<project>` — OCI packaging: Containerfile synthesis + buildah execution over a configurable container host (0.5.11).** Synthesizes the whole blueprint from framework-owned mechanics — a node base image at the engine floor, a global install of the project's pinned framework version (`--gina-version` overrides), an allowlist-staged context COPY, a `GINA_*` identity env block, `USER node`, an in-image `gina bundle:build` for non-dev envs, a deterministic `EXPOSE` set computed by an exact replica of the port allocator (the tests spawn the REAL allocator against a temp home and deep-equal both maps, so replica drift fails CI), and a `gina-init && exec gina-container` entrypoint — then builds via `buildah build --format oci`, locally or over ssh (tar over ssh stdin → remote temp dir; buildah accepts only a directory context). Host precedence: `GINA_CONTAINER_HOST=ssh://[user@]host[:port]` env > native (linux + buildah on PATH) > `container.host` in the framework settings > actionable error. Secrets are NEVER baked: context files copy byte-verbatim and `${secret:KEY}` placeholders resolve only at container runtime. `--emit` prints the Containerfile without building; `--stream` streams build output; `--format=json`. Gotchas: the CLI bootstrap sweeps every `GINA_*` OS env var into `process.gina` and DELETES it from `process.env` — read via `getEnvVar()`, never `process.env`; never force `-p 22` on a port-less ssh descriptor (it overrides ssh-config host aliases that define their own Port/ProxyCommand); a synthesized image needs the home + workdir chowned to the runtime user BEFORE `USER node` (a root-owned workdir EACCESes the manifest's atomic temp+rename write) and a `node_modules/gina → global install` symlink created AFTER any npm install layer (npm prunes symlinks it doesn't own).

218. **The static directory→index redirect sends 301 on BOTH protocols, dev or not (#B72, 2026-07-06).** A static request whose resolved filename is a DIRECTORY containing an index.html redirects to `<url>index.html` — but the HTTP/1.x branch issued its `writeHead(301)` only inside the dev gate, so outside dev it answered 200 with a `Location` header browsers ignore (a blank page instead of the index; the HTTP/2 sibling always sent `:status` 301). Reproduced live on a default-configured bundle — http/1.1 + http IS the scaffold default, and static dispatch is engine-agnostic (the built-in engine serves only its own endpoints and cache-hits itself; everything else, statics included, flows through the shared pipeline): `GET /sub/` outside dev → 200 + `location: /sub/index.html` + an empty body. The fix mirrors the HTTP/2 sibling: an unconditional 301 with content-type; the no-cache set stays dev-only. Note the branch requires the directory to CONTAIN an index.html — without one the request 403s before the redirect, which is why a probe against an asset directory with no index misses it.

217. **A model-init failure at boot fails FAST — never a silent degraded boot (#B57, 0.5.6).** The no-`onInitialize` boot path used to CATCH a model-loading throw and continue to a successful-looking boot with the entire model layer dead (`getModel()` then returned a bare connection stub → cryptic call-time TypeErrors far from the cause). It now aborts: emerg + a synchronous stderr flush + exit 1 with a clear `Model loading failed — aborting boot: <cause>`. Which surface catches the throw depends on the connector's `onReady` timing — a synchronous connector (sqlite) propagates to the boot wrapper's catch; async connectors (network DBs) surface via the process-level handler → SIGTERM — but every path now fails loud. The entity-class guard rejecting non-uppercase entity keys actually fires only for entity FILENAMES starting with a digit / underscore / non-ASCII letter — every DB connector auto-uppercases a lowercase-LETTER first char when keying its map, so the "mistyped lowercase class" the message implies never occurs (the guard stays ASCII-only by design until a consumer needs non-ASCII class names; the rejection is now a clear boot error instead of a degraded boot). Rule: a model-init failure must surface — never swallow it into a continue/success path (the boot-side sibling of "exhausted retryable errors must surface, never fall through to the success path").

219. **Bulk `bundle:start|stop|restart` (the no-name form and the `project:*` delegations) refuses a ZERO-bundle project cleanly — never index an empty bundles list (#B73, 2026-07-06).** Bulk mode entered the per-bundle loop with `self.bundles[0]` undefined (the state of every fresh project between `project:add` and the first `bundle:add`): `bundle:stop` warned a caught TypeError and reported a phantom `Trying to stop bundle [ undefined@<project> ]` with an `[ Offline ] 1/0` counter (exit 0); `bundle:restart` crashed uncaught (exit 1) — and the same pre-guard registry deref (`bundlesByProject[<project>][bundle].def_env`) also crashed a NAMED `bundle:restart <unregistered>` before its clean "is not registered" message; worst, `bundle:start` is daemon-dispatched, so its TypeError fired inside the framework command server (bin/cmd) and KILLED the daemon host-wide — every later online command for any project refused — while the crashing client exited 0. Each handler's init() now refuses bulk mode on an empty bundles list (stop → "Nothing to stop", exit 0; start/restart → a `bundle:add` hint via the error end), and restart resolves env/scope/protocol/scheme/port only BEHIND its isDefined guard. Rule: a bulk CLI loop over a project's bundles must guard the empty list before indexing, and registry lookups keyed by a user-supplied bundle name belong BEHIND the is-registered guard — doubly so in daemon-dispatched handlers, where one uncaught throw is a host-wide kill.

220. **`project:start|service:start @<project>` now delegate — scope the offline start-action version gate to the framework topic, and end its reject with a `process.exit`, not a bare return (#B74, 2026-07-06).** The `lib/cmd/index.js` gate that lets `gina start @<version>` override the daemon version keys on `action == 'start'` alone, so `project:start @<project>` and `service:start @<project>` (both offline, both action 'start') reached it and had their `@<project>` ref parsed as a version: it failed `/^\d\.\d/` → `Wrong version: <name>` + a BARE `return` that never called `process.exit`, so the delegation to the topic's own `start.js` never ran. Two faces of the same missing exit: with the CLI's MQ listener up (invoked via `bin/cli`, whose `net.createServer` on 8125 pins the event loop) it HUNG forever; via the `gina` wrapper (argv[1] doesn't match the MQ-listener regex, nothing pins the loop) it exited 0 without delegating. The `gina stop|restart @<garbage>` handlers carried sibling bare-return version parses that hung the same way. Fix: (1) scope the version parse to `opt.task.topic == 'framework'` so a project/service `@`-ref falls through to `run(opt)` → its own handler and delegates (topic is in scope); (2) both index.js rejects + the stop.js/restart.js siblings `fs.writeSync(2, msg)` + `process.exit(1)` instead of the bare return (process.exit fires bin/cli's `process.on('exit')` MQ-listener teardown, so the reject can no longer hang); (3) a `!availableVersions ||` guard so an unknown shortVersion reports `Version not installed` instead of a `.indexOf` TypeError. `topic == 'framework'` behaviour is unchanged. Rule: an offline start-action gate keyed on `action` alone fires for every topic — scope any version/`@`-token parse in it to `topic == 'framework'`, and end its reject with a `process.exit` (a bare return strands the invocation on the CLI's open MQ-listener handle).

221. **FormValidator marks an invalid submit trigger with `aria-disabled` + `.gina-form-submit-disabled`, NOT native `disabled` (#B76, 2026-07-06).** `updateSubmitTriggerState`'s invalid + live-check-on branch used to set the submit `<button>`'s native `.disabled = true`, but a natively-disabled `<button>` emits no click event — so the form-level `validate.<id>` guard (which renders every field's errors + focuses the first invalid field, and only `send()`s when `isValid()`) never fired, and the button was a dead no-op with zero feedback. The HIDE branch now sets `aria-disabled="true"` + adds the class `gina-form-submit-disabled` (keeping the trigger OPERABLE so the click still runs the guard) while `isValid()` stays the real send gate; the SHOW branch KEEPS clearing native `disabled` so a `<button disabled>` in markup still enables on valid / when live-check is off. Tag-agnostic (works for `<button>` and `<a>`; `.disabled=false` on an anchor is a harmless expando). The Enter-key/native-submit DOMParser proxy that read `submitTrigger.disabled` off a re-parsed copy simply never cancels on the invalid path now — harmless: the native submit is still cancelled by the `withRules || isBinded` branch and the `isValid()` gate still blocks the send. **Consumers must style the `[aria-disabled="true"]` / `.gina-form-submit-disabled` submit-trigger state — the framework ships no button CSS.** `main.js` is browser-bundled → a prod dist rebuild is required. Two testing gotchas: jsdom does NOT model "a disabled control emits no click" (it dispatches click on disabled buttons), so the bug is asserted via the pre-fix disabled STATE in a subtract replica; and the consumer-styling comment names the class, so pin the `classList.(add|remove)('gina-form-submit-disabled')` code form (==2), not a bare string count (==3). Tests: `validator-submit-trigger-state.test.js` (18).

222. **FormValidator built-in rule labels localise per culture from ONE per-bundle catalog on BOTH client and server — app-owned, the framework ships NO translations (0.5.x, validator i18n Slices 1–3).** The 26 English defaults live in `form-validator.js` as `_defaultErrorLabels`; each rule resolves `this.error || local.errorLabels[rule]` (per-field app override wins, else the default), and the engine seeds `local.errorLabels` from the defaults then overlays the negotiated culture's labels from the same `_validator.<rule>` catalog namespace both sides read. **CLIENT overlay** (`isGFFCtx`-gated) applies two layers over English with precedence **app `setErrorLabels()` > server-whispered bundle catalog > English**: layer 3 reads the `setErrorLabels()` registry `gina.validator._errorLabelsByCulture[gina.config.culture]` (exact `fr_FR` → base `fr` fallback); layer 2 reads `gina.config.validatorLabels` — the culture's `_validator` subset from `bundle/locales/<culture>.json` whispered by the render path (`controller.js` resolves it via `lib.i18n` walkFallback→getCatalog→resolveKey, `encodeRFC5987ValueChars`-encoded into `page.environment.validatorLabels`; `loader.js` reads it into `gina.config.validatorLabels` with a GUARDED `JSON.parse(decodeURIComponent(…))` IIFE — NOT a bare parse like `routing`, an empty whisper would throw and kill `onGinaLoaded` → whole-page JS dead — plus `@js_externs validatorLabels`). Each layer is `merge(JSON.clone(layer), local.errorLabels)` (lib/merge is target-wins/source-fills, so a higher layer wins per key and lower layers fill), catalog applied FIRST then registry; an empty `{}` catalog is skipped (own-key guard) so it degrades to registry/English. **SERVER overlay** (`!isGFFCtx`): passing a string `culture` to `gina.plugins.Validator` resolves the same `_validator` node (same walkFallback→getCatalog→resolveKey chain) and merges it over English. Apps register client overrides through the public `gina.validator.setErrorLabels(labels[, culture])`. Matches the framework's per-bundle "app owns catalogs" i18n stance (`bundle/locales` + `t()`); custom user rules already carry app messages and are untouched; a per-field/rule `error` still wins. **Two SERVER constraints (measured):** routing.json `validator::{}` requirement labels stay English (those validators run during route-matching, BEFORE culture negotiation → `req.culture` undefined there), and the auto-validation path (pass a rules object) has a pre-existing null-`$fields` crash at `forEachField` orthogonal to i18n — so the working server path is the MANUAL `new gina.plugins.Validator({}, data, formId, req.culture)` + `v.field.rule(...)`. Closure keeps the cross-module property names via the quoted-literal-decl + dot-read + `@js_externs` pattern; both files browser-bundled → any edit needs a prod dist rebuild (the 4 `gina.*` + 3 `gina.onload.*`). Tests: `test/lib/validator-label-i18n.test.js` (client registry layer) + `test/lib/validator-label-i18n-server.test.js` (server) + `test/lib/validator-label-i18n-client-catalog.test.js` (client catalog + 3-layer precedence).

223. **Per-request culture negotiation was INERT framework-wide until 0.5.x — `req.culture` / the whispered `gina.config.culture` always `'en'`/`''` regardless of bundle or environment culture, and `t()` / the `t` template filter returned keys — now activated (#I18N Slice 1).** The #I18N1 catalog + negotiation machinery shipped but three legs were dead: `lib/i18n loadCatalogs` had ZERO runtime callers (so `process.gina._i18nCatalogs` stayed empty → `core/server.js availableCultures` `[]` → `matchAvailable` null on an empty list → URL-prefix / cookie / `Accept-Language` never matched); the per-request `negotiateCulture` call passed `defaultCulture: null` (step-4 bundle default skipped); and 8 sites read `process.env.GINA_CULTURE`, which `utils/helper.js` moves into `process.gina` and DELETEs from `process.env` at init (`getEnvVar` reads `process.gina`), so `negotiateCulture` step 5, the `server.js` catch, both swig + both nunjucks `t`/`tIcu` filter fallbacks, and the `i18n:add` CLI all read an always-undefined var → negotiation fell to step 6 = `'en'`. Fix (server-side, NO dist rebuild): `config.js loadBundleConfig` (after the `secrets.resolve` seam) calls `i18n.loadCatalogs(bundle, <bundleRoot>/locales)` guarded by `fs.existsSync` (opt-in; also avoids loadCatalogs' empty-entry + background ICU-loader side effects) + `try/catch`→`console.warn` (a malformed catalog is warned, not boot-fatal — loadCatalogs THROWS on bad JSON); `server.js` reads the matched bundle `content.settings.region.culture` (format-guarded vs an unresolved `${culture}`) as `defaultCulture`; the 8 dead reads → the live `getEnvVar('GINA_CULTURE')` (`typeof getEnvVar === 'function'`-guarded in the strict global-free `lib/i18n` + the filter modules so an isolated unit-require never ReferenceErrors). Precedence now live: URL / cookie / `Accept-Language` > bundle `settings.region.culture` > operator `GINA_CULTURE` > `'en'`. **`settings.region.culture` is the REAL per-bundle culture key** — bundle scaffold `core/template/boilerplate/bundle/config/settings.json` `"culture":"${culture}"` (full underscore culture resolved at `bundle:add`; the merged config carries it — `server.js`/`controller.js` already read `content.settings.region.{timeZone,shortCode}`), NOT `settings.locale.region` (a country code in the framework `settings.json`'s separate CLDR `locale` block). Blast radius: framework-wide — `req.culture`/`gina.config.culture` now reflect the configured culture, `t()` resolves real strings for any bundle shipping a `locales/` dir (others unaffected via the `existsSync` guard). Makes the #222 client validator-label overlay (gated on a truthy `gina.config.culture`) actually reachable; Slice 1 of the validator + i18n unification. **#B84 (same day) completes the activation on the WARM route path:** Slice 1 negotiated `req.culture` only on the COLD (uncached) path — `core/server.js handle()`'s cached-route fast-path (`if ( hasCachedRoute ) { isRoute = hasCachedRoute; … break; }` ~`:4964`) breaks the routing loop BEFORE the cold-path negotiation site (~`:5115`), and the route cache persists across requests (the module-scoped `routingLib` at `:91` is a stable gen-0 binding — server.js is load-once; `Routing._cached` is set at `:5198` on the cold request and read at `:4964` on every later hit to the same `method:pathname`), so the SECOND hit to any URL is warm and left `req.culture` unset → `controller.js` embeds `''` into `page.environment.culture` → `gina.config.culture === ''` → the #222 client overlay inert on warm reloads (a warm BROWSER reload reproduces; `curl` masks it — a fresh cold-only hit after a restart still negotiates). Fixed by extracting the negotiation into a per-request `var _negotiateReqCulture = function(req, routeBundle)` closure in `handle()` scope, called on BOTH the warm (`( req.routing && req.routing.bundle ) || bundle` — `getCached` already ran `compareUrls`→`checkRouteParams`, so `req.routing` incl. `bundle`/`culturePrefix` is populated) and cold (`routing[name].bundle`) paths. **The resolved culture is NEVER cached with the shared route entry** — it's per-request (cookie / Accept-Language), so pinning it would cross-request-bleed (the #B65/#B66 per-request-vs-worker-global class); the consumer's first suggestion "cache `req.culture` with the route" was rejected for exactly this, their second "re-negotiate on the warm path" shipped. **Rule: any per-request state derived after a route match must be re-derived on the cached fast-path too — `if ( hasCachedRoute ) { … break; }` bypasses everything below it in the loop.** Server-side, NO dist rebuild; surfaced by a consumer report-back that Slice 1's cold-only activation had missed. Tests: `test/core/server-culture-negotiation.test.js` (two-path source pins + a behavioural replica of the fast-path-breaks-before-cold-site structure driving the real `negotiateCulture` + a subtract proving the warm-path call is load-bearing) + a daemonless cold→warm `gina-container` boot (`req.culture === 'xx_XX'` on cold AND warm over real HTTP; `''` pre-fix). Established 2026-07-07.

224. **The client FormValidator now AUTO-BOOTS at page load — a form declaring `data-gina-form-rule` + a matching `gina.forms.rules` entry validates in the browser with NO bundle JS (2026-07-07).** `core/asset/plugin/src/vendor/gina/core.js`'s plugin-loading `require([...])` callback previously ran only `bootPopinHandler()` (the `data-gina-dialog` popin auto-boot); it now also runs a sibling `bootValidator()` that constructs `new (require('gina/validator'))(gina.forms.rules).on('ready', function(){})` when `gina.forms.rules` is non-empty — closing the popin/validator asymmetry (both declarative attribute APIs now work with no per-element handler code). The trailing `.on('ready')` is load-bearing: validator `init()` only REGISTERS `on('init', onValidatorInit)` + sets `initialized=true`; the form-scan/bind + `gina.validator` publish live inside that handler, fired by the `events.js` self-fire that ANY post-`initialized` `.on()` triggers (`initialized && !isReady → triggerEvent('init.'+id)`). IDEMPOTENT: a later explicit bundle construction (a bundle `main.js` + a per-page handler both `new V(...)`) takes the `if (gina.hasValidator) { instance = merge(instance, gina.validator) }` merge path — its `.on('ready')` STILL fires and the already-`binded` forms are not re-scanned (auto-boot just prepends construction #1 to the re-construction path bundles already relied on). Gated on NON-EMPTY `gina.forms.rules` so a rules-less page is byte-identical (no scan, no `gina.validator` publish). The `isFrameworkLoaded` poll (bounded ≤100, `setTimeout`) is race-safe: `onGinaLoaded` sets `isFrameworkLoaded` (`loader.js:232`) THEN `gina.forms` (`:236`) in ONE synchronous whisper, so a truthy `isFrameworkLoaded` observed from a poll guarantees `gina.forms` is already populated (JS run-to-completion — no yield splits the whisper). `core.js` is browser-bundled → a prod dist rebuild is required (freshness anchor: the `[gina] validator boot failed` string literal, which survives Closure SIMPLE minification unlike the renamed `bootValidator` identifier). MEASURED via a daemonless `gina-container` render smoke (2/2 deterministic): a rule-bound form auto-validates with zero app JS (`gina.hasValidator===true`, `$forms.<id>` bound, live-check gate toggles invalid→valid→invalid), and a later `new V(gina.forms.rules).on('ready', fn)` runs `fn` with the form still bound. Tests: `test/core/validator-auto-boot.test.js`.
