# Phase 06.5: Static Client Asset Split — Research

**Researched:** 2026-05-16
**Domain:** CI/CD pipeline split + Fly.io static asset deployment + Linux symlink atomicity + Express static serving
**Confidence:** HIGH (mechanics) / MEDIUM (race-condition edge cases) / LOW (production volume capacity)

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Infrastructure:**
- Reuse existing Fly app and existing persistent volume — no new host, bucket, CDN, or paid service.
- Release directory layout: `/data/client-assets/releases/<git-sha>/` (content-addressed by `GITHUB_SHA`).
- Pointer: `/data/client-assets/current` is a symlink to the active release dir.
- Atomic swap via `ln -sfn` (rename semantics).
- Garbage collection: keep last 5 releases, delete older (`ls -1dt … | tail -n +6 | xargs -r rm -rf`).

**Server Behavior:**
- Change `apps/server/src/index.ts` static mount from hard-coded `public` to fallback chain:
  1. If `STATIC_ASSETS_DIR` env is set AND directory exists → serve that directory.
  2. Else → serve `join(__dirname, '..', 'public')` (bundled fallback).
- Bundled `/app/public` MUST remain in the server image as the cold-start fallback.
- Fly staging + prod `fly.toml` `[env]` adds `STATIC_ASSETS_DIR = "/data/client-assets/current"`.

**Build & Package:**
- Client built with `pnpm --filter @rebno/client build:staging`.
- Tarball: `tar -czf client-assets-${GITHUB_SHA}.tgz -C apps/server/public .` (Vite output already lands there).
- Upload via `flyctl ssh sftp` (or `flyctl ssh console` extraction equivalent).
- Extract to `/data/client-assets/releases/${GITHUB_SHA}` then symlink-switch.

**CI Workflow Split:**
- Client-only fast path triggers when the diff touches ONLY: `apps/client/**` and packages used only by the client (resolved at planning time).
- Full image path triggers when the diff touches ANY of: `apps/server/**`, `packages/protocol/**`, `packages/game-logic/**`, `packages/db/**`, `Dockerfile`, `fly.toml`/Fly config, `pnpm-lock.yaml`, GitHub Actions workflow files.
- Mixed diffs → full image path. Fast path is opt-in by exclusion.

**Health Checks (post-deploy, all three required):**
- `/` returns the new `index.html`.
- One hashed JS asset from the Vite manifest returns `200`.
- `/healthz` returns `200`.
- On any failure: do NOT swap symlink (or revert symlink if already swapped — planner decision).

**Rollback:**
- Single command: `ln -sfn /data/client-assets/releases/<previous-sha> /data/client-assets/current`.
- Document the runbook in `docs/deploy/` alongside the split-plan doc.

**Traceability:**
- This phase touches `REQ-DEP-01`, `REQ-DEP-04`, `REQ-CLI-08` per source doc tags.

### Claude's Discretion

- Exact GitHub Actions YAML structure (matrix vs separate workflows vs job-level conditionals).
- Path-filter implementation (`dorny/paths-filter` action vs custom `git diff` script).
- Where to centralize the static-asset-path resolver (helper module in `apps/server/src/static-assets.ts` vs inline in `index.ts`).
- Tarball staging location on the Fly machine (`/tmp/` vs a dedicated upload dir).
- Health-check command shape (curl-from-machine vs flyctl-driven external probe).
- GC threshold tuning (5 is doc default; planner may revisit).
- Symlink swap ordering relative to health checks (swap-then-check vs stage-then-check-then-swap).
- Concurrency / lock handling if two client-only deploys race (advisory file lock on the volume vs CI-side mutex).
- Logging / telemetry on which path the release came through (image vs volume).

### Deferred Ideas (OUT OF SCOPE)

- Cloudflare Pages / Tigris bucket / CDN migration — only after volume-backed split proves the workflow shape.
- Asset integrity / signing (HTTPS-only baseline assumed).
- Multi-region or multi-machine fan-out (single-machine Fly target preserved).
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-DEP-01 | Multi-stage Dockerfile builds Alpine/musl-compatible argon2 + better-sqlite3 | Existing Dockerfile already satisfies; this phase MUST NOT regress. The bundled `/app/public` fallback layer (Dockerfile line 57) stays. See `## Architecture Patterns`. `required_stages = ["doc", "impl", "int"]`. |
| REQ-DEP-04 | GitHub Actions: push to main → build → test → deploy | New client-only fast-path workflow + path-filter split is the core deliverable. See `## Standard Stack` → `dorny/paths-filter`, `## Code Examples` → CI YAML. `required_stages = ["doc", "impl", "int"]`. |
| REQ-CLI-08 | MVP GATE: two players join, move, chat over deployed server | Indirectly satisfied — the existing `deploy-staging.yml` Playwright cli-08 smoke continues to gate full-path deploys; the fast-path adds the cheap 3-check probe in lieu of the full smoke. Planner must confirm cli-08 smoke runs on at least one full-path deploy per release cycle. `required_stages = ["doc", "int"]`. |
</phase_requirements>

## Summary

The split is fundamentally a CI/CD restructuring with a small server-side fallback change. The mechanics are well-trodden (capistrano-style release directories with symlink swap have been a deployment standard since the early 2000s). Three areas need careful attention:

1. **`ln -sfn` is NOT atomic on Linux** — this is the single biggest pitfall hiding in the PRD. `ln -sfn` calls `unlink(2)` then `symlink(2)`, leaving a brief window where `/data/client-assets/current` doesn't exist. Under that window, `express.static` will 404 every request. The canonical atomic pattern is `ln -s <target> <newpath>.tmp && mv -T <newpath>.tmp <newpath>` because `mv -T` calls `rename(2)` which atomically replaces the destination on ext4 (Fly volumes are ext4). [VERIFIED: Linux rename(2) manpage, Tom Moertel "How to change symlinks atomically", Artem Chistyakov "Atomic symlinks"]

2. **`express.static` follows the symlink at request-time, not at mount-time** — so the symlink swap is "live" to running middleware without restart. There is no internal cache that needs invalidating. `serve-static` calls `fs.stat`/`fs.createReadStream` per request, both of which resolve symlinks on each call. [CITED: expressjs/serve-static source; CITED: Node fs docs]

3. **`tj-actions/changed-files` was compromised in March 2025** — avoid it. Use `dorny/paths-filter@v3` (SHA-pinned per project D-12 supply-chain stance). [VERIFIED: Snyk, Wiz, Cycode blog posts CVE-2025-30066]

**Primary recommendation:** Stage the new release dir, run health checks against the staged release URL (or against `/<sha>/` path), then atomic-swap via `mv -T`, then run a final post-swap probe. This ordering means a broken release never becomes "current," eliminating the auto-revert complexity. Use `dorny/paths-filter@v3` (SHA-pinned) for the path gate. Use `flyctl ssh sftp put -R` for the tarball upload, then a `flyctl ssh console -C "<extract-script>"` invocation for the extract+swap.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Path-changed-files filtering | CI (GitHub Actions) | — | Runs once per push before any work; cannot be deferred to the machine. |
| Tarball build + upload | CI (GitHub Actions runner) | — | Build artifact production lives in the same place the existing client build runs. |
| Release directory extraction | Fly machine (shell over `flyctl ssh`) | — | Volume is only writable from inside the machine. |
| Symlink swap | Fly machine (shell over `flyctl ssh`) | — | Must be local to the volume. |
| Cold-start fallback | Server runtime (Node `apps/server/src/index.ts`) | — | When `STATIC_ASSETS_DIR` is unset or dir is missing, the server picks bundled `/app/public`. |
| Static-asset resolution at request time | Server runtime (`express.static`) | — | OS resolves the symlink per request — no app-layer cache. |
| Post-deploy health checks | CI (GitHub Actions) calling deployed URL | Fly machine (curl-from-machine alternative) | Either tier works; planner picks. CI-driven is simpler — re-uses GitHub's egress + secret store. |
| Garbage collection of old releases | Fly machine (shell over `flyctl ssh`, post-swap) | — | Must operate on the volume; serialize after successful swap. |
| Concurrency / mutex between racing deploys | CI (GitHub Actions `concurrency:` block) | Fly machine (flock advisory) | Existing `deploy-staging.yml` already has `concurrency: deploy-staging`; reuse pattern. |

