openapi: 3.1.0
info:
  title: Phosra OCSS Data Plane — enforcement endpoints, profiles & confirmations
  description: >
    The signed OCSS data-plane the @phosra/gatekeeper SDK drives. These are NOT
    plain REST: profile GETs are RFC 9421-signed by the resolver, binding POSTs
    require accredited caller signatures, and profile responses are root-verifiable
    signed documents consumed via @openchildsafety/ocss / @phosra/gatekeeper. This
    spec documents the wire shapes for client generation; it does not change census
    behavior. The control plane (provider org, keys, advisors) is in
    openapi.control-plane.yaml; the parental-control product API is in openapi.yaml.

    Canonical profile shape: `categories[]` (there is no `rules[]` field — it does
    not exist on the wire, in the Go model, or in @phosra/gatekeeper types).

    Wire shapes are authoritative from the Go census:
      - profile.Document / CategoryEntry: internal/ocss/profile/document.go
      - EndpointHandlers.Mint / Rotate: internal/ocsshttp/handler_endpoints.go
      - ProfileReads.ServeHTTP: internal/ocsshttp/handler_profiles.go
      - EnforcementHandlers.Confirm: internal/ocsshttp/handler_enforcement.go
      - receipt.Receipt / EnforcementResultBody: internal/ocss/receipt/types.go
  version: 1.0.0
  license:
    name: Proprietary
    url: https://phosra.com/terms
  contact:
    name: Phosra
servers:
- url: https://phosra-api-sandbox-production.up.railway.app/api/v1
  description: Hosted sandbox — the one canonical, stable, partner-facing testing endpoint
- url: http://localhost:8080/api/v1
  description: Local development
