# API Performance Gate (Step 4.5)

**Precondition:** PRD written (Step 4 complete), not yet presented to user.

This gate audits the PRD for performance bottlenecks and cost inefficiencies
**before** the user sees it, so findings are integrated into the spec — not
flagged after implementation.

> **Execution model:** This protocol is run by the `api-perf-cost-auditor`
> agent when invoked at Step 4.5. The `/prd` skill spawns the agent via the
> Task tool with this file as the gate specification; the agent does NOT
> execute inline. See `prd-writing-phase.md` § Step 4.5 for the spawn
> instruction. At the audit phase (audit-phase.md), the same agent re-audits
> the backlog cards using a narrower card-level scope — that is a distinct run;
> it MUST read the `## API Performance Gate` section from the state file
> written by this Step 4.5 run to avoid re-flagging already-resolved items.

---

## When to Run

**Step 0 — Check Skip Conditions first.** Before running any gate, evaluate
the skip table below. If a skip condition applies, log the skip reason in the
state file and stop.

### Skip Conditions

| Card / PRD type | Action |
|-----------------|--------|
| Hotfix (single-line bug fix, no data/API touch) | Gate 5 only — skip Gates 1-4 |
| Purely frontend (no persistence or API changes) | Skip all gates entirely |
| Documentation-only | Skip all gates entirely |
| Prototype / spike (throw-away) | Skip all gates entirely |

**Gate 5 (Text Scan) runs on every PRD that passes the skip check.** It is a
fast keyword match and takes precedence over all other conditions.

**Gates 1-4 run ONLY** when Gate 5 finds matches OR the PRD contains any of:
- `## API contract changes` section with endpoints
- `## Data model` section with new {entity}s/{field}s (collections+documents in
  Firestore/Mongo, tables+rows in SQL, items in DynamoDB)
- Server-side mutations touching the persistence layer
- Background jobs, cron, batch operations

If Gate 5 finds zero matches AND none of the above sections are present: log
`API Performance Gate: N/A — no API/data surface` in state file and return
control to the `/prd` skill (proceed to "Present and Confirm").

**Stack-awareness:** before running Gates 1-4, read `stack.database` and
`stack.framework` from `baldart.config.yml`. The gate sections below have a
universal core (works for any stack) PLUS stack-specific addenda that activate
only when the matching value is set. When `stack.database: ""` (unset), the
gate degrades to the universal core and logs a recommendation to run
`npx baldart configure` so future gates pick up the stack-specific rules.

---

## The 5-Gate Protocol

### Gate 5 — Text Scan (ALWAYS RUNS FIRST)

Scan the PRD text for these keywords. Each match = a finding.

| Keyword Pattern | Problem | Severity |
|-----------------|---------|----------|
| "show all" / "list all" / "fetch all" / "mostra tutti" | Unbounded read — no pagination | CRITICAL |
| "for each X" + "get/read Y" / "per ogni X" + "leggi Y" | N+1 query pattern | CRITICAL |
| "notify all" / "update all" / "notifica tutti" | Fan-out write | HIGH |
| "live" / "real-time" / "tempo reale" / "auto-update" | Listener cost (1 read/doc at attach + 1/change) | HIGH |
| "global counter" / "total X" / "increment count" / "contatore" | Transaction hotspot (serializes writers) | HIGH |
| "no limit" / "unlimited" / "senza limiti" / "illimitato" | Unbounded data growth | HIGH |
| "save all in document" / "embed list" / "array nel documento" | Unbounded array in doc (max 1MB, degrades at 40K) | HIGH |
| "upload file/image via API" / "carica file" | the platform's request-body limit (e.g. 4.5MB on Vercel Functions — per `stack.deployment`) | HIGH |
| "search by name" / "full-text search" / "cerca per nome" | Many DBs need a dedicated search index (Algolia/Typesense/Postgres FTS/Mongo Atlas Search) — DB-specific | HIGH |
| "page N" / "skip" / "offset" / "pagina N" | Offset pagination is inefficient in most DBs (Firestore charges for skipped docs; SQL scans them) | MEDIUM |
| "send email/SMS" in request path / "invia email/SMS" | Blocking I/O in request path (use waitUntil / background job) | MEDIUM |
| "filter by A and B and C" / "filtra per A e B e C" | Composite index explosion (Firestore: explicit indexes; SQL: pick a covering index; Mongo: compound index) | MEDIUM |

