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

# @phosra/gatekeeper — createPlatform

> The platform SDK reference: the three things you configure, the four preconditions that gate boot, the directory row you must publish, the profiles endpoint you must serve, and the worker without which nothing is ever delivered.

# The platform SDK

`@phosra/gatekeeper` is the platform half of Phosra Link. A **platform** enforces rules it
receives; it never writes them. `createPlatform` owns the whole protocol surface — PAR,
authorize, token, delivery, retry, signing, storage, migrations, retries — and asks your app for
four real seams: who the signed-in account is, how to `apply` a rule, how to independently
`observe` that it stuck, and how to `release` it.

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

<Note>
  **Current published version: `0.8.68`.** Both shipped reference platforms run it. Install
  unpinned so a `0.8.x` fix reaches you, but know the floors: `GET /api/phosra/status` — the
  diagnostic every other page sends you to — needs **≥ 0.8.68**, and a `^0.6.0` range will **not**
  resolve it, because a caret on a `0.x` version pins the minor. The production environment
  manifest declares a separate protocol floor of `sdk_minimum.gatekeeper: 0.8.6`; that is the
  protocol floor, not the diagnostic floor.
</Note>

***

## What you configure

The Golden platform is often described as having "one Phosra input." In practice a running
platform needs **three**, and two of them cause first-boot failures when they are missed.

