openapi: 3.1.0

# ===========================================================================
# THE CONTRACT: single source of truth
#
# This file is the ONLY definition of these shapes on the web side. It:
#
#   1. defines the mock sidecar driver (app/Services/Sidecar/MockSidecarDriver)
#   2. validates every response the app emits, in CI and in local/testing
#   3. generates the public API documentation page
#
# There is no second copy. Changing a shape here changes all three.
#
# Two APIs live in one document, on purpose (charter §6: "API docs page
# generates from the same contract file that defines the mock"):
#
#   tag: sidecar   (127.0.0.1:8801, charter §5). Laravel is its ONLY caller.
#                   Never exposed publicly. Frozen.
#   tag: public    (api.tomformer.com/v1, charter §6). What developers see.
#
# Fields marked `x-differentiator: true` are the ones a standard LLM API has
# no analog for. The docs page foregrounds them; that schema IS the pitch.
# ===========================================================================

info:
  title: ToMFormer API
  version: "1.5.0"
  summary: A cloze-style API with first-class edit, grow, receipt, abstention, and footprint fields.
  description: |
    Three systems answer under the same public corpus label, side by side:

    - **TOMX**: the ToMFormer system. Editable through a session overlay,
      growable through bounded document writes, auditable with opaque source receipts,
      and able to decline at named operating points.
    - **TX**: a matched-size standard dense transformer under the same public corpus label.
      Same *resident* parameter budget; full corpus/tokenizer hashes are not exposed by the public contract.
    - **RAG**: a reader-free retrieval baseline under the same public corpus label.

    The answers are not what set this API apart from a chat-completions
    endpoint. The difference is that `trace`, `abstained`,
    `provenance`, `edit`, `grow` and `revert` are first-class fields here.
    On a standard API you would have to infer them, prompt for them, or go
    without them.

    ### What this API does not do

    This is a cloze-style question API over a fixed corpus. It is not a
    general-purpose chat model. Free-text queries outside the curated query set
    are marked with `out_of_corpus`; TOMX's margin/threshold fields report the
    serving gate used to decline low-support answers.
  contact:
    name: tomformer.com
    url: https://tomformer.com

servers:
  - url: https://tomformer.com/api
    description: "Public API base URL. Full example: https://tomformer.com/api/v1/ask"
  - url: https://api.tomformer.com/api
    description: The same API on its subdomain.
  - url: http://127.0.0.1:8801
    description: Sidecar, localhost only, Laravel is its only caller (charter §5)

tags:
  - name: public
    description: api.tomformer.com/v1, the developer-facing surface.
  - name: sidecar
    description: >
      Internal contract between Laravel and the Python sidecar. Localhost
      only, with no internet egress. It is never routed publicly. Documented
      here so the mock driver and the live sidecar are held to one definition.

# ===========================================================================
# PATHS: sidecar (charter §5)
# ===========================================================================

paths:

  /infer:
    post:
      tags: [sidecar]
      operationId: sidecarInfer
      summary: Answer one query with one arm.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/InferRequest' }
      responses:
        '200':
          description: An answer, or an explicit abstention.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/InferResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /edit:
    post:
      tags: [sidecar]
      operationId: sidecarEdit
      summary: Write one fact into the session overlay.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EditRequest' }
      responses:
        '200':
          description: The overlay diff produced by the write.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EditResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /revert:
    post:
      tags: [sidecar]
      operationId: sidecarRevert
      summary: Discard the entire session overlay.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RevertRequest' }
      responses:
        '200':
          description: Overlay cleared. The shared base is untouched, as always.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RevertResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /trust/inspect:
    get:
      tags: [sidecar]
      operationId: sidecarTrustInspect
      summary: Inspect source trust state without exposing source contents.
      parameters:
        - name: overlay
          in: query
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Opaque source handles and capability-level trust observations.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrustInspectResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /trust/discredit:
    post:
      tags: [sidecar]
      operationId: sidecarTrustDiscredit
      summary: Discredit one source in the session trust ledger.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TrustDiscreditRequest' }
      responses:
        '200':
          description: The capability-level result of the trust write.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrustMutationResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /trust/restore:
    post:
      tags: [sidecar]
      operationId: sidecarTrustRestore
      summary: Restore one source trust override exactly.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TrustRestoreRequest' }
      responses:
        '200':
          description: The capability-level result after removing the trust override.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrustMutationResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /grow:
    post:
      tags: [sidecar]
      operationId: sidecarGrow
      summary: Read a new document into the system's knowledge.
      description: |
        Retrying after a timeout is safe: the gate reads the current belief
        (including your overlay) per fact, so facts already written on the
        first attempt come back as `skip` instead of being written twice.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GrowRequest' }
      responses:
        '200':
          description: A write report, including the gate's decision per fact.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GrowResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /bench:
    post:
      tags: [sidecar]
      operationId: sidecarBench
      summary: Start a benchmark job over a dev slice.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BenchRequest' }
      responses:
        '200':
          description: Job accepted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BenchJob' }
        default: { $ref: '#/components/responses/Problem' }

  /bench/{job}:
    get:
      tags: [sidecar]
      operationId: sidecarBenchProgress
      summary: Poll a benchmark job.
      parameters:
        - name: job
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job progress and, once finished, results.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BenchProgress' }
        default: { $ref: '#/components/responses/Problem' }

  /footprint:
    get:
      tags: [sidecar]
      operationId: sidecarFootprint
      summary: Resident RAM and disk, per arm.
      responses:
        '200':
          description: Measured footprint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Footprint' }
        default: { $ref: '#/components/responses/Problem' }

  /meta:
    get:
      tags: [sidecar]
      operationId: sidecarMeta
      summary: Deployed checkpoint hashes, bench tag, corpus, overlay TTL.
      responses:
        '200':
          description: What is actually deployed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meta' }
        default: { $ref: '#/components/responses/Problem' }

  /queries:
    get:
      tags: [sidecar]
      operationId: sidecarQueries
      summary: The curated query browser's contents.
      description: |
        Added in v1.4.

        The demo cannot have a query browser under the live driver without
        this: the questions are held-out split rows owned by the sidecar, and
        charter §7 keeps the web repo from reading the corpus itself. Under the
        mock the same endpoint returns the fixture corpus's curated set, so the
        UI has one code path.
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 500, default: 60 }
      responses:
        '200':
          description: Curated queries, drawn from the shared held-out split.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryList' }
        default: { $ref: '#/components/responses/Problem' }

  /healthz:
    get:
      tags: [sidecar]
      operationId: sidecarHealth
      summary: Liveness.
      responses:
        '200':
          description: Alive.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }

# ===========================================================================
# PATHS: public API (charter §6)
# ===========================================================================

  /v1/trial-token:
    post:
      tags: [public]
      operationId: trialToken
      summary: Issue yourself a short-lived trial token. No signup.
      description: |
        The only unauthenticated route. It exists so a first-pass reader,
        human or AI agent, can prove this API is real in one call.

        The token carries the ask, edit, and grow scopes and expires within
        a day. Every write it makes lands in its own session overlay, which
        also expires; the shared base state cannot be changed by any token.
        The bench scope is not included. Issuance is tightly rate limited
        per address, and the usual per-token and per-day limits apply on
        top.
      responses:
        '201':
          description: A fresh trial token. Shown once.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrialToken' }
        '429': { $ref: '#/components/responses/Problem' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/ask:
    post:
      tags: [public]
      operationId: ask
      summary: Ask a question.
      description: |
        Mirrors the sidecar's `/infer`. `arm` defaults to `tomx`.

        With `?compare=true` the same query is run through all three arms and
        the trio is returned together, which is the entire point of the
        thing. Comparing arms costs three forwards and is rate-limited
        accordingly.
      security: [{ bearerAuth: [ask] }]
      parameters:
        - name: compare
          in: query
          required: false
          description: Run all three arms and return the trio.
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AskRequest' }
      responses:
        '200':
          description: |
            A single answer, or (with `compare=true`) one per arm.

            Note that an abstention returns a 200. So does an out-of-corpus
            query. Neither is an error. The system declining to answer is a
            result. It is not a failure.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PublicInferResponse'
                  - $ref: '#/components/schemas/PublicCompareResponse'
        '401': { $ref: '#/components/responses/Problem' }
        '403': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
        '429': { $ref: '#/components/responses/Problem' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/edits:
    post:
      tags: [public]
      operationId: createEdit
      summary: Edit one fact.
      description: |
        Writes a single `(entity, relation) -> value` into **your session
        overlay**. The base knowledge store is read-only at the filesystem
        level and no token can reach it.

        The edit takes effect on the next `ask`. There is no retraining step,
        no reindex, and no cache to warm, which is the claim this endpoint
        exists to make checkable.
      security: [{ bearerAuth: [edit] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicEditRequest' }
      responses:
        '200':
          description: The overlay diff.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EditResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/grow:
    post:
      tags: [public]
      operationId: grow
      summary: Add a document and write its facts into your overlay.
      description: |
        Retrying after a timeout is safe: the gate reads the current belief
        (including your overlay) per fact, so facts already written on the
        first attempt come back as `skip` instead of being written twice.
        The first grow on a fresh deployment loads a large document parser
        and can take 60 to 90 seconds; later calls do not pay that.
      security: [{ bearerAuth: [grow] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicGrowRequest' }
      responses:
        '200':
          description: |
            A write report. `gate` lists what was written, what was skipped as
            already-known, and what contradicted something already held.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GrowResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/revert:
    post:
      tags: [public]
      operationId: revert
      summary: Discard every edit and every grown fact in your overlay.
      security: [{ bearerAuth: [edit] }]
      responses:
        '200':
          description: Overlay cleared.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RevertResponse' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/bench:
    post:
      tags: [public]
      operationId: startBench
      summary: Start a benchmark run over a dev slice.
      security: [{ bearerAuth: [bench] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicBenchRequest' }
      responses:
        '202':
          description: Job started.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BenchJob' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/bench/{id}:
    get:
      tags: [public]
      operationId: getBench
      summary: Poll a benchmark run.
      security: [{ bearerAuth: [bench] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Progress, and results once complete.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BenchProgress' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/queries:
    get:
      tags: [public]
      operationId: listQueries
      summary: Curated queries you can ask by id.
      description: |
        The corpus's own questions, with the `query_id` to pass to `/v1/ask`.

        Asking by id is the in-corpus path: free text is accepted too, but a
        query the system cannot resolve comes back with `out_of_corpus: true`
        rather than a guess.
      security: [{ bearerAuth: [ask] }]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 500, default: 60 }
      responses:
        '200':
          description: Curated queries.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryList' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/footprint:
    get:
      tags: [public]
      operationId: getFootprint
      summary: Resident RAM and disk, per arm.
      description: |
        Per-arm resident figures are allocated parameter and buffer bytes when the driver can attribute them;
        process RSS and anonymous/required memory are reported separately. Disk is `stat` on the actual files.
        These values are measurements, not configured constants.

        This endpoint is where the physical-system claim becomes a number: TOMX
        serves knowledge from disk, so allocated resident model bytes can stay flat while
        served disk grows. TX carries its deployed knowledge in weights.
      security: [{ bearerAuth: [ask] }]
      responses:
        '200':
          description: Measured footprint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Footprint' }
        default: { $ref: '#/components/responses/Problem' }

  /v1/meta:
    get:
      tags: [public]
      operationId: getMeta
      summary: What is actually deployed right now.
      description: |
        The checkpoint hashes and bench tag returned here are the same ones
        displayed in the demo's footer. If they ever disagree, the UI says so
        rather than serving results from an unknown build.
      security: [{ bearerAuth: [ask] }]
      responses:
        '200':
          description: Deployment identity.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Meta' }
        default: { $ref: '#/components/responses/Problem' }

# ===========================================================================
# COMPONENTS
# ===========================================================================

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A Sanctum personal access token, issued from the dashboard. Scopes:
        `ask`, `edit`, `grow`, `bench`. Tokens are rate-limited per minute and
        capped per day. Edits and grows are always overlay-scoped: a token can
        never mutate base state.

  responses:
    Problem:
      description: |
        An error, as `application/problem+json` (RFC 9457): every non-2xx
        response on this API has this one shape. 401 means no or bad token,
        403 means the token lacks the scope, 422 carries field-level
        `errors`, 429 means a rate limit and arrives with the standard
        `Retry-After` and `X-RateLimit-*` headers; honor `Retry-After`
        before retrying.

        Note what is *not* an error here: abstention and out-of-corpus are
        both fields on a 200 response. The system declining to answer is a
        result.

        The API is meant to be called server-to-server (curl, scripts,
        agents). It does not serve CORS headers, so browser pages on other
        origins cannot call it directly.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:

    # ---------------------------------------------------------------- shared

    Arm:
      type: string
      enum: [tomx, tx, rag]
      description: |
        `tomx`: the ToMFormer system. It can be edited through a session overlay,
        grown through bounded document writes, audited with opaque source receipts,
        and served with named abstention thresholds.
        `tx`:   matched-size dense transformer.
        `rag`:  that same TX plus retrieval over documents from the same public corpus label.

    Regime:
      type: string
      enum: [cautious, balanced, chatty]
      description: |
        A named operating point on the abstention curve, calibrated offline.
        The contract reports each point as precision paired with coverage; tighter
        thresholds serve fewer rows, so neither metric should be read alone.

    Controls:
      type: object
      x-differentiator: true
      description: |
        Inference-time control over *whether the system answers at all*. It
        does not control sampling, so there is no temperature here. What
        these controls set is how sure the system must be before it speaks.
      additionalProperties: false
      properties:
        regime:
          $ref: '#/components/schemas/Regime'
        t_open:
          type: [number, "null"]
          description: Routing-open threshold. Overrides the regime's banked value.
        t_serve:
          description: |
            Serve threshold. Overrides the regime's banked value.

            A bare number applies to both channels. The object form sets the
            two serve channels independently.

            The per-channel form is deliberate: thresholds are banked per
            regime AND per channel, and the object form exposes the shape the
            model actually takes (CONTRACT_RULINGS N1).
          oneOf:
            - type: "null"
            - type: number
            - type: object
              additionalProperties: false
              properties:
                self: { type: number }
                kw: { type: number }
              required: [self, kw]
        gate:
          type: boolean
          default: true
          x-differentiator: true
          description: |
            The absent-detection serve gate. Added in v1.1.

            With the gate on (the default) the system detects that it
            holds no fact for what was asked and declines. Turn it off and it
            answers anyway, which is what a system without this capability
            does on every such question, all the time.

            The toggle exists so the capability can be demonstrated rather
            than asserted: ask something the corpus has no answer for, watch
            it decline, turn the gate off, watch it produce a fluent wrong
            answer instead.

            It does NOT disable the out-of-corpus badge. A query that cannot
            be routed at all hits a hard rail that no flag tunes: charter §2
            requires free text to never yield a silent wrong answer, so that
            path abstains regardless of this flag.

            Note that the regime's internal calibration constants remain
            caller-invisible by ruling: a regime IS a calibrated operating
            point, and its internals are not knobs (CONTRACT_RULINGS N1).

    Answer:
      type: object
      additionalProperties: false
      properties:
        text: { type: string }
        p:
          type: number
          minimum: 0
          maximum: 1
          description: |
            This candidate's share of the top-k mass, renormalized ACROSS THE
            RETURNED TOP-K. It is not an absolute probability. Over the full
            candidate set the absolute masses round to 0.0000 and would render
            as if the arm returned nothing.

            The arms reach it differently and the panel says so: each arm
            normalizes its own top-k scores, and those scores are not on a
            common scale across arms. Manufacturing a calibrated probability
            for TOMX would be theater; `score` below carries the real
            quantity, and `confidence.margin` is what the gate actually
            decides on.
        score:
          type: number
          description: |
            The raw, unnormalized score behind this candidate. Optional;
            omitted by arms that have no meaningful raw score to report.
        entity:
          type: string
          pattern: '^Q[0-9]+$'
          description: |
            The entity this candidate IS, as a Wikidata QID. The QID carries
            identity; `text` is a display label and labels are not unique, so
            scoring or comparing on `text` is lossy: where two entities share
            a label, label-based scoring loses the answer and makes the
            published figure a property of the label file rather than of the
            model. Consumers must score on the QID.

            Optional, because some answers have no entity behind them: an
            overlay edit carries the free text a visitor typed, which may name
            nothing in the corpus. Consumers that need identity must handle its
            absence rather than assume it.
      required: [text, p]

    Confidence:
      type: object
      x-differentiator: true
      additionalProperties: false
      description: |
        TOMX only. `margin` is the gap between the top candidate and the
        runner-up; `tau` is the threshold in force under the current regime.
        When `margin < tau` the system abstains instead of answering.

        This is why abstention here is a decision with a number behind it,
        rather than a refusal string the model was prompted into producing.
      properties:
        margin: { type: number }
        tau: { type: number }
      required: [margin, tau]

    TraceStep:
      type: object
      x-differentiator: true
      additionalProperties: false
      description: |
        One step of the read that produced the answer: which entity was
        routed to, which relation was selected, the receipt for what was read,
        and the score.

        The trace shows WHICH source was read. It does not expose how the
        system chose it or how the answer was computed.
      properties:
        entity:
          type: string
          pattern: '^Q[0-9]+$'
          description: Wikidata-style entity id.
        rel:
          type: string
          pattern: '^P[0-9]+-?$'
          description: |
            Wikidata-style property id: the relation the model SELECTED for
            this read. A trailing `-` marks the inverse direction; presenting
            an inverse read as the forward relation would make the trace
            assert something the model did not.
        row:
          type: string
          pattern: '^r:[0-9a-f]{12,32}$'
          description: |
            Opaque receipt for the source actually read, e.g.
            `r:1f3a9c04d2b7e5a1`.

            The receipt is deterministic and 1:1 with the source: the same
            source always produces the same receipt, and two sources never
            share one. That is what makes it auditable. Two answers carrying
            the same receipt read the same source. The receipt deliberately
            encodes nothing about storage layout or how the source was
            chosen, and it is derived from the span the serve path actually
            reads, so it cannot name bytes that were not read.

            THE PATTERN IS THE ENFORCEMENT (charter §7 / amendment A8). This
            field used to accept `<namespace>:<value>` for any namespace,
            which described opacity in prose and validated anything: a sidecar
            emitting `m:0x3f21a0` (naming a structure and an offset into it)
            or, worse, `row:Q42_P19` would have passed. Response validation is
            the only thing standing between an implementation detail and every
            public API consumer, so the shape is constrained to one namespace
            and lowercase hex, which is the property that matters, because
            hex cannot spell an entity id, a relation id or a structure name.

            The WIDTH is a range, not a pin. Opacity does not depend on it,
            and pinning it would have been a breaking integration change for
            no security gain: the deployed sidecar mints 12 hex, this repo's
            mock mints 16. 12 is the floor because the receipt's one promise
            is to be 1:1 with its source, and at served scale (~150k entities,
            ~3M rows) 48 bits carries roughly 1.6% odds of a collision. 64
            bits puts that near 2e-7, which is why the mock went wider and why
            the serving side should follow. Widening needs no contract change.
        score:
          type: number
          description: The measured score behind the source that was read.
      required: [entity, rel, row, score]

    Retrieved:
      type: object
      additionalProperties: false
      description: RAG only. The documents retrieved, and what was read from them.
      properties:
        doc_id:
          type: string
          pattern: '^[a-z0-9]+:[A-Za-z0-9_.-]+$'
        snippet: { type: string }
      required: [doc_id, snippet]

    ResponseMeta:
      type: object
      additionalProperties: false
      description: Which build produced this response.
      properties:
        ckpt:
          type: string
          description: Content hash of the deployed checkpoint, first 12 hex of sha256.
          pattern: '^[0-9a-f]{12}$'
        bench:
          type: string
          description: Bench version tag this build is pinned to.
        driver:
          type: string
          enum: [mock, live]
          description: |
            `live`: a model produced this answer.
            `mock`: the in-repo reference implementation of this contract
            produced it from a fixture corpus. No model is running.

            Additive to charter §5; see CONTRACT_NOTES.md N2. The UI keys a
            permanent banner off this field, and refuses to present a
            response as a model answer when the field is absent.
        artifact_namespace:
          type: [string, "null"]
          pattern: '^[a-z0-9][a-z0-9_.-]*$'
          description: Separate artifact namespace, e.g. `gl2`; null for legacy/mock responses.
        calibration_id:
          type: [string, "null"]
          description: Fresh calibration bundle that set this answer's operating point.
        comparison_manifest:
          type: [string, "null"]
          description: Matched-arm manifest used for this response's comparison context.
      required: [ckpt, bench, driver]

    # ---------------------------------------------------------------- infer

    InferRequest:
      type: object
      additionalProperties: false
      properties:
        arm: { $ref: '#/components/schemas/Arm' }
        query:
          type: string
          minLength: 1
          # Curated corpus queries are document-length cloze rows. They are
          # not chat turns. The 400-char cap still applies to the FREE-TEXT
          # box, which is enforced at the Laravel layer (charter §7 free-text
          # length cap).
          maxLength: 4000
        query_id:
          type: [string, "null"]
          description: |
            Identifier of a curated corpus query, e.g. `dev:1234`. Null for
            free text, which is treated as potentially out-of-corpus.
        controls: { $ref: '#/components/schemas/Controls' }
        overlay:
          type: string
          format: uuid
          description: The caller's session overlay. Edits are visible only within it.
      required: [arm, query, overlay]

    PublicTraceStep:
      type: object
      additionalProperties: false
      description: |
        One step of the read that produced the answer, as published to API
        callers: the receipt for what was read, and the score behind it.

        DELIBERATELY NARROWER THAN TraceStep (charter §7 / amendment A8).
        The sidecar-facing `TraceStep` carries `entity` and `rel` because the
        panels that render this site need them; that contract is spoken over
        localhost between Laravel and the sidecar, and it is not published.

        This one is. Trial tokens are self-serve, so any agent can sample
        `/v1/ask` at will, and `entity` + `rel` per hop is a map of which
        relation the model SELECTS for which question, including, via the
        inverse marker, when it traverses one backwards. Individually that is
        an auditable read; in bulk it is the routing policy, which is "how",
        and §7 publishes "which" only.

        What survives is the property a caller actually audits with: receipts
        are deterministic and 1:1 with sources, so two answers carrying the
        same receipt read the same source, and a receipt that changes after an
        edit proves the read moved. That works without naming anything.
      properties:
        receipt:
          type: string
          pattern: '^r:[0-9a-f]{12,32}$'
          description: |
            Opaque receipt for the source actually read. Same value, same
            guarantees and same constrained shape as `TraceStep.row`.
        score:
          type: number
          description: The measured score behind the source that was read.
      required: [receipt, score]

    PublicInferResponse:
      type: object
      additionalProperties: false
      description: |
        `/v1/ask`'s answer. Identical to InferResponse except that `trace`
        carries PublicTraceStep; see that schema for why.
      properties:
        answers:
          type: array
          maxItems: 5
          description: Top-k candidates, k <= 5. Empty when the system abstains.
          items: { $ref: '#/components/schemas/Answer' }
        abstained:
          type: boolean
          x-differentiator: true
          description: |
            True when the system declined to answer because its margin fell
            below the serving threshold. The abstention is native. It is not
            produced by prompting.
        confidence:
          oneOf:
            - $ref: '#/components/schemas/Confidence'
            - type: "null"
          description: TOMX only; null for other arms.
        trace:
          x-differentiator: true
          description: TOMX only; null for other arms.
          oneOf:
            - type: array
              items: { $ref: '#/components/schemas/PublicTraceStep' }
            - type: "null"
        retrieved:
          description: RAG only; null for other arms.
          oneOf:
            - type: array
              items: { $ref: '#/components/schemas/Retrieved' }
            - type: "null"
        latency_ms:
          type: number
          description: |
            Server-side, monotonic, around the forward only. Network time is
            not included.
        out_of_corpus:
          type: boolean
          description: |
            True when the query names entities the corpus does not hold. The
            answer is then unreliable by construction and the UI badges it.
        meta: { $ref: '#/components/schemas/ResponseMeta' }
      required: [answers, abstained, confidence, trace, retrieved, latency_ms, out_of_corpus, meta]

    PublicCompareResponse:
      type: object
      additionalProperties: false
      description: All three arms on one query, as published to API callers.
      properties:
        query: { type: string }
        tomx: { $ref: '#/components/schemas/PublicInferResponse' }
        tx: { $ref: '#/components/schemas/PublicInferResponse' }
        rag: { $ref: '#/components/schemas/PublicInferResponse' }
      required: [query, tomx, tx, rag]

    InferResponse:
      type: object
      additionalProperties: false
      properties:
        answers:
          type: array
          maxItems: 5
          description: Top-k candidates, k <= 5. Empty when the system abstains.
          items: { $ref: '#/components/schemas/Answer' }
        abstained:
          type: boolean
          x-differentiator: true
          description: |
            True when the system declined to answer because its margin fell
            below the serving threshold. The abstention is native. It is not
            produced by prompting.
        confidence:
          oneOf:
            - $ref: '#/components/schemas/Confidence'
            - type: "null"
          description: TOMX only; null for other arms.
        trace:
          x-differentiator: true
          description: TOMX only; null for other arms.
          oneOf:
            - type: array
              items: { $ref: '#/components/schemas/TraceStep' }
            - type: "null"
        retrieved:
          description: RAG only; null for other arms.
          oneOf:
            - type: array
              items: { $ref: '#/components/schemas/Retrieved' }
            - type: "null"
        latency_ms:
          type: number
          minimum: 0
          description: |
            Server-side monotonic clock around the forward only. Network time
            is reported separately by the caller and never folded in here.
        out_of_corpus:
          type: boolean
          description: |
            True when the query falls outside the corpus this system was
            built on. Out-of-corpus is reported as a field. It is never an
            error, and it is never a silent wrong answer.
        from_overlay:
          type: boolean
          description: |
            True when this answer came from a session overlay edit rather than
            from the model's own read of the shared base.

            It exists so an edited cell cannot be presented as a model belief.
            The overlay is a stated fact and carries no margin of its own; the
            response still reports the real margin, and this flag is what
            tells the panel not to read that margin as confidence in the value
            shown. Previously an edit returned a fabricated `margin: 1.0`.
        meta: { $ref: '#/components/schemas/ResponseMeta' }
      required: [answers, abstained, confidence, trace, retrieved, latency_ms, out_of_corpus, meta]

    AskRequest:
      type: object
      additionalProperties: false
      description: |
        Public form of InferRequest. `overlay` is derived from the token.

        Supply EITHER `query` (free text, blank marked with ____) OR
        `query_id` (a curated id from GET /v1/queries; the row's own text
        becomes the query). If both are supplied, `query_id` wins and the
        free text is ignored. An unknown `query_id` is a 422.
      properties:
        arm:
          allOf: [{ $ref: '#/components/schemas/Arm' }]
          default: tomx
        query: { type: string, minLength: 1, maxLength: 4000 }
        query_id: { type: [string, "null"] }
        controls: { $ref: '#/components/schemas/Controls' }
      anyOf:
        - required: [query]
        - required: [query_id]

    CompareResponse:
      type: object
      additionalProperties: false
      description: Returned when `?compare=true`. One response per arm, same query.
      properties:
        query: { type: string }
        tomx: { $ref: '#/components/schemas/InferResponse' }
        tx: { $ref: '#/components/schemas/InferResponse' }
        rag: { $ref: '#/components/schemas/InferResponse' }
      required: [query, tomx, tx, rag]

    # ----------------------------------------------------------------- edit

    EditRequest:
      type: object
      additionalProperties: false
      properties:
        overlay: { type: string, format: uuid }
        entity: { type: string, pattern: '^Q[0-9]+$' }
        rel:
          type: string
          pattern: '^P[0-9]+-?$'
          description: |
            The relation the edit is keyed to. A trailing '-' is the INVERSE
            direction, and the trace reports inverses that way, so an edit to
            a cell the model read via an inverse must be expressible. Without
            the suffix such edits failed validation silently.
        value: { type: string, minLength: 1, maxLength: 200 }
      required: [overlay, entity, rel, value]

    PublicEditRequest:
      type: object
      additionalProperties: false
      properties:
        entity: { type: string, pattern: '^Q[0-9]+$' }
        rel:
          type: string
          pattern: '^P[0-9]+-?$'
          description: |
            The relation the edit is keyed to. A trailing '-' is the INVERSE
            direction, and the trace reports inverses that way, so an edit to
            a cell the model read via an inverse must be expressible. Without
            the suffix such edits failed validation silently.
        value: { type: string, minLength: 1, maxLength: 200 }
      required: [entity, rel, value]

    EditResponse:
      type: object
      additionalProperties: false
      properties:
        ok: { type: boolean }
        row:
          type: string
          pattern: '^r:[0-9a-f]{12,32}$'
          description: |
            Opaque receipt for the cell the edit landed on, in the same format
            as `TraceStep.row`; see amendments A6 and A8. Constrained to the
            same shape, and for the same reason: this one is returned to
            public API callers.
        previous:
          type: [string, "null"]
          description: |
            The value the MODEL answered for this cell before the edit,
            obtained by asking it. It was previously the entity's own label,
            which is not the value being replaced.
        rel:
          type: string
          description: |
            The relation the edit was keyed to, echoed back. Edits are scoped
            to the cell (entity, relation); `rel` was previously accepted but
            not honored, so edits to different relations of one entity
            collided.
        previous_margin:
          type: number
          description: |
            The shared base's margin for the value being replaced. Reported so
            an edit cannot read as the model having become confident: an edit
            previously returned a literal margin of 1.0 beside a score of 0.0.
        overlay_rows:
          type: integer
          minimum: 0
          description: |
            How many edits this overlay now holds. Sparse: a session that has
            edited two facts holds exactly two. It does not hold a copy
            of the shared base.
      required: [ok, row, previous, overlay_rows]

    RevertRequest:
      type: object
      additionalProperties: false
      properties:
        overlay: { type: string, format: uuid }
      required: [overlay]

    RevertResponse:
      type: object
      additionalProperties: false
      properties:
        ok: { type: boolean }
        overlay_rows: { type: integer, minimum: 0 }
      required: [ok, overlay_rows]

    # --------------------------------------------------------------- trust

    TrustDiscreditRequest:
      type: object
      additionalProperties: false
      properties:
        overlay: { type: string, format: uuid }
        source:
          type: string
          pattern: '^src_[0-9a-f]{12}$'
          description: Opaque source handle, not a source address or document id.
        reason:
          type: string
          minLength: 1
          maxLength: 200
          description: Writable trust note. Never reflected publicly except as changed capability state.
      required: [overlay, source, reason]

    TrustRestoreRequest:
      type: object
      additionalProperties: false
      properties:
        overlay: { type: string, format: uuid }
        source:
          type: string
          pattern: '^src_[0-9a-f]{12}$'
      required: [overlay, source]

    TrustSource:
      type: object
      additionalProperties: false
      properties:
        source:
          type: string
          pattern: '^src_[0-9a-f]{12}$'
          description: Opaque source handle. It is stable for equality checks but not dereferenceable.
        receipt:
          type: string
          pattern: '^rcp_[0-9a-f]{20}$'
        trust:
          type: string
          enum: [trusted, discredited]
        role:
          type: string
          enum: [primary corroborated, independent corroboration, sole support, conflicting Susan source, Susan corroboration, unrelated support]
      required: [source, receipt, trust, role]

    TrustObservation:
      type: object
      additionalProperties: false
      x-differentiator: true
      description: Capability-level source-trust result with no raw source content or internal scoring path.
      properties:
        capability:
          type: string
          enum: [source inspection, corroborated hold, sole-source abstention, Susan conflict flip, unrelated-answer invariance]
        subject: { type: string }
        before: { type: string }
        after: { type: string }
        decision:
          type: string
          enum: [inspect-only, baseline, held-by-corroboration, sole-source-abstain, conflict-flip, unchanged]
        receipt:
          type: string
          pattern: '^rcp_[0-9a-f]{20}$'
      required: [capability, subject, before, after, decision, receipt]

    TrustInspectResponse:
      type: object
      additionalProperties: false
      x-differentiator: true
      description: Source trust state and observable capability outcomes; no source content is exposed.
      properties:
        ok: { type: boolean }
        mutation:
          type: string
          enum: [inspect, discredit, restore]
        source_count: { type: integer, minimum: 0 }
        trust_overrides: { type: integer, minimum: 0 }
        sources:
          type: array
          items: { $ref: '#/components/schemas/TrustSource' }
        observations:
          type: array
          items: { $ref: '#/components/schemas/TrustObservation' }
        baseline_digest:
          type: string
          pattern: '^trust_[0-9a-f]{16}$'
        current_digest:
          type: string
          pattern: '^trust_[0-9a-f]{16}$'
        exact_restore: { type: boolean }
        meta: { $ref: '#/components/schemas/ResponseMeta' }
      required: [ok, mutation, source_count, trust_overrides, sources, observations, baseline_digest, current_digest, exact_restore, meta]

    TrustMutationResponse:
      allOf:
        - $ref: '#/components/schemas/TrustInspectResponse'

    # ----------------------------------------------------------------- grow

    GateDecision:
      type: object
      additionalProperties: false
      x-differentiator: true
      description: |
        What the write gate decided about one fact extracted from the
        document, and why.

        `contradiction` is the interesting case: the new fact disagrees with
        something already held. The system records both with provenance rather
        than silently overwriting or silently ignoring, which is what
        separates integrating knowledge from merely indexing text.
      properties:
        fact: { type: string }
        decision:
          type: string
          enum: [write, skip, contradiction, no_alias, out_of_tier]
          description: |
            FIVE outcomes. The two beyond write, skip, and contradiction are
            not edge cases:

            `no_alias`: the document does not state the value in a form the
            system can cite, so the write is dropped and reported. On real
            documents a large share of attempted writes end this way, and
            reporting only write/skip/contradiction would present those as
            silent successes.

            `out_of_tier`: the value entity lives outside the served tier, so
            the fact could never be read back. Dropped, counted
            and shown rather than written into a place nothing can reach.
        entity: { type: [string, "null"], pattern: '^Q[0-9]+$' }
        rel: { type: [string, "null"], pattern: '^P[0-9]+-?$' }
        value: { type: [string, "null"] }
        value_id: { type: [integer, "null"] }
        served_before:
          type: [string, "null"]
          description: What the system answered for this cell BEFORE the write.
        margin:
          type: [number, "null"]
          description: |
            The margin behind `served_before`. The contradiction branch fires
            when this is at or above a fixed threshold, calibrated offline as
            the median margin of incorrect serves, which is what that
            constant was actually fit for.
        reason: { type: [string, "null"] }
      required: [fact, decision]

    GrowRequest:
      type: object
      additionalProperties: false
      properties:
        overlay: { type: string, format: uuid }
        text: { type: string, minLength: 1, maxLength: 8000 }
        entity:
          type: string
          pattern: '^Q[0-9]+$'
          description: |
            Which entity the document is ABOUT. Required: the gate writes the
            facts it finds onto this entity, so inferring the subject
            would scatter them onto whichever entity happened to be mentioned
            first.
      required: [overlay, text]

    PublicGrowRequest:
      type: object
      additionalProperties: false
      properties:
        entity:
          type: string
          pattern: '^Q[0-9]+$'
          description: |
            Which entity the document is ABOUT. The gate writes the facts it
            finds onto this entity's row.
        text: { type: string, minLength: 1, maxLength: 8000 }
      required: [entity, text]

    GrowResponse:
      type: object
      additionalProperties: false
      properties:
        entities_written:
          type: array
          items: { type: string, pattern: '^Q[0-9]+$' }
        rows_added: { type: integer, minimum: 0 }
        gate:
          type: array
          items: { $ref: '#/components/schemas/GateDecision' }
        entity: { type: [string, "null"] }
        entity_label: { type: [string, "null"] }
        words:
          type: [integer, "null"]
          description: Corpus words the document tokenized to.
        counts:
          type: [object, "null"]
          description: |
            Decision counts by outcome. Published so the panel cannot show only
            the writes: `no_alias` is the plurality on most real documents.
        overlay_rows: { type: [integer, "null"] }
        documents: { type: [integer, "null"] }
        note: { type: [string, "null"] }
      required: [entities_written, rows_added, gate]

    # ---------------------------------------------------------------- bench

    BenchRequest:
      type: object
      additionalProperties: false
      properties:
        arms:
          type: array
          minItems: 1
          maxItems: 3
          uniqueItems: true
          items: { $ref: '#/components/schemas/Arm' }
        slice:
          type: string
          description: Named dev slice, e.g. `dev1k`.
        overlay: { type: string, format: uuid }
      required: [arms, slice, overlay]

    PublicBenchRequest:
      type: object
      additionalProperties: false
      properties:
        arms:
          type: array
          minItems: 1
          maxItems: 3
          uniqueItems: true
          items: { $ref: '#/components/schemas/Arm' }
          default: [tomx, tx, rag]
        slice: { type: string, default: dev1k }
      required: []

    BenchJob:
      type: object
      additionalProperties: false
      properties:
        job: { type: string, format: uuid }
      required: [job]

    LatencyStats:
      type: object
      additionalProperties: false
      description: |
        Percentiles over the slice. A mean is never reported. Warmup queries
        are excluded from these numbers and counted separately in
        `warmup_excluded`, so the figure is not flattered by a cold first
        forward or by discarding it quietly (charter §2).
      properties:
        p50: { type: number, minimum: 0 }
        p95: { type: number, minimum: 0 }
        n: { type: integer, minimum: 0 }
        warmup_excluded: { type: integer, minimum: 0 }
      required: [p50, p95, n, warmup_excluded]

    BenchArmResult:
      type: object
      additionalProperties: false
      description: |
        **`accuracy` alone is not a quality comparison, and must never be
        displayed as one.**

        It is precision over what an arm actually ANSWERED. An arm that
        abstains on the questions it cannot answer will score higher here than
        one that guesses at them, without being any better at the questions
        both attempted. Two arms are only comparable on quality when their
        `coverage` is comparable, so any surface showing `accuracy` must show
        `coverage` beside it (charter §2: no claim that TOMX beats TX on raw
        quality unless a shipped bench measures it).
      properties:
        accuracy:
          type: number
          minimum: 0
          maximum: 1
          description: correct / answered. Precision on attempted questions.
        coverage:
          type: number
          minimum: 0
          maximum: 1
          description: |
            answered / n. The other half of the trade. A deployed arm without
            the TOMX abstention gate reports 1.0 here for this ungated comparison,
            which keeps its lower `accuracy` from being overread.
        correct: { type: integer, minimum: 0 }
        answered: { type: integer, minimum: 0 }
        abstained:
          type: integer
          minimum: 0
          description: |
            Counted separately from wrong answers. An arm that abstains is
            not scored as if it had guessed, and deployed no-gate comparison arms
            report 0 here; the comparison is only meaningful when both
            numbers are visible.
        n: { type: integer, minimum: 0 }
        families:
          x-differentiator: true
          description: |
            Per-family breakdown, and **the only figure P5 may headline**.

            The families are different tasks. They are not difficulty tiers.
            A relational hop (answer is a different entity from the subject)
            and an identify-the-described-entity query are won by different
            architectures for structural reasons: a reader-free retrieval arm
            cannot reach the answer's document on the former and dominates the
            latter.

            So the blended figure above is a function of the probe's family
            MIX. It does not measure capability. On a family-balanced probe it
            will report a winner that a naturally-weighted probe reverses.
            Publishing it as
            "the score" is the same class of error as reporting bare accuracy
            without coverage; see the note on `accuracy`.

            Null only when the slice carries no family labels.
          oneOf:
            - type: "null"
            # An empty families map. A bench polled before its first row has
            # nothing to report yet, and PHP's json_decode($x, true) renders an
            # empty JSON object as an empty ARRAY, so the empty case has to be
            # spelled out or every in-flight poll fails validation, which stops
            # the client polling and leaves the panel frozen at "queued 0/n".
            - type: array
              maxItems: 0
            - type: object
              minProperties: 1
              additionalProperties:
                type: object
                additionalProperties: false
                properties:
                  label: { type: string }
                  accuracy:
                    type: number
                    minimum: 0
                    maximum: 1
                    description: |
                      correct / EVERY row asked. Distinct from `precision`,
                      which divides by the rows the arm chose to answer. The
                      two differ only for an arm that can abstain, which is why
                      reporting one number called "accuracy" flattered TOMX:
                      its figure was precision-given-answered while TX and RAG,
                      which expose no TOMX abstention gate here, were scored on every row.
                  accuracy_at5:
                    type: number
                    minimum: 0
                    maximum: 1
                    description: |
                      Same, at k=5. Recorded for EVERY arm. RAG was previously
                      scored @5 while TOMX and TX were scored @1: a three-way
                      comparison in which one arm got five guesses.
                  precision: { type: number, minimum: 0, maximum: 1 }
                  coverage: { type: number, minimum: 0, maximum: 1 }
                  correct: { type: integer, minimum: 0 }
                  correct_at5: { type: integer, minimum: 0 }
                  answered: { type: integer, minimum: 0 }
                  n: { type: integer, minimum: 0 }
                  self_answering:
                    type: number
                    minimum: 0
                    maximum: 1
                    description: |
                      Share of this family whose answer IS its own source
                      document's entity. At 1.0 a retrieval arm is being asked
                      to find the document it was handed and cannot lose;
                      measured 1.0000 for families 1 and 3, 0.0000 for family
                      0.
                required: [label, accuracy, coverage, correct, answered, n]
        latency_ms: { $ref: '#/components/schemas/LatencyStats' }
        accuracy_at5: { type: number, minimum: 0, maximum: 1 }
        precision: { type: number, minimum: 0, maximum: 1 }
        correct_at5: { type: integer, minimum: 0 }
        gate_applied:
          type: boolean
          description: |
            Whether TOMX's abstention gate was in force for these figures. It
            is FALSE for the accuracy comparison: TX and RAG expose no TOMX
            abstention gate here, so scoring TOMX gated against them counts declining
            as being wrong. The gate is reported separately, below.
        gate:
          description: |
            TOMX only. What declining actually buys, computed post-hoc from the
            per-row margins of the ungated run, so every banked operating
            point is evaluated on the same rows, and none is selected after
            seeing which flatters.
          oneOf:
            - type: "null"
            - type: object
      required: [accuracy, coverage, correct, answered, abstained, n, families, latency_ms]

    BenchProgress:
      type: object
      additionalProperties: false
      properties:
        job: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, done, error]
        slice: { type: string }
        done: { type: integer, minimum: 0 }
        total: { type: integer, minimum: 0 }
        results:
          description: Per-arm results. Populated progressively while running.
          oneOf:
            - type: object
              additionalProperties: false
              properties:
                tomx: { $ref: '#/components/schemas/BenchArmResult' }
                tx: { $ref: '#/components/schemas/BenchArmResult' }
                rag: { $ref: '#/components/schemas/BenchArmResult' }
            - type: "null"
        error: { type: [string, "null"] }
        meta: { $ref: '#/components/schemas/ResponseMeta' }
      required: [job, status, slice, done, total, results, error, meta]

    # ------------------------------------------------------------ footprint

    ArmFootprint:
      type: object
      additionalProperties: false
      properties:
        resident_params:
          type: [integer, "null"]
          minimum: 0
          x-differentiator: true
          description: |
            Resident parameter count for this arm. Added in v1.2.

            This is the number the demo's central claim rests on, so it is
            published rather than described: charter §1 defines TX as having
            the same resident parameter budget as TOMX, and CONTRACT_RULINGS
            N9 fixes the tolerance at ±10% with both figures visible here.

            For TOMX this is SERVE-resident and does not scale with entity
            count: the count is what serving holds resident, and
            training-only structures are excluded, so served entity content
            comes from the disk rows rather than from weights. That is
            precisely why `disk_mb` can grow while this number stays flat,
            which is the architecture's whole argument.

            Null when the driver has no model to count, which is the honest
            answer under the mock rather than a plausible-looking integer.
        resident_mb:
          type: number
          minimum: 0
          description: Measured resident memory attributable to this arm.
        disk_mb:
          type: number
          minimum: 0
          description: |
            Measured on-disk size of the files this arm actually OPENS to
            answer a question. Zero for TX, whose knowledge is entirely in
            resident weights; that zero is the comparison. It is not a
            missing value.
        disk_unserved_mb:
          type: number
          minimum: 0
          description: |
            Measured on-disk size of artifacts shipped for this arm that no
            query reads. Reported separately. It is never folded into
            `disk_mb`.

            For TOMX this covers several gigabytes of shipped files the serve
            path never opens. Adding them to `disk_mb` would inflate the
            knowledge-store figure severalfold with bytes nothing reads: the
            same theater as the previous undercount, pointing the other way.
      required: [resident_params, resident_mb, disk_mb]

    Footprint:
      type: object
      additionalProperties: false
      x-differentiator: true
      description: |
        Where each arm keeps what it knows.

        All three arms run in one process, so per-arm `resident_mb` is an
        explicit allocation figure and `process_rss_mb` is the real total.
        RSS is never divided between arms to manufacture a per-arm number;
        the two are not expected to sum, and `resident_basis` says which kind
        of figure you are reading (CONTRACT_RULINGS N6).
      properties:
        tomx: { $ref: '#/components/schemas/ArmFootprint' }
        tx: { $ref: '#/components/schemas/ArmFootprint' }
        rag: { $ref: '#/components/schemas/ArmFootprint' }
        process_rss_mb:
          type: number
          minimum: 0
          description: |
            Real resident set size of the sidecar process, from the OS. RSS
            includes disk pages the OS caches on the process's behalf and
            reclaims under memory pressure, so on a machine with spare RAM it
            grows with use. Read it beside `process_anon_mb`.
        process_anon_mb:
          type: number
          minimum: 0
          description: |
            The memory the process needs: its anonymous resident set, which
            the OS cannot reclaim without swapping. The difference between
            `process_rss_mb` and this number is reclaimable cache. This is
            the figure the budget bounds, because it is the one that decides
            what size machine the process runs on.
        resident_basis:
          type: string
          enum: [allocated, unattributed]
          description: |
            How to read every `resident_mb` above. Machine-readable so the UI
            cannot mislabel it.

            `allocated`: torch-measured parameter and buffer bytes for that
            arm's modules. An honest "allocated" figure, and what the live
            sidecar reports.

            `unattributed`: this driver cannot attribute resident memory per
            arm, and the per-arm figures are NOT a measurement of the arm. The
            mock reports this: a PHP process holding one shared fixture corpus
            has no per-arm resident memory to measure, and the resident/disk
            split P4 exists to show is not demonstrable under it.
        budget_mb:
          type: number
          minimum: 0
          description: |
            The RAM budget this deployment is specified against: charter §3's
            t3.medium floor, 4096 MB.
        within_budget:
          type: boolean
          description: |
            Whether `process_anon_mb` fits in `budget_mb`. Gated on the
            anonymous figure because cache pages vacate under pressure and
            RSS on a large machine measures the machine, not the process.
            Both figures are always on screen, and if the anonymous figure
            ever exceeds the budget this flag goes false and the panel says
            so in red.

            This does not touch the matched-size claim, which is about
            resident PARAMETER counts (6,460,208 vs 6,573,312) and is
            unaffected.
      required: [tomx, tx, rag, process_rss_mb, resident_basis]

    # ---------------------------------------------------------- trial token

    TrialToken:
      type: object
      additionalProperties: false
      description: |
        A self-issued trial credential. Shown once; store it. It expires
        within a day and its writes live in an overlay that expires with it.
      properties:
        token:
          type: string
          description: "The bearer token. Send it in the Authorization header as a Bearer credential."
        expires_at:
          type: string
          format: date-time
        scopes:
          type: array
          items: { type: string, enum: [ask, edit, grow] }
        note: { type: string }
        docs:
          type: string
          format: uri
          description: Where the full agent-readable overview lives.
      required: [token, expires_at, scopes]

    # ----------------------------------------------------------------- meta

    Meta:
      type: object
      additionalProperties: false
      properties:
        ckpt:
          type: object
          additionalProperties: false
          properties:
            tomx: { type: string, pattern: '^[0-9a-f]{12}$' }
            tx: { type: string, pattern: '^[0-9a-f]{12}$' }
          required: [tomx, tx]
        bench: { type: string }
        corpus: { type: string }
        overlay_ttl_s: { type: integer, minimum: 1 }
        driver: { type: string, enum: [mock, live] }
        artifact_namespace:
          type: [string, "null"]
          pattern: '^[a-z0-9][a-z0-9_.-]*$'
          description: Separate artifact namespace. GL-2 reports `gl2`; legacy/mock may omit or return null.
        artifacts:
          type: [object, "null"]
          additionalProperties: false
          properties:
            gl2_trunk:
              type: string
              pattern: '^[0-9a-f]{12}$'
              description: Pinned GL-2 trunk artifact hash, not a checkpoint-swap alias.
            organ_stack:
              type: string
              pattern: '^[0-9a-f]{12}$'
              description: Pinned stack artifact hash served with the trunk.
          required: [gl2_trunk, organ_stack]
        self_tests:
          type: [object, "null"]
          additionalProperties: false
          properties:
            anchor:
              type: object
              additionalProperties: false
              properties:
                status: { type: string, enum: [pass, fail] }
                hash:
                  type: string
                  pattern: '^[0-9a-f]{12}$'
              required: [status, hash]
          required: [anchor]
        calibration:
          type: [object, "null"]
          additionalProperties: false
          properties:
            id: { type: string }
            hash:
              type: string
              pattern: '^[0-9a-f]{12}$'
            measured_at: { type: string }
            regimes:
              type: array
              items: { type: string }
          required: [id, hash]
        comparison_arms:
          type: [object, "null"]
          additionalProperties: false
          properties:
            manifest:
              type: string
              pattern: '^[0-9a-f]{12}$'
            arms:
              type: array
              items: { type: string, enum: [tomx, tx, rag] }
            matched_on:
              type: array
              items: { type: string }
          required: [manifest, arms]
      required: [ckpt, bench, corpus, overlay_ttl_s, driver]

    CuratedQuery:
      type: object
      additionalProperties: false
      properties:
        query_id: { type: string }
        text: { type: string }
        family:
          type: [integer, "null"]
          description: |
            Task family. Different families are different TASKS won by
            different architectures, which is why P5 may only headline a
            per-family breakdown.
        family_label: { type: [string, "null"] }
        answerable:
          type: boolean
          description: |
            False marks a query the system holds no fact for. Included on
            purpose: a deployed no-gate arm may still return a candidate.

            Under the live driver this is MEASURED for the entity the
            question is cut from. It was previously the literal `True` for
            every query, which made the panel's "try it on a no-row query" copy
            false: there was no such query and no way to produce one.
        self_answering:
          type: boolean
          description: |
            True when this question's answer IS its own source document's
            entity, i.e. retrieval is being asked to find the document it was
            handed. Measured for each question rather than assumed.

            Disclosed on each question rather than only in aggregate:
            families 1 and 3 measure 1.0000 here and family 0 measures
            0.0000, so a
            reader-free retrieval arm cannot lose on two thirds of the probe
            set. Presenting those rows without the flag would let a
            near-ceiling RAG score read as capability.
      required: [query_id, text, answerable]

    QueryList:
      type: object
      additionalProperties: false
      properties:
        queries:
          type: array
          items: { $ref: '#/components/schemas/CuratedQuery' }
        total: { type: integer, minimum: 0 }
      required: [queries, total]

    Health:
      type: object
      additionalProperties: false
      properties:
        ok: { type: boolean }
      required: [ok]

    # -------------------------------------------------------------- problem

    Problem:
      type: object
      description: RFC 9457 problem details.
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        errors:
          type: object
          description: Field-level validation errors, when status is 422.
          additionalProperties:
            type: array
            items: { type: string }
      required: [type, title, status]