**Stack-specific keyword addenda** (apply only when matching `stack.database`):

| Stack | Keyword | Problem | Severity |
|-------|---------|---------|----------|
| firestore | "auto-increment ID" / "sequential ID" / "ID sequenziale" | Firestore write hotspot (sequential keys serialize on a single tablet) | HIGH |
| firestore | "import CSV" / "bulk insert" / "importa CSV" | Firestore batch limit: 500 docs/write | MEDIUM |
| firestore / mongodb | "save all in document" / "embed list" / "array nel documento" | Unbounded array in doc (Firestore max 1MB, degrades >40K elements; Mongo 16MB doc cap) | HIGH |
| postgres / supabase | "SELECT \\*" on hot path | Avoid; specify columns to keep index-only scans viable | MEDIUM |
| postgres / supabase | "JSONB column" + indexed query | Verify GIN index exists | MEDIUM |
| dynamodb | "filter expression" instead of KeyCondition | Filter scans full partition — pick a GSI instead | HIGH |

**If 0 matches AND no API/data sections**: skip Gates 1-4; this result was
already handled by the early-exit in the "When to Run" block above — log
`API Performance Gate: N/A — no API/data surface` in state file and return.
**If any match OR API/data sections present**: continue to Gates 1-4.

### Gate 1 — Data Model Review

Universal checks (apply to any persistence layer):

- [ ] **Unbounded arrays/lists**: any array field/JSONB column/list-typed attribute that can grow without limit → CRITICAL
- [ ] **Row/document size**: will any row exceed a healthy size (Firestore ~100KB, Mongo 16MB, Postgres tuple alignment) regularly? → HIGH
- [ ] **Fan-out writes**: one user action writing to N rows/docs where N scales with users/data → HIGH
- [ ] **Global counters**: columns/fields incremented by many concurrent writers → HIGH (write-skew, contention, or transaction serialization depending on stack)
- [ ] **Denormalization strategy**: read-heavy paths should denormalize; write-heavy should normalize → MEDIUM

Stack-specific addenda (activate when matching `stack.database`):

- [ ] firestore: **Sequential/predictable IDs** (timestamp/counter) → HIGH (write hotspot, use auto-ID)
- [ ] firestore: **Real-time listeners** — justified? Can polling or ISR replace them? → HIGH (1 read/matched doc at attach + 1 per change)
- [ ] firestore: **Index exemptions** for large text fields → MEDIUM (N+1 index writes per doc write)
- [ ] mongodb: **Embedded vs referenced** — embedded grows the parent doc; referenced needs $lookup → MEDIUM
- [ ] postgres/supabase: **JSONB vs typed columns** — JSONB without GIN index = full scan → MEDIUM
- [ ] supabase: **RLS coverage** — every new table MUST have RLS policies aligned with the auth contract → CRITICAL. **This is a security requirement, not a performance finding.** When triggered: flag the missing policy as CRITICAL in the findings list AND note `→ HANDOFF TO security-reviewer` (do not attempt to write the RLS policy inline). The `/prd` skill MUST route this finding to `security-reviewer` before the PRD is presented to the user; the performance gate records the finding but does not resolve it unilaterally.
- [ ] dynamodb: **Hot partition** — does the partition key distribute evenly under load? → HIGH

### Gate 2 — Query & Index Review

Universal checks:

- [ ] **Pagination on all list endpoints**: every endpoint returning multiple rows/docs MUST specify cursor-based pagination (keyset / `startAfter` / `cursor`), never raw offset → CRITICAL
- [ ] **N+1 queries**: "get parent, then for each child get details" pattern → CRITICAL (use a single query with join / `collectionGroup` / `$lookup` / denormalize depending on stack)
- [ ] **Index table completeness**: if the PRD has read endpoints touching the persistence layer but Section 5 has no `### Database Indexes & Query Optimization` table (or says "nessun indice"), verify this is actually correct by checking each query pattern. Missing the table when compound queries exist → **CRITICAL**

