> ## 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.

# Migrating a hand-minted credential

> Why a credential minted separately from its Trust List entry can silently diverge, how to detect it with one call, how to fix it, and which existing platforms are exposed.

# If your credential was minted before `/apps`

This page is for platforms and providers that were provisioned the **old way**: a keypair minted
by hand or by a seeding script, a Trust List entry published separately, and a credential issued
against it. That arrangement works right up until the two halves stop agreeing — and when they
do, the symptom is uninformative and the cause is invisible from outside.

***

## The failure class, precisely

A credential embeds a signing key. The census separately publishes signing keys for that DID. On
every request the SDK compares them:

```
authority.publicKey  ==  the key the census publishes under this credential's active kid
```

When those were **minted by two separate operations, nothing structurally guaranteed they would
match.** Any of these breaks the equality, silently and at a distance:

* A **re-seed that redefines an existing `kid`.** A seeder whose upsert overwrites the published
  key material for a kid that already exists changes what the census publishes without changing
  what any deployed credential holds. Nothing rotates, nothing is versioned, and every credential
  naming that kid stops verifying at once.
* A **rotation that drops a kid.** A new kid is published and the old one removed, while a
  deployment still holds a credential naming the old one.
* A **credential re-issued** against a different key than the one on the entry.

The result is the same in every case: **every `/api/phosra/*` route returns `503 PHOSRA_NOT_READY`**,
with a message identical to the one you would get from a missing credential, a lapsed standing,
or an unmigrated database.

<Warning>
  **An SDK upgrade cannot fix a divergence.** Both sides of the failing comparison are *data* —
  one lives in your credential, the other on the census. Upgrading changes neither. The only fix is
  to make them the same key again.
</Warning>

***

## Detecting it: one call

Upgrade to `@phosra/gatekeeper` ≥ 0.8.68 and read the readiness endpoint. No code changes are
needed — the route is mounted by the same handler that already mounts your protocol routes.

```bash theme={null}
npm install @phosra/gatekeeper@^0.8.68
```

<Note>
  If your `package.json` declares `^0.6.0`, npm **will not** resolve 0.8.68 — a caret on a `0.x`
  version pins the minor. Change the range to `^0.8.68`. If you vendored the package, delete the
  vendored copy first.
</Note>

```bash theme={null}
curl -s https://your-app.example.com/api/phosra/status | jq '{precondition, signingKey}'
```

A divergence prints both public halves side by side:

```json theme={null}
{
  "precondition": "PLATFORM_SIGNING_KEY_MISMATCH",
  "signingKey": {
    "kid": "did:ocss:example#link-1",
    "credentialPublicKey": "…",
    "censusPublicKey": "…",
    "matches": false
  }
}
```

Both values are public key material — safe to read, safe to paste into a ticket.

The sibling code **`PLATFORM_SIGNING_KEY_UNPUBLISHED`** means the census publishes *no* key under
that kid at all (the rotation-dropped-it case), which needs the same fix.

<Card title="Every precondition and what to do about it" icon="stethoscope" href="/integration/platform-readiness" horizontal>
  The full readiness contract — thirteen codes, each with its cause and its fix.
</Card>

### Checking from outside, without deploying anything

The trust list is world-readable, so you can inspect what the census publishes for any DID right
now:

```bash theme={null}
curl -s https://prodapi.phosra.com/.well-known/ocss/trust-list \
| jq -r '.document | fromjson | .entries[] | select(.did=="did:ocss:YOUR-DID")
         | "\(.status)  \(.tier)  \(.role)", (.jwks.signing_keys[] | "  \(.kid)  \(.x)")'
```

Real output for `did:ocss:custo` — note the dropped `link-1` discussed below:

```
active  accredited  enforcement-agent
  link-2  5p4cuEguz-2QLVGEgbBU-IWcswv99uKp5BpRfLJgXCw
  authority-1  _j-wLRNSkfMHkDlb7wiHwUOdnngB6x7s3qgzUEQrA8U
  authority-2  _sEJXLx4XmCgOAZ7b4mS-Ygr0IXk5_wcUiD0cScl68Q
```

<Note>
  `document` is served as a **JSON string**, not a nested object — it is the exact byte sequence
  the root signature covers, which is why `fromjson` is needed.
</Note>

This tells you the census's half. It cannot tell you your credential's half — only the readiness
endpoint, running inside your deployment, can do that. That asymmetry is exactly why the endpoint
exists.

***

## Fixing it

