# Phase 5: Deploy - Pattern Map

**Mapped:** 2026-05-08
**Files analyzed:** 21 new files (no Phase-5 modifications to existing source)
**Analogs found:** 17 / 21 (4 files are greenfield infra with no in-repo analog — Dockerfile, docker-entrypoint.sh, litestream.yml, fly.toml family)

## File Classification

| New File | Role | Data Flow | Closest Analog | Match Quality |
|----------|------|-----------|----------------|---------------|
| `apps/server/Dockerfile` | infra (build) | build-time | none in-repo (greenfield) | no analog |
| `apps/server/docker-entrypoint.sh` | infra (runtime bootstrap) | shell pipeline | none in-repo (only `.mjs` scripts exist) | no analog |
| `apps/server/litestream.yml` | infra (config) | streaming WAL replication | none in-repo (greenfield) | no analog |
| `apps/server/fly.staging.toml` | infra (config) | request-response (Fly proxy) | none in-repo (greenfield) | no analog |
| `apps/server/fly.prod.toml` | infra (config) | request-response (Fly proxy) | none in-repo (greenfield) | no analog |
| `apps/obs/Dockerfile` | infra (build) | build-time | `apps/server/Dockerfile` (Phase-5 sibling) | sibling-once-written |
| `apps/obs/fly.toml` | infra (config) | request-response | `apps/server/fly.staging.toml` (Phase-5 sibling) | sibling-once-written |
| `apps/server/src/staging-invite.ts` | middleware (Express) | request-response | `apps/server/src/index.ts` lines 100-138 (CORS middleware) | role+flow-match |
| `apps/server/src/otel-init.ts` | service (init) | event-driven (telemetry) | `apps/server/src/log.ts` (singleton pino init) + `apps/server/src/room-key.ts` (boot helper) | role-match |
| `apps/server/scripts/run-migrations.ts` | script (one-shot) | batch | `apps/server/src/index.ts` lines 372-408 (`bootstrapSchemaIfFresh`) + `apps/server/scripts/migrate-legacy-accounts.ts` | exact (replaces bootstrapSchemaIfFresh) |
| `scripts/soak-staging.mjs` | test harness (long-running) | event-driven (WS clients) | `apps/server/test/authority.integ.test.ts` `joinClient` helper | role-match |
| `scripts/verify-phase-5.mjs` | composite gate (orchestrator) | batch | `scripts/verify-phase-4.mjs` | exact |
| `scripts/verify-phase-5.test.mjs` | unit test (gate-shape lock) | static analysis | `scripts/verify-phase-4.test.mjs` | exact |
| `tools/scripts/lint-deploy-stack.mjs` | lint (drift guard) | static file scan | `tools/scripts/lint-rate-limit-budgets.mjs` | exact (same family) |
| `.github/workflows/deploy-staging.yml` | CI workflow | event-driven (push) | `.github/workflows/verify-phase-4.yml` | role-match (extended with build+deploy) |
| `.github/workflows/deploy-prod.yml` | CI workflow | event-driven (tag push) | `.github/workflows/verify-phase-4.yml` | role-match |
| `.github/workflows/trace-check.yml` | CI workflow (PR gate) | request-response (PR check) | `.github/workflows/verify-phase-4.yml` | role-match |
| `.github/workflows/soak-staging.yml` | CI workflow (dispatch) | event-driven (workflow_dispatch + cron) | `.github/workflows/verify-phase-4.yml` | role-match |
| `.github/workflows/verify-phase-5.yml` | CI workflow (PR/push) | event-driven | `.github/workflows/verify-phase-4.yml` | exact |
| `docs/runbooks/RESTORE.md` (or `RESTORE.md`) | doc (runbook) | prose | none in-repo (no runbooks yet); ADR style closest | no analog |
| `docs/adr/0005-deploy-topology.md` | doc (ADR) | prose | `docs/adr/0002-persistence-layer.md` + `docs/adr/0004-room-hot-reload.md` | exact |
| `docs/adr/0006-observability-stack.md` | doc (ADR) | prose | `docs/adr/0004-room-hot-reload.md` | exact |

## Pattern Assignments

### `apps/server/src/staging-invite.ts` (middleware, request-response)

**Analog:** `apps/server/src/index.ts` lines 100-138 (the `app.use('/api/auth', ...)` Origin-header CORS gate)

**Imports pattern** (mirror `apps/server/src/index.ts` line 10-30 style):
```typescript
import type { Request, Response, NextFunction } from 'express';
import { timingSafeEqual } from 'node:crypto';
import { log } from './log.js';        // singleton pino — DO NOT call pino({...}) elsewhere
```

