# Openapi Spec

> Create, adjust, or inspect OpenAPI declarations for Blockscout API v2 endpoints. Use this skill whenever the user asks to: add an OpenAPI spec to an endpoint that lacks one, update a spec after controller/view changes, audit or fix an existing OpenAPI declaration, or work with open_api_spex annotations in the Blockscout codebase. Also trigger when the user mentions 'swagger', 'openapi', 'open_api_spex', 'API spec', 'API schema', or 'operation macro', or when debugging failures like 'response schema mismatch', 'CastAndValidate rejection', 'json_response validation error', 'Unexpected field', or extra/missing keys in API responses.

- Skill: `blockscout/openapi-spec` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add blockscout/openapi-spec`
- Raw SKILL.md: https://api.skillmd.com/api/skills/blockscout/openapi-spec/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: blockscout (https://skillmd.com/u/blockscout)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/blockscout/openapi-spec

---


# OpenAPI Spec Authoring for Blockscout API v2

This skill covers three workflows for Blockscout's OpenAPI declarations:
- **Create** — add a declaration for an endpoint that has none
- **Adjust** — update a declaration after parameters or response changed
- **Inspect & Fix** — audit an existing declaration for correctness issues

Blockscout uses the `open_api_spex` library (v3.22+) to define OpenAPI 3.0 specs inline in Elixir code. There are no hand-written spec files — the spec is derived entirely from annotations in controllers and schema modules, then assembled at runtime via router introspection.

## Key file locations

All paths are relative to `apps/block_scout_web/lib/block_scout_web/`. Most endpoints live under the flat v2 layout, but annotated endpoints also exist outside of it — the table below calls out every tree that contributes to the generated spec.

| What | Where |
|---|---|
| V2 controllers (flat) | `controllers/api/v2/<domain>_controller.ex` |
| V2 proxy controllers | `controllers/api/v2/proxy/<domain>_controller.ex` (routed under `/v2/proxy`) |
| V2 chain-type-nested controllers | `controllers/api/v2/<chain>/<domain>_controller.ex` (e.g. `controllers/api/v2/ethereum/deposit_controller.ex`) |
| Account controllers (Private spec) | `controllers/account/api/v2/<domain>_controller.ex` |
| Legacy controllers | `controllers/api/legacy/<domain>_controller.ex` (routed under `/legacy`) |
| V2 schema modules | `schemas/api/v2/<domain>.ex` and `schemas/api/v2/<domain>/*.ex` |
| V2 chain-type schema subdirs | `schemas/api/v2/<chain>/*.ex` (e.g. `schemas/api/v2/{arbitrum,beacon,celo,optimism,scroll,zilliqa,mud}/*.ex`) |
| V2 proxy schemas | `schemas/api/v2/proxy/*.ex` |
| Account schemas (Private spec) | `schemas/api/v2/account/*.ex` |
| Legacy schemas | `schemas/api/legacy/*.ex` |
| Parameter helpers | `schemas/api/v2/general.ex` (all helpers centralized here) |
| Error responses | `schemas/api/v2/error_responses.ex` |
| Schema helper | `schemas/helper.ex` (`extend_schema/2`) |
| Leaf type schemas | `schemas/api/v2/general/*.ex` (AddressHash, FullHash, IntegerString, etc.) |
| API router | `routers/api_router.ex` |
| V2 sub-routers forwarded from the API router | `routers/tokens_api_v2_router.ex`, `routers/smart_contracts_api_v2_router.ex`, `routers/api_key_v2_router.ex`, `routers/utils_api_v2_router.ex`, `routers/address_badges_v2_router.ex` |
| Account router (Private spec) | `routers/account_router.ex` |
| Views (flat v2) | `views/api/v2/<domain>_view.ex` |
| Legacy views | `views/api/legacy/<domain>_view.ex` |
| Paging helper | `paging_helper.ex` (`delete_parameters_from_next_page_params/1`) |
| Spec aggregators | `specs/public.ex` (public spec + **tag registry**), `specs/private.ex` (account/private spec) |
| Global aliases/imports | The file `block_scout_web.ex` — look for `:controller` quote block |
| V2 tests | `../../test/block_scout_web/controllers/api/v2/<domain>_controller_test.exs` |
| Legacy tests | `../../test/block_scout_web/controllers/api/legacy/<domain>_controller_test.exs` |

**On router coverage of the public spec:** `specs/public.ex` builds `paths` via `Paths.from_router(ApiRouter)`, which picks up everything reachable from `api_router.ex` — including endpoints declared in the sub-routers that `api_router.ex` `forward`s to (api-key, utils, address-badges). Only `TokensApiV2Router` and `SmartContractsApiV2Router` need the extra `Paths.from_routes(...)` merges in `public.ex` because their prefixes are stripped by Phoenix `forward` and must be re-added. A new annotated endpoint placed in any of the other sub-routers needs no extra wiring beyond the `forward` that already exists in `api_router.ex`.

## Core patterns

### The operation macro

Every annotated controller action has an `operation/2` call (from `OpenApiSpex.ControllerSpecs`):

```elixir
operation :action_name,
  summary: "Short summary for the endpoint",
  description: "Longer description of what it does.",
  parameters: [some_path_param() | base_params()],
  responses: [
    ok: {"Success description", "application/json", Schemas.SomeDomain.Response},
    not_found: NotFoundResponse.response(),
    unprocessable_entity: JsonErrorResponse.response()
  ]
```

For POST/PUT/PATCH endpoints, add `request_body:` — see `references/request-body-security-headers.md`.

### The three-way parameter coupling

Path parameters must be consistent across three locations or the endpoint breaks:

| Location | Form | Example |
|---|---|---|
| Phoenix route segment | String with `:` prefix | `get("/:transaction_hash_param", ...)` |
| `%Parameter{}` struct | Atom in `:name` field | `%Parameter{name: :transaction_hash_param, in: :path}` |
| Controller action head | Atom key in pattern match | `def transaction(conn, %{transaction_hash_param: value})` |

`CastAndValidate` reads string keys from `conn.path_params`, converts them to atoms using the Parameter `:name`, and places them in `conn.params`. The controller then pattern-matches on those atoms.

### Response schema ↔ view correlation

There is no runtime validation that view output matches the response schema. Alignment is enforced **only at test time**: every `json_response/2` call in a `ConnCase` test automatically validates the response body against the OpenAPI spec. This means:
- Schemas with `additionalProperties: false` catch extra keys the view emits
- The `required` list catches missing keys
- Type/pattern checks catch type mismatches

If a view emits a key not in the schema (or vice versa), tests will fail.

### CastAndValidate's effect on params (string keys → atom keys)

When `CastAndValidate` processes an action with a real `operation` spec (not `operation :action, false`), it transforms **all** params before the action runs:
- **String keys become atom keys:** `%{"id" => "42"}` → `%{id: 42}`
- **Values are cast to declared types:** strings become integers, booleans, etc., based on the parameter's `%Schema{type: ...}`

Actions declared with `operation :action, false` are **skipped** — they receive the original string-keyed params from Phoenix unchanged.

This matters most for **pagination**. The `paging_options/1` function in `chain.ex` has parallel clauses for both forms:
- String-key clauses (e.g., `%{"id" => id_string} when is_binary(id_string)`) — used by actions without a spec
- Atom-key clauses (e.g., `%{id: id}`) — used by actions with a real spec

When promoting an action from `operation :action, false` to a real spec, the string-key `paging_options` clause will stop matching. You must ensure a corresponding atom-key clause exists. See Workflow A, Step 4b for details.

### base_params() — always include

`base_params()` returns `[api_key_param(), key_param()]` — two optional query parameters (`apikey`, `key`) present on every public API operation. Always include it.

Common composition patterns:
```elixir
# Simple — no extra params
parameters: base_params()

# With a path param (prepend via cons)
parameters: [address_hash_param() | base_params()]

# With paging params (append via ++)
parameters: base_params() ++ define_paging_params(["index", "block_number"])

# Combined
parameters: [transaction_hash_param() | base_params()] ++ [token_type_param()] ++ define_paging_params(["index", "block_number"])
```

### Controller prerequisites

Every annotated controller needs:
```elixir
use OpenApiSpex.ControllerSpecs                          # injects operation/2, tags/1
plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true)  # validates incoming params
tags(["domain-tag"])                                      # groups operations in spec
```

These are typically near the top of the controller module, after `use BlockScoutWeb, :controller`.

**Tag naming: kebab-case.** Tag strings use kebab-case (`"internal-transactions"`, `"main-page"`, `"smart-contracts"`, `"token-transfers"`, `"account-abstraction"`), not snake_case. Multi-word controller module names such as `InternalTransactionController` still map to the kebab-case plural tag, not to the module name.

### Tag registry (`specs/public.ex`)

The order of tag groups in the generated public spec is not derived from the controllers — it's declared explicitly in `specs/public.ex` and has a fixed three-part shape:

1. **Base tags** — the `@default_api_categories` list at the top of `specs/public.ex`, always present regardless of chain type.
2. **Chain-type-specific tags** — returned by `chain_type_category/0`, whose clauses are keyed on `@chain_identity` (`{:optimism, :celo}`, `{:optimism, nil}`, `{:scroll, nil}`, `{:zilliqa, nil}`, …). For chains without OpenAPI coverage this is an empty list.
3. **`"legacy"`** — hard-coded trailer pinned last.

If a new annotated controller introduces a brand-new tag, the agent must register it in the right group, or the tag will still appear in the spec (via controller-side `tags(...)`) but with no ordering guarantee and no entry in the top-level `tags:` list:

- Base endpoint → append the kebab-case tag to `@default_api_categories`.
- Chain-type endpoint → add it inside the relevant `case @chain_identity` branch, matching the existing patterns (module-attribute + `defp` for static lists, full `defp` body when the tag set depends on a runtime flag such as `mud_enabled?()`).
- Legacy endpoint → no action; `"legacy"` is already the trailer.

Tags that are already covered by an existing group (e.g. another `addresses` endpoint) need no change.

---

## Verification

After creating or modifying a declaration, verify it using these methods in order. Each catches a different class of issues, and earlier steps are faster — so run them first to get quick feedback before committing to a full test run.

### 1. Compile (`mix compile`)

Compiling the `block_scout_web` app verifies structural validity: schema modules exist, operation names match controller action function names, and all referenced modules resolve. This is the fastest check and catches typos, missing modules, and wiring errors.

Run via devcontainer if mix is not available on the host.

### 2. Generate the spec (`generate-spec.sh`)

This exercises `OpenApiSpex.resolve_schema_modules/1`, which resolves all schema module references and inlines them into the full spec. It catches issues that compilation alone misses: circular references, malformed schema structures, and resolution failures.

```bash
.claude/skills/openapi-spec/scripts/generate-spec.sh
```

See `references/spec-generation-and-verification.md` for script options (chain-specific generation, custom output path) and `oastools` commands for inspecting the result.

### 2a. Audit for spec-wide convention drift (optional)

After regeneration, sweep the spec for convention violations a single-endpoint test run won't catch: missing `additionalProperties: false`, missing `:unprocessable_entity`, tag casing, etc. See `references/oastools-audit-recipes.md` — the quick sweep is recipes A, B, F, I.

The generated spec is cache-like, so regeneration must come first. A stale `.ai/tmp/openapi_public.yaml` produces false positives for every recipe that counts violations — e.g., it may report tag-casing hits that no longer exist in the codebase.

### 2b. Tag audit (run after creating, moving, or retagging operations)

`mix test` does not check tags. Run Recipe O (registry coverage — Step 4e) and Recipe P (URL prefix vs operation tag — Step 4d) from `references/oastools-audit-recipes.md`.

### 3. Run controller tests (`mix test`)

Run the specific controller test file. Every `json_response/2` call automatically validates the response body against the OpenAPI schema. This catches response-level issues: extra keys (via `additionalProperties: false`), missing required keys, and type mismatches.

```bash
mix test apps/block_scout_web/test/block_scout_web/controllers/api/v2/<domain>_controller_test.exs
```

If tests fail with schema validation errors, the view output doesn't match the declared schema — fix the discrepancy.

### 4. Code cross-referencing (for Inspect & Fix workflow)

Manually or via grep, compare the controller's consumed parameters against declared parameters, and the view's output keys against schema properties. This catches logical issues that tests might miss (e.g., an undeclared optional parameter that works at runtime but isn't documented, or a schema property that's declared but never emitted by the view).

See `references/inspection-checklist.md` for the systematic approach.

---

## Workflow A: Create a new declaration

Use this when an endpoint exists (route + controller action + view) but has no `operation/2` annotation.

### Step 1: Gather context

Read these files in parallel to understand the endpoint:

1. **Router** — find the route definition. Note the HTTP method, path segments (especially `:param_name` segments), and which controller/action it maps to.
2. **Controller** — read the action function. Note what keys it destructures from `params` and `conn.body_params`, what data it fetches, and what view template it renders.
3. **View** — read the render function and any `prepare_*` helper it calls. Note every key in the output map — these become schema properties. Trace **all code paths**, not just the default: look for `case`/`cond`/pattern-match branches in the render function and its helpers that produce different map shapes depending on a field value. When found, note the discriminator field and the distinct set of keys each branch emits — these indicate a polymorphic sub-object that needs special handling in Step 3.
4. **Existing schemas** — glob `schemas/api/v2/<domain>*` to see if schema modules already exist for this domain.
5. **Peer precedent (optional)** — `oastools walk operations -tag <domain> -q .ai/tmp/openapi_public.yaml` lists sibling endpoints already in the spec. Useful before choosing between schema reuse and new schemas in Step 3.

### Step 2: Find or create parameter definitions

For each parameter the controller reads:

1. **Check if a helper already exists.** Grep `general.ex` for a function matching the parameter name:
   ```
   # For a path param named :address_hash_param
   grep "def address_hash_param" in general.ex
   ```
   Read `references/parameter-discovery.md` for naming conventions and discovery patterns.

2. **If no helper exists**, decide:
   - **Reusable across controllers?** Add a new helper function to `general.ex` following the naming conventions in `references/parameter-discovery.md`.
   - **Domain-specific but used by multiple operations in the same controller?** Add a private helper function in the controller itself. This avoids polluting `general.ex` with chain-specific concerns while preventing duplication across operations.
   - **Truly one-off (single operation)?** Define an inline `%OpenApiSpex.Parameter{}` struct directly in the `operation` macro arguments.

3. **For pagination parameters**, use `define_paging_params(field_names)` — pass the cursor field names as strings, and always include `"items_count"` (the `next_page_params` helper adds it to every cursor automatically). See `references/parameter-discovery.md` section "The `define_paging_params` factory" for details.

### Step 3: Create or locate response schema

1. **Check if a schema module exists** for the response entity. Glob `schemas/api/v2/<domain>*.ex`.
2. **If schemas exist in the same domain**, compare their properties against the new view's output keys to detect subset/superset relationships (recipe N in `references/oastools-audit-recipes.md` gives a mechanical candidate list across all component schemas):
   - **Existing schema is a subset** of what the new endpoint needs — use `extend_schema` from the existing schema, adding only the extra properties. See `references/schema-conventions.md` section "Schema reuse and naming for related schemas" for the naming convention and required `title:` parameter.
   - **Existing schema is a superset** — the new endpoint may reference the existing schema directly (if it needs all the properties), or may need a reduced "minimal" schema that the existing one extends.
   - **Before reusing, check `oneOf`/`anyOf` reachability.** If the candidate schema (or any of its nested properties) contains a `oneOf` or `anyOf`, trace each variant back through the controller action's code path to the view's render function. Identify which discriminator values the controller can actually produce for this endpoint. If all variants are reachable, reuse the schema directly. If only a subset is reachable, create a narrowed schema via `extend_schema`, overriding only the polymorphic property with a `oneOf` containing just the reachable variants. `extend_schema` merges properties and overwrites existing keys, so passing the narrowed property replaces the parent's full variant list (see `references/schema-conventions.md` section "Helper.extend_schema/2"). Example: if `Batch` has a `data_availability` with 4 `oneOf` variants but endpoint `batch_by_celestia_da_info` can only produce the Celestia variant, create a schema that extends `Batch` and overrides `data_availability` to contain only that variant.
   - **No meaningful overlap** — create a standalone schema.
3. **If no suitable schema exists**, create one following the conventions in `references/schema-conventions.md`. The schema's properties must match the view's output keys exactly.
4. **Deduplicate against existing domain schemas.** Before finalizing properties, compare each inline `%Schema{type: :object}` block and each `%Schema{type: :string, enum: [...]}` definition in the new schema against properties in the existing schemas found in step 1. If an identical structure already exists in another schema in the same domain directory, extract it into a shared leaf schema module and reference it from both schemas. This avoids drift when the structure changes and consolidates Ecto.Enum sync comments to one location. See `references/schema-conventions.md` section "Domain-scoped shared schemas" for templates. Recipe D in `references/oastools-audit-recipes.md` enumerates every inline enum across the spec — group by `.enum` to find duplicates mechanically.
5. **Model polymorphic sub-objects.** If Step 1 identified a property whose structure varies based on a discriminator field (e.g., a `data_availability` object that changes shape depending on `batch_data_container`), a single flat `%Schema{type: :object}` with only the common fields will be incomplete — the variant-specific fields won't be documented or validated. Use `oneOf` to declare each variant. See `references/schema-conventions.md` section "Polymorphic properties (`oneOf`)" for the structural pattern, the per-variant discriminator-constraint rule, and a concrete template. For existing precedent, see `transaction.ex` (`revert_reason` property).
6. **Determine precise types from the Ecto schema.** The view layer is lossy — it renders everything as JSON primitives. Read the entity's Ecto schema (under `apps/explorer/lib/explorer/chain/`) to recover the real constraints: `Ecto.Enum` values, nullability, and integer-vs-string representation for large numbers. See `references/schema-conventions.md` §"Determining property types from Ecto schemas" for the full Ecto-to-OpenAPI mapping, the mandatory enum sync-comment format, and the OpenAPI-3.0 nullability rule (`nullable: true`, never `type: :null`).
7. **Set `additionalProperties: false`** on object schemas — this is a project-wide convention that enables test-time enforcement.
   - **For non-negative integer properties** (block numbers, batch numbers, counts, indices, nonces), set `minimum: 0` to enforce the domain constraint at the validation level.
8. **Set `required:`** to list all keys that the view always emits.
9. For paginated list endpoints, use `General.paginated_response/1` to wrap the item schema.
10. **Review properties for description adequacy.** After defining types and constraints, do a final pass over all properties. For each property without a `description:`, ask: "Would an API consumer unfamiliar with this chain's internals understand this from the name alone?" Add descriptions to properties that are ambiguous, use domain jargon, mirror Solidity field names, or where the chain context (Parent chain vs Rollup) is unclear. Tautological descriptions that restate the property name don't count — rewrite or remove them. See `references/schema-conventions.md` section "Property descriptions" for guidelines and examples.

### Step 4a: Write the operation annotation

Add the `operation/2` call above the controller action. Follow the structure in "The operation macro" section above. Make sure:
- `summary:` is a short imperative sentence
- `description:` adds useful detail beyond the summary
- `parameters:` includes `base_params()` and all path/query params
- `responses:` covers the success case and all error cases the action can return. If multiple controller branches share the same status code with different error messages, use a custom description tuple instead of the generic `Module.response()` helper — see `references/error-response-patterns.md` section "Multiple error branches sharing one status code".

If the controller lacks the `use OpenApiSpex.ControllerSpecs` line and `CastAndValidate` plug, add them (see "Controller prerequisites"). If the `tags([...])` string is brand-new (not already present in `@default_api_categories` or any `chain_type_category_tags/0` clause in `specs/public.ex`), proceed through Step 4e before verification — without registration the tag still renders per-operation but has no ordering guarantee.

### Step 4b: Update paging_options if the endpoint is paginated

If the action calls `paging_options(params)` (directly or via helpers like `next_page_params`), the string-key clauses in `chain.ex` will no longer match because `CastAndValidate` has already converted params to atom keys with cast types.

Check `chain.ex` for the relevant `paging_options` clause. If only a string-key clause exists (e.g., `%{"id" => id_string}` with `Integer.parse`):
- **Add** a matching atom-key clause (e.g., `%{id: id}`) if the string-key clause is still used by other actions without specs
- **Replace** the string-key clause with an atom-key one if all callers now go through `CastAndValidate`

The atom-key clause is typically simpler because `CastAndValidate` already handles type casting — no `Integer.parse` or similar parsing needed.

This step is especially important when **promoting** an action from `operation :action, false` to a real spec — that is the moment where `paging_options` stops receiving string keys and the mismatch occurs.

### Step 4c: Ensure path params are excluded from `next_page_params`

If the endpoint is paginated **and** has path parameters, those path params will leak into the pagination cursor response unless explicitly stripped.

**Why this happens:** CastAndValidate converts all params (path + query) to atom keys in a single map. The `next_page_params/5` function receives this map and builds the cursor for the response. It calls `delete_parameters_from_next_page_params/1` (in `paging_helper.ex`) to strip known non-pagination params, but only params listed in its `Map.drop` list are removed. If a path param isn't listed, it appears in the JSON response's `next_page_params`. When the client sends that cursor back as query params on the next request, CastAndValidate rejects the path param as "Unexpected field" because it's declared as `:path`, not `:query`.

**What to do:** For each path parameter declared in the operation:
1. Read `delete_parameters_from_next_page_params/1` in `apps/block_scout_web/lib/block_scout_web/paging_helper.ex`.
2. Check whether the atom-key form (e.g., `:direction`) is in the `Map.drop` list.
3. If missing, add it among the other atom-key entries at the top of the list.

The existing list already includes common path params like `:address_hash_param`, `:batch_number_param`, `:block_hash_or_number_param`, `:transaction_hash_param`. New path params need to be added as they are introduced.

### Step 4d: Pick the right tag(s) when URL prefix and controller domain disagree

If the operation's URL lives under a cross-cutting prefix that is itself a tag (e.g., `/v2/main-page/...`), add `tags: ["<cross-cutting>"]` per-operation. OpenApiSpex appends to module-level `tags(...)`, so the operation will appear under both groups (dual-tagging — the default). For exclusive relocation, see `references/schema-conventions.md` §"Cross-cutting URL prefixes and tags".

### Step 4e: Register a new tag in the registry

If the controller's `tags([...])` declares a tag not already in `@default_api_categories` or any `chain_type_category_tags/0` clause in `specs/public.ex`, add it. Base tag → `@default_api_categories`; chain-type tag → matching `case @chain_identity` branch. See "Tag registry" in Core patterns for branch shapes.

### Step 5: Ensure test coverage

Tests are the primary mechanism that validates the response schema matches the actual view output. Without tests hitting the endpoint, the schema is unverified documentation that may be wrong.

1. **Enumerate all status codes the controller action returns.** Read the controller action and list every distinct HTTP status code it can produce. Look for:
   - `put_status` calls (e.g., `put_status(:bad_request)`, `put_status(200)`)
   - Pattern-match branches that render different error responses
   - `send_resp` calls with explicit status codes
   - The implicit 200 from the success path (`render` without `put_status`)
   
   Cross-reference this list against the `responses:` declared in the operation. Every status code declared in the operation should have at least one test. If multiple branches return the same status code with different conditions, note each branch separately — ideally each gets its own test case so the conditions are documented. The spec-side half of this cross-check is one command: `oastools walk responses -path <X> -method <Y> -q .ai/tmp/openapi_public.yaml`.

   Some branches depend on external systems (RPC calls, microservice responses) and cannot be reached with pure DB setup. Decide how to handle each one:

   - **Mock when the branch produces a distinct response shape** — a different `oneOf` variant, a different set of required keys, or an enum value not exercised by other tests. These are exactly the cases where `additionalProperties: false` and type constraints silently rot without coverage. (Example: the Arbitrum withdrawal token sub-object and `:confirmed`/`:sent` status paths are only reachable through L1 RPC mocking, and testing them exposed a real OpenApiSpex schema-title collision bug that would have shipped otherwise.)
   - **Document and skip when the mocking cost is disproportionate** — e.g., a branch requires orchestrating multiple cross-chain RPC fallback steps. Add a code comment explaining what the branch does and why it's not covered (e.g., `# :unknown status — requires Outbox.isSpent=false AND get_size_for_proof/0 returning nil (multi-step L1/L2 RPC fallback), not covered here`).

   **How to mock RPC dependencies when it's worth it.** The established pattern uses `:meck` to intercept `Indexer.Helper.json_rpc_named_arguments/1` so it returns a Mox-backed transport, then `Mox.expect` stubs specific contract calls with ABI-encoded responses. See `arbitrum_controller_test.exs` helpers (`setup_arbitrum_l1_rpc_mocks!`, `expect_inbox_outbox_query!`, `expect_erc20_metadata!`, etc.) for a working reference. When building mock fixtures for chain-specific RPC calls, the Blockscout MCP server can discover real on-chain data (event logs, calldata, contract return values) to verify that fixtures match production structure — it is a discovery aid, not a source of truth; the ABI spec and contract source are authoritative.

2. **Check if tests already exist.** Look for the test file at `apps/block_scout_web/test/block_scout_web/controllers/api/v2/<domain>_controller_test.exs`. Grep for the endpoint path or action name within the file. If tests already hit the endpoint and call `json_response/2`, they will automatically validate the schema — proceed to Step 6.

3. **If no tests exist**, create them. For minimal test templates covering list / single-resource / 404 / 422 cases, see `references/inspection-checklist.md` section "Minimal test templates". Every `json_response/2` call triggers schema validation automatically. Cover every status code enumerated in item 1 — if the controller returns codes beyond the templates (e.g., 400 from business-logic checks), add tests for those too, setting up the DB state that triggers each branch.

4. **If the schema contains `oneOf` polymorphic sub-objects** (from Step 3 item 5), write at least one test per variant so each branch's `additionalProperties: false` constraint is exercised. The default factory typically produces only the simplest variant, so other variants need explicit setup — insert the factory with the discriminator value set, plus any associated records the view fetches. If a variant is only reachable through an external dependency (RPC, microservice), see item 1 above.

### Step 6: Verify

Run the verification ladder from the "Verification" section above (compile → generate-spec → tests). If tests fail with schema validation errors, the view output doesn't match the declared schema — fix the discrepancy.

---

## Workflow B: Adjust an existing declaration

Use this when an endpoint's parameters or response have changed and the OpenAPI spec needs to catch up.

### Step 1: Identify the change

Read the controller action and view to understand what changed. Common scenarios:
- **New parameter added** — controller now reads a new key from params
- **Parameter removed** — controller no longer uses a parameter
- **New response field** — view now emits an additional key
- **Response field removed** — view no longer emits a key
- **Type changed** — a field's type or format changed

### Step 2: Update the declaration

- **Parameters**: Add/remove from the `parameters:` list in the `operation` macro. If adding a new reusable param, add a helper to `general.ex`.
- **Response fields**: Update the schema module's `properties:` map and `required:` list. If adding a field, add it to both. If removing, remove from both.
- **Type changes**: Update the property's schema type in the schema module.

### Step 3: Verify

Run the verification ladder from the "Verification" section above (compile → generate-spec → tests). If parameters changed, also revisit Workflow A Step 4b/4c — atom-key `paging_options` clauses and `next_page_params` path-param stripping apply equally when adjusting.

---

## Workflow C: Inspect & fix an existing declaration

Use this to audit an existing declaration for correctness, completeness, and adherence to project conventions. Read `references/inspection-checklist.md` and work through it end to end — it owns the full cross-reference procedure (parameters, response fields, conventions, schema organization) and ends with the verification ladder.

---

## When to read reference files

| Reference | Read when... |
|---|---|
| `references/parameter-discovery.md` | You need to find existing parameter helpers, create new ones, or understand naming/categorization conventions |
| `references/schema-conventions.md` | You need to create new schema modules, understand directory layout, work with chain-type customizations, or model polymorphic properties with `oneOf` |
| `references/error-response-patterns.md` | You need to declare error responses or understand which error module to use for a status code |
| `references/request-body-security-headers.md` | You're working with POST/PUT/PATCH endpoints, authentication/security, or HTTP header declarations |
| `references/inspection-checklist.md` | You're running an audit of an existing declaration (Workflow C) |
| `references/spec-generation-and-verification.md` | You need to generate the spec YAML, validate it, or inspect specific operations/schemas with oastools |
| `references/oastools-audit-recipes.md` | You want to audit the generated spec for spec-wide convention drift or reuse candidates, without reading every source file |

## Using subagents

For the Create workflow, parallelize the initial context gathering (Step 1) by spawning subagents to read the router, controller, view, and existing schemas simultaneously.

When running tests after changes, use the devcontainer skill if mix/elixir is not available on the host.