<Steps>
  <Step title="Confirm it is a divergence, not something else">
    `PLATFORM_SIGNING_KEY_MISMATCH` or `PLATFORM_SIGNING_KEY_UNPUBLISHED`. If you see
    `PLATFORM_STANDING_NOT_ACCREDITED` instead, your keys are fine — an attestation probably
    lapsed, which is a different and much smaller fix.
  </Step>

  <Step title="Re-provision so both halves come from one key">
    [`POST /api/v1/developers/orgs/{orgId}/apps`](/platform/create-app) generates the keypair,
    registers and claims the DID, publishes the Trust List entry, and mints the credential
    **from that same key**. They cannot disagree afterwards, because there is only one key.
  </Step>

  <Step title="Set the returned credential and redeploy">
    Two environment variables. Capture `writer_seed` in the same step — it is shown once and
    Phosra does not store it.
  </Step>

  <Step title="Verify before you close the ticket">
    `curl -s .../api/phosra/status | jq .precondition` → `PLATFORM_READY`. Do not infer success
    from a route that stopped 503ing under load.
  </Step>
</Steps>

<Warning>
  **Re-provisioning an existing DID is not currently possible through `/apps`.** The endpoint is
  **create-only** by design, and both walls are deliberate:

  * It derives identity from your org and app name and binds it in the org↔DID table. A DID that
    was hand-seeded has no such binding, so `/apps` would mint a *different* DID rather than re-key
    the existing one.
  * Claiming an already-registered DID requires proving possession of **that DID's real keypair**.
    A caller who has lost key agreement with the census by definition cannot do that, so the claim
    fails closed — which is correct (it is the same check that stops one org claiming another org's
    DID) but means the case that most needs re-provisioning is the case `/apps` refuses.

  So today, migrating an existing diverged DID is an **operator action**, not a self-serve one.
  Detect it with the readiness endpoint, then raise it with Phosra with the `signingKey` block
  attached. New apps get the structural guarantee from day one.
</Warning>

***

## Who is exposed today

Verified against the production census (`prodapi.phosra.com`, trust list issue 144, root
`root-prod-bootstrap-2026-07`) and by probing each deployment directly, 2026-07-27.

| DID                 | Deployment probe                                                                    | Published signing kids                 | Exposure                                                                                                                                                                                                                                                                           |
| ------------------- | ----------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `did:ocss:notflix`  | `/authorize` → **503 `PHOSRA_NOT_READY`**; `/status` → `404 PHOSRA_ROUTE_NOT_FOUND` | `link-1`, `2026-07`, `authority-1`     | **Broken now.** Standing is fine (`active` / `accredited` / `enforcement-agent`, attestation 1.1 days old), so the fault is downstream of standing. Runs a vendored gatekeeper predating the readiness route, so the precondition cannot yet be named.                             |
| `did:ocss:pixagram` | `/authorize` → **503 `PHOSRA_NOT_READY`**; `/status` → `404 PHOSRA_ROUTE_NOT_FOUND` | `2026-07`, `authority-1`, `link-1`     | **Broken now — identically.** Same symptom, same SDK gap, same standing (healthy). Two platforms failing the same way argues for a shared input rather than two independent key mismatches.                                                                                        |
| `did:ocss:bloxby`   | `/status` and `/authorize` → **307 → `/login`**                                     | `2026-07`, `authority-1`, `link-1`     | **Unmeasurable.** Its own auth middleware shadows `/api/phosra/*`, so no external probe can see readiness. Shares the exact kid triple of the two broken platforms. Exempt that prefix from auth before anything else.                                                             |
| `did:ocss:snaptr`   | `/status` and `/authorize` → framework HTML `404`                                   | `2026-07` only                         | **Not exposed to this class.** The SDK is not mounted, and the entry carries no `link-*` kid, so it never received a Link credential.                                                                                                                                              |
| `did:ocss:custo`    | `/api/health` → `200`                                                               | `link-2`, `authority-1`, `authority-2` | **Healthy today, latent risk.** It publishes `link-2` and **no `link-1`**, while keeping `authority-1` — a rotation dropped the signing key and kept its authority sibling. Anything still holding a custo credential that names `link-1` hits `PLATFORM_SIGNING_KEY_UNPUBLISHED`. |

<Note>
  **One standing issue worth knowing, unrelated to key divergence.** `did:ocss:propagate` is served
  `tier: accredited` with a conformance attestation **16.5 days old** against a 7-day TTL. Every
  other accredited entry is 1.2 days old or newer. If your integration depends on propagate's
  standing, be aware it is presently accredited on an attestation that has passed its stated
  lifetime.
</Note>

***

## What the new path guarantees

For anything created through [`/apps`](/platform/create-app), this failure class does not exist:
one keypair is generated once and used for **both** the credential and the published Trust List
entry, inside a single request, and the census refuses to release the credential envelope unless
the freshly recompiled, root-verified Trust List already resolves that key id to those exact
bytes.

Credential and census cannot diverge, because there is only ever one key.