**Env-gated middleware pattern** (copy structure from `index.ts:111-139`):
```typescript
// index.ts:111-139 — Origin allowlist middleware. Same skeleton:
//  1. read an env-derived config once at factory time
//  2. if config absent (no ALLOWED_ORIGINS / no STAGING_MODE) → next() no-op
//  3. else apply gate, log denials at warn level, end response on reject
if (allowedOrigins.length > 0) {
  app.use('/api/auth', (req, res, next) => {
    const origin = req.headers.origin;
    if (!origin || !allowedOrigins.includes(origin)) {
      log.warn({ origin: origin ?? '<none>', path: req.path }, 'cors_origin_rejected');
      res.status(403).end();
      return;
    }
    // ...
    next();
  });
}
```

**Mount-order pattern** (Pitfall 9 in 05-RESEARCH.md): the new middleware must mount in `index.ts` BEFORE `createNodeMatchmakingMiddleware()` at `index.ts:100`. Reuse the same factory-then-`app.use()` shape the legacy-login pre-middleware uses at `index.ts:162-240`.

**Logging pattern**: every gate-deny path uses `log.warn({ ... }, 'event_name_snake_case')` exactly as `index.ts:115-118` does (`'cors_origin_rejected'`). New event name suggested: `'staging_invite_rejected'`. The pino redact list in `apps/server/src/log.ts:23-67` already covers `authorization` headers, so `req.headers.authorization` ends up `[Redacted]` automatically — no per-call sanitization needed.

---

### `apps/server/src/otel-init.ts` (service, event-driven)

**Analog (boot-time init wrapping a node module):** `apps/server/src/log.ts` (singleton init at module load) + `apps/server/src/room-key.ts` (boot-time keypair load).

**Imports pattern** (mirror `room-key.ts:14-29`):
```typescript
// room-key.ts mixes node:crypto with node:fs and exports a single boot helper.
// otel-init follows the same shape: pull SDK pieces from one ecosystem,
// wrap in one boot-time try/catch, return nothing (singleton side effect).
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
// ... (other OTel exporters per RESEARCH §Pattern 5)
```

**Singleton-init-with-fail-soft pattern** (mirror `log.ts:8-70` "create once, export const"):
```typescript
// log.ts:8 — `export const log = pino({...})` at module top level.
// CONTEXT D-12 says "failure to ship to OpenObserve is non-fatal — game server
// keeps logging to stdout." So otel-init wraps the SDK construction in a
// try/catch, logs the failure, and continues. NO export — purely side-effecty.
try {
  const sdk = new NodeSDK({ /* ... */ });
  sdk.start();
} catch (err) {
  console.warn('[otel-init] init failed; continuing without OTel:', err);
}
```

**SIGTERM hook pattern** (mirror `apps/server/src/sigterm.ts:208-247`):
```typescript
// sigterm.ts attaches handlers via process.on('SIGTERM', handler) at boot.
// otel-init mirrors that for sdk.shutdown(), but its hook is independent
// from the main grace-shutdown in sigterm.ts — order does not matter
// because OTel shutdown is metric/log flush only.
process.on('SIGTERM', async () => {
  try { await sdk.shutdown(); } catch (e) { console.warn('[otel-init] shutdown failed', e); }
});
```

**Loading pattern**: per RESEARCH §Pattern 5, this file is loaded via `node --import ./dist/otel-init.js dist/index.js` so auto-instrumentation patches `require()` BEFORE `index.ts` evaluates. The `docker-entrypoint.sh` carries that flag.

---

### `apps/server/scripts/run-migrations.ts` (script, batch)

**Analog:** `apps/server/scripts/migrate-legacy-accounts.ts` (one-shot CLI) + `apps/server/src/index.ts:372-408` (`bootstrapSchemaIfFresh` — Phase 5 retires this in favor of run-migrations.ts).

**Header banner pattern** (mirror `migrate-legacy-accounts.ts:1-30`):
```typescript
#!/usr/bin/env node
// [impl->REQ-DEP-01]
// One-shot migrator (CONTEXT D-09). Run by docker-entrypoint.sh BEFORE
// `node dist/index.js`. Failure → non-zero → container crashloop → no traffic.
//
// Usage: node dist/scripts/run-migrations.js
// Exit: 0 success, 1 failure.
```

**SQLite-open + pragma pattern** (copy verbatim from `apps/server/src/db.ts:12-23`):
```typescript
const sqlite = new Database(databaseUrl);
sqlite.pragma('journal_mode = WAL');     // Litestream prerequisite (CONTEXT D-15)
sqlite.pragma('synchronous = NORMAL');    // RESEARCH §Pitfall 6 — DO NOT change
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('busy_timeout = 5000');
```

