| name | openapi-spec |
| description | 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. |
| allowed-tools | ["Bash(.claude/skills/openapi-spec/scripts/generate-spec.sh *)","Bash(oastools *)"] |
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 forwards 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):
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:
# 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:
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:
- Base tags — the
@default_api_categories list at the top of specs/public.ex, always present regardless of chain type.
- 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.
"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.
.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.
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:
- Router — find the route definition. Note the HTTP method, path segments (especially
:param_name segments), and which controller/action it maps to.
- 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.
- 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.
- Existing schemas — glob
schemas/api/v2/<domain>* to see if schema modules already exist for this domain.
- 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:
-
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.
-
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.
-
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
- Check if a schema module exists for the response entity. Glob
schemas/api/v2/<domain>*.ex.
- 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.
- 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.
- 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.
- 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).
- 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).
- 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.
- Set
required: to list all keys that the view always emits.
- For paginated list endpoints, use
General.paginated_response/1 to wrap the item schema.
- 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:
- Read
delete_parameters_from_next_page_params/1 in apps/block_scout_web/lib/block_scout_web/paging_helper.ex.
- Check whether the atom-key form (e.g.,
:direction) is in the Map.drop list.
- 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.
-
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.
-
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.
-
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.
-
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.