First-party only — a session id and where you arrived from, so we can tell which pages are worth writing more of. No advertising, no third-party trackers, nothing that follows you off this site.
11 ambiguities caught, one authorisation surface avoided, one DoS found in review.
examples/api-endpoint.md · 4.0 KB
# Worked example — a new API endpoint
The request: *"We need an endpoint to list a customer's invoices."*
Abbreviated to show what each stage produced and what changed as a result.
---
## 01 Intake
Asked six questions. The two that mattered:
> **What already exists?** `internal/http/orders.go` implements an almost
> identical list endpoint with cursor pagination — `listOrders`, line 42.
> Extending that pattern is likely right.
>
> **What do you need from me?** Can a customer list *another* customer's
> invoices if they have an admin role? What counts as an invoice for an
> order that was refunded?
Both were real. Neither was in the original request.
## 02 Spec
Fourteen behaviours. Eleven decisions surfaced. The three that changed the
work:
| Question | Chosen |
|---|---|
| Refunded orders — invoice still listed? | Yes, with `status: refunded`. Hiding them broke reconciliation. |
| Admin listing another customer's invoices | Separate endpoint under `/admin`. Overloading this one would have put an authorisation branch in a hot path. |
| Default sort | `issued_at desc`. Unspecified originally; the agent would have defaulted to `id`. |
The second one is the interesting failure that did not happen. Left
unasked, the implementation would have added a role check inside the
customer endpoint — which works, ships, and creates an authorisation
surface nobody reviewed.
## 03 Risk pass
Found two things:
- `invoices` had no index on `(customer_id, issued_at)`. The endpoint would
have worked and been slow at 50k rows.
- The existing `listOrders` used offset pagination. Copying its pattern
would have inherited a known problem. → cursor pagination, and a
follow-up filed for orders.
Neither was in the spec. Both became plan steps.
## Gate 1
Fifteen minutes. Three decisions escalated to whoever owned billing.
## 04 Plan
Six steps:
1. Migration: index on `(customer_id, issued_at)` — `CREATE INDEX CONCURRENTLY`
2. `Invoice` list DTO + cursor codec
3. Repository method + tests
4. Handler + tests
5. Route registration + auth middleware
6. OpenAPI spec update
Each verified with a scoped test command. Working tree green after each.
## 05 Scaffold
Signatures + 14 failing tests, one per spec behaviour.
**Caught at gate 2:** the repository method took `customerID string` and
returned `[]Invoice`. No cursor in either. The cursor had been specified but
did not exist in the signature — it would have been bolted on during
implementation and probably leaked into the handler.
Two-minute fix. Would have been an hour after implementation.
## 06 Implement
Six turns, one per step. Step 3 stopped:
> The spec says refunded invoices appear with `status: refunded`, but the
> `invoices` table has no `status` column — refund state lives on `orders`.
> This needs a join the plan did not anticipate. Stopping.
Correct behaviour. Went back to stage 04, added a step, continued.
## 07 Self-review
Fresh context. Eleven findings; four real:
1. Cursor was not validated — a malformed cursor caused a 500 instead of 400
2. No test for a cursor pointing at a deleted invoice
3. `limit` had no maximum — `?limit=100000` was allowed
4. Timezone: `issued_at` compared against a local-time cursor
Finding 3 is the kind that reaches production: it passes every test, it is
correct for every reasonable input, and it is a denial-of-service.
## 08 Backfill
Four tests, one per finding. Then broke the implementation four times to
confirm each new test caught it. One did not — the timezone test was
asserting on a value computed the same way the code computed it. Rewritten
with a hardcoded expected timestamp.
## 09 Writeup
Named the two places to look hardest: the cursor codec and the refund join.
Both got real review comments. Neither would have been flagged by a
description that said "adds an invoices endpoint".
---
## The scorecard
Ambiguities caught before code: **11**
Design problems caught at gate 2: **1** (~1 hour saved)
Bugs caught in self-review: **4**, one of them a DoS
Decorative tests caught in backfill: **1**
Total pipeline overhead: roughly 40 minutes.The rest of Spec-to-Ship is written the same way. $49 once, yours permanently, 14-day refund.