Checks one running service: its API, failure handling, limits, and security. Use it for this input: service. Report each check as PASS, FAIL, or N/A with file:line evidence. A FAIL carries the card's severity. Any blockFAIL means request changes. A should-fix or discussFAIL goes in the review but does not block.
FAIL [block] chg-expand-contract - src/a.py:42 - renames the column and drops the old one in one migration
FAIL [should-fix] chg-one-concern - src/b.py:7 - a logging rewrite rides along with the bug fix
PASS chg-breaking-change-taxonomy - src/c.py:15 - no signature or return shape changes
N/A chg-perf-claim-evidence - the description makes no performance claim
Checklist
CardSeverityConcernsRule
svc-api-conditional-writesshould-fixcorrectnessReturn an ETag on reads and honour If-Match on writes, answering 412 when the entity tag is stale.
svc-api-deprecation-sunsetshould-fixcompatibilityBefore removing an endpoint, mark it deprecated in the spec and send Deprecation and Sunset headers, with the Sunset date no earlier than the Deprecation date.
svc-api-error-code-stableshould-fixcompatibilityGive each error a stable machine-readable code that callers branch on, and never make them parse the message text.
svc-api-versioning-policyshould-fixcompatibilityWrite down one versioning scheme for the API, and ship no breaking change inside a version it has published.
svc-auth-throttle-generic-errorsblocksecurityLimit failed attempts per account, and return the same message and status code whether or not the account exists.
svc-authz-central-deny-defaultblocksecurityRoute every request through one authorization layer that denies when no rule allows it.
svc-bound-client-controlled-sizesshould-fixsecurityCap every client-controlled size and count on the server: body bytes, string length, array items, page size and batch size.
svc-bounded-background-tasksblockreliabilitycorrectnessCap how many background tasks run at once, and never spawn one per input with no limit.
svc-cors-allowlistshould-fixsecurityAllow cross-origin access only to a fixed list of named origins, and never echo the request Origin header back unchecked.
svc-csrf-state-changingblocksecurityRequire a CSRF token (or an equivalent header check) on every cookie-authenticated request that changes state, and never change state on GET.
svc-idempotency-keyblockreliabilitycorrectnessMake every retryable write idempotent: the caller sends a unique key, and a retry with the same key never applies the write twice.
svc-list-endpoint-paginatesshould-fixperformancePaginate every list endpoint, with a default page size and a server-side maximum.
svc-no-redundant-entryshould-fixusabilityaccessibilityDo not ask for information the user already gave in the same flow. Fill it in, or let the user pick it.
svc-rate-limit-quotashould-fixsecurityreliabilityEnforce a per-tenant quota on shared APIs and queues.
svc-reflow-and-zoomshould-fixaccessibilityusabilityMake the page usable at 320 CSS pixels wide and at 200% text zoom, with no sideways scrolling, no clipped content, and no blocked pinch-zoom.
svc-retry-policyblockreliabilityRetry only transient errors, with capped attempts and jittered exponential backoff.
svc-session-lifecycleblocksecurityIssue a new session ID at login, and set Secure, HttpOnly and SameSite on the session cookie.
svc-slow-operation-shows-progressshould-fixusabilityaccessibilityShow that a slow action is under way, and show its progress when it runs long.
svc-sqli-authnblocksecurityPass untrusted data to queries as bound parameters, never by string concatenation or formatting.
svc-timeoutsblockperformancereliabilitySet an explicit timeout on every remote call.
svc-upload-validate-storeblocksecurityAllowlist the file type, cap the size, and store each upload under a generated name where the server will not execute it.
What the badges mean
blockmust be fixed before merge or release.
should-fixfix it, but it need not block.
discussraise it and decide together.
stablevetted; use for verdicts.
draftnot vetted; do not use for a verdict.
Tool gate: what a linter or CI check could enforce instead of a reviewer.
Filters
Concerns
Topic tags
Topics
Cards
svc-api-conditional-writes
should-fixstablecorrectness
Applies when: Two callers can update the same resource through PUT, PATCH or DELETE.
Rule. Return an ETag on reads and honour If-Match on writes, answering 412 when the entity tag is stale.
Why. A read-modify-write over HTTP is a race. The second writer overwrites the first without knowing it, and no error appears anywhere.
FAIL
GET /docs/4 -> 200 {title: "A"}
PUT /docs/4 {title: "B"} -> 200 # overwrites a change made after the GET
PASS
GET /docs/4 -> 200 ETag: "v7"
PUT /docs/4 If-Match: "v7" -> 200 ETag: "v8"
PUT /docs/4 If-Match: "v7" -> 412 Precondition Failed
Counterpoint. "Last write wins is fine here." For a log or a counter it is; say so in the spec. For a document, a profile or a config that two people edit, it loses work. The card applies to shared resources that people edit.
Tool gate: Integration test that sends a write with a stale If-Match and expects 412.
Applies when: A service retires an endpoint, field, version or parameter that outside callers use.
Rule. Before removing an endpoint, mark it deprecated in the spec and send Deprecation and Sunset headers, with the Sunset date no earlier than the Deprecation date.
Why. Callers cannot read your roadmap. A removal with no signal in the responses is an outage for whoever has not read the changelog. Counting calls that carry the headers also tells you when removal is safe.
FAIL
git: delete GET /v1/orders/{id}/summary
responses before removal: no header, spec not marked deprecated
Counterpoint. "Our only callers are internal." Then the call count with the header gives you the list of owners to ask, and removal is cheap. chg-expand-contract covers the order of steps; this card covers what the response tells the caller. Both headers describe one resource, so send them on each deprecated route, not only on the API root.
Tool gate: Middleware that adds both headers for routes tagged deprecated; a spec linter on deprecated: true without a sunset date; a dashboard counting calls that carry the headers.
Applies when: Callers of an HTTP or RPC API branch on the kind of error.
Rule. Give each error a stable machine-readable code that callers branch on, and never make them parse the message text.
Why. Message text gets reworded, translated and fixed. A client that matches on "already exists" breaks on the first edit. A published code is a contract.
FAIL
if "already exists" in response.json()["detail"]: # client
handle_conflict()
PASS
if response.json()["type"] == "https://api.example.com/problems/order-exists": # client
handle_conflict()
# the server keeps that type value for as long as the API version lives
Counterpoint. "The HTTP status is enough." It is too coarse: two different 409 conflicts need different handling. Add a code only where callers act differently; do not mint one for every branch.
Tool gate: Spec linter that requires an enumerated error type or reason per operation; a test that pins the codes.
Applies when: A service offers an HTTP API to callers you do not deploy together with it.
Rule. Write down one versioning scheme for the API, and ship no breaking change inside a version it has published.
Why. Callers upgrade on their own schedule. With no stated scheme, each breaking change is a surprise, and each team invents its own v2.
FAIL
GET /orders # renamed total -> amount_total
no version marker anywhere; README says "we try not to break things"
PASS
docs/api-versioning.md: dated api-version query parameter, previews last 6 months, 90 days' notice
GET /orders?api-version=2026-03-01 keeps returning "total"
the rename ships under api-version=2026-09-01
Counterpoint. "We will cross that bridge later." Later arrives with a customer on the old shape. The scheme can be minimal. A media type, a dated query parameter and a pinned header all work. Pick one and write it down. What counts as breaking is in chg-breaking-change-taxonomy.
Tool gate: Spec diff in CI that fails on a breaking change inside a published version.
Applies when: The service has a login, password-reset or one-time-code endpoint.
Rule. Limit failed attempts per account, and return the same message and status code whether or not the account exists.
Why. Unlimited guesses make passwords and 6-digit codes brute-forceable. Different errors for "no such user" and "wrong password" let an attacker list valid accounts first. Limit per account, since a per-IP limit is bypassed by rotating addresses. Response time leaks the same fact: if the code hashes the password only when the user exists, a fast reply means "no such user", so hash on every attempt. Registration needs its own design: give the same reply whether or not the address is taken, and email the owner of an existing account instead of creating a second one.
FAIL
if not user: return "No such user", 404
if not check(pw): return "Wrong password", 401 # no attempt limit
PASS
if too_many_failures(account_key): return "Try again later", 429 # checked before the password
ok = check(pw, user.pw_hash if user else DUMMY_HASH) # hash runs either way
if not (user and ok): record_failure(account_key); return "Invalid credentials", 401
Counterpoint. "A lockout lets attackers lock out real users." Use a rising delay, not a permanent lock, and keep the recovery flow reachable. Log every lockout so abuse shows up.
Tool gate: Rate-limit middleware on auth routes; test that known and unknown users get identical responses.
Applies when: The service exposes routes or functions that different roles may call.
Rule. Route every request through one authorization layer that denies when no rule allows it.
Why. Per-handler checks drift: one admin route ships without its decorator and any user can call it. A central layer with deny as the fallback turns a forgotten check into a 403 instead of a hole.
FAIL
@app.route("/admin/export") # no role check; framework default is allow
def export(): ...
PASS
policy = {"export": {"admin"}} # endpoint -> roles, reviewed in one place
@app.before_request
def gate(): require_role(policy.get(request.endpoint)) # missing entry -> 403
Counterpoint. "A central policy is a bottleneck for feature teams." Keep the engine central and let each team own its rows. The cost of a one-line policy entry is small next to an unguarded admin route.
Tool gate: Router that denies routes with no policy; a test that lists every route and its required role.
Applies when: A request parameter or body sets how many records, bytes, items or operations the server handles.
Rule. Cap every client-controlled size and count on the server: body bytes, string length, array items, page size and batch size.
Why. A request for limit=10000000 or a batch of 50,000 operations lets one client exhaust memory or the bill for a paid backend. The cap has to sit in the server, since a UI limit is not one. svc-rate-limit-quota covers request rate; this card covers size.
FAIL
limit = int(request.args["limit"])
rows = db.fetch(limit) # no ceiling
items: list[Item] # schema with no maxItems
PASS
limit = max(1, min(int(request.args.get("limit", 50)), 100))
items: list[Item] = Field(max_length=100) # body capped at 1 MB by the proxy
Counterpoint. "Legit clients sometimes need big batches." Offer a documented bulk or export endpoint with its own quota. The default path stays capped.
Tool gate: Gateway request-size limit; schema maxLength/maxItems; test with a 10 MB body and limit=1000000.
Applies when: The code starts a task, goroutine, or thread per item, request, or message.
Rule. Cap how many background tasks run at once, and never spawn one per input with no limit.
Why. Input size is not under your control. One task per item works for ten items and exhausts memory, connections, or the downstream service at ten thousand, and the failure lands on everything else in the process. Go's errgroup.SetLimit, asyncio's Semaphore and Tokio's Semaphore exist for exactly this.
FAIL
for msg in batch: # batch size comes from the queue
asyncio.create_task(handle(msg)) # 50k messages -> 50k concurrent handlers
PASS
async def worker(queue):
while (msg := await queue.get()) is not None:
await handle(msg)
queue = asyncio.Queue(maxsize=100)
async with asyncio.TaskGroup() as tg:
for _ in range(32): # at most 32 handlers at once
tg.create_task(worker(queue))
for msg in batch:
await queue.put(msg)
for _ in range(32):
await queue.put(None) # one stop signal per worker
Counterpoint. "Tasks are cheap." Creating them is, but what each one holds (a socket, a buffer, a database connection) is not. A semaphore around each handler also caps concurrency, though it still creates one task object per input. This card covers work that a service starts for itself. sys-load-shedding-bounded-queues covers refusing incoming requests under load.
Applies when: The service sends Access-Control-Allow-Origin or similar CORS headers.
Rule. Allow cross-origin access only to a fixed list of named origins, and never echo the request Origin header back unchecked.
Why. Reflecting any Origin, or allowing * on an endpoint that returns private data, lets a hostile page read responses. CORS also does not stop CSRF, so it is no substitute for svc-csrf-state-changing. A subdomain regex that matches evil-example.com is the same bug, and so is allowing the null origin.
ALLOWED = {"https://app.example.com"}
if request.headers.get("Origin") in ALLOWED:
resp.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
resp.headers["Vary"] = "Origin" # caches keep one copy per origin
Counterpoint. "* is fine for a public API." It is, for endpoints that return only public data and take no credentials. Keep it off any endpoint behind a login.
Tool gate: Integration test that sends a hostile Origin and asserts it is not echoed.
Applies when: The service authenticates browsers with cookies and changes state on requests.
Rule. Require a CSRF token (or an equivalent header check) on every cookie-authenticated request that changes state, and never change state on GET.
Why. The browser attaches cookies to a cross-site request, so a hostile page can post as the user. SameSite helps but Lax still sends cookies on cross-site top-level GET, so it supports the token and does not replace it. Naive double-submit cookies fail when a sibling subdomain can set cookies.
FAIL
@app.get("/account/delete") # state change on GET
@app.post("/transfer") # cookie auth, no token check
PASS
@app.post("/transfer")
@csrf_protect # token bound to the session
Set-Cookie: sid=...; SameSite=Lax; Secure; HttpOnly
Counterpoint. "Our API uses JSON, so browsers cannot forge it." A simple content type such as text/plain can carry a JSON body without a preflight. Reject it, or require a custom header such as X-CSRF-Token. Pure bearer-token APIs with no cookies are exempt.
Tool gate: Framework CSRF middleware enabled; test that a POST without the token returns 403.
Applies when: A non-idempotent operation (charge, create, send) can be retried by a client, a queue, or a saga.
Rule. Make every retryable write idempotent: the caller sends a unique key, and a retry with the same key never applies the write twice.
Why. A timeout does not say whether the first attempt ran. Retrying without a key charges twice or sends two emails. A common way to answer the retry is to store the first result and return it again.
FAIL
POST /payments {amount: 10} # retried on timeout
INSERT INTO payment(amount) VALUES (10)
PASS
POST /payments Idempotency-Key: k-123
INSERT ... ON CONFLICT (idem_key) DO UPDATE SET idem_key = EXCLUDED.idem_key RETURNING *
See also.sys-lock-fencing shares the goal but uses a different mechanism (stale lock holders).
Counterpoint. "Our retries are rare." Queues redeliver and sagas retry by design, so rare is not never. The cost is a unique key and a stored result. No standard fixes how long the server keeps a key. Pick a window and publish it.
Applies when: The service exposes an endpoint or RPC that returns a collection.
Rule. Paginate every list endpoint, with a default page size and a server-side maximum.
Why. A list that returns everything grows with the data until one request exhausts memory or the timeout. Adding pagination later breaks clients, so start with it. Without a maximum, a caller can ask for a page as big as the table.
FAIL
GET /orders -> every order the user ever placed
GET /orders?limit=1000000 (server honours it)
PASS
GET /orders?page_size=50&page_token=abc (default 25, values above 100 clamped to 100)
response: { "items": [...], "next_page_token": "def" }
Counterpoint. "This list is always small." Say so with a bounded type or a documented limit, since data tends to outgrow its first estimate. Cursors cost more than offsets to build, but they stay stable while rows change.
Tool gate: Contract test that calls the list with a huge page size and asserts the clamp or a 400.
Applies when: A multi-step flow, such as checkout or signup, asks for data in more than one step.
Rule. Do not ask for information the user already gave in the same flow. Fill it in, or let the user pick it.
Why. Typing the same address twice costs time and adds typos. People recognise what they gave more easily than they recall it, so show it and let them confirm it.
<!-- step 2 -->
<label><input type="checkbox" name="same" checked> Billing address is the same as shipping</label>
<input name="billing_address" value="12 Elm Road, Leeds" hidden> <!-- shown, filled in, if the box is unchecked -->
Counterpoint. "Retyping a new password confirms it." That is a fair exception. So is data that is no longer valid, and re-entry that is the point of the task, such as a memory test. Browser autofill does not count: the page itself must supply the value.
Tool gate: none; a walk through the flow that lists each field asked twice.
Applies when: A shared API or queue serves several tenants or callers.
Rule. Enforce a per-tenant quota on shared APIs and queues.
Why. Without a quota, one noisy tenant consumes shared capacity for everyone. Also add a per-tenant concurrency cap, and return 429 for over-quota but 503 for the service's own lack of capacity, so clients do not retry quota errors as capacity problems.
FAIL
every tenant shares one worker pool and one request budget
no per-tenant limit: one tenant's batch job starves the rest
PASS
limiter.check(tenant_id) -> 429 + Retry-After
per-tenant semaphore on workers
Counterpoint. "One global rate limit is simpler." A global limit protects the service, but one tenant can use all of it, and then every other tenant gets errors. A per-tenant limit costs one key per tenant in the limiter. Some older services return 503 for quota errors, so the status code is a convention; what matters is that a client can tell a quota error from a capacity error. Shuffle sharding (per-tenant worker subsets) isolates tenants further, and this card does not require it.
Applies when: A page or app is served to browsers on screens of any size.
Rule. Make the page usable at 320 CSS pixels wide and at 200% text zoom, with no sideways scrolling, no clipped content, and no blocked pinch-zoom.
Why. Low-vision users enlarge the page instead of using a small screen. A fixed width or a clipped container hides content from exactly those users. user-scalable=no takes away their only tool.
.layout { max-width: 60rem; padding-inline: 1rem; }
.card { min-height: 7.5rem; }
.table-wrap { overflow-x: auto; } /* a data table scrolls in its own box */
Counterpoint. "Maps and data tables need two-way scrolling." They are exempt, but only the part that needs it. Wrap the table in its own scroll box, and keep the headings and controls around it reflowing.
Tool gate: axe meta-viewport; a rendered check at a 320 px viewport and at 200% text zoom.
Applies when: The code retries a failed call, or wraps a client that retries.
Rule. Retry only transient errors, with capped attempts and jittered exponential backoff.
Why. Immediate, unbounded, or synchronized retries turn a brief fault into a load spike on the struggling dependency. Retry at one layer only: three layers of three attempts is nine calls per user request. Make retried writes safe with svc-idempotency-key.
FAIL
while True:
try: return client.call()
except Exception: sleep(1) # no cap, no jitter, retries 4xx too
PASS
import random, time
for i in range(3):
try:
return client.call()
except TransientError:
if i == 2:
raise
time.sleep(random.uniform(0, min(CAP_S, BASE_S * 2**i)))
# retry at one layer only: the client's own retries are off
Counterpoint. "Retries make us more reliable." Only for transient faults and only within a budget. Past that they hurt, so a non-transient error should fail at once. A retry budget (retries capped as a share of traffic) helps further. This card leaves its size to the team.
Applies when: The service sets a session cookie or session ID after login.
Rule. Issue a new session ID at login, and set Secure, HttpOnly and SameSite on the session cookie.
Why. A session ID that survives login can be planted by an attacker beforehand (fixation). A cookie without HttpOnly is readable by any injected script, and one without Secure travels over plain HTTP. Also end the session on the server at logout, because clearing only the browser cookie leaves a stolen copy valid, and enforce idle and absolute timeouts on the server.
FAIL
Set-Cookie: sid=abc123
login(): keep the pre-login session id
Counterpoint. "The framework already does this." Some do: Spring Security changes the session ID at login by default, and Rails gives you reset_session. Hand-written login code and custom session stores often skip it, and cookie flags depend on configuration. Check the login handler and the real Set-Cookie header, not the framework's reputation.
Tool gate: Header test on the login response; session fixation test in the end-to-end suite.
Applies when: A page action, such as a save, an upload, a search, or a page load, can take more than about a second.
Rule. Show that a slow action is under way, and show its progress when it runs long.
Why. With no feedback, users click again, reload, or leave, and a double click can run the action twice. A bar tells them whether to wait. A message that says what is happening is better than "Loading". As a guide, show feedback after about one second. Past about ten seconds, show how much is done and offer Cancel where stopping is safe.
<button onClick={busy ? undefined : upload} aria-disabled={busy}>Upload</button>
{busy && <progress value={sent} max={total} aria-label="Uploading report.pdf" />}
{busy && <button onClick={cancel}>Cancel</button>}
<div role="status">{message}</div> // "Uploading report.pdf", then "Upload complete"
// aria-disabled keeps keyboard focus on the button; disabled would drop it
Counterpoint. "A spinner on a 200 ms call flickers." Right: below about a second, show nothing, or the placeholder just flashes. Skeleton screens suit page loads, and progress bars suit uploads and other long jobs.
Tool gate: none; throttle the network and run the action.
Applies when: The code builds a SQL query that includes request data or other untrusted input.
Rule. Pass untrusted data to queries as bound parameters, never by string concatenation or formatting.
Why. Concatenated input becomes SQL, so a value of 1 OR 1=1 reads every row and a quote ends the string early. A bound parameter travels apart from the query text, and the database never parses it as SQL.
FAIL
db.execute("SELECT * FROM users WHERE id = " + req.id)
db.execute(f"SELECT * FROM users WHERE name = '{name}'")
PASS
db.execute("SELECT * FROM users WHERE id = %s", (req.id,))
db.execute("SELECT * FROM users WHERE name = %s", (name,))
Counterpoint. "An ORM makes this moot." Raw fragments still bypass it, and so do table, column and ORDER BY names, which cannot be bound. Map those to a fixed allowlist in code.
Applies when: The code calls anything over a network or process boundary.
Rule. Set an explicit timeout on every remote call.
Why. A call without a bound holds a thread, a connection, and a caller indefinitely, so one slow dependency becomes an outage. Many clients, gRPC and requests among them, set no timeout by default. Where the caller has a deadline of its own, pass the remaining time down: a per-hop timeout that ignores it keeps working for a client that already gave up, and a retry loop shares that one deadline across its attempts.
FAIL
resp = requests.get(url) # no timeout: can wait forever
reply = stub.GetUser(request) # gRPC sets no deadline by default
PASS
client = httpx.Client(timeout=httpx.Timeout(10.0, connect=2.0)) # set once on the client
resp = client.get(url)
def GetProfile(self, request, context):
# bounded, and no longer than what the caller has left
return stub.GetUser(request, timeout=min(2.0, context.time_remaining()))
Counterpoint. "Defaults are fine; the library will time out eventually." Many defaults are infinite, and an eventual timeout longer than the caller's own deadline is wasted work. Configure the client's timeout once at construction, connect and read separately, then shrink it per call to what the caller has left. Pick the numbers from measured latency, such as the downstream p99.9, not folklore.
Rule. Allowlist the file type, cap the size, and store each upload under a generated name where the server will not execute it.
Why. The client-supplied name and Content-Type are attacker text. A user-chosen name allows path tricks, and an upload served from the web root with its original extension can run as code. Count the decompressed size of archives. Storing outside the web root and serving files through a handler, or from a separate domain, keeps a file that slips past the type check from running. fn-confine-file-path covers the path check.
FAIL
path = f"/var/www/uploads/{file.filename}"
file.save(path) # any type, any size, public URL
PASS
if ext(file.filename) not in {".png", ".jpg"} or size > 5_000_000: abort(400)
file.save(STORE / f"{uuid4()}.bin") # outside web root; served by /files/<id>
Counterpoint. "The extension check is enough." The extension is attacker text, and double extensions or parser quirks get past a check on it. The generated name and a storage place that never executes files are what make a file that slips through harmless.
Tool gate: Upload tests with a renamed .php, an oversized file and a zip bomb.
A draft card is not vetted. Read it for ideas, but do not use it for a verdict.
svc-api-cache-control
should-fixdraftperformance
Applies when: A service returns responses that a browser, CDN, gateway or client library may cache.
Rule. Set an explicit Cache-Control on every response a cache may store, use private or no-store for user-specific data, and add Vary for any request header that changes the body.
Why. With no directive, a cache may guess a lifetime from Last-Modified. A shared cache may then serve one user's response to another, or serve stale data for hours after a write.
FAIL
GET /me/orders -> 200
(no Cache-Control, no Vary; body differs per Authorization)
PASS
GET /me/orders -> 200 Cache-Control: private, no-cache
GET /plans -> 200 Cache-Control: public, max-age=300
Vary: Accept-Language
Counterpoint. "Our CDN is configured to bypass the cache." That config is a second place to get wrong, and it does not cover browsers. The origin states the intent; the CDN may tighten it. no-store is not a privacy control against a cache that ignores it, so keep secrets out of cacheable URLs too.
Tool gate: Integration test or header lint that rejects a 200 GET without Cache-Control.
Applies when: A service calls, or is called by, another service owned by a different team or deployed on its own schedule.
Rule. Run a contract test for each cross-service call, derived from the consumer's real requests, and fail the provider's build when it breaks one.
Why. A provider's own tests check what the provider thinks callers need. A spec check says what could happen, not what the consumer relies on. Only a check built from the consumer's usage catches the removed field before deploy, without a full-system end-to-end run.
FAIL
billing calls GET /customers/{id} and reads "vat_id"
customers team removes "vat_id"; its tests pass; billing breaks in production
PASS
billing CI publishes a contract: GET /customers/{id} -> body has "vat_id"
customers CI verifies every published contract and fails on the removal
Counterpoint. "End-to-end tests cover it." They are slow, need every service running, and run late. Contract tests run per service in minutes. Pact is one way; a consumer-written example set replayed against the provider also passes. For services deployed together from one repo, mark the card N/A.
Tool gate: Contract test run in CI for both sides, and a gate that blocks deploy when a consumer's contract fails against the provider.
Applies when: A service has an OpenAPI description, or callers depend on its error responses.
Rule. Document every error status each operation can return, with a schema and one example.
Why. Callers write error handling from the spec. An operation that lists only 200 teaches them nothing about 404, 409, 422 or 429, so they find out in production.
/orders/{id}:
get:
responses:
"200": {description: ok}
"404": {$ref: "#/components/responses/Problem"} # has an example
"429": {$ref: "#/components/responses/RateLimited"}
Counterpoint. "A shared default response covers everything." OpenAPI allows default, but it hides which codes carry which meaning. List the codes a caller must act on and use default for the rest.
Tool gate: Spec linter that requires 4xx and 5xx responses with a schema on every operation, and an example on each.
Applies when: A service defines JSON fields, query parameters, headers or path segments for an HTTP API.
Rule. Use one casing convention per location (fields, query parameters, headers, paths) across the whole API, and state it in the API guide.
Why. Mixed casing in one API makes callers look up each name. A client that guesses orderId after meeting order_id gets a silent null.
FAIL
GET /orders?pageSize=20 -> {"order_id": 1, "createdAt": "...", "Total-Cents": 500}
PASS
GET /orders?page_size=20 -> {"order_id": 1, "created_at": "...", "total_cents": 500}
# snake_case everywhere, as docs/api-guide.md says
Counterpoint. "Pick the right convention." The sources disagree: Zalando asks for snake_case, Microsoft for camelCase in fields. Either passes. Do not change an existing API's casing to match a guide; that is a breaking change.
Tool gate: Spec linter with a casing rule per location.
Applies when: A service returns errors from an HTTP API to other services or to clients.
Rule. Return errors as application/problem+json with a status that equals the HTTP status, and leave stack traces and internal detail out.
Why. Callers then parse every error one way. A body that differs per endpoint, or a status that disagrees with the real code, makes generic client and proxy handling unreliable. A stack trace in detail hands an attacker the implementation.
FAIL
HTTP/1.1 500
Content-Type: text/html
<pre>NullPointerException at OrderRepo.java:88 ...</pre>
PASS
HTTP/1.1 404
Content-Type: application/problem+json
{"type": "https://api.example.com/problems/order-not-found",
"title": "Order not found", "status": 404,
"detail": "No order with id 7731 for this account."}
Counterpoint. "We already have our own error envelope." That is fine inside one team's API, if it is one envelope and documented. The card asks for one shape everywhere; problem details is the one with a registered media type and existing client support. See sys-secrets-and-errors for what must stay out of any error.
Tool gate: Middleware that builds every error body from one problem-details type; a test that fails on an error response without the media type.
Applies when: A service answers 429 or 503 to HTTP callers.
Rule. Send Retry-After on every 429 and 503, and set it no earlier than the time capacity returns.
Why. Without it, every client invents its own backoff, and a hundred of them retry in the same second. The header lets the server pace its own recovery.
FAIL
HTTP/1.1 429 Too Many Requests
{"error": "slow down"}
PASS
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json
Counterpoint. "Clients should use exponential backoff anyway." They should, and svc-retry-policy says so. A server hint narrows the guess; it does not replace the client's cap. The IETF RateLimit header fields are still an Internet-Draft, so the card requires only Retry-After, which is in the HTTP standard. The quota itself is svc-rate-limit-quota.
Tool gate: Integration test that forces a 429 and a 503 and asserts a Retry-After header.
Applies when: A service exposes HTTP endpoints, or a handler changes which method it accepts.
Rule. Keep GET, HEAD and OPTIONS free of side effects, and make PUT and DELETE give the same end state when repeated.
Why. Clients, caches, prefetchers and crawlers repeat safe methods freely, and clients and proxies retry PUT and DELETE automatically after a connection failure. A GET that deletes or counts, or a PUT that appends, turns a retry into data loss or duplicates.
FAIL
GET /reports/7/archive -> moves report 7 to the archive
PUT /carts/9/items -> appends the item again on each call
PASS
POST /reports/7/archive -> state change under an unsafe method
PUT /carts/9/items/42 {qty: 2} -> sets the item; repeating it changes nothing
Counterpoint. "We need a link a user can click to confirm an email." Make the click a GET that shows a page, and have the page submit a POST. Logs or counters written by a GET are fine; the rule covers changes to resource state that a caller can see. Retries of POST fall under svc-idempotency-key.
Tool gate: API linter, and an integration test that calls each GET twice and each PUT or DELETE twice and compares state.
Applies when: A service publishes an OpenAPI (Swagger) description that callers, SDK generators or gateways read.
Rule. Keep the OpenAPI document as the source of truth, and generate it from the code or test the running service against it in CI.
Why. A hand-edited spec drifts from the handlers. Callers and generated SDKs then code against fields and codes the service does not return.
FAIL
openapi.yaml (hand-written, last edited 14 months ago)
POST /orders returns {id, status, total_cents}; spec lists {id, status, total}
no CI step reads openapi.yaml
PASS
CI: schemathesis run openapi.yaml --url $STAGING # fails on any response the spec does not describe
or: the framework emits the spec from the handler types and the file is committed
Counterpoint. "The spec is documentation, a stale one does little harm." Generated SDKs, gateways and mock servers read it as fact, and a wrong spec fails there first. If the team does not publish a spec, mark the card N/A.
Tool gate: Contract test or schema validation in CI that runs requests against the service and checks responses against the spec, or a spec generated from the code.
Applies when: A service returns HTTP responses that callers or intermediaries interpret by status code.
Rule. Return the most specific status code that fits: 201 with Location for a create, 405 with Allow for a wrong method, and a 4xx or 5xx for any failure.
Why. Clients, proxies and monitors act on the status line. A 200 with an error in the body hides failures from retries, caches and error-rate alerts. A create that returns 200 without Location forces the caller to guess the new URL.
FAIL
POST /orders -> 200 {"error": "out of stock"}
PATCH /orders (route is read-only) -> 404, no Allow header
PASS
POST /orders -> 201 Location: /orders/9031
POST /orders (out of stock) -> 409 application/problem+json
PATCH /orders (not supported) -> 405 Allow: GET, POST
Counterpoint. "A 200 with a status field is easier for our clients." It breaks every generic tool that reads the status line, including your own dashboards. Choosing between two fitting codes is a style call; the card fails only a code that says the wrong thing.
Tool gate: Integration tests asserting status and headers per operation; a spec linter for 201 and 405 responses.
Applies when: A service calls another service over HTTP while handling a request.
Rule. Forward traceparent and tracestate on every outgoing call, keep the incoming trace id, and set a new parent id for each hop.
Why. A hop that drops the header splits one request into two traces. When something is slow or failing, the trace ends at the service that forgot, and the next hop cannot be found.
FAIL
resp = http.post(PAYMENTS_URL, json=body) # no trace headers; a new trace starts in payments
Counterpoint. "A mesh or agent handles it." Then show it propagates through your queue consumers and thread pools too; a sidecar sees only the network edge. Never put personal data in either header, since they travel to third parties. The id in log lines is svc-request-id-logging.
Tool gate: Instrumentation library or HTTP-client middleware that injects traceparent; an integration test that checks the outgoing header.
Applies when: A page loads images, embeds, fonts, ads, or fetched content after the first paint.
Rule. Reserve the space for late-loading content with set dimensions or an aspect ratio, so nothing already on screen moves.
Why. A page that jumps while someone reads or taps makes them hit the wrong target. A cumulative layout shift of 0.1 or less is good, and over 0.25 is poor, measured on real users at the 75th percentile.
FAIL
<img src="/hero.jpg">
<div id="promo"></div> <!-- script inserts a banner above the article -->
Counterpoint. "Ads vary in height." Reserve the common height, or the largest, and accept some blank space. A banner that pushes the article down is the worse trade.
Tool gate: Lighthouse CI assertion on cumulative-layout-shift.
Applies when: The service has routes that need a signed-in user next to routes that are public.
Rule. Require authentication on every route by default, and mark each public route by name.
Why. When each handler opts in to authentication, the one that forgets is open to the internet, and nothing in the diff looks wrong. A global fallback flips the default: a new route is closed until someone marks it public, and that mark is a line a reviewer can question. Health checks and internal admin ports count as routes too.
FAIL
@app.get("/invoices/<id>") # no login check; anonymous callers get the invoice
def invoice(id): ...
PASS
PUBLIC = {"login", "health", "static"}
@app.before_request
def require_login():
if request.endpoint not in PUBLIC and not current_user.is_authenticated:
abort(401)
Counterpoint. "Our gateway already checks the token." Then the service trusts every caller that can reach it without the gateway: another pod, a debug port, a misrouted path. Keep the gateway check and the fallback in the service. The fallback costs one middleware.
Tool gate: A framework fallback that requires authentication (ASP.NET Core FallbackPolicy, Django LoginRequiredMiddleware, Spring Security anyRequest().authenticated()); a test that calls every route without credentials and expects 401 or a login redirect unless the route is on the public list.
Applies when: The critical path of a request or a job calls a dependency whose data the product can do without, such as enrichment, recommendations, analytics, or a rate-limit control plane.
Rule. Give each call to a non-critical dependency a short budget and a default, so its failure degrades the result instead of failing the critical path, and count every fallback.
Why. An order page should not fail because the recommendations service is slow. Netflix makes enrichment best-effort, so a slow dependency can degrade its knowledge graph but never block it. Uber's rate-limit clients let traffic through when the control plane is down, because blocking would cause an outage of its own. Cabify drops non-critical details from a driver offer when it cannot collect them in time. It also puts metrics and alerts on every such fallback, because SLO dashboards built for critical flows miss them.
FAIL
def trip_offer(trip_id):
trip = trips.get(trip_id, timeout=0.5)
eta = traffic.eta(trip.route, timeout=2.0) # optional detail, but an error here fails the offer
return Offer(trip, eta)
PASS
def trip_offer(trip_id):
trip = trips.get(trip_id, timeout=0.5)
try:
eta = traffic.eta(trip.route, timeout=0.1)
except (TimeoutError, ConnectionError):
eta = None # the offer goes out without the ETA
degraded.labels(dep="traffic").inc() # alert on this rate
return Offer(trip, eta)
Counterpoint. "A silent default hides an outage." It does when nobody counts it, so the PASS needs the metric and an alert on it. Call a dependency non-critical only when the product works without its data. Authentication, payment, and authorization checks are critical, and they fail closed. Failing open on a rate limiter gives up protection to keep availability, so write that choice into the design. For the timeout itself, see svc-timeouts. To check that the fallback really runs, see prod-kill-switch-fallback.
Applies when: A service calls several dependencies from shared pools, or retries a dependency that may be down.
Rule. Give each critical dependency its own concurrency limit.
Why. One shared pool lets a slow dependency use every thread and starve healthy paths. Retrying a dead dependency only adds load, so also fail fast through a breaker with limited half-open probing where retries are demonstrably harmful.
FAIL
pool = ThreadPool(50) # used by billing, search, email alike
breaker = None # retries a dead host every request
PASS
pools = {"billing": asyncio.Semaphore(10), "search": asyncio.Semaphore(20)}
# pseudocode: breaker logs and counts every state change; half-open allows 1 probe
Counterpoint. "More moving parts than a timeout." True. Start with the timeout and the per-dependency limit, and add the breaker where retries are demonstrably harmful. Retry and breaker have opposite aims, so do not present one as a substitute for the other.
Applies when: A command-line tool has a command that deletes, overwrites or sends something.
Rule. Give a destructive command a preview flag that changes nothing and a flag that skips the confirmation prompt.
Why. Without a preview, the first run is the real one. Without a bypass, a script cannot run the command at all, so people write wrappers that pipe "yes" into it.
FAIL
$ prune-backups
Delete 41 backups? [y/N] # no --dry-run, no --yes
PASS
$ prune-backups --dry-run # lists the 41 backups, deletes none
$ prune-backups --yes # deletes without asking
Counterpoint. "A bypass flag invites accidents." The prompt stays the default for people, and the flag has to be typed on purpose. Pair it with svc-cli-non-interactive.
Tool gate: CLI test that runs the command with the preview flag and checks nothing changed.
Applies when: The code ships a command-line tool that scripts or agents may run.
Rule. Give every input a flag, use a prompt only as a fallback, and exit at once with an example call when a required flag is missing.
Why. A script or an agent has no one to answer a prompt, so the command hangs until a timeout kills it. A failure with a usable example lets the caller fix the call in one step.
FAIL
$ deploy
Choose environment: _ # waits on stdin forever in CI
Applies when: The service serves HTML pages to browsers.
Rule. Send a Content-Security-Policy whose script-src uses a per-response nonce or script hashes, with object-src 'none' and base-uri 'none'.
Why. When an XSS bug slips through, CSP decides whether the injected script runs. A host allowlist such as script-src 'self' cdn.example usually has a bypass, because some allowed host serves JSONP or an old library. A nonce or hash only lets through scripts the server marked. 'strict-dynamic' lets those scripts load their own children. Browsers that support nonces ignore an 'unsafe-inline' fallback placed next to one, so that fallback is safe. 'unsafe-inline' on its own turns the policy off for inline script.
Counterpoint. "A strict CSP breaks inline handlers and third-party tags." It does at first, so roll it out with Content-Security-Policy-Report-Only, move onclick= attributes into script files, and switch to enforce when the reports go quiet. A policy that never leaves report-only protects nothing, so give that step a date.
Tool gate: Google CSP Evaluator on the policy; a CI header check against a staging URL.
Applies when: The code consumes from a queue or stream.
Rule. Give every consumer queue a dead-letter queue and a way to replay its messages after the fix.
Why. A poison message otherwise loops forever, uses the consumers' capacity and hides the real backlog. Cap the retries, so that a failing message moves to the dead-letter queue after a few attempts. Alarm on any message in the dead-letter queue, because each one is a bug to fix. Also keep the visibility timeout longer than the worst-case processing time (or extend it), or slow work is delivered twice.
FAIL
resource "aws_sqs_queue" "orders" {
name = "orders" # no redrive_policy: a message that always fails is retried forever
}
PASS
resource "aws_sqs_queue" "orders_dlq" {
name = "orders-dlq"
}
resource "aws_sqs_queue" "orders" {
name = "orders"
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.orders_dlq.arn
maxReceiveCount = 5
})
}
# replay after the fix: aws sqs start-message-move-task --source-arn <orders-dlq ARN>
Counterpoint. "A dead-letter queue just hides failures." Only if nobody watches it. With an alarm on its depth it shows each failure once, instead of hiding it in retries, and the replay step means a fix does not lose the messages that failed before it.
Tool gate: A config check that every consumed queue has a dead-letter target (for SQS in Terraform, a redrive_policy with maxReceiveCount), and an alarm on dead-letter queue depth.
Applies when: A client or server puts an API key, session token, bearer token, password, or personal data into a URL.
Rule. Send tokens, keys, passwords, and personal data in a header or the request body, never in the path or query string.
Why. A URL is copied to places nobody reviews: proxy and access logs, browser history, the Referer header sent to third-party scripts and images, and links pasted into chat. A token there stays readable long after the request ends. The Authorization header and a POST body skip all of those copies.
FAIL
GET /api/orders?api_key=sk_live_abc123
GET /reset?email=ana@example.com&token=9f2c
PASS
GET /api/orders
Authorization: Bearer sk_live_abc123
POST /reset
Content-Type: application/json
{"token": "9f2c"}
Counterpoint. "Email links have to carry the token in the URL." Make that token single-use and short-lived, have the landing page swap it for a session at once, and send Referrer-Policy: no-referrer on that page. A long-lived API key has no such excuse.
Applies when: An app serves several pages or client-side routes under one HTML shell.
Rule. Give every page and every client-side view its own <title> that says what it is.
Why. The title labels the browser tab, the history, search results, and bookmarks, and it helps users find where they are. If every route says "My App", a user cannot tell the pages apart. In a single-page app the shell's title stays put unless the code changes it, so it goes on describing the first view.
FAIL
<title>My App</title>
<!-- the same title on /billing, /settings, and /billing/error -->
PASS
function usePageTitle(title) {
useEffect(() => { document.title = `${title} - My App`; }, [title]);
}
function Settings() { usePageTitle("Settings"); /* ... */ }
function Billing({ hasErrors }) {
usePageTitle(hasErrors ? "Error: Billing details" : "Billing details");
/* ... */
}
Counterpoint. "The router library sets it." Then test that it does for each route. A router that changes the URL and the view but not the title is the common failure.
Tool gate: axe document-title catches a missing or empty title only; a test that navigates each route and asserts its document.title.
Applies when: One API or store serves requests whose cost differs by orders of magnitude, such as GraphQL queries, scans next to point reads, or bulk endpoints.
Rule. When request costs differ by orders of magnitude, charge each request its estimated cost against the quota instead of counting it as one.
Why. A request count treats a one-row lookup and a 100,000-row scan the same, so a client within its quota can still overload the backend. Shopify computes a GraphQL query's cost by static analysis before it runs: one point per object, nothing for scalars, and a budget of 50 points per second up to 1,000. GitHub's GraphQL API also limits points, not calls. Airbnb's key-value store charges request units from the bytes read and the latency of each operation.
FAIL
def handle(tenant, query):
if not limiter.take(tenant, 1): # a full scan costs the same as a point read
raise TooManyRequests()
return run(query)
PASS
def handle(tenant, query):
cost = estimate_cost(query) # static: one point per object requested
if not limiter.take(tenant, cost):
raise TooManyRequests(retry_after=limiter.wait_for(tenant, cost))
return run(query)
Counterpoint. "A cost model is guesswork." Uber tried a bytes-based quota for its storage and dropped it. A full table scan that returned one row looked cheap, and the shared counter in Redis added a network hop and a failure point. Base the cost on something you can measure, such as objects requested or bytes read, check it against real load, and keep a concurrency limit as a backstop. Where every request costs about the same, a request count is fine and the card is N/A. Which key carries the quota is svc-rate-limit-quota. This card covers the unit it counts in.
Applies when: A service handles requests and writes logs while doing so.
Rule. Put the request or trace id on every log line written during a request.
Why. Concurrent requests interleave in one log. Without a shared id, you cannot pull one request's lines together or match them with other services' logs and the trace.
FAIL
log.info("payment failed") # which request?
PASS
log.info("payment failed", extra={"trace_id": ctx.trace_id}) # id set once by middleware
See also.svc-api-trace-context covers the traceparent header that carries the id between services.
Counterpoint. "Ids are high-cardinality and will blow up our metrics." Keep them out of metric labels (prod-wide-events-bounded-labels). Logs and events are where per-request ids belong.
Tool gate: Logging middleware or formatter that injects the id; test that fails on a log line without it.
Applies when: A business operation spans services, each with its own database, coordinated as a saga.
Rule. Make steps after the pivot idempotent and retryable, and make every compensation idempotent with a failure path.
Why. After the pivot there is no undo, only retry until done. A compensation can itself fail, and unhandled it leaves the system inconsistent with nobody told.
step 4: charge_card(order, idempotency_key=order.id) # safe to retry
compensate: release_stock(order) retried; on exhaustion -> alert + manual queue
Counterpoint. "Use a distributed transaction." Often unavailable across services. A saga has no isolation, so also guard against reading half-done data (semantic locks, re-reading values before acting). svc-idempotency-key covers keys, and svc-dlq covers failed messages.
Rule. Log every failed authentication with the account name and a timestamp.
Why. Without these events, password guessing in progress is invisible and an incident cannot be reconstructed. Log authorization denials and validation failures the same way, and add a correlation ID so one request can be followed across services. Never log passwords, tokens or session IDs, because the log then becomes a second leak. Untrusted values with CR or LF can forge log lines, so use structured output.
Counterpoint. "Logging every failed login is noisy." Log them as one event type, count them, and alert on the rate, not on each line. Without the events, nobody sees the attack.
Tool gate: Test that a failed login produces one structured log line with the account and a timestamp.
Applies when: The service serves HTML pages or sets cookies for browsers over HTTPS.
Rule. Send Strict-Transport-Security on every HTTPS response.
Why. Without HSTS, the first plain-HTTP request can be downgraded by anyone on the path, and a user can click through a certificate warning. With it, the browser rewrites http to https and refuses the click-through. Two more one-line headers belong next to it: X-Content-Type-Options: nosniff, so a browser does not run an upload as script, and CSP frame-ancestors (or X-Frame-Options: DENY), so a hostile page cannot frame yours for clickjacking.
FAIL
HTTP/1.1 200 OK
Content-Type: text/html
(no Strict-Transport-Security)
Counterpoint. "HSTS can lock users out if the certificate lapses." True, so ramp max-age up and check the preload rules before you submit the domain. A lockout from an expired certificate is an outage you fix, while a downgrade is a breach.
Tool gate: Header check in CI against a staging URL; framework middleware that sets defaults.
Applies when: A handler reads, transforms, or returns a result whose size depends on the data (files, query results, response bodies).
Rule. Process large results as a stream with backpressure; do not load them whole into memory.
Why. A fast source piles data into memory faster than a slow consumer drains it. In the Node docs' test, memory grew from about 88 MB to about 1.5 GB. Memory then scales with data size, not with the code.
def generate():
for row in db.execute("SELECT * FROM events"): # server-side cursor
yield encode(row)
return StreamingResponse(generate())
Counterpoint. "Streaming complicates errors and retries." Yes: a failure mid-stream cannot change the status code. For small, bounded results, loading the whole result is simpler and fine; state the bound. sys-load-shedding-bounded-queues covers queue limits.
Tool gate: Memory limit in tests with a large fixture.
Applies when: The service or its load balancer terminates TLS.
Rule. Accept only TLS 1.2 and TLS 1.3 on servers, and set that minimum in the config instead of relying on the default.
Why. TLS 1.0 and 1.1 lack modern cipher suites and are deprecated by RFC 8996, and SSL 3.0 is broken outright. A server that still offers them lets an attacker on the path force a downgrade, and it fails compliance scans. Library defaults have moved to TLS 1.2, but an explicit lower setting, an old base image, or a load-balancer policy from years ago keeps the old versions alive. With TLS 1.2, prefer AEAD suites with forward secrecy.
Counterpoint. "Some of our users have old clients." NIST SP 800-52 lets public-facing servers keep TLS 1.0 and 1.1 when needed, after checking who still uses them. Measure first: log the negotiated version, and if old clients are a real share, serve them from a separate endpoint with an end date instead of weakening every endpoint.
Tool gate: testssl.sh or a similar scan against the public endpoint in CI; the load balancer's named security policy checked in config review.