# The typed exceptions

A refusal from any door on this origin is an RFC 7807
`application/problem+json` document whose `type` is one of the EPCIS
standard's own `epcisException:*` identifiers. The `type` and `status`
are the contract; `title` and `detail` are worded for humans and may be
rephrased between releases. **Match on `type`, never on prose.**

## The taxonomy the shipped surfaces emit

| type | status | meaning | remediation |
|---|---|---|---|
| `epcisException:ValidationException` | 400 | malformed JSON, a document the pinned schema rejects, or a failed translation | fix the payload; the per-path `errors` array names every violation |
| `epcisException:QueryParameterException` | 400 | a SimpleEventQuery the pinned query-schema rejects | fix the named parameter; CBV values are bare words (`shipping`, not a URN) |
| `epcisException:SecurityException` | 401 | a keyed door reached without a key | send `Authorization: Bearer <capture-scoped key>`; ask for one at /get-a-key/ |
| `epcisException:NoSuchResourceException` | 404 | an eventID that is not in the caller's scope — absent, never redacted | nothing to retry; an out-of-scope event and a nonexistent one answer identically |
| `epcisException:ResourceAlreadyExistsException` | 400, nested per event | a capture that collides with a stored event of the same `eventID` but different content — the job refuses whole as a 400 `ValidationException` envelope ("no partial acceptance") and the collision arrives per event inside its `errors[]` | a byte-identical replay is a benign ack; different bytes under the same identity are refused whole — submit a correction as a new event carrying an `errorDeclaration` |
| `epcisException:CaptureLimitExceededException` | 413 | payload exceeds the file-size limit | split the document |
| `epcisException:UnsupportedMediaTypeException` | 415 | the right payload at the wrong door | the `detail` names the right one: XML goes to `/translate`, JSON to `/validate` and `/hash` |
| `epcisException:ImplementationException` | 501 | a capability this door does not serve: the `resolve` and `subscribe` MCP tools | `resolve`: identity resolution is homed at id.org.ai; `subscribe`: use the `query` tool — this deployment serves pull queries only |

The CLI mirrors the same discipline on stderr with four stable code
strings — `USAGE`, `PARSE`, `NOT_FOUND`, `INTERNAL` — documented on the
[CLI reference](/docs/reference/cli/).

## Real refusals, cut from real runs

**415 — the wrong door names the right one.** JSON posted to the XML
door:

```
$ curl -s -X POST https://epcis.dev/translate \
    -H 'Content-Type: application/json' -d '{"not":"xml"}'
{
  "type": "epcisException:UnsupportedMediaTypeException",
  "title": "Unsupported Media Type",
  "status": 415,
  "detail": "the translation door accepts application/xml or
    text/xml (got \"application/json\"); POST JSON to /validate
    or /hash"
}
```

**400 — malformed bytes are a ValidationException, not a 500.**

```
$ curl -s -X POST https://epcis.dev/validate \
    -H 'Content-Type: application/json' -d '{"broken'
{
  "type": "epcisException:ValidationException",
  "title": "malformed JSON",
  "status": 400,
  "detail": "SyntaxError: Unterminated string in JSON at
    position 8 (line 1 column 9)"
}
```

**401 — the keyed ceremony, stated by the door itself.** On the apex
MCP door a keyed tool refuses as a tool-level error (`isError: true`)
carrying the same problem document — the HTTP envelope stays 200
because in MCP a refusal is a result, not a transport failure:

```
$ curl -s -X POST https://epcis.dev/mcp \
    -H 'Content-Type: application/json' -d '{
      "jsonrpc":"2.0","id":1,"method":"tools/call",
      "params":{"name":"capture","arguments":{"document":{}}}}'
{
  "jsonrpc": "2.0", "id": 1,
  "result": {
    "structuredContent": {
      "type": "epcisException:SecurityException",
      "title": "Unauthorised request",
      "status": 401,
      "detail": "/capture is a keyed door: send Authorization:
        Bearer <capture-scoped key>. Ask for one at /get-a-key/;
        we email you when it is ready."
    },
    "isError": true
  }
}
```

**501 — the capability this door does not serve, refused typed.** The
apex `resolve` tool answers the same problem-document grammar — cut
live from `POST https://epcis.dev/mcp` on 2026-07-31:

```
$ curl -sS -X POST https://epcis.dev/mcp \
    -H 'Content-Type: application/json' -d '{
      "jsonrpc":"2.0","id":4,"method":"tools/call",
      "params":{"name":"resolve",
        "arguments":{"reference":"urn:epc:id:sgtin:0614141.107346.2021"}}}'
{
  "jsonrpc": "2.0", "id": 4,
  "result": {
    "structuredContent": {
      "type": "epcisException:ImplementationException",
      "title": "resolve is not served on this door",
      "status": 501,
      "detail": "identity resolution is homed at id.org.ai — one
        resolver, one home; this door answers a typed refusal,
        never a fabricated resolution."
    },
    "isError": true
  }
}
```

**400 with the collision nested — the capture rejection envelope.**
The capture door never answers a partial acceptance: a job that
collides refuses whole as a `ValidationException` problem document,
and `ResourceAlreadyExistsException` names each colliding event
inside `errors[]`. Executed 2026-07-31 against `epcis.dev@0.1.0` —
the same document captured twice with different bytes under one
`eventID`, over the stdio MCP door:

```
{
  "structuredContent": {
    "type": "epcisException:ValidationException",
    "title": "capture job rejected — no partial acceptance",
    "status": 400,
    "detail": "every EPCIS event in the payload is rejected;
      see errors[]",
    "captureID": "074b4c8a-4ebe-4561-8ca5-3859611f7f1c",
    "errors": [
      {
        "type": "epcisException:ResourceAlreadyExistsException",
        "title": "eventID urn:uuid:2f24f00d-0000-4000-8000-
          000000000001 was already captured with different
          content; events are append-only — submit a correction
          as a new event carrying an errorDeclaration",
        "status": 400,
        "instance": "/epcisBody/eventList/0/eventID"
      }
    ]
  },
  "isError": true
}
```

Match the envelope on `type`, then walk `errors[]` and match each
entry on its own `type` — the collision is per event, the refusal is
whole.

(The `detail` and long `title` strings above are wrapped for width;
`type`, `status` and every other byte are verbatim from the real
responses. Each full `result` also carries the same document as a
`content` text block.)

A refusal is one of three honest answer shapes — the other two, the
result and the truthful empty, are on
[the refusal grammar](/docs/errors/refusals/).

---

**Proof, not adjectives.** This build could not verify the gateway's test suite green, so no sentence on this page claims a passing run — the last verified run is recorded in the repository's own CI, not here. Live on this origin, no key: POST /translate, /validate, /hash. No conformance attestation has ever been issued. The dated ledger is /what-ships-today/.
