openapi: 3.1.0
info:
  title: MultiEdge Signal Relay API
  version: "1.0.0"
  summary: Auditable signal-distribution infrastructure — not execution.
  description: |
    MultiEdge Signal Relay is auditable signal-distribution infrastructure — **not
    execution**. Strategist firms publish signals once; their institutional clients
    receive them over WebSocket, webhook, or REST — at-least-once, replayable, and
    fully auditable. The relay routes no orders, ranks no strategies, and gives no
    investment advice. **Institutional and professional participants only.**

    This document describes the surface AS IMPLEMENTED by the backend
    (src/MultiEdge.Relay.Api). Frozen semantics:
    - `sequence` is monotonic and gapless per (tenant, strategy), assigned atomically
      inside the publish transaction and returned in the ack.
    - Duplicate `client_signal_id` returns the ORIGINAL ack (`200`, `duplicate: true`);
      a fresh publish returns `201`.
    - Webhook retry ladder: initial attempt + 5 retries (+30s/+2m/+10m/+1h/+6h), then
      `dead` in the delivery ledger. Nothing is dropped silently.
    - Signature: HMAC-SHA256 over `"{unix_ts}." + raw_body_bytes`, per-endpoint 32-byte
      secret, constant-time compare, 5-minute freshness; field redaction BEFORE signing.
    - Tenant identity comes only from the authenticated API key — never a request body.
    - **Sealed mode (per-strategy, ADR 0004):** payloads are end-to-end encrypted
      client-side (hybrid X25519 + ML-KEM-768 key wrap, ChaCha20-Poly1305, dual
      Ed25519 + ML-DSA-65 publisher signatures). The relay stores and forwards the
      sealed envelope byte-identical and sees only metadata (IDs, sequence,
      timestamps, size, recipient count) — it validates envelope STRUCTURE only and
      performs no cryptography. Schema validation, the forbidden-term scan, and
      field-level redaction require plaintext and are structurally unavailable on
      sealed strategies. Sealed encryption supports up to ~100 entitled recipients
      per strategy today (256 KiB envelope cap); support for larger recipient sets
      is on the roadmap.
  contact:
    name: MultiEdge access
    email: access@multiedge.ai
    url: https://multiedge.ai/contact
servers:
  - url: https://relay-api.multiedge.ai
    description: Production (United States — API tier East US, relay database Central US)
security:
  - bearerAuth: []
tags:
  - name: Signals
  - name: Sealed keys
  - name: Streaming
  - name: Deliveries
  - name: Endpoints
  - name: Control plane
  - name: Ops
  - name: Health