**Migrate-folder-resolution pattern** (mirror `index.ts:383-396` walk-up-from-barrel logic):
```typescript
// index.ts:389-396 — resolve @rebno/db barrel, walk up to package root,
// then join 'migrations/0001_baseline.sql'. Same trick avoids CWD issues.
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
const dbBarrel = require_.resolve('@rebno/db');
const dbRoot = path.resolve(path.dirname(dbBarrel), '..');
migrate(db, { migrationsFolder: path.join(dbRoot, 'migrations') });
```

**Idempotency reconciliation** (Assumption A7 in 05-RESEARCH.md, identified hazard): if the volume already has a Phase-4 `bootstrapSchemaIfFresh`-applied schema (no `__drizzle_migrations` row), seed the migration row before calling `migrate(db, ...)`. The "check for sentinel table presence then act" pattern is from `index.ts:377-382`:
```typescript
// index.ts:377-382 — sentinel-table check
const has = sqlite
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
  .get();
// Phase 5 extension: if has && no __drizzle_migrations row → seed it first.
```

**Top-level `if (isMain)` pattern** (mirror `migrate-legacy-accounts.ts:205-215`):
```typescript
const isMain =
  import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}` ||
  import.meta.url === `file:///${process.argv[1]?.replace(/\\/g, '/')}`;
if (isMain) {
  main(process.argv).then((c) => process.exit(c)).catch((e) => { console.error(e); process.exit(1); });
}
```

---

### `scripts/soak-staging.mjs` (test harness, event-driven)

**Analog:** `apps/server/test/authority.integ.test.ts` lines 36-54 (`joinClient` helper) and the two-client setup pattern in the same file at lines 76-117.

**Two-client harness pattern** (extracted from `authority.integ.test.ts:76-92`):
```typescript
// authority.integ.test.ts:36-54 — joinClient helper. soak-staging.mjs
// extracts this into a plain async function (no vitest), parameterized
// by URL + invite token (Phase 5 D-04 STAGING_INVITE_TOKEN).
async function joinClient(wssUrl: string, invite: string) {
  const { Client } = await import('@colyseus/sdk');
  const client = new Client(wssUrl);
  const room = await client.joinOrCreate('rebno', {
    protocol_version: PROTOCOL_VERSION,
    invite,                                  // NEW — D-04 staging-invite gate
    session_token: '<real Better-Auth token>',
  });
  return { client, room, close: async () => { try { await room.leave(true); } catch {} } };
}
```

**Drive-input + chat cadence pattern** (mirror `authority.integ.test.ts:135-156`):
```typescript
// authority.integ.test.ts:135-156 — single-frame send + setTimeout wait.
// soak loops this at realistic cadence (D-11: move-input @ ~10Hz +
// chat-send @ ~0.1Hz + heartbeat @ ~0.07Hz) for SOAK_DURATION_MINUTES.
a.room.send('input', { type: 'input', seq: ++seq, dt_ms: 100, axis_x: 0, axis_y: 0, jump: false, action_btns: 0 });
```

**Assertions pattern** (mirror `authority.integ.test.ts:88-92` + `162-165`): collect events into an array, then assert via plain `if`/`throw new Error` rather than vitest `expect` (no vitest in `.mjs` script). Exit non-zero on first assertion failure to match Phase-4 lint script convention.

**File extension**: `.mjs` (not `.ts`) per CONTEXT.md `## Established Patterns` line 177 ("New Phase 5 scripts (`apps/server/docker-entrypoint.sh` is the only shell) live as `.mjs` Node scripts"). This is consistent with `apps/server/argon2-bench.mjs`.

---

### `scripts/verify-phase-5.mjs` (composite gate, batch)

**Analog:** `scripts/verify-phase-4.mjs` (exact match — copy file structure verbatim).

**Header banner** (mirror `verify-phase-4.mjs:1-13`):
```javascript
#!/usr/bin/env node
// scripts/verify-phase-5.mjs
// Source: Phase 5 DEP-01..DEP-08 composite verify gate.
// Sequential orchestrator running every Phase 5 lint + test + carry-over.
// First non-zero exit fails.
// Usage: node scripts/verify-phase-5.mjs   OR: pnpm verify:phase-5
// Exit: 0 success, 1 first-failed-step.
```

**Steps array shape** (copy structure from `verify-phase-4.mjs:30-53`):
```javascript
// verify-phase-4.mjs locks the order:
//   [0]   Phase N-1 carry-over (verify-phase-4)            ← anchor first
//   [1]   Workspace: typecheck                              ← fastest type safety net
//   [2-N] cheap regex lints (Phase 5: lint:deploy-stack)   ← WR-14 reorder
//   [-2]  Workspace: test                                   ← slowest
//   [-1]  Traceable-reqs: check                             ← anchor last
const steps = [
  ['Phase 4 carry-over: verify-phase-4', 'pnpm', ['verify:phase-4']],
  ['Workspace: typecheck',               'pnpm', ['-r', 'typecheck']],
  ['Lint: deploy-stack',                 'pnpm', ['lint:deploy-stack']],
  // ... (ADR 0005 + 0006 lints if shape requires)
  ['Workspace: test',                    'pnpm', ['-r', 'test']],
  ['Traceable-reqs: check',              'pnpm', ['trace:check']],
];
```