components:
  securitySchemes:
    Rfc9421Signature:
      type: http
      scheme: signature
      description: >
        RFC 9421 message signature (Ed25519). The sender signs the request with its
        Trust-List-published key; the census verifies provenance and accreditation tier
        before processing. Used for POST enforcement-endpoints (binding mint), POST
        enforcement-endpoints/{id}/rotate, and POST enforcement-confirmations.
        Profile GETs (GET /enforcement-profiles/{endpoint_id}) also require the
        resolver's RFC 9421 signature — the §9.3(b) bound-resolver auth.
    SandboxBearer:
      type: http
      scheme: bearer
      description: >
        SANDBOX-ONLY opaque bearer ("sbxtok_…") issued by POST /oauth/token, presented to
        GET /oauth/profiles. Not a production credential — the sandbox reference OAuth
        surface is stateless and gated on PHOSRA_ENV==sandbox.
  schemas:
    SignedDocument:
      type: object
      description: >
        Router-signed wrapper. `document` is a JSON STRING on the wire (not an
        object) whose UTF-8 contents are the canonical JCS bytes the router key
        signed. Verify with the resolved signing key over exactly those bytes —
        never by re-stringifying an object. Matches the Signed.MarshalJSON contract
        in internal/ocss/profile/document.go.
      required: [document, key_id, alg, sig]
      properties:
        document:
          type: string
          description: >
            Canonical JSON string of the inner EnforcementProfile document.
            The signature covers exactly these UTF-8 bytes. Parse as JSON to obtain
            the EnforcementProfile; do not re-marshal before verifying.
        key_id:
          type: string
          description: D-15 key id in "did:ocss:<slug>#<kid>" form.
          example: "did:ocss:phosra-router#router-sandbox-2026-06"
        alg:
          type: string
          enum: [ed25519]
          description: 'One of: ed25519.'
        sig:
          type: string
          description: >
            base64url-raw (-_ alphabet, no padding) Ed25519 detached signature
            over the UTF-8 bytes of `document`. Matches
            base64.RawURLEncoding in internal/ocss/profile/document.go Sign().
      additionalProperties: true
    EnforcementProfile:
      type: object
      description: >
        Inner §8.3.6 profile document carried as the SignedDocument.document string.
        Canonical shape: categories[] (profile.rules[] does not exist).
        Source: profile.Document in internal/ocss/profile/document.go.
      required: [document_type, ocss_version, window, categories, rotation_epoch, token_binding]
      properties:
        document_type:
          type: string
          enum: [enforcement_profile]
          description: Domain-separation discriminator (§11.4). Always "enforcement_profile".
        ocss_version:
          type: string
          description: Negotiated OCSS spec version echoed from the caller's Accept-OCSS header.
          example: "ocss-v4-draft-4"
        profile_ref:
          type: string
          description: >
            Content-addressed ref: "sha256:" + hex SHA-256 of the document canon bytes
            with profile_ref="" (the ref cannot cover itself). Computed by profile.Ref().
          example: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
        rotation_epoch:
          type: integer
          format: int64
          description: Monotonically increasing epoch index; window = [epoch*window_sec, (epoch+1)*window_sec).
        token_binding:
          type: string
          description: >
            §6.3 SHA-256(family_hash ":" rotation_epoch) binding the profile to
            the endpoint's family during this window (deviation 14).
        window:
          $ref: '#/components/schemas/Window'
        categories:
          type: array
          description: >
            Per-rule enforcement decisions. The CANONICAL profile field.
            profile.rules[] does not exist — any code using it returns undefined.
          items:
            $ref: '#/components/schemas/CategoryEntry'
        residuals:
          type: array
          description: >
            §9.3 cl.c named residuals (deviation 35). Empty for DoH-only resolvers.
            The vocabulary is CLOSED: only "dot_cleartext_sni" is defined.
          items:
            $ref: '#/components/schemas/Residual'
      additionalProperties: true
    Window:
      type: object
      description: §8.3.6 profile validity window. RFC3339 UTC strings.
      required: [not_before, not_after]
      properties:
        not_before:
          type: string
          format: date-time
          description: RFC 3339 timestamp.
        not_after:
          type: string
          format: date-time
          description: RFC 3339 timestamp.
      additionalProperties: true
    CategoryEntry:
      type: object
      description: >
        One §8.3.6 cl.2 categories[] row. Source: profile.CategoryEntry in
        internal/ocss/profile/document.go.
      required: [category, decision, fail_mode, rule_slug]
      properties:
        category:
          type: string
          description: OCSS rule category slug (one of the 123 in registry/ocss-rules.json).
          example: infinite_scroll_block
        decision:
          type: string
          enum: [allow, warn, block]
          description: 'One of: allow, warn, block.'
        fail_mode:
          type: string
          enum: [open, closed]
          description: >
            "closed" only for categories on the binding's statutory_fail_closed list
            (§9.1 floor 3 / §9.2); "open" for everything else.
        rule_slug:
          type: string
          description: Registry rule slug for traceability.
        statute_mapping_ref:
          type: string
          description: >
            Set on every Block row: "ocss-rules/<RegistryVersion>#<slug>".
            Censorship-traceability (§8.3.6 cl.2, §9.3 cl.3).
        rule_ref:
          type: string
          description: >
            Per-child opaque reference (familyhash.RuleRef). Only present when the
            profile was compiled for a specific child. The §8.3.8 confirmation submits
            this same value so (rule_ref, app_did) is unambiguous per-child.
        params:
          description: >
            json.RawMessage advisory hint carrier. Advisory only — never enforcement.
            The gatekeeper evaluates decision, never params.
            Example family "engagement_pacing": {max_items_before_pause, page_size, autoplay_default_off}.
          type: object
          additionalProperties: true
      additionalProperties: true
    Residual:
      type: object
      description: >
        §9.3 cl.c named residual (closed vocabulary). Source: profile.Residual in
        internal/ocss/profile/residual.go. Only "dot_cleartext_sni" is defined.
      required: [kind, value, rotation_epoch]
      properties:
        kind:
          type: string
          enum: [dot_cleartext_sni]
          description: 'One of: dot_cleartext_sni.'
        value:
          type: string
          description: The SNI hostname / pattern the resolver exposes.
        rotation_epoch:
          type: integer
          format: int64
          description: familyhash.Epoch index the label rotates on (D-14).
        statute_ref:
          type: string
          description: Identifier of the associated statute.
      additionalProperties: true
    EndpointMintRequest:
      type: object
      description: >
        Binding-leg mint request. Source: endpointMintBody in
        internal/ocsshttp/handler_endpoints.go. Required: audience_did, child_ref,
        window_seconds >= 1.
      required: [audience_did, child_ref, window_seconds]
      properties:
        audience_did:
          type: string
          description: DID of the resolving platform (e.g. "did:ocss:snaptr").
          example: "did:ocss:snaptr"
        child_ref:
          type: string
          description: >
            "child:<uuid>" — the child the resolver is binding to.
          example: "child:11111111-1111-4111-8111-111111111111"
        window_seconds:
          type: integer
          format: int64
          minimum: 1
          description: >
            Profile validity window in seconds. The census mints a
            window of this duration and increments rotation_epoch accordingly.
        statutory_fail_closed:
          type: array
          items:
            type: string
          description: >
            Optional list of category slugs the deployment declares fail-closed
            (§9.1 floor 3 / §9.2). fail_mode="closed" appears on those CategoryEntry rows.
      additionalProperties: true
    EndpointMintResponse:
      type: object
      description: >
        Binding mint result. Source: EndpointHandlers.Mint response in
        internal/ocsshttp/handler_endpoints.go. All three fields are returned
        exactly once; endpoint_id_label and connect_secret are never logged.
      required: [endpoint_id_label, binding_id, connect_secret]
      properties:
        endpoint_id_label:
          type: string
          description: >
            High-entropy §9.3(b) bound-resolver label. Presenting it as the
            {endpoint_id} path segment in GET /enforcement-profiles/{endpoint_id}
            IS the authentication credential — treat as a secret, never log.
        binding_id:
          type: string
          format: uuid
          description: UUID of the persisted binding row (for rotation).
        connect_secret:
          type: string
          description: >
            HMAC-SHA256 secret for verifying X-Phosra-Signature on inbound
            connect-leg deliveries (feed into gk.config({ connectSecret })).
            Returned ONCE; store it immediately.
      additionalProperties: true
    EndpointRotateResponse:
      type: object
      description: >
        Binding rotation result. Source: EndpointHandlers.Rotate response in
        internal/ocsshttp/handler_endpoints.go. Returns a fresh label only.
      required: [endpoint_id_label]
      properties:
        endpoint_id_label:
          type: string
          description: >
            Rotated high-entropy §9.3(b) label. Replaces the prior label immediately;
            the prior label returns 404 after rotation. Never log.
      additionalProperties: true
    EnforcementResultBody:
      type: object
      description: >
        §8.3.8 enforcement-confirmation inner body. Identity-free by construction:
        exactly these five fields; no child ref, device id, or user id.
        Source: receipt.EnforcementResultBody in internal/ocss/receipt/types.go.
      required: [envelope_ref, rule_ref, enforcement_state, applied_at, method_class]
      properties:
        envelope_ref:
          type: string
          description: Idempotency key of the carried envelope.
        rule_ref:
          type: string
          description: >
            Per-child opaque rule reference from the enforcement profile CategoryEntry.
            Links this confirmation to the specific rule_ref the app enforced.
        enforcement_state:
          type: string
          enum: [applied, degraded, refused]
          description: 'One of: applied, degraded, refused.'
        applied_at:
          type: string
          format: date-time
          description: RFC 3339 timestamp.
        method_class:
          type: string
          description: >
            Mechanism-of-enforcement attestation (e.g. dns_block, platform_gate,
            content_removal). MUST be non-empty when enforcement_state = applied.
      additionalProperties: true
    EnforcementReceipt:
      type: object
      description: >
        Full §8.3.8 signed receipt (the app's first-person apply attestation). The
        confirmation POST body IS this outer receipt — not the inner body alone.
        Sig covers canon.Marshal({body, spec, type}); id and key_id ride outside
        the signed bytes (D-9 exposure rule). Source: receipt.Receipt in
        internal/ocss/receipt/types.go with body = EnforcementResultBody.
      required: [id, type, spec, body, key_id, sig]
      properties:
        id:
          type: string
          description: "rcpt_<ULID> — rides outside signed bytes (D-9)."
          example: "rcpt_01JTEST00000000000000000"
        type:
          type: string
          enum: [enforcement_result]
          description: Always "enforcement_result" for §8.3.8 confirmations.
        spec:
          type: string
          description: OCSS spec version — §11.4 domain separation.
          example: "OCSS-v1.0-pre"
        body:
          description: >
            JSON-encoded EnforcementResultBody. Kept as raw bytes: re-marshaling
            would alter the canonical signed bytes and break verification.
          oneOf:
          - $ref: '#/components/schemas/EnforcementResultBody'
        key_id:
          type: string
          description: >
            DID key id of the submitting enforcing app (rides outside signed bytes).
            Verified indirectly: the signature only verifies if the resolved key matches.
          example: "did:ocss:snaptr#snaptr-2026-06"
        sig:
          type: string
          description: '"ed25519:" + base64url-raw signature over canon.Marshal({body,spec,type}).'
      additionalProperties: true
    SandboxTestConnectRequest:
      type: object
      description: >
        SANDBOX-ONLY (POST /api/v1/sandbox/test-connect). Runs the PROVIDER side of the
        EXT-04 §3.2 connect ceremony against the caller's own platform DID + webhook so a cold
        self-registered platform dev receives a real signed binding with no live counterparty.
        RFC 9421 signed as the caller. Source: sandboxTestConnectBody in
        internal/ocsshttp/handler_sandbox_test_connect.go.
      required: [platform_did, webhook_url, connect_secret]
      properties:
        platform_did:
          type: string
          description: The consuming platform's Trust-List DID (the DID you self-registered and sign with).
          example: "did:ocss:my-gatekeeper"
        webhook_url:
          type: string
          description: >
            Your POST /api/ocss/connect receiver BASE (the well-known path is appended).
            Absolute http/https, no query/fragment/credentials. Plain http admitted (sandbox).
          example: "https://my-gatekeeper.example.com"
        connect_secret:
          type: string
          description: >
            The HMAC secret you fed into gk.config({ connectSecret }); the §3.6 callback is
            signed with it so your receiver verifies X-Phosra-Signature. Supplied because the
            census stores only its digest.
        child_ref:
          type: string
          description: 'Optional seeded test child ("child:<uuid>"); defaults to the seeded Mia child.'
          example: "child:a11ce0fa-0000-4000-8000-0000000000a1"
        window_seconds:
          type: integer
          format: int64
          description: Optional §6.3 rotation window in seconds; defaults to 3600.
      additionalProperties: true
    SandboxTestConnectResponse:
      type: object
      description: >
        Result of the sandbox provider ceremony. The binding is minted and profile_url is
        observable even when the webhook delivery leg fails (delivered=false + note).
        Source: sandboxTestConnectResp in handler_sandbox_test_connect.go.
      required: [binding_id, resolver_did, endpoint_id_label, profile_url, connect_receiver, delivered, http_status, state]
      properties:
        binding_id:
          type: string
          format: uuid
          description: The binding UUID returned once by the mint ceremony; pass it to the rotate endpoint.
        resolver_did:
          type: string
          description: Echo of platform_did — the DID the binding resolves to (sign the profile poll with it).
        endpoint_id_label:
          type: string
          description: >
            The connect-ceremony binding label (unprefixed 43-char base64url). Poll it at
            profile_url with your platform key. Never log.
        profile_url:
          type: string
          example: "/api/v1/enforcement-profiles/…"
          description: Path to poll the router-signed enforcement profile for this binding.
        connect_receiver:
          type: string
          description: The full receiver URL the signed §3.6 callback was POSTed to (webhook_url + /api/ocss/connect).
        delivered:
          type: boolean
          description: >
            True when your receiver returned 2xx. False (with note) when the webhook leg failed
            — the binding is minted regardless.
        http_status:
          type: integer
          description: The HTTP status your receiver returned (0 if unreachable).
        state:
          type: string
          description: Opaque sandbox principal ref delivered alongside the label ("sandbox-test-connect:<binding_id>").
        note:
          type: string
          description: Present only when delivered=false — explains the webhook-leg failure.
      additionalProperties: true
    SandboxOAuthToken:
      type: object
      description: >
        SANDBOX-ONLY access token from POST /oauth/token. Source:
        internal/ocsshttp/handler_sandbox_oauth.go Token().
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token:
          type: string
          description: Opaque bearer ("sbxtok_…"). Present it to GET /oauth/profiles.
        token_type:
          type: string
          enum: [Bearer]
          description: 'One of: Bearer.'
        expires_in:
          type: integer
          example: 3600
          description: Access-token lifetime in seconds (sandbox tokens live one hour).
        scope:
          type: string
          example: profiles
          description: Granted scope; the reference provider issues the single `profiles` scope.
      additionalProperties: true
    SandboxOAuthProfile:
      type: object
      description: >
        One seeded sandbox child profile. GET /oauth/profiles returns a BARE JSON ARRAY of
        these (not wrapped in an object). Source: sandboxOAuthProfile in
        internal/ocsshttp/handler_sandbox_oauth.go.
      required: [id, displayName, subject_ref, kind]
      properties:
        id:
          type: string
          example: mia
          description: Unique identifier for this resource.
        displayName:
          type: string
          example: Mia
          description: Human-readable child display name shown on the consent page.
        subject_ref:
          type: string
          description: Stable child id downstream policy/enforcement legs key on.
          example: "a11ce0fa-0000-4000-8000-0000000000a1"
        kind:
          type: string
          example: child
          description: Profile kind; always `child` for the seeded sandbox family.
      additionalProperties: true
    Error:
      type: object
      description: Standard error envelope returned for every 4xx and 5xx response.
      properties:
        error:
          type: string
          description: Short, stable, machine-readable error class — matches the HTTP status text (e.g. `Not Found`).
        message:
          type: string
          description: Human-readable explanation of what went wrong and, where possible, how to fix it.
        code:
          type: integer
          description: Numeric HTTP status code, duplicated in the body for convenience.
        class:
          type: string
          enum: [signature_invalid, nonconformant, replay, standing_failure, scope_failure, malformed, not_found]
          description: >
            Stable machine-readable error subclass for programmatic branching. Present
            on ALL seven published census (data-plane) error classes — 400⇒`malformed`,
            401⇒`signature_invalid`, 403⇒`standing_failure`|`scope_failure`,
            404⇒`not_found`, 409⇒`replay`, 422⇒`nonconformant`. ABSENT on the plain
            house 500, on control-plane/management errors, and on the compact
            sandbox-lane `{error:"<code>"}` bodies (OAuth + sandbox consent).
        failed_step:
          type: string
          description: >
            Present only on a §8.3.1 standing failure (`class:standing_failure`) — names
            the §6.2 step that failed, e.g. `authority_binding`.
      example:
        error: Not Found
        message: unknown enforcement endpoint
        code: 404
        class: not_found
      additionalProperties: true
  responses:
    BadRequest:
      description: >
        The request was malformed — a required field is missing, a value failed
        validation, or an identifier is not a well-formed UUID. Census (data-plane)
        400s carry the `malformed` error class.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          # Live wire body captured from the sandbox census (non-uuid path id).
          example:
            error: Bad Request
            message: id must be a uuid
            code: 400
            class: malformed
    Unauthorized:
      description: >
        RFC 9421 message-signature authentication failed. Every data-plane call must
        carry exactly one `Signature-Input` and one `Signature` header computed over
        the request with your Trust-List-published Ed25519 key. `class` is
        `signature_invalid`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          # Live wire body captured from the sandbox census (unsigned request).
          example:
            error: Unauthorized
            message: exactly one Signature-Input and one Signature header are required
            code: 401
            class: signature_invalid
    RateLimited:
      description: Too many requests. Back off and retry after the interval given in the `Retry-After` response header.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Too Many Requests
            message: rate limit exceeded
            code: 429
    ServerError:
      description: An unexpected error occurred inside Phosra. The request is safe to retry with exponential backoff; contact support if it persists.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Internal Server Error
            message: internal error
            code: 500
    BadGateway:
      description: A downstream provider returned an invalid response while Phosra was fulfilling the request. Retry with backoff.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Bad Gateway
            message: downstream provider error
            code: 502
    ServiceUnavailable:
      description: A downstream provider or dependency is temporarily unavailable. Retry with backoff.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Service Unavailable
            message: downstream provider unavailable
            code: 503
paths:
  /enforcement-endpoints:
    post:
      operationId: bindAChildEnforcementEndpoint
      summary: Bind a child enforcement endpoint
      x-codeSamples:
      - lang: Shell
        label: Signed curl (wire shape)
        source: |
          # Signature-Input/Signature/Content-Digest are per-request — compute with the SDK
          # (see the Node tab); a static signature cannot be reused. Body shown for reference.
          curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints \
            -H "Content-Type: application/json" \
            -H "OCSS-Spec-Version: OCSS-v1.0-pre" \
            -H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
            -H 'Signature: ocss=:<base64-ed25519-sig>:' \
            -H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
            -d '{"audience_did":"did:ocss:loopline","child_ref":"child:5ba0d00c-0000-4000-8000-0000000000c1","window_seconds":3600}'
      - lang: JavaScript
        label: Node (@openchildsafety/ocss)
        source: |
          import { signRequest } from "@openchildsafety/ocss"
          const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
          const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
          const keyID = "did:ocss:loopline#2026-06"
          const body = { audience_did: "did:ocss:loopline", child_ref: "child:5ba0d00c-0000-4000-8000-0000000000c1", window_seconds: 3600 }
          const t = BASE + "/enforcement-endpoints", b = JSON.stringify(body)
          const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode(b), keyID, seed, created: Math.floor(Date.now() / 1000) })
          h["Content-Type"] = "application/json"
          const res = await fetch(t, { method: "POST", headers: h, body: b })
          console.log(res.status, await res.json())
      description: >
        Mints a §9.3(b) bound-resolver label and returns the label, binding UUID,
        and connect_secret (all three returned exactly once). Requires an active
        consent_attestation for (audience_did, child_ref) as a §4.2.2 precondition.
        Caller must be an accredited resolver with a valid RFC 9421 signature.
      security:
      - Rfc9421Signature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EndpointMintRequest'
      responses:
        '201':
          description: Endpoint bound. Store endpoint_id_label and connect_secret immediately.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndpointMintResponse'
              # Live wire body captured from the production sandbox census — a real
              # loopline-signed POST /enforcement-endpoints for the seeded sandbox
              # test child (child:5ba0d00c-…c1). Rotate/store these three at once.
              example:
                endpoint_id_label: U49ctRQSAz5TEpBSuHZxWhVaW28J7qfEvI0grdOxaRE
                binding_id: e376e187-cecb-42ed-9d19-aa05de1043cd
                connect_secret: PyBm20VF2xm78D-HOgi0Jg3ldKSguAXy_H5DRIpNhnQ
        '400':
          description: >
            Malformed request — a required field is missing or an identifier is
            not well-formed. `class` is always `malformed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the sandbox census (missing child_ref).
              example:
                error: Bad Request
                message: audience_did, child_ref, and window_seconds (>=1) are required
                code: 400
                class: malformed
        '403':
          description: >
            No active consent_attestation backs this (audience_did, child_ref)
            binding (§4.2.2 precondition). `class` is `standing_failure` and
            `failed_step` names the §6.2 step that failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the sandbox census (unknown/unconsented child).
              example:
                error: Forbidden
                message: no active consent_attestation backs this binding (§4.2.2 precondition)
                code: 403
                class: standing_failure
                failed_step: authority_binding
        '404':
          description: >
            The child_ref is a well-formed `child:<uuid>` but no such child
            record exists. `class` is `not_found`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (internal/ocsshttp/handler_endpoints.go — ClassNotFound "unknown child").
              example:
                error: Not Found
                message: unknown child
                code: 404
                class: not_found
        '503':
          description: >
            The census operation is not yet available on this deployment
            (feature-gated surface). Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (httputil.Error → lowercase "census operation not yet available").
              example:
                error: Service Unavailable
                message: census operation not yet available
                code: 503
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '500':
          $ref: '#/components/responses/ServerError'
  /enforcement-endpoints/{id}/rotate:
    post:
      operationId: rotateABoundEndpointSLabel
      summary: Rotate a bound endpoint's label
      x-codeSamples:
      - lang: Shell
        label: Signed curl (wire shape)
        source: |
          # Signed headers are per-request — compute with the SDK (Node tab). Body is {}.
          curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints/c8ff82dd-4aa8-4a52-a235-ae13717eb4c8/rotate \
            -H "Content-Type: application/json" \
            -H "OCSS-Spec-Version: OCSS-v1.0-pre" \
            -H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
            -H 'Signature: ocss=:<base64-ed25519-sig>:' \
            -H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
            -d '{}'
      - lang: JavaScript
        label: Node (@openchildsafety/ocss)
        source: |
          import { signRequest } from "@openchildsafety/ocss"
          const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
          const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
          const keyID = "did:ocss:loopline#2026-06"
          const bindingId = "c8ff82dd-4aa8-4a52-a235-ae13717eb4c8"
          const t = `${BASE}/enforcement-endpoints/${bindingId}/rotate`
          const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode("{}"), keyID, seed, created: Math.floor(Date.now() / 1000) })
          h["Content-Type"] = "application/json"
          const res = await fetch(t, { method: "POST", headers: h, body: "{}" })
          console.log(res.status, await res.json())
      description: >
        Issues a fresh §9.3(b) label for the binding identified by {id} (UUID from
        the mint response). The prior label is invalidated immediately. The new label
        is returned once; it is never logged. Returns only endpoint_id_label
        (connect_secret is not rotated; it stays fixed per binding).
      security:
      - Rfc9421Signature: []
      parameters:
      - name: id
        in: path
        required: true
        description: Binding UUID from the mint response (binding_id).
        schema:
          type: string
          format: uuid
      responses:
        '200':
          description: Rotated. New label replaces the prior one immediately.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EndpointRotateResponse'
              # Live wire body captured from the sandbox census rotate.
              example:
                endpoint_id_label: a-bmtAOYzb-Yhhnw11lZtOLFPwD_ms0fnRNQnNgRe-Q
        '400':
          description: The path `id` is not a well-formed UUID. `class` is `malformed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the sandbox census (non-uuid id).
              example:
                error: Bad Request
                message: id must be a uuid
                code: 400
                class: malformed
        '404':
          description: No binding exists for this UUID. `class` is `not_found`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (handler_endpoints.go rotate — ClassNotFound).
              example:
                error: Not Found
                message: unknown enforcement endpoint
                code: 404
                class: not_found
        '503':
          description: >
            The census operation is not yet available on this deployment
            (feature-gated surface). Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (httputil.Error → lowercase "census operation not yet available").
              example:
                error: Service Unavailable
                message: census operation not yet available
                code: 503
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '500':
          $ref: '#/components/responses/ServerError'
  /enforcement-profiles/{endpoint_id}:
    get:
      operationId: fetchTheRouterSignedEnforcementProfileForABoundEndpoint
      summary: Fetch the router-signed enforcement profile for a bound endpoint
      x-codeSamples:
      - lang: Shell
        label: Signed curl (wire shape)
        source: |
          # Signed GET — headers are per-request (compute with the SDK, Node tab). No body.
          curl https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-profiles/iGrFqzp43O0S9YTNN2oAT6zMzcugEX_EwZbraWrE1AA \
            -H "OCSS-Spec-Version: OCSS-v1.0-pre" \
            -H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
            -H 'Signature: ocss=:<base64-ed25519-sig>:'
      - lang: JavaScript
        label: Node (@openchildsafety/ocss)
        source: |
          import { signRequest } from "@openchildsafety/ocss"
          const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
          const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
          const keyID = "did:ocss:loopline#2026-06"
          const label = "iGrFqzp43O0S9YTNN2oAT6zMzcugEX_EwZbraWrE1AA"
          const t = `${BASE}/enforcement-profiles/${label}`
          const h = signRequest({ method: "GET", targetURI: t, keyID, seed, created: Math.floor(Date.now() / 1000) })
          const res = await fetch(t, { method: "GET", headers: h })
          const signed = await res.json()
          const profile = JSON.parse(signed.document) // parse AFTER verifying signed.sig
          console.log(res.status, profile.categories)
      description: >
        §9.3(b) bound-resolver authentication: the caller signs the request with its
        Trust-List-published key (RFC 9421); the {endpoint_id} path segment is the
        high-entropy bound-resolver label from the mint response. A wrong or unknown
        label returns 404 (indistinguishable from a wrong-resolver dereference —
        fail-closed, no existence leak, §9.3(a)). The response is a SignedDocument
        whose `document` string parses to an EnforcementProfile; verify the signature
        to the router key then to the root before trusting the profile.

        Serving discipline (§8.3.6 cl.3): signatures are minted at compile time and
        cached per binding. Supports ETag / If-None-Match for 304 Not Modified.
      security:
      - Rfc9421Signature: []
      parameters:
      - name: endpoint_id
        in: path
        required: true
        description: >
          The high-entropy §9.3(b) bound-resolver label returned by the mint
          endpoint. Treat as a secret credential — never log.
        schema:
          type: string
      responses:
        '200':
          description: >
            Router-signed enforcement profile. Verify to root before trusting.
            categories[] is the canonical profile field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignedDocument'
              # Live wire body captured from the sandbox census (freshly-minted binding, no
              # rules yet ⇒ categories:[]). `document` is a JSON STRING you parse AFTER
              # verifying `sig` against the router key, then the router key to the root.
              # categories[] fills in as rules are written to the child's policy.
              example:
                document: '{"categories":[],"document_type":"enforcement_profile","ocss_version":"OCSS-v1.0-pre","profile_ref":"sha256:898314e28cbd0e7bede49d8c48b6e4309ef019ff3189d03cae4215fe7b8d2b39","rotation_epoch":495369,"token_binding":"9f4187aa0931973599d0178b0b6a219604206f4a48dcc6166edfb66468a21220","window":{"not_after":"2026-07-06T10:00:00Z","not_before":"2026-07-06T09:00:00Z"}}'
                key_id: "did:ocss:phosra-router#router-sandbox-2026-06"
                alg: ed25519
                sig: stpRC97FNDiMd5JlkwTn_VxGJ3OngJ9opWiABfEZu4cof6ocL2aqIrjTDGH1vVUFmdI2m2TAhnMsT_Snz416Aw
        '304':
          description: Not Modified (ETag / If-None-Match cache hit). Empty body.
        '404':
          description: >
            Unknown label OR a wrong-resolver dereference — the two are answered
            IDENTICALLY (fail-closed, no existence leak per §9.3(a)). `class` is
            `not_found`. (An unaccredited/unsigned caller is rejected earlier by the
            RFC 9421 layer — see 401.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the sandbox census.
              example:
                error: Not Found
                message: unknown enforcement endpoint
                code: 404
                class: not_found
        '503':
          description: >
            The router signing identity is not provisioned on this deployment, so
            no signed profile can be served (honest vacancy — never a fabricated
            signature). Gatekeeper fail-closes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (handler_profiles.go — httputil.Error, no class).
              example:
                error: Service Unavailable
                message: enforcement-profile signing identity not provisioned
                code: 503
        '401':
          $ref: '#/components/responses/Unauthorized'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '500':
          $ref: '#/components/responses/ServerError'
  /enforcement-confirmations:
    post:
      operationId: submitASigned838EnforcementResultReceipt
      summary: Submit a signed §8.3.8 enforcement-result receipt
      x-codeSamples:
      - lang: JavaScript
        label: Node (@phosra/gatekeeper — recommended)
        source: |
          import { createGatekeeper } from "@phosra/gatekeeper"
          const gk = createGatekeeper({
            platformDid: "did:ocss:loopline",
            platformKeyId: "did:ocss:loopline#2026-06",
            gatekeeperSigningKey: { seed: new Uint8Array(32) /* real Ed25519 seed */, keyID: "did:ocss:loopline#2026-06" },
            censusBaseUrl: "https://phosra-api-sandbox-production.up.railway.app",
            trustRootXB64Url: process.env.PHOSRA_TRUST_ROOT_X,
            endpointId: "iGrFqzp43O0S9YTNN2oAT6zMzcugEX_EwZbraWrE1AA",
          })
          const verdict = gk.check("infinite_scroll_block")
          // ...apply the decision at the edge, then attest the outcome:
          await verdict.confirm("applied") // signs the §8.3.8 receipt + POSTs it
      - lang: JavaScript
        label: Node (raw — sign the receipt yourself)
        source: |
          import { signRequest, signReceipt, ed25519Sign } from "@openchildsafety/ocss"
          import { randomBytes } from "node:crypto"
          const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
          const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
          const keyID = "did:ocss:loopline#2026-06"
          const key = { keyId: keyID, sign: (m) => ed25519Sign(seed, m) }
          const body = { envelope_ref: "env_abc123", rule_ref: "rr_mia_infinite_scroll", enforcement_state: "applied", applied_at: new Date().toISOString(), method_class: "platform_gate" }
          const receipt = signReceipt("enforcement_result", body, key, new Date(), new Uint8Array(randomBytes(10)))
          const t = BASE + "/enforcement-confirmations", b = JSON.stringify(receipt)
          const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode(b), keyID, seed, created: Math.floor(Date.now() / 1000) })
          h["Content-Type"] = "application/json"
          const res = await fetch(t, { method: "POST", headers: h, body: b })
          console.log(res.status) // 201 recorded, 200 idempotent re-submit
      description: >
        The enforcing app (gatekeeper) POSTs its OWN signed enforcement_result receipt
        attesting that it applied a rule. The census VERIFIES the app's RFC 9421
        signature (caller must equal the receipt's signer — first-person binding,
        §8.3.8 cl.1) and RECORDS the receipt verbatim. It does NOT re-sign or append
        to the Receipt rail.

        Three invariants (spec-pinned, non-optional):
        - §6.2 NOT-A-WRITE: this is the app's attestation, not a policy write.
        - §10.6 NEVER-GATES-THE-RULE: a missing/rejected confirmation never blocks
          or revives the §8.3.1 rule's enforcement.
        - HONEST CEILING (§8.3.8): "reported applied, never proven applied."

        The body is the full outer receipt (id, type, spec, body, key_id, sig),
        not just the inner EnforcementResultBody.
      security:
      - Rfc9421Signature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnforcementReceipt'
      responses:
        '201':
          description: >
            Confirmation recorded. The census **echoes your signed receipt back
            byte-for-byte** (§8.3.8 cl.3 — it records and returns the app's own
            artifact, it does NOT re-sign or wrap it). `Content-Type` is
            `application/json`; there is no `OCSS-Replay` header on the first write.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnforcementReceipt'
              # Live wire body captured from the staging sandbox census — a real loopline-
              # signed POST /enforcement-confirmations (did:ocss:loopline#2026-06, on the
              # sandbox Trust List). The 201 body is the request receipt, echoed verbatim.
              example:
                id: rcpt_01KWVD0ABX041061050R3GG28A
                type: enforcement_result
                spec: OCSS-v1.0-pre
                body:
                  applied_at: '2026-07-06T09:43:59.612Z'
                  enforcement_state: applied
                  envelope_ref: env_9f2c1a7e
                  method_class: platform_gate
                  rule_ref: rr_loopline_infinite_scroll_block
                key_id: did:ocss:loopline#2026-06
                sig: ed25519:mGm30Y7TKrhTnTXH8fq2H8BaaVwFGOJCmJpI_F1hqefhCf9u9I5TRZawk2t12KSiq0pyuCbgzxjeQF11djnUAA
        '200':
          description: >
            Idempotent re-submit of the **same** receipt (same `(rule_ref, app_did)`,
            byte-identical content). The body is the original receipt echoed verbatim
            — identical to the `201` body — and the response carries an
            `OCSS-Replay: original` header so a client can tell the write already landed.
          headers:
            OCSS-Replay:
              description: Present only on an idempotent replay; always the literal `original`.
              schema:
                type: string
                enum: [original]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnforcementReceipt'
              # Live wire body captured from the staging sandbox census — the byte-identical
              # re-POST of the 201 above returned 200 + OCSS-Replay: original.
              example:
                id: rcpt_01KWVD0ABX041061050R3GG28A
                type: enforcement_result
                spec: OCSS-v1.0-pre
                body:
                  applied_at: '2026-07-06T09:43:59.612Z'
                  enforcement_state: applied
                  envelope_ref: env_9f2c1a7e
                  method_class: platform_gate
                  rule_ref: rr_loopline_infinite_scroll_block
                key_id: did:ocss:loopline#2026-06
                sig: ed25519:mGm30Y7TKrhTnTXH8fq2H8BaaVwFGOJCmJpI_F1hqefhCf9u9I5TRZawk2t12KSiq0pyuCbgzxjeQF11djnUAA
        '400':
          description: >
            The body did not strict-parse as an enforcement_result receipt (wrong
            `type`, missing `rule_ref`, etc.). `class` is `malformed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the staging sandbox census — a valid loopline-
              # signed but WRONG-type receipt (type "ack") POSTed here returned this 400.
              example:
                error: Bad Request
                message: payload is not an enforcement_result receipt (§8.3.8)
                code: 400
                class: malformed
        '401':
          description: >
            The receipt is signed by an app DID not resolvable on the Trust List,
            or its `sender_signature` failed verification (§8.3.8 cl.4). `class`
            is `signature_invalid`. (A request with NO RFC 9421 signature at all
            gets the generic Unauthorized body — see `components/responses/Unauthorized`.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (handler_enforcement.go — ClassSignatureInvalid).
              example:
                error: Unauthorized
                message: enforcement_result signed by an app DID not resolvable on the Trust List (§8.3.8 cl.4)
                code: 401
                class: signature_invalid
        '403':
          description: >
            First-person binding violation (§8.3.8 cl.1): the receipt was not signed
            by the calling app. `class` is `scope_failure`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (handler_enforcement.go — ClassScopeFailure).
              example:
                error: Forbidden
                message: 'the enforcement_result must be signed by the calling app; failed binding: writer_binding'
                code: 403
                class: scope_failure
        '409':
          description: >
            A receipt for the same `(rule_ref, app_did)` was already recorded with
            DIFFERENT content (D-13 replay). An IDENTICAL re-submit is idempotent and
            returns 200, not 409. `class` is `replay`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the staging sandbox census — re-POSTing the
              # recorded rule_ref above with DIFFERENT content (enforcement_state
              # "degraded") returned this 409.
              example:
                error: Conflict
                message: (rule_ref, app_did) replayed with a different enforcement_result (D-13)
                code: 409
                class: replay
        '503':
          description: >
            The census operation is not yet available on this deployment
            (feature-gated surface). Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (httputil.Error → lowercase "census operation not yet available").
              example:
                error: Service Unavailable
                message: census operation not yet available
                code: 503
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '500':
          $ref: '#/components/responses/ServerError'
  /sandbox/test-connect:
    post:
      operationId: runTheProviderSideOfTheConnectCeremonyToYourOwnDidSandboxOnly
      summary: Run the provider side of the connect ceremony to your own DID (sandbox only)
      x-codeSamples:
      - lang: Shell
        label: Signed curl (wire shape)
        source: |
          # Signed headers are per-request — compute with the SDK (Node tab).
          curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/test-connect \
            -H "Content-Type: application/json" \
            -H "OCSS-Spec-Version: OCSS-v1.0-pre" \
            -H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
            -H 'Signature: ocss=:<base64-ed25519-sig>:' \
            -H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
            -d '{"platform_did":"did:ocss:loopline","webhook_url":"https://your-gatekeeper.example.com","connect_secret":"cs_your_hmac_secret"}'
      - lang: JavaScript
        label: Node (@openchildsafety/ocss)
        source: |
          import { signRequest } from "@openchildsafety/ocss"
          const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
          const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
          const keyID = "did:ocss:loopline#2026-06"
          const body = { platform_did: "did:ocss:loopline", webhook_url: "https://your-gatekeeper.example.com", connect_secret: "cs_your_hmac_secret" }
          const t = BASE + "/sandbox/test-connect", b = JSON.stringify(body)
          const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode(b), keyID, seed, created: Math.floor(Date.now() / 1000) })
          h["Content-Type"] = "application/json"
          const res = await fetch(t, { method: "POST", headers: h, body: b })
          console.log(res.status, await res.json())
      description: >
        SANDBOX-ONLY reference provider. Mints a §9.3 binding for a seeded test child scoped to
        your platform_did and delivers the signed §3.6 callback to your webhook, so a cold
        self-registered platform dev can watch a real signed enforcement profile land with no
        live counterparty and no OCSS_CONSENT_ATTESTATION_APPS roster edit (it skips §6.2
        standing for the seeded fixture child). RFC 9421 signed as the caller. Gated on the
        Restricted band (PHOSRA_ENV==sandbox) — returns 404 on dev/staging/production.
      security:
      - Rfc9421Signature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SandboxTestConnectRequest'
      responses:
        '200':
          description: >
            Ceremony ran. The binding is minted and profile_url is observable even if the
            webhook leg failed (delivered=false + note).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SandboxTestConnectResponse'
              # Live wire body captured from the production sandbox census — a real
              # loopline-signed POST /sandbox/test-connect with an unreachable webhook_url,
              # so it shows the honest delivered=false leg (the binding still minted).
              example:
                binding_id: 22b577ea-4a53-4505-9eb5-541bf237cb42
                resolver_did: did:ocss:loopline
                endpoint_id_label: iWdlltz5F4sKbZKYgIvGkIJg7sylz2LN37cZUK8wiWY
                profile_url: "/api/v1/enforcement-profiles/iWdlltz5F4sKbZKYgIvGkIJg7sylz2LN37cZUK8wiWY"
                connect_receiver: https://loopline.example.com/api/ocss/connect
                delivered: false
                http_status: 0
                state: "sandbox-test-connect:22b577ea-4a53-4505-9eb5-541bf237cb42"
                note: "callback delivery did not receive a 2xx — check webhook_url reachability and that connect_secret matches your gk.config; the binding is minted and profile_url is observable regardless"
        '400':
          description: >
            A required field is missing (`platform_did`, `connect_secret`) or the
            webhook_url is malformed. `class` is `malformed`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the production sandbox census (empty body).
              example:
                error: Bad Request
                message: platform_did is required
                code: 400
                class: malformed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >
            The Restricted-band sandbox surface is not active on this deployment
            (dev / staging-internal / production), so the route is not mounted —
            the router answers with a plain-text chi 404, NOT the JSON error
            envelope. Live only on the partner sandbox census.
          content:
            text/plain:
              schema:
                type: string
              # Live capture — an un-mounted route yields chi's plain-text 404.
              example: "404 page not found\n"
        '503':
          description: >
            The census operation is not yet available on this deployment
            (feature-gated surface). Retry later.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (httputil.Error → lowercase "census operation not yet available").
              example:
                error: Service Unavailable
                message: census operation not yet available
                code: 503
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
  /sandbox/consent-attestations:
    post:
      operationId: mintATestConsentAttestationForYourOwnDidSandboxOnly
      summary: Mint a test consent attestation for your own DID (sandbox only)
      x-codeSamples:
      - lang: Shell
        label: Signed curl (wire shape)
        source: |
          # Signed headers are per-request — compute with the SDK (Node tab).
          curl -X POST https://phosra-api-sandbox-production.up.railway.app/api/v1/sandbox/consent-attestations \
            -H "Content-Type: application/json" \
            -H "OCSS-Spec-Version: OCSS-v1.0-pre" \
            -H 'Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"' \
            -H 'Signature: ocss=:<base64-ed25519-sig>:' \
            -H 'Content-Digest: sha-256=:<base64-sha256-of-body>:' \
            -d '{"band":"13_15","consent_scope":"collection_parental_authority"}'
      - lang: JavaScript
        label: Node (@openchildsafety/ocss)
        source: |
          import { signRequest } from "@openchildsafety/ocss"
          const BASE = "https://phosra-api-sandbox-production.up.railway.app/api/v1"
          const seed = new Uint8Array(Buffer.from("bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE", "base64url"))
          const keyID = "did:ocss:loopline#2026-06"
          const body = { band: "13_15", consent_scope: "collection_parental_authority" }
          const t = BASE + "/sandbox/consent-attestations", b = JSON.stringify(body)
          const h = signRequest({ method: "POST", targetURI: t, body: new TextEncoder().encode(b), keyID, seed, created: Math.floor(Date.now() / 1000) })
          h["Content-Type"] = "application/json"
          const res = await fetch(t, { method: "POST", headers: h, body: b })
          const consent = await res.json()
          console.log(res.status, consent.target_ref) // pass to POST /enforcement-endpoints
      description: >
        SANDBOX-ONLY self-serve consent. Mints a §8.3.2-shaped TEST consent row with
        app_ref = the RFC 9421 caller DID, so a cold self-registered platform can satisfy
        the consent-first gate on POST /api/v1/enforcement-endpoints with no roster edit
        and no live counterparty. With no child_ref, the census auto-provisions the sandbox
        self-serve test child and targets it; a supplied child_ref must already exist (the
        door never fabricates a caller-named child). Production §8.3.2 semantics unchanged.
        Gated on the Restricted band (PHOSRA_ENV==sandbox) — returns 404 on
        dev/staging/production. Source: internal/ocsshttp/handler_sandbox_consent.go.
      security:
      - Rfc9421Signature: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                child_ref:
                  type: string
                  description: >
                    Optional child:<uuid> target. Empty ⇒ the auto-provisioned sandbox
                    self-serve test child.
                  example: "child:5ba0d00c-0000-4000-8000-0000000000c1"
                band:
                  type: string
                  enum: [under_13, "13_15", "16_17", adult]
                  default: "13_15"
                consent_scope:
                  type: string
                  enum: [collection_parental_authority, third_party_disclosure]
                  default: collection_parental_authority
      responses:
        '201':
          description: >
            Test consent minted. Pass audience_did=<your DID> and child_ref=<target_ref>
            to POST /api/v1/enforcement-endpoints to complete the consent-first mint.
          content:
            application/json:
              schema:
                type: object
                required: [ok, app_ref, target_ref, standing_ref, band, consent_scope, expiry]
                properties:
                  ok: {type: boolean, example: true}
                  app_ref: {type: string, example: "did:ocss:playnest"}
                  target_ref: {type: string, example: "child:5ba0d00c-0000-4000-8000-0000000000c1"}
                  standing_ref: {type: string, example: "consent:attestation:sbx-consent-playnest-5ba0d00c"}
                  idempotency_key: {type: string}
                  band: {type: string, example: "13_15"}
                  consent_scope: {type: string, example: "collection_parental_authority"}
                  expiry: {type: string, format: date-time}
                  note: {type: string}
              # Live wire body captured from the sandbox census (no child_ref ⇒ auto-provisioned
              # per-caller test child). standing_ref == "consent:attestation:" + idempotency_key.
              example:
                ok: true
                app_ref: "did:ocss:dx24c6fcc23"
                target_ref: "child:c22fd864-a783-5354-bfbb-d7d066b17592"
                standing_ref: "consent:attestation:sbx-consent:did:ocss:dx24c6fcc23:child:c22fd864-a783-5354-bfbb-d7d066b17592"
                idempotency_key: "sbx-consent:did:ocss:dx24c6fcc23:child:c22fd864-a783-5354-bfbb-d7d066b17592"
                band: "13_15"
                consent_scope: "collection_parental_authority"
                expiry: '2027-07-06T09:01:54Z'
                note: "sandbox-only test consent; pass audience_did=<this DID> and child_ref=<target_ref> to POST /api/v1/enforcement-endpoints to complete the consent-first mint"
        '400':
          description: >
            The body failed to parse (`invalid_json`) or carried an out-of-vocabulary
            `band` / `consent_scope`. NOTE: the sandbox consent lane emits a COMPACT
            `{error: "<code>"}` body (no `message`/`code`/`class`), unlike the census
            error envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the sandbox census (band: "not-a-band").
              example:
                error: invalid_band
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >
            The Restricted-band sandbox surface is not active on this deployment, so
            the route is not mounted — the router answers with a plain-text chi 404,
            NOT the JSON error envelope. (A supplied-but-malformed child_ref is a 400
            `{"error":"child_ref must be child:<uuid>"}`, not a 404.)
          content:
            text/plain:
              schema:
                type: string
              # Live capture — an un-mounted route yields chi's plain-text 404.
              example: "404 page not found\n"
        '503':
          description: >
            The census store is vacant / not yet provisioned. Compact
            `{error: "<code>"}` sandbox-lane body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Real handler wire body (handler_sandbox_consent.go — compact error).
              example:
                error: census_store_vacant
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'
  /oauth/authorize:
    servers:
    - url: https://phosra-api-sandbox-production.up.railway.app
      description: Sandbox census host ROOT — the /oauth/* surface is NOT under /api/v1.
    get:
      operationId: referenceParentConsentAuthorizeLegSandboxOnly
      summary: Reference parent-consent authorize leg (sandbox only)
      x-codeSamples:
      - lang: Shell
        label: curl (runnable — unsigned leg)
        source: |
          # Render the consent page (200, text/html):
          curl "https://phosra-api-sandbox-production.up.railway.app/oauth/authorize?redirect_uri=https://example.com/cb&state=xyz123"
          # Machine path — approve and capture the 302 Location:
          curl -sD - -o /dev/null "https://phosra-api-sandbox-production.up.railway.app/oauth/authorize?redirect_uri=https://example.com/cb&state=xyz123&decision=approve" | grep -i '^location:'
      - lang: JavaScript
        label: Node (runnable)
        source: |
          const BASE = "https://phosra-api-sandbox-production.up.railway.app"
          const res = await fetch(`${BASE}/oauth/authorize?redirect_uri=https://example.com/cb&state=xyz123&decision=approve`, { redirect: "manual" })
          const code = new URL(res.headers.get("location")).searchParams.get("code")
          console.log(res.status, code) // 302  sbxauth_…
      description: >
        SANDBOX-ONLY reference OAuth provider hosted on the census host root (NOT /api/v1). With
        no `decision`, renders an HTML consent page seeded with the sandbox test family
        (Mia/Leo/Ava). MACHINE-USABLE CONTRACT: re-request with `decision=approve` and the census
        issues an authorization code and 302-redirects to `redirect_uri?code=…&state=…` — so CI
        can automate the approval with no HTML scraping. `decision=deny` 302s with
        `error=access_denied&state=…`. Custom mobile schemes (e.g. propagate://) are allowed as
        redirect_uri. Stateless (opaque `sbxauth_` code). Gated on the Restricted band — 404 on
        dev/staging/production. Source: internal/ocsshttp/handler_sandbox_oauth.go Authorize().
      parameters:
      - name: redirect_uri
        in: query
        required: true
        description: Where the 302 sends the code (https, http, or a custom mobile scheme).
        schema:
          type: string
        example: "propagate://connect"
      - name: state
        in: query
        required: false
        description: Opaque value echoed back verbatim in the redirect (CSRF binding).
        schema:
          type: string
      - name: decision
        in: query
        required: false
        description: >
          Omit to render the consent page. `approve` → 302 with ?code=&state=.
          `deny` → 302 with ?error=access_denied&state=.
        schema:
          type: string
          enum: [approve, deny]
      responses:
        '200':
          description: Consent page (text/html) — shown when `decision` is omitted.
          content:
            text/html:
              # Live wire body captured from the production sandbox census —
              # GET /oauth/authorize with no decision param (the seeded Mia/Leo/Ava family).
              schema:
                type: string
              example: |
                <!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
                <title>Phosra Sandbox — Connect</title>
                <style>body{font:16px/1.5 system-ui,sans-serif;max-width:30rem;margin:3rem auto;padding:0 1.25rem;color:#0f172a}
                h1{font-size:1.25rem}.card{border:1px solid #e2e8f0;border-radius:12px;padding:1.25rem}
                ul{padding-left:1.1rem}.act{display:flex;gap:.75rem;margin-top:1.25rem}
                a.btn{flex:1;text-align:center;text-decoration:none;padding:.7rem 1rem;border-radius:10px;font-weight:600}
                .ap{background:#4f46e5;color:#fff}.dn{background:#f1f5f9;color:#334155}
                .tag{display:inline-block;background:#eef2ff;color:#4338ca;border-radius:999px;padding:.1rem .6rem;font-size:.75rem}</style>
                <div class="card"><span class="tag">Sandbox reference provider</span>
                <h1>Connect your family to this app?</h1>
                <p>Approving shares these sandbox child profiles so the app can receive their safety policy:</p>
                <ul><li>Mia <small>(child)</small></li><li>Leo <small>(child)</small></li><li>Ava <small>(child)</small></li></ul>
                <div class="act"><a class="btn ap" href="/oauth/authorize?decision=approve&amp;redirect_uri=https%3A%2F%2Fexample.com%2Fcb&amp;state=xyz123">Approve</a>
                <a class="btn dn" href="/oauth/authorize?decision=deny&amp;redirect_uri=https%3A%2F%2Fexample.com%2Fcb&amp;state=xyz123">Deny</a></div></div>
        '302':
          description: >
            Approve → Location: {redirect_uri}?code={sbxauth_…}&state={state}.
            Deny → Location: {redirect_uri}?error=access_denied&state={state}.
          headers:
            Location:
              description: redirect_uri with code+state (approve) or error+state (deny).
              schema:
                type: string
        '400':
          description: Missing or malformed redirect_uri. Full census error envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the production sandbox census.
              example:
                error: Bad Request
                message: redirect_uri is required
                code: 400
        '404':
          description: >
            Not a sandbox census (gated). The /oauth/* surface is not mounted on
            dev / staging-internal / production, so the router answers with a
            plain-text chi 404, NOT a JSON envelope.
          content:
            text/plain:
              schema:
                type: string
              # Live capture against the real (non-sandbox) production census.
              example: "404 page not found\n"
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
        '500':
          $ref: '#/components/responses/ServerError'
      security: []
  /oauth/token:
    servers:
    - url: https://phosra-api-sandbox-production.up.railway.app
      description: Sandbox census host ROOT — the /oauth/* surface is NOT under /api/v1.
    post:
      operationId: referenceTokenLegAuthorizationCodeAccessTokenSandboxOnly
      summary: Reference token leg — authorization_code → access_token (sandbox only)
      x-codeSamples:
      - lang: Shell
        label: curl (runnable — unsigned leg)
        source: |
          curl -X POST https://phosra-api-sandbox-production.up.railway.app/oauth/token \
            -H "Content-Type: application/x-www-form-urlencoded" \
            -d "grant_type=authorization_code&code=sbxauth_QBmKueI14KzQj9PM-2M9naWGkh2OU6WL"
      - lang: JavaScript
        label: Node (runnable)
        source: |
          const BASE = "https://phosra-api-sandbox-production.up.railway.app"
          const res = await fetch(`${BASE}/oauth/token`, {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: new URLSearchParams({ grant_type: "authorization_code", code: "sbxauth_QBmKueI14KzQj9PM-2M9naWGkh2OU6WL" }),
          })
          const { access_token } = await res.json()
          console.log(res.status, access_token) // 200  sbxtok_…
      description: >
        SANDBOX-ONLY. Exchanges an authorization code (any value carrying the `sbxauth_` prefix)
        for an opaque bearer access token. Accepts application/x-www-form-urlencoded or JSON;
        PKCE code_verifier is accepted and ignored. Stateless. Gated on the Restricted band.
        Source: internal/ocsshttp/handler_sandbox_oauth.go Token().
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, code]
              properties:
                grant_type:
                  type: string
                  enum: [authorization_code]
                code:
                  type: string
                  example: "sbxauth_…"
          application/json:
            schema:
              type: object
              required: [grant_type, code]
              properties:
                grant_type:
                  type: string
                  enum: [authorization_code]
                code:
                  type: string
                  example: "sbxauth_…"
      responses:
        '200':
          description: Access token issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SandboxOAuthToken'
              # Live wire body captured from the production sandbox census token leg.
              example:
                access_token: sbxtok_8zc0PBmNAjyIs3p8B3Y2oxQW2csHHoy1
                token_type: Bearer
                expires_in: 3600
                scope: profiles
        '400':
          description: >
            `unsupported_grant_type` (grant_type != authorization_code) or
            `invalid_grant` (code lacks the `sbxauth_` prefix). Compact
            `{error: "<code>"}` OAuth-style body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the production sandbox census (grant_type=foo).
              example:
                error: unsupported_grant_type
        '404':
          description: >
            Not a sandbox census (gated). The route is not mounted off the sandbox,
            so the router answers with a plain-text chi 404, not a JSON envelope.
          content:
            text/plain:
              schema:
                type: string
              example: "404 page not found\n"
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
        '500':
          $ref: '#/components/responses/ServerError'
      security: []
  /oauth/profiles:
    servers:
    - url: https://phosra-api-sandbox-production.up.railway.app
      description: Sandbox census host ROOT — the /oauth/* surface is NOT under /api/v1.
    get:
      operationId: referenceProfilesLegBearerScopedChildListSandboxOnly
      summary: Reference profiles leg — bearer-scoped child list (sandbox only)
      x-codeSamples:
      - lang: Shell
        label: curl (runnable — bearer token)
        source: |
          curl https://phosra-api-sandbox-production.up.railway.app/oauth/profiles \
            -H "Authorization: Bearer sbxtok_9xRWtUBLsk1omBYjWWe-20KbCxTO1Noh"
      - lang: JavaScript
        label: Node (runnable)
        source: |
          const BASE = "https://phosra-api-sandbox-production.up.railway.app"
          const res = await fetch(`${BASE}/oauth/profiles`, { headers: { Authorization: "Bearer sbxtok_9xRWtUBLsk1omBYjWWe-20KbCxTO1Noh" } })
          const profiles = await res.json() // bare array
          console.log(res.status, profiles.map((p) => p.displayName)) // 200  [ 'Mia', 'Leo', 'Ava' ]
      description: >
        SANDBOX-ONLY. Returns a BARE JSON ARRAY of the seeded sandbox child profiles
        (Mia/Leo/Ava) for a valid `Authorization: Bearer sbxtok_…` token. Gated on the
        Restricted band. Source: internal/ocsshttp/handler_sandbox_oauth.go Profiles().
      security:
      - SandboxBearer: []
      responses:
        '200':
          description: Bare array of child profiles (NOT wrapped in an object).
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SandboxOAuthProfile'
              # Live wire body captured from the production sandbox census — the real
              # authorize(approve) → token → GET /oauth/profiles chain (bearer sbxtok_…).
              example:
              - id: mia
                displayName: Mia
                subject_ref: "a11ce0fa-0000-4000-8000-0000000000a1"
                kind: child
              - id: leo
                displayName: Leo
                subject_ref: "a11ce0fa-0000-4000-8000-0000000000a2"
                kind: child
              - id: ava
                displayName: Ava
                subject_ref: "a11ce0fa-0000-4000-8000-0000000000a3"
                kind: child
        '401':
          description: >
            Missing, or a non-`sbxtok_` bearer token. Compact `{error: "invalid_token"}`
            OAuth-style body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              # Live wire body captured from the production sandbox census (bad bearer).
              example:
                error: invalid_token
        '404':
          description: >
            Not a sandbox census (gated). The route is not mounted off the sandbox,
            so the router answers with a plain-text chi 404, not a JSON envelope.
          content:
            text/plain:
              schema:
                type: string
              example: "404 page not found\n"
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/BadGateway'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
        '500':
          $ref: '#/components/responses/ServerError'