## Standard Stack

### Core
| Library / Tool | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `dorny/paths-filter` | v3 (SHA-pin) | Detect whether the diff is client-only vs touches server/packages | Most-recommended path filter on the GH Actions marketplace; uses `picomatch`; no known supply-chain incidents [VERIFIED: GitHub Marketplace, Snyk supply-chain report] |
| `flyctl` | already installed (v0.4.49 per `flyctl version`) | SSH/SFTP into Fly machine, run extract/swap scripts | Project already uses `superfly/flyctl-actions/setup-flyctl@fc53c09` SHA-pinned in deploy-staging.yml |
| GNU `mv -T` (coreutils) | bundled in `node:22-bookworm-slim` | Atomic symlink swap via `rename(2)` | Single syscall, ext4-supported; `ln -sfn` is NOT atomic [VERIFIED: rename(2) manpage] |
| `tar` | bundled in runner + image | Package + extract static output | Already used in standard Linux CI patterns |
| `express.static` (built-in to `express` 4.x already in `apps/server`) | already installed | Serve static files with symlink follow-through | Default behavior is to follow symlinks; no cache between requests [CITED: expressjs/serve-static] |

**Version verification (npm view dates):**
- `dorny/paths-filter@v3` — latest minor tag (verified 2026-05-16). Pin via `gh api repos/dorny/paths-filter/git/refs/tags/v3 --jq .object.sha` to capture the resolved commit SHA, matching the project D-12 supply-chain SHA-pin convention.

### Supporting
| Library / Tool | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `flock` (util-linux) | bundled in bookworm-slim | Advisory file lock for machine-side race protection | Only if planner decides CI-side `concurrency:` is insufficient |
| `curl` | bundled in runner | Post-deploy `/`, `/<hashed-asset>`, `/healthz` probes from CI | Default choice for cheap health probe |
| `jq` | bundled in runner | Parse `apps/server/public/.vite/manifest.json` to pick a deterministic hashed asset for the probe | The manifest is tiny (1 entry currently — see `## Code Examples`) |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `dorny/paths-filter` | `tj-actions/changed-files` | **REJECTED** — compromised in March 2025 (CVE-2025-30066), supply-chain risk [VERIFIED: Snyk, Wiz] |
| `dorny/paths-filter` | Custom `git diff --name-only origin/main...HEAD \| grep -v apps/client` | More fragile on first-push-to-branch (no base ref) and on squash merges; reinventing the action's edge-case handling |
| `flyctl ssh sftp put` | `flyctl ssh console -C "curl <CI-hosted-tarball-url>"` | sftp keeps the tarball private (no public hosting needed); console+curl needs a temporary signed URL on object storage = new infra = violates zero-extra-cost constraint |
| `flyctl ssh sftp put -R apps/server/public/` (recursive tree) | tarball + `tar -xzf` on machine | Tarball is fewer round-trips, preserves modification times atomically, and the extract is local to the volume (faster). Recursive sftp issues an SFTP request per file (slow for 100+ asset files). |
| `ln -sfn` | `ln -s … <tmp> && mv -T <tmp> <current>` | `ln -sfn` is NOT atomic — has a brief window where the destination doesn't exist. `mv -T` invokes `rename(2)` and is atomic on ext4. **MUST USE the `mv -T` pattern.** [VERIFIED: Linux rename(2) manpage, Capistrano issue #346] |
| CI-driven health probe (curl from GH runner) | Machine-driven probe (`flyctl ssh console -C 'curl localhost'`) | CI-driven runs against the public Fly URL — tests the full HTTPS + Fly proxy + Node path. Machine-driven skips the proxy. Pick CI-driven for end-to-end realism. |

**Installation:** Nothing to install in package.json. `dorny/paths-filter` is invoked via GitHub Actions YAML.

## Architecture Patterns

### System Architecture Diagram

```
┌──────────────────────────────────────────────────────────────────────┐
│ GitHub Actions (push to main)                                        │
│                                                                      │
│   ┌──────────────────────────┐                                       │
│   │ path-filter job          │   ← dorny/paths-filter@v3             │
│   │  - reads diff vs base    │                                       │
│   │  - emits `is_client_only`│                                       │
│   └──────────┬───────────────┘                                       │
│              │                                                       │
│       ┌──────┴─────────────┐                                         │
│       │                    │                                         │
│   is_client_only=true   is_client_only=false                         │
│       │                    │                                         │
│       ▼                    ▼                                         │
│   ┌─────────────────┐  ┌────────────────────────────────────────┐    │
│   │ fast-path job   │  │ full-path job (existing deploy-staging)│    │
│   │  - vite build   │  │  - vite build → apps/server/public/    │    │
│   │  - tar -czf     │  │  - docker buildx + push                │    │
│   │  - fly sftp put │  │  - flyctl deploy --image …             │    │
│   │  - fly ssh:     │  │  - playwright cli-08 smoke             │    │
│   │     extract +   │  └────────────────────────────────────────┘    │
│   │     stage +     │                                                │
│   │     check +     │                                                │
│   │     swap        │                                                │
│   │  - curl probes  │                                                │
│   │  - fly ssh: GC  │                                                │
│   └────────┬────────┘                                                │
│            │                                                         │
└────────────┼─────────────────────────────────────────────────────────┘
             │ (1) tarball via flyctl ssh sftp put
             │ (2) extract+swap script via flyctl ssh console -C
             │ (3) HTTPS probes from runner → https://staging.rebno...
             ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Fly machine (rebno-staging / rebno-prod)                             │
│                                                                      │
│   ┌────────────────────────────────────────────────────────────┐     │
│   │ Persistent volume mounted at /data (ext4)                  │     │
│   │                                                            │     │
│   │   /data/client-assets/                                     │     │
│   │     ├── releases/                                          │     │
│   │     │   ├── <sha-N-4>/  (oldest kept)                      │     │
│   │     │   ├── <sha-N-3>/                                     │     │
│   │     │   ├── <sha-N-2>/                                     │     │
│   │     │   ├── <sha-N-1>/                                     │     │
│   │     │   └── <sha-N>/    (newest)                           │     │
│   │     └── current  →  releases/<sha-N>/   (symlink)          │     │
│   └────────────────────────┬───────────────────────────────────┘     │
│                            │                                         │
│                            │ resolved per-request                    │
│                            ▼                                         │
│   ┌────────────────────────────────────────────────────────────┐     │
│   │ Node server (apps/server)                                  │     │
│   │   express.static( resolveStaticDir() )                     │     │
│   │     • if STATIC_ASSETS_DIR set AND exists → use it         │     │
│   │     • else fallback to /app/public (baked into image)      │     │
│   └────────────────────────────────────────────────────────────┘     │
│                                                                      │
│   On cold start (fresh machine, volume empty):                       │
│     resolveStaticDir() → /data/client-assets/current does NOT exist  │
│       → falls back to bundled /app/public → page still loads         │
└──────────────────────────────────────────────────────────────────────┘
```

### Recommended Project Structure

