Skip to content

Spec Examples — Weak to Strong

Reference page. Each entry shows the request as it actually gets typed into a chat window, then the abridged-but-real spec that should have existed, then the specific agent failure the spec prevents. The strong versions follow the anatomy in Writing Effective Specs; fill-in-able skeletons are in Templates: Specs and Planning.

All examples share one invented stack so details stay concrete: a project-management SaaS — Node/Express + Postgres backend, React dashboard, Stripe billing.

a. Feature request: "add notifications"

Weak:

text
add notifications so users know when stuff happens in their projects.
in-app for now, maybe email later. keep it simple.

Strong (abridged):

markdown
# Spec: In-app notifications — v1

## Intent
Users miss task assignments and mentions, then get pinged on Slack anyway.
Goal: never miss an assignment/mention. Anti-goal: engagement bait — no
digests, no badges on marketing surfaces.

## Functional requirements
- FR-1: Notify on exactly 3 events: task assigned to you, @mention in a
  comment, task you own moved to Blocked.
- FR-2: Bell icon in top nav shows unread count (cap display at "9+").
- FR-3: Panel lists newest-first, 20 per page, mark-read on click;
  "mark all read" button.
- FR-4: Self-actions never notify (assigning a task to yourself).

## Data model
notifications: id, user_id FK NOT NULL, type enum(assigned|mentioned|blocked),
subject_id uuid NOT NULL, actor_id FK, read_at timestamptz NULL,
created_at NOT NULL. Index: (user_id, read_at, created_at DESC).

## Acceptance criteria
- [ ] Assigning a task creates exactly one notification for the assignee
      within the same transaction (no queue in v1).
- [ ] Unread count query does an index-only scan (verify EXPLAIN in PR).
- [ ] Deleting a task hard-deletes its notifications (FK cascade test).
- [ ] Self-assignment produces zero rows.

## Out of scope (do not build)
- Email/Slack/push channels, preference/settings UI, digests,
  notification grouping, real-time websocket delivery (poll every 60s).

What it prevents: the weak version reliably produces a notification platform — preference tables, channel abstractions, a websocket layer — because that's what "notifications" looks like in training data. The out-of-scope list plus "3 events, enumerated" caps a 3-day gold-plating spiral into a half-day feature. See Scope and Context Failures.

b. Backend: webhook ingestion endpoint

Weak:

text
we need an endpoint to receive stripe webhooks and update subscription
status in our db. handle errors properly.

Strong (abridged):

markdown
# Spec: Stripe webhook ingestion

## Intent
Single ingress for Stripe events. Correctness rule that dominates all others:
processing the same event twice must be harmless; dropping an event must be
recoverable via replay.

## Endpoint
POST /webhooks/stripe   (raw body required — signature verification needs it,
so this route must be excluded from the global JSON body parser)

## Auth
- Verify Stripe-Signature with STRIPE_WEBHOOK_SECRET, tolerance 5 min.
- Invalid signature: 400, log at WARN with event id if parseable. No retry —
  Stripe retries on 4xx are pointless for bad signatures.

## Idempotency
- Table webhook_events(stripe_event_id text PRIMARY KEY, type, status
  enum(received|processed|failed), received_at, processed_at).
- INSERT ... ON CONFLICT DO NOTHING first; if the row already existed in
  status processed, return 200 immediately (duplicate delivery).

## Processing and retries
- Handle: customer.subscription.updated|deleted, invoice.payment_failed.
  All other types: record row, return 200, do nothing (forward-compat).
- Handler runs synchronously; must complete < 10s (Stripe timeout is ~10s;
  budget 5s p99). If handler throws: mark failed, return 500 — Stripe's
  retry (exponential, ~3 days) is our retry mechanism. No internal queue in v1.

## Rate limiting
- None on this route (Stripe is the only caller and is signature-gated);
  explicitly exempt from the global limiter in src/middleware/rateLimit.ts.

## Acceptance criteria
- [ ] Same event POSTed twice: second returns 200, handler ran once
      (assert on webhook_events + subscription state).
- [ ] Tampered payload with valid-looking header: 400, no DB writes.
- [ ] Unknown event type: 200, row recorded, no handler invoked.
- [ ] Handler exception: 500 returned, row status = failed, safe to redeliver.

What it prevents: without the idempotency ledger and the "Stripe is our retry queue" decision, agents produce the classic wrong shape — parse JSON body (breaking signature verification), process inline, return 200 unconditionally, and add a redundant internal retry queue. Each of those is a plausible guess; two of them cause double-charged-state bugs that surface weeks later.

c. Frontend: data-table screen

Weak:

text
build an admin page listing all workspace members in a table with
search and the usual stuff. use our existing components.

Strong (abridged):

markdown
# Spec: Workspace members admin table

## Route and permissions
/settings/members — visible to role admin|owner. Members with role member
who hit the URL directly get the existing <NoAccess /> screen, not a redirect.

## Columns
Name (avatar + name, sorts by last name), Email, Role (badge), Last active
(relative, tooltip = absolute UTC), Actions (kebab: Change role, Remove).
Owner rows: Remove is disabled with tooltip "Transfer ownership first".

