openapi: 3.0.3
info:
  title: Sovereign Sign-off Protocol — Client API
  version: "1.0"
  description: |
    Integration API for **upstream client systems** (the "integrated client").

    A client opens a **case** by submitting an artifact plus its already
    **client-signed** upstream lifecycle events (`policy.committed`,
    `ai.inference.completed`, `guardrail.evaluated`). A qualified human then
    reviews and signs it in the approver UI, producing a **Sovereign Record**
    (non-custodial WebAuthn signature + Ed25519 trust countersignature + Rekor
    transparency-log entry + RFC 3161 timestamp). The client can later record a
    client-signed `outcome.executed`.

    Sovereign **verifies** each client event (canonical hash + Ed25519 signature)
    and links it as a precedent.
  contact:
    name: Sovereign demo
    url: https://app.sovereign.xor.ma/

servers:
  - url: https://app.local/api
    description: Local dev stack
  - url: https://app.sovereign.xor.ma/api
    description: Hosted demo

tags:
  - name: Client API
    description: API-key authenticated endpoints for the integrated client system.
  - name: Verification
    description: Public, unauthenticated endpoints to verify a sealed record.

security:
  - ApiKeyAuth: []

paths:
  /client/cases:
    post:
      tags: [Client API]
      summary: Open a case
      description: |
        Submit an artifact, domain context, and the client-signed upstream
        events. Every event is verified (canonical hash + Ed25519 signature);
        on success the case appears in the approver queue.
      operationId: createCase
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCaseRequest' }
            example:
              domain: finance
              filename: wire.txt
              mime_type: text/plain
              topic: Vendor wire
              party: ACME Supplies
              subcategory: Wire transfer
              required_role: ""
              due_at: "2026-06-16T18:00:00Z"
              quorum:
                mode: parallelRoles
                dsl: "Senior Credit Officer*2, Compliance"
                workflow: { type: ratify }
                sla: { approval_duration: PT24H, expire_by: "2026-07-01T00:00:00Z" }
              artifact_b64: UGF5IEFDTUUgMTIsNzUwIFVTRA==
              context:
                transaction_id: TXN-1
                amount_usd: 12750
                account_id: ACC-1
              client_events:
                - claim:
                    ssp_version: "1.0"
                    claim_id: 01KV5DGCCSKAJ1TERSB6J9N0H1
                    issued_at: "2026-06-15T10:33:40Z"
                    type: policy.committed
                    tenant: { id: acme-bank }
                    subject: { ref: subj-001 }
                    authority: { policy: wire-approval-policy, version: "2026.1" }
                  claim_canonical_hash: { alg: sha256, value: 94b54bd4f73d... }
                  signature:
                    alg: ed25519
                    key_id: 29aadab6...
                    value: MEUCIQ...
                    public_key: q1WrCN...
      responses:
        '201':
          description: Case opened.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreateCaseResponse' }
              example:
                submission_id: d3057aae793ecc4e...
                tenant: acme-bank
                precedents:
                  - { type: policy.committed, hash: 94b54bd4f73d... }
                  - { type: ai.inference.completed, hash: 0c0aefa439f0... }
                  - { type: guardrail.evaluated, hash: 8b3345c3c9e1... }
                approver_url: https://app.local/approver.html
        '400':
          description: Invalid body, bad/empty `artifact_b64`, missing required context field, or a client event failed verification.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401':
          description: Missing or invalid API key.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /client/outcome:
    post:
      tags: [Client API]
      summary: Record an outcome
      description: |
        Close the loop after a decision is sealed: record what the client system
        actually **did** with the approved decision (e.g. the wire was sent, the
        PHI disclosure was released, the candidate was rejected).

        - `ledger_uuid` is the sealed decision's **transparency-log UUID** — the
          one returned by `GET /verify/{uuid}` and shown in the approver UI after
          signing. The outcome is stored against that record so an auditor sees
          not just *what was approved* but *what was executed*.
        - `event` is a **client-signed** SSP event whose `claim.type` is
          `outcome.executed` (typically with `decision_ref` = the ledger UUID and
          an `outcome` object such as `{ "status": "executed" }`).
        - Sovereign re-verifies the event (recompute the canonical hash, check the
          Ed25519 signature against the embedded key) and **links** it to the
          record — it does not re-sign or alter the client's event. A failed
          signature, or a `claim.type` other than `outcome.executed`, is rejected.

        Call it once the real-world action completes. Submitting again for the
        same `ledger_uuid` replaces the stored outcome (latest wins).
      operationId: recordOutcome
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OutcomeRequest' }
            example:
              ledger_uuid: "00000000075bcd156bc387a9..."
              event:
                claim:
                  ssp_version: "1.0"
                  claim_id: 01KV5DH2Q...
                  issued_at: "2026-06-15T12:00:00Z"
                  type: outcome.executed
                  tenant: { id: acme-bank }
                  subject: { ref: "00000000075bcd156bc387a9..." }
                  decision_ref: "00000000075bcd156bc387a9..."
                  outcome: { status: executed }
                claim_canonical_hash: { alg: sha256, value: abcd... }
                signature: { alg: ed25519, key_id: 29aa..., value: MEUC..., public_key: q1Wr... }
      responses:
        '200':
          description: Outcome linked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: linked }
                  ledger_uuid: { type: string }
        '400':
          description: Invalid body or not a valid `outcome.executed` event.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '401':
          description: Missing or invalid API key.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '403':
          description: |
            The outcome's signing key does not match the **client key bound to this
            decision**. The fingerprint of the key that opened the case is anchored
            in the decision claim, and the outcome must be signed by that same key.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: No sealed decision exists for that `ledger_uuid` on this instance.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /client/cases/{id}:
    get:
      tags: [Client API]
      summary: Case status
      description: |
        Follow a case you opened: `pending` (with quorum progress), `sealed`
        (with `ledger_uuid`, `outcome` and `verify_url` — use the UUID to post
        `outcome.executed`), or `cancelled` (the case expired). Cases belonging
        to other tenants are indistinguishable from absent (404).
      operationId: caseStatus
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: The submission_id returned when the case was opened.
      responses:
        '200':
          description: Case status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  submission_id: { type: string }
                  status: { type: string, enum: [pending, sealed, cancelled] }
                  topic: { type: string }
                  domain: { type: string }
                  due_at: { type: string }
                  quorum:
                    type: object
                    properties:
                      required: { type: integer }
                      collected: { type: integer }
                      workflow: { type: string }
                      mode: { type: string }
                  outcome: { type: string, description: "sealed only" }
                  sealed_at: { type: string, description: "sealed only" }
                  ledger_uuid: { type: string, description: "sealed only" }
                  verify_url: { type: string, description: "sealed only" }
                  callback:
                    type: object
                    description: "sealed only, when a webhook was registered"
                    properties:
                      delivered_at: { type: string }
                      attempts: { type: integer }
                      last_error: { type: string }
        '401':
          description: Missing or invalid API key.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404':
          description: No such case for this tenant.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /verify/{uuid}:
    get:
      tags: [Verification]
      summary: Verify a sealed record
      description: |
        Return the decoded record and the result of every cryptographic check
        (claim hash, WebAuthn user signature, Ed25519 trust signature, Rekor
        SET/inclusion/checkpoint, RFC 3161 timestamp). Public; the raw domain
        context is only included for authenticated internal reviewers.
      operationId: verifyRecord
      security: []
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string }
          description: Ledger (transparency-log) UUID.
      responses:
        '200':
          description: Verification result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VerifyResult' }
        '404': { description: Entry not found in the log. }

  /verify/{uuid}/attestation:
    get:
      tags: [Verification]
      summary: Download the PII-free attestation (.sspa)
      description: A zip bundle (record + anchors + RFC 3161 token + TSA CA), without the domain context. Verifiable offline.
      operationId: downloadAttestation
      security: []
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The `.sspa` zip.
          content:
            application/zip: { schema: { type: string, format: binary } }
        '404': { description: Record not found. }

  /trust-key:
    get:
      tags: [Verification]
      summary: Sovereign trust public key (PEM)
      description: |
        The Ed25519 **trust** public key, served here for dev convenience. Supply
        it to the verifier out-of-band as the root of trust — a bundle must never
        carry its own trust key.
      operationId: trustKey
      security: []
      responses:
        '200':
          description: PEM-encoded public key.
          content:
            application/x-pem-file: { schema: { type: string } }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Client API key (also accepted as `Authorization: Bearer <key>`)."

  schemas:
    ClientEvent:
      type: object
      description: A client-signed upstream lifecycle event.
      required: [claim, claim_canonical_hash, signature]
      properties:
        claim:
          type: object
          description: |
            The SSP 1.0 claim. Conventional fields: `ssp_version`, `claim_id`
            (ULID), `issued_at` (RFC 3339), `type` (e.g. `policy.committed`,
            `ai.inference.completed`, `guardrail.evaluated`, `outcome.executed`),
            `tenant.id`, `subject.ref`, plus event-specific fields.
        claim_canonical_hash:
          type: object
          required: [alg, value]
          properties:
            alg: { type: string, example: sha256 }
            value:
              type: string
              description: Hex SHA-256 over the JCS (RFC 8785) canonicalisation of `claim`.
        signature:
          type: object
          required: [alg, value, public_key]
          properties:
            alg: { type: string, example: ed25519 }
            key_id: { type: string, description: Hex SHA-256 of the raw public key. }
            value: { type: string, description: base64url Ed25519 signature over the canonical claim bytes. }
            public_key: { type: string, description: base64url raw (32-byte) Ed25519 public key. }

    CreateCaseRequest:
      type: object
      required: [domain, artifact_b64, due_at, client_events]
      properties:
        domain:
          type: string
          enum: [finance, medical, hiring, general]
          description: Decision domain; sets which `context` fields are required.
        filename: { type: string }
        mime_type: { type: string }
        topic: { type: string, description: "≤5-word approval subject (queue title)." }
        party: { type: string, description: Person / customer / company the approval concerns. }
        subcategory: { type: string, description: "Finer type within the domain (e.g. 'Drug prescription')." }
        required_role: { type: string, description: OIDC group the approver must hold to sign. }
        due_at: { type: string, format: date-time, description: "Approval SLA deadline (RFC 3339). Required." }
        quorum:
          allOf:
            - $ref: '#/components/schemas/Quorum'
          description: Optional m-of-n approval policy. Omit for a single approver.
        callback:
          type: object
          description: |
            Optional webhook fired when the decision SEALS. You author the payload:
            `template` is a Go text/template rendered over the sealed record — emit
            JSON, YAML, or any text you like. Template context: `submission_id`,
            `ledger_uuid`, `outcome`, `sealed_at`, `topic`, `party`, `domain`,
            `tenant`, `verify_url`, plus the full decoded `record` and its `claim`
            (e.g. `{{.claim.decision.outcome}}`, `{{json .claim.decision}}`).
            Helpers: `json`, `jsonindent`. The single-use `token` is echoed back as
            `Authorization: Bearer <token>` and `X-Callback-Token`, and consumed
            after successful delivery (3 attempts).
          required: [url, template]
          properties:
            url:          { type: string, description: "http(s) endpoint to POST the rendered payload to." }
            token:        { type: string, description: "Single-use secret echoed on the delivery so you can authenticate it." }
            content_type: { type: string, description: "Content-Type of the delivery (default application/json)." }
            template:     { type: string, description: "Go text/template producing the payload body." }
        artifact_b64: { type: string, format: byte, description: Base64 of the document under review. }
        context:
          type: object
          additionalProperties: true
          description: Domain context held as evidence **outside** the signed claim (where PII/PHI lives).
        client_events:
          type: array
          items: { $ref: '#/components/schemas/ClientEvent' }

    Quorum:
      type: object
      description: |
        An approval policy. Composition (who signs, in what order) is given as a
        `dsl` string, a flat `approvers` list, or explicit `stages`; `mode` groups
        a flat list/DSL into stages (parallel | sequential | parallelRoles | custom).
        Workflow (how the decision is reached) is `ratify` (co-sign one decision) or
        `independent` (vote/veto). The required approval count is DERIVED from the
        slots — there is no threshold. Omit for a single approver.
      properties:
        mode:
          type: string
          enum: [parallel, sequential, parallelRoles, custom]
          description: "How a flat list / DSL groups into stages. Default parallel."
        dsl:
          type: string
          description: 'Composition DSL, e.g. "Senior Credit Officer*2, Compliance" (counts) or, for custom, "r1, r2*2, r3" (comma=stage, *N=parallel). Use @name for a named-user slot.'
        approvers:
          type: array
          description: "Flat requirement list (alternative to dsl)."
          items:
            type: object
            properties:
              role:   { type: string }
              user:   { type: string, description: "Named approver (mutually exclusive with role)." }
              count:  { type: integer, description: "How many of this requirement (default 1)." }
              weight: { type: integer, description: "Vote weight; 0 = advisory (no vote/veto). Default 1." }
        stages:
          type: array
          description: "Explicit ordered stages of parallel slots (custom; alternative to dsl/approvers)."
          items:
            type: object
            properties:
              slots:
                type: array
                items:
                  type: object
                  properties:
                    role:              { type: string }
                    user:              { type: string }
                    weight:            { type: integer }
                    approval_duration: { type: string, description: "Per-slot SLA override (ISO-8601 or Go duration)." }
        workflow:
          type: object
          properties:
            type:                    { type: string, enum: [ratify, independent] }
            approval_mode:           { type: string, enum: [veto, vote], description: "independent only" }
            vote_threshold:          { type: number, description: "vote only; fraction to win, strict >, default 0.5" }
            return_cap_per_approver: { type: integer, description: "ratify only; optional cap on send-backs" }
        assignment:
          type: string
          enum: [auto, user_preferred, user_prompted]
          description: "Which slot a signer fills when eligible for several. Default auto."
        sla:
          type: object
          properties:
            approval_duration: { type: string, description: "Per-approver SLA window (ISO-8601 or Go duration)." }
            expire_by:         { type: string, format: date-time, description: "Absolute cancellation deadline." }

    CreateCaseResponse:
      type: object
      properties:
        submission_id: { type: string }
        tenant: { type: string }
        precedents:
          type: array
          items:
            type: object
            properties:
              type: { type: string }
              hash: { type: string }
        approver_url: { type: string, description: "Derived from the host the request arrived on." }
        status_url: { type: string, description: "GET here (same API key) to follow the case." }
        quorum:
          allOf:
            - $ref: '#/components/schemas/Quorum'
          description: "Echo of the NORMALIZED policy, including the derived `required` count. Present only when a quorum was requested."
        warnings:
          type: array
          items: { type: string }
          description: "Non-fatal policy warnings, e.g. the total approval time budget exceeding the window to expire_by."
        callback:
          type: object
          description: "Present when a webhook was registered."
          properties:
            registered: { type: boolean }
            url: { type: string }

    OutcomeRequest:
      type: object
      required: [ledger_uuid, event]
      properties:
        ledger_uuid: { type: string }
        event:
          allOf:
            - $ref: '#/components/schemas/ClientEvent'
          description: A client-signed event whose `claim.type` is `outcome.executed`.

    VerifyResult:
      type: object
      description: Decoded record plus per-check booleans (subset shown).
      properties:
        status: { type: string, example: verified }
        format: { type: string, enum: [ssp, dsse] }
        ledger_uuid: { type: string }
        claim: { type: object, description: "The decoded Sovereign Record claim (decision, human, presentation, …)." }
        user_signature_valid: { type: boolean, description: "Single: the WebAuthn assertion. Quorum/resolution: ALL member signatures + slot satisfaction + distinctness (+ recomputed tally for resolutions)." }
        trust_signature_valid: { type: boolean }
        quorum:
          type: object
          nullable: true
          description: |
            Present for multi-approver records. Ratify quorum: {required, collected,
            met, distinct, workflow, mode, signers:[{id, role, stage, slot, valid,
            signed_at}]}. Independent resolution: {required, collected, resolved,
            distinct, approval_mode, votes:[{id, role, stage, slot, outcome,
            rationale, valid, signed_at}]}.
        rekor_set_valid: { type: boolean }
        rekor_inclusion_valid: { type: boolean }
        rekor_checkpoint_valid: { type: boolean }
        tsa_valid: { type: boolean }
        tsa_time: { type: string }
        decoded: { type: object, description: "Transparency-log metadata (log_index, integrated_time, data hash, signer key)." }

    Error:
      type: object
      properties:
        error: { type: string }