```
apps/server/src/
├── static-assets.ts         # NEW: resolveStaticDir() helper — single source of truth
└── index.ts                 # MODIFIED: app.use(express.static(resolveStaticDir(env, log)))

apps/server/test/
└── static-assets.test.ts    # NEW: unit tests for the env→path fallback chain
└── static-assets.integ.test.ts  # NEW: integration — boot server with permutations of env + dir presence

.github/workflows/
├── deploy-staging.yml       # MODIFIED: add job-level `if: needs.changes.outputs.client_only != 'true'`
│                            #   for the docker buildx + flyctl deploy + cli-08 smoke steps;
│                            #   add a new client-only-fast-path job that runs when client_only == 'true'.
│                            # (Alternative: separate file deploy-staging-client.yml — planner picks.)
├── deploy-prod.yml          # MODIFIED: prod still uses tag-push trigger; add documentation that the
│                            #   client-only fast path is staging-only or also wired to prod via a
│                            #   second tag style (e.g. v*.*.*-client). Planner picks scope.
└── _path-filters.yml        # OPTIONAL NEW: reusable workflow file containing the dorny/paths-filter
                             #   config so both staging + prod workflows can call it.

scripts/                     # NEW
├── deploy-client-only.sh    # OPTIONAL: encapsulate the "ssh sftp put + ssh console extract + probes
                             #   + GC" sequence so the workflow YAML stays short.
└── client-release.sh        # OPTIONAL: the script that runs ON THE MACHINE — extract, swap, GC.
                             #   Cleaner to keep machine-side logic versioned in git than to inline
                             #   shell into the workflow YAML.

docs/deploy/
├── static-client-assets-split-plan.md   # EXISTING (PRD source)
└── ROLLBACK.md                          # NEW: rollback runbook documenting the symlink-flip command
                                          #   + the curl probes operators run to confirm post-rollback.

apps/server/fly.staging.toml             # MODIFIED: add STATIC_ASSETS_DIR = "/data/client-assets/current"
apps/server/fly.prod.toml                # MODIFIED: same env addition
```

### Pattern 1: Atomic Symlink Swap via `mv -T`