## Data and pagination
GET /api/workspaces/:id/members?page&per=25&q&sort — server-side pagination
and search (debounce input 300ms). URL is the source of truth for
page/q/sort (deep-linkable, survives refresh).

## States (all five, no exceptions)
- Loading: skeleton rows (existing <TableSkeleton rows={8} />), not a spinner.
- Empty (no members besides self): illustration + "Invite your team" CTA.
- Empty (search miss): "No members match '<q>'" + clear-search link. Distinct
  from the no-members state.
- Error: inline retry panel (existing <RetryPanel />); keep last good data
  visible if this was a refetch, full-panel only on first load.
- Partial action failure: role change fails -> toast + revert the row
  (optimistic update with rollback).

## Acceptance criteria
- [ ] Refresh on page 3 with a search term restores page 3 + term from URL.
- [ ] Role=member direct URL hit renders NoAccess (test, not manual check).
- [ ] Both empty states rendered in Storybook stories.
- [ ] Remove action requires confirm dialog; Enter does NOT confirm it.

## Out of scope
Bulk selection/actions, CSV export, column customization, virtualized
scrolling (25/page is fine), inline editing of any field.

What it prevents: "the usual stuff" is where agents ship the happy path only — one empty state, no distinction between first-load and refetch errors, client-side pagination that dies at 2,000 members, and state kept in React instead of the URL. Enumerating the five states makes "done" checkable instead of vibes; see Shallow Implementation.

d. Data model: subscriptions and billing

Weak:

text
add tables for plans and subscriptions so we can bill workspaces.
should support upgrades and cancellation.

Strong (abridged):

markdown
# Spec: Billing data model

## Entities
plans — catalog, code-managed, never deleted (rows are deactivated).
  id, key text UNIQUE (e.g. 'pro_monthly'), price_cents int NOT NULL CHECK
  (price_cents >= 0), interval enum(month|year), seat_limit int NULL
  (NULL = unlimited), active bool NOT NULL DEFAULT true.

subscriptions — one live subscription per workspace, history preserved.
  id, workspace_id FK NOT NULL, plan_id FK NOT NULL,
  status enum(trialing|active|past_due|canceled) NOT NULL,
  stripe_subscription_id text UNIQUE NULL (NULL only while trialing),
  current_period_start/end timestamptz NOT NULL,
  canceled_at timestamptz NULL, cancel_at_period_end bool NOT NULL DEFAULT false.
  Partial unique index: UNIQUE (workspace_id) WHERE status IN
  ('trialing','active','past_due')  -- one live sub; canceled history kept.

invoices — mirror of Stripe, read model only. Never mutated by app logic;
  written only by the webhook ingester. id, subscription_id FK,
  stripe_invoice_id UNIQUE NOT NULL, amount_cents int, status, issued_at.

## Invariants (enforce in DB where possible, else in service layer + test)
- I-1: Money is integer cents everywhere. No numeric/float columns.
- I-2: An upgrade is a plan_id change on the live row + Stripe proration —
  never a new subscription row. Cancel/resubscribe IS a new row.
- I-3: status transitions only: trialing->active|canceled,
  active->past_due|canceled, past_due->active|canceled. Enforce in
  service layer; test the full matrix including forbidden ones.

## Migration
Additive. Backfill: existing workspaces get a subscriptions row on plan
'legacy_free', status active. Backfill script must be idempotent (rerunnable).

What it prevents: the two guesses agents make here — floats for money and "just UNIQUE(workspace_id)" — are both catastrophic-later: rounding drift in invoices, and no ability to keep canceled-subscription history. Invariants written as I-1/I-2/I-3 also survive into every future session that touches billing, which is exactly where Docs as Memory pays out.

e. Bug report: "login sometimes fails"

Weak:

text
users are saying login sometimes fails and they have to try again.
can you look into it and fix it?

Strong (abridged):

markdown
# Bug: intermittent 500 on POST /auth/login

## Repro
~2% of logins. Concentrated 09:00–10:00 UTC (Mon spike). Retry immediately
succeeds. Not reproducible locally so far.

## Expected vs actual
Expected: 200 + session cookie. Actual: 500, body {"error":"internal"},
no Set-Cookie.

## Evidence
- Sentry AUTH-2291: "TimeoutError: Connection terminated due to
  connection timeout" at src/auth/session.ts:47 (pg pool checkout).
- pg_pool_waiting_count spikes to 15–20 in the same windows (Grafana,
  db-pool dashboard). Pool max is 10 (src/db/pool.ts).