Stack-specific addenda:

- [ ] firestore: **Composite index** for every query combining `where()` + `orderBy()` on different fields or multiple `where()` on different fields MUST appear in the PRD index table (Variant A). Missing index = runtime FAILED_PRECONDITION → **CRITICAL**
- [ ] firestore: **orderBy + inequality on different fields** requires composite index, max 1 inequality per query (inequality field MUST be last) → HIGH
- [ ] firestore: **>3 inequality filters on different fields** = index explosion → MEDIUM
- [ ] firestore: **Full-text search** — not native, needs Algolia/Typesense or array-contains workaround → HIGH
- [ ] postgres/supabase: **EXPLAIN check** on the slowest query in the PRD — sequential scans on tables above the project's sequential-scan threshold → CRITICAL. Threshold: read `${paths.references_dir}/perf-budgets.md`; if absent, use >100k rows as the ad-hoc default and emit a `BUDGETS_GAP` warning. [DESIGN-CHOICE: 100k-row fallback chosen as a conservative p95-safe default for OLTP workloads; calibrate via perf-budgets.md for your project]
- [ ] postgres/supabase: **Covering index** for hot read paths → MEDIUM
- [ ] mongodb: **Compound index field order** matches query equality → range → sort rule → HIGH
- [ ] dynamodb: **GSI / LSI definition** for every access pattern that doesn't hit the primary key → CRITICAL

### Gate 3 — Serverless / Runtime Architecture Review

Universal checks:

- [ ] **Payload size**: any endpoint accepting/returning a payload above the platform limit → CRITICAL (use signed URL / multipart upload pattern). Resolve threshold from `stack.deployment`: Vercel → 4.5 MB, AWS Lambda → 6 MB sync / 256 KB async, Cloudflare Workers → 100 MB, Firebase Functions gen 2 → 32 MB. When `stack.deployment` is unset, use 4.5 MB as a conservative fallback and emit a `BUDGETS_GAP` warning. [DESIGN-CHOICE: 4.5 MB unset-fallback matches the most restrictive common platform (Vercel) to avoid silent over-limit on unknown deployments]
- [ ] **Sync vs async**: long operations (email, PDF, image processing) in request path → HIGH (use queue / waitUntil / background worker per stack)
- [ ] **Function duration**: operations that may exceed the platform timeout (Vercel 300s Hobby / 800s Pro; Lambda 900s; Cloudflare Workers 30s CPU) → HIGH
- [ ] **CPU-intensive ops**: image resize, PDF gen, heavy computation in serverless → MEDIUM (consider dedicated worker / container)

Stack-specific addenda (`stack.framework`):

- [ ] nextjs: **GET Route Handler caching** — new GET endpoints MUST specify `revalidate` or `use cache` — dynamic by default since Next.js 15 → HIGH
- [ ] nextjs: **Server Action vs Route Handler** chosen explicitly per use case → MEDIUM
- [ ] remix: **`loader`/`action` separation** + `headers` for `Cache-Control` set explicitly → MEDIUM
- [ ] sveltekit: **`+page.server.ts` vs `+page.ts`** chosen explicitly; Edge vs Node runtime declared → MEDIUM
- [ ] astro: **`output: 'static'|'server'|'hybrid'`** declared per route group; `prerender` flag set for static routes → MEDIUM

### Gate 4 — Caching Strategy Review

Check data display and freshness requirements for:

- [ ] **Freshness requirement defined**: every read endpoint should specify acceptable staleness → HIGH
- [ ] **Per-user vs shared data**: shared data (menu, store info) should use ISR/cache; per-user data (orders, profile) stays dynamic → MEDIUM
- [ ] **Cache invalidation plan**: if using cache, how is it invalidated on writes? revalidateTag? revalidatePath? → MEDIUM
- [ ] **Redundant reads**: same data fetched in multiple components without dedup → MEDIUM (use React.cache for single-request dedup)

---

## Severity Actions

