> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Troubleshooting

> Start from the symptom. Find the cause. Apply the fix — with a runnable check for each.

This page is organized by **what you are seeing**, not by error code. Find your
symptom, read the cause, run the check. For the exhaustive code-by-code reference,
see [Errors](/errors).

<Info>
  **Every check runs against the live sandbox** at
  `https://phosra-api-sandbox-production.up.railway.app` — no key required for the
  reads below. Set it once:

  ```bash theme={null}
  export BASE=https://phosra-api-sandbox-production.up.railway.app
  ```
</Info>

## First: decode any error in five seconds

Every error carries the same three fields. Read them in this order:

1. **`code`** — the HTTP status. `4xx` is your request; `5xx` is ours.
2. **`class`** — *(OCSS routes only)* the machine failure class. Branch on this.
3. **`failed_step`** — *(403 standing failures only)* the exact check that rejected you.

```bash theme={null}
# Pretty-print any error and its class
curl -s "$BASE/api/v1/providers/did:ocss:nope/connect" | python3 -m json.tool
# {
#   "error": "Not Found",
#   "message": "provider not found",
#   "code": 404,
#   "class": "not_found"
# }
```

<Tip>
  Grab the **`X-Railway-Request-Id`** response header before you contact support —
  it lets us find your exact request in the logs.

  ```bash theme={null}
  curl -s -D - -o /dev/null "$BASE/health" | grep -i x-railway-request-id
  ```
</Tip>

***

## 401 vs 404: which one should I be getting?

The two most-confused failures. They mean opposite things, and the API keeps them
strictly separate so you always know which side the problem is on.

|                       | `401 signature_invalid`                                         | `404 not_found`                                                                                        |
| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **The route**         | exists, and requires you to prove who you are                   | may or may not exist — the *resource* behind it doesn't                                                |
| **What's wrong**      | your **request signature** (missing, malformed, wrong `key_id`) | the **id in the path** (a DID / rule / endpoint that isn't there)                                      |
| **Re-signing helps?** | Yes — fix the signature and retry                               | No — the thing isn't there; a retry returns 404 forever                                                |
| **Leaks existence?**  | No — you get 401 before any lookup                              | Yes, deliberately: 404 means "genuinely absent," **never** a silent redirect to a nearby live resource |

**The trap:** a signed route you call *without* a signature returns `401`, not `404` —
even if the path is nonsense. Authentication is checked **before** resource lookup, so
a missing signature masks whether the resource exists at all.

```bash theme={null}
# No signature on a signed route -> 401 (the id is never even looked up)
curl -s -o /dev/null -w "%{http_code}\n" -X POST "$BASE/api/v1/enforcement-confirmations" \
  -H "Content-Type: application/json" -d '{}'
# 401

# An UNSIGNED read route with a bogus id -> 404 (resource lookup runs)
curl -s -o /dev/null -w "%{http_code}\n" "$BASE/api/v1/providers/did:ocss:nope/connect"
# 404
```

**Decide in one question:** *does this route require a request signature?*
If yes and you're getting 401 → fix the signature (see below). If no, or your
signature is already valid and you still get 404 → the id in your path is wrong;
verify it character-for-character. A 404 is **terminal** — do not poll it back to life.

***

## Symptoms