paths:
  /v1/status:
    get:
      tags: [Status]
      operationId: getStatus
      summary: Public availability status (no authentication)
      description: >
        Component availability and rolling uptime, as published on
        https://status.multiedge.ai. Anonymous: this operation overrides the
        global security requirement with an empty list, and carries nothing
        tenant-scoped. It ALWAYS returns 200 — when the monitoring backend
        cannot be read every component reports `unknown`, because a status
        endpoint that errors is useless precisely when it is needed.
      security: []
      responses:
        "200":
          description: Current status. Results are cached server-side for 60 seconds.
          content:
            application/json:
              schema:
                type: object
                required: [generated_at, components]
                properties:
                  generated_at:
                    type: string
                    format: date-time
                    description: When the rollup was computed (UTC).
                  components:
                    type: array
                    items:
                      type: object
                      required: [name, state]
                      properties:
                        name:
                          type: string
                          example: relay-api
                        state:
                          type: string
                          enum: [operational, degraded, outage, unknown]
                          description: >
                            `unknown` means no probe result inside the recent
                            window — never assume health from it.
                        uptime_24h:
                          type: number
                          nullable: true
                          description: Success percentage; null when the window holds no measurements.
                        uptime_7d:
                          type: number
                          nullable: true
                        uptime_30d:
                          type: number
                          nullable: true
              example:
                generated_at: "2026-08-18T04:05:06.789Z"
                components:
                  - name: relay-api
                    state: operational
                    uptime_24h: 100
                    uptime_7d: 99.983
                    uptime_30d: 99.951
                  - name: multiedge-ai
                    state: operational
                    uptime_24h: 100
                    uptime_7d: 100
                    uptime_30d: 99.996
  /v1/signals:
    post:
      tags: [Signals]
      operationId: publishSignal
      summary: Publish a signal (idempotent via client_signal_id)
      description: Requires `admin` or `publisher:<strategy_id>` scope for the target strategy.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PublishRequest" }
      responses:
        "201":
          description: Signal accepted and sequenced.
          content: { application/json: { schema: { $ref: "#/components/schemas/PublishAck" } } }
        "200":
          description: >
            Duplicate client_signal_id with a byte-identical payload — the
            ORIGINAL ack, duplicate: true. (For plain strategies a duplicate id
            with a DIFFERENT payload is a 409; sealed strategies skip that
            comparison, since re-sealing identical plaintext yields different
            ciphertext, and always take this 200 path.)
          content: { application/json: { schema: { $ref: "#/components/schemas/PublishAck" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            Two cases. `strategy_archived` (ADR 0013): the strategy is archived;
            its history stays readable — what is refused is a NEW signal on a
            retired channel; restore the strategy to publish again.
            `client_signal_id_conflict` (ADR 0015): the id already names a
            signal with a DIFFERENT payload — the body carries the original's
            `signal_id` and `sequence`; publish the correction under a NEW id
            (revision-suffix convention, e.g. `<strategy>:<date>:r2` — the
            subscriber rule is that the highest sequence for a signal_date
            wins). Refusing loudly beats the silent 200 that would discard the
            correction. Sealed strategies never take this path.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "413":
          description: >
            Payload exceeds the cap — 65,536 bytes for plain strategies,
            262,144 bytes for sealed strategies (`max_bytes` in the body names
            the applicable cap).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: >
            The tenant's plan publish budget is exhausted
            (`plan_rate_limited`; rate-limited plans such as sandbox only).
            `retry_after_seconds` in the body and the `Retry-After` header say
            when to retry.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422":
          description: >
            Missing `strategy_id`/`payload`; an identifier wider than its column
            (`client_signal_id_too_long` 100, `schema_version_too_long` 100,
            `correlation_id_too_long` 200 — each carries `max_length`); or, for
            plain strategies, payload is not JSON (`payload_not_json`), violates
            the strategy schema (`schema_violation`), or (compliance profile)
            carries forbidden terms (`forbidden_terms`). Sealed strategies: the
            payload is not a structurally valid sealed envelope
            (`sealed_envelope_invalid`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    get:
      tags: [Signals]
      operationId: listSignals
      summary: Sequence-ordered catch-up read from a cursor
      description: |
        Publisher/admin keys see the raw tenant ledger. Subscriber keys need an active
        entitlement and receive the entitlement view: rows younger than the policy's
        delay_seconds are hidden and payloads are field-filtered. Pass the returned
        `next_sequence` as the next `since_sequence`.
      parameters:
        - { name: strategy_id, in: query, required: true, schema: { type: string } }
        - { name: since_sequence, in: query, schema: { type: integer, format: int64, default: 0 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
      responses:
        "200":
          description: A page of envelopes plus the cursor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  signals:
                    type: array
                    items: { $ref: "#/components/schemas/SignalEnvelope" }
                  next_sequence: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/clients/{clientId}/sealed-keys:
    post:
      tags: [Sealed keys]
      operationId: registerRecipientSealedKey
      summary: Register a subscriber's sealed-mode recipient key bundle
      description: >
        Admin scope, or the client's own `subscriber:<client_id>` key. The relay
        stores the bundle OPAQUELY — it never recomputes the fingerprint and holds
        no private keys; publishers re-derive and verify `key_id` locally
        (fingerprint = SHA-256 of the canonical bundle JSON). Verify fingerprints
        out-of-band: the relay is deliberately untrusted for key authenticity.
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterSealedKeyRequest" }
      responses:
        "201":
          description: Key registered.
          content: { application/json: { schema: { $ref: "#/components/schemas/SealedKey" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This key_id is already registered.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/strategies/{strategyId}/sealed-keys:
    get:
      tags: [Sealed keys]
      operationId: listRecipientSealedKeys
      summary: List the recipient key bundles a publisher must seal to
      description: >
        Admin or `publisher:<strategy_id>` scope. Returns the non-revoked
        recipient bundles of every client whose entitlement to the strategy is
        currently active — the exact recipient set for the next sealed publish.
        Sealed encryption supports up to ~100 recipients per strategy today
        (256 KiB envelope cap); larger recipient sets are on the roadmap.
      parameters:
        - { name: strategyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The current recipient set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/SealedKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/strategies/{strategyId}/sealed-keys/sender:
    put:
      tags: [Sealed keys]
      operationId: registerSenderSealedKey
      summary: Register the publisher's sealed-mode signing key bundle
      description: >
        Admin or `publisher:<strategy_id>` scope. Insert-as-new — older sender
        keys stay valid until revoked, so rotation never breaks verification of
        in-flight signals.
      parameters:
        - { name: strategyId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterSealedKeyRequest" }
      responses:
        "201":
          description: Sender key registered.
          content: { application/json: { schema: { $ref: "#/components/schemas/SealedKey" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This key_id is already registered.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
    get:
      tags: [Sealed keys]
      operationId: getSenderSealedKeys
      summary: Fetch the publisher's signing key bundles (newest first)
      description: >
        Admin/publisher of the strategy, or a subscriber with a currently active
        entitlement. Subscribers pin the fetched bundle and verify its
        fingerprint out-of-band before trusting signatures from it.
      parameters:
        - { name: strategyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Non-revoked sender bundles, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items: { $ref: "#/components/schemas/SealedKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/sealed-keys/{keyId}:
    delete:
      tags: [Sealed keys]
      operationId: revokeSealedKey
      summary: Revoke a sealed key (owner or admin)
      description: >
        Admin always; a subscriber key for its own client's recipient keys; a
        publisher key for its strategy's sender keys. Revoked keys disappear
        from listings immediately; signals already sealed to a revoked
        recipient key remain decryptable by its holder (no re-encryption).
      parameters:
        - { name: keyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Key revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key_id: { type: string }
                  revoked_at: { type: string, format: date-time }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/ws/negotiate:
    post:
      tags: [Streaming]
      operationId: negotiateWebSocket
      summary: Mint a 5-minute Web PubSub client URI for the caller's endpoint group
      description: Subscriber scope only; the token auto-joins `ep:{endpoint_id}` and carries no join-group role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [endpoint_id]
              properties:
                endpoint_id: { type: string }
      responses:
        "200":
          description: Connect to this URI with a plain WebSocket client.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Endpoint exists but is not active.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422":
          description: Endpoint is not a websocket endpoint.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  /v1/deliveries:
    get:
      tags: [Deliveries]
      operationId: listDeliveries
      summary: Tenant-scoped delivery ledger with attempts (admin scope)
      parameters:
        - { name: endpoint_id, in: query, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, dead, suspended] } }
      responses:
        "200":
          description: Up to 200 most recent deliveries, each with its attempt trail.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/Delivery" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/deliveries/{deliveryId}/replay:
    post:
      tags: [Deliveries]
      operationId: replayDelivery
      summary: Re-enqueue one webhook delivery with a fresh ladder (admin scope)
      parameters:
        - { name: deliveryId, in: path, required: true, schema: { type: string } }
      responses:
        "202":
          description: Replay enqueued; attempts are flagged `replayed`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery_id: { type: string }
                  replayed: { type: boolean }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/endpoints/{endpointId}:
    get:
      tags: [Endpoints]
      operationId: getEndpoint
      summary: Endpoint status/health (admin or the owning subscriber)
      parameters:
        - { name: endpointId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Status plus delivery counts by state.
          content: { application/json: { schema: { $ref: "#/components/schemas/EndpointHealth" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/endpoints/{endpointId}/resume:
    post:
      tags: [Endpoints]
      operationId: resumeEndpoint
      summary: Reactivate a paused endpoint and re-enqueue suspended + dead deliveries (admin scope)
      description: Re-enqueued in sequence order, each with a fresh retry ladder; attempts flagged `replayed`.
      parameters:
        - { name: endpointId, in: path, required: true, schema: { type: string } }
      responses:
        "202":
          description: Resume accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint_id: { type: string }
                  re_enqueued: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/tenants:
    post:
      tags: [Control plane]
      operationId: createTenant
      summary: Bootstrap a tenant (operator key only) — returns the initial admin key ONCE
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company_name, billing_email]
              properties:
                company_name: { type: string }
                billing_email: { type: string, format: email }
                country:
                  type: string
                  maxLength: 100
                  description: Optional country of the firm; omitted/blank = not recorded.
                registration_number:
                  type: string
                  maxLength: 100
                  description: Optional company registration number in its home registry.
                website:
                  type: string
                  maxLength: 200
                  description: Optional company website; absolute http/https URL.
      responses:
        "201":
          description: >
            Tenant created. NO credential is minted: the operator creating a
            tenant is not the party that will hold its API key. The tenant's
            first admin arrives by portal invite and mints their own key from a
            portal session (POST /v1/api-keys).
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id: { type: string }
                  company_name: { type: string }
                  plan: { type: string }
                  plan_expires_at: { type: string, format: date-time, nullable: true }
                  billing_mode: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/tenants/me:
    get:
      tags: [Control plane]
      operationId: getTenantMe
      summary: The caller's tenant
      responses:
        "200":
          description: Tenant row.
          content: { application/json: { schema: { $ref: "#/components/schemas/Tenant" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/strategies:
    post:
      tags: [Control plane]
      operationId: createStrategy
      summary: Register a strategy (admin scope); the JSON Schema must compile
      description: >-
        Omit `signal_schema_json` to use the shipped standard schema `portfolio_rebalance/1.1`
        (ADR 0015). Under that schema, a non-empty `positions` array is the COMPLETE post-trade
        target portfolio for the date (INITIALIZE | BUY | SELL | HOLD): an unchanged holding is
        stated affirmatively with HOLD at its unchanged weight, and a ticker ABSENT from a
        non-empty array has a target weight of 0 and is liquidated. A no-action day publishes an
        empty `positions` array as a heartbeat — it never trades and never liquidates.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [slug, display_name]
              properties:
                slug: { type: string }
                display_name: { type: string }
                signal_schema_json: { type: string, description: "JSON Schema Draft 2020-12 document" }
                compliance_profile: { type: boolean, default: false }
                sealed:
                  type: boolean
                  default: false
                  description: >
                    Sealed mode (ADR 0004): payloads must be end-to-end encrypted
                    sealed envelopes; immutable after creation. Mutually exclusive
                    with compliance_profile (422
                    `sealed_incompatible_with_compliance_profile`) — the
                    forbidden-term scan requires plaintext.
      responses:
        "201":
          description: Strategy created (sequence counter seeded).
          content: { application/json: { schema: { $ref: "#/components/schemas/Strategy" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Slug already exists for this tenant.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
    get:
      tags: [Control plane]
      operationId: listStrategies
      summary: List the tenant's live strategies, each with its subscriber count
      parameters:
        - name: include_archived
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: >
            Include archived strategies (ADR 0013). They are hidden by default:
            the listing is the working set.
      responses:
        "200":
          description: The tenant's strategies.
          content:
            application/json:
              schema:
                type: object
                properties:
                  strategies:
                    type: array
                    items: { $ref: "#/components/schemas/Strategy" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/strategies/{strategyId}:
    get:
      tags: [Control plane]
      operationId: getStrategy
      summary: One strategy (admin or its publisher key)
      parameters:
        - { name: strategyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The strategy. Archived strategies remain addressable by id.
          content: { application/json: { schema: { $ref: "#/components/schemas/Strategy" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Control plane]
      operationId: archiveStrategy
      summary: Archive a strategy — the tenant-facing delete (admin scope)
      description: >
        Stamps `archived_at` (ADR 0013). The strategy stops accepting publishes
        (409 `strategy_archived`), leaves the default listing and frees its plan
        slot; its signals, deliveries, attempt trail and evidence records are
        kept unchanged and its catch-up reads keep working. IDEMPOTENT: a second
        call returns the SAME `archived_at` rather than moving it. Reversible via
        the restore route.
      parameters:
        - { name: strategyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The archived strategy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Strategy" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/strategies/{strategyId}/restore:
    post:
      tags: [Control plane]
      operationId: restoreStrategy
      summary: Restore an archived strategy (admin scope)
      description: >
        Clears `archived_at` under the same serializable plan-cap check as
        creation — restoring past the ceiling is refused with 403
        `plan_limit_reached`. Restoring a live strategy is a no-op.
      parameters:
        - { name: strategyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The restored strategy.
          content: { application/json: { schema: { $ref: "#/components/schemas/Strategy" } } }
        "403":
          description: Forbidden scope, or the plan ceiling is reached (`plan_limit_reached`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/clients:
    post:
      tags: [Control plane]
      operationId: createClient
      summary: Onboard a subscriber (admin scope)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [display_name, primary_contact_email]
              properties:
                display_name: { type: string }
                primary_contact_email: { type: string, format: email }
      responses:
        "201":
          description: Client created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  client_id: { type: string }
                  display_name: { type: string }
                  primary_contact_email: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
    get:
      tags: [Control plane]
      operationId: listClients
      summary: List the tenant's live subscribers
      parameters:
        - name: include_archived
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: "Include archived subscribers (ADR 0013); hidden by default."
      responses:
        "200":
          description: The tenant's subscribers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  clients:
                    type: array
                    items:
                      type: object
                      properties:
                        client_id: { type: string }
                        display_name: { type: string }
                        archived_at: { type: string, format: date-time, nullable: true }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/clients/{clientId}:
    delete:
      tags: [Control plane]
      operationId: archiveClient
      summary: Archive a subscriber — the tenant-facing delete (admin scope)
      description: >
        Stamps `archived_at` (ADR 0013). The subscriber leaves the default listing,
        frees its plan slot, and receives no NEW fan-out — deliveries already
        accepted for it still run their retry ladder to completion, and its
        delivery ledger, attempt trail and evidence records are kept unchanged.
        Its entitlements are left intact so a restore is exact. IDEMPOTENT.
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The archived subscriber.
          content: { application/json: { schema: { $ref: "#/components/schemas/Client" } } }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/clients/{clientId}/restore:
    post:
      tags: [Control plane]
      operationId: restoreClient
      summary: Restore an archived subscriber (admin scope)
      description: >
        Clears `archived_at` under the same serializable plan-cap check as
        creation; restoring past the ceiling is refused with 403
        `plan_limit_reached`. Restoring a live subscriber is a no-op.
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The restored subscriber.
          content: { application/json: { schema: { $ref: "#/components/schemas/Client" } } }
        "403":
          description: Forbidden scope, or the plan ceiling is reached (`plan_limit_reached`).
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/clients/{clientId}/endpoints:
    post:
      tags: [Control plane]
      operationId: createEndpoint
      summary: Register a delivery endpoint — the HMAC secret is returned ONCE
      parameters:
        - { name: clientId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [transport]
              properties:
                transport: { type: string, enum: [websocket, webhook, rest_pull] }
                url: { type: string, format: uri, description: "Required for webhook transport." }
      responses:
        "201":
          description: Endpoint created; `secret_base64` (32-byte HMAC key) is shown exactly once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoint_id: { type: string }
                  client_id: { type: string }
                  transport: { type: string }
                  url: { type: string }
                  secret_base64: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/entitlements:
    post:
      tags: [Control plane]
      operationId: createEntitlement
      summary: Grant a strategy to a client with a policy (admin scope)
      description: >
        When the operator has capped subscribers per strategy for this
        tenant, a grant past the cap is refused with 403
        `plan_limit_reached` (`resource: subscribers_per_strategy`); revoking
        an existing grant (window moved to the past) frees its slot.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, strategy_id]
              properties:
                client_id: { type: string }
                strategy_id: { type: string }
                policy_json:
                  type: string
                  description: >
                    The policy DSL as a JSON string (delivery/payload_policy/window);
                    empty = permissive realtime. On SEALED strategies a
                    payload_policy with fields_allowed/fields_redacted is rejected
                    (422 `payload_policy_forbidden_for_sealed`) — redaction
                    requires plaintext.
      responses:
        "201":
          description: Entitlement created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entitlement_id: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: This (client, strategy) pair is already entitled.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/entitlements/{entitlementId}:
    patch:
      tags: [Control plane]
      operationId: updateEntitlement
      summary: Replace an entitlement's policy (admin scope)
      parameters:
        - { name: entitlementId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [policy_json]
              properties:
                policy_json: { type: string }
      responses:
        "200":
          description: Policy replaced.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entitlement_id: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/api-keys:
    post:
      tags: [Control plane]
      operationId: createApiKey
      summary: Issue an API key (admin scope) — the raw key is returned ONCE
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope]
              properties:
                scope:
                  type: string
                  description: "admin | publisher:<strategy_id> | subscriber:<client_id> (target must belong to the tenant)."
      responses:
        "201":
          description: Key created; `api_key` is shown exactly once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key_id: { type: string }
                  scope: { type: string }
                  api_key: { type: string }
                  key_prefix: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unprocessable" }
  /v1/api-keys/{keyId}:
    delete:
      tags: [Control plane]
      operationId: revokeApiKey
      summary: Revoke an API key immediately (admin scope)
      parameters:
        - { name: keyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Key revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key_id: { type: string }
                  revoked_at: { type: string, format: date-time }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /internal/ops/outbox/sweep:
    post:
      tags: [Ops]
      operationId: forceOutboxSweep
      summary: Force one outbox sweep pass now (admin scope)
      responses:
        "200":
          description: Rows dispatched by this pass.
          content:
            application/json:
              schema:
                type: object
                properties:
                  dispatched: { type: integer }
        "403": { $ref: "#/components/responses/Forbidden" }
  /healthz:
    get:
      tags: [Health]
      operationId: liveness
      summary: Liveness (anonymous)
      security: []
      responses:
        "200":
          description: Process is up.
  /readyz:
    get:
      tags: [Health]
      operationId: readiness
      summary: Readiness — SQL reachable (anonymous)
      security: []
      responses:
        "200":
          description: SQL reachable.
        "503":
          description: SQL unreachable.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "mesk_<43 base64url chars>"
  responses:
    BadRequest:
      description: Missing/invalid query parameters.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: The key's scope or tenant does not permit this operation.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: The resource does not exist within the caller's tenant.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unprocessable:
      description: The request body is semantically invalid.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        details: {}
    PublishRequest:
      type: object
      required: [strategy_id, payload]
      properties:
        strategy_id: { type: string }
        client_signal_id: { type: string, description: "Idempotency key, unique per (tenant, strategy); effective window = 90-day retention, documented externally as at least 24 h. A byte-identical resend replays the original ack (200, duplicate: true); a DIFFERENT payload under a reused id is refused with 409 client_signal_id_conflict for plain strategies (ADR 0015) — publish corrections under a new id, e.g. a ':r2' revision suffix. Sealed strategies skip the payload comparison." }
        schema_version: { type: string, default: "1.0", description: "Informational free-text label echoed to subscribers; never validated and unrelated to the schema the relay enforces (that comes from the strategy row). Convention: the standard schema id, e.g. 'portfolio_rebalance/1.1'." }
        payload:
          type: object
          description: >
            Plain strategies: tenant-defined JSON ≤ 64 KB, validated against the
            strategy's registered schema. Sealed strategies: a SealedEnvelope
            object ≤ 256 KiB, validated structurally only — the relay cannot
            read it.
        expires_at: { type: string, format: date-time }
        correlation_id: { type: string }
    PublishAck:
      type: object
      properties:
        signal_id: { type: string, description: "ULID." }
        client_signal_id: { type: string }
        sequence: { type: integer, format: int64 }
        accepted_at: { type: string, format: date-time }
        duplicate: { type: boolean }
    SignalEnvelope:
      type: object
      properties:
        signal_id: { type: string }
        client_signal_id: { type: string }
        tenant_id: { type: string }
        strategy_id: { type: string }
        schema_version: { type: string }
        sequence: { type: integer, format: int64 }
        published_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }
        correlation_id: { type: string }
        payload: { type: object }
    Tenant:
      type: object
      properties:
        tenant_id: { type: string }
        company_name: { type: string }
        billing_email: { type: string }
        status: { type: string }
        plan:
          type: string
          description: >
            Commercial plan of the tenant (e.g. `standard`, or `sandbox` for
            the free 7-day evaluation). Plans bound resource counts and
            publish rate; assignment is operator-managed, never self-serve.
        plan_expires_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When a time-boxed plan (sandbox) expires; null for perpetual
            plans. After expiry every API key of the tenant is refused with
            403 `plan_expired` until the operator extends or upgrades the plan.
        created_at: { type: string, format: date-time }
        limits:
          type: object
          description: >
            The tenant's EFFECTIVE ceilings (plan defaults with any operator
            override applied) and the live rows counted against them. Published
            so a client can refuse a creation flow at its first step instead of
            at its last; the server-side caps remain the enforcement. Counts are
            LIVE rows only — an archived feed or subscriber frees its slot (ADR 0013).
          properties:
            max_strategies:
              type: integer
              nullable: true
              description: "Ceiling on live strategies; null = uncapped, 0 = creation frozen."
            strategies_used: { type: integer }
            max_clients:
              type: integer
              nullable: true
              description: "Ceiling on live subscribers; null = uncapped, 0 = creation frozen."
            clients_used: { type: integer }
            max_subscribers_per_strategy:
              type: integer
              nullable: true
              description: >
                Ceiling on live entitlements PER strategy; null = uncapped.
                No plan sets it — it exists only as an operator override.
    Client:
      type: object
      properties:
        client_id: { type: string }
        display_name: { type: string }
        primary_contact_email: { type: string }
        created_at: { type: string, format: date-time }
        archived_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When the subscriber was archived (ADR 0013), or null while it
            is live. An archived subscriber receives no new fan-out and frees its plan
            slot; its delivery history is kept unchanged.
    Strategy:
      type: object
      properties:
        strategy_id: { type: string }
        slug: { type: string }
        display_name: { type: string }
        compliance_profile: { type: boolean }
        sealed: { type: boolean, description: "Sealed mode (ADR 0004); immutable after creation." }
        signal_schema_json: { type: string }
        created_at: { type: string, format: date-time }
        archived_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When the strategy was archived (ADR 0013), or null while it is live.
            An archived strategy refuses publishes with 409 `strategy_archived`,
            leaves the default listing and frees its plan slot — while every
            signal, delivery and evidence record it produced is kept unchanged
            and its catch-up reads keep working.
        subscribers:
          type: integer
          nullable: true
          description: >
            Live (non-expired) entitlements on this strategy — the same basis
            the per-strategy subscriber cap counts on. Present on the LIST
            response; null means "not counted", never "none".
    RegisterSealedKeyRequest:
      type: object
      required: [key_id, bundle]
      properties:
        key_id:
          type: string
          pattern: "^[0-9a-f]{64}$"
          description: >
            The bundle fingerprint, computed by the REGISTRANT as SHA-256 over
            the canonical bundle JSON (sorted keys, compact separators, ASCII).
            The relay never recomputes it; consumers verify it locally.
        bundle: { $ref: "#/components/schemas/SealedKeyBundle" }
    SealedKeyBundle:
      type: object
      required: [bundle, purpose, created_at]
      description: >
        A mek-v1 public key bundle. Recipient bundles carry x25519_pub (32 B)
        and mlkem768_ek (ML-KEM-768 encapsulation key, 1184 B); sender bundles
        carry ed25519_pub (32 B) and, for dual post-quantum signatures,
        mldsa65_pub (ML-DSA-65 verification key, 1952 B). All binary fields are
        standard base64. The relay stores bundles verbatim and never validates
        key material cryptographically.
      properties:
        bundle: { type: string, enum: [mek-v1] }
        purpose: { type: string, enum: [recipient, sender] }
        x25519_pub: { type: string, description: "base64, 32 bytes (recipient)." }
        mlkem768_ek: { type: string, description: "base64, 1184 bytes (recipient)." }
        ed25519_pub: { type: string, description: "base64, 32 bytes (sender)." }
        mldsa65_pub: { type: string, description: "base64, 1952 bytes (sender, dual signatures)." }
        created_at: { type: string, format: date-time }
    SealedKey:
      type: object
      properties:
        key_id: { type: string, pattern: "^[0-9a-f]{64}$" }
        purpose: { type: string, enum: [recipient, sender] }
        client_id: { type: string, description: "Owning client (recipient keys)." }
        strategy_id: { type: string, description: "Owning strategy (sender keys)." }
        bundle: { $ref: "#/components/schemas/SealedKeyBundle" }
        created_at: { type: string, format: date-time }
    SealedEnvelope:
      type: object
      required: [sealed, alg, sender_kid, nonce, ct, recipients, sig]
      description: >
        The sealed envelope v1 (ADR 0004) — the `payload` shape for sealed
        strategies. Produced and consumed ONLY by client SDKs
        (multiedge-relay[sealed]); the relay validates structure and forwards
        byte-identical. Confidentiality: per-signal DEK, ChaCha20-Poly1305,
        AAD binds strategy_id + client_signal_id + sender_kid. Key wrap per
        recipient: hybrid X25519 + ML-KEM-768 (FIPS 203) with HKDF-SHA256
        transcript binding. Authenticity: Ed25519 always, plus ML-DSA-65
        (FIPS 204) for dual senders — verifiers reject sig_pq stripping.
        Capacity: up to ~100 recipients per envelope under the 256 KiB cap;
        larger recipient sets are on the roadmap.
      properties:
        sealed: { type: string, enum: [v1] }
        alg:
          type: object
          properties:
            aead: { type: string, enum: [chacha20poly1305] }
            kem: { type: string, enum: [x25519-mlkem768] }
            kdf: { type: string, enum: [hkdf-sha256] }
            sig: { type: string, enum: [ed25519, ed25519+mldsa65] }
        sender_kid: { type: string, pattern: "^[0-9a-f]{64}$" }
        nonce: { type: string, description: "base64, 12 bytes." }
        ct: { type: string, description: "base64 ciphertext of the canonical-JSON payload." }
        recipients:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: object
            required: [kid, epk, kem_ct, wrap]
            properties:
              kid: { type: string, pattern: "^[0-9a-f]{64}$" }
              epk: { type: string, description: "base64, 32 bytes — per-signal ephemeral X25519 public key." }
              kem_ct: { type: string, description: "base64, 1088 bytes — ML-KEM-768 ciphertext." }
              wrap: { type: string, description: "base64, 48 bytes — wrapped DEK." }
        sig: { type: string, description: "base64, 64 bytes — Ed25519." }
        sig_pq: { type: string, description: "base64, 3309 bytes — ML-DSA-65 (dual senders)." }
    Delivery:
      type: object
      properties:
        delivery_id: { type: string }
        endpoint_id: { type: string }
        signal_id: { type: string }
        strategy_id: { type: string }
        sequence: { type: integer, format: int64 }
        transport: { type: string, enum: [websocket, webhook] }
        status: { type: string, enum: [pending, succeeded, dead, suspended] }
        attempt_count: { type: integer }
        next_retry_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
        attempts:
          type: array
          items:
            type: object
            properties:
              attempt_no: { type: integer }
              replayed: { type: boolean }
              started_at: { type: string, format: date-time }
              duration_ms: { type: integer }
              http_status: { type: integer }
              error_class: { type: string, enum: [http_4xx, http_5xx, timeout, network] }
              response_snippet: { type: string, maxLength: 500 }
    EndpointHealth:
      type: object
      properties:
        endpoint_id: { type: string }
        client_id: { type: string }
        transport: { type: string }
        url: { type: string }
        status: { type: string, enum: [active, paused, disabled] }
        paused_at: { type: string, format: date-time }
        pause_reason: { type: string }
        delivery_counts:
          type: object
          additionalProperties: { type: integer }
