# Gina — LLM Reference

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

---

## Key concepts

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

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

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

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

---

## Directory layout

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

---

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

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

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

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

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

---

## TypeScript declarations & explicit exports

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

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

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

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

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

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

---

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

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

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

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

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

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

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

---

## Controller pattern

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

function ApiContentController() {
    var self = this;

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

module.exports = ApiContentController;
```

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

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

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

---

## Views / Templates

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

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

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

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

---

## Entity (ORM) pattern

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

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

module.exports = UserEntity;
```

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

---

## SQL file format

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

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

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

---

## Connector config (connectors.json)

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

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

## AI connector

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

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

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

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

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

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

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

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

---

## Async jobs (#AI6)

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

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

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

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

---

## Route radix trie

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

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

---

## HTTP/2 settings & metrics

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

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

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

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

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

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

`GinaHttp2Error` codes: `TIMEOUT`, `PREMATURE_CLOSE`, `STREAM_ERROR`, `ECONNRESET`, `ECONNREFUSED`, `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`.

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

---

## Routing (routing.json)

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

---

## OpenAPI spec generation

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

Mapping from routing.json to OpenAPI:

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

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

---

## MCP tool manifest generation

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

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

Mapping from routing.json to MCP Tool:

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

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

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

Independent of `bundle:openapi`. No alias.

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

---

## MCP runtime server (stdio)

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

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

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

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

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

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

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

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

---

## Project config files

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

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

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

---

## Bundle config files

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

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

---

## CLI entry points

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

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

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

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

---

## Dev mode behaviour

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

---

## Ports

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

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

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

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

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

---

## Inspector (formerly "Beemaster")

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

**Data channels:**

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

**Log channels:**

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

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

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

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

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

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

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

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

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

**Query Instrumentation (QI):**

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

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

**UX enhancements:**

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

---

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

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

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

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

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

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

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

---

## Home directory (~/.gina/)

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

---

## Common gotchas

1. **`services/` is gitignored** — built-in bundles not published to npm.
2. **Commit messages**: no AI references, no co-author footers. Imperative or gerund style.
3. **"services" = bundles** — when asked to start/stop/restart services, interpret as Gina bundles.
4. **`inherits(Child, Parent)`** calls both constructors on a fresh `this` — not the same as `Object.create`.
5. **`getConnection()` in tests**: pass `{ connector: mockConn }` as the third arg to EntitySuper.
6. **`super()` does not exist** — use `Parent.call(this, ...)` for constructor chaining.
7. **Roadmap sync**: any roadmap change must be applied to both `ROADMAP.md` and `~/Sites/gina/docs/repo/docs/roadmap.md`.
8. **`${variable}` convention in all docs** — Every path, filename pattern, and inline variable reference in Gina documentation uses `${variable}` (with the leading `$`), never bare `{variable}`. This matches the `whisper()` interpolation syntax. Breaking this convention makes variables look like plain text. Applies to paths (`~/.gina/${shortVersion}/`), filename patterns (`connectors.${env}.json`), framework paths (`${core}/controller/`), and cache paths (`${cache.path}/${bundle}/`). Always scan existing docs before writing new content to verify consistency.
9. **`gina_version` in `manifest.json`** — optional per-bundle pin. When set, that bundle's spawned process gets `GINA_VERSION`, `GINA_FRAMEWORK_DIR`, and `GINA_CORE` overridden in its context. The socket server is unaffected. CLI flag `--gina-version` takes priority over the manifest declaration.
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:557-560`) calls `inherits(Controller, SuperController)` automatically before every dispatch. Writing `var SuperController = require('../../../core/controller/controller')` or `Controller = inherits(Controller, SuperController)` inside a controller file is wrong and will break on path changes. The correct pattern is a plain constructor function with `this.action = function(req, res) {}` closure-style methods — matching the boilerplate in `core/template/boilerplate/bundle/controllers/controller.content.js`. Never use `Controller.prototype.method` either; actions are defined as properties on `this` inside the constructor.
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)` at `server.js:3095` is called without `await` or `.catch()`. Any unhandled throw becomes a silent unhandled promise rejection. When debugging routing crashes, wrap the body in try-catch temporarily.
39. **`getAssets()` warns on inline `<script>` tags** — `server.js:811` regex matches all `<script>...</script>` tags in the rendered HTML, including inline ones. Inline scripts have no `src` attribute, so the `urlArr` match at line 927 returns `null`, causing `urlArr.length` to throw into the catch block that prints `"Problem with this asset"`. Fix: skip `<script>` tags without `\ssrc\s*=` after the tag detection block. The `\s` before `src` prevents false matches on `data-src`. Inline scripts are not external assets — they have no URL to catalog.
42. **Validator touched-field-only error display** — the validator runs a global validation pass on every field blur to determine submit button state (`isFormValid`). The global pass must NOT display errors for untouched fields — only for `event.target.name` (the field the user actually touched). Two call sites in `validator/src/main.js`: ~line 3337 (within the `isFormValid && gResult.error` block) and ~line 4865 (same pattern in the second validation path). The global pass still correctly disables/enables the submit button.
108. **Docker `node_modules` isolation** — Docker containers using `node_modules` anonymous volumes have their own copy of all npm packages. Edits to `node_modules/` on the host are invisible inside the container. To debug SDK-level behaviour, either `docker exec` and edit inside the container, or add logging to the framework connector code (which IS bind-mounted) rather than the SDK itself.

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

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

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

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

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

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

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 on `process.gina.PROXY_PREFIX`. Composition site: `core/controller/controller.js` at the `set('page.environment.webroot', ...)` call (~line 513) — when `PROXY_PREFIX` is set, public webroot becomes `prefix + bundle.server.webroot` (slash-normalised), then templated into `gina.onload.min.js`. Internal disk-path resolution and asset-rewrite call sites (e.g. `controller.js:927,956`) still use the bundle's native `server.webroot` — only the browser-facing `gina.config.webroot` carries the prefix; the proxy strips the prefix when forwarding upstream so the bundle's static handlers receive native paths. Symptom shape that surfaces this gap: browser fetches `/_gina/*` URLs that nginx routes to the wrong upstream (the bundle that owns bare `/`, not the bundle that rendered the page); inspector indicator names one bundle but `routing.json` contents come from another. Server-side asset URL rewriting at `controller.js:927,956` and the `linkTo` family also write URLs intended for browser consumption — same prefix-awareness gap, deferred follow-up. The bare `/_gina/assets/routing.json` handler exists only in `server.isaac.js` with no engine-agnostic equivalent in `server.js`; bundles using the Express engine don't have this endpoint at all — independent of this slice but worth noting on the next `/_gina/*` parity sweep. Established 2026-05-06 after the symptom surfaced from a multi-bundle nginx deployment where a sub-path bundle's pages were fetching the root bundle's `routing.json`.

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

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

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

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

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

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

128. **Routing configuration (`routing.json` semantics) — 7 lessons consolidated** (replaces individual entries #19, #20, #27, #35, #36, #105-first, #106):
- **`namespace` is required to target a namespace controller** — omitting `"namespace": "content"` makes the router look in `controller.js` instead of `controller.content.js`. Error surface is `"control not found: list"` pointing at `controller.js`, which is confusing. Always set `namespace` when the action lives in a namespace controller file.
- **URL params need `"id": ":id"` in `param` to reach `req.params`** — declaring `"url": "/notes/:id"` alone is not enough. You MUST also add `"id": ":id"` inside the `param` block so the router binds the segment to `req.params.id`. `requirements` are optional (any non-empty segment is accepted when omitted); the `param` binding is not.
- **`fitsWithRequirements` requires `"slug": ":slug"` in `param` even when `requirements` is set** — the router only increments the route-match score (and populates `req.params`) for a URL parameter if that parameter key exists in the `param` block. Having `requirements: { "slug": "..." }` alone is not enough — the route 404s without the binding. The `requirements` block adds regex validation on top of the binding; it does not imply the binding.
- **`req.routing.param.<key>` ≠ URL value — use `req.params.<key>`** — `req.routing.param` holds the raw routing config object from `routing.json`. For URL parameter bindings like `"id": ":id"`, the value stored there is the literal placeholder string `":id"`, not the actual URL segment. The actual captured value is in `req.params.id` (always available) and `req.get.id` / `req.delete.id` etc. (method-specific). Static values declared in `param` (e.g. `"code": 302`) ARE correctly available on `req.routing.param.code`. Rule of thumb: use `req.routing.param` only for static metadata, never for captured URL params.
- **Routing loop cross-route contamination** — `fitsWithRequirements()` mutates `request.params` and `request[method]` during each route comparison. Leftover values from non-matching routes cause `parseRouting` lines 396–407 to inject phantom URL segments, compounding work exponentially. Fix: `req.params = Object.assign({}, _origParams)` + `delete req[method]` before each `compareUrls()`. Never `delete req.params` — `fitsWithRequirements` gates on `typeof(request.params) != 'undefined'`. Never use `JSON.clone` on request state in the routing loop — `Object.assign` is sufficient and safe.
- **`server.isaac.js:812` initializes `req.params`** — `request.params = {}` and `request.params[0] = url` are set before `handle()` runs. Any code that deletes `req.params` breaks `fitsWithRequirements` which checks `typeof(request.params) != 'undefined'` before setting param values.
- **internal-bundle `setting-get-one-design` was a latent crash** — 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 L1391 and loops `response.setHeader(h, headerValue)` over each key at L1481-1534 — so any entry in the env.json `response.header` block IS applied on Express. But `core/server.isaac.js` does NOT read `server.response.header` anywhere (grep `serverResponseHeaders` / `server\.response\.header` against `server.isaac.js` returns zero matches) — Isaac builds its headers from the framework's own primitives (`_setPoweredByHeader()` for X-Powered-By, fixed setHeader calls for cache/CORS at the `/_gina/*` handlers), so any env.json `response.header` default is silently ignored on Isaac bundles. Layered on top: framework code that emits its own canonical value via a later `setHeader` call in the Express request lifecycle OVERWRITES whatever env.json set earlier. The combination means an env.json `response.header` config default can look load-bearing in the template while being functionally dead at runtime — applied-then-overwritten on Express, ignored on Isaac. Operator rule: before claiming a `response.header` config key reaches the wire, (a) grep both engines for reads of `server.response.header` (the Isaac silence is the dead-giveaway), and (b) grep for framework `setHeader('<that-key>', ...)` calls that would overwrite. Precedent: 2026-05-17 — the `X-Powered-By: "Gina I/O - v${version}"` env.json default at `core/template/conf/env.json:91` was structurally dead (overwritten by `server.js:2425` on Express, never read on Isaac); dropped in favour of the canonical `Gina/<version>` shape emitted by `server.js:2425` + `_setPoweredByHeader()` and suppressed by `gina.plugins.HidePoweredBy()` middleware + `settings.json > server.hidePoweredBy: true` gate respectively. The handoff that prompted the cleanup had framed the env.json default as a "third emit path that neither plugin nor gate reaches" — wrong in both directions (Express middleware DID remove the overwritten value cleanly; Isaac never read the env.json key at all). Established 2026-05-17.

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

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

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:install <version>` message via `process.stderr.write` (`console` is not reassigned to the framework logger until later, so `console.alert` is undefined there). Diagnostic tell: `could not load package.json` followed by `Cannot find module '…/framework/v<X>/lib/generator'` means framework version `<X>` is not installed, not that package.json is broken. (`8fa9c278`, 2026-05-30)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

162. **`self.query()` and the `#B28` body parser are now self-consistent for `application/json` bodies — the built-in HTTP client sends raw JSON, and the server tolerantly accepts a percent-encoded body as a fallback.** The server-to-server client (`this.query` in `core/controller/controller.js`) historically serialized PUT/POST `application/json` bodies as `encodeRFC5987ValueChars(JSON.stringify(data))` (percent-encoded), while `#B28` had switched the server parser (`processRequestData` in `core/server.js`) to read `application/json` verbatim via `JSON.parse(request.body)` — so the framework emitted bodies its own parser rejected: every internal `self.query()` POST/PATCH with a non-empty body returned HTTP 500, and PUT silently dropped the body. Empty-data calls were unaffected (no body sent). Two-sided fix. **Client:** `this.query` now sends `queryData = JSON.stringify(data)` raw for the json/text/x-www-form PUT/POST branch (the wire content-type is forced to `application/json` anyway) — RFC5987 value-encoding is defined for HTTP header values (`filename*=`), not request bodies; the empty-data short-circuit and the urlencoded-into-path GET-style branch (per-key `encodeRFC5987ValueChars`, correct for URL params) are untouched. **Server:** each of the three `application/json` branches (POST/PUT/PATCH) tries the verbatim `JSON.parse(request.body)` FIRST and falls back to `JSON.parse(decodeURIComponent(request.body))` only when that throws — so a genuine raw-JSON body (incl. a literal `%XX` inside a string value) is never double-decoded (`#B28` intent preserved) while an already-deployed encoded sender is still accepted (version-skew-safe). Error disposition unchanged: POST/PATCH `throwError(500)` only when BOTH attempts fail, PUT warn-only. No dist rebuild — `controller.js` is not in the browser bundle and `encodeRFC5987ValueChars`'s definition is untouched (only its use in `this.query` changed). Tests: `test/core/http-methods.test.js` §14 (server tolerant-parse source pins + behavioural replica: encoded body parses, raw parses, `%XX` preserved via verbatim-win, malformed throws, POST/PATCH-500-vs-PUT-warn disposition) + §15 (client raw-JSON source pins + round-trip replica, incl. the legacy-encoded-sender still round-tripping); §13's `decodeURIComponent`-absence pin was tightened to verbatim-first / decode-only-as-fallback. **Proxy leg (#FORMCT2, PR #38, merged 2026-06-12):** the same self-consistency had a THIRD breakage point between the two fixed ends — the HTTP/2 request prep (`handleHTTP2ClientRequest`) forwarded the INCOMING request's Content-Type onto the outbound options unconditionally, so when a browser's urlencoded form POST triggered an inter-bundle `query()`, the raw-JSON body was re-labeled `application/x-www-form-urlencoded` and the receiving parser's urlencoded branch corrupted it (`+`→space, decodeURIComponent, and `"true"/"false"/"on"/"null"` coercion — all inside JSON string values; e.g. a `+`-alias email losing its `+`). The forward now skips `application/json` outbound bodies — the label `query()` itself just forced for the body it serialized — and is kept for non-JSON ones (the MSIE `text/plain` override). The HTTP/1.1 client path never forwarded Content-Type at all, so the guard aligns the two engines; this forward (dating to 2019) was the only incoming-Content-Type→outbound copy framework-wide. Tests: `test/core/query-proxy-content-type.test.js` (guard source pins incl. a no-unguarded-forward-remains negative, forward resolution matrix, corruption demonstration). Established 2026-06-09; proxy leg added 2026-06-12.

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

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

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


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

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

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

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

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

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

172. **Late response-API calls on a released response must no-op, never crash (#B31, 2026-06-12, commit `27aa95a9`, shipped `0.5.1-alpha.2`).** Every response terminal exit releases the per-request refs (`local.req/res/next = null`) — and `redirect()` releases them AND THEN calls `next()`, so the middleware chain continues into the controller action with the refs already null. Any later response-API call on the same request therefore ran against a released response, and two entry points turned that into a process kill rather than a no-op: `throwError` normalizes every 1-/2-arg call shape to `res = local.res` and read `typeof(res.getHeaders)` off the null (`TypeError: Cannot read properties of null (reading 'getHeaders')`, no application frames), and `headersSent()` read `typeof(_res.stream)` off the null — both uncaughtExceptions the process supervisor escalates to SIGTERM, killing the bundle plus every in-flight request. Field shape: an auth middleware that 301-redirects unauthenticated requests to a login route and lets the chain continue makes the crash deterministic on every unauthenticated hit — and the crash-respawn loop keeps every process cold, which can masquerade as a separate "valid sessions never authenticate" bug (each request lands on a freshly-respawned process whose session-store connector hasn't warmed). Fix: `headersSent()` reports a released response as already-sent (single chokepoint — every `!headersSent()` caller no-ops), and `throwError` logs the serialized late error (the warn now names what previously died as an opaque uncaughtException) and returns false, mirroring its renderingStack guard; live-response paths are byte-identical. Same crash class as the malformed-percent decode DoS: fix the throw site, never widen the uncaughtException net. Reusable repro recipe: `controller.js` loads standalone — inject the framework dir into NODE_PATH + `Module._initPaths()`, require the framework `helpers` (injects the `_`/`getPath`/`requireJSON` globals), `setPath('gina', { core: <fw>/core })`, then `SuperController.createTestInstance({req, res, next, options})` with a minimal mock response + `renderTEXT()` as the lightest terminal exit reproduces any released-response sequence against the real class.

173. **Dev-mode hot-reload eviction leaked the whole framework module graph per request via module.children (#B32, 2026-06-12, commit `3ad449f8`).** 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 (refreshCore in the isaac engine re-requiring lib/index.js + plugins/index.js; refreshCoreDependencies in the router re-requiring the controller pair; lazy in-function requires of evicted libs) made long-lived parents accumulate one dead Module per eviction, each pinning its entire evaluated exports graph. Measured on a minimal starter bundle in dev: ~1.8 MB of post-GC live heap retained per request, dead-linear (435.5 ± 0.3 MB per 250 requests), require.cache at 164 entries vs 14,178 reachable Modules, heap-limit OOM (V8 abort, exits SIGABRT) at ~2400 requests — presenting upstream as HTTP/2 PING timeouts (the GC death spiral freezes the event loop) then ECONNREFUSED, then a supervisor respawn loop that keeps every process cold. Fix: a pruneDeadModuleChildren() sweep at the end of BOTH eviction cycles walks require.cache and keeps only children whose resolved id still maps to that same instance — children is diagnostic metadata (nothing in Node resolution reads it), so pruning never unloads a module still referenced elsewhere. Post-fix: flat ~32 MB live heap across 3000 requests, 168 reachable vs 164 cached. Prod was never affected (no eviction + cache-hit dedup). Known residual, same class, deliberately deferred: the CLI daemon evicts config JSONs per command (cmd helper + requireJSON) with no sweep — KB-scale at human command cadence; run the same sweep at command dispatch if it ever measures as a problem. Tests: test/core/module-children-prune.test.js (source pins + pure-logic replica + subtract).

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.

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.

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

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.

181. **`gina port:set --force` pins a bundle to a fixed port even when another bundle holds it — the fix for `port:reset`'s alphabetical, count-sensitive allocation drifting hardcoded ports (2026-06-14, commit `fbde6543`).** `gina port:reset @<project> --start-port-from=N` allocates by SORTING the whole project's bundle set alphabetically (`reset.js` `self.bundles.sort()`) and counting up, so the bundle→port map depends on bundle COUNT + NAMES: adding / removing / splitting a bundle shifts every alphabetically-later bundle's port (a new bundle adds 2 ports/scheme to each prior block, moving the http/2 block start, AND inserts its own name into the sort order). A one-bundle-per-container deployment that HARDCODES each bundle's port (reverse-proxy upstream / compose mapping / `PORT` env) then breaks the moment the set changes — the bundle binds the drifted registry port while the proxy still targets the old one (connection refused). Root fix: don't depend on `port:reset`'s allocation — pin each container's own bundle to its declared port with `gina port:set <bundle> @<project> --protocol=http/2.0 --scheme=https --port=<PORT> --env=<env> --force`. The new `--force` evicts the prior holder from BOTH maps (forward `ports.json` + reverse `ports.reverse.json`) then assigns; WITHOUT `--force` an in-use port is still rejected — back-compat preserved, the conflict error+exit is byte-identical, only gated on `!--force`. Idempotent (re-setting the bundle's own port is a no-op); the displaced bundle re-pins itself the next time its own context runs the same line. Eviction (not swap) is the chosen semantic: the displaced bundle simply loses that protocol/scheme slot, which suits per-container deployments where each container owns exactly one bundle. Tests: `test/lib/port-set.test.js` §02 (force source pins) / §04 (parse) / §06 (evict-both-maps + non-force-still-rejects + idempotent) / §07 (help).

182. **Framework-connection CLI flags (`--port`/`--mq-port`/`--host-v4`/`--hostname`/`--debug-port`) must be scoped to framework-scoped commands, or a sub-topic command's `--port` corrupts the framework command-socket port in `~/.gina/<short>/settings.json` (the recurring container-boot instability, 2026-06-14, commit `85a5debf`).** These flags were hoisted into `GINA_*` for EVERY command by TWO independent arg pre-processors — `bin/cli`'s param loop AND `utils/helper.js` `filterArgs` (the generic `--k=v → GINA_K` hoister) — and `framework:init` then persists `GINA_PORT` into settings.json. So `gina port:set <bundle> --port=N` (where `--port` is the BUNDLE port) overwrote the framework command-socket port (8124 → N); every later online command read the corrupted value, and depending on timing the framework daemon itself could bind the bundle port N instead of 8124. In a one-bundle-per-container deployment that pins its bundle port, this made the bundle unhealthy on boot (`bundle:start` → `[ gina ] not started` + `ECONNREFUSED`). Fix: BOTH hoisters gate the framework-connection flags on `isFrameworkScopedCmd` — `process.argv[2]` has no `:` (bare `start`/`stop`/`restart`, which are prefixed to `framework:*` downstream) OR starts with `framework:`; for a sub-topic command the flags stay in argv for that command's own parser to read. The `--inspect`/`--debug` splice stays unconditional (`bundle:start --inspect` relies on it). **Lesson (no-fix-without-measurement): reading mis-identified the write site — the first fix (the `bin/cli` param loop only) did NOT stop the corruption; temporarily instrumenting `createFileFromDataSync` to dump the stack on any settings.json write revealed the SECOND hoister (`filterArgs`) and the actual persist site in `framework:init`. Instrument the WRITE, don't infer it from reading the hoisters.**

183. **The framework command socket must accumulate-and-guard-parse incoming chunks — an unguarded `JSON.parse(data.toString())` on each raw `'data'` chunk is a crash vector (2026-06-14, commit `c4694bde`).** `bin/cmd`'s `launchFramework` socket handler parsed every TCP chunk directly as JSON. The client (`bin/cli`) sends `JSON.stringify(process.argv)` in a SINGLE write, but TCP may split or coalesce it across chunks, and a partial / empty / non-JSON payload makes `JSON.parse` throw a `SyntaxError`; with no try/catch and no request lifecycle around it, the throw reaches `proc.js`'s `uncaughtException` handler → emerg + `dismiss(pid, 'SIGTERM')`, dropping the command and risking a daemon teardown (same throw-site crash family as the malformed-`%` decode DoS and the boot-exit-flush truncation entries). Fix: a per-connection `_payload` accumulator (declared in the `net.createServer(function(conn){…})` closure so each connection has its own buffer); `conn.on('data')` appends, then `try { argv = JSON.parse(_payload) } catch { return }` — a partial chunk keeps accumulating until complete, a non-JSON/empty payload never parses and is ignored, and on success the buffer resets for any further command on the connection. An `!Array.isArray(argv)` guard (warn + drop) closes the residual path, since `argv.join`/`argv[0]=` sit OUTSIDE the parse try and would throw on a valid-but-non-array payload. The parse catch stays SILENT (no log): it fires on a legitimate incomplete chunk too, before it is known whether the payload will complete, so a "parse failed" log there would be misleading. Rule: never `JSON.parse` a raw socket chunk unguarded — accumulate and try/catch, and guard the post-parse shape.

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