openapi: 3.1.0
info:
  title: InvariantDB HTTP API
  description: |
    InvariantDB is a bitemporal authorization graph for agentic-AI accountability:
    every AI action leaves a tamper-evident receipt that's evaluatable against the
    policy in force at the moment of access. The HTTP surface is multi-graph
    (per-tenant) with a small set of global routes (`/jobs`, `/info`, `/metrics`,
    `/cluster/*`, `/raft/*`). Every `/graphs/{name}/...` route resolves the engine
    through the `GraphRegistry`.

    See `docs/adr/` for the architecture decisions, `docs/engineering-decisions.md`
    for cross-cutting engineering notes.

    User-facing entry points:
    - `docs/auth.md` — five auth modes (bearer, API key, JWT, HMAC, capability)
    - `docs/sdk.md` — Python + Node SDK quickstart
    - `docs/cypher-reference.md` — supported Cypher subset
    - `docs/MOAT-COOKBOOK.md` — recipes for the compliance + AI-memory procedures
    - `docs/applied-surfaces.md` — documents / suggestions / cases / NLQ / agent
    - `docs/mcp-tools.md` — MCP server tool catalog
    - `docs/compliance.md` — WORM, audit chain, crypto-shred, DSAR
    - `docs/operations/healthchecks.md`, `docs/operations/backup-restore.md`,
      `docs/operations/kek-runbook.md` — operator runbooks

  version: "0.1.0"
  license:
    name: Proprietary

servers:
  - url: http://localhost:3000
    description: Default local server
  - url: https://gq-rust-dev
    description: Dev EC2

tags:
  - name: meta
    description: Health, info, metrics, healthz with cluster state
  - name: cluster
    description: Raft consensus (C2.2 ADR-0053). Bearer token via GQ_REPLICATION_TOKEN. See CLUSTER-RUNBOOK.
  - name: replication
    description: Primary-replica streaming (C2.1 ADR-0052). Bearer token via GQ_REPLICATION_TOKEN.
  - name: graphs
    description: Multi-graph lifecycle
  - name: cypher
    description: Cypher query + execute
  - name: sql
    description: SQL SELECT → Cypher translator (ADR-0041)
  - name: graphql
    description: GraphQL → Cypher translator (ADR-0042)
  - name: documents
    description: Bidirectional graph ↔ JSON template engine (ADR-0043)
  - name: cases
    description: Investigation cases (read from memory graph)
  - name: suggestions
    description: Query optimization suggestions (ADR-0044)
  - name: nlq
    description: Natural language → Cypher (ADR-0046)
  - name: investigate
    description: Grounded Q&A (one-shot + full agent loop ADR-0049)
  - name: agent
    description: C1 agent harness — multi-turn tool calling with provenance (ADR-0049)
  - name: import
    description: Bulk import + async job queue (ADR-0047, ADR-0048)
  - name: jobs
    description: Async job polling
  - name: search
    description: Fulltext + vector search
  - name: schema
    description: Schema CRUD + history
  - name: admin
    description: Operator-only endpoints
  - name: moat-procedures
    description: |
      Compliance + AI-memory primitives exposed as Cypher `CALL` procedures
      and consumed via `/graphs/{name}/cypher`. See `docs/adr/0062-compliance-and-memory-procedures.md`
      for the full catalog. Quick reference:

      Compliance: `db.subjectExport`, `db.destroySubjectKey`,
      `db.proveErasure`, `db.aclEvents`, `db.verifyAclChain`,
      `db.propertyProvenance`, `db.linkDerivedFrom`, `db.derivedFrom`.

      AI memory: `db.recordEpisode`, `db.replayEpisodes`,
      `db.consolidateSession`, `db.sessionSummaries`,
      `db.applySalienceDecay`, `db.reinforceSalience`, `db.recordBelief`,
      `db.reviseBelief`, `db.beliefLineage`, `db.embeddingModelDrift`,
      `gds.semanticMatchExpand`.

      Phase 3 + Phase 4 v0 decision trails (2026-06-15, shipped, NOT
      surfaced to customers per strategy doc gates):
      `db.recordDecision`, `db.exportDecisionTrail`,
      `db.materializeTrainingSet` (Phase 3 — bitemporal training-set
      materialiser; AT RECORDED slicing as of v1);
      `db.registerAgent`, `db.listAgents`, `db.disableAgent`
      (Phase 4 v0 — declarative agent registry; runtime is separate
      service-layer build). See
      `docs/ADR-PHASE-3-4-DECISION-TRAILS-2026-06-15.md`.

      Auth signals consumed by these procedures: `Principal.subject`,
      `Principal.roles`, `Principal.session`, `Principal.purpose`.

      Schema declarations consumed: `properties.{p}.acl.{read,write,purpose}`,
      `properties.{p}.encryption.subject_id_property`,
      `properties.{p}.{type=vector,dim,metric,model,modelVersion,index}`.
      `index` can be `"flat"` (linear scan; default for small labels) or
      `"hnsw"` (sub-linear; auto-picked once a label crosses
      `GQ_HNSW_THRESHOLD`, default 100K).
  - name: cursor
    description: |
      #279 cursor-paginated query reads. POST opens a cursor and
      returns the first batch + a `cursor_id`. GET pulls subsequent
      batches; the cursor auto-releases when drained or after
      ~5 min idle. DELETE releases early.
  - name: cloud-public
    description: |
      Cloud control-plane public entry points (ADR-0078). Auth
      bypassed in `crate::auth::auth_middleware` — signup, verify,
      the public plan catalog, and the Stripe webhook receiver.
      Only mounted when `GQ_CLOUD_ENABLED=1` on the target node.
      See `docs/cloud-launch.md`.
  - name: cloud-me
    description: |
      Cloud self-serve endpoints under `/cloud/me/*`. Cognito JWT
      required. The Cognito `sub` claim is the primary key that
      binds a signed-in user to a tenant row via
      `tenants.cognito_sub`. `POST /cloud/me/bootstrap` creates the
      binding on first login; every subsequent request resolves
      through it. Dashboard flow: bootstrap once, then hit
      `/cloud/me`, `/cloud/me/graphs`, `/cloud/me/api-keys`,
      `/cloud/me/usage`.
  - name: cloud-admin
    description: |
      Cloud operator surface — `/cloud/tenants/*` and
      `/cloud/plans` administration. Requires an admin-role
      credential (root API key). Read + create + revoke against
      any tenant. `DELETE /cloud/tenants/{id}` is IRREVERSIBLE —
      it drops every graph, revokes every key, and flips the
      tenant to `terminated`. Also carries the Stripe billing
      hand-off endpoints (`checkout-session`, `portal-session`)
      and the billing / usage read paths for operator triage.