> **Taxonomy note:** This gate uses CRITICAL/HIGH/MEDIUM/LOW. When the
> `api-perf-cost-auditor` agent runs this protocol (as it does at Step 4.5
> and Step 6), its output schema uses BLOCKER/HIGH/MEDIUM/LOW. Mapping:
> agent BLOCKER (Priority ≥ 16) = gate CRITICAL; agent HIGH = gate HIGH.
> The agent's BLOCKER findings MUST be treated as CRITICAL for PRD action
> purposes. Never present a PRD with an unresolved BLOCKER/CRITICAL finding.

| Severity (gate) | Agent equivalent | Action |
|-----------------|-----------------|--------|
| CRITICAL | BLOCKER | MUST fix in PRD before presenting to user. Add/modify NFR, add edge case, change API design. If the fix requires architectural redesign the skill cannot determine unilaterally, **STOP** — see "Unresolvable CRITICAL" procedure below. |
| HIGH | HIGH | MUST add as NFR or documented design decision in PRD. User sees it in the spec. |
| MEDIUM | MEDIUM | Add to `Edge cases and failure modes` table in PRD as advisory. |
| LOW | LOW | Log in state file only. No PRD change. |

### Unresolvable CRITICAL Procedure

When a CRITICAL finding cannot be resolved by editing the PRD (e.g. the PRD
describes an inherently fan-out write or unbounded read that requires an
architectural redesign), the skill MUST:

1. Record the finding verbatim in the `## API Performance Gate` section of the
   state file with status `UNRESOLVED`.
2. **Do NOT present the PRD to the user without surfacing the blocker.** Instead,
   surface an explicit blocker message before "Present and Confirm":

```
PERFORMANCE GATE BLOCKER — unresolvable CRITICAL finding:
  Finding: <title>
  Reason it cannot be auto-resolved: <one line>
  Required action: <what the user must decide / redesign>

The PRD cannot be presented until this is resolved. Please clarify the
architectural direction so the spec can be updated.
```

3. Wait for user input. Resume "Present and Confirm" only after the user
   provides a resolution direction and the PRD is updated accordingly.

---

## Execution Procedure

> **Who runs this:** the `api-perf-cost-auditor` agent, spawned by the `/prd`
> skill at Step 4.5. Steps below are instructions to that agent. The agent
> MUST complete all steps before returning control to the skill.

**Step 0 — Prior findings check (delta-run guard).**
Read the `## API Performance Gate` section of the state file. If a prior run
exists (e.g. `/prd-add` re-triggered this gate):
- Extract all entries from the `findings[]` array where `status: RESOLVED`.
- Do NOT re-flag any resolved item unless the PRD delta introduces a new
  instance of the same pattern (same root cause, different location is a new
  finding; same location + same root cause = duplicate, skip).
- Record the prior run's resolved-finding list in your working context as
  `PRIOR_RESOLVED = [...]` before starting Gate 5.

**Step 1 — Skip-condition check.**
Evaluate the Skip Conditions table in the "When to Run" section. If a skip
condition applies, write `API Performance Gate: SKIPPED — <reason>` to the
state file and return `status: SKIPPED` to the calling skill. Stop here.

**Step 2 — Run Gate 5 on the full PRD text.**

**Step 3 — If Gate 5 finds zero matches AND the PRD has no API/data sections:**
Write `API Performance Gate: N/A — no API/data surface` to the state file and
return `status: NA`. Stop here.

**Step 4 — Run Gates 1-4.** Apply stack-specific addenda from `baldart.config.yml`.

**Step 5 — For each finding, apply the Severity Actions table:**
- CRITICAL: attempt to fix in PRD. If unresolvable → follow "Unresolvable
  CRITICAL" procedure (STOP and surface to user; do NOT proceed to Present).
- CRITICAL + security handoff (RLS): record as CRITICAL with
  `→ HANDOFF TO security-reviewer`; do not attempt to write security policy.
- HIGH: add as NFR or design decision in PRD.
- MEDIUM: add to edge cases table.
- LOW: note in state file.