<AccordionGroup>
  <Accordion title="401 — 'exactly one Signature-Input and one Signature header are required'" icon="signature">
    **Class:** `signature_invalid`

    The route requires an RFC 9421 HTTP Message Signature and your request had none,
    had more than one, or the pair was malformed.

    **Common causes**

    * You sent a bearer token where a **request signature** is required. Data-plane
      writes (rule writes, confirmations, consent) are *signed*, not bearer-authed.
    * You sent `Signature` without its matching `Signature-Input` (or vice-versa).
    * A proxy stripped or duplicated the headers.

    **Fix**

    1. Attach exactly one `Signature-Input` and one `Signature` header.
    2. Sign with the `key_id` the census published for you — the exact `did#kid` echoed
       in your self-register response body.
    3. Confirm your key is on the Trust List:

    ```bash theme={null}
    # The signed Trust List carries an `entries` array — find YOUR DID in it.
    # (The census serves `document` as a JSON object here; older builds stringified
    #  it, so fall back to json.loads if needed.)
    curl -s "$BASE/.well-known/ocss/trust-list" \
      | python3 -c "import sys,json; d=json.load(sys.stdin); doc=d['document']; doc=json.loads(doc) if isinstance(doc,str) else doc; e=doc['entries']; print(len(e),'entries'); print('mine:', any('did:ocss:acme-demo' in x.get('did','') for x in e))"
    # e.g. 133 entries  — the live count grows as providers accredit; yours will differ.
    ```

    **Reproduce the failure** (no signature → 401):

    ```bash theme={null}
    curl -s -X POST "$BASE/api/v1/enforcement-confirmations" \
      -H "Content-Type: application/json" -d '{}'
    ```
  </Accordion>

  <Accordion title="401 — 'unknown key_id' / signature verifies locally but the census rejects it" icon="key">
    **Class:** `signature_invalid`

    Your signature is well-formed, but the `key_id` you signed with is not the one
    published on the Trust List for your DID.

    **Cause**

    * You signed with `did:ocss:you#some-kid` but the census published
      `did:ocss:you#2026-07` (the default is `YYYY-MM`).
    * You rotated your key and are signing with the old `kid`.

    **Fix** — sign with the **exact** `key_id` from your registration response. When you
    self-register you can pin a bespoke `kid`; whatever the census echoes back is
    authoritative:

    ```bash theme={null}
    curl -s -X POST "$BASE/api/v1/advisors/self-register" \
      -H "Content-Type: application/json" \
      -d '{"did":"did:ocss:acme-demo-'$(date +%s)'","public_key_b64url":"jDWZfB70DM8B3ZnPG8FQ0tnanqLgwJX4ZMr_E1Ugv3w"}' \
      | python3 -m json.tool
    # -> "key_id": "did:ocss:acme-demo-...#2026-07"  <-- sign with THIS
    ```
  </Accordion>

  <Accordion title="400 — 'did is required' / 'public_key_b64url is required' / 'invalid_json'" icon="triangle-exclamation">
    **Class:** *(house — no `class` field)*

    A validation error before the OCSS layer: a missing required field or a body that
    isn't valid JSON.

    **Fix**

    * `invalid_json: unexpected EOF` → your JSON is truncated. Check quoting and that
      the body is complete.
    * `did is required` → include a `did`.
    * `public_key_b64url is required` → include your Ed25519 public key as a 43-char
      base64url string (no padding).

    **Minimal valid self-register:**

    ```bash theme={null}
    curl -s -X POST "$BASE/api/v1/advisors/self-register" \
      -H "Content-Type: application/json" \
      -d '{"did":"did:ocss:acme-demo-'$(date +%s)'","public_key_b64url":"jDWZfB70DM8B3ZnPG8FQ0tnanqLgwJX4ZMr_E1Ugv3w"}'
    ```
  </Accordion>

  <Accordion title="400 — 'unregistered rule_category slug: the OCSS rule vocabulary is closed'" icon="ban">
    **Class:** `malformed` *(standing check step d)*

    You wrote a rule with a `rule_category` that is not in the OCSS registry. The
    vocabulary is **closed** — the API rejects unknown slugs rather than coercing them.

    **Fix** — use a registered category. The full list is the
    [rule reference](/ocss/rule-reference); common ones:
    `addictive_pattern_block`, `dm_restriction`, `age_gate`, `content_block_title`,
    `infinite_scroll_block`.

    The same closed-vocabulary rule applies to `age_band`, `harm_class`, and
    `method_class` — an off-list value returns `malformed`, never a best-effort match.
  </Accordion>

  <Accordion title="403 — signature is valid but the write is refused" icon="lock">
    **Class:** `standing_failure` or `scope_failure` — **read `failed_step`.**

    Your request was cryptographically valid but you lack the *authority* for it.
    `failed_step` names the exact §6.2 check:

    | `failed_step`       | What it means                                             | Fix                                                          |
    | ------------------- | --------------------------------------------------------- | ------------------------------------------------------------ |
    | `authority_binding` | No standing binds your DID to this child/subject          | Present a consent attestation or grant for the subject first |
    | `scope`             | The category is outside your standing's granted scope     | Write only in-scope categories, or widen the standing        |
    | `unregistered_slug` | The `rule_category` slug is not registered                | Use a registered category                                    |
    | `band_exceeds_tier` | Your accreditation tier can't reach that enforcement band | Raise your tier, or write at a permitted band                |

    For **`scope_failure`**, a consent attestation arrived for an app your DID does not
    operate — fix the `app_ref` so it names an app you run, or register that app.

    **Sandbox shortcut** — a self-registered provisional DID can mint a TEST consent
    for itself over a sandbox test child, so a consent-first write completes without a
    Phosra-side roster edit:

    ```
    POST /api/v1/sandbox/consent-attestations   (signed; PHOSRA_ENV=sandbox only)
    ```
  </Accordion>

  <Accordion title="404 — 'provider not found' on /providers/{did}/connect" icon="plug">
    **Class:** `not_found`

    The connect endpoint resolves OAuth config for an **accredited** provider. A 404
    means either the DID isn't on the Trust List, or it's registered but has no
    `oauth_connect` config seeded.

    **Fix / diagnose**

    ```bash theme={null}
    # Accredited + configured -> 200 with authorize/token/profiles URLs
    curl -s "$BASE/api/v1/providers/did:ocss:loopline/connect" | python3 -m json.tool

    # Registered but NO connect config -> 404 (this is the load-bearing
    # 'accredited-but-unconfigured' case, e.g. courier in the sandbox)
    curl -s "$BASE/api/v1/providers/did:ocss:courier/connect"
    ```

    A 404 here is **never** a silent substitution — the census will not redirect an
    unknown DID to a nearby live provider. Verify the DID string exactly.
  </Accordion>

  <Accordion title="409 — 'did_already_registered' on self-register" icon="copy">
    **Class:** *(house — Conflict)*

    Self-register is **create-only**. It cannot overwrite an existing Trust List entry —
    that guards against a squatter rebinding another party's DID to a new key.

    **Fix**

    * Registering for the first time? Pick a DID that isn't taken (the sandbox seeds 16+
      reserved slugs like `loopline`, `courier`, `beacon`).
    * Rotating a key on a DID you already own? Self-register is not the path — use the
      key-rotation endpoint for your existing entry.

    ```bash theme={null}
    # First call -> 200; identical DID again -> 409 Conflict
    DID="did:ocss:dup-demo-$(date +%s)"
    curl -s -o /dev/null -w "first:  %{http_code}\n" -X POST "$BASE/api/v1/advisors/self-register" \
      -H "Content-Type: application/json" \
      -d "{\"did\":\"$DID\",\"public_key_b64url\":\"jDWZfB70DM8B3ZnPG8FQ0tnanqLgwJX4ZMr_E1Ugv3w\"}"
    curl -s -o /dev/null -w "second: %{http_code}\n" -X POST "$BASE/api/v1/advisors/self-register" \
      -H "Content-Type: application/json" \
      -d "{\"did\":\"$DID\",\"public_key_b64url\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"}"
    # first:  200
    # second: 409
    ```
  </Accordion>

  <Accordion title="409 — 'replay' on a write I retried" icon="rotate">
    **Class:** `replay`

    You reused an `Idempotency-Key` with a **different** payload than the first time it
    was seen. The key is permanently bound to its first body.

    **Cause** — a retry regenerated part of the payload: a fresh `timestamp`, a new
    nonce, or a re-serialized object whose key order changed.

    **Fix**

    1. Build the payload **once**, freeze it, then attach the key.
    2. On retry, resend the *byte-identical* payload with the *same* key.
    3. A faithful replay is a **success** — it returns `200` with the original receipt
       bytes and header `OCSS-Replay: original`. Treat that as done, not as a duplicate.
  </Accordion>

  <Accordion title="422 — 'nonconformant': signature valid but the profile is rejected" icon="diagram-project">
    **Class:** `nonconformant`

    Distinct from `signature_invalid`. Your signature verified, but your deployment
    profile *selects* enforcement modes that fall outside the **SUPPORTED** set your
    Trust List entry declares (§9.4).

    **Fix** — reconcile the two sets: either narrow the modes your profile selects to
    what you published as SUPPORTED, or update your Trust List entry's SUPPORTED set to
    include the modes you actually run. Never collapse this into a signature retry — the
    signature was fine.
  </Accordion>

  <Accordion title="429 — 'Too Many Requests'" icon="gauge-high">
    **Class:** *(house)*

    You exceeded the per-key / per-window rate limit. Every response carries the live
    counters — you never have to guess the quota.

    **Fix** — read `X-RateLimit-Reset` (a Unix timestamp), sleep until then, retry once.

    ```bash theme={null}
    # See your current window on any /api/v1 route (this GET needs no key).
    curl -s -D - -o /dev/null "$BASE/api/v1/platforms" | grep -i x-ratelimit
    # x-ratelimit-limit: 100
    # x-ratelimit-remaining: 90
    # x-ratelimit-reset: 1783311960
    ```

    <Note>
      Only `/api/v1/*` responses carry these headers — the unauthenticated
      `.well-known/` discovery reads are **unmetered**, so `grep`-ing them there
      returns nothing. Read your window off an API route.
    </Note>

    Back off proactively when `X-RateLimit-Remaining` approaches zero rather than
    waiting for the 429.
  </Accordion>

  <Accordion title="Disconnect / rotation races — a call that worked a moment ago now 401s or 404s" icon="link-slash">
    When a provider **rotates its key** or a parent **disconnects**, there is a brief
    window where in-flight requests can race the change. The symptoms:

    * **`401 signature_invalid`** right after a key rotation — you signed with the old
      `kid` while the census already published the new one. **Fix:** re-fetch the Trust
      List, pick up the current `kid`, re-sign. Do not retry with the stale key.
    * **`404 not_found`** on a provider `connect` or profile right after a disconnect —
      the entry or endpoint was removed. **Fix:** treat 404 as terminal for that
      resource; do not poll it back to life. Re-run the connect ceremony to establish a
      fresh binding.
    * **`403 standing_failure` (`authority_binding`)** right after a consent withdrawal —
      the standing that authorized your write is gone. **Fix:** stop writing for that
      subject; a withdrawn consent is final until re-granted.

    **Why this is safe by design:** the Trust List is served with
    `Cache-Control: max-age=300` and a strong `ETag`. Poll it with `If-None-Match` so
    you notice a rotation within one cache window instead of discovering it via a failed
    write:

    ```bash theme={null}
    ETAG=$(curl -s -D - -o /dev/null "$BASE/.well-known/ocss/trust-list" \
      | grep -i '^etag:' | tr -d '\r' | awk '{print $2}')
    curl -s -o /dev/null -w "changed? %{http_code}\n" \
      -H "If-None-Match: $ETAG" "$BASE/.well-known/ocss/trust-list"
    # 304 = unchanged, keep your cached keys; 200 = re-read, a key moved
    ```
  </Accordion>

  <Accordion title="A read looks stale — am I getting old data?" icon="clock-rotate-left">
    **Not an error.** OCSS reads serve **last-known-good** and report staleness through
    a Receipt (§8.1 cl.4) rather than failing. There is deliberately no "stale" error
    class.

    **How to tell how fresh a read is**

    * `Cache-Control: public, max-age=300` → the artifact is guaranteed fresh for 300s.
    * The strong `ETag` changes the instant the artifact does — poll with
      `If-None-Match` and a `304` proves nothing moved.

    If you need a definitely-current view, issue a conditional GET; a `200` (not `304`)
    means you now hold the latest bytes.
  </Accordion>

  <Accordion title="500 / 502 / 503 — a server error" icon="server">
    **500** returns the fixed body `{"error":"Internal Server Error","message":"internal
        error","code":500}` — deliberately no `class` and no cause text (we never leak
    internals onto the wire).

    **502 / 503** usually mean an upstream census is warming. Sandbox services
    auto-sleep to save cost and cold-start on the first request after idle.

    **Fix**

    1. Retry with exponential backoff (1s, 2s, 4s, 8s).
    2. If a 500 persists, capture the `X-Railway-Request-Id` and contact
       [developers@phosra.com](mailto:developers@phosra.com) — it pins your exact request
       in the logs.
  </Accordion>

  <Accordion title="405 — 'Method Not Allowed' with an empty body" icon="arrow-right-arrow-left">
    **Class:** *(house)*

    You used the wrong HTTP method for the route (e.g. `POST` to a `GET`-only path). The
    response has an empty body and an **`Allow`** header listing the accepted method.

    ```bash theme={null}
    curl -s -D - -o /dev/null -X POST "$BASE/api/v1/providers/did:ocss:loopline/connect" \
      | grep -i '^allow:'
    # allow: GET      (read routes also advertise HEAD)
    ```

    **Fix** — switch to the method in `Allow`. Double-check you're hitting the intended
    route; a trailing-slash or path-segment typo can land you on a different handler.
  </Accordion>
</AccordionGroup>

***

## Still stuck?

<Steps>
  <Step title="Confirm the census is up">
    ```bash theme={null}
    curl -s "$BASE/health"          # {"status":"ok"}
    ```
  </Step>

  <Step title="Confirm your identity is on the Trust List">
    ```bash theme={null}
    curl -s "$BASE/.well-known/ocss/trust-list" | python3 -m json.tool | head
    ```
  </Step>

  <Step title="Capture the request id and reach out">
    Grab `X-Railway-Request-Id` from the failing response and email
    [developers@phosra.com](mailto:developers@phosra.com) with the request id, the
    `code`, the `class`, and (if present) the `failed_step`.
  </Step>
</Steps>

<CardGroup cols={2}>
  <Card title="Error reference" icon="list" href="/errors">
    The complete status-code and error-class tables, every field documented.
  </Card>

  <Card title="Authentication" icon="key" href="/authentication">
    API keys, WorkOS sessions, and RFC 9421 request signing.
  </Card>
</CardGroup>
