openapi: 3.1.0
info:
  title: fremforge Control-Plane API
  version: 1.0.0
  summary: EU-sovereign CI/CD with Git included — public REST API
  description: |
    The fremforge control-plane REST API. Every admin UI action has a public
    API equivalent — there are no admin-only endpoints. AI agents acting on
    behalf of users consume the same surface as the admin UI and human
    integrators.

    See the README at https://frem.sh/fremforge/api/README for spec conventions,
    versioning policy, and agent usage patterns.

    ## Implementation status (2026-04-27, last updated after the Phase 1.5 cluster ship)

    **Fully shipped — call as documented:**

      - GET /health
      - GET /users/me  (alias /user kept for CLI v0 backward compatibility)
      - POST /auth/tokens
      - GET /auth/tokens
      - DELETE /auth/tokens/{tokenId}
      - POST /auth/token-exchange — OIDC JWT → short-lived ffp_ token (RFC 8693).
      - GET /orgs/{org}
      - PATCH /orgs/{org}
      - GET /orgs/{org}/audit
      - GET /orgs/{org}/exports
      - POST /orgs/{org}/exports
      - GET /orgs/{org}/exports/{jobId}
      - GET /orgs/{org}/findings/secrets
      - GET /orgs/{org}/findings/attestations
      - GET /orgs/{org}/findings/attestations/{attestationId}
      - GET /orgs/{org}/push-protection/overrides
      - GET /orgs/{org}/runners/usage
      - GET /orgs/{org}/runners/federation
      - GET /orgs/{org}/policy
      - PUT /orgs/{org}/policy
      - GET /orgs/{org}/sso
      - GET /orgs/{org}/seats

    **Documented but not yet shipped — return HTTP 501 with `not-implemented-yet`
    Problem Details body, including a pointer to the working alternative**:

      - GET /orgs/{org}/findings/dependencies — Renovate-opened CVE PRs land in
        the customer's Forgejo repo PR list today; a denormalised endpoint
        requires new schema (Phase 1.5 follow-up).
      - GET /orgs/{org}/findings/signed-commits — would require a Forgejo
        live-query layer (Phase 1.5 follow-up).
      - GET /orgs/{org}/audit/integrity — audit-chain integrity is verified
        hourly by a FunctionGraph job (T-Cloud-gated per plan.md).
      - PUT /orgs/{org}/runners/federation — runner OIDC federation is
        platform-wide; per-tenant overrides documented as customer-side IaC
        (Phase 2 follow-up).
      - GET /orgs/{org}/repos/{repo}/policy + PUT — repo-scoped policy lives in
        `.fremforge/*.yml` in the repo today; denormalised storage is Phase 1.5.
      - POST /orgs/{org}/seats + DELETE /orgs/{org}/seats/{userId} — public-REST
        wrapper not yet split out from the SCIM 2.0 endpoint at `/{org}/scim/v2/*`,
        which is the production-ready path today.
      - POST /orgs/{org}/sso + PUT /orgs/{org}/sso — JSON wrappers around the
        Forgejo client's auth-source CRUD; admin-UI HTML form at
        `/{org}/admin/sso` is the working path today.
      - POST /orgs — public-REST org create requires an elevated-auth model;
        signup flow at https://frem.sh/signup is the production-ready path.
      - /orgs/{org}/mandates/* — agent delegated mandates; Phase 2.

    Tracking: fremforge/plan/round-3-audit.md addendum 3 + addendum 4 + the
    Phase 1.5 cluster ship 2026-04-27.
  contact:
    name: fremverk ApS
    email: support@frem.sh
    url: https://frem.sh/trust
  license:
    name: Proprietary (fremverk ApS)
    url: https://frem.sh/terms
  termsOfService: https://frem.sh/terms

servers:
  - url: https://frem.sh/_app/api/v1
    description: Production (fremforge control-plane API; URL scheme Option B — Forgejo at apex `frem.sh`, control-plane served behind the `/_app/` Bunny strip-prefix on the same hostname; URL-scheme finalised 2026-05-11).

tags:
  - name: Health
    description: Unauthenticated liveness probe.
  - name: Authentication
    description: Token issuance and introspection.
  - name: Organisations
    description: Tenant-level configuration.
  - name: Seats
    description: Seat management and prorated billing.
  - name: Policy
    description: Three-level policy hierarchy (platform / org / repo).
  - name: Findings
    description: Secret scan, dependency scan, signed commit, SLSA attestation.
  - name: Runners
    description: Hosted CI runner usage and OIDC federation config.
  - name: Exports
    description: On-demand signed exports of tenant data.
  - name: Audit
    description: Audit log access and integrity verification.
  - name: Mandates
    description: Scoped delegated mandates for AI agents (Phase 2+).
  - name: AI
    description: |
      Customer-callable AI gateway. BYOK at launch — the customer configures
      their own AI vendor at /<org>/_admin/ai-integrations and fremforge proxies
      calls with audit-emit + per-call usage tracking. Customer code never
      sub-processed through fremforge's own AI vendor (no fremforge AI vendor
      exists today; turnkey AIFS-DE is the Phase-2 roadmap). DPA Annex B §B.8.

security:
  - bearerAuth: []
  - oauth2ClientCredentials: []
  - oauth2AgentMandate: []

paths:
  # -------------------------------------------------------------------------
  # Health
  # -------------------------------------------------------------------------
  /health:
    get:
      tags: [Health]
      summary: Unauthenticated liveness probe
      description: |
        Returns 200 when the control-plane API is reachable and the backing
        session store is responsive. Intended for external uptime monitors,
        load balancers, and the status-page SLI probes per the SLA
        three-probe-agreement rule.
      security: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                  version:
                    type: string
                    description: API version identifier.
                  commit:
                    type: string
                    description: Deploy commit identifier.
        "503":
          $ref: "#/components/responses/ProblemDetails"

  # -------------------------------------------------------------------------
  # Authentication
  # -------------------------------------------------------------------------
  /auth/tokens:
    post:
      tags: [Authentication]
      summary: Issue a personal access token (PAT)
      description: |
        Issues a scoped, expirable personal access token for the authenticated
        user. Requires an active admin-UI OIDC session; token scope cannot
        exceed the user's role.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenIssueRequest"
      responses:
        "201":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenIssueResponse"
        "400":
          $ref: "#/components/responses/ProblemDetails"
        "401":
          $ref: "#/components/responses/ProblemDetails"
        "403":
          $ref: "#/components/responses/ProblemDetails"

    get:
      tags: [Authentication]
      summary: List the caller's active tokens
      security:
        - bearerAuth: []
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens:
                    type: array
                    items:
                      $ref: "#/components/schemas/TokenSummary"

  /auth/tokens/{tokenId}:
    delete:
      tags: [Authentication]
      summary: Revoke a personal access token
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/TokenIdParam"
      responses:
        "204":
          description: Revoked

  /users/me:
    get:
      tags: [Authentication]
      summary: Retrieve the authenticated caller's profile and org memberships
      description: |
        Returns the caller's user id, display name, email, the list of
        organisations the caller is a member of with their role in each, and
        — for agent callers — the on-behalf-of user and active mandate scope.

        Intended for client bootstrapping (admin UI, CI integrations, AI
        agents discovering what they can act on) and for verifying that
        the current token is valid against the server's session registry.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMe"
        "401":
          $ref: "#/components/responses/ProblemDetails"

  /auth/token-exchange:
    post:
      tags: [Authentication]
      summary: Exchange an OIDC JWT for a short-lived fremforge access token (with optional OBO)
      description: |
        RFC 8693-shaped token exchange. Accepts a JWT signed by one of:

        - The customer's OIDC IdP, when registered as an `auth_sources`
          entry for the target tenant (the same JWKS already used for
          member sign-in). Wire your IdP from the tenant SSO settings
          page first.
        - Our own runner-OIDC issuer — Forgejo Actions runner steps can
          self-mint a fremforge token without any extra IdP wiring.

        **Audience binding (per-tenant).** The submitted JWT's `aud` MUST
        match either the canonical per-tenant audience
        `https://frem.sh/<tenant-slug>` OR a per-source override
        configured at Org admin → Agent authentication. The legacy
        `fremforge-api` audience remains accepted for back-compat during
        rollout but new integrations should use the per-tenant value —
        it gives strict cross-tenant replay protection.

        **On-behalf-of (RFC 8693).** Pass an `actor_token` alongside the
        `subject_token` to delegate. `subject_token` is the human; the
        `actor_token` is the agent acting on their behalf. The minted
        token's audit events carry `actor=oidc:agent:<sub>` and the
        indexed column `on_behalf_of=<human-sub>`. Both tokens must
        verify against the same `auth_sources` row (single-source v1
        OBO model).

        The response's `access_token` is a regular `ffp_` PAT — pass it
        on subsequent calls as `Authorization: Bearer <token>`. TTL is
        hard-capped at 1 hour. Scope set is the request's `scopes`
        intersected with the source/app allow-list (`*` and
        `tokens:manage` never grantable via this endpoint).

        This endpoint is **unauthenticated** (the JWT is the auth) but
        per-source-IP rate-limited at 30/min — bursts beyond that 429.

        See the [Agent-native authentication](https://docs.frem.sh/security/agent-auth/)
        doc page for the end-to-end setup.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenExchangeRequest"
      responses:
        "201":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenExchangeResponse"
        "400":
          $ref: "#/components/responses/ProblemDetails"
        "403":
          $ref: "#/components/responses/ProblemDetails"
        "404":
          $ref: "#/components/responses/ProblemDetails"
        "429":
          $ref: "#/components/responses/ProblemDetails"
        "503":
          $ref: "#/components/responses/ProblemDetails"

  /oauth/token:
    post:
      tags: [Authentication]
      summary: OAuth 2.0 client_credentials grant (fremforge as IdP)
      description: |
        RFC 6749 §4.4 client_credentials grant. For apps without an IdP
        of their own — tenant admin mints a `(client_id, client_secret)`
        pair at Org admin → Agent authentication, and the app exchanges
        the credentials for a short-lived `ffp_` access token.

        **For OBO / IdP-issued JWTs** use [`/auth/token-exchange`](#operation/POST_authtokenexchange)
        instead — that's where the `actor_token` and the customer-IdP
        verify pipeline live.

        **Caller authentication.** Client credentials are submitted via
        either:

        - `Authorization: Basic base64(client_id:client_secret)` header
          (RFC 6749 §2.3.1 `client_secret_basic`), OR
        - form-encoded `client_id` + `client_secret` body params
          (`client_secret_post`).

        **Audience binding.** Optional `audience` form param. Defaults
        to the per-tenant canonical `https://frem.sh/<tenant-slug>`.
        Custom audiences require an explicit entry in the client's
        Allowed audiences list.

        **Scope** is the requested `scope` (space-separated) intersected
        with the client's configured scope cap. Omitting `scope` grants
        the full cap (RFC 6749 §3.3 server-default).

        TTL is hard-capped at 1 hour. The endpoint is unauthenticated by
        design (the secret IS the auth) but per-IP rate-limited at
        30/min with a key distinct from `/auth/token-exchange` so bursts
        on one don't starve the other.

        See [Agent-native authentication](https://docs.frem.sh/security/agent-auth/)
        for the end-to-end setup.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OAuthTokenRequest"
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthTokenRequest"
      responses:
        "201":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthTokenResponse"
        "400":
          $ref: "#/components/responses/ProblemDetails"
        "401":
          $ref: "#/components/responses/ProblemDetails"
        "415":
          $ref: "#/components/responses/ProblemDetails"
        "429":
          $ref: "#/components/responses/ProblemDetails"

  # -------------------------------------------------------------------------
  # Organisations
  # -------------------------------------------------------------------------
  /orgs:
    post:
      tags: [Organisations]
      summary: Create a new organisation (tenant provisioning)
      description: |
        Self-service signup with credit-card captured upfront via Mollie. The
        organisation starts in 30-day trial state with `plan=trial`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrgCreateRequest"
      responses:
        "201":
          description: Organisation provisioned
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organisation"

  /orgs/{org}:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Organisations]
      summary: Get organisation details
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organisation"

    patch:
      tags: [Organisations]
      summary: Update organisation settings (owner/admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrgUpdateRequest"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organisation"

  /orgs/{org}/sso:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Organisations]
      summary: Get SSO configuration (OIDC or SAML)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SSOConfig"
    put:
      tags: [Organisations]
      summary: Configure SSO (OIDC metadata URL or SAML metadata XML)
      description: |
        Activation requires domain verification — DNS TXT record or
        .well-known/fremforge-verification at the claimed domain.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SSOConfig"
      responses:
        "200":
          description: Configured
        "409":
          $ref: "#/components/responses/ProblemDetails"

  # -------------------------------------------------------------------------
  # Seats
  # -------------------------------------------------------------------------
  /orgs/{org}/seats:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Seats]
      summary: List active seats
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Seat"
    post:
      tags: [Seats]
      summary: Add a seat (prorated invoice via Lago)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SeatAddRequest"
      responses:
        "201":
          description: Seat added
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Seat"

  /orgs/{org}/seats/{userId}:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
      - $ref: "#/components/parameters/UserIdParam"
    delete:
      tags: [Seats]
      summary: Remove a seat
      responses:
        "204":
          description: Removed

  /orgs/{org}/seat-cap:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    put:
      tags: [Seats]
      summary: Bump or shrink the contracted seat cap
      description: |
        Pro-rated upgrade charges the saved Mollie mandate (no interactive
        consent — the mandate is the authorisation). Downgrade issues a
        Dinero credit note for the unused days. Requires `billing:write`
        scope, NOT `seats:write` — the latter only authorises adding
        users within the existing cap. Both are needed for a CI pipeline
        that auto-scales seats: `billing:write` for the cap bump,
        `seats:write` for the subsequent provision call.

        Body must include `expected_current_seat_cap` for optimistic-
        concurrency safety; a double-submit lands the second request
        with a stale expectation and is rejected with 409 (prevents
        double-charging).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_seat_cap, expected_current_seat_cap]
              properties:
                new_seat_cap:
                  type: integer
                  minimum: 1
                  maximum: 1000
                expected_current_seat_cap:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: |
            Updated. Response `kind` is `upgraded` (mandate charged,
            invoice issued), `downgraded` (credit note), or `no_change`
            (new cap == current).
          content:
            application/json:
              schema:
                type: object
                properties:
                  kind:
                    type: string
                    enum: [upgraded, downgraded, no_change]
                  new_seat_cap:
                    type: integer
                  invoice_id:
                    type: string
                  mollie_payment_id:
                    type: string
                  prorated_amount:
                    type: string
                  currency:
                    type: string
        "409":
          description: |
            Stale `expected_current_seat_cap`. Re-read GET `/orgs/{org}/seats`
            to refresh, then retry.

  # -------------------------------------------------------------------------
  # Policy (three-level hierarchy)
  # -------------------------------------------------------------------------
  /orgs/{org}/policy:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Policy]
      summary: Get org-level policy (default for all repos)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrgPolicy"
    put:
      tags: [Policy]
      summary: Update org-level policy (owner/admin only)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrgPolicy"
      responses:
        "200":
          description: Updated

  /orgs/{org}/repos/{repo}/policy:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
      - $ref: "#/components/parameters/RepoParam"
    get:
      tags: [Policy]
      summary: Get effective repo policy (merged platform + org + repo)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EffectivePolicy"
    put:
      tags: [Policy]
      summary: Update repo-level policy
      description: |
        Repo may tighten org policy freely. Repo may loosen org policy only
        if the org has explicitly granted relaxation rights. Any loosening
        is audit-logged and produces a below-baseline indicator visible to
        org owners.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RepoPolicy"
      responses:
        "200":
          description: Updated
        "403":
          description: Cannot loosen org policy without relaxation rights
          $ref: "#/components/responses/ProblemDetails"

  # -------------------------------------------------------------------------
  # Findings
  # -------------------------------------------------------------------------
  /orgs/{org}/findings/secrets:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Findings]
      summary: List secret-scan findings
      parameters:
        - name: severity
          in: query
          schema:
            type: string
            enum: [critical, high, medium, low]
        - name: status
          in: query
          schema:
            type: string
            enum: [open, resolved, allowlisted]
        - $ref: "#/components/parameters/PageParam"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FindingList"

  /orgs/{org}/findings/dependencies:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Findings]
      summary: List dependency-scan findings
      responses:
        "200":
          description: OK

  /orgs/{org}/findings/signed-commits:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Findings]
      summary: List signed-commit verification results
      responses:
        "200":
          description: OK

  /orgs/{org}/findings/attestations:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Findings]
      summary: List SLSA provenance attestations per artifact
      responses:
        "200":
          description: OK

  /orgs/{org}/findings/attestations/{attestationId}:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
      - name: attestationId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Findings]
      summary: Download an SLSA attestation bundle
      responses:
        "200":
          description: OK
          content:
            application/vnd.in-toto+json:
              schema:
                type: object

  /orgs/{org}/push-protection/overrides:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Findings]
      summary: Audit trail of push-protection overrides
      description: |
        Every --push-option=fremforge-override=<justification> use is recorded
        here. Visible to org admins regardless of repo policy settings.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/OverrideRecord"

  # -------------------------------------------------------------------------
  # Runners
  # -------------------------------------------------------------------------
  /orgs/{org}/runners/usage:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Runners]
      summary: Runner minute usage for current billing period
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RunnerUsage"

  /orgs/{org}/runners/federation:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Runners]
      summary: Get OIDC federation configuration for runners to T Cloud
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OIDCFederationConfig"
    put:
      tags: [Runners]
      summary: Configure per-tenant OIDC federation overrides
      description: |
        Upserts the per-tenant federation override row. Two knobs:

        - **`allowed_audiences`** — when non-empty, the issuer rejects
          any token mint whose `aud` is not in this list. Empty array
          resets to platform default (no restriction). Max 16 entries,
          each 1..256 chars.
        - **`sub_claim_template`** — optional Mustache-shaped template
          rendered against `{tenant, repo, branch, ref_type}` at mint
          time. NULL resets to default. Max 256 chars.

        See [docs.frem.sh/runners/oidc-federation/](https://docs.frem.sh/runners/oidc-federation/)
        for worked trust-policy examples on AWS / GCP / Azure / T Cloud.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OIDCFederationConfig"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OIDCFederationConfig"

  # -------------------------------------------------------------------------
  # Exports (on-demand signed)
  # -------------------------------------------------------------------------
  /orgs/{org}/exports:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    post:
      tags: [Exports]
      summary: Initiate an export job
      description: |
        Step-up auth (OIDC prompt=login) required for session-based callers;
        export:read tokens must be org-scoped, 7-day max TTL, single-use per
        job. Rate-limited to 3 per org per 24h. Every initiation triggers an
        email notification to all admins of record.
      security:
        - bearerAuth: [export:read]
      responses:
        "202":
          description: Job accepted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExportJob"
        "429":
          $ref: "#/components/responses/ProblemDetails"

  /orgs/{org}/exports/{jobId}:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
      - name: jobId
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Exports]
      summary: Get export job status; on completion, returns signed OBS URL
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExportJob"

  # -------------------------------------------------------------------------
  # AI gateway (P3-AI-INTEGRATIONS Phase 1, 2026-05-24)
  # -------------------------------------------------------------------------
  /orgs/{org}/ai/complete:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    post:
      tags: [AI]
      summary: Generate a completion against the tenant's configured AI vendor
      description: |
        BYOK gateway. Routes to the AI provider the tenant configured in their
        admin UI (Anthropic, OpenAI, Azure OpenAI, Mistral, Vertex Gemini, or
        any OpenAI-compatible URL). fremforge proxies the call with audit-emit
        + usage tracking; the customer's vendor is the customer's processor,
        not a fremforge sub-processor (DPA Annex B §B.8).

        Shape mirrors OpenAI's `/v1/chat/completions` enough that an OpenAI
        SDK can be pointed at `https://api.frem.sh/api/v1/orgs/<org>/ai` with
        a fremforge PAT carrying the `ai:invoke` scope. Streaming, tool/function
        calling, and embeddings are NOT supported on this endpoint at Phase 1.

        **Soft-fail semantics** — when the configured vendor returns an error,
        the response is RFC-7807 with a status code mapped from the failure
        reason: 503 (no_provider), 502 (invalid_key | vendor_error),
        429 (rate_limited), 402 (over_cap), 504 (timeout). The endpoint never
        throws; workflows can branch on status.
      security:
        - bearerAuth: [ai:invoke]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                messages:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [role, content]
                    properties:
                      role:
                        type: string
                        enum: [system, user, assistant]
                      content:
                        type: string
                        minLength: 1
                model_override:
                  type: string
                  description: |
                    Overrides the model the tenant configured at the feature
                    default level. Vendor-specific format (e.g.
                    `claude-opus-4-7`, `gpt-4o-mini`).
                provider_override:
                  type: string
                  format: uuid
                  description: |
                    Routes the call to a specific configured provider by id
                    instead of the feature default. The provider must belong
                    to the same tenant.
                max_tokens:
                  type: integer
                  minimum: 1
                  maximum: 32000
                temperature:
                  type: number
                  minimum: 0
                  maximum: 2
      responses:
        "200":
          description: Completion successful
          content:
            application/json:
              schema:
                type: object
                required: [text, model, provider_id, tokens_in, tokens_out, latency_ms]
                properties:
                  text:
                    type: string
                  model:
                    type: string
                  provider_id:
                    type: string
                    format: uuid
                  tokens_in:
                    type: integer
                  tokens_out:
                    type: integer
                  latency_ms:
                    type: integer
        "400":
          $ref: "#/components/responses/ProblemDetails"
        "402":
          description: Monthly token cap exceeded (over_cap)
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "429":
          $ref: "#/components/responses/ProblemDetails"
        "502":
          description: Vendor returned an error or the configured key is invalid
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "503":
          description: No AI provider configured for this tenant
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"
        "504":
          description: Vendor request timed out
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/Problem"

  # -------------------------------------------------------------------------
  # Audit
  # -------------------------------------------------------------------------
  /orgs/{org}/audit:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Audit]
      summary: Filtered audit log stream
      parameters:
        - name: actor_kind
          in: query
          description: Filter by actor kind (human, agent, service)
          schema:
            type: string
            enum: [human, agent, service]
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - $ref: "#/components/parameters/PageParam"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditPage"

  /orgs/{org}/audit/integrity:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    get:
      tags: [Audit]
      summary: Verify audit-chain integrity over a time window
      description: |
        Returns the anchored hash and a per-record chain, allowing a consumer
        to independently recompute and verify the chain.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuditIntegrity"

  # -------------------------------------------------------------------------
  # Mandates (Phase 2+ agent-native access)
  # -------------------------------------------------------------------------
  /orgs/{org}/mandates:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
    post:
      tags: [Mandates]
      summary: Issue a scoped delegated mandate for an AI agent
      x-fremforge-phase: 2
      description: |
        Phase 2+ endpoint (not yet implemented at launch; spec present so
        the admin UI and the monolith can be built against the final
        shape). Available only to session-authenticated callers with a
        fresh OIDC prompt=login. Mandate scope cannot exceed the issuing
        user's role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MandateIssueRequest"
      responses:
        "201":
          description: Mandate issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Mandate"

    get:
      tags: [Mandates]
      summary: List active mandates the caller has issued
      x-fremforge-phase: 2
      responses:
        "200":
          description: OK

  /orgs/{org}/mandates/{mandateId}:
    parameters:
      - $ref: "#/components/parameters/OrgParam"
      - name: mandateId
        in: path
        required: true
        schema:
          type: string
    delete:
      tags: [Mandates]
      summary: Revoke a mandate (immediate, no grace period)
      x-fremforge-phase: 2
      responses:
        "204":
          description: Revoked

# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: PAT
      description: Personal access token (PAT) issued via /auth/tokens.
    oauth2ClientCredentials:
      type: oauth2
      description: Machine-to-machine access via OAuth 2.0 client credentials.
      flows:
        clientCredentials:
          tokenUrl: https://frem.sh/api/v1/oauth/token
          scopes:
            org:read: Read organisation data
            org:write: Modify organisation settings
            seats:read: Read seat allocation + utilisation
            seats:write: Provision / deprovision seats within the contracted cap (does NOT charge — see billing:write).
            billing:read: Read billing contract (cap, term, next-charge date, invoice history).
            billing:write: Change billing contract; bumping seat cap charges the saved mandate.
            policy:write: Modify policy at org or repo level
            findings:read: Read findings
            runners:read: List Actions runner jobs + status
            runners:write: Configure runners and federation
            export:read: Initiate and read exports
            audit:read: Read audit log
    oauth2AgentMandate:
      type: oauth2
      description: |
        Phase 2+ agent-identity flow — client credentials + on-behalf-of
        assertion carrying a scoped delegated mandate.
      flows:
        clientCredentials:
          tokenUrl: https://frem.sh/api/v1/oauth/token
          scopes:
            agent:act: Act on behalf of the user who issued the mandate, within mandate scope.

  parameters:
    OrgParam:
      name: org
      in: path
      required: true
      schema:
        type: string
      description: Organisation slug.
    RepoParam:
      name: repo
      in: path
      required: true
      schema:
        type: string
    UserIdParam:
      name: userId
      in: path
      required: true
      schema:
        type: string
    TokenIdParam:
      name: tokenId
      in: path
      required: true
      schema:
        type: string
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1

  responses:
    ProblemDetails:
      description: Error — RFC 7807 Problem Details
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/Problem"
      headers:
        RateLimit:
          $ref: "#/components/headers/RateLimit"
        RateLimit-Policy:
          $ref: "#/components/headers/RateLimit-Policy"
        Retry-After:
          $ref: "#/components/headers/Retry-After"

  headers:
    RateLimit:
      description: |
        IETF draft-ietf-httpapi-ratelimit-headers. Returned on every response.
        Format: `"<quota-policy>";q=<remaining>;r=<reset-seconds>`.
        Example: `RateLimit: "default";q=99;r=30`
      schema:
        type: string
    RateLimit-Policy:
      description: |
        Describes the active rate-limit policy. Format:
        `<limit>;w=<window-seconds>;policy="<name>"`.
        Example: `RateLimit-Policy: 100;w=60;policy="default"`
      schema:
        type: string
    Retry-After:
      description: |
        Returned on 429 responses only. Seconds until the caller may retry,
        per RFC 6585. Example: `Retry-After: 30`
      schema:
        type: integer

  schemas:
    # Auth
    TokenIssueRequest:
      type: object
      required: [name, scopes]
      properties:
        name:
          type: string
          description: Human-readable label for the token
        scopes:
          type: array
          items:
            type: string
        ttl_days:
          type: integer
          minimum: 1
          maximum: 90
          default: 30
      example:
        name: ci-export-token
        scopes: [export:read, audit:read]
        ttl_days: 30

    TokenIssueResponse:
      type: object
      properties:
        token:
          type: string
          description: The raw token; shown once, then discarded server-side (only the hash is stored).
        id:
          type: string
        expires_at:
          type: string
          format: date-time
      example:
        token: fgt_v1_c3RhcnRpbmcgdGhpcyB3aWxsIG5vdCB3b3Jr
        id: 01920d3e-0000-7000-0000-000000000001
        expires_at: "2026-05-27T12:00:00Z"

    TokenSummary:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        scopes:
          type: array
          items:
            type: string
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
      example:
        id: 01920d3e-0000-7000-0000-000000000002
        name: ci-export-token
        scopes: [export:read, audit:read]
        created_at: "2026-04-27T10:00:00Z"
        expires_at: "2026-05-27T10:00:00Z"
        last_used_at: "2026-04-27T11:30:00Z"

    OAuthTokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type:
          type: string
          enum: [client_credentials]
          description: |
            Only `client_credentials` is supported on this endpoint.
            For OIDC JWT exchange (including OBO via `actor_token`)
            use `/auth/token-exchange`.
        client_id:
          type: string
          description: |
            `ffc_`-prefixed identifier, minted at Org admin → Agent
            authentication. May be omitted if the caller supplies HTTP
            Basic auth instead.
        client_secret:
          type: string
          description: |
            `ffs_`-prefixed plaintext, shown once at issuance/rotation.
            sha256 is what's stored. Omit if HTTP Basic auth is used.
        scope:
          type: string
          description: |
            Optional space-separated scope subset. Defaults to the
            client's full configured scope cap (RFC 6749 §3.3).
        audience:
          type: string
          description: |
            Optional audience for the minted token. Defaults to the
            per-tenant canonical `https://frem.sh/<tenant-slug>`.
            Custom values require an explicit entry in the client's
            Allowed audiences list.
      example:
        grant_type: client_credentials
        client_id: ffc_QzVmZWFGNVlqUVlfTm5kdC04WHdRdw
        client_secret: ffs_SDM2Z3hpcjY0SmtZbGtaeHlBSjlpcDgwOTZNNWNDQTU
        scope: "findings:write runners:read"

    UserMe:
      type: object
      description: |
        The authenticated caller's profile. Mirrors the data the admin UI
        loads on sign-in.
      properties:
        user_id:
          type: string
        email:
          type: string
          format: email
        display_name:
          type: string
        established_via:
          type: string
          description: |
            Identifier of the Auth Source that established the current session
            or token — an OIDC/SAML Auth Source ID, `local`, or `break-glass`.
        memberships:
          type: array
          items:
            type: object
            properties:
              org:
                type: string
              role:
                type: string
                enum: [owner, admin, member]
        actor:
          $ref: "#/components/schemas/Actor"
          description: |
            For agent callers: carries kind=agent, id, and on_behalf_of.
            For human callers: kind=human, id=user_id, on_behalf_of=null.
        active_mandate:
          type: object
          nullable: true
          description: |
            Present only for agent callers using an on-behalf-of token.
            Reflects the mandate under which the current request is authorised.
          properties:
            mandate_id:
              type: string
            scope:
              type: array
              items:
                type: string
            expires_at:
              type: string
              format: date-time
      example:
        user_id: 01920d3e-0000-7000-0000-000000000010
        email: alice@acme.example
        display_name: Alice Admin
        established_via: oidc-entra
        memberships:
          - org: acme
            role: owner
        actor:
          kind: human
          id: 01920d3e-0000-7000-0000-000000000010
          on_behalf_of: null
        active_mandate: null

    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token:
          type: string
          description: "`ffp_`-prefixed PAT. Pass as `Authorization: Bearer <token>`."
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Seconds until expiry. Capped at 3600.
        scope:
          type: string
          description: Space-delimited list of granted scopes.
        token_id:
          type: string
          format: uuid
          description: Internal id of the minted token.
        audience:
          type: string
          description: The audience bound to the issued token.
        expires_at:
          type: string
          format: date-time
      example:
        access_token: ffp_QzVmZWFGNVlqUVlfTm5kdC04WHdRdw
        token_type: Bearer
        expires_in: 3600
        scope: "findings:write runners:read"
        token_id: 0191e3b6-0000-7000-0000-000000000001
        audience: "https://frem.sh/acme"
        expires_at: "2026-05-12T13:00:00Z"

    TokenExchangeRequest:
      type: object
      required: [subject_token, tenant_slug, scopes]
      properties:
        subject_token:
          type: string
          description: |
            OIDC JWT to exchange. MUST carry `aud=https://frem.sh/<tenant_slug>`
            (per-tenant canonical) or the per-source override configured at
            Org admin → Agent authentication. Legacy `aud=fremforge-api` is
            still accepted for back-compat. Issuer must be either the
            tenant's registered OIDC `auth_sources` or our own
            runner-OIDC issuer. Recent `exp` required.
          example: eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6ImNpQGV4YW1wbGUuY29tIiwiYXVkIjoiaHR0cHM6Ly9mcmVtLnNoL2FjbWUiLCJleHAiOjE3MDAwMDAwMDB9.sig
        actor_token:
          type: string
          description: |
            **OBO / RFC 8693 on-behalf-of.** When set, `subject_token` is
            the human's IdP token and `actor_token` is the agent acting
            on the human's behalf. Both verify against the SAME
            auth_sources row (single-source v1 OBO model). The minted
            token's audit events carry `actor=oidc:agent:<sub>` and
            `on_behalf_of=<human-sub>` (indexed for compliance queries).
        assertion:
          type: string
          description: |
            Alias for `subject_token` — friendlier for hand-rolled clients
            that don't follow RFC 8693 strictly. If both are set,
            `subject_token` wins.
        tenant_slug:
          type: string
          pattern: "^[a-z][a-z0-9-]{0,62}$"
          description: Org slug the exchanged token will be bound to.
          example: acme
        scopes:
          oneOf:
            - type: array
              items:
                type: string
            - type: string
          description: |
            Scopes to grant. Accept either an array or a space-delimited
            string (RFC 6749 §3.3 shape). Granted scopes are the request
            intersected with the exchangeable allow-list — `*` and
            `tokens:manage` are never grantable here.
          example: [exports:write, exports:read, audit:read]
        name:
          type: string
          maxLength: 128
          description: |
            Optional human-readable label persisted to the issued token's
            `name`. Defaults to `exchanged-<issuer-kind>`.

    TokenExchangeResponse:
      type: object
      required: [access_token, token_type, expires_in]
      properties:
        access_token:
          type: string
          description: "ffp_-prefixed PAT. Pass as `Authorization: Bearer <token>`."
        issued_token_type:
          type: string
          enum: ["urn:ietf:params:oauth:token-type:access_token"]
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Seconds until expiry. Capped at 3600.
        scope:
          type: string
          description: Space-delimited list of scopes granted.
        token_id:
          type: string
          format: uuid
          description: Internal id of the issued token (useful for revocation via DELETE /auth/tokens/{tokenId}).
        expires_at:
          type: string
          format: date-time
      example:
        access_token: ffp_QzVmZWFGNVlqUVlfTm5kdC04WHdRdw
        issued_token_type: "urn:ietf:params:oauth:token-type:access_token"
        token_type: Bearer
        expires_in: 3600
        scope: "exports:write exports:read audit:read"
        token_id: 0191e3b6-0000-7000-0000-000000000001
        expires_at: "2026-05-12T13:00:00Z"

    # Organisations
    OrgCreateRequest:
      type: object
      required: [slug, admin_email]
      properties:
        slug:
          type: string
          description: |
            Unique URL slug used as the tenant's org path segment:
            `frem.sh/<slug>`. Must be 2–39 characters, lowercase
            alphanumeric with internal single hyphens, starting and ending
            with an alphanumeric character. Squatting, trademark lookalike,
            and fuzzy-match protection apply — reserved-name rejections
            return 409 Conflict; soft-review matches return 202 Accepted
            with `status=pending-review`.
          pattern: "^[a-z0-9][a-z0-9-]{0,37}[a-z0-9]$"
          minLength: 2
          maxLength: 39
        legal_name:
          type: string
        admin_email:
          type: string
          format: email
        mollie_mandate_id:
          type: string
      example:
        slug: acme
        legal_name: Acme GmbH
        admin_email: admin@acme.example
        mollie_mandate_id: mdt_h3fjij

    OrgUpdateRequest:
      type: object
      properties:
        legal_name:
          type: string
        vat_number:
          type: string
        billing_email:
          type: string
          format: email
        permit_public_repos:
          type: boolean
          description: Default false; admin may toggle true.
      example:
        legal_name: Acme GmbH
        vat_number: DE123456789
        billing_email: billing@acme.example
        permit_public_repos: false

    Organisation:
      type: object
      properties:
        slug:
          type: string
        legal_name:
          type: string
        plan:
          type: string
          enum: [trial, paid, enterprise-on-demand]
        seat_count:
          type: integer
        permit_public_repos:
          type: boolean
        created_at:
          type: string
          format: date-time
      example:
        slug: acme
        legal_name: Acme GmbH
        plan: paid
        seat_count: 12
        permit_public_repos: false
        created_at: "2026-01-15T09:00:00Z"

    SSOConfig:
      type: object
      required: [kind]
      properties:
        kind:
          type: string
          enum: [oidc, saml]
        oidc:
          type: object
          properties:
            issuer_url:
              type: string
              format: uri
            client_id:
              type: string
            client_secret:
              type: string
        saml:
          type: object
          properties:
            metadata_xml:
              type: string
        domains:
          type: array
          items:
            type: string
          description: Verified via DNS TXT or .well-known/fremforge-verification.
        sso_required:
          type: boolean
          default: false
        break_glass_accounts:
          type: array
          items:
            type: string
      example:
        kind: oidc
        oidc:
          issuer_url: https://login.microsoftonline.com/acme-tenant-id/v2.0
          client_id: fremforge-sso-app
          client_secret: "***"
        domains: [acme.example]
        sso_required: true
        break_glass_accounts: [breakglass@acme.example]

    # Seats
    Seat:
      type: object
      properties:
        user_id:
          type: string
        email:
          type: string
          format: email
        role:
          type: string
          enum: [owner, admin, member]
        assigned_at:
          type: string
          format: date-time
      example:
        user_id: 01920d3e-0000-7000-0000-000000000011
        email: alice@acme.example
        role: owner
        assigned_at: "2026-01-15T09:00:00Z"

    SeatAddRequest:
      type: object
      required: [email, role]
      properties:
        email:
          type: string
          format: email
        role:
          type: string
          enum: [owner, admin, member]
      example:
        email: bob@acme.example
        role: member

    # Policy
    OrgPolicy:
      type: object
      properties:
        secret_scan:
          $ref: "#/components/schemas/SecretScanPolicy"
        dependency_scan:
          $ref: "#/components/schemas/DependencyScanPolicy"
        push_protection:
          $ref: "#/components/schemas/PushProtectionPolicy"
        signed_commits:
          $ref: "#/components/schemas/SignedCommitPolicy"
        slsa:
          $ref: "#/components/schemas/SLSAPolicy"
        permit_repo_relaxation:
          type: boolean
          default: false
      example:
        secret_scan:
          enabled: true
          severity_threshold: high
        dependency_scan:
          enabled: true
          merge_block_severity: critical
        push_protection:
          enabled: true
          override_permitted: true
        signed_commits:
          enabled: false
          require_on_protected_branches: false
        slsa:
          enabled: false
          require_attestation: false
        permit_repo_relaxation: false

    RepoPolicy:
      type: object
      description: |
        Repo-level overrides. Tightens org policy freely; loosens only if
        permit_repo_relaxation=true at org level.
      properties:
        secret_scan:
          $ref: "#/components/schemas/SecretScanPolicy"
        dependency_scan:
          $ref: "#/components/schemas/DependencyScanPolicy"
        push_protection:
          $ref: "#/components/schemas/PushProtectionPolicy"
        signed_commits:
          $ref: "#/components/schemas/SignedCommitPolicy"
        slsa:
          $ref: "#/components/schemas/SLSAPolicy"
      example:
        secret_scan:
          enabled: true
          severity_threshold: critical
        dependency_scan:
          enabled: true
          merge_block_severity: critical
        push_protection:
          enabled: true
          override_permitted: false

    EffectivePolicy:
      type: object
      description: |
        Merged policy (platform floor + org + repo) for the target repo.
        Includes a below_baseline flag if the repo has loosened below
        org defaults.
      properties:
        effective:
          $ref: "#/components/schemas/RepoPolicy"
        platform_floor:
          type: object
          additionalProperties: true
          description: Non-disableable controls (e.g. high-confidence pre-receive secret scan).
        below_baseline:
          type: boolean
      example:
        effective:
          secret_scan:
            enabled: true
            severity_threshold: high
          push_protection:
            enabled: true
            override_permitted: true
        platform_floor:
          secret_scan_pre_receive: true
        below_baseline: false

    SecretScanPolicy:
      type: object
      properties:
        enabled:
          type: boolean
        ruleset_id:
          type: string
        severity_threshold:
          type: string
          enum: [critical, high, medium, low]
        allowlist_patterns:
          type: array
          items:
            type: string
      example:
        enabled: true
        ruleset_id: gitleaks-default
        severity_threshold: high
        allowlist_patterns: ["EXAMPLE_KEY_.*"]

    DependencyScanPolicy:
      type: object
      properties:
        enabled:
          type: boolean
        merge_block_severity:
          type: string
          enum: [critical, high, medium, low, none]
          default: critical
      example:
        enabled: true
        merge_block_severity: critical

    PushProtectionPolicy:
      type: object
      properties:
        enabled:
          type: boolean
        override_permitted:
          type: boolean
          default: true
          description: Whether --push-option=fremforge-override is honoured at this level.
      example:
        enabled: true
        override_permitted: true

    SignedCommitPolicy:
      type: object
      properties:
        enabled:
          type: boolean
        require_on_protected_branches:
          type: boolean
          default: false
      example:
        enabled: true
        require_on_protected_branches: false

    SLSAPolicy:
      type: object
      properties:
        enabled:
          type: boolean
        require_attestation:
          type: boolean
          default: false
      example:
        enabled: false
        require_attestation: false

    # Findings
    FindingList:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Finding"
        next_page:
          type: integer
          nullable: true
      example:
        items:
          - id: 01920d3e-0000-7000-0000-000000000020
            kind: secret
            severity: critical
            repo: acme/api-service
            details:
              rule_id: aws-access-key
              commit: abc1234
              path: config/deploy.env
              line: 12
            detected_at: "2026-04-27T08:15:00Z"
            status: open
        next_page: null

    Finding:
      type: object
      properties:
        id:
          type: string
        kind:
          type: string
          enum: [secret, dependency, signed_commit, slsa]
        severity:
          type: string
          enum: [critical, high, medium, low]
        repo:
          type: string
        details:
          type: object
          additionalProperties: true
        detected_at:
          type: string
          format: date-time
        status:
          type: string
          enum: [open, resolved, allowlisted]
      example:
        id: 01920d3e-0000-7000-0000-000000000020
        kind: secret
        severity: critical
        repo: acme/api-service
        details:
          rule_id: aws-access-key
          commit: abc1234
          path: config/deploy.env
          line: 12
        detected_at: "2026-04-27T08:15:00Z"
        status: open

    OverrideRecord:
      type: object
      properties:
        id:
          type: string
        repo:
          type: string
        commit_sha:
          type: string
        actor:
          $ref: "#/components/schemas/Actor"
        justification:
          type: string
        at:
          type: string
          format: date-time
      example:
        id: 01920d3e-0000-7000-0000-000000000030
        repo: acme/api-service
        commit_sha: abc1234def5678
        actor:
          kind: human
          id: alice
          on_behalf_of: null
        justification: Confirmed test fixture — not a live key
        at: "2026-04-27T08:20:00Z"

    # Runners
    RunnerUsage:
      type: object
      properties:
        period_start:
          type: string
          format: date-time
        minutes_used:
          type: integer
        minutes_allocated:
          type: integer
        concurrent_jobs_peak:
          type: integer
        overage_minutes:
          type: integer
      example:
        period_start: "2026-04-01T00:00:00Z"
        minutes_used: 847
        minutes_allocated: 2000
        concurrent_jobs_peak: 4
        overage_minutes: 0

    OIDCFederationConfig:
      type: object
      properties:
        issuer:
          type: string
          format: uri
          description: Platform-wide issuer URL (read-only — same for every tenant).
        jwks_uri:
          type: string
          format: uri
          description: Platform-wide JWKS endpoint (read-only).
        tenant_org_slug:
          type: string
          description: The tenant this federation config belongs to (read-only).
        allowed_audiences:
          type: array
          items:
            type: string
            minLength: 1
            maxLength: 256
          maxItems: 16
          description: |
            When non-empty, the issuer rejects any token mint whose `aud`
            claim is not in this list. Empty array (or missing field) means
            no audience restriction — the platform default.
        sub_claim_template:
          type: string
          maxLength: 256
          nullable: true
          description: |
            Optional Mustache-shaped template rendered against `{tenant,
            repo, branch, ref_type}` at mint time. NULL resets to default
            (`repo:<owner>/<repo>:ref:<ref>`). Unknown placeholders are
            left literal so misconfigured templates surface at first
            auth-attempt.
      example:
        issuer: https://frem.sh/runner/oidc
        jwks_uri: https://frem.sh/runner/oidc/.well-known/jwks.json
        tenant_org_slug: acme
        allowed_audiences:
          - sts.amazonaws.com
          - sts.eu-west-1.amazonaws.com
        sub_claim_template: "tenant:{{tenant}}:repo:{{repo}}:branch:{{branch}}"

    # Exports
    ExportJob:
      type: object
      properties:
        id:
          type: string
        status:
          type: string
          enum: [queued, running, completed, failed]
        signed_url:
          type: string
          format: uri
          description: Present when status=completed; valid 1h; single-use.
        manifest_hash:
          type: string
        initiated_by:
          $ref: "#/components/schemas/Actor"
        initiated_at:
          type: string
          format: date-time
      example:
        id: 01920d3e-0000-7000-0000-000000000040
        status: completed
        signed_url: "https://obs.eu-de.t-systems.com/fremforge-exports/01920d3e.tar.gz?X-Amz-Signature=abc"
        manifest_hash: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        initiated_by:
          kind: human
          id: alice
          on_behalf_of: null
        initiated_at: "2026-04-27T10:00:00Z"

    # Audit
    AuditPage:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AuditEntry"
        next_page:
          type: integer
          nullable: true
      example:
        items:
          - id: 01920d3e-0000-7000-0000-000000000050
            at: "2026-04-27T10:05:00Z"
            actor:
              kind: human
              id: alice
              on_behalf_of: null
            kind: seat.added
            subject:
              email: bob@acme.example
              role: member
            hash: "sha256:4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d"
        next_page: null

    AuditEntry:
      type: object
      properties:
        id:
          type: string
        at:
          type: string
          format: date-time
        actor:
          $ref: "#/components/schemas/Actor"
        kind:
          type: string
          description: Event kind (e.g. seat.added, policy.changed, export.initiated).
        subject:
          type: object
          additionalProperties: true
        hash:
          type: string
          description: Chained hash linking to the previous entry.
      example:
        id: 01920d3e-0000-7000-0000-000000000050
        at: "2026-04-27T10:05:00Z"
        actor:
          kind: human
          id: alice
          on_behalf_of: null
        kind: seat.added
        subject:
          email: bob@acme.example
          role: member
        hash: "sha256:4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d"

    AuditIntegrity:
      type: object
      properties:
        anchor_hash:
          type: string
        anchored_at:
          type: string
          format: date-time
        entries:
          type: integer
        breaks:
          type: array
          items:
            type: object
            properties:
              at:
                type: string
                format: date-time
              reason:
                type: string
      example:
        anchor_hash: "sha256:4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b"
        anchored_at: "2026-04-27T00:00:00Z"
        entries: 142
        breaks: []

    Actor:
      type: object
      properties:
        kind:
          type: string
          enum: [human, agent, service]
        id:
          type: string
        on_behalf_of:
          type: string
          nullable: true
          description: Present when kind=agent — the user the agent represents.
      example:
        kind: human
        id: alice
        on_behalf_of: null

    # Mandates
    MandateIssueRequest:
      type: object
      required: [agent_id, scope, expires_in_seconds]
      properties:
        agent_id:
          type: string
          description: Identifier the agent uses when authenticating (OAuth client_id).
        scope:
          type: array
          items:
            type: string
          description: |
            Narrow scope grammar — each entry is a bounded permission. Examples:
            - "seats:add:max=3"
            - "policy:write:scope=findings-only"
            - "export:read:max=1"
        expires_in_seconds:
          type: integer
          minimum: 300
          maximum: 604800
          description: Up to 7 days.
        reason:
          type: string
          description: Free text, recorded in audit log.
      example:
        agent_id: fremforge-ci-agent
        scope: ["seats:add:max=3", "export:read:max=1"]
        expires_in_seconds: 86400
        reason: Automated seat provisioning for CI pipeline

    Mandate:
      type: object
      properties:
        id:
          type: string
        agent_id:
          type: string
        issued_by:
          type: string
        issued_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        scope:
          type: array
          items:
            type: string
        revoked:
          type: boolean
          default: false
      example:
        id: 01920d3e-0000-7000-0000-000000000060
        agent_id: fremforge-ci-agent
        issued_by: alice
        issued_at: "2026-04-27T10:00:00Z"
        expires_at: "2026-04-28T10:00:00Z"
        scope: ["seats:add:max=3", "export:read:max=1"]
        revoked: false

    # Error
    Problem:
      type: object
      description: RFC 7807 Problem Details
      properties:
        type:
          type: string
          format: uri
          default: "about:blank"
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string
          format: uri
      example:
        type: "https://docs.frem.sh/api/errors/validation"
        title: Validation Error
        status: 400
        detail: "slug: must be 2–39 lowercase alphanumeric characters"
        instance: "/orgs"