**Step 6 — Update state file.** Write the machine-readable block below to the
`## API Performance Gate` section. This is consumed by the audit-phase
`api-perf-cost-auditor` run (prd/references/audit-phase.md § Step 6) to avoid
re-flagging already-resolved items when auditing backlog cards:

```
## API Performance Gate
run_at: <ISO timestamp>
status: PASS | PASS_WITH_NOTES | BLOCKER | SKIPPED | NA
findings_total: N
findings_by_severity:
  critical: N  # resolved in PRD (or UNRESOLVED — see below)
  high: N      # added as NFR
  medium: N    # added to edge cases
  low: N       # state file only
findings:
  - title: <finding title>
    severity: CRITICAL | HIGH | MEDIUM | LOW
    status: RESOLVED | UNRESOLVED | SECURITY_HANDOFF
    resolution: <one line — what was changed in the PRD, or "" if UNRESOLVED>
    handoff_to: security-reviewer  # only when status == SECURITY_HANDOFF
```

**Step 7 — If any CRITICAL findings were resolved:** add a
`### Performance-Informed Decisions` subsection to the PRD listing what was
changed and why.

**Step 8 — Return to skill.** Return `status: <value>` from Step 6
(machine-readable). If `status == BLOCKER`, the skill MUST NOT proceed
to "Present and Confirm" — the blocker message in the Severity Actions section
is displayed to the user instead.

---

## Reference Data

Cite ONLY the section matching the project's `stack.database` /
`stack.framework` / `stack.deployment`. Sections for other stacks are
informational and should be removed from the populated PRD section.

### Database pricing & cost-model snapshots

**Firestore (Blaze Plan):** Reads $0.06/100K, Writes $0.18/100K, Deletes $0.02/100K. Offset pagination charges for ALL skipped docs. Index writes: N+1 per document. Listener: 1 read/matched doc at attach + 1 per change.

**Supabase / Postgres:** Compute + storage + egress billing. Hot row updates serialize on a single page; HOT updates and `FILLFACTOR` matter for write-heavy tables. Sequential scans above the project's threshold (see `${paths.references_dir}/perf-budgets.md`; default: >100k rows) are a CRITICAL finding regardless of price (see Gate 2).

**MongoDB Atlas:** Cluster tier dictates IOPS ceiling. Aggregation `$lookup` cost ≈ N queries when uncached. WiredTiger cache hit rate <90% on hot path = HIGH finding.

**DynamoDB:** On-demand vs provisioned billing model dictates RCU/WCU sizing. Hot partition (skewed PK distribution) triggers throttle errors before billing impact shows.

### Runtime limits (by `stack.deployment`)

| Platform | Payload | Duration | Notes |
|----------|---------|----------|-------|
| Vercel (Fluid Compute) | 4.5 MB | 300s Hobby / 800s Pro | Active CPU pricing, FD limit 1024 shared, archived after 2w prod / 48h preview |
| Cloudflare Workers | 100 MB | 30s CPU | No FD pool, smaller heap |
| AWS Lambda | 6 MB sync / 256 KB async | 900s | Cold start ~100-500ms; provisioned concurrency to mitigate |
| Firebase Functions (gen 2) | 32 MB | 540s 2nd-gen | Cold start ~200-2000ms |

### Framework caching primitives (by `stack.framework`)

**Next.js App Router:** GET Route Handlers DYNAMIC by default since v15 (opt-in to caching via `revalidate` / `'use cache'`). `'use cache'` in serverless: in-memory LRU, not persisted across cold starts. `React.cache()`: single-request dedup only. Sequential `await` = waterfall; use `Promise.all()` for independent fetches.

**Remix:** `headers` function sets `Cache-Control`; `loader` revalidation via `shouldRevalidate`. No built-in cache layer — relies on CDN.

**SvelteKit:** `setHeaders({'cache-control': ...})` in `load`; `prerender = true` for static; Edge vs Node runtime per route.

**Astro:** SSG by default; `output: 'server'` enables on-demand rendering; per-route `prerender` toggle.

<!-- Skip conditions have been moved to "When to Run" § Skip Conditions table
     and Execution Procedure Step 1. Do not add skip logic here. -->