- Deploy 2026-06-28 (#812) added 3 queries to login for the new audit
  trail. First AUTH-2291 event: 2026-06-29.

## Suspected surface
Login transaction now holds a pool connection across an external call
(GeoIP lookup, src/auth/audit.ts:31) — checkout starvation under the
Monday-morning login burst. Confirm before fixing.

## Fix constraints
- Confirm root cause first: reproduce starvation in a test or staging
  load run BEFORE writing the fix. If evidence contradicts the
  hypothesis, stop and report — do not fix speculatively.
- Do NOT just raise pool max — that moves the cliff, and the DB is
  shared. Acceptable: move GeoIP outside the transaction, or make
  audit-write async. Keep the audit trail (compliance).
- Add a regression test + an alert on pg_pool_waiting_count > 5 for 60s.

What it prevents: given the weak version, agents pattern-match "flaky login" to a plausible generic fix — add a retry around the query, bump a timeout — declare victory, and the incident recurs next Monday. The evidence section pins the investigation to real signals, and "confirm before fixing / stop if contradicted" blocks confident-but-wrong root-causing. See Hallucination and False Confidence and Production Debugging.

f. Refactor: extract a service layer

Weak:

text
our express route handlers are huge and full of business logic.
extract a proper service layer. don't break anything.

Strong (abridged):

markdown
# Spec: Extract service layer from route handlers — projects domain only

## Scope
src/routes/projects.ts (612 lines, 9 handlers) -> src/services/projects/.
Other domains (auth, billing, members) are explicitly out of scope; this
PR chain is the template for them later.

## Target shape
- Services take plain params + a ctx {db, currentUser}, return data or
  throw typed errors (NotFoundError, ForbiddenError — src/lib/errors.ts).
- Handlers shrink to: parse/validate input -> call service -> map result
  and typed errors to HTTP. No knex usage left in any handler (lint rule
  no-restricted-imports on 'knex' under src/routes/ lands in the final PR).

## Invariants — behavior may not change
- INV-1: Every route returns byte-identical status codes and JSON shapes.
  Error bodies included ({"error": string, "detail"?: string}).
- INV-2: Transaction boundaries stay exactly where they are today
  (createProject wraps project+default-board insert; keep that).
- INV-3: Audit log calls fire the same events with the same payloads.

## No-behavior-change gate
- BEFORE any extraction: characterization tests capturing current
  request/response for all 9 routes, including 403/404/422 paths
  (supertest snapshots). These merge as PR 1 and must pass unchanged
  on every subsequent PR.

## Sequencing (one PR each, reviewable in < 30 min)
1. Characterization tests (no production code changes).
2. Extract read-only handlers (list, get) — lowest risk, sets conventions.
3. Extract mutations (create, update, archive) — INV-2 applies.
4. Extract permissions checks into the service layer; delete dead code;
   enable the lint rule.

## Out of scope
Renaming routes, "improving" validation or error messages, touching other
domains, introducing a DI framework or repository pattern.

What it prevents: "extract a service layer, don't break anything" as a single prompt yields a 3,000-line big-bang diff where behavior changes hide inside the restructuring — the least reviewable artifact an agent can produce. Characterization-tests-first turns "don't break anything" from a plea into a gate, and the sequencing keeps each diff reviewable. Full playbook: Legacy Refactor.

g. Deployment: zero-downtime deploy with a migration

Weak:

text
we're renaming projects.owner_id to lead_id and need to deploy it without
downtime. write the migration and deploy steps.

Strong (abridged):

markdown
# Spec: Zero-downtime rename projects.owner_id -> lead_id

## Constraint that shapes everything
Rolling deploy: old and new app code run side by side for ~10 min. Any
step must be safe with BOTH versions live. Therefore: expand/contract,
never a rename in place.

## Sequence (each step = separate PR/deploy; do not batch)
1. EXPAND — migration: ADD COLUMN lead_id (nullable, no default);
   backfill in batches of 5,000 rows with pg_sleep(0.1) between batches
   (projects has 4.2M rows; one UPDATE would lock it for minutes);
   then trigger to dual-write owner_id <-> lead_id.
2. Deploy code that READS lead_id (fallback to owner_id when NULL),
   WRITES both. Verify: log a metric on fallback-read; expect it to
   drop to ~0 within a day.
3. Verify parity: SELECT count(*) WHERE owner_id IS DISTINCT FROM
   lead_id must be 0. Gate: do not proceed on nonzero — investigate.
4. Deploy code that reads/writes lead_id only.
5. CONTRACT — after 1 full week at step 4: drop trigger, drop owner_id,
   add NOT NULL on lead_id. Separate migration; needs its own review.

## Rollback map
Steps 1–2: redeploy previous code; column is additive, nothing to undo.
Step 4: redeploy step-2 code (dual-write still on — this is why the
trigger stays until step 5). Step 5: NOT reversible cheaply — hence the
one-week soak and the parity gate.

## Acceptance criteria
- [ ] Each migration runs with lock_timeout='2s' and retries; a blocked
      ALTER must fail fast, not queue behind long transactions.
- [ ] Staging rehearsal of steps 1–5 against a prod-sized snapshot,
      with old+new code deliberately overlapped at each boundary.
- [ ] Fallback-read metric exists and is on the deploy dashboard.

What it prevents: asked for "a migration," agents produce ALTER TABLE ... RENAME COLUMN plus a same-deploy code change — correct in a world with atomic deploys, an outage in yours. The spec encodes the one fact the agent cannot infer (old and new code overlap) and the rollback map forces reversibility to be designed, not hoped for. See Migration.

A field manual for AI-native software engineering.