**Spawn pattern** (copy verbatim from `verify-phase-4.mjs:14-16, 56-74`):
```javascript
import { spawnSync } from 'node:child_process';
const isWindows = process.platform === 'win32';
// ... loop with shell: isWindows, stdio: 'inherit'
```

**First/last invariants** (mirror `verify-phase-4.test.mjs:79-87`): first step MUST be the prior phase's verify; last step MUST be `Traceable-reqs: check` (CLAUDE.md hard rule). The companion `verify-phase-5.test.mjs` asserts both.

---

### `scripts/verify-phase-5.test.mjs` (unit test for gate shape)

**Analog:** `scripts/verify-phase-4.test.mjs` (exact — copy file).

**Static-regex-extract pattern** (verbatim from `verify-phase-4.test.mjs:46-57`):
```javascript
const src = readFileSync('scripts/verify-phase-5.mjs', 'utf-8');
const m = src.match(/const steps\s*=\s*\[([\s\S]*?)\n\];/);
// ... extract labels via /^\s*\['([^']+)'/gm match-all
```

**EXPECTED_LABELS** locks the canonical Phase-5 order. Update both files together when adding/removing a step (this is the forcing function — same as Phase-4 D-25 lint pattern).

---

### `tools/scripts/lint-deploy-stack.mjs` (lint, static file scan)

**Analog:** `tools/scripts/lint-rate-limit-budgets.mjs` (exact match — same family).

**Imports + header banner pattern** (mirror `lint-rate-limit-budgets.mjs:1-9`):
```javascript
#!/usr/bin/env node
// tools/scripts/lint-deploy-stack.mjs
// Source: 05-CONTEXT.md D-01..D-21 — drift guard for fly.toml + Dockerfile +
// litestream.yml + OTel base URL across rebno-staging, rebno-prod, rebno-obs.
// Refusing silent infra changes via PR.
// Usage: node tools/scripts/lint-deploy-stack.mjs
// Exit: 0 in-sync, 1 drift detected.
import { readFileSync } from 'node:fs';
```

**Expected-set + violation-counter pattern** (mirror `lint-rate-limit-budgets.mjs:11-30`):
```javascript
// lint-rate-limit-budgets.mjs:11-17 declares an EXPECTED array of literals,
// loops checking each against the source via regex, increments `violations`.
// lint-deploy-stack.mjs uses the same pattern but checks across multiple
// files (per RESEARCH §"lint-deploy-stack.mjs skeleton" lines 757-784):
const errors = [];
const flyStaging = readFileSync('apps/server/fly.staging.toml', 'utf-8');
const flyProd = readFileSync('apps/server/fly.prod.toml', 'utf-8');
const dockerfile = readFileSync('apps/server/Dockerfile', 'utf-8');
const litestream = readFileSync('apps/server/litestream.yml', 'utf-8');
if (!flyStaging.includes('STAGING_MODE = "1"')) errors.push('fly.staging.toml missing STAGING_MODE=1');
if (flyProd.includes('STAGING_MODE')) errors.push('fly.prod.toml MUST NOT set STAGING_MODE');
// ... (full ruleset in RESEARCH.md lines 769-777)
if (errors.length) { errors.forEach(e => console.error('lint-deploy-stack:', e)); process.exit(1); }
console.log('lint-deploy-stack: OK');
```

**Exit pattern** (verbatim from `lint-rate-limit-budgets.mjs:28-30`): `process.exit(1)` on any violation; `console.log('lint-...: OK')` on success. Plug into `package.json` scripts as `"lint:deploy-stack": "node tools/scripts/lint-deploy-stack.mjs"`.

---

### `.github/workflows/deploy-staging.yml` (CI, event-driven push)

**Analog:** `.github/workflows/verify-phase-4.yml` (skeleton — extend with build+deploy steps from RESEARCH §Pattern 6).

**Header + trigger pattern** (mirror `verify-phase-4.yml:1-26`):
```yaml
# .github/workflows/deploy-staging.yml
# Source: Phase 5 DEP-04 — push-to-main → staging deploy.
# Linux determinism reference platform (verify-phase-4.yml precedent).
name: deploy-staging
on:
  push:
    branches: [main]
    paths:
      - 'apps/server/**'
      - 'apps/obs/**'
      - 'packages/**'
      - 'tools/**'
      - 'pnpm-workspace.yaml'
      - 'pnpm-lock.yaml'
      - '.github/workflows/deploy-staging.yml'
```

