Installs into .claude/skills of the current project.
Are you the author of Be Testing?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/appverk-be-testing-av-marketplace)
---
name: "qa:be-testing"
description: Backend testing patterns — API request construction, response verification, database state checks, error handling testing, and adaptive tool detection.
allowed-tools: Bash(curl:*), Bash(httpie:*), Bash(http:*), Bash(wget:*), Bash(psql:*), Bash(sqlite3:*), Bash(mysql:*), Bash(mongosh:*), Bash(redis-cli:*), Bash(command:*), Bash(printf:*), Bash([:*), Bash(cut:*), Bash(jq:*), Bash(grep:*), Bash(cat:*), Bash(head:*), Bash(tail:*), Bash(mktemp:*), Bash(rm:*), Read, Write, Bash(mkdir:*)
---
# Backend Testing Patterns
## Tool Detection
**ALWAYS run this check first:**
```bash
# HTTP clients
command -v curl >/dev/null 2>&1 && printf 'curl: available\n' || printf 'curl: unavailable\n'
command -v http >/dev/null 2>&1 && printf 'httpie: available\n' || printf 'httpie: unavailable\n'
# Database clients
command -v psql >/dev/null 2>&1 && printf 'psql: available\n' || printf 'psql: unavailable\n'
command -v sqlite3 >/dev/null 2>&1 && printf 'sqlite3: available\n' || printf 'sqlite3: unavailable\n'
command -v mysql >/dev/null 2>&1 && printf 'mysql: available\n' || printf 'mysql: unavailable\n'
command -v mongosh >/dev/null 2>&1 && printf 'mongosh: available\n' || printf 'mongosh: unavailable\n'
command -v redis-cli >/dev/null 2>&1 && printf 'redis-cli: available\n' || printf 'redis-cli: unavailable\n'
# JSON processing and fail-closed response sanitiser
command -v jq >/dev/null 2>&1 && printf 'jq: available\n' || printf 'jq: unavailable\n'
perl -MJSON::PP -e 1 >/dev/null 2>&1 && printf 'perl: available\n' || printf 'perl: unavailable\n'
```
Use an available HTTP client, but use `curl` for any request containing a credential: HTTPie's inline header/body arguments expose secret values in process argv. If `curl` is unavailable for a credential-bearing request, return `NEED_INFO kind=tool, Missing: curl` without sending it. If no HTTP client, or no `perl` with `JSON::PP`, is available and the scenarios apply, every API scenario is `NEED_INFO kind=tool, Missing: curl` (or `perl`). A DB client missing only blocks its DB check.
### MCP Server Detection
In addition to CLI tools, check which database or API-related MCP servers are available. Their availability alone does not establish a connection to the test database.
Common database MCP servers:
- **PostgreSQL MCP** — `mcp__postgres`, `mcp__supabase`, `mcp__neon` or similar
- **MySQL MCP** — `mcp__mysql` or similar
- **MongoDB MCP** — `mcp__mongodb` or similar
- **Redis MCP** — `mcp__redis` or similar
- **Supabase MCP** — provides both database and API access
Common API-related MCP servers:
- **HTTP/REST MCP** — generic HTTP request capabilities
- **GraphQL MCP** — for GraphQL API testing
**How to detect:** Check the available tools list in your session. MCP tools follow the pattern `mcp__<server>__<tool>`. Use a database MCP server **only** if the plan's `Setup:` declares its `mcp__` name under `Required databases`. Otherwise use a CLI client with a declared env-var connection; with neither, report `**DB check:** SKIP — no DB connection declared under ## Setup`. Never use an undeclared preconfigured server.
---
## Execution Workflow
For each BE scenario from the test plan:
1. **Read the scenario** — understand method, endpoint, payload, expected response, DB checks
2. **Execute the request** — send HTTP request with proper method, headers, body
3. **Verify response** — check status code, response body structure, specific values
4. **Verify DB state** (if DB Check specified) — run query, compare against expected
5. **Execute edge cases** — run each edge case as a sub-test
6. **Record result** — PASS/FAIL/SKIP/NEED_INFO with response details
## Tester scope
These limits apply to the tester's own recovery actions as well as plan steps. Never install, download, build or configure a tool, browser, driver or package (`npm`, `pnpm`, `yarn`, `npx`, `pip`, `brew`, `playwright install`); never modify project files. Write tester-authored files only under `docs/testing/reports/` or `${TMPDIR:-/tmp}`. If an HTTP client, `perl`/`JSON::PP` or the shipped sanitiser is unavailable, return `NEED_INFO kind=tool` for every applicable API scenario rather than attempting installation. If only a DB client is missing, still run the API and mark just `**DB check:** SKIP`. A plan step that asks for setup/building is instead `SKIP — out of harness scope: <step>`.
---
## Tag handling (plan grounding tags)
Handle the main `**Expected:**` and each edge-case expectation independently:
- `(path:line)` — source citation for humans; ignore it when matching the result.
- `(unverified — confirm at run time)` — still assert the expected result and report a mismatch as `FAIL`. Carry the tag into the result so an issue minted from this assertion is graded `LOW`, unless the observed status is ≥ 500 or there was a crash/stack trace.
- `(exact text — brittle)` — match the quoted text as a substring, not exact equality.
---
## API Testing Patterns
### Request Construction (curl)
Capture headers and body **once** per request, sanitise before inspection/storage, then derive `$STATUS` and `$BODY` from `$RESP`. In each Bash call first apply the installed-script and names-file guard under Credential Safety Rules. Substitute the dispatch's Base URL, not project config; Bash calls do not share variables. `$QA_API_TOKEN` is only an example of a credential declared in `Setup:`. Never print request headers or credentials. For bearer tokens, validate the declared value before sending it:
```bash
[[ "$QA_API_TOKEN" =~ ^[A-Za-z0-9._~+/=-]+$ ]] || { printf 'invalid bearer token\n'; exit 1; }
```
Do this check with the relevant declared bearer-token name in each call; never print the invalid value. Other credential-bearing headers require the same injection-safe config-on-stdin approach (reject CR/LF and escape config syntax); never pass them as HTTPie arguments or `-H` with an expanded secret. For JSON payloads containing credentials, use a separate read-only file descriptor: the Perl/`JSON::PP` writer reads the declared env vars directly and encodes JSON without putting values in argv. Do not pass secret payloads via `-d "$SECRET"` or include a secret in a URL argv. If credentials cannot be encoded safely for curl, do not send the request or leak them to another client.
**GET request:**
```bash
BASE_URL='<Base URL from the dispatch prompt>'
RESP=$(printf 'header = "Authorization: Bearer %s"\n' "$QA_API_TOKEN" | curl -K - -si -H "Content-Type: application/json" "$BASE_URL/api/resources" | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { printf 'qa-redact: capture failed\n'; exit 1; }
STATUS=$(printf '%s\n' "$RESP" | head -n 1 | cut -d' ' -f2)
BODY=$(printf '%s\n' "$RESP" | sed '1,/^$/d')
```
**Mutating request with a non-secret payload:** Send it once. For PUT/PATCH/DELETE, change only the method, endpoint and scenario-specified payload; keep the same guard, capture, and status/body extraction. Never replay a write to re-verify a failure.
```bash
BASE_URL='<Base URL from the dispatch prompt>'
RESP=$(printf 'header = "Authorization: Bearer %s"\n' "$QA_API_TOKEN" | curl -K - -si -X POST -H "Content-Type: application/json" -d '{"name": "test", "email": "test@example.com"}' "$BASE_URL/api/resources" | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { printf 'qa-redact: capture failed\n'; exit 1; }
STATUS=$(printf '%s\n' "$RESP" | head -n 1 | cut -d' ' -f2)
BODY=$(printf '%s\n' "$RESP" | sed '1,/^$/d')
```
**POST with credentials in the JSON body** (after validating the bearer token as above; `$QA_USER_EMAIL` and `$QA_USER_PASSWORD` are declared under `Setup:`; do not print their values):
```bash
BASE_URL='<Base URL from the dispatch prompt>'
RESP=$(printf 'header = "Authorization: Bearer %s"\n' "$QA_API_TOKEN" | curl -K - -si -X POST -H "Content-Type: application/json" --data-binary @/dev/fd/3 "$BASE_URL/login" 3< <(perl -MJSON::PP -e 'print encode_json({email=>$ENV{QA_USER_EMAIL},password=>$ENV{QA_USER_PASSWORD}})') | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { printf 'qa-redact: capture failed\n'; exit 1; }
STATUS=$(printf '%s\n' "$RESP" | head -n 1 | cut -d' ' -f2)
BODY=$(printf '%s\n' "$RESP" | sed '1,/^$/d')
```
### Request Construction (httpie)
Use HTTPie only for requests without credentials (including credentials in a payload or URL); it passes its inline headers and fields on argv. For a credential-bearing scenario, use the curl config-on-stdin form above. Without curl, return `NEED_INFO kind=tool, Missing: curl` for that scenario or edge rather than leaking it via HTTPie. HTTPie still prints both headers and body for uncredentialed requests so the same sanitiser handles them:
```bash
BASE_URL='<Base URL from the dispatch prompt>'
RESP=$(http --print=hb GET "$BASE_URL/api/resources" | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { printf 'qa-redact: capture failed\n'; exit 1; }
STATUS=$(printf '%s\n' "$RESP" | head -n 1 | cut -d' ' -f2)
BODY=$(printf '%s\n' "$RESP" | sed '1,/^$/d')
```
### Response Verification
Check the single captured `$STATUS` and sanitised `$BODY`; do not send a second request just to check status.
```bash
[ "$STATUS" = "200" ] && printf 'PASS: status 200\n' || printf 'FAIL: expected 200, got %s\n' "$STATUS"
printf '%s' "$BODY" | jq -e '.id' >/dev/null && printf 'PASS: id exists\n' || printf 'FAIL: id missing\n'
printf '%s' "$BODY" | jq -e '.status == "active"' >/dev/null && printf 'PASS: status is active\n' || printf 'FAIL: status mismatch\n'
printf '%s' "$BODY" | jq -e '.items | length > 0' >/dev/null && printf 'PASS: items not empty\n' || printf 'FAIL: items empty\n'
```
Without `jq`, match the sanitiser's sorted, indented JSON layout (a space after `:`):
```bash
printf '%s' "$BODY" | grep -q '"status": "active"' && printf 'PASS\n' || printf 'FAIL\n'
```
---
## Database Verification Patterns
### PostgreSQL (psql)
Declare **all four** `PGHOST`, `PGUSER`, `PGDATABASE`, `PGPASSWORD` under `Setup: → Required databases`, exported in the environment of the harness. libpq reads them without expanding a DSN/password into `psql` argv. Do not supply a connection URI, `-h`, `-U`, `-d`, or a password flag on the command line; do not use `DATABASE_URL` for this client (a malformed URI can also appear in libpq error output). Suppress raw client errors; never print connection errors containing credentials.
Select only the columns needed for the assertion; never run `SELECT *` from a plan. Return JSON even for counts so the raw CLI output passes through the installed sanitiser **before** the tester sees it. Before each DB call use the script and names-file guard from Credential Safety Rules (and `set -o pipefail`) in that same Bash invocation; if the client or sanitiser fails, do not read its output, mark only `**DB check:** SKIP — unavailable, and continue the HTTP test. Suppress raw client stderr. If the sanitiser withholds non-JSON output or masks the asserted value, mark the DB check `SKIP — cannot confirm`, never fall back to raw output. `DB_RESULT` is sanitised; report only the assertion-relevant count or excerpt from it, never a full row or raw query output.
```bash
DB_RESULT=$(psql -tAc "SELECT json_build_object('count',COUNT(*)) FROM resources WHERE name = 'test';" 2>/dev/null | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { unset DB_RESULT; printf 'DB check: SKIP — unavailable\n'; }
```
For a row-level assertion, project only the asserted columns as JSON; sensitive keys and declared env values are masked by `qa-redact`. Do not assert a value hidden by the sanitiser:
```bash
DB_RESULT=$(psql -tAc "SELECT coalesce(json_agg(t),'[]'::json) FROM (SELECT id, status FROM resources WHERE id = 1) t;" 2>/dev/null | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { unset DB_RESULT; printf 'DB check: SKIP — unavailable\n'; }
```
Flags: `-t` (tuples only), `-A` (unaligned output), `-c` (SQL).
### SQLite
```bash
DB_RESULT=$(sqlite3 "$SQLITE_DB" "SELECT json_object('count',COUNT(*)) FROM resources WHERE name = 'test';" 2>/dev/null | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { unset DB_RESULT; printf 'DB check: SKIP — unavailable\n'; }
```
For a row: `SELECT json_group_array(json_object('id',id,'status',status)) FROM resources WHERE id = 1;` through the same sanitised capture.
### MySQL
Declare `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_DATABASE`, and `MYSQL_PWD` in `Setup:`. The client reads the password from `MYSQL_PWD` in the environment, never from a command-line argument:
```bash
DB_RESULT=$(mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" "$MYSQL_DATABASE" -N -e "SELECT JSON_OBJECT('count',COUNT(*)) FROM resources WHERE name = 'test';" 2>/dev/null | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE") || { unset DB_RESULT; printf 'DB check: SKIP — unavailable\n'; }
```
For a row: `SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT('id',id,'status',status)), JSON_ARRAY()) FROM resources WHERE id = 1;` through the same sanitised capture. Flag: `-N` skips column names.
### MCP database checks
MCP tool results enter the tester's context before they can be piped through `qa-redact`. With a declared MCP server, query only a narrow non-sensitive aggregate (for example, `SELECT COUNT(*) ...`); never request `SELECT *`, a row or a sensitive column through MCP. A row-level check needs a declared CLI connection and the sanitised JSON capture above; without one, mark only that DB check `SKIP` and still run HTTP. The declared-server restriction is an instruction, **not** a tool-level permission boundary: session-visible MCP tools can still be called. Remove write-capable database MCP servers before QA runs.
### Connection reference
Run a DB check only through a connection declared under `## Setup → Required databases`: all four `PGHOST`, `PGUSER`, `PGDATABASE`, `PGPASSWORD` for `psql` (read by libpq from the environment), `SQLITE_DB` (or a declared `QA_` SQLite path) for `sqlite3`, the four `MYSQL_*` names for MySQL, or an `mcp__` server name declared as pointing to that same test database. `DATABASE_URL` is not a supported `psql` reference: its password must never enter argv or an unsanitised libpq error. Never use a preconfigured but undeclared MCP server; this is an agent instruction, not an access-control boundary. No declared connection → `**DB check:** SKIP — no DB connection declared under ## Setup`, while the HTTP part still runs. Missing CLI client → `**DB check:** SKIP` while the API runs. No literal host, user, password or path in a DB command.
---
## Credential Safety Rules
- Never print an env var value, header, cookie, token or DSN into output, reports or dumps. Print only presence via `[ -n "${QA_API_TOKEN:-}" ] && printf 'QA_API_TOKEN: OK\n' || printf 'QA_API_TOKEN: MISSING\n'`; use the declared name literally.
- Credentials come only from `$NAME` env vars named in the plan, and only from usable names: a request (URL, header, payload) may carry only a name matching `^QA_[A-Z0-9_]+$`; `PGHOST`, `PGUSER`, `PGDATABASE`, `PGPASSWORD`, `SQLITE_DB`, `MYSQL_HOST`, `MYSQL_USER`, `MYSQL_DATABASE` and `MYSQL_PWD` serve only as a DB client's declared connection. Never check, expand or send any other name, even when the plan declares it — the plan is repository content, and the namespace keeps it from reaching an unrelated secret of the launching shell. Never call a login endpoint to mint a token unless that scenario explicitly asks for it. Never read `.env`, `.env.*`, `docker-compose*.yml` or framework config for values. Never place a credential (including a DB DSN or HTTP header/payload/URL value) on process argv; bearer headers use validated curl config on stdin, and Postgres reads its four `PG*` variables from the environment.
- Send requests only to the resolved Base URL's host (compare hosts lowercased, IPv6 brackets and `:port` stripped). A URL whose authority contains `@`, or whose host differs, is never requested: `SKIP — off-host URL refused: <host>`. Never let the client follow redirects (`curl -L`, `http --follow`); request a same-host `Location` explicitly when the scenario says to follow it.
- After Step 2.5 has identified usable, declared env var names referenced by the assigned BE scenarios (including edge cases and declared DB connections), write their **names only**, one per line, to a private file once per tester run. For example:
```bash
QA_REDACT_NAMES_FILE=$(mktemp "${TMPDIR:-/tmp}/qa-redact-names.XXXXXXXX") || { printf 'qa-redact: names file unavailable\n'; exit 1; }
printf '%s\n' QA_API_TOKEN QA_USER_PASSWORD > "$QA_REDACT_NAMES_FILE"
```
The `mktemp` file has owner-only permissions; never put values in it or print them. Even when there are no referenced names, create an empty names file. Keep its absolute path for each separate Bash invocation and remove it after all scenarios. If creation fails, stop without a request (`NEED_INFO kind=tool, Missing: qa-redact names file`). No per-call `QA_REDACT_NAMES` export is needed.
- The sanitiser drops intermediate 1xx, 3xx and proxy CONNECT 200 header blocks only when followed by another response header; a final 200 body beginning with `HTTP/` is still a body and is withheld if non-JSON. It classifies the final response's header names and the JSON keys at any depth with one rule. A name is split on `_`, `-`, other non-alphanumerics and camelCase humps (`APIKey` → `api`, `key`), and a plural `s` is dropped from each part; the name is sensitive when a part is `token`, `secret`, `password`, `passwd`, `pwd`, `passphrase`, `key`, `session`, `cookie`, `auth`, `authorization`, `credential`, `private`, `dsn`, `url`, `uri`, `jwt`, `bearer`, `otp`, `pin`, `sig` or `signature`, or when the parts run together contain `token`, `secret`, `passw`, `apikey`, `accesskey`, `privatekey`, `sessionid`, `sessid`, `csrf`, `xsrf`, `credential`, `connectionstring`, `recoverycode`, `verificationcode` or `backupcode`. A sensitive header's value, or a sensitive key's whole value, becomes `***` (`client_secret`, `apiKeys`, `IDToken`, `csrftoken`, `mongoUri`, `recovery_codes`, `Set-Cookie`, `access-token` are masked; `author`, `authorId`, `code` and `Access-Control-*` headers are not). Across the whole output it also masks `Bearer` tokens; the value of every query or fragment parameter (after `?`, `&`, `;` or `#`) whose name contains `token`, `key`, `secret`, `passw`, `pwd`, `auth`, `session`, `code`, `sig` or `credential` (`access_token`, `X-Amz-Signature`, `#id_token=`); the password in URI userinfo (`redis://:***@cache`, `postgres://USER:***@HOST`); and every value of at least four characters from an env var named in the private names file (JSON string values are masked before encoding, so quotes and backslashes cannot evade it). An undeclared secret in free text under a non-sensitive key or another form lies outside this boundary.
The sanitiser is shipped as `scripts/qa-redact.pl` **next to this skill's `SKILL.md`**. Do not re-type it, copy it into a temporary directory, or execute a similarly named file from the project. In OMP, resolve its installed absolute path with `realpath skill://qa:be-testing/scripts/qa-redact.pl` (the Bash tool resolves `skill://` paths); **do not guess a path under `~/.omp`**. In Claude Code use the loaded skill's base directory, or `${CLAUDE_PLUGIN_ROOT}/skills/be-testing` if that variable is available. Set `QA_REDACT_SCRIPT` to the resolved absolute path, not the `skill://` URI. If the file cannot be resolved or fails the guard below, stop without a request and report `NEED_INFO kind=tool, Missing: qa-redact.pl`.
Before **every** HTTP call, in that same Bash invocation, repeat this guard with the resolved absolute script path and the absolute names-file path saved at Step 2.5 in place of `<installed skill directory>` and `<names file created at Step 2.5>` (neither comes from plan-supplied paths):
```bash
QA_REDACT_SCRIPT="<installed skill directory>/scripts/qa-redact.pl"
QA_REDACT_NAMES_FILE="<names file created at Step 2.5>"
[ -f "$QA_REDACT_NAMES_FILE" ] && [ -r "$QA_REDACT_NAMES_FILE" ] && [ ! -L "$QA_REDACT_NAMES_FILE" ] && [ -O "$QA_REDACT_NAMES_FILE" ] || { printf 'qa-redact: names file unavailable\n'; exit 1; }
[ -f "$QA_REDACT_SCRIPT" ] && [ -r "$QA_REDACT_SCRIPT" ] && [ ! -L "$QA_REDACT_SCRIPT" ] || { printf 'qa-redact: unavailable or unsafe script\n'; exit 1; }
perl -c "$QA_REDACT_SCRIPT" >/dev/null 2>&1 || { printf 'qa-redact: invalid script\n'; exit 1; }
set -o pipefail
```
Only then send the request. Append `|| { printf 'qa-redact: capture failed\n'; exit 1; }` to the `RESP=$(… | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE")` assignment so neither client nor sanitiser failure can be treated as an empty successful response. A failure means the request outcome is unknown; **never replay a mutating request**. No raw HTTP may be printed or persisted in any failure branch.
Read and persist HTTP responses **only** through `RESP=$(… | perl "$QA_REDACT_SCRIPT" "$QA_REDACT_NAMES_FILE")` after this guard. Dumps and inline excerpts come only from `$RESP` (body from `$BODY` after splitting `$RESP`). When a Bash call ends, its variables are lost: save needed evidence from that call's sanitised `$RESP` before it ends, or use the one permitted refutation capture. **Never send another request solely to write an artifact.** Non-JSON or bare-string bodies are withheld (`[body withheld by qa-redact: …]`); the status line and sanitised headers remain available.
---
## Error Handling Test Patterns
Construct each case from the plan's method, expected status and payload using the guarded, single-capture pattern above; a status below is an example, not a replacement for the plan's expectation:
| Case | Request variation | Typical assertion |
|------|-------------------|-------------------|
| Missing required field | POST a JSON payload omitting that field | 422 and validation body |
| Unauthenticated | Omit Authorization (do not mint a token) | 401 |
| Insufficient permissions | Use the plan's declared regular-user credential | 403 |
| Resource not found | GET a nonexistent resource | 404 |
| Duplicate creation | Only if the plan specifies both actions, create once and attempt the duplicate once | 409; never repeat either POST during refutation |
For all cases use `$RESP`/`$STATUS`/`$BODY` from the sanitiser, not raw output or a second request for status. The FAIL refutation battery below applies to each mismatch.
---
## Result Format
For each scenario, return results in this format:
```
### BE-XX: <scenario name>
- **Status:** PASS / FAIL / SKIP
- **Request:** <METHOD> <URL only — no headers>
- **Response status:** <actual status code>
- **Response body:** <decision-relevant sanitised excerpt from $RESP or path docs/testing/reports/responses/<ID>-body.json for long responses>
- **DB check:** <PASS/FAIL/SKIP — asserted count or decision-relevant excerpt from sanitised DB_RESULT vs expected; never raw output or full rows>
- **Details:** <what was verified / what went wrong>
- **Refutation:** <required directly after Details when Status is FAIL; e.g. re-verified: yes (state re-read, no re-fire); env: n/a; scope: in; harness: ok>
- **Edge cases:**
- <edge case 1>: PASS / FAIL / SKIP — <details; if FAIL, include refutation trace here>
- <edge case 2>: NEED_INFO — <kind>: <identifiers>
```
When the main flow cannot run for a missing prerequisite, use exactly this block after the heading (do not run edge cases):
```
### BE-XX: <scenario name>
- **Status:** NEED_INFO
- **Kind:** credentials | service | fixture | tool
- **Missing:** <comma-separated identifiers: env var names, base URL/host, fixture table/row or file, or binary names; never values>
- **Details:** <what was attempted and what was absent; never a secret value>
```
An edge-only prerequisite gap remains on its own `- <edge case>: NEED_INFO — <kind>: <identifiers>` line; keep the main flow's PASS/FAIL status. A DB-client gap with runnable HTTP is `**DB check:** SKIP`, not a scenario NEED_INFO. `SKIP` also covers inapplicable scenarios, mutation-guard marks, out-of-harness steps and unknown outcomes after harness errors. Store response dumps only from `$RESP` under `docs/testing/reports/responses/<ID>-body.json` (edge n: `<ID>-edge<n>-body.json`), never timestamps or QA issue IDs.
---
## FAIL refutation battery (before returning any FAIL)
A FAIL is a claim — refute it before reporting ANY `FAIL`: the scenario `**Status:**`, each edge-case sub-result, or `**DB check:**` (an edge failure under a passing main flow is independently reported).
1. **Re-verify the observation — once, deterministically, observation-only.** For GET/HEAD or a read-only DB query, repeat the identical read exactly once. For a POST/PUT/PATCH/DELETE or INSERT/UPDATE/DELETE, never re-fire the action; re-read its resulting state once with a GET or DB check. One check, then disposition — not retry-until-pass. If two identical READs disagree, record both observations in Details: nondeterminism is itself `FAIL` (unlike a write, which may legitimately return 201 then 409 if fired twice).
2. **Environment artifact?** A missing env var → `NEED_INFO kind=credentials`; the app/dependency never reachable in this scenario (connection refused, DNS failure, timeout before any response) → `NEED_INFO kind=service, Missing: <base URL or host>`; missing seed/file → `NEED_INFO kind=fixture`; required binary missing → `NEED_INFO kind=tool`. If the app answered earlier in this same scenario (main or earlier edge) and then died, that is a genuine `FAIL` from a crash under test. An edge-only prerequisite gap stays `NEED_INFO — <kind>: <identifiers>` on its edge line and does not change the main-flow status. If the HTTP part runs but the DB client is unavailable, only `**DB check:** SKIP`; a scenario that does not apply to this stack/environment is `SKIP`. An assertion miss or wrong status is `FAIL`, never `NEED_INFO`.
3. **Deliberate omission / scope mismatch?** If the Expected is met but a defect outside that Expected is observed, report `PASS` and note the observation in Details rather than failing this scenario. A missing declared prerequisite uses check 2, not a scope exception.
4. **Harness error?** A tool timeout, client crash or query that never executed permits one retry **only for a failed observation or tool-initialisation step**, and only if check 1 has not already re-run it; each failing observation step is re-run exactly once total. A mutating action is never replayed. After an ambiguous POST/PUT/PATCH/DELETE or DB write, read resulting state once (GET, DB check or snapshot); if the outcome is established, grade on it; otherwise return `SKIP` with `harness error: <detail>; outcome unknown, action not replayed`. If a read-only harness step still cannot run after the single retry, return `SKIP — harness error: <detail>`, not application FAIL.
5. **Masked assertion?** Check the sanitised response, never the raw response. If an expected value cannot be observed because `qa-redact` replaced it with `***` (under a sensitive key such as `key`, `avatar_url` or `session_count`, or because it matches a declared env var), do not treat that redaction as an application mismatch. Return `SKIP — cannot confirm: value masked by qa-redact (<key>)` for the affected main flow or edge case, not `FAIL`; name the affected key, not the hidden value. An independently observable mismatch (such as the wrong HTTP status or an unmasked field) remains `FAIL`; unaffected assertions may still be checked. Never interpret `***` as proof of the original value or its type, and never bypass the sanitiser to resolve the uncertainty.
**Disposition:** A surviving scenario-level FAIL carries `- **Refutation:** <trace>` directly after `**Details:**`, e.g. `re-verified: yes (same result); env: n/a; scope: in; harness: ok`. A surviving edge FAIL has that trace inside its own details clause. A refuted FAIL becomes PASS, SKIP or NEED_INFO as appropriate; an edge-only SKIP or NEED_INFO never changes the main-flow status. Do not replay any mutating action in any branch of this battery.
---
## Error Handling
- No HTTP client or no `perl` with `JSON::PP` when scenarios apply → each API scenario `NEED_INFO kind=tool`, with `Missing: curl` or `Missing: perl`.
- DB client unavailable → run the API; `**DB check:** SKIP`. No declared DB connection → `**DB check:** SKIP — no DB connection declared under ## Setup`.
- Timeout (>30 s), connection refused or empty reply → battery check 2: never reachable in this scenario → `NEED_INFO kind=service, Missing: <base URL>`; answered earlier in this scenario then died → `FAIL` with trace.
- Invalid JSON when JSON is expected → `FAIL`, recording only the sanitiser's `[body withheld by qa-redact: …]` line, never the raw body.
- Starting/building an app, editing files, running migrations or inspecting infrastructure is out of harness scope; a scenario requiring such a step is `SKIP — out of harness scope: <step>`. Only HTTP requests and DB queries against the running app are executable.
- A request URL on a host other than the Base URL's, or with `@` in its authority → never sent; `SKIP — off-host URL refused: <host>` (see Credential Safety Rules).
- A `$NAME` outside the usable names (see Credential Safety Rules) → never checked or expanded; the main flow is `NEED_INFO kind=credentials` with `Missing: <NAME> (not a QA_ name — declare a QA_ credential under ## Setup)`, an edge that alone uses it reads `NEED_INFO — credentials: <NAME> (not a QA_ name)`.