paths:
  # ──────────────────────── meta ────────────────────────
  /:
    get:
      tags: [meta]
      summary: Health check
      responses:
        "200":
          description: OK
          content:
            text/plain: { schema: { type: string } }
  /info:
    get:
      tags: [meta]
      summary: Engine + build info
      responses:
        "200": { description: OK }
  /engine_info:
    get:
      tags: [meta]
      summary: Engine build + version info (REST alias for MCP tool)
      description: |
        Returns the same JSON shape as the `engine_info` MCP tool.
        REST alias for CLI / SDK clients that prefer HTTP to JSON-RPC 2.0.
        Added 2026-06-26 after a customer-facing 404 on `GET /e/engine_info`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
  /metrics:
    get:
      tags: [meta]
      summary: Prometheus exposition (22+ counters across core, replication, Raft, agent, B2, per-graph budgets — exact counter list grows; treat the count as informational)
      responses:
        "200":
          description: OK
          content:
            text/plain: { schema: { type: string } }
  /mcp:
    post:
      tags: [meta]
      summary: JSON-RPC 2.0 MCP (Model Context Protocol) endpoint — default graph
      description: |
        HTTP transport for Model Context Protocol (MCP) clients (Claude
        Desktop, Claude Code, Cursor). Wire protocol is JSON-RPC 2.0
        (see RFC 8259 §2.4 and https://spec.modelcontextprotocol.io);
        the gq-mcp catalog exposes 14 tools discoverable at runtime via
        the `tools/list` method — clients should prefer that call to
        hard-coding the tool set.

        Notable tools: `engine_info`, `schema_info`, `cypher_query`,
        `flush`, `list_graphs`, `create_graph`, plus the moat wrappers
        (`db.subjectExport`, `db.recordEpisode`, `db.beliefLineage`, …).
        See `docs/mcp-tools.md`.

        This route dispatches every tool call against the process's
        default graph. Multi-graph deployments should point clients at
        the per-graph variant `POST /graphs/{name}/mcp` instead, which
        scopes every downstream tool call to a specific tenant graph.

        Auth: raw API key.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MCPRequest" }
      responses:
        "200":
          description: JSON-RPC 2.0 response (result or error envelope).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MCPResponse" }
  /cypher:
    post:
      tags: [cypher, moat-procedures]
      summary: Run a Cypher query against the process's default graph
      description: |
        Single-engine variant of `POST /graphs/{name}/cypher` for
        deployments that never leave the default graph (dev machines,
        single-tenant appliances, CI). Same request/response shape as
        the graph-scoped route — the `$ref` targets the shared
        `CypherRequest` / `CypherResponse` component schemas.

        `CypherRequest.graph` is honoured when present: the request is
        routed via `GraphRegistry::get(graph)` just as if the caller
        had hit `/graphs/{graph}/cypher`. When absent, the request
        routes to the process's default graph (`GraphRegistry::default`).
        This is the same routing the frontend used from #297 onward.

        Wire-format query string `?format=<tagged|flat|neo4j>` behaves
        identically to the graph-scoped route (Lux 2026-07-09):

          * `tagged` (default) — legacy typed envelopes.
          * `flat` / `neo4j` — Neo4j-style unwrapped scalars +
            `{id, labels, properties}` node/edge shape.

        Auth: raw API key (same header + gate as
        `/graphs/{name}/cypher`).
      parameters:
        - in: query
          name: format
          required: false
          schema: { type: string, enum: [tagged, flat, neo4j] }
          description: "Row-cell wire shape. Omitted → tagged."
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CypherRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CypherResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "499":
          description: |
            Query was cancelled (same semantics as the graph-scoped
            route). Body uses the standard `ErrorBody` envelope with
            `code: ENG_QUERY_CANCELLED`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "503":
          description: |
            Per-graph query-bytes admission gate refused this query.
            See the identical block on `POST /graphs/{name}/cypher`
            for the full response envelope and retry semantics.

  # ─── Single-engine search surface (#370 PR-6, 2026-07-09) ───
  # Single-engine variants of the graph-scoped /graphs/{name}/search/*
  # routes. These target the process's default graph (backed by
  # AppState.engine) and are the surface most single-tenant appliances
  # + CI harnesses hit. Handler pair: `search_fulltext` / `search_vector`
  # / `search_rebuild` in `crates/gq-server/src/handlers.rs`.
  /search/fulltext:
    post:
      tags: [search]
      summary: BM25 fulltext search against the default graph
      description: |
        Single-engine variant of `POST /graphs/{name}/search/fulltext`.
        Runs a BM25 query over the per-label/property persistent
        fulltext index (ADR-0017) held by the process's default graph.

        On first hit for a `(label, property)` pair the handler builds
        (or reuses) the shared `SearchCache` entry via
        `SearchCache::get_or_build_bm25`; subsequent calls are O(query
        term × posting list). The cache is process-wide — not
        per-request — so warm calls skip the index build entirely.

        Also addressable via `CALL gds.fulltextSearch(...)` in Cypher.

        Auth: raw API key.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FulltextRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
  /search/vector:
    post:
      tags: [search]
      summary: Vector KNN search against the default graph
      description: |
        Single-engine variant of `POST /graphs/{name}/search/vector`.
        Returns top-`k` nearest neighbors in the vector index for the
        given `(label, property)` pair on the process's default graph.

        Backend selection matches the graph-scoped route: HNSW when
        the schema declares `properties.{prop}.index = "hnsw"` or the
        label exceeds `GQ_HNSW_THRESHOLD`; Flat otherwise. Warmed on
        first call via `SearchCache::get_or_build_vector`; the
        `dropVectorIndex` hook (ADR-0107 Wave 3) invalidates cached
        entries cross-graph on schema changes.

        Also addressable via `CALL gds.vectorSearch(...)` in Cypher.
        See ADR-0017 + ADR-0058 + ADR-0059.

        Auth: raw API key.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VectorRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResponse" }
        "400":
          description: |
            Bad request. Common causes: unknown `metric` (accepted
            values are `cosine`, `euclidean`, `dot` / `dotProduct`);
            missing required field (`label`, `property`, `query`);
            malformed body. Uses the standard `ErrorBody` envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
  /search/rebuild:
    post:
      tags: [search]
      summary: Clear the process-wide search cache
      description: |
        Drops every cached BM25 + vector index entry held by the
        process's shared `SearchCache`. The next `/search/fulltext`
        or `/search/vector` call for a given `(label, property)` pair
        will rebuild (or reload) the index on demand.

        This is the operator escape hatch when a `dropVectorIndex`
        hook + memtable epoch bump have not converged (rare — the
        cache invalidation path is normally automatic per ADR-0107
        Wave 3). Also useful in CI harnesses that mutate the
        underlying schema between tests.

        No request body. The response is a stable `{cleared: true}`
        envelope — the older draft claim of `{ok: true, rebuilt: […]}`
        is not the actual wire shape.

        Auth: raw API key.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchRebuildResponse" }
  /healthz:
    get:
      tags: [meta]
      summary: K8s-friendly liveness + cluster state probe
      description: |
        Always returns 200 if the process is reachable. Body carries engine
        `next_seq`, current snapshot seq, and (when a `RaftNode` is attached)
        the current leader, term, last_applied, state. Auth-bypassed for
        probe systems. See `docs/operations/healthchecks.md`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "ok" }
                  service: { type: string, example: "graphiquity" }
                  version: { type: string, example: "0.1.0" }
                  engine:
                    type: object
                    properties:
                      graph_dir: { type: string }
                      next_seq: { type: integer, format: int64 }
                      current_snapshot_seq:
                        type: integer
                        format: int64
                        nullable: true
                  raft:
                    type: object
                    properties:
                      enabled: { type: boolean }
                      node_id: { type: integer, format: int64 }
                      addr: { type: string }
                      current_leader:
                        type: integer
                        format: int64
                        nullable: true
                      current_term: { type: integer, format: int64 }
                      last_applied:
                        type: integer
                        format: int64
                        nullable: true
                      state:
                        type: string
                        enum: [Leader, Follower, Candidate, Learner]
                      is_leader: { type: boolean }
                  embedder:
                    type: object
                    description: |
                      Embedding-pipeline visibility surface (ADR-0115 Wave 6,
                      2026-06-30, #286). Emitted unconditionally by every
                      binary since sha256 `37181540…`. The `state` field
                      collapses the two silent-failure classes — wrong Cargo
                      feature flags shipped, catalog file missing/malformed —
                      into one operator-visible signal. See
                      `docs/operations/healthchecks.md` and
                      `GET /admin/embeddings/models` for the full catalog
                      dump.
                    properties:
                      state:
                        type: string
                        enum:
                          - ready
                          - no_provider_features_compiled
                          - no_default_model
                          - default_unavailable
                      default_model:
                        type: string
                        nullable: true
                        description: "Name of the catalog `default: true` entry, or null when no default is present."
                      providers_compiled:
                        type: array
                        description: "Mirrors `gq_embed::compiled_providers()` — the set of provider Cargo features linked into this binary."
                        items:
                          type: string
                          enum: [local_onnx, openai, voyage, cohere, custom_http]
                      catalog_path:
                        type: string
                        description: "Resolved `GQ_EMBEDDING_CATALOG_PATH` (default `/var/lib/graphiquity-v2/embedding_models.json`)."
                      catalog_loaded:
                        type: boolean
                        description: "`true` iff the resolved catalog file exists on disk. `false` means the process silently fell back to the builtin minilm-l6-v2 entry — this is the only wire signal for that fallback."
                      catalog_entry_count:
                        type: integer
                        format: int64
                        minimum: 0
                        description: "Total entries in the catalog (enabled + disabled)."
                      enabled_entry_count:
                        type: integer
                        format: int64
                        minimum: 0
                        description: "Entries whose gating env var (e.g. `OPENAI_API_KEY`) resolved at boot."

  # ──────────────────────── cluster / Raft (ADR-0053) ────────────────
  /cluster/init:
    post:
      tags: [cluster]
      summary: Bootstrap a single-voter Raft cluster on this node
      security: [{ replicationToken: [] }]
      responses:
        "200": { description: OK — node 1 is now leader }
        "404": { description: Raft not configured on this node }
  /cluster/add-learner:
    post:
      tags: [cluster]
      summary: Add a non-voting replica (learner)
      security: [{ replicationToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [node_id, addr]
              properties:
                node_id: { type: integer, format: int64, example: 2 }
                addr:    { type: string, example: "http://10.0.1.22:3000" }
      responses:
        "200": { description: Learner added; replication starts }
        "503": { description: Not the leader; retry against current_leader }
  /cluster/change-membership:
    post:
      tags: [cluster]
      summary: Replace the voting set (joint-consensus transition)
      security: [{ replicationToken: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [members]
              properties:
                members:
                  type: array
                  items: { type: integer, format: int64 }
                  example: [1, 2, 3]
      responses:
        "200": { description: OK }
        "503": { description: Not the leader }
  /cluster/info:
    get:
      tags: [cluster]
      summary: Current term, leader, membership, state
      security: [{ replicationToken: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  node_id: { type: integer, format: int64 }
                  current_leader: { type: integer, format: int64 }
                  current_term: { type: integer, format: int64 }
                  last_applied: { type: integer, format: int64 }
                  state: { type: string, example: "Leader" }
                  membership: { type: object }
  /cluster/read:
    get:
      tags: [cluster]
      summary: Linearizable read barrier — succeeds only on the leader
      description: |
        Returns success only after this node confirms it sees the leader's
        latest committed state. Issue before a read that must reflect a
        recent client_write. Followers return 503 with leader addr in body.
      security: [{ replicationToken: [] }]
      responses:
        "200":
          description: Linearizable read barrier passed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  node_id: { type: integer, format: int64 }
                  read_log_id:
                    type: object
                    nullable: true
                    properties:
                      term: { type: integer, format: int64 }
                      index: { type: integer, format: int64 }
        "503": { description: Not the leader; retry against current_leader }
  /cluster/write:
    post:
      tags: [cluster]
      summary: Submit a write through Raft (explicit Raft write path)
      description: |
        Body is a raw bincode-encoded `MutationBatch` (same format as C2.1
        replication stream + Raft AppRequest). Returns `applied_seq` only
        after majority commit AND local state-machine apply. The bitemporal
        guarantee: when you see `applied_seq`, that seq is durably replicated.

        `/cypher` mutations are refused when Raft is enabled (silent-divergence
        safety); use this endpoint for any write that must be replicated.
      security: [{ replicationToken: [] }]
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "200":
          description: Write committed + applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  applied_seq: { type: integer, format: int64 }
        "400": { description: Failed to decode MutationBatch }
        "503": { description: Not the leader }

  # ──────────────────────── replication / Raft transport ──────────────
  /raft/snapshot-blob:
    get:
      tags: [cluster]
      summary: Stream the leader's engine snapshot as a gzipped tarball
      description: |
        Side-channel snapshot transfer for far-behind learners whose log
        position has been purged. Operator flow in CLUSTER-RUNBOOK §6.
      security: [{ replicationToken: [] }]
      responses:
        "200":
          description: snapshot.tgz
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
  /raft/append-entries:
    post:
      tags: [cluster]
      summary: Raft AppendEntries RPC (inter-node only)
      security: [{ replicationToken: [] }]
      responses:
        "200": { description: OK }
  /raft/vote:
    post:
      tags: [cluster]
      summary: Raft RequestVote RPC (inter-node only)
      security: [{ replicationToken: [] }]
      responses:
        "200": { description: OK }
  /raft/install-snapshot:
    post:
      tags: [cluster]
      summary: Raft InstallSnapshot RPC (inter-node, metadata only — see /raft/snapshot-blob for engine data)
      security: [{ replicationToken: [] }]
      responses:
        "200": { description: OK }
  /graphs/{name}/_replicate/stream:
    get:
      tags: [replication]
      summary: C2.1 — NDJSON stream of MutationBatches after a watermark
      security: [{ replicationToken: [] }]
      parameters:
        - in: path
          name: name
          required: true
          schema: { type: string }
        - in: query
          name: after_seq
          schema: { type: integer, format: int64, default: 0 }
      responses:
        "200":
          description: NDJSON of BatchEnvelopes
          content:
            application/x-ndjson:
              schema: { type: string }

  # ──────────────────────── graphs lifecycle ────────────────────────
  /graphs:
    get:
      tags: [graphs]
      summary: List graphs known to the registry
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: string }
    post:
      tags: [graphs]
      summary: Create a new graph
      description: |
        **Customer-key-gated self-serve graph creation.** Accepts a raw API key
        bearer (`gq_…`) — no admin scope required. The graph is created empty
        and inherits the caller's tenant scope from the API key's `graphs`
        allow-list (if the key is scoped to specific graphs and this one isn't
        in the allow-list, the create still succeeds but the caller won't be
        able to write to it with the same key — you'll need to mint a new key
        that includes the new graph in its scope, via
        `POST /me/api-keys { graphs: [...] }`).

        Cognito-JWT callers are refused with HTTP 403 and directed to
        `POST /cloud/me/graphs` — that route auto-prefixes the graph name
        with the caller's tenant sub for isolation. Use `/cloud/me/graphs`
        from the dashboard UI flow; use `POST /graphs` from CLI/SDK/direct
        HTTP with an API key.

        Documented for Lux feedback #4 (2026-07-09). Prior confusion:
        `POST /graphs/{name}` (with name in path, no body) → 404, wrong shape;
        `POST /admin/graphs` → 503, wrong route (admin route family is
        `/admin/graphs/{name}/budget` etc., not a collection).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  pattern: "^[a-zA-Z0-9_-]{1,64}$"
                  example: "my_dev_graph"
      responses:
        "200":
          description: Created (or already-exists idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  current_snapshot_seq: { type: integer, nullable: true }
                  next_seq: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403":
          description: |
            Cognito-JWT callers must use `POST /cloud/me/graphs` instead —
            that route enforces tenant-prefix isolation. Raw-API-key callers
            never hit this branch.
  /graphs/{name}:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [graphs]
      summary: Graph info (snapshot seq, label list)
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [graphs]
      summary: Drop a graph (destructive)
      responses:
        "200": { description: Dropped }
        "404": { $ref: "#/components/responses/NotFound" }

  /graphs/{name}/rename:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [graphs]
      summary: Rename a graph in place (sync default, async on `wait=false`)
      description: |
        Renames `{name}` on disk to `newName` and migrates the
        per-graph cache entries (engine handle, cypher semaphore,
        access stamps). Pure directory operation; no storage-layer
        changes. Atomic on POSIX and within-volume on Windows.

        JWT-authenticated callers must own both the source name AND
        the destination name under their tenant prefix; otherwise
        Forbidden.

        **Sync / async selection.** Controlled by the `wait` query
        param:

          * `wait=true` (default) — blocks the request until the
            rename lands (Engine::drop join + directory rename +
            cache migration) and returns `200` with the
            `RenameJobSummary` body. This preserves the #283 sync
            contract **byte-identical**; existing clients that omit
            `wait` see no behavior change.
          * `wait=false` — enqueues the rename as a background job,
            returns `202` immediately with a job envelope, and the
            caller polls `GET /jobs/{jobId}` (see `JobRecord`) for
            state transitions (`pending` → `running` →
            `succeeded` | `failed`). The final `JobRecord.result`
            carries the same `RenameJobSummary` shape as the sync
            body. Use this for large busy graphs where
            `Engine::drop` may take multiple seconds to quiesce
            in-flight writers.

        Added 2026-07-02 for the async branch of #287 (rename-slow
        operator decision — layer-2 opt-in). The layer-1
        Engine::drop spawn-drop fix shipped in #287 first; this
        async branch layers on top so callers who *want* to block
        still can, and callers who don't can move on.
      parameters:
        - name: wait
          in: query
          required: false
          description: |
            When `true` (default) the request blocks until the rename
            completes and returns `200` with `RenameJobSummary`.
            When `false` the rename runs in the background and the
            server returns `202` with a job envelope; poll
            `GET /jobs/{jobId}` for the final `JobRecord`.
          schema:
            type: boolean
            default: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [newName]
              properties:
                newName:
                  type: string
                  description: "New graph name (1..=64 chars, `[a-zA-Z0-9_-]+`, not a reserved word)."
      responses:
        "200":
          description: |
            Renamed synchronously (`wait=true`, the default). Body is
            the new graph's summary — byte-identical to the #283
            sync contract.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RenameJobSummary" }
        "202":
          description: |
            Rename accepted and running in the background
            (`wait=false`). Poll `href` (or equivalently
            `GET /jobs/{jobId}`) until `status` transitions out of
            `pending`; the final `JobRecord.result` carries the same
            `RenameJobSummary` shape as the sync `200` body.
          content:
            application/json:
              schema:
                type: object
                required: [jobId, status, href]
                properties:
                  jobId:
                    type: string
                    description: "Opaque job id — pass to `GET /jobs/{jobId}`."
                  status:
                    type: string
                    enum: [pending]
                    description: "Initial job state at accept time."
                  href:
                    type: string
                    description: |
                      Relative URL for the job-poll endpoint (i.e.
                      `/jobs/{jobId}`). Convenience so clients don't
                      have to build the path themselves; the shape at
                      that URL is `JobRecord`.
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { description: "Forbidden (JWT caller crossing tenant prefix)." }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Destination name already exists." }

  # ──────────────────────── cypher / execute / flush ────────────────────────
  /graphs/{name}/cypher:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cypher, moat-procedures]
      summary: Run a Cypher query against the named graph
      description: |
        Accepts a Cypher query string + optional parameters. Also the
        entry point for all `CALL db.*` and `CALL gds.*` procedures —
        including the moat suite (`db.subjectExport`,
        `db.destroySubjectKey`, `db.aclEvents`, `db.recordEpisode`,
        `db.replayEpisodes`, `db.consolidateSession`, `db.recordBelief`,
        `db.reviseBelief`, `db.beliefLineage`, `db.linkDerivedFrom`,
        `db.derivedFrom`, `gds.semanticMatchExpand`, etc).

        Each procedure runs under the caller's `Principal` (role + purpose
        + session) so ACL gating, purpose binding, and per-session
        sub-tenancy apply automatically. See
        `docs/adr/0062-compliance-and-memory-procedures.md`.

        **Procedure discovery:** `CALL db.listProcedures()` returns the
        live catalog (`name`, `signature`, `description`) — clients
        should prefer this over hard-coding the list (added 2026-06-29
        / ADR-0107). New on the same wave: `db.relabel(oldLabel,
        newLabel, where?)` (atomic node relabel with kwarg-WHERE
        filtering, ADR-0108) and `db.dropVectorIndex(label, prop)`
        (clears the VectorCache and triggers cross-graph search-cache
        eviction via the registered drop hook, ADR-0109).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CypherRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CypherResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "499":
          description: |
            Query was cancelled (operator kill via
            `POST /admin/queries/{id}/kill`, or client disconnect after
            the executor checked the cancel token). HTTP 499 follows the
            nginx "Client Closed Request" convention. Body uses the
            standard `ErrorBody` envelope with
            `code: ENG_QUERY_CANCELLED`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "503":
          description: |
            Per-graph query-bytes admission gate refused this query
            (ADR-0105 Phase 3c, shipped 2026-06-29 / #282). The target
            graph's working-set bytes (resident + in-flight per-query
            materialised) already exceed the resolved per-graph budget
            (file `{graph_dir}/per_graph_budget.json` > env
            `GQ_PER_GRAPH_QUERY_BUDGET` > hardcoded 8 GiB). The body
            uses a STANDALONE envelope (`PerGraphBudgetRefusal`), not
            the wave-2 `ErrorBody` — `code` is the bare
            `PER_GRAPH_QUERY_BUDGET` literal, not an `ENG_*` value.
            Retry against the budget admin endpoint
            (`/admin/graphs/{name}/budget`) or against the leader
            after `Retry-After` seconds.

            Note: read-side backpressure (host out of evictable
            inventory) ALSO surfaces here via the wave-2 envelope with
            `code: ENG_BACKPRESSURE`; clients should branch on `code`
            to disambiguate the two refusal classes.

            **New upstream cause as of 2026-06-30 / ADR-0113 / #286
            Wave 4**: `EmbeddingError::RateLimited` raised by an
            embedding provider (OpenAI / Voyage / Cohere / custom-http)
            during a `CALL db.recordEpisode(…)` or
            `CALL db.recordBelief(…)` write now surfaces on this same
            wave-2 envelope with `code: ENG_BACKPRESSURE` and the
            provider's hint in `retry_after_ms`. A second new
            sub-cause from Wave 4 is `EmbeddingError::ModelMismatch`
            — fired when the resolved embedder for a `(label, prop)`
            differs from the model pinned in
            `derived/vector_index_locks/{label}.{prop}.lock.json`
            from an earlier write; same `ENG_BACKPRESSURE` code, with
            `details: {existing, requested}` carrying the two model
            names. Recovery is one Cypher call —
            `CALL db.dropVectorIndex(label, prop)` clears the lock
            file + the SearchCache; the next write re-locks under
            the current default model. The "skip-and-log" failure
            modes (no embedder configured, model unavailable) do NOT
            surface here — those return 200 OK with no `embedding`
            property on the produced node. See RUNBOOK §2.6.2 for
            the full taxonomy.
          headers:
            Retry-After:
              schema: { type: integer }
              description: "Seconds (currently always 30 for PER_GRAPH_QUERY_BUDGET)."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PerGraphBudgetRefusal"
                  - $ref: "#/components/schemas/ErrorBody"
  /graphs/{name}/mcp:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [meta]
      summary: JSON-RPC 2.0 MCP endpoint scoped to a named graph
      description: |
        Graph-scoped variant of `POST /mcp`. Every tool invocation
        (`tools/call`) resolves against the engine returned by
        `GraphRegistry::get(name)`, so multi-graph deployments can point
        Claude Desktop / Claude Code / Cursor at a specific tenant graph
        without changing tool arguments.

        Wire protocol: JSON-RPC 2.0 request/response over HTTP POST.
        See RFC 8259 §2.4 and https://spec.modelcontextprotocol.io for
        the underlying protocol. The gq-mcp catalog exposes 14 tools —
        discover them at runtime with the JSON-RPC `tools/list` method
        (no server-side hard-coding required). Notable tools:
        `engine_info`, `schema_info`, `cypher_query`, `flush`,
        `list_graphs`, `create_graph`, plus the moat wrappers
        (`db.subjectExport`, `db.recordEpisode`, `db.beliefLineage`, …).

        Auth: raw API key (same header + gate as
        `/graphs/{name}/cypher`). The MCP session inherits the caller's
        `Principal` (role + purpose + session) so every downstream
        `CALL db.*` runs under the caller's ACL scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MCPRequest" }
      responses:
        "200":
          description: JSON-RPC 2.0 response (result or error envelope).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MCPResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/execute:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cypher]
      summary: Execute a batch of pre-resolved atomic CmdOps
      responses:
        "200": { description: OK }
        "503":
          description: |
            Per-graph query-bytes admission gate refused this batch
            (ADR-0105 Phase 3c / #282, shipped 2026-06-29). Same
            envelope + headers as `POST /graphs/{name}/cypher` 503
            above.
          headers:
            Retry-After:
              schema: { type: integer }
              description: "Seconds (currently always 30 for PER_GRAPH_QUERY_BUDGET)."
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/PerGraphBudgetRefusal"
                  - $ref: "#/components/schemas/ErrorBody"
  /graphs/{name}/cursor:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cursor]
      summary: Open a cursor over a Cypher query
      description: |
        Runs the query, parks the row buffer in the server's cursor
        registry, and returns the first batch plus a `cursor_id` the
        client can use to pull additional batches via
        `GET /graphs/{name}/cursor/{cursor_id}`. The `batch_size`
        defaults to 1000 and is capped at 10_000.

        Mutation queries are refused with 400 — cursors are read-only.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, description: "Cypher query (read-only)" }
                parameters:
                  type: object
                  description: "Bind values for `$placeholder` parameters."
                batch_size:
                  type: integer
                  minimum: 1
                  maximum: 10000
                  description: "First-batch size; default 1000."
      responses:
        "200":
          description: Cursor opened + first batch returned
          content:
            application/json:
              schema:
                type: object
                properties:
                  cursor_id: { type: string }
                  columns: { type: array, items: { type: string } }
                  batch: { type: array, items: { type: array, items: {} } }
                  has_more: { type: boolean }
                  warnings: { type: array, items: { type: object } }
                  stats: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/cursor/{cursor_id}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: cursor_id, in: path, required: true, schema: { type: string } }
    get:
      tags: [cursor]
      summary: Pull the next batch
      parameters:
        - name: batch
          in: query
          schema: { type: integer, minimum: 1, maximum: 10000 }
      responses:
        "200":
          description: Next batch (auto-releases the cursor when `has_more=false`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  batch: { type: array, items: { type: array, items: {} } }
                  has_more: { type: boolean }
        "404":
          description: Cursor not found (expired, closed, or wrong graph)
    delete:
      tags: [cursor]
      summary: Release a cursor early (idempotent)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  released: { type: boolean }
  /graphs/{name}/flush:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cypher]
      summary: Force-flush memtable to a new snapshot
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  snapshotSeq: { type: integer }
  /graphs/{name}/warmup:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cypher]
      summary: |
        Pre-warm page cache for hot adjacencies (#257). Avoids the 22s cold-
        start penalty on a fresh process that has the snapshot mmapped but
        none of its pages resident.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                edge_types:
                  type: array
                  items: { type: string }
                  description: Edge types to walk. Empty = walk all known types.
                labels:
                  type: array
                  items: { type: string }
                  description: Node labels to prefetch. Empty = walk all known labels.
      responses:
        "200":
          description: WarmupReport with per-type / per-label byte counts touched
          content:
            application/json:
              schema:
                type: object
                properties:
                  edge_types: { type: array, items: { type: object } }
                  labels: { type: array, items: { type: object } }
                  duration_ms: { type: integer }

  # ──────────────────────── SQL / GraphQL ────────────────────────
  /graphs/{name}/sql:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [sql]
      summary: Translate SQL SELECT → Cypher, execute, return rows
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query:
                  type: string
                  example: "SELECT name, age FROM Person WHERE age > 30 LIMIT 10"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  cypher: { type: string }
                  from_label: { type: string }
                  joins: { type: array, items: { type: string } }
                  columns: { type: array, items: { type: string } }
                  rows: { type: array, items: { type: array } }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/graphql:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [graphql]
      summary: Run a GraphQL query — translates each top-level selection to Cypher
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query:
                  type: string
                  example: "{ persons(limit: 5) { name age } }"
                meta:
                  type: object
                  description: "Optional override for the engine-derived meta."
                schema:
                  type: object
                  description: "Optional caller-supplied schema for property typing."
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: object }

  # ──────────────────────── Documents ────────────────────────
  /graphs/{name}/documents:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [documents]
      summary: List document templates
      responses:
        "200": { description: OK }
    post:
      tags: [documents]
      summary: Save (upsert) a document template
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SaveTemplateRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SavedTemplate" }
  /graphs/{name}/documents/auto:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [documents]
      summary: Infer template from sample JSON + decompose + execute
      parameters:
        - name: label
          in: query
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200": { description: OK }
  /graphs/{name}/documents/{templateName}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { $ref: "#/components/parameters/TemplateName" }
    get:
      tags: [documents]
      summary: List documents (feed) for a template's root label
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100 }
        - name: atValid
          in: query
          schema: { type: string }
        - name: atRecorded
          in: query
          schema: { type: string }
      responses:
        "200": { description: OK }
    post:
      tags: [documents]
      summary: Decompose JSON document → graph mutations, execute
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  operations: { type: integer }
                  succeeded: { type: integer }
                  failed: { type: integer }
                  details: { type: array }
    delete:
      tags: [documents]
      summary: Remove a template
      responses:
        "200": { description: Removed }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/documents/{templateName}/{id}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { $ref: "#/components/parameters/TemplateName" }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [documents]
      summary: Materialize a document (or explain the plan)
      parameters:
        - name: explain
          in: query
          schema: { type: string, enum: ["true"] }
        - name: atValid
          in: query
          schema: { type: string }
        - name: atRecorded
          in: query
          schema: { type: string }
        - name: fields
          in: query
          description: "Comma-separated top-level field whitelist."
          schema: { type: string }
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/documents/{templateName}/{id}/diff:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { $ref: "#/components/parameters/TemplateName" }
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [documents]
      summary: Bitemporal diff of a document between two points in time
      parameters:
        - name: from
          in: query
          schema: { type: string }
        - name: to
          in: query
          schema: { type: string }
        - name: fromRecorded
          in: query
          schema: { type: string }
        - name: toRecorded
          in: query
          schema: { type: string }
      responses:
        "200": { description: OK }

  # ──────────────────────── Cases ────────────────────────
  /graphs/{name}/cases:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [cases]
      summary: List investigation cases targeting this graph
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
  /graphs/{name}/cases/{caseId}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: caseId, in: path, required: true, schema: { type: string } }
    get:
      tags: [cases]
      summary: Full case detail (session + chronological event stream)
      parameters:
        - name: asOf
          in: query
          description: "Replay the case at a past AT RECORDED timestamp."
          schema: { type: string }
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }

  # ──────────────────────── Suggestions ────────────────────────
  /graphs/{name}/suggestions:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [suggestions]
      summary: List the per-graph suggestion ledger
      responses:
        "200": { description: OK }
  /graphs/{name}/suggestions/analyze:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [suggestions]
      summary: Analyze a Cypher query and merge hits into the ledger
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
                resultCount: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { type: object }
  /graphs/{name}/suggestions/{key}/apply:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: key, in: path, required: true, schema: { type: string } }
    post:
      tags: [suggestions]
      summary: Apply a suggestion (create-index runs the CREATE INDEX)
      responses:
        "200": { description: OK }
        "404": { $ref: "#/components/responses/NotFound" }

  # ──────────────────────── NLQ ────────────────────────
  /graphs/{name}/nlq:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [nlq]
      summary: Natural-language question → Cypher (LLM-backed, schema-pinned)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string }
                mode:
                  type: string
                  enum: [query, explain]
                  description: "explain = dry-run (return generated Cypher + validation)"
                meta: { type: object }
                schema: { type: object }
                provider:
                  type: string
                  enum: [anthropic, openai, claude, gpt]
                apiKey: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NlqResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }

  # ──────────────────────── Investigate / Agent ────────────────────────
  /graphs/{name}/investigate:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [investigate]
      summary: One-shot grounded Q&A (no tool loop)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string }
                context_query: { type: string }
                provider: { type: string }
                api_key: { type: string }
                meta: { type: object }
                schema: { type: object }
      responses:
        "200": { description: OK }
  /graphs/{name}/agent:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [agent]
      summary: Full multi-turn agent loop with tool calling + provenance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties:
                question: { type: string }
                provider:
                  type: string
                  enum: [anthropic, openai, claude, gpt]
                apiKey: { type: string }
                model: { type: string }
                systemPrompt: { type: string }
                userId: { type: string }
                caps: { $ref: "#/components/schemas/Caps" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionId: { type: string }
                  answer: { type: string }
                  stats: { $ref: "#/components/schemas/RunStats" }
                  polling: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }

  # ──────────────────────── Import / S3 / Jobs ────────────────────────
  /graphs/{name}/import:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [import]
      summary: Enqueue an async import job (returns 202)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [operations]
              properties:
                operations:
                  type: array
                  items: { type: object }
                  maxItems: 50000
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImportAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/import/upload-url:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [import]
      summary: Get a presigned S3 PUT URL for a direct upload
      parameters:
        - name: bucket
          in: query
          schema: { type: string }
        - name: region
          in: query
          schema: { type: string }
        - name: contentType
          in: query
          schema: { type: string }
        - name: key
          in: query
          schema: { type: string }
        - name: expiresInSecs
          in: query
          schema: { type: integer }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
                  bucket: { type: string }
                  key: { type: string }
                  expiresInSecs: { type: integer }
                  region: { type: string }
  /graphs/{name}/import/from-s3:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [import]
      summary: Download an S3 object + enqueue an import job
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                bucket: { type: string }
                region: { type: string }
                key: { type: string }
      responses:
        "202": { description: Accepted }
        "400": { $ref: "#/components/responses/BadRequest" }
  /jobs:
    get:
      tags: [jobs]
      summary: List jobs
      parameters:
        - name: state
          in: query
          schema: { type: string, enum: [pending, running, completed, failed] }
        - name: graph
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500 }
      responses:
        "200": { description: OK }
  /jobs/{jobId}:
    parameters:
      - { name: jobId, in: path, required: true, schema: { type: string } }
    get:
      tags: [jobs]
      summary: Job status poll (status + progress + flattened result fields)
      description: |
        Returns the current `JobRecord` for the given job id. Used by
        callers of async endpoints (e.g. `POST /graphs/{name}/rename?wait=false`
        with the `RenameJobSummary` result shape, `POST /admin/embeddings/backfill`,
        `POST /graphs/{name}/import/*`) to poll state transitions.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobRecord" }
        "404": { $ref: "#/components/responses/NotFound" }
  /jobs/{jobId}/result:
    parameters:
      - { name: jobId, in: path, required: true, schema: { type: string } }
    get:
      tags: [jobs]
      summary: Full job record (state, progress, result blob)
      responses:
        "200": { description: OK }

  # ──────────────────────── Search ────────────────────────
  /graphs/{name}/search/fulltext:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [search]
      summary: BM25 fulltext search
      description: |
        Returns top-`k` matches ranked by BM25 score. Uses the
        per-label/property persistent index built on compaction
        (ADR-0017). The bitemporal-cut cache (`bm25/<sha>.bm25`) gives
        immortal answers for past `t_r`. Also addressable via
        `CALL gds.fulltextSearch(...)` in Cypher.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, property, query]
              properties:
                label: { type: string }
                property: { type: string }
                query: { type: string }
                k: { type: integer, minimum: 1, maximum: 1000, default: 25 }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  hits:
                    type: array
                    items:
                      type: object
                      properties:
                        nodeId: { type: integer, format: int64 }
                        strId: { type: string }
                        score: { type: number, format: float }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/search/vector:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [search]
      summary: Vector search (Flat or HNSW, chosen by schema + label size)
      description: |
        Returns top-`k` nearest neighbors. Backend is auto-selected: HNSW
        if the schema declares `properties.{prop}.index = "hnsw"` or the
        label exceeds `GQ_HNSW_THRESHOLD` (default 100k); Flat otherwise.
        Also addressable via `CALL gds.vectorSearch(...)` in Cypher.
        See ADR-0017 + ADR-0058 + ADR-0059.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, property, vector]
              properties:
                label: { type: string }
                property: { type: string }
                vector:
                  type: array
                  items: { type: number, format: float }
                k: { type: integer, minimum: 1, maximum: 1000, default: 10 }
                metric:
                  type: string
                  enum: [cosine, euclidean, dot]
                  default: cosine
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  hits:
                    type: array
                    items:
                      type: object
                      properties:
                        nodeId: { type: integer, format: int64 }
                        strId: { type: string }
                        distance: { type: number, format: float }
                  backend:
                    type: string
                    enum: [flat, hnsw]
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/memory/recall:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [search]
      summary: One-shot agent-memory recall (BM25 + hop expand + salience)
      description: |
        Single round-trip recall primitive for agent-memory workloads.
        Pipeline: BM25 over `(label, property)` → take top `k * 3`
        candidates → expand each candidate by `hops` outgoing and
        incoming neighbors across every known edge type (capped at
        50 expansions per candidate) → score combines BM25, salience
        (`1 + salience_weight * salience`) and a 1/(1+days/30) recency
        factor → return top `k`.

        Defaults match the Teesters memory schema: label `Memory`,
        property `content`. Override `label` / `property` for tenants
        whose memory rows live elsewhere.

        Follow-up (deferred): swap the recency log-decay for a call
        into `db.applySalienceDecay` so the rank match what an
        on-write salience pass would produce.

        2026-07-03 vector-routing fix (binary `6f285872…`, ADR-0103
        appendix): three new optional request fields — `mode`,
        `vector_property`, `metric` — plus two new response fields —
        `mode` (effective retrieval path that actually ran) and
        `embedder_model` (which model produced the query vector, when
        a vector path fired). Under `mode="hybrid"` the handler
        merges BM25 + vector via Reciprocal Rank Fusion (k=60); on
        any silent-fallback branch the response's `mode` carries a
        distinct `bm25_fallback_<reason>` tag
        (`bm25_fallback_no_embedder` / `bm25_fallback_embed_error` /
        `bm25_fallback_dim_mismatch`) so callers can tell
        "BM25 found nothing" from "vector path degraded" from "no
        embedder compiled". Under explicit `mode="vector"` the
        handler does not silently fall back — it returns 503 with
        `subcode:"EMBEDDER_UNAVAILABLE"` instead. Additive per
        ADR-0073 wave-2 compat rules — pre-fix callers see the
        pre-fix response byte-identical when no embedder is
        resolvable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, description: "Non-empty query text" }
                k: { type: integer, minimum: 1, maximum: 50, default: 10 }
                hops: { type: integer, minimum: 0, maximum: 3, default: 1 }
                salience_weight:
                  type: number
                  format: double
                  minimum: 0
                  description: "Multiplier on (1 + w * salience). 0 disables salience boost."
                label: { type: string, default: "Memory" }
                property: { type: string, default: "content" }
                mode:
                  type: string
                  enum: [bm25, vector, hybrid]
                  description: |
                    2026-07-03 vector-routing fix. Retrieval mode. Omitted →
                    resolves to `hybrid` when an embedder is available AND the
                    engine has any vector rows for `(label, vector_property)`;
                    otherwise `bm25`. Under `vector`/`hybrid` the handler
                    embeds the query, runs `Engine::vector_top_k`, and (for
                    `hybrid`) fuses the two rank lists via Reciprocal Rank
                    Fusion (k=60). Silent BM25 fallback under `hybrid`; loud
                    503 under `vector` when no embedder is resolvable.
                vector_property:
                  type: string
                  default: "_embedding_v1"
                  description: |
                    Vector slot name to route through `Engine::vector_top_k`.
                    Defaults to `_embedding_v1` — the ADR-0113 Wave-4 slot
                    stamped by `db.recordEpisode` / `db.recordBelief`. Wave-5
                    callers with prefixed slots pass the explicit name
                    (`_embedding_v1_{srcProp}`).
                metric:
                  type: string
                  enum: [cosine, euclidean, dot]
                  default: cosine
                  description: |
                    Vector similarity metric. `dotproduct` is accepted as an
                    alias for `dot`. Ignored on `mode=bm25`. Same contract
                    as `POST /graphs/{name}/vector-search`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [snippets, total_candidates_scored, took_ms, mode]
                properties:
                  snippets:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, format: int64 }
                        str_id: { type: string, nullable: true }
                        score: { type: number, format: double }
                        labels:
                          type: array
                          items: { type: string }
                        content:
                          type: object
                          description: "Full node record (id, labels, properties, span)."
                        expanded_from:
                          type: integer
                          format: int64
                          nullable: true
                          description: "Seed node id when reached by hop expansion; null for direct fulltext hits."
                  total_candidates_scored:
                    type: integer
                    format: int64
                    description: |
                      2026-07-03: unique union of the BM25 and vector hit
                      sets (deduped on node id). Pre-fix this counted BM25
                      candidates only.
                  took_ms: { type: integer, format: int64 }
                  mode:
                    type: string
                    description: |
                      2026-07-03 vector-routing fix. Effective retrieval mode
                      that actually ran. `bm25` | `vector` | `hybrid` on the
                      happy path; on any silent-fallback branch one of
                      `bm25_fallback_no_embedder` /
                      `bm25_fallback_embed_error` /
                      `bm25_fallback_dim_mismatch` /
                      `bm25_fallback_no_vectors_for_prop` (2026-07-04 §9 item
                      B2 — pinned slot / candidate list has no populated
                      vectors) /
                      `bm25_fallback_declared_dim_mismatch` (2026-07-05 §9
                      item B1 — schema declared `(label, prop, dim=D)` but
                      every row's actual vector dim disagrees; see
                      `gq_memory_recall_declared_dim_mismatch_total`
                      counter) /
                      `bm25_fallback_embedder_slot_model_mismatch`
                      (2026-07-04 §9 item C1) /
                      `bm25_fallback_missing_lock` (2026-07-04 §9 item C2,
                      strict mode only) /
                      `bm25_fallback_model_boundary_ambiguous`
                      (2026-07-04 §9 item D1).
                  embedder_model:
                    type: string
                    nullable: true
                    description: |
                      2026-07-03 vector-routing fix. `Some(model_name)` when
                      the query embed actually ran (matches
                      `Embedder::model_name()` and the `default:true`
                      catalog entry from `GET /admin/embeddings/models`).
                      Omitted on pure-BM25 paths and on fallback branches
                      that fired before an embed call.
        "400": { $ref: "#/components/responses/BadRequest" }
        "503":
          description: |
            Emitted when `mode="vector"` is set explicitly but no embedder
            is resolvable via the `EmbedderRegistry`. Envelope carries
            `code:"ENG_BACKPRESSURE"`,
            `subcode:"EMBEDDER_UNAVAILABLE"` per ADR-0073 wave-2. Silent
            fallback under `mode="hybrid"` (default) does NOT surface
            here — inspect `response.mode` for a `bm25_fallback_*` tag.

  # ──────────────────────── Schema / backup / clone / rebuild ────────────────────────
  /graphs/{name}/schema:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [schema]
      summary: Current schema version (with introspected live counts)
      description: |
        Returns the latest schema version plus live label and edge-type
        counts derived from the snapshot's `meta.json`. See ADR-0010.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: integer }
                  schema: { type: object }
                  labels:
                    type: array
                    items:
                      type: object
                      properties:
                        label: { type: string }
                        count: { type: integer, format: int64 }
                  edge_types:
                    type: array
                    items:
                      type: object
                      properties:
                        type: { type: string }
                        count: { type: integer, format: int64 }
                        source_labels:
                          type: array
                          items: { type: string }
                        target_labels:
                          type: array
                          items: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [schema]
      summary: Append a new schema version
      description: |
        Appends a new immutable schema version. Useful properties:
        `properties.{p}.type`, `properties.{p}.acl.{read,write,purpose}`,
        `properties.{p}.encryption.subject_id_property`,
        `properties.{p}.{type=vector,dim,metric,model,modelVersion,index}`.
        Malformed bodies return 200 with `warnings[]` populated (#286).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                labels:
                  type: array
                  items: { type: string }
                properties: { type: object }
                constraints:
                  type: array
                  items: { type: object }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: integer }
                  warnings:
                    type: array
                    items: { type: string }
  /graphs/{name}/schema/history:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [schema]
      summary: All schema versions in order
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    version: { type: integer }
                    ts: { type: string }
                    schema: { type: object }
  /graphs/{name}/rebuild-derived:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Force a flush + rebuild of derived state
      description: |
        Re-derives sidecars (id_map, CSR, indexes, bloom, BM25, vector
        indexes) from canonical SSTables. Used after low-level repairs or
        when a sidecar is suspected corrupt. Aborted if the
        reconciliation would dramatically shrink record counts —
        regression class #169.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  rebuilt:
                    type: array
                    items: { type: string }
                  snapshot_seq: { type: integer, format: int64 }
                  warnings:
                    type: array
                    items: { type: string }
  /graphs/{name}/backup:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Produce a per-graph backup tarball
      description: |
        Writes a tar.gz containing the current snapshot, WAL, history.log,
        `committed.json`, and `api_keys.json`. Output path is
        server-local. See `docs/operations/backup-restore.md`. The KEK
        is NOT included — back it up separately per
        `docs/operations/kek-runbook.md`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [output_path]
              properties:
                output_path:
                  type: string
                  example: /var/lib/graphiquity-v2/backups/acme-2026-06-02T18.tar.gz
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  output_path: { type: string }
                  bytes: { type: integer, format: int64 }
  /graphs/{name}/restore:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Restore from a backup tarball
      description: |
        Unpacks a backup tarball into the graph's data directory. The
        target must be empty (refuses to overwrite). After restore,
        verify via `/graphs/{name}/audit/verify` and rotate any
        travelling API keys.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [backup_path]
              properties:
                backup_path: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  snapshot_seq: { type: integer, format: int64 }
  /graphs/{name}/clone-from/{source}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: source, in: path, required: true, schema: { type: string } }
    post:
      tags: [admin]
      summary: Clone an existing graph into a new one (uses engine.fork_to)
      description: |
        Hardlinks the source snapshot tree into a new graph if the
        filesystems match; copies otherwise. WAL + history start fresh
        on the clone. Used for regulatory holds (clone then engage WORM),
        analytics snapshots, and sandbox testing. See ADR-0026.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  new_graph: { type: string }
                  cloned_snapshot_seq: { type: integer, format: int64 }
                  bytes_hardlinked: { type: integer, format: int64 }
                  bytes_copied: { type: integer, format: int64 }
  /graphs/{name}/audit/verify:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [admin]
      summary: Verify the audit chain hash continuity
      description: |
        Walks the hash-chained `history.log`, re-computing every entry's
        SHA-256 against the prior entry. `ok=false` indicates tampering or
        corruption — the response surfaces the first broken seq. See
        `docs/compliance.md` § audit chain.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  first_seq: { type: integer, format: int64 }
                  last_seq: { type: integer, format: int64 }
                  total: { type: integer, format: int64 }
                  broken_at:
                    type: integer
                    format: int64
                    nullable: true
                    description: First seq where the chain failed to verify. Null when ok=true.
  /graphs/{name}/compliance/export:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Produce a compliance bundle (audit + schema history + WORM + lineage)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [output]
              properties:
                output: { type: string }
      responses:
        "200": { description: OK }
  /graphs/{name}/admin/create-index:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Create a property index
      description: |
        Builds a typed property index. `value_type` controls the index
        format: `Int`, `String`, `Float`, `Bool`. Synchronously back-fills
        historical rows (#294). Idempotent if the index already exists
        with the same type — `INDEX_TYPE_MISMATCH` is returned as a 200
        warning when the existing index has a different type (#295).

        Cypher-form alternative: `CREATE INDEX FOR (n:Label) ON (n.prop)`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, property]
              properties:
                label: { type: string, example: "Person" }
                property: { type: string, example: "email" }
                value_type:
                  type: string
                  enum: [Int, String, Float, Bool]
                  default: String
                unique: { type: boolean, default: false }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: boolean }
                  warnings:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/indexes:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [schema]
      summary: List all property indexes for the graph
      description: |
        Returns every registered property index (label + property pairs)
        with value type. Used by the remote MCP `list_indexes` tool.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    label: { type: string }
                    property: { type: string }
                    value_type:
                      type: string
                      enum: [Int, String, Float, Bool]
                    unique: { type: boolean }
    post:
      tags: [schema]
      summary: Create a property index (RESTful alias for /admin/create-index)
      description: |
        RESTful alias for `POST /graphs/{name}/admin/create-index`
        (Anakalypsis #296). Builds a typed property index. `value_type`
        controls the format: `Int`, `String`, `Float`, `Bool`.
        Synchronously back-fills historical rows. Idempotent if the
        index already exists with the same type.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label, property]
              properties:
                label: { type: string, example: "Person" }
                property: { type: string, example: "email" }
                value_type:
                  type: string
                  enum: [Int, String, Float, Bool]
                  default: String
                unique: { type: boolean, default: false }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: boolean }
                  warnings:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/indexes/{label}/{property}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: label, in: path, required: true, schema: { type: string } }
      - { name: property, in: path, required: true, schema: { type: string } }
    delete:
      tags: [schema]
      summary: Drop a property index (idempotent)
      description: |
        Removes the index on (label, property). Idempotent — succeeds
        even if the index does not exist. Used by the remote MCP
        `drop_index` tool.
      responses:
        "200":
          description: OK (idempotent)
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/edges-between:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [cypher]
      summary: List edges between two nodes
      description: |
        Returns the set of edges (in either direction) connecting the two
        nodes. Accepts string ids; engine resolves to int ids internally.
        Used by the graph-viz frontend tab.
      parameters:
        - { name: from, in: query, required: true, schema: { type: string } }
        - { name: to,   in: query, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  edges:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer, format: int64 }
                        strId: { type: string }
                        type: { type: string }
                        source: { type: string }
                        target: { type: string }
                        properties: { type: object }

  # ──────────────────────── WORM (irrevocable write-block) ────────────
  /graphs/{name}/worm:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [admin]
      summary: Check whether WORM mode is engaged
      description: |
        Returns the current WORM (Write-Once-Read-Many) state. Once
        engaged, all mutations are refused — even by privileged
        principals. Designed for regulatory retention zones. See
        `docs/compliance.md` § WORM mode and ADR-0010 § WORM.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  engaged_at:
                    type: string
                    nullable: true
                    description: ISO-8601 UTC timestamp; null when not engaged.
  /graphs/{name}/worm/engage:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Irrevocably engage WORM mode
      description: |
        Engages WORM. **IRREVOCABLE**: this cannot be undone. After
        engagement, every mutation is refused with 403 `WORM_ENGAGED`.
        The audit chain remains updatable for ACL events
        (`db.aclEvents`). To produce a modified copy, clone via
        `/graphs/{newName}/clone-from/{name}` and engage WORM on the clone
        instead.
      responses:
        "200":
          description: WORM engaged
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean, example: true }
                  engaged_at: { type: string, example: "2026-06-02T18:00:00Z" }
        "409":
          description: Already engaged
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }

  # ──────────────────────── EXPLAIN (#172) ────────────
  /graphs/{name}/explain:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cypher]
      summary: Plan-only analysis for a Cypher query
      description: |
        Same shape as a Cypher prefix `EXPLAIN ...` but addressable as a
        clean endpoint so SDKs don't have to mangle query strings. No
        execution; returns the per-clause execution strategy.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
                parameters: { type: object }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:
                    type: array
                    items:
                      type: object
                      properties:
                        clause: { type: string }
                        strategy: { type: string }
                        estimated_rows: { type: integer, nullable: true }
                  warnings:
                    type: array
                    items: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/explain-analyze:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [cypher]
      summary: Runtime-instrumented query analysis (plan + wall time + row counts)
      description: |
        Runs a Cypher query and captures per-clause execution strategy,
        wall time, and row counts. Like `EXPLAIN` but with actual
        execution metrics. Returns plan steps plus elapsed_ms per step.
        Used for query debugging and optimisation analysis.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
                parameters: { type: object }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
        "400": { $ref: "#/components/responses/BadRequest" }

  # ───── Per-graph customer surface ─────
  # #370 PR-5 (2026-07-09). Ten per-graph routes that the v1 dashboard,
  # SDK, and API-key holders exercise directly. All entries require a
  # raw API key (`gq_…`) with graph-scope for `{name}`; JWT callers may
  # also reach them via tenant-prefixed graph names. `Name` parameter
  # gates on `^[a-zA-Z0-9_-]{1,64}$`.
  /graphs/{name}/schema/definition:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [schema]
      summary: Load the current schema document (v1 editor's "load" call)
      description: |
        Returns the current schema body (as stamped by the most recent
        schema version) or an empty object when the graph has no schema.
        Distinct from `GET /graphs/{name}/schema` which additionally
        returns live label/edge-type counts — this endpoint is the raw
        document only, matching the shape the v1 schema editor round-
        trips through `PUT /graphs/{name}/schema/definition`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SchemaDefinition" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [schema]
      summary: Replace the schema document (v1 editor's "save" call)
      description: |
        Stamps a new immutable schema version with the posted body
        (same code path as `POST /graphs/{name}/schema`). Body is
        parsed by `validate_schema_body` (#286) — malformed shapes
        still succeed with a populated `warnings[]` array so the
        editor can surface actionable diagnostics without blocking
        the save.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SchemaDefinition" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: integer }
                  warnings:
                    type: array
                    items: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
    delete:
      tags: [schema]
      summary: Drop the schema document (v1 editor's "clear" button)
      description: |
        Stamps an EMPTY schema as a new version — the history still
        records the clear so downstream audit chains stay dense.
        `db.schemaHistory()` will show a version bump with an empty
        body rather than a delete marker.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: integer }
                  warnings:
                    type: array
                    items: { type: string }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/config:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [admin]
      summary: Per-graph config toggles (bitemporal / worm / accessLog)
      description: |
        Reads the per-graph config knobs the v1 settings page renders.
        In v2 `bitemporal` is always `true` (bitemporal-always is a
        v2 architectural invariant); `worm` and `accessLog` are
        surfaced as `false` with a `_note` clarifying that neither
        toggle is implemented in v2 storage. Real WORM engagement
        lives on `POST /graphs/{name}/worm/engage`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GraphConfig" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [admin]
      summary: Update per-graph config toggles (accept-but-honest)
      description: |
        v1 settings page's save button. Body accepts any subset of
        `bitemporal`, `worm`, `accessLog`. In v2 the engine returns
        `applied: false` with the requested body echoed back and a
        `_note` — no state changes because bitemporal is always on
        and the other two toggles aren't implemented at the storage
        layer. Use `POST /graphs/{name}/worm/engage` for irreversible
        WORM.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                bitemporal: { type: boolean }
                worm: { type: boolean }
                accessLog: { type: boolean }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  applied: { type: boolean, description: "Always false in v2." }
                  requested:
                    type: object
                    description: Echo of the request body.
                  _note: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/build-bilabels:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Backfill missing bitemporal-label index files (ADR-0085)
      description: |
        Idempotent operator one-shot that fills in any missing BIL
        (bitemporal-label index) files without waiting for the next
        compaction or engine restart. Labels that already have a BIL
        file are skipped. Response reports how many labels got a fresh
        file and total bytes written.

        Use this to retrofit graphs that were last compacted before
        ADR-0080 shipped, or that opened with `GQ_BILABEL_BUILD_ON_OPEN=0`.
        For an unconditional rebuild of every BIL from scratch, prefer
        `CALL db.buildBitemporalLabelIndex(label)` per-label instead.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BuildBilabelsResponse" }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/compliance/summary:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [admin]
      summary: Compliance dashboard summary (Enterprise; 403 on Community)
      description: |
        One-shot summary used by the v1 compliance dashboard. Reports
        audit-chain availability, WORM engagement, access-log status,
        and last-verified timestamp, plus `_endpoints` pointers to
        the verification and export routes. All linked routes are
        themselves Enterprise-gated, so this summary is too.

        **Licensing (ADR-0077)**: Enterprise feature. The Community
        binary refuses with HTTP 403 + `code: "ENG_CLIENT_ERROR"`.
        See `docs/compliance.md` for the underlying audit chain.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ComplianceSummary" }
        "403":
          description: |
            Enterprise-only feature. Community binary refuses with the
            standard error envelope.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorBody" }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/seed-status:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    get:
      tags: [admin]
      summary: Poll status of the most recent seed job for a graph
      description: |
        The v1 dashboard's "load demo data" progress probe. Returns
        `available: false` when no seed job has ever run for the
        graph, so the dashboard's success branch can show the "use
        Import" pointer cleanly. When a seed job exists, surfaces
        its lifecycle state (`pending` / `running` / `completed` /
        `failed`), current progress percentage, and result payload.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SeedStatusResponse" }
  /graphs/{name}/seed:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Enqueue a JSONL-bundle seed job for a known template
      description: |
        Enqueues a seed job that streams a bundled JSONL fixture into
        the graph. Known templates today: `insurance`, `insurance_huge`.
        Poll progress via `GET /jobs/{jobId}` or
        `GET /graphs/{name}/seed-status`.

        Templates without a bundled file return HTTP 200 with
        `available: false` (not an error) so the v1 dashboard's
        success branch shows the "use Import" pointer rather than
        a generic "Request failed" toast.

        Bundles live at `{GQ_SEEDS_DIR}/{template}.jsonl[.gz]`
        (default `/var/lib/graphiquity-v2/seeds`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [template]
              properties:
                template:
                  type: string
                  example: insurance
                  description: "Bundled seed template name."
      responses:
        "200":
          description: |
            Template is unknown to the server (no bundled file). Body
            has `available: false` and a message pointing at the
            Import flow. Not an error — the shape stays 2xx so the
            v1 UI's success branch runs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SeedTriggerResponse" }
        "202":
          description: |
            Seed job accepted. Body carries `jobId` for polling via
            `GET /jobs/{jobId}` or `GET /graphs/{name}/seed-status`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SeedTriggerResponse" }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/node-by-str/{str_id}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: str_id, in: path, required: true, schema: { type: string }, description: "External string ID (e.g. business key or import key)." }
    get:
      tags: [graphs]
      summary: Fetch a node by its external string ID (#162)
      description: |
        Named-graph variant of `GET /node-by-str/{str_id}`. Resolves
        the external string ID through the graph's id_map sidecar,
        then returns the full `NodeRecord`. Runs field-ACL redaction
        against the caller's Principal (#70) — properties denied by
        the schema's `properties.{p}.acl.read` clause are stripped
        from the response.

        404 when the string ID isn't in the id_map (or the node was
        deleted). Multi-label nodes return a single `NodeRecord` with
        every label in the `labels` array.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NodeRecord" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /graphs/{name}/edge-by-str/{str_id}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: str_id, in: path, required: true, schema: { type: string }, description: "External string ID for the edge." }
    get:
      tags: [graphs]
      summary: Fetch an edge by its external string ID (#162)
      description: |
        Named-graph variant of `GET /edge-by-str/{str_id}`. Same
        id_map + field-ACL semantics as `/graphs/{name}/node-by-str`
        above, but returns an `EdgeRecord` (with `edge_type`,
        `source`, `target`, `properties`, and bitemporal `span`).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EdgeRecord" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ──────────────────────── Admin debug + API keys ────────────
  /admin/debug/storage:
    get:
      tags: [admin]
      summary: Live snapshot / lineage / sidecar telemetry
      description: |
        Operator debug surface. Mirrors the data in `/metrics` as one
        structured JSON snapshot. See `docs/operations/healthchecks.md`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  graph_dir: { type: string }
                  current_snapshot_seq:
                    type: integer
                    format: int64
                    nullable: true
                  lineage:
                    type: object
                    properties:
                      files:
                        type: array
                        items:
                          type: object
                          properties:
                            path: { type: string }
                            sha256: { type: string }
                            bytes: { type: integer, format: int64 }
                      verified_at_load: { type: boolean }
                  lsm:
                    type: object
                    properties:
                      levels:
                        type: array
                        items:
                          type: object
                          properties:
                            level: { type: integer }
                            sstables: { type: integer }
                      compaction_in_progress: { type: boolean }
                      consecutive_failures: { type: integer }
                  indexes: { type: object, additionalProperties: { type: array, items: { type: string } } }
                  bloom: { type: object }
                  block_cache: { type: object }
  /admin/embeddings/models:
    get:
      tags: [admin]
      summary: List embedding catalog entries (ADR-0107 Wave 3, #286)
      description: |
        Operator dump of the in-process embedding model catalog. Lists
        every catalog entry — including cloud entries whose `api_key_env`
        was unset at boot (`enabled:false`) — plus the names of
        embedders that have been lazily constructed since boot.

        Catalog source: `GQ_EMBEDDING_CATALOG_PATH` (default
        `/var/lib/graphiquity-v2/embedding_models.json`). Missing or
        malformed files fall back to the builtin minilm-l6-v2 entry.

        Gated by `GQ_ADMIN_CACHE_RELEASE_ENDPOINT=0` (returns 404).
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [catalog_path, available, loaded]
                properties:
                  catalog_path: { type: string }
                  available:
                    type: array
                    items:
                      type: object
                      required: [name, kind, dim, default, enabled, max_concurrency]
                      properties:
                        name: { type: string }
                        kind: { type: string, description: "LocalOnnx | LocalCandle | Openai | Voyage | Cohere | CustomHttp" }
                        dim: { type: integer, minimum: 1 }
                        default: { type: boolean }
                        enabled: { type: boolean }
                        max_concurrency: { type: integer, minimum: 1 }
                  loaded:
                    type: array
                    items: { type: string }
        "403": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /admin/embeddings/test:
    post:
      tags: [admin]
      summary: Single-shot embed call for operator verification (ADR-0107 Wave 3, #286)
      description: |
        Dispatches one embed call against the named catalog entry and
        returns the dimensionality + first 4 floats of the resulting
        vector. Used to verify catalog correctness + provider
        connectivity without writing a Cypher query.

        Error mapping:
          * unknown model → 404 `not_found`
          * API key env var unset → 400 `bad_request`
          * dimension drift from catalog → 503 `service_unavailable`
          * upstream provider failure → 503 `service_unavailable` with `retry_after_ms`

        Gated by `GQ_ADMIN_CACHE_RELEASE_ENDPOINT=0` (returns 404).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, text]
              properties:
                model: { type: string, description: "Catalog entry name" }
                text: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [model, dim, sample, latency_ms]
                properties:
                  model: { type: string }
                  dim: { type: integer, minimum: 1 }
                  sample:
                    type: array
                    description: "First 4 floats of the embedding vector"
                    items: { type: number }
                  latency_ms: { type: integer, minimum: 0 }
        "400": { description: Bad request (e.g. `api_key` env var unset) }
        "403": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Model not in catalog or feature-disabled }
        "503": { description: Upstream provider failure or dimension drift }
  /admin/embeddings/backfill:
    post:
      tags: [admin]
      summary: Enqueue an async embedding-backfill job (ADR-0107, #286)
      description: |
        Enqueues a resumable backfill that scans `(graph, label)` and
        embeds every node whose `prop` is present + non-empty but has
        no `_embedding_v1` sidecar yet. Returns 202 immediately with a
        `job_id`; the worker (`AppState::spawn_worker`) claims the row
        and streams progress into the job store. Poll with
        `GET /admin/embeddings/backfill/{job_id}`; cancel with
        `DELETE /admin/embeddings/backfill/{job_id}`.

        Gated by `GQ_ADMIN_CACHE_RELEASE_ENDPOINT=0` (returns 404).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [graph, label, prop]
              properties:
                graph: { type: string, description: "Target graph — must exist (fail-fast rather than enqueue against a missing graph)." }
                label: { type: string }
                prop:  { type: string, description: "Property to source text from." }
                model: { type: string, description: "Catalog entry name; omit to use the catalog default." }
      responses:
        "202":
          description: Accepted — job enqueued
          content:
            application/json:
              schema:
                type: object
                required: [job_id, status, graph, label, prop, polling]
                properties:
                  job_id: { type: string, description: "Opaque id — pass to the GET/DELETE endpoints." }
                  status: { type: string, enum: [pending] }
                  graph:  { type: string }
                  label:  { type: string }
                  prop:   { type: string }
                  model:  { type: string, nullable: true }
                  polling: { type: string, description: "Convenience: relative URL for the poll endpoint." }
        "400": { description: Bad request — missing/empty field or unknown graph }
        "403": { $ref: "#/components/responses/Unauthorized" }
  /admin/embeddings/backfill/{job_id}:
    get:
      tags: [admin]
      summary: Poll a backfill job (ADR-0107, #286)
      description: |
        Returns the full `JobRecord` for the enqueued backfill. State
        transitions: `queued` → `running` → `succeeded` | `failed` |
        `cancelled`. `processed` / `succeeded` / `failed` counters
        advance as the worker drains rows. `completed_at_ms` is null
        while `state` is `queued` or `running`.

        Gated by `GQ_ADMIN_CACHE_RELEASE_ENDPOINT=0` (returns 404).
      parameters:
        - in: path
          name: job_id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [job_id, kind, state, graph]
                properties:
                  job_id: { type: string }
                  kind:   { type: string, enum: [embedding-backfill] }
                  state:
                    type: string
                    enum: [queued, running, succeeded, failed, cancelled]
                  graph:  { type: string }
                  processed:      { type: integer, minimum: 0 }
                  succeeded:      { type: integer, minimum: 0 }
                  failed:         { type: integer, minimum: 0 }
                  started_at_ms:  { type: integer, format: int64, nullable: true }
                  completed_at_ms: { type: integer, format: int64, nullable: true }
        "400": { description: Job exists but is not an `embedding-backfill` kind }
        "403": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Job id unknown }
    delete:
      tags: [admin]
      summary: Cancel a backfill job (ADR-0107, #286)
      description: |
        Cooperative cancellation — stamps `cancel_requested` via
        `JobQueue::request_cancel`. The worker observes the flag on
        the next chunk poll and exits gracefully, marking the record
        `cancelled`. Idempotent: cancelling a completed or
        already-cancelled job returns 200.

        Gated by `GQ_ADMIN_CACHE_RELEASE_ENDPOINT=0` (returns 404).
      parameters:
        - in: path
          name: job_id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — cancel flag stamped
          content:
            application/json:
              schema:
                type: object
                required: [job_id, cancel_requested, prior_state]
                properties:
                  job_id:            { type: string }
                  cancel_requested:  { type: boolean }
                  prior_state:       { type: string, enum: [queued, running, succeeded, failed, cancelled] }
        "400": { description: Job exists but is not an `embedding-backfill` kind }
        "403": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Job id unknown }
  /admin/api-keys:
    post:
      tags: [admin]
      summary: Create a new API key (`gq_*` prefix)
      description: |
        Issues a runtime-mintable bearer credential. The token value is
        returned **once**; only the SHA-256 hash is persisted in
        `api_keys.json`. See ADR-0066 + `docs/auth.md` § Mode 2.

        #271 / ADR-0072: optional `privacyMode` block flips the key
        into differential-privacy mode — the holder is restricted to
        scalar aggregate queries (`count` / `sum` / `avg`), each
        result is Laplace-noised, and a per-principal ε budget is
        tracked under `derived/dp_budget/`. See `docs/compliance.md`
        § Differential privacy.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tenant: { type: string }
                name: { type: string, description: "Human label shown in the UI." }
                role:
                  type: string
                  description: |
                    ACL role — `admin` | `writer` | `reader` | `tenant_admin`.
                    `tenant_admin` (new 2026-06-27 / ADR-0101) is a scoped admin:
                    the key holder can `GET /admin/api-keys` and `GET /graphs`
                    but the response is filtered to only entries whose `tenant`
                    field matches `Principal.tenant`. `tenant` MUST be set on
                    the key for `tenant_admin` to pass the gate (fail-closed).
                graphs:
                  type: array
                  description: "Restrict the key to these graphs. Omit / null = any graph."
                  items: { type: string }
                ttlDays:
                  type: integer
                  description: "Days until expiry. 0 / absent = never expires."
                rateLimits:
                  $ref: "#/components/schemas/RateLimits"
                privacyMode:
                  $ref: "#/components/schemas/PrivacyMode"
                owner_email:
                  type: string
                  description: |
                    #257 (2026-06-27): per-key audit linkage to a human owner.
                    Operators trust the body on `/admin/api-keys`; the self-serve
                    `/cloud/me/api-keys` path always overrides this from the
                    validated JWT (users cannot impersonate). Also accepted as
                    `ownerEmail` (camelCase). Echoed back in `NewApiKey.ownerEmail`
                    and listed in `ApiKeyView.ownerEmail`.
                cognito_sub:
                  type: string
                  description: |
                    #257: Cognito `sub` of the minter when known. Stable across
                    email changes. Also accepted as `cognitoSub`. Echoed in
                    `NewApiKey.cognitoSub` and `ApiKeyView.cognitoSub`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewApiKey"
    get:
      tags: [admin]
      summary: List API keys (id + tenant; never the token)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKeyView"
  /admin/api-keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [admin]
      summary: Revoke an API key (immediate; no grace period)
      responses:
        "200": { description: Revoked }
        "404": { $ref: "#/components/responses/NotFound" }
  /apikeys:
    get:
      tags: [admin]
      summary: List your own API keys (self-serve alias; tenant-scoped)
      description: |
        Alias for `GET /me/api-keys` (#370 PR-4). Same tenant-scoped
        `ApiKeyView[]` shape; caller sees only entries whose `tenant`
        matches their JWT `email` (or `sub` fallback). Provided so raw
        API-key holders and the SDK can hit a shorter, more discoverable
        path. Admin listing across all tenants stays on
        `/admin/api-keys`.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKeyView"
    post:
      tags: [admin]
      summary: Create an API key for yourself (self-serve alias; tenant-scoped)
      description: |
        Alias for `POST /me/api-keys` (#370 PR-4). Body shape, tenant
        override, ignored `role`, and the `NewApiKey` response
        (including the one-shot raw `key`) all match the aliased
        endpoint. Rate-limited on the same per-tenant token-bucket as
        `/me/api-keys` and `/cloud/me/api-keys` — see those entries for
        the 429 wire shape (`CloudRateLimitBody` / `Retry-After`
        header) and the `GQ_API_KEY_CREATE_RATE_LIMIT` tuning knob.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, description: "Human label shown in the UI." }
                graphs:
                  type: array
                  description: "Restrict the key to these graphs. Omit / null = any graph."
                  items: { type: string }
                ttlDays:
                  type: integer
                  description: "Days until expiry. 0 / absent = never expires."
                rateLimits:
                  $ref: "#/components/schemas/RateLimits"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewApiKey"
        "400": { $ref: "#/components/responses/BadRequest" }
        "429":
          description: |
            Too Many Requests — per-tenant token-bucket exhausted
            (#355). Wire shape identical to `POST /me/api-keys`;
            body conforms to `CloudRateLimitBody`, `Retry-After`
            header carries integer seconds until the next refill.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudRateLimitBody" }
  /apikeys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [admin]
      summary: Revoke your own API key (self-serve alias; tenant-scoped)
      description: |
        Alias for `DELETE /me/api-keys/{id}` (#370 PR-4). Gates on
        tenant ownership; returns 404 (not 403) when the id belongs to
        another tenant so cross-tenant existence is never leaked.
        Accepts either the full uuid-like id or the `gq_xxxxxxx`
        prefix.
      responses:
        "200": { description: Revoked }
        "404": { $ref: "#/components/responses/NotFound" }
  /me/api-keys:
    get:
      tags: [admin]
      summary: List your own API keys (tenant-scoped)
      description: |
        Self-serve endpoint for non-admin users (#332). Returns the same
        `ApiKeyView` shape as `/admin/api-keys`, filtered to entries whose
        `tenant` matches the caller's JWT email (or `sub` fallback).
        Returns an empty list (not 403) when the caller has no matching
        keys, so the page renders cleanly for first-time users.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ApiKeyView"
    post:
      tags: [admin]
      summary: Create an API key for yourself (tenant-scoped)
      description: |
        Self-serve endpoint for non-admin users (#332). Body shape mirrors
        the admin `POST /admin/api-keys`, except `tenant` is ALWAYS
        overridden to the caller's JWT identity (email or sub) and the
        `role` field is ignored — self-serve keys use whatever
        `GQ_DEFAULT_ROLES` provides on the engine. Admin-role minting
        still goes through `/admin/api-keys` (which requires admin).

        **Rate limit (#355, 2026-07-09)**: gated by a per-tenant
        token-bucket. Default 5 creates per hour per Cognito tenant
        (email or `sub` fallback). On exceeded → HTTP 429 with
        `Retry-After` header (integer seconds until the next refill)
        and body shape
        `{ error: "rate_limited", message, retry_after_ms, details: { subject, retry_after_ms } }`.
        Tune via `GQ_API_KEY_CREATE_RATE_LIMIT`
        (`<N>/<period_secs>` form, e.g. `"1/60"` for 1/minute, or
        `"disabled"` / `"0/3600"` to bypass entirely). Operators
        monitor via the labelled Prometheus counters
        `gq_cloud_api_key_creates_total{tenant}` +
        `gq_cloud_api_key_creates_rate_limited_total{tenant}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, description: "Human label shown in the UI." }
                graphs:
                  type: array
                  description: "Restrict the key to these graphs. Omit / null = any graph."
                  items: { type: string }
                ttlDays:
                  type: integer
                  description: "Days until expiry. 0 / absent = never expires."
                rateLimits:
                  $ref: "#/components/schemas/RateLimits"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewApiKey"
        "400": { $ref: "#/components/responses/BadRequest" }
        "429":
          description: |
            Too Many Requests — per-tenant token-bucket exhausted
            (#355). Default cap 5 creates/hour per Cognito tenant.
            `Retry-After` header carries integer seconds until the
            next single-token refill; body carries the sub-second
            precise `retry_after_ms` for SDKs that back off
            programmatically.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
              description: |
                Seconds until the next token refill. Clients should
                wait at least this long before retrying. Rounded up
                from the underlying `retry_after_ms` in the body.
          content:
            application/json:
              schema:
                type: object
                required: [error, message, retry_after_ms]
                properties:
                  error:
                    type: string
                    enum: [rate_limited]
                  code:
                    type: string
                    example: ENG_BACKPRESSURE
                  message:
                    type: string
                    example: "rate limit exceeded for tenant:alice@example.com; retry after 3542.87s"
                  retry_after_ms:
                    type: integer
                    format: int64
                    minimum: 1
                    description: "Milliseconds until the next refill; ceil() of the internal float."
                  details:
                    type: object
                    properties:
                      subject:
                        type: string
                        description: "`tenant:<Cognito email or sub>` — the bucket the caller drained."
                        example: "tenant:alice@example.com"
                      retry_after_ms:
                        type: integer
                        format: int64
                        minimum: 1
  /me/api-keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [admin]
      summary: Revoke your own API key (tenant-scoped; immediate)
      description: |
        Self-serve revocation (#332). Gates on tenant ownership, so users
        can revoke only keys they own. Returns 404 (not 403) when the
        target id belongs to another tenant, so the existence of
        admin / other-tenant key ids is never leaked. Accepts either the
        full uuid-like id or the `gq_xxxxxxx` prefix.
      responses:
        "200": { description: Revoked }
        "404": { $ref: "#/components/responses/NotFound" }
  /admin/graphs/{name}/budget:
    parameters: [ { $ref: "#/components/parameters/Name" } ]
    post:
      tags: [admin]
      summary: Stamp per-graph query-bytes budget override (ADR-0105 Phase 3c / #282)
      description: |
        Writes `{graph_dir}/per_graph_budget.json`. The Phase 3c admission
        gate re-resolves the budget on every `/cypher` request — no
        restart needed. Resolution order: file > env
        `GQ_PER_GRAPH_QUERY_BUDGET` > hardcoded 8 GiB. Admin-only.

        Body shape: set EITHER `bytes` (numeric cap) OR `unlimited:true`
        (disable the gate for this graph). When both are set,
        `unlimited:true` wins at resolution time
        (`PerGraphBudgetFile::is_unlimited`).

        Gated by `GQ_ADMIN_CACHE_RELEASE_ENDPOINT` (set to `0` to make
        all three operations on this path 404). See RUNBOOK
        "ADR-0105 Phase 3c per-graph query budget" for the operator
        workflow + rollback recipes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: "Set either `bytes` (u64) OR `unlimited:true`. Refused with 400 if both are absent."
              properties:
                bytes:
                  type: integer
                  format: int64
                  nullable: true
                  minimum: 0
                  description: "Hard cap in bytes for this graph's working-set admission gate."
                unlimited:
                  type: boolean
                  default: false
                  description: "Disable the gate for this graph regardless of env default. Wins over `bytes`."
      responses:
        "200":
          description: Override file written.
          content:
            application/json:
              schema:
                type: object
                required: [graph, path]
                properties:
                  graph:     { type: string }
                  bytes:     { type: integer, format: int64, nullable: true }
                  unlimited: { type: boolean }
                  path:
                    type: string
                    description: "Filesystem path of the override file."
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Caller is not an admin." }
        "404": { description: "Graph not found, or admin cache endpoints disabled (`GQ_ADMIN_CACHE_RELEASE_ENDPOINT=0`)." }
    get:
      tags: [admin]
      summary: Resolve the effective per-graph query-bytes budget
      description: |
        Returns the resolved `(budget, source)` tuple for `{name}`.
        `budget=null` means the gate is disabled for this graph — either
        the file says `unlimited:true` OR the env says `unlimited`/`0`.
        `source` is the `BudgetSource::as_str` value so operators see at
        a glance which tier produced the resolution.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [source]
                properties:
                  budget:
                    type: integer
                    format: int64
                    nullable: true
                    description: "u64 byte cap, or null when the gate is disabled for this graph."
                  source:
                    type: string
                    description: "Which tier produced the resolution."
                    enum:
                      - file_per_graph
                      - file_per_graph_unlimited
                      - env_default
                      - env_unlimited
                      - hardcoded_default
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Caller is not an admin." }
        "404": { description: "Graph not found, or admin cache endpoints disabled." }
    delete:
      tags: [admin]
      summary: Remove per-graph query-bytes budget override (idempotent)
      description: |
        Removes `{graph_dir}/per_graph_budget.json`. Idempotent — returns
        `{"removed": false}` when no override file was present. After
        this returns, resolution falls back to the env / hardcoded
        tiers.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [removed]
                properties:
                  removed:
                    type: boolean
                    description: "false if no override file was present."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { description: "Caller is not an admin." }
        "404": { description: "Graph not found, or admin cache endpoints disabled." }
  /admin/backup:
    post:
      tags: [admin]
      summary: Online backup of the default graph (tar.gz to a server-local path)
      description: |
        Writes a per-graph backup tarball to the operator-supplied
        server-local path. For named graphs use
        `/graphs/{name}/backup`. See ADR-0020 +
        `docs/operations/backup-restore.md`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [output_path]
              properties:
                output_path:
                  type: string
                  example: /var/lib/graphiquity-v2/backups/2026-06-02T18.tar.gz
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  output_path: { type: string }
                  bytes: { type: integer, format: int64 }
        "400": { $ref: "#/components/responses/BadRequest" }
  /capabilities/issue:
    post:
      tags: [admin, moat-procedures]
      summary: Issue a scoped, time-limited capability token (ADR-0025)
      description: |
        Returns an HMAC-SHA256-signed bearer token that can be presented
        via `Authorization: Capability <token>`. Used to carve a
        narrower principal out of an existing one — limit to a single
        graph, a list of operation scopes, a TTL, an optional `session`
        (per-conversation sub-tenancy — #277), and an optional
        `purpose` (GDPR Article 5(1)(b) binding — #268).

        The engine's field-level ACL checks `Principal.purpose` against
        `properties.{p}.acl.purpose` on every read. Episodic-memory
        procedures (`db.recordEpisode`, `db.replayEpisodes`) refuse
        cross-session reads when a `session` claim is present.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [graph, principal, ttl_seconds]
              properties:
                graph: { type: string }
                principal: { type: string }
                scope:
                  type: array
                  items: { type: string }
                  description: |
                    Operation scope tags (e.g. ["read", "write"]).
                    Default empty = no restriction beyond the parent.
                session:
                  type: string
                  description: |
                    Optional conversation sub-tenancy identifier (#277).
                    When present, propagates to `Principal.session` and
                    is enforced by episodic-memory procedures.
                purpose:
                  type: string
                  description: |
                    Optional GDPR Article 5(1)(b) processing-purpose tag
                    (#268). When present, propagates to
                    `Principal.purpose` and gates reads against
                    `properties.{p}.acl.purpose`.
                ttl_seconds:
                  type: integer
                  description: Token lifetime in seconds.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expires_at: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
  /graphs/{name}/collections/{label}:
    parameters:
      - { $ref: "#/components/parameters/Name" }
      - { name: label, in: path, required: true, schema: { type: string } }
    get:
      tags: [cypher]
      summary: List nodes with a given label (paginated)
      responses:
        "200": { description: OK }

  # ───── Cloud control plane ─────
  # All routes below are only mounted when `GQ_CLOUD_ENABLED=1` on
  # the target node. See ADR-0078 + `docs/cloud-launch.md`.
  # gq-cloud crate: `crates/gq-cloud/src/{routes,webhooks,model}.rs`.
  # HTTP wiring: `crates/gq-server/src/cloud_routes.rs`.

  /cloud/signup:
    post:
      tags: [cloud-public]
      summary: Start signup — Pending tenant + verification token
      description: |
        Public entry point. Creates a `Pending` tenant row and returns
        a verification token the caller must pass to `POST /cloud/verify`
        to move the tenant to `Trial`.

        MVP: the token is returned in the response body so dev + soft-
        launch flows can complete signup without an outbound email
        service. Phase 4 moves the token behind SES + the response drops
        the `verification_token` field.

        **Per-IP rate limit**: keyed on `X-Forwarded-For` (nginx front)
        with a `<no-fwd>` fallback bucket for direct hits. On exceeded
        → HTTP 409 `conflict` with a "rate limit exceeded" message.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudSignupRequest" }
      responses:
        "200":
          description: OK — tenant is Pending; call /cloud/verify next.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudSignupResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: Rate-limited or duplicate email.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }
    get:
      tags: [cloud-public]
      summary: Contract discovery (address-bar helper)
      description: |
        Address-bar hits return the JSON contract instead of a bare
        405. Not a real GET-serviced endpoint — always POST for real
        signups.
      responses:
        "200": { description: OK }

  /cloud/verify:
    post:
      tags: [cloud-public]
      summary: Trade verification token for Trial status + control-plane key
      description: |
        Consumes the `verification_token` produced by `POST /cloud/signup`
        and flips the tenant to `Trial`. Also mints a
        `role=cloud_tenant` control-plane API key returned once in the
        response — this is the credential the caller uses on subsequent
        `/cloud/tenants/{id}/*` requests. Data-plane keys are minted
        separately when graphs are provisioned.

        On success the tenant is bound to the configured default trial
        plan (`CloudState.default_trial_plan_id`). Double-verify is a
        409 conflict — a tenant can only move Pending → Trial once.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudVerifyRequest" }
      responses:
        "200":
          description: OK — tenant is Trial. `control_plane_key` is shown ONCE.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudVerifyResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: Tenant already verified or token mismatch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }
    get:
      tags: [cloud-public]
      summary: Contract discovery (address-bar helper)
      responses:
        "200": { description: OK }

  /cloud/plans:
    get:
      tags: [cloud-public]
      summary: List public plans (Free / Starter / Growth / Enterprise)
      description: |
        Returns the plan catalog. Prices, included graph counts, and
        included storage GB are read straight from the control DB
        `plans` table; operators seed this via `bin/cloud-seed-plans`.
        No auth required — the price list is public.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  plans:
                    type: array
                    items: { $ref: "#/components/schemas/CloudPlan" }

  /cloud/webhooks/stripe:
    post:
      tags: [cloud-public]
      summary: Stripe webhook receiver (HMAC-verified)
      description: |
        Receives billing events from Stripe. Requires a valid
        `Stripe-Signature` header — the body is HMAC-SHA256'd with the
        `GQ_STRIPE_WEBHOOK_SECRET` env, replay window 300s per Stripe's
        documented tolerance. Invalid or stale signatures → HTTP 400.

        Dispatch is idempotent by `event.id` — a retry of the same
        event returns the cached outcome with an
        `"(idempotent replay) …"` prefix in `action_taken`.

        Events handled:
        * `customer.subscription.updated` / `.created` → flip tenant
          status (trial/active/past_due/suspended).
        * `customer.subscription.deleted` → tenant → `terminating`.
        * `invoice.paid` → record invoice + tenant → `active`.
        * `invoice.payment_failed` → tenant → `past_due`.
        * Anything else → acknowledged as `ignored` so Stripe doesn't
          retry.

        **NEVER trust unverified webhooks** — the signature check is
        the only gate. Under no circumstances should this endpoint be
        called without a valid Stripe-Signature header.

        The route is mounted at BOTH `/webhooks/stripe` (top-level,
        legacy compatibility) AND `/cloud/webhooks/stripe` — Stripe
        dashboards can be configured against either URL and the
        auth-middleware allowlist covers both.
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          description: |
            Stripe HMAC signature per
            <https://stripe.com/docs/webhooks/signatures>.
            Format: `t=<unix_ts>,v1=<hex_sig>[,v1=<other_sig>]`.
            Multiple `v1=` entries during secret rotation — we accept
            if any match.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudStripeWebhookEnvelope" }
      responses:
        "200":
          description: Dispatched (or idempotent replay).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudWebhookOutcome" }
        "400":
          description: Signature verification failed OR malformed body.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }

  # ───── Cloud self-serve (Cognito JWT auth) ─────

  /cloud/me:
    get:
      tags: [cloud-me]
      summary: Get the tenant for the calling Cognito user
      description: |
        Resolves the Cognito `sub` claim → tenant row via
        `tenants.cognito_sub`. Returns 404 if no tenant has been
        bootstrapped yet — the dashboard should call
        `POST /cloud/me/bootstrap` in that case.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudTenant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cloud/me/bootstrap:
    post:
      tags: [cloud-me]
      summary: Idempotently create the tenant row for this Cognito user
      description: |
        Called once by the dashboard after the first post-Cognito-verify
        redirect. If a tenant already exists for this `sub` it's
        returned as-is (idempotent). Otherwise a new tenant is created,
        flipped straight to `Trial` (Cognito already verified the
        email), and bound to the configured default trial plan.

        **Precedence for the tenant's stored `email`** (evaluated in
        order, first non-empty wins):
          1. JWT signed `email` claim (preferred).
          2. `POST` body `email` override (fallback if the IdP omits it).
          3. **Synthetic fallback** — `<cognito_sub>@cognito.invariantdb.com`.
             Introduced 2026-07-09 (#371) as defense-in-depth after an
             app-client mis-configuration shipped tokens without the
             `email` claim in `ReadAttributes`. The tenant row still
             satisfies `UNIQUE(email)` because the sub is per-user
             stable, and the dashboard can still display something. If
             the synthetic path fires we log at INFO with the sub so
             ops can trace it back to the offending Cognito app client.

        **Interaction with `docs/auth.md` "email required"**: an
        upstream doc still states an `email` claim is required for
        Cognito auth. That guidance holds as the *preferred* path;
        this endpoint is the only route today with a documented
        synthetic-email fallback (per #371) — every other JWT-authed
        route continues to require a signed `email` claim.
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudMeBootstrapRequest" }
      responses:
        "200":
          description: OK — tenant returned (existing or newly created).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudTenant" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /cloud/me/graphs:
    get:
      tags: [cloud-me]
      summary: List the calling tenant's graphs
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id: { type: string }
                  graphs:
                    type: array
                    items: { $ref: "#/components/schemas/CloudGraph" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [cloud-me]
      summary: Provision a tenant-prefixed graph
      description: |
        End-to-end provisioning: creates the graph dir + tenancy.json
        + a scoped `gq_` data-plane key + a control-DB row in one
        atomic call. The graph is created with the effective name
        `<tenant_prefix>_<requested_name>` — surfaced as both `name`
        (effective) and `requested_name` (what the caller typed).

        On the fresh-provision path the response includes the raw
        `api_key` **once** plus a copy-paste-ready `mcp_config` block
        for Claude Desktop / Claude Code / Cursor / Codex. On the
        idempotent-replay path (`already_existed=true`) both fields
        are `null` because the original secret is unrecoverable —
        the UI should route the user to the API keys panel to mint
        a fresh key.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudMeCreateGraphRequest" }
      responses:
        "200":
          description: OK — provisioned or idempotent replay.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudCreateGraphResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: Tenant status can't provision graphs, or plan cap reached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }

  /cloud/me/graphs/{name}:
    parameters:
      - name: name
        in: path
        required: true
        description: |
          The EFFECTIVE prefixed graph name (e.g. `abc123_memory`) —
          the same value returned by `GET /cloud/me/graphs`. Not the
          `requested_name` the caller originally typed.
        schema:
          type: string
          pattern: "^[a-zA-Z0-9_-]{1,64}$"
    delete:
      tags: [cloud-me]
      summary: Drop a tenant-owned graph (DESTRUCTIVE)
      description: |
        **DESTRUCTIVE — no undo.** Removes the on-disk graph dir via
        `GraphRegistry::drop_graph` (rename-then-remove into `.trash`),
        marks the control-DB row as `terminating`, and revokes every
        data-plane API key whose `graphs[]` scope contains this name.
        Errors during individual revoke steps are logged and returned
        in the response `errors` array but do not block the drop.

        The endpoint is 403-safe: if the graph belongs to a different
        tenant the call returns 404 (tenant / graph not found)
        because the ownership check runs before any dispatch.
      responses:
        "200":
          description: Dropped (best-effort — see `errors` for partial failures).
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: { type: string }
                  status: { type: string, enum: [terminating] }
                  api_keys_revoked: { type: integer, minimum: 0 }
                  errors: { type: array, items: { type: string } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cloud/me/api-keys:
    get:
      tags: [cloud-me]
      summary: List the calling tenant's API keys (metadata only)
      description: |
        Returns `ApiKeyView`-shaped entries. The raw `gq_*` secret NEVER
        re-surfaces here — it's returned once at creation time via
        `POST /cloud/me/api-keys` and lost forever otherwise.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id: { type: string }
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKeyView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [cloud-me]
      summary: Mint a new API key (raw secret returned ONCE)
      description: |
        Self-serve key minting. The `tenant` field on the resulting key
        is always the calling user's tenant (server-side override — the
        request body cannot impersonate). The response is the
        `NewApiKey` shape and includes the raw `key` — **this is the
        only time the raw secret is ever exposed**. Store it now.

        **Rate limit (#355, 2026-07-09)**: per-tenant token bucket,
        default 5 creates per hour. On exceeded → HTTP 429 with a
        `Retry-After` header (integer seconds until next refill) and
        body `{ error: "rate_limited", message, retry_after_ms,
        details: { subject, retry_after_ms } }`. Tune via
        `GQ_API_KEY_CREATE_RATE_LIMIT` (`<N>/<period_secs>` form) or
        disable with `"disabled"`. Metrics:
        `gq_cloud_api_key_creates_total{tenant}` +
        `gq_cloud_api_key_creates_rate_limited_total{tenant}`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudCreateApiKeyRequest" }
      responses:
        "200":
          description: OK — raw secret in `key`. Save it now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NewApiKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429":
          description: |
            Too Many Requests — per-tenant token-bucket exhausted
            (#355). `Retry-After` header carries integer seconds
            until the next single-token refill.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
              description: Seconds until next refill.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudRateLimitBody" }

  /cloud/me/api-keys/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [cloud-me]
      summary: Revoke one of the calling tenant's API keys
      description: |
        Revocation is immediate; no grace period. The key row is
        preserved (with `revoked_at` stamped) for audit — subsequent
        listings show it under the `revoked=true` filter.
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cloud/me/usage:
    get:
      tags: [cloud-me]
      summary: Per-tenant usage snapshot (storage bytes + recent events)
      description: |
        Walks each graph's on-disk directory to compute
        `storage_bytes` live, then joins the last 100 usage events
        from the control DB. Cheap for the tenant's first dozen
        graphs; polled by the dashboard once per page-load.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudMeUsageResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ───── Cloud admin (root API key) ─────

  /cloud/tenants:
    get:
      tags: [cloud-admin]
      summary: List every tenant (operator view)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenants:
                    type: array
                    items: { $ref: "#/components/schemas/CloudTenant" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /cloud/tenants/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [cloud-admin]
      summary: Get a single tenant
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudTenant" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [cloud-admin]
      summary: Delete a tenant + all its data (DESTRUCTIVE, GDPR Art. 17)
      description: |
        **DESTRUCTIVE — no undo (past the 30-day audit-log window).**
        Steps (from the tenant's perspective, atomic):

          1. Validate the tenant exists and is not already `Terminated`.
          2. Drop every graph in the data plane (GraphRegistry) +
             mark each control-DB graph row as `Terminating`.
          3. Revoke every data-plane API key with `tenant == id`.
          4. Flip control-DB tenant status straight to `Terminated`
             (skipping `Terminating` because we do the work
             synchronously here).

        Failure of individual graph drops or key revokes is logged and
        returned in the `errors` array but does NOT block the
        termination — a stuck-fd graph dir shouldn't leave a customer
        who pulled the trigger in `Terminating` limbo.

        The `cloud.sqlite` row is preserved for 30 days for the audit
        log (`recordkeeping` requirement); a separate
        `purge_terminated()` job hard-deletes it afterward.
      responses:
        "200":
          description: Terminated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_id: { type: string }
                  status: { type: string, enum: [terminated] }
                  graphs_dropped: { type: array, items: { type: string } }
                  api_keys_revoked: { type: integer, minimum: 0 }
                  errors: { type: array, items: { type: string } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Tenant already terminated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }

  /cloud/tenants/{id}/graphs:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [cloud-admin]
      summary: List a tenant's graphs (operator view)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  graphs:
                    type: array
                    items: { $ref: "#/components/schemas/CloudGraph" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [cloud-admin]
      summary: Provision a graph on behalf of a tenant
      description: |
        Same end-to-end flow as `POST /cloud/me/graphs`: graph dir +
        tenancy.json + scoped `gq_` key + control-DB row in one
        atomic call. On fresh-provision the response includes the raw
        `api_key` **once** plus an `mcp_config` block and a curl
        example the operator can hand back to the customer. On
        idempotent-replay (`already_existed=true`) `api_key`,
        `mcp_config`, and `examples.curl` are `null`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudCreateGraphRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudCreateGraphResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: Tenant status can't provision or plan cap reached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }

  /cloud/tenants/{id}/api-keys:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [cloud-admin]
      summary: List a tenant's API keys (metadata only)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKeyView" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [cloud-admin]
      summary: Record an API key row for a tenant
      description: |
        Operator-scoped mint. Current MVP shape only records intent in
        the control DB (`gq_pending` placeholder prefix); the paired
        data-plane mint against `/admin/api-keys` still returns the
        raw `gq_` secret separately. When the data-plane provisioning
        client is wired here the raw key will land in the response.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudCreateApiKeyRequest" }
      responses:
        "200":
          description: OK — control-DB row recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudCreateApiKeyResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /cloud/tenants/{id}/api-keys/{key_id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
      - { name: key_id, in: path, required: true, schema: { type: string } }
    delete:
      tags: [cloud-admin]
      summary: Revoke a tenant's API key
      description: |
        Revocation is immediate; no grace period. The row is
        preserved with `revoked_at` stamped for audit.
      responses:
        "204": { description: Revoked }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cloud/tenants/{id}/usage:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [cloud-admin]
      summary: Usage events for a tenant (last 100)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/CloudUsageEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cloud/tenants/{id}/billing:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    get:
      tags: [cloud-admin]
      summary: Billing info (subscription + invoices)
      description: |
        Joins the tenant's `subscriptions` row (Stripe-driven state:
        trial / active / past_due / cancelled + current period end)
        with `invoices` for that tenant. Rendered by the operator
        billing pane.
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudBillingResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cloud/tenants/{id}/checkout-session:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [cloud-admin]
      summary: Initiate a Stripe Checkout session (redirect URL)
      description: |
        Returns a Stripe-hosted Checkout URL the caller redirects the
        customer to for card collection. Stripe's Checkout handles
        payment, then fires `checkout.session.completed` → the paired
        webhook lands via `POST /cloud/webhooks/stripe`.

        **Free plans**: skipped entirely — the tenant's `plan_id`
        flips in-place and the response returns `{ free: true, url:
        null, session_id: null }` with no Stripe round-trip.

        Env-driven config:
          * `GQ_STRIPE_SECRET_KEY` — Stripe restricted / secret key.
            Without it the endpoint returns 502 `data_plane`
            (`Stripe not configured`).
          * `GQ_STRIPE_PRICE_ID_<PLAN>` — Stripe price id per plan.
            `<PLAN>` is the upper-cased `plan_id` from the body.
          * `GQ_STRIPE_MOCK_URL` — test / dev escape hatch. When set,
            returns this URL synthetically with a fake session id and
            skips the Stripe API call. **Production NEVER sets this.**

        On successful Stripe response, the returned `customer` id is
        bound to the tenant's `stripe_id` so subsequent webhook events
        can resolve `customer → tenant`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudCheckoutSessionRequest" }
      responses:
        "200":
          description: OK — redirect the customer to `url`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudCheckoutSessionResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "502":
          description: Stripe unreachable / mis-configured.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }

  /cloud/tenants/{id}/portal-session:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string } }
    post:
      tags: [cloud-admin]
      summary: Stripe Customer Portal URL (self-serve billing management)
      description: |
        Returns a URL for Stripe's hosted Customer Portal — the
        customer can update their card, cancel subscriptions, and
        view invoice history without an operator round-trip.

        **Precondition**: the tenant must have a `stripe_id` bound
        (i.e. must have completed at least one Checkout). If not,
        returns HTTP 409 with a message steering the caller to
        `POST /cloud/tenants/{id}/checkout-session` first.

        Same env-var story as checkout-session: `GQ_STRIPE_SECRET_KEY`
        required for real traffic; `GQ_STRIPE_MOCK_URL` short-circuits
        for tests.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CloudPortalSessionRequest" }
      responses:
        "200":
          description: OK — redirect the customer to `url`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Tenant has no Stripe customer yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }
        "502":
          description: Stripe unreachable / mis-configured.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CloudErrorBody" }

components:
  securitySchemes:
    replicationToken:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        Inter-node + cluster-ops credential. Operators set
        `GQ_REPLICATION_TOKEN` on every node + supply it as
        `Authorization: Bearer <token>` on every `/raft/*`,
        `/cluster/*`, and `/_replicate/*` call. When the env is
        unset on the server, the routes accept any caller (private-
        network deployment mode).
  parameters:
    Name:
      name: name
      in: path
      required: true
      description: "Graph name. Must match `^[a-zA-Z0-9_-]{1,64}$`."
      schema:
        type: string
        pattern: "^[a-zA-Z0-9_-]{1,64}$"
    TemplateName:
      name: templateName
      in: path
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    NotFound:
      description: Not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorBody" }

  schemas:
    ErrorBody:
      type: object
      description: |
        Structured error envelope. ADR-0073. Stable contract — locked in
        docs/STABLE-CONTRACTS.md. Field additions are always OK; renames
        require a deprecation window.
      properties:
        error:
          type: string
          description: |
            Legacy short-name field (`cypher_exec`, `cypher_parse`,
            `bad_request`, `not_found`, etc.). Customer log-scrapers key
            off this — never renamed.
        code:
          type: string
          description: |
            Structured error code. Wave 2 (shipped 2026-06-09) values:

            Caller-correctable (never retry):
              * `ENG_UNKNOWN_PROCEDURE` — unknown `CALL` target.
                `details = {procedure, did_you_mean: [...]}`.
              * `ENG_MISSING_NODE` — MERGE / DETACH DELETE / SET
                targeted a node that doesn't exist.
                `details = {hint}`.
              * `ENG_SCHEMA_VIOLATION` — UNIQUE constraint, vector
                dim/metric mismatch, or schema-version conflict.
                `details = {hint}`.
              * `ENG_CLIENT_ERROR` — fallback for caller-correctable
                errors not matching a specific shape above.

            Transient (retry with backoff):
              * `ENG_BACKPRESSURE` — per-graph concurrency cap or
                per-subject rate limit. `details = {subject?,
                retry_after_ms}`. `retry_after_ms` is also at the
                top level for back-compat with wave-1 clients.
              * `ENG_QUERY_TIMEOUT` — query exceeded the wall-clock
                budget. Retry-once semantics; if it fails again, fix
                the query. `details = {hint}`.
              * `ENG_LEADER_REROUTE` — Raft non-leader read. Retry
                against the leader. `details = {hint}`.
              * `ENG_QUERY_CANCELLED` — query was cancelled (operator
                kill via `/admin/queries/{id}/kill`, or client
                disconnect after the executor checked the cancel
                token). HTTP 499. `details = {hint}`. Added 2026-06-29
                (#283 / ADR-0107).

            Internal:
              * `ENG_INTERNAL` — engine bug or unrecoverable storage
                failure. Not retryable; should be reported.

            Note on `PER_GRAPH_QUERY_BUDGET`: the per-graph admission
            gate (ADR-0105 Phase 3c / #282) refuses queries with HTTP
            503 + a STANDALONE envelope (`PerGraphBudgetRefusal`),
            NOT this `ErrorBody`. Its `code` is the bare
            `PER_GRAPH_QUERY_BUDGET` literal; clients should branch on
            `code` to disambiguate from the wave-2 `ENG_BACKPRESSURE`
            envelope that read-side backpressure produces at the same
            status.

            New codes may be added freely; existing codes never
            renamed. See ADR-0073 + ADR-0107 + docs/STABLE-CONTRACTS.md.
          enum:
            - ENG_UNKNOWN_PROCEDURE
            - ENG_MISSING_NODE
            - ENG_SCHEMA_VIOLATION
            - ENG_CLIENT_ERROR
            - ENG_BACKPRESSURE
            - ENG_QUERY_TIMEOUT
            - ENG_LEADER_REROUTE
            - ENG_QUERY_CANCELLED
            - ENG_INTERNAL
        subcode:
          type: string
          nullable: true
          description: |
            INV_SCHEMA (#283, 2026-06-29 / ADR-0107): finer-grained
            discriminator inside the caller-correctable class.
            Populated for `error: cypher_parse` (always `SYNTAX_ERROR`
            today) and for `error: cypher_exec`. Stable per the
            wave-2 contract — new values may be added without notice,
            existing values never renamed. Absent on non-cypher errors
            (`bad_request`, `not_found`, storage errors, etc.).
          enum:
            - SYNTAX_ERROR
            - PROCEDURE_NOT_FOUND
            - ARGUMENT_TYPE_MISMATCH
            - MISSING_PROPERTY
            - UNRESOLVED_VARIABLE
            - AGGREGATE_MISUSE
            - ANCHOR_REQUIRED
            - UNSUPPORTED_FEATURE
            - UNKNOWN_YIELD_COLUMN
            - BUDGET_EXCEEDED
            - PRIVATE_MODE_NON_AGGREGATE_RETURN
            - PRIVATE_MODE_AGG_NOT_SUPPORTED
            - PRIVATE_MODE_UNCLAMPED_PROPERTY
            - PRIVATE_MODE_BUDGET_EXHAUSTED
            - PRIVATE_MODE_PER_QUERY_EXCEEDED
            - SPILL_DISABLED
            - SPILL_BUDGET_EXCEEDED
            - SPILL_IO
            - SPILL_ENCODE
            - SPILL_DECODE
            - RUNTIME_ERROR
        position:
          $ref: "#/components/schemas/Position"
        message:
          type: string
          description: Human-readable explanation. Wording may change freely.
        retry_after_ms:
          type: integer
          nullable: true
          description: |
            Milliseconds to wait before retry. Set when `code` is
            `ENG_BACKPRESSURE` (top-level shortcut for the wave-1
            shape; the same value is in `details.retry_after_ms`).
            Also surfaced in the `Retry-After` HTTP header (rounded
            to seconds).
        details:
          type: object
          nullable: true
          description: |
            Per-code structured context. Shape depends on `code` (and,
            for cypher errors, on `subcode`); see the `code` and
            `subcode` enum docs. Absent on codes that don't carry
            context (`ENG_CLIENT_ERROR` fallback, `ENG_INTERNAL`).
            Callers should treat unknown keys as informational — new
            keys may be added without notice.

            INV_SCHEMA (#283 / ADR-0107) per-`subcode` shapes for
            `error: cypher_parse` and `error: cypher_exec`:

              * `SYNTAX_ERROR` →
                `{expected: [string], found?: string}` (either field
                may be absent — empty `expected` and `found == None`
                both collapse to omitted).
              * `PROCEDURE_NOT_FOUND` →
                `{procedure: string, did_you_mean: [string]}`.
              * `ARGUMENT_TYPE_MISMATCH` → `{op: string}`.
              * `MISSING_PROPERTY` → `{var: string, prop: string}`.
              * `UNKNOWN_YIELD_COLUMN` (2026-07-03 / #305) →
                `{column: string, hint: string}`. Emitted when a
                `YIELD` list requests a name the procedure does not
                produce and `GQ_UNKNOWN_YIELD_HARD_ERROR` is on
                (default post-2026-07-01). Envelope `code` stays
                `ENG_CLIENT_ERROR`.

            Pre-INV_SCHEMA wave-2 shapes (still emitted on the same
            `code` values):

              * `ENG_UNKNOWN_PROCEDURE` →
                `{procedure, did_you_mean: [string]}`.
              * `ENG_MISSING_NODE` → `{hint}`.
              * `ENG_SCHEMA_VIOLATION` →
                `{hint, constraint?, value?}`.
              * `ENG_LEADER_REROUTE` → `{hint}`.
              * `ENG_BACKPRESSURE` → `{subject?, retry_after_ms}`.
              * `ENG_QUERY_TIMEOUT` → `{hint}`.
              * `ENG_QUERY_CANCELLED` → `{hint}`.
      required: [error, code, message]
    Position:
      type: object
      description: |
        INV_SCHEMA (#283 / ADR-0107): 1-indexed source position for
        `error: cypher_parse` failures. Absent when the parser
        couldn't produce a span (e.g. the rare `cypher_parse_simple`
        path where only a `String` is available).
      required: [line, col]
      properties:
        line: { type: integer, minimum: 1 }
        col:  { type: integer, minimum: 1 }
    PerGraphBudgetRefusal:
      type: object
      description: |
        Standalone HTTP 503 envelope for the per-graph query-bytes
        admission gate (ADR-0105 Phase 3c / #282, shipped 2026-06-29).
        Distinct from `ErrorBody` — the `code` value is the bare
        `PER_GRAPH_QUERY_BUDGET` literal, not an `ENG_*` family member,
        because the refusal predates cypher parse and never flows
        through the wave-2 classifier. Clients should branch on `code`
        to tell this apart from the `ENG_BACKPRESSURE` (`ErrorBody`)
        shape that read-side backpressure produces at the same status.
      required: [error, code, graph, current_bytes, retry_after_ms]
      properties:
        error:
          type: string
          enum: [per_graph_query_budget]
          description: "Legacy short-name; matches the field on `ErrorBody` so log-scrapers keep working."
        code:
          type: string
          enum: [PER_GRAPH_QUERY_BUDGET]
        graph:
          type: string
          description: "Graph that tripped the budget."
        current_bytes:
          type: integer
          format: int64
          description: "Working-set bytes observed for this graph at refusal time (resident + in-flight per-query materialised)."
        budget_bytes:
          type: integer
          format: int64
          nullable: true
          description: "Resolved cap in bytes. `null` would mean the gate is disabled — in practice this only appears on actual refusals, so the value is always a number here."
        source:
          type: string
          description: "Which tier produced the resolved budget."
          enum:
            - file_per_graph
            - file_per_graph_unlimited
            - env_default
            - env_unlimited
            - hardcoded_default
        retry_after_ms:
          type: integer
          default: 30000
          description: "Milliseconds to wait before retry. Currently always 30000; pairs with the `Retry-After: 30` HTTP header."
    RateLimits:
      type: object
      description: "Per-key query / mutate budgets. Units are operations per minute."
      properties:
        query: { type: integer, nullable: true }
        mutate: { type: integer, nullable: true }
    PrivacyMode:
      type: object
      description: |
        #271 / ADR-0072: differential-privacy configuration on an API
        key. When set, the holder is restricted to scalar aggregate
        queries (`count` / `sum` / `avg`); each result is Laplace-noised
        and charged against the principal's persistent ε budget.
      required: [epsilon_budget, period, max_per_query]
      properties:
        epsilon_budget:
          type: number
          format: double
          description: "Total ε per period. Smaller = stronger privacy."
          example: 1.0
        period:
          type: string
          description: "Reset cadence."
          enum: [Daily, PerQuery, Lifetime]
        max_per_query:
          type: number
          format: double
          description: "Refuses any single query whose ε spend exceeds this."
          example: 0.1
    ApiKeyView:
      type: object
      description: |
        Wire-format view of one API key entry. The secret token is
        NEVER returned through this shape — only via the one-shot
        `NewApiKey` response at create time.
      properties:
        id: { type: string }
        prefix: { type: string, description: "First 8 chars of the gq_ token, displayed in the UI." }
        tenant: { type: string }
        name: { type: string, nullable: true }
        role: { type: string, nullable: true }
        graphs:
          type: array
          nullable: true
          items: { type: string }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, nullable: true }
        expired: { type: boolean }
        rateLimits:
          $ref: "#/components/schemas/RateLimits"
        privacyMode:
          $ref: "#/components/schemas/PrivacyMode"
        ownerEmail:
          type: string
          nullable: true
          description: |
            #257 (2026-06-27): per-key audit linkage to a human owner. Set
            from JWT on self-serve mints, from request body on operator mints.
            Pre-#257 entries omit this field.
        cognitoSub:
          type: string
          nullable: true
          description: |
            #257: Cognito `sub` of the minter. Stable across email changes.
        revoked: { type: boolean }
    NewApiKey:
      type: object
      description: |
        Returned ONCE at create time. The `key` field is the only
        place the operator will ever see the full secret.
      properties:
        id: { type: string }
        key: { type: string, description: "The full `gq_...` secret. Save it now — never reproducible from the store afterward." }
        prefix: { type: string }
        tenant: { type: string }
        created_at_ms: { type: integer, format: int64 }
        name: { type: string, nullable: true }
        role: { type: string, nullable: true }
        graphs:
          type: array
          nullable: true
          items: { type: string }
        expires_at_ms: { type: integer, format: int64, nullable: true }
        rate_limits:
          $ref: "#/components/schemas/RateLimits"
        privacyMode:
          $ref: "#/components/schemas/PrivacyMode"
        ownerEmail:
          type: string
          nullable: true
          description: |
            #257 (2026-06-27): echoed back from the create request body (or
            stamped from JWT on self-serve mints). Omitted on pre-#257 mints.
        cognitoSub:
          type: string
          nullable: true
          description: |
            #257: echoed back from the create request body or stamped from JWT.
    CypherRequest:
      type: object
      required: [query]
      description: |
        **Field aliases:** `query` is the canonical field name; `cypher` is
        accepted as an alias because the endpoint is named `/cypher` and
        callers reasonably reach for `{"cypher": "…"}` first try. Both work
        long-term. Similarly `params` is canonical; `parameters` is accepted
        as an alias. Send whichever pair feels natural — the engine normalizes
        internally. Documented for Lux feedback #2 (2026-07-09).
      properties:
        query:
          type: string
          description: |
            The Cypher query to execute. Canonical field name; `cypher` is
            accepted as an alias (see request-body description above).
        atTime:
          type: string
          format: date-time
          description: |
            Optional ISO-8601 timestamp. When set, the engine parses the query,
            walks every MATCH clause, and broadcasts `AT VALID '<atTime>'` so the
            query reads the bitemporal snapshot at that time. Rejected with HTTP 400
            if the query already contains an explicit `AT VALID` / `AT RECORDED`
            clause (silent override prevention). New in 2026-06-27 (#268).
          example: "2025-01-01T00:00:00Z"
        params:
          type: object
          additionalProperties: true
          description: |
            Named parameter map bound at execute time. Canonical field name;
            `parameters` is accepted as an alias.
    CypherResponse:
      type: object
      properties:
        columns: { type: array, items: { type: string } }
        rows: { type: array, items: { type: array } }
        stats:
          type: object
          properties:
            rows_returned: { type: integer }
            mutations: { type: integer }
            graph_seq: { type: integer, nullable: true }
        mutations_applied: { type: integer }
        last_seq: { type: integer, nullable: true }
        warnings:
          type: array
          description: |
            Soft warnings from the executor. Notable codes added 2026-06-27:
            `TEMPORAL_PROPERTY_PREFILTER` (AS OF / AT VALID with property pin —
            current-truth filter applied; may drop bitemporally-correct rows),
            `INDEX_TYPE_DEFAULTED` (CREATE INDEX on empty label — value_type
            defaulted to `String` on scalar CREATE INDEX, `Int` on composite
            CREATE INDEX; `details.defaulted_type` names the choice;
            `details.escape_hatch` names the OPTIONS override syntax; see
            RUNBOOK §12). `INDEX_TYPE_PENDING` is retired as of 2026-07-04 —
            the Cypher path no longer emits it; `INDEX_TYPE_DEFAULTED` is
            its successor. `TEMPORAL_INDEX_PENDING` continues to fire on
            every `CREATE TEMPORAL INDEX` but now carries the ADR-0100
            phase-2b/c "bitemporal read pickup not yet built" meaning —
            it is no longer an empty-label-pending signal.
            `EVIDENCE_TRUNCATED` (db.beliefLineage hit per-version evidence cap).
            Added 2026-06-29 (ADR-0110): `VECTOR_CACHE_EVICTED_CROSS_GRAPH`
            (a `db.dropVectorIndex` call evicted SearchCache entries that
            belonged to other graphs — informational, see RUNBOOK §12c).
            `RELABEL_PARTIAL` is NEVER emitted — `db.relabel` is atomic
            (ADR-0109); a chunk-budget overflow fails the whole call.
            2026-06-29 (#283 / #285): structured `error.subcode` envelope
            carries 9 discriminators (`PROCEDURE_NOT_FOUND`,
            `ARGUMENT_TYPE_MISMATCH`, `MISSING_PROPERTY`, plus six more);
            legacy `error` / `message` fields stay byte-identical.
          items:
            type: object
            properties:
              code: { type: string }
              message: { type: string }
              severity: { type: string, enum: [info, warn, error] }
              details: { type: object, additionalProperties: true }
    SaveTemplateRequest:
      type: object
      required: [name, root]
      properties:
        name: { type: string }
        root: { type: string, description: "Root label." }
        idProperty: { type: string, default: id }
        properties:
          type: array
          items: { type: string }
        embed:
          type: array
          items: { type: object }
        computed:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              expr: { type: string }
        validation:
          type: array
          items: { type: object }
        aliases:
          type: array
          items: { type: string }
        deprecated: { type: boolean }
    SavedTemplate:
      type: object
      properties:
        version: { type: integer }
        contentHash: { type: string }
        createdAt: { type: string }
        updatedAt: { type: string }
        breaking: { type: boolean }
        changeType:
          type: string
          enum: [initial, update, "no-change"]
        breakingReasons:
          type: array
          items: { type: string }
        meta:
          type: object
          properties:
            rootLabel: { type: string }
            referencedLabels: { type: array, items: { type: string } }
            referencedEdgeTypes: { type: array, items: { type: string } }
            maxDepth: { type: integer }
            hasComputed: { type: boolean }
            hasFilter: { type: boolean }
            isDeterministic: { type: boolean }
    NlqResponse:
      type: object
      properties:
        question: { type: string }
        generated_query: { type: string, nullable: true }
        parsed_ok: { type: boolean }
        validation_ok: { type: boolean }
        warnings:
          type: array
          items: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              code: { type: string }
              message: { type: string }
        repair_attempted: { type: boolean }
        repaired_query: { type: string, nullable: true }
        results:
          type: object
          nullable: true
          properties:
            columns: { type: array, items: { type: string } }
            rows: { type: array, items: { type: array } }
            rowCount: { type: integer }
        explanation: { type: string, nullable: true }
    Caps:
      type: object
      properties:
        max_tool_calls: { type: integer, default: 12 }
        max_turns: { type: integer, default: 8 }
        deadline_secs: { type: integer, default: 60 }
        max_total_tokens: { type: integer, default: 50000 }
    RunStats:
      type: object
      properties:
        turns: { type: integer }
        tool_calls: { type: integer }
        input_tokens: { type: integer }
        output_tokens: { type: integer }
        total_tokens: { type: integer }
        elapsed_ms: { type: integer }
    ImportAccepted:
      type: object
      properties:
        jobId: { type: string }
        status: { type: string, enum: [running] }
        operationCount: { type: integer }
        polling: { type: string }
    RenameJobSummary:
      type: object
      description: |
        Result body for `POST /graphs/{name}/rename`. Identical shape
        for both the sync path (`wait=true` `200` body) and the async
        path (final `JobRecord.result` after polling `GET /jobs/{jobId}`
        with `wait=false`). Byte-identical to the #283 sync
        `GraphSummary` — clients that don't set `wait` see no change.
        Added 2026-07-02 with the #287 async rename branch.
      required: [name, current_snapshot_seq, next_seq]
      properties:
        name:
          type: string
          description: "New graph name after the rename lands."
        current_snapshot_seq:
          type: integer
          format: int64
          description: |
            Sequence number of the current published snapshot at the
            moment the rename completed. Same value the
            `GET /graphs/{name}` summary would return immediately
            after the rename.
        next_seq:
          type: integer
          format: int64
          description: |
            Next sequence the writer will publish. Together with
            `current_snapshot_seq` this pins the graph's state at
            hand-off; callers can use it to detect writers that
            landed between the rename request and the poll response.
    JobRecord:
      type: object
      description: |
        Generic async-job envelope returned by `GET /jobs/{jobId}`.
        Shared shape for every async endpoint (rename, embedding
        backfill, import). `result` carries the endpoint-specific
        payload once `status` reaches `succeeded` — for the rename
        async branch (`POST /graphs/{name}/rename?wait=false`) that
        payload is a `RenameJobSummary`.
      required: [jobId, status]
      properties:
        jobId:
          type: string
          description: "Opaque job id — the same one returned in the accept envelope."
        status:
          type: string
          enum: [pending, running, succeeded, failed, cancelled]
          description: |
            Lifecycle state. Async rename transitions
            `pending` → `running` → `succeeded` | `failed`;
            `cancelled` is reserved for operator kill (not surfaced
            by the rename branch today).
        kind:
          type: string
          nullable: true
          description: |
            Job kind discriminator (e.g. `rename`, `embedding-backfill`,
            `import`). Callers can dispatch on this before deciding
            how to interpret `result`.
        graph:
          type: string
          nullable: true
          description: |
            Graph the job applies to. For `rename` this is the
            *destination* name (post-rename), matching
            `RenameJobSummary.name`.
        progress:
          type: object
          nullable: true
          description: |
            Per-kind progress counters (e.g. `{processed, succeeded, failed}`
            for embedding backfill). Absent for the rename branch —
            rename is atomic and doesn't emit intermediate progress.
        result:
          nullable: true
          description: |
            Terminal payload, populated when `status` is `succeeded`.
            Shape depends on `kind`:

              * `rename` → `RenameJobSummary`
              * `embedding-backfill` → backfill summary (see
                `/admin/embeddings/backfill/{job_id}`)
              * `import` → import counters (see `ImportAccepted`)
        error:
          type: object
          nullable: true
          description: |
            Terminal failure envelope, populated when `status` is
            `failed`. Uses the standard `ErrorBody` shape so the
            caller can inspect `code`/`subcode`/`details` identically
            to a sync error response.
        started_at_ms:
          type: integer
          format: int64
          nullable: true
          description: "Unix millis when the worker picked the job up. Null while `pending`."
        completed_at_ms:
          type: integer
          format: int64
          nullable: true
          description: "Unix millis when the job reached a terminal state. Null while `pending`/`running`."
    MCPRequest:
      type: object
      required: [jsonrpc, method]
      description: |
        JSON-RPC 2.0 request envelope for the Model Context Protocol
        (MCP) endpoints (`POST /mcp`, `POST /graphs/{name}/mcp`). See
        RFC 8259 §2.4 and https://spec.modelcontextprotocol.io for the
        underlying protocol.

        Common methods:
          * `initialize` — MCP handshake; returns protocol version +
            server capabilities.
          * `tools/list` — enumerate available tools (name, input
            schema, description). Clients should call this at session
            start rather than hard-coding the catalog — the gq-mcp tool
            set is versioned with the engine binary.
          * `tools/call` — invoke a tool by name; `params.name` +
            `params.arguments`. All 14 gq-mcp tools dispatch through
            this method.
          * `prompts/list`, `resources/list` — MCP-standard discovery
            calls, returned empty by the current server.
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
          description: "Protocol version. Must be the string `2.0`."
        method:
          type: string
          description: "The MCP method to invoke (e.g. `tools/list`, `tools/call`, `initialize`)."
          example: "tools/list"
        params:
          type: object
          nullable: true
          additionalProperties: true
          description: "Method-specific parameters. Shape depends on `method`."
          example: { name: "engine_info", arguments: {} }
        id:
          oneOf:
            - { type: string }
            - { type: integer }
            - { type: "null" }
          description: |
            Request correlator. Echoed on the response. `null` (or omitted)
            indicates a JSON-RPC notification for which the server MUST
            NOT reply — the current MCP dispatcher still replies to be
            robust against clients that stamp `null` unintentionally.
          example: 1
    MCPResponse:
      type: object
      required: [jsonrpc]
      description: |
        JSON-RPC 2.0 response envelope. Exactly one of `result` /
        `error` is present on a successful vs. failed dispatch. See
        RFC 8259 §2.4 and https://spec.modelcontextprotocol.io for the
        underlying protocol.
      properties:
        jsonrpc:
          type: string
          enum: ["2.0"]
        result:
          type: object
          nullable: true
          additionalProperties: true
          description: |
            Method-specific result payload on success. Shape depends on
            the request `method`. For `tools/list` this is
            `{tools: [{name, description, inputSchema}, ...]}`; for
            `tools/call` this is the tool's own result envelope.
        error:
          type: object
          nullable: true
          description: |
            JSON-RPC 2.0 error object on failure. Standard codes
            (`-32600` invalid request, `-32601` method not found,
            `-32602` invalid params, `-32603` internal error) plus
            MCP-specific codes carried in `data`.
          properties:
            code: { type: integer }
            message: { type: string }
            data:
              type: object
              nullable: true
              additionalProperties: true
        id:
          oneOf:
            - { type: string }
            - { type: integer }
            - { type: "null" }
          description: "Echoes the request `id` verbatim (JSON-RPC 2.0 §5)."

    # ───── Per-graph customer-surface schemas (#370 PR-5) ─────

    NodeRecord:
      type: object
      description: |
        Canonical node record shape (see `gq_format::NodeRecord`). Used
        by `/graphs/{name}/node-by-str/{str_id}` and `/nodes/{id}`.
        Multi-label nodes carry every label in the `labels` array
        (multi-label semantics — CLAUDE.md rule "multi-label nodes
        write to every label file" preserves this on the wire).
        `properties` uses a stable BTreeMap ordering so bincode
        encoding is deterministic (feeds SHA-256 lineage stamping at
        compaction).
      required: [id, labels, properties, span]
      properties:
        id:
          type: integer
          format: int64
          description: Internal 64-bit `NodeId`.
        labels:
          type: array
          items: { type: string }
          description: Every label the node carries. Non-empty for created nodes.
        properties:
          type: object
          additionalProperties: true
          description: Property map with per-value `gq_core::Value` shapes. Field-ACL redaction (#70) strips denied properties before serialization.
        span:
          type: object
          description: |
            Bitemporal span (`BitemporalSpan`). Carries `valid_from`,
            `valid_to`, `recorded_from`, `recorded_to`. Open ranges use
            timestamp sentinel `Timestamp::MAX`.
          additionalProperties: true

    EdgeRecord:
      type: object
      description: |
        Canonical edge record shape (see `gq_format::EdgeRecord`). Used
        by `/graphs/{name}/edge-by-str/{str_id}` and `/edges/{id}`.
        Bitemporal `span` same shape as `NodeRecord.span`.
      required: [id, edge_type, source, target, properties, span]
      properties:
        id:
          type: integer
          format: int64
          description: Internal 64-bit `EdgeId`.
        edge_type:
          type: string
          description: Relationship type (typed as `TYPE` in Cypher).
        source:
          type: integer
          format: int64
          description: Internal `NodeId` of the tail endpoint.
        target:
          type: integer
          format: int64
          description: Internal `NodeId` of the head endpoint.
        properties:
          type: object
          additionalProperties: true
        span:
          type: object
          additionalProperties: true

    SchemaDefinition:
      type: object
      description: |
        Opaque schema-document shape (`engine.current_schema().body`).
        Round-trips through the v1 schema editor via
        `GET/PUT/DELETE /graphs/{name}/schema/definition`. Well-known
        keys: `labels`, `properties` (per-label, with `.type`, `.acl`,
        `.encryption`, `.vector`), `constraints`. The engine tolerates
        additional keys — `validate_schema_body` (#286) only surfaces
        warnings on missing / malformed known keys.
      properties:
        labels:
          type: array
          items: { type: string }
          description: Declared node labels (informational; multi-label semantics still apply).
        edgeTypes:
          type: array
          items: { type: string }
          description: Declared edge/relationship types.
        properties:
          type: object
          additionalProperties: true
          description: |
            Per-label property declarations. Supported keys per property
            (see the `/graphs/{name}/schema` POST description for the
            full grammar): `type`, `acl.{read,write,purpose}`,
            `encryption.subject_id_property`, and for vector
            declarations `{type=vector,dim,metric,model,modelVersion,index}`.
        constraints:
          type: array
          items:
            type: object
            additionalProperties: true
      additionalProperties: true

    GraphConfig:
      type: object
      description: |
        Per-graph config toggles. In v2 `bitemporal` is always `true`;
        `worm` and `accessLog` are surfaced honestly as `false` with a
        `_note` — neither toggle is wired at the storage layer today.
        Real WORM engagement happens via `POST /graphs/{name}/worm/engage`.
      properties:
        bitemporal: { type: boolean, description: "Always `true` in v2 (bitemporal-always)." }
        worm: { type: boolean, description: "Always `false` — not implemented in v2 storage." }
        accessLog: { type: boolean, description: "Always `false` — not implemented in v2 storage." }
        _note: { type: string }
      required: [bitemporal, worm, accessLog]

    BuildBilabelsResponse:
      type: object
      description: |
        Result of `POST /graphs/{name}/build-bilabels`. `labels_built`
        is the count of labels that had a missing BIL file backfilled
        on this call; already-present BILs are skipped. `total_bytes`
        is the on-disk size of the newly-written BIL files (does not
        include hardlinked ones from prior snapshots).
      required: [labels_built, total_bytes]
      properties:
        labels_built:
          type: integer
          description: "How many BIL files were freshly built."
        total_bytes:
          type: integer
          format: int64
          description: "Bytes written across the fresh BIL files."

    ComplianceSummary:
      type: object
      description: |
        One-shot compliance dashboard summary. Enterprise-only
        (ADR-0077); Community binary returns HTTP 403. Every advertised
        `_endpoints` entry is itself Enterprise-gated.
      required: [graph, auditChain, wormMode, accessLog]
      properties:
        graph: { type: string }
        auditChain:
          type: string
          description: |
            Availability marker. `"available"` when the audit chain
            has been walked at least once; other values reserved for
            future error surfaces.
          example: available
        wormMode:
          type: boolean
          description: "Whether the graph has WORM engaged."
        accessLog:
          type: boolean
          description: "Whether access-log ingestion is currently on."
        lastVerified:
          type: string
          format: date-time
          nullable: true
          description: "Timestamp of the last successful audit-chain verify. `null` until a verify runs."
        _endpoints:
          type: object
          description: |
            Pointers to related routes: `verify` →
            `POST /graphs/{name}/audit/verify`; `export` →
            `POST /graphs/{name}/compliance/export`. Both Enterprise.
          properties:
            verify: { type: string }
            export: { type: string }

    SeedStatusResponse:
      type: object
      description: |
        Poll shape for `GET /graphs/{name}/seed-status`. Two branches:
        (1) no seed job has ever run → `available: false`, `seeded:
        false`, and a `message`; (2) a seed job exists → `available:
        true` plus the job's current lifecycle state.
      required: [available, seeded]
      properties:
        available: { type: boolean, description: "Whether a seed job record exists for the graph." }
        seeded: { type: boolean, description: "True only when the most recent job reached the `completed` state." }
        running: { type: boolean, description: "True when the most recent job is `pending` or `running`." }
        jobId: { type: string, description: "Present when `available: true`." }
        jobState:
          type: string
          enum: [pending, running, completed, failed]
          description: "Present when `available: true`."
        progress:
          type: number
          format: float
          description: "0.0 – 1.0 progress marker when `running` is true. Present when `available: true`."
        result:
          type: object
          nullable: true
          additionalProperties: true
          description: "Job result payload; nullable while running."
        kind:
          type: string
          description: "Job kind — `Seed` for these entries. Present when `available: true`."
        message:
          type: string
          description: "Present only on the `available: false` branch."

    SeedTriggerResponse:
      type: object
      description: |
        Result of `POST /graphs/{name}/seed`. Two branches: (a) HTTP
        202 with `available: true, async: true` + `jobId` for polling
        (accepted seed job); (b) HTTP 200 with `available: false` +
        a `message` pointing at the Import flow (template not bundled
        on this server). Both branches use the same shape — key on
        `available` to disambiguate.
      required: [ok, available, template]
      properties:
        ok: { type: boolean }
        available: { type: boolean, description: "True when the template's JSONL bundle is present on-disk." }
        async: { type: boolean, description: "Present on the 202 branch — always `true` when set." }
        jobId: { type: string, description: "Present on the 202 branch. Poll via `GET /jobs/{jobId}`." }
        template: { type: string }
        polling: { type: string, description: "Suggested polling URL for the caller — present on the 202 branch." }
        nodesCreated: { type: integer, description: "Present on the 200 unavailable branch (always 0)." }
        edgesCreated: { type: integer, description: "Present on the 200 unavailable branch (always 0)." }
        message: { type: string }

    # ───── Cloud control-plane schemas (gq-cloud crate) ─────

    CloudErrorBody:
      type: object
      description: |
        Cloud-plane error envelope emitted by `CloudHttpError`. Distinct
        from the data-plane `ErrorBody` because the cloud-plane error
        vocabulary is narrower (Validation / Conflict / TenantNotFound /
        GraphNotFound / ApiKeyNotFound / PlanNotFound / DataPlane).
      required: [error, message]
      properties:
        error:
          type: string
          description: |
            Short code identifying the failure class.
          enum:
            - validation
            - conflict
            - tenant_not_found
            - graph_not_found
            - api_key_not_found
            - plan_not_found
            - data_plane
            - internal
        message:
          type: string
          description: Human-readable detail.

    CloudRateLimitBody:
      type: object
      description: |
        Rate-limited body shape (#355). Same wire shape as the data-plane
        `ENG_BACKPRESSURE` envelope, so shared SDK back-off code paths
        can key off `retry_after_ms`.
      required: [error, message, retry_after_ms]
      properties:
        error:
          type: string
          enum: [rate_limited]
        code:
          type: string
          example: ENG_BACKPRESSURE
        message:
          type: string
          example: "rate limit exceeded for tenant:alice@example.com; retry after 3542.87s"
        retry_after_ms:
          type: integer
          format: int64
          minimum: 1
          description: Milliseconds until the next refill.
        details:
          type: object
          properties:
            subject:
              type: string
              description: "`tenant:<Cognito email or sub>` — the drained bucket."
              example: "tenant:alice@example.com"
            retry_after_ms:
              type: integer
              format: int64
              minimum: 1

    CloudRegion:
      type: string
      description: Cloud deployment region (ADR-0079).
      enum: [us, eu]

    CloudTenantStatus:
      type: string
      description: |
        Tenant lifecycle state (cloud-launch.md Phase 1-2).
        `Trial | Active | PastDue` are `data_plane_active` — they can
        make data-plane writes. `Pending | Suspended | Terminating |
        Terminated` cannot.
      enum:
        - pending
        - trial
        - active
        - past_due
        - suspended
        - terminating
        - terminated

    CloudGraphStatus:
      type: string
      description: |
        Graph lifecycle state on the control plane. `Provisioning` rows
        without a paired data-plane graph are swept on the next
        cleanup pass.
      enum:
        - provisioning
        - active
        - suspended
        - terminating

    CloudSubscriptionStatus:
      type: string
      enum: [trial, active, past_due, cancelled]

    CloudTenant:
      type: object
      description: |
        Wire shape of a control-DB `tenants` row.
      required: [id, email, region, status, created_at]
      properties:
        id: { type: string, example: "tnt_a1b2c3d4" }
        email: { type: string, format: email }
        name: { type: string, nullable: true }
        region: { $ref: "#/components/schemas/CloudRegion" }
        stripe_id:
          type: string
          nullable: true
          description: Stripe customer id once Checkout has completed.
        status: { $ref: "#/components/schemas/CloudTenantStatus" }
        plan_id:
          type: string
          nullable: true
          description: Currently-bound plan id (references `plans.id`).
        cognito_sub:
          type: string
          nullable: true
          description: |
            Cognito user-pool `sub` claim bound to this tenant. Set on
            first `POST /cloud/me/bootstrap`. Used to resolve every
            subsequent JWT-authed `/cloud/*` request back to this
            `tenant_id` without a separate lookup table.
        created_at:
          type: string
          format: date-time
          description: ISO-8601 UTC string.

    CloudGraph:
      type: object
      description: Wire shape of a control-DB `graphs` row.
      required: [id, tenant_id, name, status, created_at]
      properties:
        id: { type: string }
        tenant_id: { type: string }
        name:
          type: string
          description: |
            EFFECTIVE prefixed name — what lives on disk + goes in every
            data-plane URL. E.g. `abc123_memory` (not `memory`).
        gq_key_id:
          type: string
          nullable: true
          description: |
            Id of the scoped `gq_` data-plane key minted with this
            graph. The raw secret only ever appeared once in the
            creation response.
        status: { $ref: "#/components/schemas/CloudGraphStatus" }
        created_at: { type: string, format: date-time }

    CloudPlan:
      type: object
      description: Wire shape of a control-DB `plans` row.
      required: [id, display_name, monthly_cents]
      properties:
        id: { type: string, example: "free" }
        display_name: { type: string, example: "Free" }
        monthly_cents:
          type: integer
          format: int64
          minimum: 0
          description: Recurring cost in cents. `0` for the Free plan.
        included_graphs:
          type: integer
          format: int64
          nullable: true
          description: Plan cap on graph count. `null` = unlimited.
        included_gb:
          type: integer
          format: int64
          nullable: true
          description: Plan cap on storage in GB. `null` = unlimited.
        overage_per_gb_cents:
          type: integer
          format: int64
          nullable: true
          description: |
            Charge per GB above `included_gb`. `null` = overage
            forbidden (hard cap).
        features_json:
          type: string
          nullable: true
          description: |
            Free-form JSON blob for plan-specific features (SLA tier,
            regional availability, etc.).

    CloudSubscription:
      type: object
      description: |
        Wire shape of a control-DB `subscriptions` row (one per tenant).
      required: [tenant_id, plan_id, status]
      properties:
        tenant_id: { type: string }
        plan_id: { type: string }
        stripe_sub_id:
          type: string
          nullable: true
        current_period_start:
          type: string
          format: date-time
          nullable: true
        current_period_end:
          type: string
          format: date-time
          nullable: true
        status: { $ref: "#/components/schemas/CloudSubscriptionStatus" }

    CloudInvoice:
      type: object
      description: Wire shape of a control-DB `invoices` row.
      required: [id, tenant_id, amount_cents]
      properties:
        id: { type: string }
        tenant_id: { type: string }
        stripe_inv_id: { type: string, nullable: true }
        period_start: { type: string, format: date-time, nullable: true }
        period_end: { type: string, format: date-time, nullable: true }
        amount_cents: { type: integer, format: int64 }
        paid_at: { type: string, format: date-time, nullable: true }

    CloudUsageEvent:
      type: object
      description: |
        Wire shape of a control-DB `usage_events` row. Emitted by the
        data plane on writes; polled by the operator + tenant usage
        views.
      required: [id, tenant_id, event_type, ts]
      properties:
        id: { type: integer, format: int64 }
        tenant_id: { type: string }
        graph_id: { type: string, nullable: true }
        event_type:
          type: string
          description: |
            Free-form category — e.g. `cypher_query`, `nodes_created`,
            `edges_created`, `storage_bytes`.
        bytes_delta:
          type: integer
          format: int64
          nullable: true
        ops_count:
          type: integer
          format: int64
          nullable: true
        ts:
          type: string
          format: date-time

    CloudSignupRequest:
      type: object
      required: [email, region]
      properties:
        email:
          type: string
          format: email
          description: Customer email. Loose shape validation on the endpoint.
        name:
          type: string
          description: Optional display name (organisation / tenant handle).
        region:
          $ref: "#/components/schemas/CloudRegion"
        plan_id:
          type: string
          description: Optional plan hint. Not honoured today — trial plan is enforced.

    CloudSignupResponse:
      type: object
      required: [tenant_id, status, region, verification_token]
      properties:
        tenant_id: { type: string }
        status:
          type: string
          enum: [pending]
        region:
          $ref: "#/components/schemas/CloudRegion"
        verification_token:
          type: string
          description: |
            Deterministic verification token to trade in for a
            `Trial` tenant via `POST /cloud/verify`. MVP-only — Phase
            4 moves this behind SES + drops it from the response body.

    CloudVerifyRequest:
      type: object
      required: [tenant_id, token]
      properties:
        tenant_id: { type: string }
        token:
          type: string
          description: The `verification_token` from `SignupResponse`.

    CloudVerifyResponse:
      type: object
      required: [tenant_id, status]
      properties:
        tenant_id: { type: string }
        status:
          type: string
          enum: [trial]
        control_plane_key:
          type: string
          description: |
            Raw `gq_*` secret for the freshly-minted `role=cloud_tenant`
            key. Surfaced ONCE — subsequent `GET /cloud/tenants/{id}/api-keys`
            calls do not return it. Save it now. Use as
            `Authorization: Bearer <control_plane_key>` on
            `/cloud/tenants/{id}/*` requests.
        control_plane_key_prefix:
          type: string
          description: First 8 chars of the raw key — safe to display in a UI.

    CloudMeBootstrapRequest:
      type: object
      description: |
        Optional overrides for the tenant bootstrap flow. The JWT
        signed claims are preferred; body values are only used when
        the corresponding claim is absent.
      properties:
        region:
          type: string
          description: |
            Region override. Defaults to `us` when neither this nor an
            explicit JWT claim provides one.
          enum: [us, eu]
        email:
          type: string
          format: email
          description: |
            Email fallback. Used ONLY when the JWT `email` claim is
            absent. Precedence: JWT signed claim > this body override
            > synthetic `<sub>@cognito.invariantdb.com` fallback (#371).
        name:
          type: string
          description: Display name fallback (JWT `name` claim preferred).

    CloudMeCreateGraphRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: |
            REQUESTED graph name (customer-facing). The effective name
            in URLs will be `<tenant_prefix>_<name>`.
          pattern: "^[a-zA-Z0-9_-]{1,64}$"

    CloudCreateGraphRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Requested graph name.
          pattern: "^[a-zA-Z0-9_-]{1,64}$"

    CloudCreateGraphResponse:
      type: object
      description: |
        Provisioning response — same shape on both the operator
        (`POST /cloud/tenants/{id}/graphs`) and self-serve
        (`POST /cloud/me/graphs`) paths.
      required: [graph_id, name, requested_name, status, already_existed]
      properties:
        graph_id: { type: string }
        name:
          type: string
          description: Effective prefixed name (what lives on disk).
        requested_name:
          type: string
          description: What the customer typed.
        status:
          type: string
          enum: [active]
        api_key:
          type: string
          nullable: true
          description: |
            RAW `gq_*` data-plane secret. **ONLY IN CREATE RESPONSE**
            on the fresh-provision path. `null` on idempotent replay
            (`already_existed=true`) because the original secret is
            unrecoverable — the UI should route the user to the API
            keys panel to mint a fresh key.
        api_key_id:
          type: string
          nullable: true
        api_key_prefix:
          type: string
          nullable: true
          description: First 8 chars of the raw key — safe to display.
        mcp_config:
          type: object
          nullable: true
          description: |
            Copy-paste-ready MCP server config block for Claude
            Desktop / Claude Code / Cursor / Codex. `null` on
            idempotent replay.
          additionalProperties: true
        examples:
          type: object
          description: |
            Ready-made client snippets. Only surfaced by the operator
            (`/cloud/tenants/{id}/graphs`) path today.
          properties:
            curl:
              type: string
              nullable: true
        already_existed:
          type: boolean
          description: |
            `true` if the graph was already provisioned (idempotent
            replay) — the raw secret was NOT re-issued and `api_key`
            is `null`.

    CloudCreateApiKeyRequest:
      type: object
      description: |
        Body shape for both `/cloud/me/api-keys` and
        `/cloud/tenants/{id}/api-keys`. The `graphs[]` scope
        constrains the key to the listed graphs; omit for
        any-graph.
      properties:
        graphs:
          type: array
          items: { type: string }
          nullable: true
          description: Restrict the key to these graphs. Omit / null = any graph.
        name:
          type: string
          description: Human label shown in the UI.

    CloudCreateApiKeyResponse:
      type: object
      description: |
        Operator-scoped API-key mint response. MVP shape: only the
        control-DB row is recorded, so `api_key` is `null` today.
        The paired data-plane mint against `/admin/api-keys` returns
        the raw `gq_` secret separately. The self-serve
        `POST /cloud/me/api-keys` path uses the shared `NewApiKey`
        shape instead — the raw `key` field is present there.
      required: [key_id, prefix, created_at]
      properties:
        key_id: { type: string }
        prefix:
          type: string
          description: First 8 chars of the (data-plane) secret when minted.
        created_at:
          type: string
          format: date-time
        api_key:
          type: string
          nullable: true
          description: |
            RAW `gq_*` secret. `null` on the MVP path (control-DB row
            recorded but data-plane mint not yet wired). Once the
            data-plane provisioning client lands here this will be
            populated ONCE on create.

    CloudMeUsageResponse:
      type: object
      required: [tenant_id, graphs, total_storage_bytes, events]
      properties:
        tenant_id: { type: string }
        plan_id:
          type: string
          nullable: true
        graphs:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              status: { $ref: "#/components/schemas/CloudGraphStatus" }
              storage_bytes:
                type: integer
                format: int64
                minimum: 0
        total_storage_bytes:
          type: integer
          format: int64
          minimum: 0
        events:
          type: array
          items: { $ref: "#/components/schemas/CloudUsageEvent" }

    CloudBillingResponse:
      type: object
      description: |
        Joined billing view — subscription state + last N invoices.
      properties:
        invoices:
          type: array
          items: { $ref: "#/components/schemas/CloudInvoice" }
        subscription:
          allOf:
            - $ref: "#/components/schemas/CloudSubscription"
          nullable: true

    CloudCheckoutSessionRequest:
      type: object
      required: [plan_id]
      properties:
        plan_id:
          type: string
          description: |
            Target plan id. The env var `GQ_STRIPE_PRICE_ID_<PLAN>`
            (uppercased) must be set on the server or the endpoint
            returns 400.
        success_url:
          type: string
          format: uri
          description: |
            Optional. Defaults to
            `https://cloud.invariantdb.com/billing/success`.
        cancel_url:
          type: string
          format: uri
          description: |
            Optional. Defaults to
            `https://cloud.invariantdb.com/billing/cancelled`.

    CloudCheckoutSessionResponse:
      type: object
      description: |
        Stripe Checkout hand-off. On the Free-plan short-circuit,
        `free=true` and `url` / `session_id` are `null`. On the
        `GQ_STRIPE_MOCK_URL` escape hatch, `session_id` is a mock
        constant.
      required: [free]
      properties:
        session_id:
          type: string
          nullable: true
        url:
          type: string
          format: uri
          nullable: true
        free:
          type: boolean
        note:
          type: string
          description: |
            Human-readable annotation (Free-plan short-circuit or
            mock-URL warning).

    CloudPortalSessionRequest:
      type: object
      properties:
        return_url:
          type: string
          format: uri
          description: |
            Where Stripe redirects the customer after they close the
            portal. Optional; defaults to
            `https://cloud.invariantdb.com/dashboard`.

    CloudStripeWebhookEnvelope:
      type: object
      description: |
        Minimal Stripe event envelope we deserialise. Real Stripe
        events have many more fields; we only need `id`, `type`, and
        `data.object` for dispatch. NEVER accepted without a valid
        `Stripe-Signature` header — the HMAC gate is the ONLY trust
        boundary.
      required: [id, type, data]
      properties:
        id:
          type: string
          description: |
            Stripe event id (e.g. `evt_1MzX…`). Used as the
            idempotency key — retries of the same id return the
            cached outcome.
        type:
          type: string
          description: |
            Stripe event type. Handled:
            `customer.subscription.updated`,
            `customer.subscription.created`,
            `customer.subscription.deleted`,
            `invoice.paid`,
            `invoice.payment_failed`.
            Everything else is acknowledged as `ignored` to prevent
            retry storms.
        data:
          type: object
          required: [object]
          properties:
            object:
              type: object
              description: |
                Free-form Stripe object payload. We pull `customer`,
                `status`, `id`, `amount_paid`, `period_start`,
                `period_end`, and `status_transitions.paid_at` as
                needed per event type.
              additionalProperties: true

    CloudWebhookOutcome:
      type: object
      description: |
        Dispatch outcome. `action_taken` is either the effect string
        (e.g. `tenant tnt_… -> active`, `recorded invoice inv_… for
        tenant tnt_…`) or `ignored`, or an
        `(idempotent replay) <cached>` marker on retry.
      required: [event_id, event_type, action_taken]
      properties:
        event_id: { type: string }
        event_type: { type: string }
        action_taken: { type: string }

    # ───── Single-engine search schemas (#370 PR-6) ─────
    # Wire-shape pin for the three `/search/*` single-engine routes.
    # Mirrors `FulltextRequest` / `VectorRequest` / `SearchResponse` /
    # `SearchHitJson` in `crates/gq-server/src/handlers.rs`. The wire
    # shape is deliberately narrower than the graph-scoped
    # `/graphs/{name}/search/*` docs claim (which pre-date the handler
    # unification); the single-engine documentation records the actual
    # emitted JSON.

    FulltextRequest:
      type: object
      required: [label, property, query]
      description: |
        Request body for `POST /search/fulltext`. Mirrors
        `handlers::FulltextRequest`. Missing `k` defaults to 10.
      properties:
        label:
          type: string
          description: Node label to search within.
        property:
          type: string
          description: Node property that carries the searched text.
        query:
          type: string
          description: BM25 query string.
        k:
          type: integer
          format: int64
          minimum: 1
          default: 10
          description: Maximum number of hits to return. Defaults to 10.

    VectorRequest:
      type: object
      required: [label, property, query]
      description: |
        Request body for `POST /search/vector`. Mirrors
        `handlers::VectorRequest`. `metric` and `k` default to
        `cosine` and `10` respectively.
      properties:
        label:
          type: string
          description: Node label to search within.
        property:
          type: string
          description: Node property that stores the embedding vector.
        query:
          type: array
          items: { type: number, format: float }
          description: Query embedding vector. Dimension must match the label + property vector index dimension; a mismatch surfaces as `ServerError::Search`.
        k:
          type: integer
          format: int64
          minimum: 1
          default: 10
          description: Number of nearest neighbors to return. Defaults to 10.
        metric:
          type: string
          enum: [cosine, euclidean, dot, dotProduct]
          default: cosine
          description: |
            Similarity metric. Case-insensitive; both `dot` and
            `dotProduct` map to the same backend metric. Unknown
            values yield a 400 Bad Request.

    SearchHit:
      type: object
      required: [node_id, score]
      description: |
        Single search hit. Mirrors `handlers::SearchHitJson`. Both
        BM25 and vector paths emit the same shape — the score is
        BM25 score or (metric-dependent) similarity value.
      properties:
        node_id:
          type: integer
          format: int64
          minimum: 0
          description: Internal `NodeId` of the hit row.
        score:
          type: number
          format: float
          description: |
            Ranking score. For BM25 this is the raw BM25 score
            (higher is better). For vector this is the metric-
            dependent similarity (higher is better for cosine +
            dot; distance for euclidean).

    SearchResponse:
      type: object
      required: [hits]
      description: |
        Response envelope for `POST /search/fulltext` and
        `POST /search/vector`. Mirrors `handlers::SearchResponse`.
        Empty `hits` array is a normal "no matches" signal; a
        missing index surfaces as a build error at the handler,
        not an empty result.
      properties:
        hits:
          type: array
          items: { $ref: "#/components/schemas/SearchHit" }

    SearchRebuildResponse:
      type: object
      required: [cleared]
      description: |
        Response envelope for `POST /search/rebuild`. The handler
        calls `SearchCache::clear` and returns a stable one-field
        JSON object — no per-index rebuild statistics are surfaced
        (rebuilds happen lazily on the next `/search/*` call).
      properties:
        cleared:
          type: boolean
          enum: [true]
          description: Always `true` on a 200 response.