**Job preamble pattern** (verbatim from `verify-phase-4.yml:38-52`):
```yaml
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 10 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: 'pnpm' }
      - name: Install workspace
        run: pnpm install --frozen-lockfile
```

**Verify-before-deploy gate**: insert `pnpm verify:phase-4` + `pnpm verify:phase-5` + `pnpm trace:check` BEFORE the build step. Reuses the same `pnpm verify:phase-N` pattern as `verify-phase-4.yml:55-56`.

**Build + deploy pattern**: the action steps that don't exist in any analog come verbatim from RESEARCH §Pattern 6 lines 482-505 — `superfly/flyctl-actions/setup-flyctl@<sha>` + `flyctl auth docker` + `docker buildx build --push` + `flyctl deploy --image <sha>`. SHA-pin per CONTEXT D-12 supply-chain stance.

---

### `.github/workflows/deploy-prod.yml` (CI, tag-triggered)

**Analog:** `.github/workflows/verify-phase-4.yml` (skeleton) + RESEARCH §Pattern 8 lines 552-577 (deploy body).

**Trigger pattern**: `on: push: tags: ['v*.*.*']` (NEW — no in-repo analog has tag triggers; pulled from RESEARCH).

**Image-SHA-resolution pattern** (verbatim from RESEARCH §Pattern 8 lines 567-569):
```yaml
- name: Resolve image SHA from tag
  id: sha
  run: |
    TAG_SHA=$(git rev-parse ${{ github.ref_name }}^{commit})
    echo "image=registry.fly.io/rebno-staging:${TAG_SHA}" >> "$GITHUB_OUTPUT"
```

Otherwise mirrors `deploy-staging.yml` shape (checkout + flyctl-action + flyctl deploy).

---

### `.github/workflows/trace-check.yml` (PR-required check)

**Analog:** `.github/workflows/verify-phase-4.yml` (mirror trigger + install steps; replace verify step with `pnpm trace:check`).

**Trigger** (use both `pull_request` and `push` like `verify-phase-4.yml:12-35`):
```yaml
on:
  pull_request:
    paths: ['**/*.ts', '**/*.md', 'traceable-reqs.toml']
  push:
    branches: [main]
```

**Body**: install workspace (same 4-step pattern from `verify-phase-4.yml:40-52`) → `run: pnpm trace:check`. This is the D-06 hard-gate that closes Phase 4 carry-forward DEP-04.

---

### `.github/workflows/soak-staging.yml` (CI, dispatch + cron)

**Analog:** `.github/workflows/verify-phase-4.yml` (preamble) + RESEARCH §Pattern 7 lines 514-544 (dispatch+cron+concurrency body).

**Trigger pattern** (verbatim from RESEARCH lines 515-520):
```yaml
on:
  workflow_dispatch:
    inputs:
      sha: { required: true, type: string }
  schedule:
    - cron: '0 6 * * *'
concurrency:
  group: soak-staging
  cancel-in-progress: true
```

**Body**: install steps copied from `verify-phase-4.yml:40-52`, then `run: pnpm soak:staging` with env vars `STAGING_WSS_URL` / `STAGING_INVITE_TOKEN` / `SOAK_DURATION_MINUTES`.

---

### `.github/workflows/verify-phase-5.yml` (PR/push verify gate)

**Analog:** `.github/workflows/verify-phase-4.yml` (exact — copy verbatim, change `4` → `5`, update path filters to include Phase-5 files).

**Path filters** (mirror `verify-phase-4.yml:13-25`, swap):
```yaml
paths:
  - 'apps/server/**'
  - 'apps/obs/**'
  - 'packages/**'
  - 'tools/scripts/**'
  - 'docs/adr/0005-*.md'
  - 'docs/adr/0006-*.md'
  - 'pnpm-workspace.yaml'
  - 'package.json'
  - 'scripts/verify-phase-5.mjs'
  - 'scripts/verify-phase-5.test.mjs'
  - '.github/workflows/verify-phase-5.yml'
```

**Body**: identical to `verify-phase-4.yml:38-61` — checkout + pnpm/setup-node@v4 + install + `pnpm verify:phase-5` + `git diff --exit-code` post-condition.

---

### `docs/adr/0005-deploy-topology.md` (ADR)

**Analog:** `docs/adr/0002-persistence-layer.md` (close — operational decision with multi-phase impact) + `docs/adr/0004-room-hot-reload.md` (close — short, decision-locked, locks Phase-5 contract).