**What:** Replace a symlink with no zero-existence window.
**When to use:** Every symlink swap touching `/data/client-assets/current`. Both the deploy-time forward swap and the rollback-time reverse swap MUST use this pattern.
**Example:**
```bash
# WRONG (PRD doc uses this — fix it in the plan):
ln -sfn /data/client-assets/releases/${SHA} /data/client-assets/current

# RIGHT — atomic, no zero-existence window:
ln -s /data/client-assets/releases/${SHA} /data/client-assets/current.new
mv -T /data/client-assets/current.new /data/client-assets/current
```
Source: [VERIFIED: Capistrano issue #346 — Capistrano switched to this exact pattern in 2014 for the same reason]; [VERIFIED: man 2 rename — "If newpath already exists, it will be atomically replaced, so that there is no point at which another process attempting to access newpath will find it missing."]

### Pattern 2: Stage-Check-Swap Ordering

**What:** Extract release → probe via SHA-pathed URL → atomic-swap → final probe via `/current` URL.
**When to use:** All client-only deploys.
**Why:** If the post-swap check fails, we'd otherwise need an auto-revert step that introduces complexity and another race. By probing BEFORE the swap, a broken release is never `current` and rollback is unnecessary on health-check failure. The post-swap probe is a sanity check only.
**Example flow:**
```bash
# Step 1: extract into content-addressed dir
mkdir -p /data/client-assets/releases/${SHA}
tar -xzf /tmp/client-assets-${SHA}.tgz -C /data/client-assets/releases/${SHA}

# Step 2: pre-swap probe (planner: how to URL-route? options below)
# Option A: optional Express middleware that serves /releases/<sha>/* from
#   /data/client-assets/releases/<sha>/ for any release dir that exists.
#   (Simple but exposes all releases as fetchable.)
# Option B: skip pre-swap probe; rely on file-existence checks on the
#   extracted tree (manifest.json exists, index.html non-empty, expected
#   hashed JS exists). Cheaper, no Express change.
# Recommendation: Option B for v1 — file existence is sufficient evidence
#   the tarball wasn't truncated.

test -f /data/client-assets/releases/${SHA}/index.html
test -f /data/client-assets/releases/${SHA}/.vite/manifest.json

# Step 3: atomic swap (pattern 1)
ln -s /data/client-assets/releases/${SHA} /data/client-assets/current.new
mv -T /data/client-assets/current.new /data/client-assets/current

# Step 4: post-swap probe (from CI runner, against public URL)
curl -fsS https://staging.rebno.decidel.com/ > /dev/null
HASHED_JS=$(jq -r '."index.html".file' /data/client-assets/current/.vite/manifest.json)
curl -fsS https://staging.rebno.decidel.com/${HASHED_JS} > /dev/null
curl -fsS https://staging.rebno.decidel.com/health > /dev/null
```
Note: The PRD writes `/healthz`. The actual endpoint in `apps/server/src/index.ts:309` is `/health` (no z). The planner MUST use `/health`. [VERIFIED: `apps/server/src/index.ts:309`]

### Pattern 3: Fallback-Resolver Helper

**What:** Centralize the `STATIC_ASSETS_DIR` env → directory-exists → fallback decision in one module so tests can exercise all four permutations (env-set + exists, env-set + missing, env-unset + bundled-exists, env-unset + bundled-missing).
**Example:**
```typescript
// apps/server/src/static-assets.ts
// [impl->REQ-DEP-04] [impl->REQ-CLI-08] [impl->REQ-DEP-01]
import { existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { pino } from 'pino';

const __dirname = dirname(fileURLToPath(import.meta.url));

export interface StaticAssetsResolution {
  dir: string;
  source: 'STATIC_ASSETS_DIR' | 'bundled';
}

/**
 * Resolve the directory that express.static should mount.
 *
 * Priority:
 *   1. process.env.STATIC_ASSETS_DIR (must point at an existing readable dir)
 *   2. <__dirname>/../public (bundled fallback; always present in the image)
 *
 * The dir-exists check is one-shot at boot. After boot, the OS resolves the
 * symlink per request — so swapping /data/client-assets/current under a
 * running server picks up the new release without restart.
 */
export function resolveStaticDir(
  env: NodeJS.ProcessEnv,
  log: pino.Logger,
): StaticAssetsResolution {
  const bundled = join(__dirname, '..', 'public');
  const envDir = env.STATIC_ASSETS_DIR;
  if (envDir && existsSync(envDir) && statSync(envDir).isDirectory()) {
    log.info({ dir: envDir }, 'static_assets_source_env');
    return { dir: envDir, source: 'STATIC_ASSETS_DIR' };
  }
  if (envDir) {
    log.warn(
      { envDir, bundled },
      'STATIC_ASSETS_DIR set but missing — falling back to bundled /app/public',
    );
  } else {
    log.info({ dir: bundled }, 'static_assets_source_bundled');
  }
  return { dir: bundled, source: 'bundled' };
}
```
Then in `apps/server/src/index.ts` (replace line 282-283):
```typescript
const { dir: staticDir, source: staticDirSource } = resolveStaticDir(process.env, log);
// Optional: surface the resolution on /health for operator visibility.
app.use(express.static(staticDir));
```
[CITED: Node `fs.existsSync` docs, `path.join` docs]

### Pattern 4: GitHub Actions Path Filter + Conditional Jobs

**What:** Single workflow file, top job runs the path filter, downstream jobs gate on its output.
**Example:**
```yaml
# .github/workflows/deploy-staging.yml (excerpt)
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      client_only: ${{ steps.filter.outputs.client_only }}
    steps:
      - uses: actions/checkout@v4
      # SHA-pin per project D-12 supply-chain stance.
      # Verify at plan-execution time:
      #   gh api repos/dorny/paths-filter/git/refs/tags/v3 --jq .object.sha
      - uses: dorny/paths-filter@<RESOLVED-SHA>
        id: filter
        with:
          # 'client_only' is true ONLY when EVERY changed file matches the
          # client glob. The trick: use a negative filter (`!apps/client/**`)
          # for everything-else, then `client_only = !everything_else`.
          # paths-filter exposes a `predicate-quantifier: every` option for
          # this, but the simpler shape is to define two filters and combine
          # them in a `needs` job condition.
          filters: |
            client:
              - 'apps/client/**'
            non_client:
              - 'apps/server/**'
              - 'packages/protocol/**'
              - 'packages/game-logic/**'
              - 'packages/db/**'
              - 'apps/server/Dockerfile'
              - 'apps/server/fly.*.toml'
              - 'apps/server/litestream.yml'
              - 'apps/server/docker-entrypoint.sh'
              - 'pnpm-lock.yaml'
              - 'pnpm-workspace.yaml'
              - 'package.json'
              - '.github/workflows/**'
              - 'scripts/**'

  fast-path-client-only:
    needs: changes
    if: ${{ needs.changes.outputs.client == 'true' && needs.changes.outputs.non_client != 'true' }}
    runs-on: ubuntu-latest
    # ... build + sftp + extract + swap + probes
  full-path:
    needs: changes
    if: ${{ needs.changes.outputs.non_client == 'true' || needs.changes.outputs.client != 'true' }}
    runs-on: ubuntu-latest
    # ... current Plan 05/06-08 full-image flow (unchanged shape)
```
Source: [CITED: dorny/paths-filter README — "predicate-quantifier" option; example workflow patterns]

### Anti-Patterns to Avoid

- **`ln -sfn` for the swap.** PRD writes it this way; it is NOT atomic. Always use `mv -T`.
- **`tj-actions/changed-files`.** Compromised March 2025 (CVE-2025-30066). Use `dorny/paths-filter` instead.
- **Recursive `flyctl ssh sftp put -R apps/server/public /data/...`.** One SFTP transfer per file; slow for the asset tree. Tarball is one transfer + one local extract.
- **Inlining the extract+swap script into the workflow YAML.** Multi-line shell inside YAML is hard to test and harder to debug. Write `scripts/client-release.sh`, ship it with the tarball or commit it into the image, and invoke it from `flyctl ssh console -C "/path/to/script $SHA"`.
- **Probing only `/`.** The HTML file is small and might survive a partial tarball extract. The PRD's "one hashed JS asset" probe is the load-bearing check — a hashed JS that 404s means the asset directory is broken even if `/` happens to return 200.
- **Skipping the cold-start fallback test.** If a deploy sets `STATIC_ASSETS_DIR` and the dir doesn't exist on a freshly provisioned machine, an over-strict resolver could throw. The fallback to bundled `/app/public` MUST be a soft "log and continue," not an error.
- **Running GC before health check passes.** GC must be the LAST step. If we GC at position N-5, then the swap fails, then the operator wants to roll back to position N-5, the target is gone.
- **Reading the manifest from the CI runner's local checkout rather than the deployed volume.** The runner's working copy and the volume can drift if a build is non-deterministic. Resolve the hashed asset name from the on-volume manifest (`flyctl ssh console -C "cat /data/client-assets/current/.vite/manifest.json"`) OR from the just-built artifact (same workflow run, before push) — but always one or the other, never mix.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Path-changed detection on a push | Custom `git diff` script | `dorny/paths-filter@v3` (SHA-pinned) | Handles base-ref resolution on first-push-to-branch, PR-vs-push event differences, squash merges. Maintained, audited. |
| Atomic symlink swap | `ln -sfn` | `ln -s … <tmp> && mv -T <tmp> <current>` | `mv -T` calls `rename(2)`, the only atomic primitive for replacing a directory entry. `ln -sfn` is implemented as unlink+symlink. |
| Tarball upload over SSH | Custom rsync-over-flyctl or per-file SFTP loop | `flyctl ssh sftp put <local>.tgz <remote>.tgz` | One round-trip, one file, no per-file metadata negotiation. |
| Multi-process race protection | Hand-rolled lockfile | GitHub Actions `concurrency: { group: deploy-staging-client, cancel-in-progress: false }` | Project already uses `concurrency: deploy-staging` (line 57 of existing `deploy-staging.yml`); same pattern. |
| Static-asset cache headers | Custom middleware | `express.static` `maxAge` + `immutable` options | Built-in to serve-static; honors hashed-filename cache discipline. (Out of phase scope unless planner adds it.) |
| Health-check polling logic | Custom Node script | `curl -fsS --retry 5 --retry-delay 2` | Built-in to curl; `--retry-connrefused` handles slow-warmup edge case. |

**Key insight:** Every primitive needed for this phase already exists as a battle-tested tool. The phase is about wiring them up correctly — not about authoring new mechanisms.

## Runtime State Inventory

This is a deploy-mechanics phase, not a refactor or rename. There is no existing runtime state to migrate. The new state introduced is:

| Category | Items Introduced | Action Required |
|----------|------------------|------------------|
| Stored data | `/data/client-assets/releases/<sha>/` directories + `/data/client-assets/current` symlink on each Fly machine's persistent volume | Greenfield — first deploy creates the directory tree. Idempotent: `mkdir -p` in the extract script. |
| Live service config | `STATIC_ASSETS_DIR` env var added to `fly.staging.toml` + `fly.prod.toml` | Both files are in git; redeploy picks up the env. No external dashboard config. |
| OS-registered state | None — no Task Scheduler, launchd, systemd, or pm2 registrations involved. | None. |
| Secrets/env vars | None new. Existing `FLY_API_TOKEN` GH secret is reused (already authorizes `flyctl deploy`; `flyctl ssh` uses the same token scope). | Verify `FLY_API_TOKEN` scope includes `ssh` operations (it does — default scope) at plan-execution time. |
| Build artifacts | `apps/server/public/` tarball produced by `tar -czf client-assets-${GITHUB_SHA}.tgz -C apps/server/public .` (ephemeral on the runner) | None — tarball is uploaded then discarded. The Vite output landing in `apps/server/public/` continues to be wiped + repopulated by `vite build` per existing Phase 06 D-18 contract. |

**Nothing else found.**

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| `flyctl` (CLI) | Tarball upload, machine-side extract/swap | ✓ on local dev machine (v0.4.49) | v0.4.49 | — |
| `flyctl` in CI | Tarball upload, machine-side extract/swap | ✓ via `superfly/flyctl-actions/setup-flyctl@fc53c09e1bc3be6f54706524e3b82c4f462f77be` (already SHA-pinned in `deploy-staging.yml:150`) | Latest stable | — |
| `tar` on runner | Build tarball | ✓ Ubuntu runners always include | bsd/gnu tar | — |
| `tar` in Fly machine | Extract tarball on volume | ✓ `node:22-bookworm-slim` includes tar by default | gnu tar | — |
| `mv` with `-T` flag in Fly machine | Atomic symlink swap | ✓ `node:22-bookworm-slim` ships GNU coreutils mv (supports `-T`) | GNU coreutils | — |
| `jq` on runner | Parse `.vite/manifest.json` | ✓ Ubuntu runners include jq | 1.6+ | Inline node one-liner if missing |
| `jq` in Fly machine | Parse manifest in-container | ✗ NOT installed in `node:22-bookworm-slim` by default | — | Use `node -e "console.log(require('/path/manifest.json')['index.html'].file)"` — Node is already present |
| `curl` on runner | Health probes from CI | ✓ | — | — |
| `curl` in Fly machine | (Only if planner chooses machine-side health probes) | ✓ `apt-get install ca-certificates` already adds curl indirectly? **TO VERIFY** | — | `wget` (also typically present) OR `node -e fetch(...)` |
| `flock` in Fly machine | Optional machine-side advisory lock | ✓ util-linux ships with bookworm-slim | — | CI-side `concurrency:` group |
| GitHub Actions `concurrency:` | Prevent racing client-only deploys | ✓ first-class GH Actions feature | — | — |
| `dorny/paths-filter` action | Path-changed detection | ✓ public action, just needs SHA pin at plan time | v3.x | Custom `git diff` script (rejected — too fragile) |

**Missing dependencies with no fallback:** None.

**Missing dependencies with fallback:**
- `jq` inside the Fly machine container — fallback to a `node -e` one-liner if needed.
- `curl` inside the Fly machine container — needs verification; default is to run probes from the CI runner, which sidesteps this.

**Action item for planner:** During plan execution, run `flyctl ssh console -C "which jq curl"` to confirm what's actually installed before writing the extract script.

## Common Pitfalls

### Pitfall 1: `ln -sfn` is not atomic
**What goes wrong:** During the symlink swap, there's a tiny window (microseconds, but real) where `/data/client-assets/current` doesn't exist. Any request that hits `express.static` in that window gets a 404. Under steady load on staging, this is rare; under prod traffic it'll show up in error rates.
**Why it happens:** `ln -sfn` internally does `unlink(old)` then `symlink(new, old_path)`. Two syscalls = non-atomic.
**How to avoid:** Use the `ln -s … current.new && mv -T current.new current` pattern. `mv -T` invokes `rename(2)` which is atomic on ext4.
**Warning signs:** Sporadic 404s in logs around deploy time. Easy to miss if no log alerting is set on `serve-static`-emitted 404s.
**Source:** [VERIFIED: Capistrano issue #346 (2014), Tom Moertel "How to change symlinks atomically", `man 2 rename`]

### Pitfall 2: Vite output dir is OUTSIDE its project root
**What goes wrong:** `apps/client/vite.config.ts` sets `outDir: '../../apps/server/public'` and `emptyOutDir: true`. Since v3.2.0 Vite warns when `outDir` is outside the project root because of the wipe risk. Currently the warning is suppressed (operator already accepted the trade), but a planner who casually adds something to `apps/server/public/` outside of `vite build`'s control will see it deleted on next build.
**Why it happens:** Vite's `emptyOutDir: true` wipes the entire `outDir` before each build.
**How to avoid:** Keep treating `apps/server/public/` as build-output-only (already documented in `apps/server/public/.gitignore`). Do NOT add machine-side runtime files here. The new release dirs live on the volume (`/data/client-assets/releases/`) precisely to avoid this — never under `/app/public/`.
**Warning signs:** Files mysteriously disappearing after `vite build`. The existing `.gitignore` comment ("apps/server/public/ is BUILD-OUTPUT-ONLY") is the canary.
**Source:** [VERIFIED: `apps/server/public/.gitignore`, vite docs on emptyOutDir, vite issue #10696]

### Pitfall 3: Cold-start race during first deploy
**What goes wrong:** Fresh machine boots with `STATIC_ASSETS_DIR=/data/client-assets/current` set in `fly.toml`. The volume mount is empty (no release uploaded yet). The naive resolver `if (fs.existsSync(envDir)) serve(envDir)` fails the check and falls back to bundled `/app/public` — OK. But if the resolver throws on missing dir instead of falling back, the server crashes on boot.
**Why it happens:** Operators have a habit of writing resolvers that "fail fast" on misconfiguration. For this fallback chain, missing-env-dir is a NORMAL condition (cold start), not a misconfiguration.
**How to avoid:** Resolver MUST treat "env set but dir missing" as a soft warning + fall back. Log it once at boot for operator visibility. Add an integration test that boots the server with `STATIC_ASSETS_DIR=/nonexistent/path` and asserts the server reaches `ready` and serves `index.html` from bundled.
**Warning signs:** Server crashloop on the first deploy after the env var is added. Catch this with the integration test.

### Pitfall 4: Symlink swap under a running Node process
**What goes wrong:** Operators sometimes worry that `express.static` caches a `realpath()` of the mount root and won't see the symlink swap.
**Why it doesn't happen:** `serve-static` (the lib `express.static` wraps) calls `fs.stat`/`fs.createReadStream` per request against the mount root. The OS resolves the symlink fresh each time. No app-layer cache. Verified by reading `serve-static`'s `send` package — `path.normalize` runs per request, no `realpath` caching.
**Confirm:** Add an integration test: boot server with `STATIC_ASSETS_DIR=/tmp/test-a/current` pointing at `/tmp/test-a/r1/`. GET `/index.html` — read content. Atomically swap symlink to point at `/tmp/test-a/r2/`. GET `/index.html` again — expect different content. No server restart between the two requests.
**Source:** [CITED: expressjs/serve-static source on GitHub, send package internals]

### Pitfall 5: GC deletes the rollback target
**What goes wrong:** Deploy N succeeds, GC trims to keep last 5 releases. Operator immediately wants to roll back to deploy N-6 (it's known-good from yesterday's UAT). Target is already deleted.
**Why it happens:** GC is naive — it sorts by mtime and trims the tail. It doesn't know which release was the operator's "last known good."
**How to avoid:** Two mitigations: (a) keep last N where N ≥ 5 (PRD default) — usually enough buffer for the same-day rollback case; (b) consider keeping a `last-good` symlink alongside `current` that operators flip explicitly on a verified-good deploy, and GC excludes the target of any extant symlink in `/data/client-assets/`. **Recommendation:** Ship with N=5 in v1; defer (b) to a future iteration if rollback-out-of-buffer becomes a real complaint.
**Warning signs:** Operator reports "tried to roll back to <sha> but the dir doesn't exist." Add to ROLLBACK.md a check step ("Confirm target release exists on the volume before issuing the swap").

### Pitfall 6: Concurrent client-only deploys
**What goes wrong:** Two pushes arrive close together. Both runners build, both upload tarballs, both extract. Last one to swap wins, but the loser's GC run might delete the winner's release if the extract orders differ.
**Why it happens:** Two CI jobs, no mutex.
**How to avoid:** Use GH Actions `concurrency:` group at the workflow level. The existing `deploy-staging.yml:57` already does this with `cancel-in-progress: true`. For prod, use `cancel-in-progress: false` (do not interrupt a release in flight; queue the second one). Choose `cancel-in-progress: true` for the staging fast path to keep iteration fast.
**Source:** [VERIFIED: GitHub Actions concurrency docs, GH changelog 2021-04-19]

### Pitfall 7: Probing the wrong `/health` path
**What goes wrong:** PRD says "/healthz" but the actual endpoint at `apps/server/src/index.ts:309` is `/health`. Plans copy-pasting from the PRD will probe a 404.
**Why it happens:** Two different conventions (Kubernetes-style `/healthz` vs Node-stdlib-style `/health`).
**How to avoid:** Plan uses `/health` everywhere. Cross-reference the existing `fly.staging.toml:48` (also uses `/health`).
**Warning signs:** Probe always fails. Easy to debug — curl shows 404 not connection error.

### Pitfall 8: tar relative-path quirks
**What goes wrong:** `tar -czf out.tgz -C dir .` archives with relative paths (`./index.html`, `./assets/foo-hash.js`). `tar -czf out.tgz dir` archives with the parent dir prefix (`dir/index.html`). On extract, the latter creates `<target>/dir/index.html`, not `<target>/index.html`.
**Why it happens:** Different commands, different layouts.
**How to avoid:** Use exactly `tar -czf <file>.tgz -C apps/server/public .` (as the PRD specifies). On extract, use `tar -xzf <file>.tgz -C /data/client-assets/releases/${SHA}`. Confirm with a dry-run: `tar -tzf <file>.tgz | head` should show entries like `./index.html`, `./assets/index-<hash>.js`.

## Code Examples

Verified patterns drawn from official docs and project-existing code.

### Example 1: Resolved static-dir helper with logging
See Pattern 3 above (`apps/server/src/static-assets.ts`). Tests cover four permutations:
```typescript
// apps/server/test/static-assets.test.ts
// [unit->REQ-DEP-04] [unit->REQ-DEP-01]
import { describe, it, expect, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import pino from 'pino';
import { resolveStaticDir } from '../src/static-assets.js';

describe('resolveStaticDir', () => {
  const log = pino({ level: 'silent' });
  it('uses STATIC_ASSETS_DIR when env set AND directory exists', () => {
    const tmp = mkdtempSync(join(tmpdir(), 'static-test-'));
    try {
      const r = resolveStaticDir({ STATIC_ASSETS_DIR: tmp }, log);
      expect(r.source).toBe('STATIC_ASSETS_DIR');
      expect(r.dir).toBe(tmp);
    } finally { rmSync(tmp, { recursive: true, force: true }); }
  });
  it('falls back to bundled when env set but dir missing', () => {
    const r = resolveStaticDir({ STATIC_ASSETS_DIR: '/does/not/exist' }, log);
    expect(r.source).toBe('bundled');
  });
  it('falls back to bundled when env unset', () => {
    const r = resolveStaticDir({}, log);
    expect(r.source).toBe('bundled');
  });
  it('falls back to bundled when env points at a file, not a directory', () => {
    // ... mkdtemp + writeFileSync a file path, pass that ...
  });
});
```

### Example 2: Atomic-swap shell function used in `scripts/client-release.sh`

```bash
#!/usr/bin/env bash
# scripts/client-release.sh — runs ON THE FLY MACHINE, invoked via flyctl ssh console -C
# [impl->REQ-DEP-04] [impl->REQ-CLI-08]
set -euo pipefail
SHA="${1:?usage: $0 <sha>}"
ROOT=/data/client-assets
TGZ=/tmp/client-assets-${SHA}.tgz
REL=${ROOT}/releases/${SHA}
CUR=${ROOT}/current

# 1. Extract into content-addressed dir (idempotent if already present)
mkdir -p "${REL}"
tar -xzf "${TGZ}" -C "${REL}"

# 2. Sanity-check the extract
test -f "${REL}/index.html"     || { echo "missing index.html"; exit 2; }
test -f "${REL}/.vite/manifest.json" || { echo "missing vite manifest"; exit 2; }

# 3. Atomic symlink swap (mv -T pattern)
ln -s "${REL}" "${CUR}.new"
mv -T "${CUR}.new" "${CUR}"
echo "swapped ${CUR} -> ${REL}"

# 4. Garbage-collect old releases (keep last 5)
# `ls -1dt` sorts by mtime, newest first. tail -n +6 skips the first 5.
# `xargs -r` is GNU extension — does nothing if input is empty (safe).
# Guard: never delete a dir that is the resolved target of CUR.
CURTARGET=$(readlink -f "${CUR}")
for dir in $(ls -1dt "${ROOT}/releases/"* | tail -n +6); do
  if [[ "$(readlink -f "${dir}")" == "${CURTARGET}" ]]; then
    echo "skipping GC of current target ${dir}"
    continue
  fi
  rm -rf "${dir}"
done

# 5. Remove the uploaded tarball
rm -f "${TGZ}"
```

### Example 3: `flyctl ssh sftp put` + `console -C` invocation from CI

```yaml
# .github/workflows/deploy-staging.yml — fast-path job excerpt
- name: Build client (staging mode)
  run: pnpm --filter @rebno/client build:staging

- name: Tarball static output
  run: |
    tar -czf client-assets-${{ github.sha }}.tgz -C apps/server/public .
    ls -lh client-assets-${{ github.sha }}.tgz

- name: Upload tarball to Fly machine
  env:
    FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
  run: |
    flyctl ssh sftp put \
      client-assets-${{ github.sha }}.tgz \
      /tmp/client-assets-${{ github.sha }}.tgz \
      -a rebno-staging

- name: Upload release script
  env:
    FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
  run: |
    flyctl ssh sftp put \
      scripts/client-release.sh \
      /tmp/client-release-${{ github.sha }}.sh \
      -a rebno-staging

- name: Extract + swap on machine
  env:
    FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
  run: |
    flyctl ssh console \
      -a rebno-staging \
      -C "bash /tmp/client-release-${{ github.sha }}.sh ${{ github.sha }}"

- name: Post-swap health probes
  run: |
    URL=https://staging.rebno.decidel.com
    curl -fsS --retry 5 --retry-delay 2 --retry-connrefused "${URL}/" > /dev/null
    curl -fsS --retry 5 --retry-delay 2 --retry-connrefused "${URL}/health" > /dev/null
    # Parse the just-built manifest to find the hashed JS asset.
    HASHED=$(jq -r '."index.html".file' apps/server/public/.vite/manifest.json)
    curl -fsS --retry 5 --retry-delay 2 --retry-connrefused "${URL}/${HASHED}" > /dev/null
    echo "all three probes passed"
```
Note: The current Vite manifest produces a single hashed JS asset path (verified via `pnpm --filter @rebno/client build:staging` on 2026-05-16, output `assets/index-LpQRZqoI.js`). The `.vite/manifest.json` records:
```json
{ "index.html": { "file": "assets/index-LpQRZqoI.js", "name": "index", "src": "index.html", "isEntry": true } }
```
[VERIFIED: locally built 2026-05-16]

### Example 4: `dorny/paths-filter` SHA pin lookup

```bash
# At plan-execution time, capture the resolved v3 tag SHA:
gh api repos/dorny/paths-filter/git/refs/tags/v3 --jq .object.sha
# Then pin: uses: dorny/paths-filter@<resolved-sha>
```
Matches the project's existing SHA-pin convention (e.g. `superfly/flyctl-actions/setup-flyctl@fc53c09e1bc3be6f54706524e3b82c4f462f77be` in `deploy-staging.yml:150` — comment block documents the verification command).

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Rebuild + push entire Docker image on every client-bundle change | Split: client → tarball + symlink swap; server → image push | This phase | 5× faster client iteration; image push only on server code changes |
| `tj-actions/changed-files` for path filtering | `dorny/paths-filter` for path filtering | March 2025 (CVE-2025-30066) | Supply-chain risk eliminated |
| `ln -sfn` for symlink swap (Capistrano pre-2014, old deploy guides) | `ln -s … <tmp> && mv -T <tmp> <dest>` | 2014 (Capistrano #346) | Eliminates the zero-existence window |
| Single bake-everything-into-image deploy model | Capistrano-style content-addressed release dirs + symlink current | Industry standard since ~2007 | Atomic rollback as `mv` instead of full image redeploy |

**Deprecated/outdated (do not use):**
- `tj-actions/changed-files` — supply-chain compromised
- `ln -sfn` for atomic swap — never was atomic
- `rsync` over plain SSH to Fly machines — Fly's SSH path is via WireGuard; plain SSH connect-back from machine is not the documented egress; `flyctl ssh sftp` is the supported channel.

## Project Constraints (from CLAUDE.md)

Directives that constrain this phase's plans:

1. **Server-authoritative.** N/A here — static asset split doesn't change game state authority.
2. **Tagging contract.** Every artifact in this phase MUST embed `[doc->REQ-DEP-04]` / `[impl->REQ-DEP-04]` / `[int->REQ-DEP-04]` (plus DEP-01 / CLI-08 where applicable) per the project tagging contract. `traceable-reqs.toml` declares `required_stages = ["doc", "impl", "int"]` for DEP-04 and DEP-01 — the plan must produce all three stages.
3. **Each plan = one commit.** Atomic commits per plan, REQ-ID-referenced messages.
4. **Repo stays private through Phase 7.** No impact on this phase — no public-facing artifacts.
5. **`pnpm trace:check` before claiming phase complete.** Plan must include a final `trace:check` run in the verify gate (Wave 4 in the existing project pattern).
6. **TypeScript everywhere, strict mode.** New `apps/server/src/static-assets.ts` must conform.
7. **Phase 06.4 deploy-script delta MUST NOT regress.** Commit `c01038f` ("chore(deploy): speed staging debug deploys") added:
   - `workflow_dispatch` inputs `skip_verification`, `skip_phase_4_carryover`, `skip_smoke`.
   - Commit-message escape hatches `[skip-staging-verify]`, `[skip-phase-4-carryover]`, `[skip-staging-smoke]`.
   - `SKIP_PHASE_4_CARRYOVER=1` honored by `scripts/verify-phase-5.mjs`.
   - Docker Buildx GitHub-Actions cache (`--cache-from type=gha,scope=rebno-staging`).
   - Dockerfile cache-friendly layer split (deps → tsconfig → scripts → src → rooms → public).
   The new fast-path job MUST coexist with these and respect the same `[skip-staging-smoke]` semantics (the client-only fast path is the fastest path; the planner should decide whether `[skip-staging-smoke]` still applies — the cheap 3-check probe is already the smoke for this path).

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Fly volumes are formatted as ext4 | Pattern 1 (atomic swap) | LOW — well-documented; `mv -T` works on every Linux filesystem that supports rename(2), which is all of them. [ASSUMED — Fly docs say "NVMe-backed" but don't explicitly confirm ext4 in every result I found.] |
| A2 | `flyctl ssh sftp put` overwrites existing remote files | Pattern 4 / Example 3 | LOW — standard SFTP behavior, but Fly's docs don't explicitly confirm. Mitigation: tarball name is content-addressed (`-${SHA}`), so re-upload after a CI retry overwrites the same hash — semantically safe. [ASSUMED] |
| A3 | `node:22-bookworm-slim` ships `tar`, `mv`, `coreutils`, `ln` | Environment Availability | LOW — bookworm-slim is the Debian minimal image; coreutils is part of essential packages. [ASSUMED — verify with `flyctl ssh console -C "which tar mv ln readlink"` at plan-execution.] |
| A4 | The `FLY_API_TOKEN` secret already granted to the workflow has the scope to invoke `flyctl ssh sftp` and `flyctl ssh console` | Pattern 4 | MEDIUM — if scope is `deploy-only`, ssh ops would fail. Mitigation: planner adds a smoke step `flyctl ssh console -C "echo ok"` at the top of the fast-path job. [ASSUMED — Fly typically issues full-app-scope tokens; verify by reading the GH secret in the Fly dashboard or via `flyctl tokens list`.] |
| A5 | The Fly machine's `/data` volume has enough headroom for `5 × <build-output-size>` | Pitfall 5 / steady-state cost | LOW — verified build output size below. Current Vite output is ~14 MB total (1 hashed JS + sourcemap + atlas PNG/JSON + fonts). 5 releases × 14 MB = 70 MB. The volume is `initial_size = "10gb"` per `fly.staging.toml:26`. Negligible. [VERIFIED: 14 MB via local `du -sh apps/server/public/` 2026-05-16.] |
| A6 | The PRD-named `/healthz` should be read as `/health` | Pitfall 7 | LOW — verified the actual endpoint at line 309 of `apps/server/src/index.ts`. Plan uses `/health`. [VERIFIED] |
| A7 | The cli-08 Playwright smoke is NOT needed on the client-only fast path | Pattern 4 / discretion | MEDIUM — operator may want it as a stronger gate. The 3-check probe verifies the static assets serve correctly, but does NOT verify two-player movement+chat works after the swap. **Recommendation:** Keep the cli-08 smoke as the gate on the FULL path (where server code changed and behavior might shift), and accept the 3-check probe as sufficient for client-only (where only the bundle changed). Planner confirms with operator. [ASSUMED] |
| A8 | `apps/client/**` is the only "client-only" path | CI Workflow Split | MEDIUM — `tools/asset-pipeline` produces files that land in `apps/server/public/` AND is currently invoked from `deploy-staging.yml` lines 133-139. A change to the atlas (BMP source under `extracted/`) regenerates `atlas-mvp.{png,json}` + `pipeline-manifest.json` that live under `apps/server/public/`. Planner must decide: do atlas regenerations count as "client-only" or "full-path"? The pipeline currently runs unconditionally as `continue-on-error: true` (best-effort); recommendation is to treat changes under `extracted/` + `tools/asset-pipeline/` as full-path (those changes typically accompany asset-pipeline tool changes, which is non-client code). [ASSUMED] |

**If this table is empty:** N/A — table has entries requiring user confirmation.

## Open Questions (RESOLVED)

1. **Should the fast-path also deploy to prod, or only staging?** RESOLVED → staging only in v1.
   - What we know: Current prod is triggered by `git tag v*.*.*` push. Tag pushes by definition can include server changes, so prod typically wants the full path.
   - What's unclear: Whether the operator wants a separate prod-only-client-fast-path on, say, `gh workflow run deploy-prod-client.yml -f sha=<staging-tested-sha>` for hotfixes.
   - Resolution: v1 ships the fast path for staging only. Add prod support in a follow-up after operator confirms the workflow shape on staging. Note this in the deferred-items list. Plans 04+05 honor this.

2. **Should the fast path use the same `concurrency: deploy-staging` group as the full path, or a separate group?** RESOLVED → same group, cancel-in-progress.
   - What we know: Existing group `deploy-staging` with `cancel-in-progress: true`.
   - What's unclear: If a full-path deploy is in flight and a client-only push arrives, do we want to cancel the full path (current behavior on shared group) or queue the client deploy (separate group)?
   - Resolution: Same group (`deploy-staging`) with `cancel-in-progress: true`. The newest push always wins, regardless of which path it takes. Predictable. Matches "newest commit wins" intuition. Plan 04 honors this.

3. **Where to keep `scripts/client-release.sh` so it can run on the machine?** RESOLVED → upload per deploy (option a).
   - What we know: The script needs to be on the machine to run.
   - What's unclear: Two options:
     - (a) Upload it alongside the tarball every deploy (current shape in Example 3). Always-fresh, no version skew.
     - (b) Bake it into the image at `/app/scripts/client-release.sh`. One fewer upload per deploy, but image needs to be re-pushed when the script changes (defeats the point of the fast path for script changes).
   - Resolution: (a) — upload per deploy. The script is small (~30 lines), upload is fast, and version-skew risk is zero. Plan 04 honors this.

4. **Does `flyctl ssh sftp put` need explicit `--app` or does `-a` work?** RESOLVED → use `-a` (project convention).
   - What we know: Fly docs document both `--app` and `-a` aliases in most commands.
   - What's unclear: Whether the exact flag for `sftp put` is `-a`, `--app`, or both.
   - Resolution: Use `-a` (project pattern in deploy-staging.yml uses `-a` for `flyctl deploy`). Verify at plan-execution. Plan 04 honors this.

5. **How are atlas/asset-pipeline regenerations classified — client-only or full-path?** RESOLVED → full-path.
   - See Assumption A8.
   - Resolution: full-path. Atlas changes typically accompany tools changes or extracted-asset changes; not safe to assume the bundle code didn't shift. Plan 04 honors this via `non_client` glob list.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | `vitest` 3.2.4 (server unit/integ tests) + `playwright` 1.59.1 (e2e) |
| Config file | `apps/server/vitest.config.ts` + `apps/client/playwright.config.ts` |
| Quick run command | `pnpm --filter @rebno/server test` |
| Full suite command | `pnpm -r test && pnpm trace:check` |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-DEP-04 | `resolveStaticDir` returns env dir when env set + dir exists | unit | `pnpm --filter @rebno/server test apps/server/test/static-assets.test.ts` | ❌ Wave 0 |
| REQ-DEP-04 | `resolveStaticDir` falls back to bundled when env set + dir missing | unit | (same as above) | ❌ Wave 0 |
| REQ-DEP-04 | `resolveStaticDir` falls back to bundled when env unset | unit | (same as above) | ❌ Wave 0 |
| REQ-DEP-04 | `resolveStaticDir` falls back when env points at a file (not dir) | unit | (same as above) | ❌ Wave 0 |
| REQ-DEP-04 | Server boots with `STATIC_ASSETS_DIR=/nonexistent` and serves bundled `index.html` | integ | `pnpm --filter @rebno/server test apps/server/test/static-assets.integ.test.ts` | ❌ Wave 0 |
| REQ-DEP-04 | Server serves swapped content after symlink change without restart | integ | (same as above — separate `describe` block) | ❌ Wave 0 |
| REQ-DEP-04 | `dorny/paths-filter` correctly classifies client-only diffs | int (CI) | The `changes` job's `client_only` output is logged; manual verification via test PRs that touch various paths. No unit test possible for GH Actions YAML. | ❌ Wave 0 — verified by deliberate test pushes |
| REQ-DEP-04 | Fast-path CI run completes < 3 minutes end-to-end | int (CI) | Measured wall-clock on staging deploys; pass criterion in 06.5-VERIFICATION.md | ❌ Wave 0 — operator UAT |
| REQ-DEP-04 | Rollback runbook restores serving correctly | int (manual) | `docs/deploy/ROLLBACK.md` runbook executed end-to-end on staging | ❌ Wave 0 — operator UAT |
| REQ-DEP-01 | Bundled `/app/public` fallback works on a fresh machine (no volume content) | int (manual) | `flyctl machine destroy <id> --force && flyctl deploy` → `curl /` returns bundled index | ❌ Wave 0 — operator UAT |
| REQ-CLI-08 | Two-player movement+chat continues to pass after a client-only deploy | int (Playwright) | Existing `pnpm --filter @rebno/client test:e2e` (full-path workflow already gates on this; client-only path runs the 3-check probe as the proxy) | ✅ existing |

### Sampling Rate
- **Per task commit:** `pnpm --filter @rebno/server test apps/server/test/static-assets*.test.ts`
- **Per wave merge:** `pnpm -r test && pnpm lint:deploy-stack`
- **Phase gate:** Full suite green + `pnpm trace:check` (allowing for the existing `traceable-reqs` Linux-CI deferral) + one successful staging fast-path deploy + one successful staging full-path deploy + one rollback drill.

### Wave 0 Gaps
- [ ] `apps/server/src/static-assets.ts` — RED stub returning fallback only
- [ ] `apps/server/test/static-assets.test.ts` — RED unit tests for all 4 permutations
- [ ] `apps/server/test/static-assets.integ.test.ts` — RED integration test: spawn server, swap symlink under it, observe served content change
- [ ] `scripts/client-release.sh` — empty stub that `set -e; exit 1` (RED until implemented)
- [ ] `.github/workflows/deploy-staging.yml` — does NOT have a fast path job yet (gap to fill)
- [ ] `docs/deploy/ROLLBACK.md` — does not exist yet
- [ ] No `lint:deploy-stack`-style gate exists for the new artifacts; planner extends the existing lint to cover the new env var declaration in both `fly.*.toml` files

### Failure Mode Coverage
The Nyquist sample must include tests for these failure modes:
| Failure Mode | Test |
|--------------|------|
| `STATIC_ASSETS_DIR` env set, dir missing | unit + integ |
| `STATIC_ASSETS_DIR` env set, dir is a file not a directory | unit |
| `STATIC_ASSETS_DIR` env unset | unit + integ |
| Broken symlink (target dir removed mid-deploy) | integ — break symlink under running server, assert 404s but no crash |
| Partial tarball extract (truncated archive) | int (manual) — `scripts/client-release.sh` asserts presence of `index.html` + `manifest.json` |
| Race between extraction and swap (two CI runs) | int (CI) — verify `concurrency: deploy-staging` group cancels the older run |
| GC about to delete the symlink's current target | int (manual) — readlink check in `client-release.sh` skips the current target |
| Bundle cold-start fallback (volume empty on fresh machine) | int (manual) — destroy + redeploy drill |

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (indirect) | `FLY_API_TOKEN` GH secret is the only credential touched. Scope already established for the existing `flyctl deploy` step; reused for `flyctl ssh sftp` + `flyctl ssh console`. No new auth surface. |
| V3 Session Management | no | Phase doesn't touch session/cookie handling. |
| V4 Access Control | yes | The `flyctl ssh` operations run as the Fly machine's default SSH user (root inside the container in practice — Fly's ssh implementation). The release script writes to `/data/client-assets/` which is volume-mounted and only accessible from the machine. No new public attack surface. |
| V5 Input Validation | yes | The `${SHA}` input to `client-release.sh` MUST be validated as a 40-hex-char git SHA. The script should reject anything else to prevent path traversal (`SHA=../etc/passwd`). [Standard: regex check at script top.] |
| V6 Cryptography | no | No new crypto. |
| V12 File and Resources | yes | Path traversal via `${SHA}` argument: mitigated by SHA format check. Symlink swap race: mitigated by `mv -T`. Static-asset serving via `express.static`: follows symlinks by default — acceptable here because the symlink is operator-controlled, points only into `/data/client-assets/releases/`, and `express.static` strips `..` segments per its docs. |

### Known Threat Patterns for {node + express + fly + github-actions} stack

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Compromised GitHub Action steals secrets (e.g. `tj-actions/changed-files` 2025) | Spoofing / Tampering / Info Disclosure | SHA-pin every third-party action. Use `dorny/paths-filter@<sha>`, never `@v3` floating. Already project convention. |
| Path-traversal via `${SHA}` argument to release script | Tampering | Regex-validate the SHA at script entry: `[[ "${SHA}" =~ ^[a-f0-9]{40}$ ]] || exit 2` |
| Symlink swap leaves zero-existence window → 404 storm | Denial of Service | Use `mv -T` (atomic), not `ln -sfn` |
| GC deletes the release that `current` points to | Denial of Service | Readlink-protect GC: skip any dir whose realpath matches the current target |
| Stale tarball in `/tmp` accumulates | Resource exhaustion | `client-release.sh` removes the tarball after extract; `/tmp` is typically tmpfs and reaped on machine restart anyway |
| Public exposure of `.vite/manifest.json` | Info Disclosure (LOW) | Acceptable — manifest only lists hashed file names; same info is visible in any built HTML's `<script src>` tag |
| Public exposure of sourcemap `.js.map` (staging) | Info Disclosure | Already accepted in `vite.config.ts` (`sourcemap: mode === 'staging'`); prod build omits maps. No change. |
| Recursive sftp upload uploading unintended files | Tampering | We use tarball (not recursive sftp); single file = no enumeration risk |

## Sources

### Primary (HIGH confidence)
- `apps/server/src/index.ts` — current static-mount code (line 282-283) [VERIFIED via Read tool]
- `apps/server/Dockerfile` — bundled `/app/public` COPY (line 57) [VERIFIED]
- `apps/server/fly.staging.toml` + `fly.prod.toml` — `[env]` insertion sites + volume mount [VERIFIED]
- `.github/workflows/deploy-staging.yml` + `deploy-prod.yml` — existing CI shape [VERIFIED]
- `apps/client/vite.config.ts` — outDir target, emptyOutDir, manifest emit [VERIFIED]
- `apps/server/public/.vite/manifest.json` — actual built manifest shape [VERIFIED via local `pnpm build:staging` 2026-05-16]
- `docs/deploy/static-client-assets-split-plan.md` — PRD source of truth [VERIFIED]
- `traceable-reqs.toml` — REQ-DEP-01 / DEP-04 / CLI-08 stage declarations [VERIFIED]
- `man 2 rename` (Linux manpage) — atomic-replacement semantics
- GitHub Actions `concurrency:` docs (docs.github.com)
- Fly.io docs — `fly ssh sftp put`, `fly ssh console`
- expressjs/serve-static GitHub source — symlink follow behavior

### Secondary (MEDIUM confidence — multiple sources cross-verified)
- Capistrano issue #346 (2014) — established `mv -T` pattern in deploy tooling
- Tom Moertel "How to change symlinks atomically" (2005) — original blog post documenting `ln -sfn` non-atomicity
- Artem Chistyakov "Atomic symlinks" (2017) — modernized version of same advice
- Snyk + Wiz + Cycode blog posts on `tj-actions/changed-files` CVE-2025-30066

### Tertiary (LOW confidence — single source, marked for validation)
- Specific behavior of `flyctl ssh sftp put` overwriting existing files — Fly docs don't explicitly state. Mitigation: content-addressed tarball name eliminates ambiguity.
- Fly volume filesystem type — assumed ext4 based on Linux defaults; `mv -T` works regardless.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every tool is already in use in the project or has clear, well-documented usage. SHA pinning convention is established.
- Architecture: HIGH — Capistrano-style release-dir + symlink-swap is a 20-year-old pattern with abundant references.
- Pitfalls: HIGH — the `ln -sfn` and `tj-actions/changed-files` traps are well-documented in the security and ops literature.
- Race conditions: MEDIUM — single-machine Fly target simplifies a lot of distributed-systems concerns, but the planner should still write the integ tests in `## Validation Architecture`.
- Production volume capacity: LOW until verified — Assumption A5 says headroom is fine for v1 (5 × 14 MB on a 10 GB volume), but if `tools/asset-pipeline` outputs grow significantly in Phase 7 (AST-02 audio, AST-03 fonts), revisit.

**Research date:** 2026-05-16
**Valid until:** 2026-06-15 (30 days for stable infrastructure patterns; reassess if Fly or GH Actions change API surface)
