# Errors your agent can branch on.

*31 July 2026 · the launch journal*

Contract first, prose second. If you are an agent (or writing one), these are the facts to persist:

- Every wire failure is an RFC 7807 `application/problem+json` body whose `type` carries the official EPCIS exception vocabulary (`epcisException:*`, from the pinned OpenAPI). Branch on `type` and `status`.
- Every CLI failure is typed on **stderr** — `{"error":{"code","message","hint"?}}` in `--json` mode, `error\tcode=…` lines in text mode. stdout is payload-only, always: `JSON.parse(stdout)` never sees a half-written object.
- Exit codes are stable: `0` ok · `1` fail (negative domain verdict) · `2` usage · `3` not-found (unreadable input) · `4` internal. `code` strings are stable across releases; match on `code`, never on `message`.

Everything below is those three facts, demonstrated.

## The wire: one error vocabulary, the spec's own.

The EPCIS 2.0 OpenAPI defines the exception vocabulary; this surface returns it as problem `type`s rather than inventing a parallel one. The bodies, exactly as the wire returns them:

**Rejected capture (validation failure):**

```
HTTP/1.1 400
content-type: application/problem+json; charset=utf-8

{
  "type": "epcisException:ValidationException",
  "title": "document failed EPCIS 2.0.1 schema validation",
  "status": 400,
  "errors": [
    { "path": "/epcisBody/eventList/0",
      "keyword": "required",
      "message": "must have required property 'action'" }
  ]
}
```

**Unknown resource (missing event, dead page token):**

```
HTTP/1.1 404
content-type: application/problem+json; charset=utf-8

{
  "type": "epcisException:NoSuchResourceException",
  "title": "no such page token",
  "status": 404
}
```

**Refused query parameter** — and note *why* it refuses rather than ignores: a silently dropped predicate **widens** results, which for a reader is a correctness failure, not a convenience:

```
HTTP/1.1 400

{
  "type": "epcisException:QueryParameterException",
  "title": "unknown query parameter",
  "status": 400
}
```

The full vocabulary in use on this surface: `ValidationException`, `QueryParameterException`, `NoSuchResourceException`, `ResourceAlreadyExistsException`, `SecurityException`, `MethodNotAllowedException`, `UnsupportedMediaTypeException`, `NotAcceptableException`, `CaptureLimitExceededException`, `ImplementationException`. Ten types cover the surface; none of them requires reading a sentence. Successful responses carry `GS1-EPCIS-Version: 2.0.1` headers, so even the version check is a header match, not a docs lookup. Same contract at [the MCP door](/journal/a-door-not-a-second-path/): a domain refusal arrives as a tool result with `isError: true` mirroring the problem document — switch on it; it is not a JSON-RPC transport error.

## The CLI: three channels, no ambiguity.

Real transcripts, unedited. A domain verdict — the document is *invalid*, which is the tool working, not the tool failing:

```
$ npx epcis.dev validate object-event-missing-action.json
schema-error  /epcisBody/eventList/0  must have required property 'action'
schema-error  /epcisBody/eventList/0  must match "then" schema
schema-error  /                       must match "then" schema
invalid  errors=3
$ echo $?
1
```

Verdict lines on stdout (they are the payload), stderr empty, exit `1`. Now an operational error — the input can't be read at all:

```
$ npx epcis.dev validate does-not-exist.json
error  code=NOT_FOUND  message=cannot read document: ENOENT: … open 'does-not-exist.json'
$ echo $?
3
```

And a usage error, with a hint an agent can act on:

```
$ npx epcis.dev frobnicate
error  code=USAGE  message=unknown verb "frobnicate"  hint=run `npx epcis.dev` with no arguments for the verb map
$ echo $?
2
```

## Exit 1 is a verdict, not a crash.

This is the distinction most CLIs blur and agents most need. `validate` exiting `1` on an invalid document is a **negative domain verdict** — the fail-closed path *worked*. Exit `4` is a crash. Collapse them into one nonzero and an agent cannot tell "this document is bad, report it upstream" from "this tool is broken, stop trusting its output." The decision tree that falls out, suitable for pasting into a retry policy:

| observed | meaning | branch |
|---|---|---|
| exit 0 | verdict: pass / delivered / accepted | consume stdout, proceed |
| exit 1 | verdict: negative (invalid, rejected, failed suite) | report the typed payload; do **not** retry unchanged input |
| exit 2, `code=USAGE` | your invocation is wrong | fix the call from `hint`; retry once |
| exit 3, `code=NOT_FOUND` | input path unreadable | resolve the path; retry |
| exit 4 | internal fault | back off; escalate to the human; distrust partial output |
| wire 400 `ValidationException` | payload defect, permanent | fix payload; never blind-retry |
| wire 404 `NoSuchResourceException` | absent (possibly out of scope) | treat as absence — the door does not distinguish "hidden" from "missing," by design |
| wire 5xx / `ImplementationException` | transient or fault | retry with backoff; [replay is safe — capture is idempotent](/journal/capture-once-replay-anything/) |

The last row matters most in combination: because [event identity is the content hash](/journal/identity-is-computed/), the retry branch never risks duplication. Typed errors decide *whether* to retry; idempotent capture makes the retry itself free. The two halves of the contract are designed for each other.

## Read the contract where you already are.

The whole surface — verbs, flags, channels, exit codes, error code strings — ships as machine-readable prose inside the tarball: `node_modules/epcis.dev/AGENTS.md`, [the package's own operating manual](/journal/a-door-not-a-second-path/). Grep it, persist the block it tells you to persist, and branch on what this post showed you: `type` on the wire, `code` on stderr, integers everywhere else. No regexes over English. The error surface is as much the product as the success path — for you, arguably more.

---

**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/.