**Section structure pattern** (verbatim from `0004-room-hot-reload.md:1-180`):
```markdown
# ADR 0005: Deploy Topology — Two-App Fly Split + Litestream + Tigris

[doc->REQ-DEP-01] [doc->REQ-DEP-02] [doc->REQ-DEP-03]    # tag every requirement this ADR documents

**Date:** 2026-05-08
**Phase:** 05 (during planning)

## Status
**Accepted** — locked at start of Phase 5 planning. Re-evaluation gates listed
in **Forcing Functions for Re-Open** below.
Supersedes: nothing. Superseded by: nothing.

## Context
[Records D-01..D-04: two-app split, region lax, staging IP allowlist + invite,
volume mount layout. Reference 05-CONTEXT.md decisions section verbatim.]

## Decision
[3-5 paragraphs. Mirror 0004:46-91 — narrative naming each lock.]

## Consequences
### Positive
[Mirror 0004:96-107 — 3-5 bullets.]
### Negative
[Mirror 0004:110-130 — numbered list with concrete drift-risk mitigations.]
### Neutral
[Mirror 0004:131-136.]

## Forcing Functions for Re-Open
[Mirror 0004:138-153 — exactly 4 numbered triggers; each cites the failure
mode that re-opens this ADR.]

## References
[Mirror 0004:155-179 — link every codebase file + planning doc.]
```

**Trace tag pattern**: line 3 of `0004-room-hot-reload.md` carries `[doc->REQ-SRV-13]`. ADR 0005 carries `[doc->REQ-DEP-01]`, `[doc->REQ-DEP-02]`, `[doc->REQ-DEP-03]` — one tag per requirement this ADR closes the `doc` stage for.

**ADR lint** (`pnpm lint:adr:0005`) — add to `package.json` scripts mirroring `"lint:adr:0004": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0004-room-hot-reload.md --no-matrix"` at `package.json:25`. Then add `'lint:adr:0005'` step to `verify-phase-5.mjs` steps array.

---

### `docs/adr/0006-observability-stack.md` (ADR)

**Analog:** `docs/adr/0004-room-hot-reload.md` (exact — short Phase-5 lock-in ADR).

Same structure as 0005 above. Trace tag: `[doc->REQ-DEP-06]`. Records D-12..D-16 (OpenObserve self-hosted, dual-rail logs, signals = logs+metrics+traces, IP-allowlist UI, log levels). Forcing Functions: cost overrun, OpenObserve EOL, OTel SDK breaking change. ADR lint script: `lint:adr:0006`.

---

### `docs/runbooks/RESTORE.md` or `RESTORE.md` (runbook)

**Analog:** No in-repo runbook exists. The closest pattern is the ADR `## Forcing Functions for Re-Open` numbered-list shape from `0004-room-hot-reload.md:138-153` — a runbook is similar (numbered procedures with concrete commands).

**Section structure pattern** (derived from CONTEXT.md `<specifics>` + RESEARCH.md §"Validation Architecture"):
- `## Prerequisites` — `fly auth login`, FLY_API_TOKEN scope, operator IP in allowlist
- `## Cold Restore (<5min target)` — numbered shell-block recipe
- `## Point-in-Time Replay`
- `## Per-Env Initial Setup` — 6-step ritual: `fly apps create` → `fly volumes create` → `fly storage create` → `fly secrets set BETTER_AUTH_SECRET` → seed legacy creds via `fly ssh sftp` (D-17 verbatim) → `fly deploy`
- `## Phase-4 Carry-Forward Verification` — three named acceptance steps:
  - **Test 1: kill -9 mid-tick** (verbatim 6-step procedure from `.planning/phases/04-server-rebuild-mvp/04-09-SUMMARY.md` §"Manual Verification (Phase 5 Debt)")
  - **Test 2: multi-client move+chat smoke** (the 30-min soak; `04-HUMAN-UAT.md` Test 2)
  - **Test 3: argon2 prod-hardware bench** (`apps/server/argon2-bench.mjs` re-run; bump `memoryCost` 65536→131072 if mean < 200ms; `04-HUMAN-UAT.md` Test 3)
- `## Secret Rotation` — per-secret table: BETTER_AUTH_SECRET, STAGING_INVITE_TOKEN, ZO_ROOT_USER_PASSWORD, Tigris keys, Ed25519 keypair (D-21)
- `## Combined Rollback (Bad Migration + Bad Image)` — `flyctl deploy --image <prior-sha>` + `litestream restore -timestamp <ts>`