| Input                                           | Required        | Notes                                                                                                                                                                             |
| ----------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `credential` — the `PHOSRA_CREDENTIAL` envelope | always          | Operator-issued. See [Getting a credential](#getting-a-platform-credential). It pins your DID, your environment, your census, your trust root **and your application origin**.    |
| `adapter.database` — a node-postgres `Pool`     | always          | The SDK owns its own schema in this database and migrates it. The role must be allowed to create objects. A missing or unmigratable database is `PLATFORM_DATABASE_NOT_READY`.    |
| The public application origin                   | non-Next mounts | The Next adapter derives absolute URLs from the request. Any other framework has to be told the public origin explicitly, and it **must equal the origin inside the credential**. |

```ts theme={null}
import { createPlatform } from "@phosra/gatekeeper"

export const phosra = createPlatform({
  credential: process.env.PHOSRA_CREDENTIAL!,
  onDiagnostic: (d) => console.error(JSON.stringify(d)),
  humanRecovery: { signInPath: "/login", createProfilePath: "/profiles/manage" },
  adapter: {
    database: pool,                          // pg.Pool — the SDK migrates this
    authorizedProviders: ["did:ocss:custo"], // see "Who goes in authorizedProviders"
    resolveAccount,                          // (Request) => { accountId, profiles } | null
    apply, observe, release, observeRelease,
  },
})
```

<Info>
  **Construction does no network or database work.** `createPlatform` snapshots the config and
  builds a lazy bootstrap coordinator; the first request (or your first `ready()`) drives the
  actual work. That is why the module is safe to import from a route file during `next build`.
  **This guarantee is specific to the platform factory** — `createGoldenLinkServer` on the
  provider side does network and schema work before it resolves. Do not carry one page's promise
  across to the other.
</Info>

Castle+ chooses to throw at its own boot when `PHOSRA_CREDENTIAL` is absent rather than serve a
503 on every route. That is a reasonable local policy; the SDK itself tolerates the missing value
and reports `CREDENTIAL_MISSING` from the readiness endpoint instead.

***

## The four preconditions that gate boot

`createPlatform` refuses to serve the protocol routes until every precondition passes, and the
routes then return a deliberately uniform `503 PHOSRA_NOT_READY`. Two of these preconditions are
things you publish. One is a route you must write yourself. One is not yours at all.

Read [`GET /api/phosra/status`](/integration/platform-readiness) to find out which one is
outstanding — it is the only route that answers while the rest are 503.

### 1. Your own directory row

Before it will boot, the SDK fetches **its own** entry from the census
(`GET /api/v1/providers/{yourDid}/connect`) and compares it to your credential. Six values are
checked, five of them for **exact**, byte-for-byte equality. Any one of them off is
`PLATFORM_DIRECTORY_UNAVAILABLE`, and every `/api/phosra/*` route then serves an opaque 503
forever — a correct credential, correct standing and correct origin are not enough.

| Field               | Must equal                                                                                    | Failure if wrong                          |
| ------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `directory_version` | the integer `1`                                                                               | rejected                                  |
| `name`              | your credential's `display.name`, byte for byte                                               | rejected — a one-character drift is fatal |
| `authorize_url`     | `{applicationOrigin}/api/phosra/authorize`                                                    | rejected                                  |
| `par_url`           | `{applicationOrigin}/api/phosra/par`                                                          | rejected                                  |
| `token_url`         | `{applicationOrigin}/api/phosra/token`                                                        | rejected                                  |
| `profiles_url`      | any path, but the **same origin** as the credential's application origin, and no query string | rejected                                  |

Two more rules catch people out:

* **The row is a closed set.** Beyond the six required members above, only `scopes`, `icon_url`,
  `connect_url`, `provisioning_form` and `profile_management_url` are permitted. **An unexpected
  member fails the whole check** — the row is rejected, not ignored.
* **The three protocol paths are fixed.** `/api/phosra/authorize`, `/api/phosra/par` and
  `/api/phosra/token` are compared as literal strings. You cannot mount the catch-all somewhere
  else and publish where you actually put it.

Verify a live row — this is Castle+'s, on the production census:

```bash theme={null}
curl -s https://prodapi.phosra.com/api/v1/providers/did:ocss:castle-plus/connect | jq
```

```json theme={null}
{
  "directory_version": 1,
  "name": "Castle+",
  "authorize_url": "https://castle-api-production-7071.up.railway.app/api/phosra/authorize",
  "par_url": "https://castle-api-production-7071.up.railway.app/api/phosra/par",
  "token_url": "https://castle-api-production-7071.up.railway.app/api/phosra/token",
  "profiles_url": "https://castle-api-production-7071.up.railway.app/api/phosra/profiles",
  "connect_url": "https://castle-api-production-7071.up.railway.app/api/phosra/delivery",
  "scopes": ["child_profiles.read", "content_rating"],
  "provisioning_form": "per_child"
}
```

Assert every field, not just `connect_url`:

```bash theme={null}
ORIGIN=https://your-app.example.com
curl -s https://prodapi.phosra.com/api/v1/providers/did:ocss:YOUR-DID/connect \
| jq --arg o "$ORIGIN" '{
    directory_version_ok: (.directory_version == 1),
    name: .name,
    authorize_ok: (.authorize_url == ($o + "/api/phosra/authorize")),
    par_ok:       (.par_url       == ($o + "/api/phosra/par")),
    token_ok:     (.token_url     == ($o + "/api/phosra/token")),
    profiles_same_origin: (.profiles_url | startswith($o))
  }'
```

Then check `name` against your credential's display name by eye — that comparison is a string
equality the SDK performs and no `jq` here can do for you.

#### Publishing the row: `PATCH /api/v1/platforms/{did}/connect`

The row is **self-declared**. `PATCH /api/v1/platforms/{did}/connect` is the operation that
publishes it:

* **RFC-9421 signed**, with your own census signing key. It is `401` without a signature, on
  both the production and the staging sandbox census.
* **Self-scoped** — you may only PATCH your own DID.
* **JSON-merge semantics.** Send only the members you are changing; `null` deletes a member.
* It **never** rotates endpoint labels or connect secrets, and it is idempotent.

```bash theme={null}
# Unsigned, to prove the route exists (401 = registered; a bogus sibling path returns
# the plain-text "404 page not found").
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
  https://prodapi.phosra.com/api/v1/platforms/did:ocss:castle-plus/connect
# → 401
```

<Warning>
  **Operator seeding is the bootstrap fallback, and it is what both live platforms actually got.**
  Castle+ and Notflix both satisfy the directory check because an operator seeded the row to
  match the credential at onboarding. If your app moves origin or changes display name, **the
  credential and the directory row must move together** — re-issue the credential *and* PATCH the
  row. Changing one alone is a permanent 503.
</Warning>

### 2. The profiles endpoint you must serve

`profiles_url` is in the required set, and a provider reads your child profiles from it during
the connect ceremony. **The catch-all does not serve it.** The `createPlatform` router answers
exactly six paths — `status`, `par`, `authorize`, `token`, `delivery`, `retry` — and 404s
everything else. You have to write this route yourself.

<Warning>
  **Readiness cannot catch a broken profiles endpoint.** The boot check verifies only the URL's
  *origin*, never that it answers. A platform can report `PLATFORM_READY` while its published
  `profiles_url` is a hard `404 PHOSRA_ROUTE_NOT_FOUND` — and at least one production deployment is
  in exactly that state right now, because the path looks like a `/api/phosra/*` route and is
  assumed to come from the facade. It does not. The failure then surfaces mid-ceremony, in the
  provider's app, to a parent. **Curl your own `profiles_url` before you call an integration
  done.**
</Warning>

The contract, as the shipped reference implements it:

* **Bearer-scoped.** The access token minted by your `/api/phosra/token` leg identifies the
  parent account. No token, or an unknown one, is `401`.
* **A bare JSON array**, not an object wrapper — the provider SDK consumes the body directly as
  a list.
* **Child profiles only.** Never the account holder, never a placeholder. An account with no
  children returns exactly `[]`.
* On the **same origin** as your credential's application origin, with **no query string**.

```ts theme={null}
// app/api/ocss/profiles/route.ts — Notflix's real handler, trimmed.
export async function GET(req: Request): Promise<Response> {
  const auth = req.headers.get("authorization") ?? ""
  const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : ""
  const uid = token ? uidFromAccessToken(token) : null
  if (!uid) {
    return Response.json({ error: "invalid_token" }, {
      status: 401,
      headers: { "WWW-Authenticate": 'Bearer realm="notflix"' },
    })
  }
  // Child profiles ONLY — [] when the account has none.
  const profiles = await connectableProfiles(uid)
  return Response.json(profiles, { status: 200, headers: { "Cache-Control": "no-store" } })
}
```

Notflix publishes `profiles_url: ".../api/ocss/profiles"` — a different path from its
`/api/phosra/*` protocol routes, which is fine: only the *origin* is constrained. Verify it the
way anyone else will:

```bash theme={null}
curl -s -o /dev/null -w '%{http_code}\n' https://your-app.example.com/api/ocss/profiles
# → 401  (a real handler refusing an anonymous read)
# → 404  means you have not written it yet
```

### 3. `authorizedProviders`

`adapter.authorizedProviders` is a **required, non-empty** allowlist of provider DIDs. Every DID
on it must be `status: active`, `tier: accredited` **and** `role: enforcement-agent` on the
census trust list, or boot fails with `AUTHORIZED_PARTY_NOT_ACCREDITED`.

This is **authorization, not discovery**. Adding a DID says "this company may write enforcement
rules into my product." It follows a commercial or integration agreement; it is not a config
default to copy from an example.

To see the candidates the census actually publishes:

```bash theme={null}
curl -s https://prodapi.phosra.com/.well-known/ocss/trust-list \
| jq -r '.document | fromjson | .entries[]
         | select(.status=="active" and .tier=="accredited" and .role=="enforcement-agent")
         | .did'
```

<Warning>
  **`role: enforcement-agent` does not separate providers from platforms.** On the production
  census that filter today returns `did:ocss:bloxby`, `did:ocss:castle-plus`, `did:ocss:notflix`,
  `did:ocss:pixagram`, `did:ocss:propagate`, `did:ocss:snaptr` and `did:ocss:xfanity-dns`
  alongside `did:ocss:custo` — most of those are platforms, like you. The trust list carries no
  flag that tells the two apart. Put a DID on your allowlist because you have an agreement with
  that company, not because a filter returned it.
</Warning>

The `document` member is served as a **JSON string** — it is the exact byte sequence the root
signature covers, which is why the `jq` needs `fromjson`. Parsing it any other way changes the
bytes and breaks verification.

### 4. The preconditions Phosra owns

Two preconditions are not yours, are invisible from your configuration, and are fatal.

#### The operating router must be accredited

`createPlatform` resolves the census's **operating router** — `routing.operating_router_did` in
the signed environment manifest, `did:ocss:phosra-router` on every current census — and hard-fails
boot if that entry is not `active` + `accredited`. The router is the signer whose signature is on
every enforcement profile you consume; if it lapses, nothing can be trusted, so nothing boots.

You do not configure this value, and you cannot fix it. It surfaces as
`AUTHORIZED_PARTY_NOT_ACCREDITED` — the same code as a lapsed provider — with **your** credential
perfectly healthy.

```bash theme={null}
curl -s https://prodapi.phosra.com/.well-known/ocss/trust-list \
| jq -r '.document | fromjson | .entries[] | select(.did=="did:ocss:phosra-router")
         | "\(.status)  \(.tier)  role=\(.role)"'
# → active  accredited  role=null
```

<Note>
  **The router carries no `role`.** Do not apply the `enforcement-agent` check to it — only
  `status` and `tier` are read. And if the router is the failing party, that is a Phosra-side
  condition: **report it, do not change your configuration.**
</Note>

#### Your DID must be on the census's consent-attestation roster

Before a provider can complete a connect ceremony against your platform, it lands a **consent
attestation** on the census naming your app. The census checks that name against an
operator-declared roster of apps it ingests for (`OCSS_CONSENT_ATTESTATION_APPS`). A DID that is
not on the roster is refused as a scope failure:

```
403  failed binding: app_ref — the attestation names an app this receiving surface
     does not operate (§8.3.2 IngestConsentAttestation cl.4)
```

This does **not** affect your boot — `/api/phosra/status` will happily report `PLATFORM_READY` —
and it is not something you can set. It is a census-side operator setting, requested once at
onboarding. If your platform boots cleanly but no ceremony ever reaches your delivery route, ask
Phosra to confirm your DID is on the roster of the census your credential names.

***

## Mounting

### Next.js

```ts theme={null}
// app/api/phosra/[...phosra]/route.ts
import { phosra } from "@/lib/phosra/platform"
export const { GET, POST } = phosra.next.handlers()
```

### Any other framework

`phosra.next` is a convenience wrapper, not a dependency. The real surface is
`phosra.handlers`, a set of plain WHATWG Fetch handlers — `(Request) => Promise<Response>` —
usable from Fastify, Express, Hono, Koa or bare `node:http`:

```ts theme={null}
phosra.handlers.status.GET       // GET  /api/phosra/status
phosra.handlers.par.POST         // POST /api/phosra/par
phosra.handlers.authorize.GET    // GET  /api/phosra/authorize
phosra.handlers.authorize.POST   // POST /api/phosra/authorize
phosra.handlers.token.POST       // POST /api/phosra/token
phosra.handlers.delivery.POST    // POST /api/phosra/delivery
phosra.handlers.retry.POST       // POST /api/phosra/retry
```

A Fastify bridge, condensed from the one Castle+ ships
(`services/phosra/src/plugin.ts`):

```ts theme={null}
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"

export async function phosraPlugin(app: FastifyInstance, opts: {
  phosra: GoldenPlatform
  applicationOrigin: string   // must equal the credential's application origin
}) {
  // 1. RAW BODY. The SDK verifies signatures over the exact bytes; a pre-parsed
  //    body is a different byte sequence and every signed request fails.
  app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))

  const bridge = (handler: (r: Request) => Promise<Response>) =>
    async (req: FastifyRequest, reply: FastifyReply) => {
      const url = new URL(req.url, opts.applicationOrigin)
      const headers = new Headers()
      for (const [k, v] of Object.entries(req.headers)) {
        if (v === undefined) continue
        Array.isArray(v) ? v.forEach((x) => headers.append(k, x)) : headers.set(k, String(v))
      }
      const hasBody = req.method !== "GET" && req.method !== "HEAD" && Buffer.isBuffer(req.body)
      const request = hasBody
        ? new Request(url, { method: req.method, headers, body: new Uint8Array(req.body as Buffer) })
        : new Request(url, { method: req.method, headers })
      const response = await handler(request)
      reply.code(response.status)
      response.headers.forEach((value, key) => {
        if (key !== "content-length") reply.header(key, value)
      })
      await reply.send(Buffer.from(await response.arrayBuffer()))
    }

  app.get("/api/phosra/status",    bridge(opts.phosra.handlers.status.GET))
  app.post("/api/phosra/par",      bridge(opts.phosra.handlers.par.POST))
  app.get("/api/phosra/authorize", bridge(opts.phosra.handlers.authorize.GET))
  app.post("/api/phosra/authorize",bridge(opts.phosra.handlers.authorize.POST))
  app.post("/api/phosra/token",    bridge(opts.phosra.handlers.token.POST))
  app.post("/api/phosra/delivery", bridge(opts.phosra.handlers.delivery.POST))
  app.post("/api/phosra/retry",    bridge(opts.phosra.handlers.retry.POST))
}
```

Two requirements that are easy to miss and both fail confusingly:

<Warning>
  **Hand the SDK the raw body.** Signatures cover the exact bytes. A framework that parses JSON
  for you (Express `express.json()`, Fastify's default parser, a Next middleware that reads the
  body) breaks every signed delivery with no useful error.

  **Exempt `/api/phosra/*` from your app's global auth.** The protocol routes carry their own
  authentication — RFC-9421 request signatures on delivery, OAuth/PAR on the connect leg. A global
  bearer hook 401s them before the SDK ever sees the request; a redirect-to-login middleware turns
  `/api/phosra/status` into a `307` and makes the whole platform look unmounted. Castle+ marks the
  scope public with an `onRoute` hook; Notflix's Next matcher excludes `api` wholesale.
</Warning>

### Next.js middleware matchers

If you run a Next `middleware.ts`, its `config.matcher` decides whether your auth/proxy layer
runs on a route. Get it wrong in either direction and you get a bad failure:

* Matcher **covers** `/api/phosra/*` with a redirecting auth middleware → protocol routes are
  redirected to your login page and never reach the SDK.
* Matcher **excludes** the routes a session-dependent connect leg needs → the middleware never
  refreshes the session and the ceremony fails mid-flight with a 500 and no error code.

The SDK ships a boot-time assertion for the second class:

```ts theme={null}
// middleware.ts
import { assertGatekeeperConfig, gatekeeperConnectRoutes } from "@phosra/gatekeeper"

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)", "/api/phosra/:path*"],
}

assertGatekeeperConfig({ matcher: config.matcher })  // throws GatekeeperConfigError at boot
```

<Note>
  **Know what it checks.** By default `assertGatekeeperConfig` asserts coverage of the
  `createConnectReceiver` connect family — `/api/phosra/connect` and `/api/phosra/connect/init` —
  the exact gap that regressed one integrator three times. `createPlatform` does not serve those
  paths. Pass the routes you actually care about (`routes: ["/api/phosra/authorize"]`), or
  `gatekeeperConnectRoutes("/api/ocss")` if you mount the receiver elsewhere. The failure it
  prevents is a silent 500 mid-ceremony, which is the worst diagnostic you can be handed.
</Note>

***

## `ready()` returns a report — it does not throw

```ts theme={null}
readonly ready: () => Promise<{ ready: boolean; missing: readonly string[] }>
```

`ready()` verifies environment, trust and directory identity and applies the package-owned
schema. **It reports; it does not throw.** A worker that calls it and ignores the result starts
cleanly against an unmigrated database, logs nothing, and does no work — which looks exactly
like the "the parent finished but nothing happened" failure.

```ts theme={null}
const readiness = await phosra.ready()
if (!readiness.ready) {
  throw new Error(`Phosra schema not ready: ${readiness.missing.join(", ")}`)
}
```

`missing` names the exact package-owned schema objects still absent. It is the same data
`/api/phosra/status` reports as `database.missing`.

***

## The worker is not optional

```ts theme={null}
const worker = phosra.worker.start({ signal, onError: (e) => console.error(e) })
await worker.drain()
```

<Warning>
  **Without a running worker, nothing is ever delivered.** Materialization, read-back and evidence
  dispatch all happen in the worker pass, never in the request path. Mount the catch-all without
  running a worker and the ceremony *appears* to succeed: the parent completes the flow, your route
  acknowledges, and the connection sits pre-delivery indefinitely with no error to search for.
</Warning>

Run **exactly one** mode: the long-lived `phosra.worker.start(...)` loop **or**
`phosra.runWorkerOnce()` from a scheduler. Never both. It is a long-lived process, so a
serverless-only deployment cannot host it.

Two deployment shapes both work, and the reference platforms use one each.

<CodeGroup>
  ```ts Non-Next service (Castle+) theme={null}
  // The platform factory lives in a plain Node package, so the worker just imports it.
  export async function runPhosraWorker(deps: PhosraDeps): Promise<void> {
    const phosra = createCastlePhosra(deps)
    const readiness = await phosra.ready()
    if (!readiness.ready) throw new Error(`schema not ready: ${readiness.missing.join(", ")}`)

    const shutdown = new AbortController()
    process.once("SIGTERM", () => shutdown.abort())
    process.once("SIGINT", () => shutdown.abort())

    const worker = phosra.worker.start({
      signal: shutdown.signal,
      onError: (error) => console.error("phosra worker pass failed", error),
    })
    await worker.drain()
  }
  ```

  ```bash Next.js app (Notflix) theme={null}
  # The singleton is a `server-only` module behind the `@/` alias, so a worker entry
  # that imports it cannot be run by plain `node`. Bundle it, then deploy the bundle
  # as a SECOND always-on service from the same release, with the SAME
  # PHOSRA_CREDENTIAL and DATABASE_URL as the web app.

  # package.json
  "build:phosra-worker": "esbuild phosra-worker.ts --bundle --platform=node --format=esm \
    --target=node24 --outfile=.phosra-worker/worker.mjs \
    --alias:server-only=./lib/phosra/worker-server-only.ts \
    --tsconfig=tsconfig.json --packages=external",
  "start:phosra-worker": "node .phosra-worker/worker.mjs"
  ```
</CodeGroup>

`lib/phosra/worker-server-only.ts` in the Next shape is a one-line stub that exists purely to
neutralise the `server-only` import outside the Next runtime.

`phosra.worker.status()` reports `state`, `healthy`, `passesCompleted`, `consecutiveFailures`
and `authorityExpiresAt` — useful for a worker health endpoint.

***

## The onDiagnostic hook

```ts theme={null}
onDiagnostic: (d) => console.error(JSON.stringify(d))
```

The payload is content-free and correlation-safe — `operation`, `subcode`, `retryable`,
`correlationId`, `causeClass`. It carries three operations:

| `operation`                  | When                                                                           | Visible on `/api/phosra/status`? |
| ---------------------------- | ------------------------------------------------------------------------------ | -------------------------------- |
| `platform_ready`             | Bootstrap stages — credential, manifest, trust, directory, composition, schema | Yes, as `precondition`           |
| `materialization`            | A delivery pass failed mid-flight                                              | **No**                           |
| `target_aggregate_lifecycle` | Shared-target admission or revocation failed                                   | **No**                           |

Wire it. The readiness endpoint never reports the last two, so without this hook a delivery that
fails in the worker leaves no diagnosable trace — and `correlationId` is the value support asks
for when `PLATFORM_COMPOSITION_FAILED` says the fault is ours.

***

## The rules you receive

Your `apply` and `observe` see the verified profile's `categories[]`. Each entry is:

```ts theme={null}
interface NormalizedVerifiedRule {
  readonly ruleRef: string
  readonly category: string
  readonly decision: string
  readonly failMode: "open" | "closed"
  readonly ruleSlug: string
  readonly statuteMappingRef?: string
  readonly enforcement?: "unsupported"
  readonly parameters: Readonly<Record<string, unknown>> | null
}
```

`parameters` is where the threshold lives, and it is untyped — a platform must validate it
before acting. A real `content_rating` rule as Notflix consumes it:

```json theme={null}
{
  "category": "content_rating",
  "decision": "allow",
  "failMode": "closed",
  "parameters": { "family": "numeric_threshold", "scale": "ratings_age", "max_allowed": 13 }
}
```

<Note>
  **A numeric threshold carries `allow`, not `block`** — content at or below `max_allowed` is
  admitted and the threshold blocks everything above it. Treat an unrecognised `family`, `scale`
  or category as *unenforceable* and report it with the reporter's `refused(rule, "unsupported")`
  rather than guessing. An overlay may tighten what the account owner chose; it must never loosen
  it.
</Note>

`apply` is a **command** — its return value is never evidence. `observe` is an independent
read-back, and the SDK only emits an event for a rule whose observation carries a concrete
`sideEffectId` naming the real persisted platform effect.

***

## `recoverSelectedProfileForRemoval`

An optional adapter member, and the one migration seam on the platform surface:

```ts theme={null}
recoverSelectedProfileForRemoval: async ({ priorApplyIdempotencyKey }) => {
  const operation = await overlayStore.getOperation(priorApplyIdempotencyKey)
  if (!operation || operation.kind !== "apply") return null
  return Object.freeze({ selectedProfileId: operation.selectedProfileId })
}
```

It exists to remove an overlay that was admitted **before** retained credential keyrings — i.e.
it is what lets a platform that bound profiles under an older SDK generation still disconnect
them. It may resolve only the exact prior apply operation. Omit it and removal for those
pre-existing bindings silently cannot resolve the prior apply. Notflix implements it in five
lines against its overlay store.

***

## Origins, and local development

Your credential pins `application.origin`. Every protocol route and the directory check are
validated against it, so **a production credential cannot be used from `localhost`** — the
origin comparison fails and the platform never boots.

* **Production credentials are canonical-HTTPS-origin only.** Lowercase host; port 443 not
  spelled out.

* **A sandbox credential may carry `http://localhost[:port]` or `http://127.0.0.1[:port]`.** The
  SDK admits the `http:` scheme only when the environment is `sandbox` *and* the host is
  `localhost` or `127.0.0.1`, and the census only publishes the loopback allowance on a sandbox
  environment manifest:

  ```bash theme={null}
  curl -s https://phosra-api-sandbox-staging.up.railway.app/.well-known/phosra/environment-manifest-v1 \
  | cut -d. -f3 | base64 -d 2>/dev/null | jq '.configuration.redirect_policy'
  # → { "allow_http_loopback": true, "http_loopback_hosts": ["127.0.0.1", "localhost"] }
  ```

  The production manifest returns `allow_http_loopback: false` with an empty host list.

* **Ports are part of the origin.** Changing your dev port means a new credential; an issued one
  cannot be edited.

<Warning>
  **Every app on `http://localhost` shares one cookie jar — cookies ignore the port.** If your
  platform and the provider you are testing against both run on localhost and both use the same
  session cookie name (the WorkOS default `wos-session` is the common collision), signing in at
  one evicts the other's session and the callback returns `UNAUTHENTICATED`. Give each app its own
  cookie name — `WORKOS_COOKIE_NAME=<app>-wos-session` — before you spend an afternoon on it.
</Warning>

***

## Getting a platform credential

<Warning>
  **Platform credentials are operator-issued. There is no host on which you can mint one yourself
  today.** Issuance runs through `POST /api/v1/admin-ops/link/credentials`, which is super-admin
  gated on the production census and **compiled out entirely** on the sandbox censuses (404 under
  `SANDBOX_MODE`). The self-serve lanes — `POST /developers/orgs/{orgId}/link/credentials` and
  `POST /developers/orgs/{orgId}/apps` — are live only on the Phosra-internal *staging* sandbox
  census, and even there a freshly self-registered DID is `provisional`, while issuance requires an
  `accredited` trust-list entry.

  **To get one, email [developers@phosra.com](mailto:developers@phosra.com)** with your DID (or the
  display name you want derived), your canonical application origin, and whether you need sandbox
  or production. See [Create an app](/platform/create-app) for the shape of the funnel that will
  eventually replace this, and [Production accreditation](/integration/production-accreditation)
  for what admission requires.
</Warning>

What you receive is a single `phosra_cred_v3.` envelope. It is a **secret** — it carries private
seeds. Set it as `PHOSRA_CREDENTIAL` and nothing else; the census origin, trust root, manifest
pins, DID, display name and application origin all ride inside it.

Onboarding also seeds your directory row to match. If either ever needs to change, both change
together.

<Note>
  **Rotation.** A re-issue bumps the key generation (`#link-1` → `#link-2`) and publishes the new
  public halves on your trust-list entry. Old key ids stay published until explicitly retired, so a
  rotation does not break an in-flight deploy.
</Note>

***

## Verify a deployment

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

Then the three things readiness does **not** check:

```bash theme={null}
# 1. Your profiles endpoint answers (401 is correct; 404 means you never wrote it).
curl -s -o /dev/null -w 'profiles %{http_code}\n' https://your-app.example.com/api/ocss/profiles

# 2. Your directory row still matches your origin.
curl -s https://prodapi.phosra.com/api/v1/providers/did:ocss:YOUR-DID/connect | jq

# 3. Your worker is running (whatever health surface you gave it).
```

<Card title="The readiness contract — every precondition and its fix" icon="stethoscope" href="/integration/platform-readiness" horizontal>
  What each of the thirteen `precondition` codes means, what causes it, and what you do about it.
</Card>

<Card title="Platform Quickstart" icon="rocket" href="/integration/platform" horizontal>
  The end-to-end walkthrough: singleton, catch-all route, worker, enforcement adapter.
</Card>

<Card title="Credential divergence" icon="triangle-exclamation" href="/integration/credential-divergence" horizontal>
  Why a credential can drift from the census, how to detect it, and why an SDK upgrade cannot fix it.
</Card>