**Path decision (Claude's discretion per CONTEXT.md `<code_context>` line 199):** place at `docs/runbooks/RESTORE.md` so future Phase-7 runbooks (PAR-07 admin UI deploy, PAR-05 .bnu migration ritual) live in the same directory. Repo-root `RESTORE.md` is also acceptable but mixes runbooks with project metadata.

---

### `apps/server/Dockerfile` (no in-repo analog — greenfield)

No existing Dockerfile in repo. Use **RESEARCH §Pattern 1 lines 201-243 verbatim** as the source. Key locks:
- Base: `node:22-bookworm-slim` (NOT alpine — Pitfall 1)
- 3-stage: builder → litestream → runtime
- COPY `litestream` binary from `litestream/litestream:0.3.13` AS litestream
- `dumb-init` as PID 1
- `chmod +x docker-entrypoint.sh`

**Header tag**: `# [doc->REQ-DEP-01]` on line 1 per `traceable-reqs.toml` contract (CLAUDE.md tagging contract — Markdown/comment tag form).

---

### `apps/server/docker-entrypoint.sh` (no in-repo analog — greenfield)

No existing shell script in repo (CONTEXT.md line 177 explicitly: "`apps/server/docker-entrypoint.sh` is the only shell"). Use **RESEARCH §Pattern 2 lines 250-284 verbatim**. Key locks:
- `set -e` first line
- 6-step sequence: restore-if-empty → pre-migrate snapshot → drizzle migrate → litestream replicate & → trap SIGTERM → exec node
- `exec` on the final line is mandatory (Pitfall 2 — SIGTERM delivery)
- Header comment: `# [doc->REQ-DEP-01] [doc->REQ-DEP-03]`

---

### `apps/server/litestream.yml` (no in-repo analog — greenfield)

Use **RESEARCH §Pattern 3 lines 290-315 verbatim**. Key locks:
- `sync-interval: 1s` (D-03 RPO)
- Tigris endpoint via `${AWS_ENDPOINT_URL_S3}` + `${BUCKET_NAME}` (auto-injected by `fly storage create`)
- Header tag: `# [doc->REQ-DEP-03]`

---

### `apps/server/fly.staging.toml` + `apps/server/fly.prod.toml` (no in-repo analog — greenfield)

Use **RESEARCH §Pattern 4 lines 327-384 verbatim** for `fly.staging.toml`. `fly.prod.toml` is the same structure with these diffs:
- `app = "rebno-prod"`
- DROP `STAGING_MODE` (must be absent — `lint-deploy-stack.mjs` enforces)
- `LOG_LEVEL = "info"` (D-16)
- `OTEL_RESOURCE_ATTRIBUTES = "...deployment.environment=production"`
- `ALLOWED_ORIGINS = "https://rebno.decidel.com"`

Both files: header tag `# [doc->REQ-DEP-02] [doc->REQ-DEP-05]`. `auto_stop_machines = "off"`, `min_machines_running = 1`, mount `/data`, http_check on `/health` every 10s.

---

### `apps/obs/Dockerfile` + `apps/obs/fly.toml` (third Fly app)

Once `apps/server/Dockerfile` is written it becomes the analog. Differences:
- `FROM public.ecr.aws/zinclabs/openobserve:v0.14.x` — single FROM, no multi-stage
- `apps/obs/fly.toml`: app `rebno-obs`, region `lax`, mount `/data`, env vars `ZO_S3_*` per RESEARCH §Architectural Map line 31
- `ZO_ROOT_USER_PASSWORD` set via `fly secrets set` (D-15) — never in fly.toml
- Listens 5080 (HTTP+OTLP) — gated to flycast (private 6PN), no public port
- Header tag: `# [doc->REQ-DEP-06]`

---

## Shared Patterns

### Trace tag (every artifact)
**Source:** `traceable-reqs.toml` + CLAUDE.md "## Tagging contract"
**Apply to:** Every Phase-5 artifact (Markdown, TS, mjs, sh, yml, Dockerfile, toml).
**Markdown form:** `[<doc>->REQ-DEP-NN]` on a line by itself or inline (e.g. line 3 of `0004-room-hot-reload.md`)
**Code form:** `// [<impl>->REQ-DEP-NN]` (TS), `# [<doc>->REQ-DEP-NN]` (yml/toml/Dockerfile/sh)
**Multiple per line allowed:** `// [impl->REQ-DEP-01] [impl->REQ-DEP-03]`
**Verification:** `pnpm trace:check` (D-06 hard-gate)

### pino logger (singleton import only)
**Source:** `apps/server/src/log.ts` (entire file)
**Apply to:** All new TS files in `apps/server/src/` that emit log lines (`staging-invite.ts`, plus any logging in `otel-init.ts`).
**Concrete excerpt** (`log.ts:8-70`): the singleton `export const log = pino({...redact: { paths: [...] }})` — DO NOT call `pino({...})` elsewhere; `redact` covers `authorization`, `password`, `legacy_hash`, `session_token` etc. through 3 levels of nesting.

### SQLite open + WAL pragmas
**Source:** `apps/server/src/db.ts` lines 12-23
**Apply to:** `apps/server/scripts/run-migrations.ts` (must set the same pragmas BEFORE `migrate()` to avoid Pitfall 8).
**Excerpt:**
```typescript
const sqlite = new Database(databaseUrl);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('synchronous = NORMAL');
sqlite.pragma('foreign_keys = ON');
sqlite.pragma('busy_timeout = 5000');
```

### Boot-time fail-closed env validation
**Source:** `apps/server/src/env.ts` lines 17-36 (zod schema) + `apps/server/src/index.ts:51-79` (production guard)
**Apply to:** Phase-5 introduces no new env-loader code (env.ts is the singleton), but `staging-invite.ts` reads `process.env.STAGING_MODE` and `STAGING_INVITE_TOKEN` directly. Pattern: factory accepts `env = process.env` as a parameter so tests can inject (mirrors `loadEnv(raw = process.env)` at `env.ts:51`).

### `.mjs` lint scripts (drift guards)
**Source:** `tools/scripts/lint-rate-limit-budgets.mjs` (entire file — 30 lines)
**Apply to:** `tools/scripts/lint-deploy-stack.mjs`
**Excerpt structure** (lines 1-30): shebang → header banner with tag → `import { readFileSync }` → `EXPECTED` array → loop with regex check → `process.exit(1)` on violation, `console.log('lint-...: OK')` on success.

### CI workflow preamble
**Source:** `.github/workflows/verify-phase-4.yml` lines 38-52
**Apply to:** All 5 new `.yml` workflows.
**Excerpt:**
```yaml
runs-on: ubuntu-latest
steps:
  - uses: actions/checkout@v4
  - uses: pnpm/action-setup@v4
    with: { version: 10 }
  - uses: actions/setup-node@v4
    with: { node-version: 22, cache: 'pnpm' }
  - name: Install workspace
    run: pnpm install --frozen-lockfile
```

### Composite-gate orchestrator
**Source:** `scripts/verify-phase-4.mjs` lines 14-74 (entire file)
**Apply to:** `scripts/verify-phase-5.mjs`
**Excerpt (anchors):** first step = previous-phase carry-over; last step = `Traceable-reqs: check`. CLAUDE.md hard rule. The companion `verify-phase-N.test.mjs` (mirrors `verify-phase-4.test.mjs:79-87`) asserts both anchors.

### ADR document structure
**Source:** `docs/adr/0004-room-hot-reload.md` (entire file — 179 lines)
**Apply to:** `docs/adr/0005-deploy-topology.md` and `docs/adr/0006-observability-stack.md`
**Sections:** Status / Context / Decision / Consequences (Positive/Negative/Neutral) / Forcing Functions for Re-Open (numbered) / References (link every codebase file + planning doc cited).
**Tag form:** `[<doc>->REQ-DEP-NN]` on line 3.
**Lint:** add `lint:adr:000N` script to `package.json` mirroring line 25, plug into `verify-phase-5.mjs` steps array.

## No Analog Found

These files have no close in-repo match — planner uses RESEARCH.md patterns directly:

| File | Role | Reason | Source |
|------|------|--------|--------|
| `apps/server/Dockerfile` | container build | No Dockerfile exists in repo yet | RESEARCH §Pattern 1 |
| `apps/server/docker-entrypoint.sh` | shell entrypoint | Only shell script in entire codebase | RESEARCH §Pattern 2 |
| `apps/server/litestream.yml` | replicator config | No prior Litestream config | RESEARCH §Pattern 3 |
| `apps/server/fly.staging.toml` + `fly.prod.toml` | Fly config | First Fly deploy | RESEARCH §Pattern 4 |
| `apps/obs/*` | third Fly app | Greenfield (OpenObserve) | RESEARCH §Architectural Responsibility Map + §Pattern 4 (mirror) |
| `docs/runbooks/RESTORE.md` | runbook | No prior runbooks; only ADRs | RESEARCH §Validation Architecture + Phase-4 04-09-SUMMARY.md kill -9 procedure |

For each, the planner copies the concrete code/config block from `.planning/phases/05-deploy/05-RESEARCH.md` at the cited line range.

## Metadata

**Analog search scope:**
- `apps/server/src/` (all 16 .ts files surveyed; pulled patterns from `index.ts`, `health.ts`, `sigterm.ts`, `db.ts`, `env.ts`, `log.ts`, `room-key.ts`)
- `apps/server/scripts/` (`migrate-legacy-accounts.ts`)
- `apps/server/test/` (pulled `authority.integ.test.ts` joinClient harness)
- `scripts/` (`verify-phase-4.mjs` + `.test.mjs`)
- `tools/scripts/` (`lint-rate-limit-budgets.mjs` family)
- `.github/workflows/` (`verify-phase-4.yml`)
- `docs/adr/` (`0002`, `0004`)

**Files scanned:** ~30
**Pattern extraction date:** 2026-05-08
