# openapi.yaml — GENERATED by scripts/openapi-yaml.js from openapi.json. Do not hand-edit; edit the JSON's source and rerun.
openapi: "3.1.0"
info:
  title: fixRAgent triage API
  version: engine-v2-triage-door-1.0.0
  summary: "One photo in, one triage out: what it is, whether it is broken, how urgent, and a link to tell us whether the answer was right."
  description: "POST a photo. You get back a CONTRACT v1 CORE triage of 20 fields, a server-assigned\nseverity and tier, a `diagnosis_id`, and a `callback_url` for reporting what actually fixed it.\n\n**Severity and tier are assigned by the server, from the rubric, in code.** The model is asked for\nobservations — four axis values and two photo-grounded booleans — and the server derives P1..P4 from\nthem and the tier from P. A model's own opinion of severity is recorded as\n`provenance.model_proposed_severity` and then overwritten, and the overwrite is recorded too.\n\n**Criterion 1.** Visible wiring in the photo, or water near anything electrical, is EMERGENCY by\ndefinition — no other axis can lower it. Where either fact is uncertain the engine answers false and\nthe decision table decides instead; that is deliberate, so a dim photo of a basement is not an\nemergency.\n\n**This is a demo door.** Three guards stand in front of it, in this order:\n1. 20 requests per IP per 1 hour (the same scheme /api/diagnose uses).\n2. One demo key in the `x-triage-key` header, 60 requests per UTC day. The key is read from the\n   `TRIAGE_DEMO_KEY` environment variable; when that is unset the gate is open and every response says so.\n3. A daily spend fuse at $20. Past it the endpoint answers 503 with a plain-English message and\n   spends nothing — the fuse is checked before the body is read.\n\n**About the spend figure.** Every dollar amount this API reports is COMPUTED from token counts and a\ndeclared price table. It is not metered and it is not a bill. It is labelled that way in the payload\n(`basis`), and it should be labelled that way anywhere it is quoted.\n\n**The core is checked, not just shaped.** Every emitted core is validated against all eight of CONTRACT-v1.json's semantic rules before it leaves the handler, and the result rides in `contract_validation` on every 200. Violations are repaired and named, never silently fixed; a repair that moves severity says so in the payload, the warnings and a response header; a violation that survives repair refuses the whole response (502 `contract_unrepairable`).\n\n**Photos.** Every uploaded photo has its metadata stripped before it is hashed, sent to the engine, or\nstored. A photo whose metadata cannot be stripped is refused (415 or 500) rather than processed — there\nis no pass-through fallback, because a photo that still carries GPS looks identical to one that was\ncleaned. No photo bytes are stored by this endpoint; only the SHA-256 of the stripped image."
  contact:
    name: AR Logic LLC
    url: "https://fixragent.com"
servers:
  - url: "https://fixragent.com"
    description: Production. Same origin as the Triage Profile on the home page.
  - url: "/"
    description: Same origin as the app.
tags:
  - name: triage
    description: The diagnosis door.
paths:
  "/api/triage":
    post:
      tags:
        - triage
      operationId: createTriage
      summary: Triage one photo
      description: "Runs the two-pass engine — identify, then diagnose — and returns CONTRACT v1 CORE with a server-assigned severity and tier."
      parameters:
        - name: config
          in: query
          required: false
          description: "Which engine config to run. With ENGINE_V2_FAST unset, configs are read at request time from qa/engine-v2/ENGINE-V2-CANDIDATES.json (two-pass: an identify call and then reasoning reads — fast = one reasoning read after the identify call (two provider calls, agreement.n 1), deep = nine reasoning reads after it). With ENGINE_V2_FAST=1 they are read from api/_engine-v2-candidates.json and served through the k-of-n vote (fast = three reads, deep = five reads, the measured a1_rubric_v11 export on gemini-3.5-flash-lite). Either way, when the file is absent, unparseable, has no such config, or (vote path) is mismatched against the code that would serve it, a HARDCODED FALLBACK answers instead and says so loudly: `config_source.fallback_used` is true, `config_source.warning` and the top-level `warnings` array carry a sentence saying the response was not produced by a measured candidate, the `config_id` begins with \"fallback-\", and the response carries an X-Triage-Config-Fallback header."
          schema:
            type: string
            enum:
              - fast
              - deep
            default: fast
        - name: extended
          in: query
          required: false
          description: "Set to 1 to also receive the `extended` block. Omitted, `extended` comes back null."
          schema:
            type: string
            enum:
              - "1"
              - "true"
        - name: "k"
          in: query
          required: false
          description: "The demo key, as an alternative to the x-triage-key header. Prefer the header: a key in a query string ends up in logs."
          schema:
            type: string
        - name: x-triage-key
          in: header
          required: false
          description: "The demo key. Required whenever TRIAGE_DEMO_KEY is set on the deployment; 60 requests per UTC day. Exception: a browser request whose Origin is exactly https://fixragent.com or https://www.fixragent.com (fixRAgent's own Triage Profile) needs no key; it is still rate-limited per IP and bounded by the daily spend fuse."
          schema:
            type: string
        - name: channel
          in: query
          required: false
          description: Which door this request came through, when it is easier to put on the URL than in the body (the ruled convention for door links). A channel in the body wins over this one; either wins over everything the server derives.
          schema:
            type: string
            enum:
              - page
              - embed
              - handyman
              - shortcut
              - email-in
              - mcp
              - api
        - name: lang
          in: query
          required: false
          description: "The language the free-text fields come back in: `en` or `es`. A `lang` in the JSON body wins over this one; with neither, the first tag of the Accept-Language header decides (es-* → es), and the default is en. Under `es` the engine writes fault_summary, photo_subject, asset_type, trade_required, resident_explanation and the other prose fields in Spanish; hazard_detail, resident_do_not and every *_reason field stay in English on every path, and the reply's `language` object says which language was served. Enum values, taxonomy ids and the four urgency words are never translated on the wire."
          schema:
            type: string
            enum:
              - en
              - es
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              type: object
              required:
                - image
              properties:
                image:
                  type: string
                  description: "The photo, base64. A `data:image/...;base64,` prefix is accepted and stripped. Decoded size must be at least 1KB and at most 3MB (3,145,728 bytes).\n\nWHY 3MB and not something rounder: the photo arrives base64-encoded inside a JSON body, and the platform caps the whole request body at 4.5 MB before this function is ever entered (https://vercel.com/docs/functions/limitations, fetched 2026-09-11, page last updated August 24, 2026). Base64 adds a third, so 3MB of photo is ~4MB on the wire — inside the cap with room for the rest of the body. This figure is CITED from vendor documentation, not measured against a deployed function: nothing is deployed from this branch. An earlier version of this document published 20MB, which no request could ever have delivered."
                mimeType:
                  type: string
                  enum:
                    - image/jpeg
                    - image/png
                    - image/webp
                  default: image/jpeg
                  description: Used by the metadata stripper. A format it cannot clean is refused with 415.
                problem_text:
                  type: string
                  maxLength: 1200
                  description: "The reporter's own words. Treated as reported symptoms, never as ground truth — where the photo and the report disagree the photo wins on the fault, and the report still wins on the hazard."
                reporter:
                  type: string
                  enum:
                    - resident
                    - assessor
                    - technician
                  default: resident
                  description: Who wrote problem_text. It changes how the engine refers to them, nothing else.
                config:
                  type: string
                  enum:
                    - fast
                    - deep
                  description: Same as the query parameter; the query parameter wins.
                extended:
                  type:
                    - boolean
                    - string
                  description: Same as the query parameter.
                role:
                  type: string
                  maxLength: 40
                  description: "Free-form funnel context: who is on the other end. Stored as-is. No vocabulary is ruled yet."
                variant:
                  type: string
                  maxLength: 40
                  description: "Free-form funnel context: which surface rendered the Triage Profile."
                channel:
                  type: string
                  enum:
                    - page
                    - embed
                    - handyman
                    - shortcut
                    - email-in
                    - mcp
                    - api
                  description: "Which door this request came through. Optional: when absent (or not one of these seven) the server derives it — variant 'source:email-in' or 'source:mcp' first, then a first-party Origin (page), then a key (api) — and says which rule answered in provenance.channel_source."
                lang:
                  type: string
                  enum:
                    - en
                    - es
                  description: Same as the lang query parameter; the body value wins.
      responses:
        "200":
          description: "A triage. Note that a 200 can still carry warnings — read `config_source.fallback_used` and `warnings` before treating the result as a measured-candidate run."
          headers:
            X-Triage-Config-Fallback:
              description: "Set to \"true\" when the engine config came from the hardcoded fallback rather than ENGINE-V2-CANDIDATES.json."
              schema:
                type: string
            X-Triage-Warning:
              description: "Set to \"demo-key-gate-open\" when TRIAGE_DEMO_KEY is unset on this deployment and the key gate is therefore open."
              schema:
                type: string
            X-Triage-Schema:
              description: The response schema id@version this deployment answers under (sent on every response, errors included; exposed to browsers). Additive only within v1.
              schema:
                type: string
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/TriageResponse"
        "400":
          description: "image_required — A photo is required. Send it as base64 in the \"image\" field.  |  image_invalid — That photo could not be read as base64 image data.  |  image_too_small — That photo is too small to diagnose."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - image_required
                      - image_invalid
                      - image_too_small
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
        "401":
          description: "demo_key_required — A demo key is required. Send it as the x-triage-key header."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - demo_key_required
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
        "405":
          description: "method_not_allowed — Method not allowed. POST a photo to this endpoint."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - method_not_allowed
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
        "413":
          description: "image_too_large — That photo is larger than 3MB. Please send a smaller one."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - image_too_large
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
                  max_photo_bytes:
                    type: integer
                    const: 3145728
                  platform_request_body_limit:
                    type: object
                    description: "The platform ceiling that makes the photo ceiling what it is: 4.5 MB, cited from https://vercel.com/docs/functions/limitations (fetched 2026-09-11), not measured."
        "415":
          description: "image_unsupported — That image format cannot have its metadata stripped, so it is refused. Send a JPEG, PNG or WebP."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - image_unsupported
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
        "429":
          description: "rate_limited — Too many requests from this address. The Retry-After header on this response says how many seconds until this address is allowed again.  |  demo_key_quota — This key has used its 60 requests for today. The count resets at 00:00 UTC; the Retry-After header on this response says how many seconds that is.  Both carry a Retry-After header computed from the clock that actually gates the caller: rate_limited from that address’s own one-hour window, demo_key_quota from the next 00:00 UTC. The same number is repeated in the body as retry_after_seconds, with retry_after_basis naming which clock."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - rate_limited
                      - demo_key_quota
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
                  retry_after_seconds:
                    type: integer
                    minimum: 1
                    description: "Seconds until the gate that produced this 429 opens again. Always equals the Retry-After header on the same response — they are computed once, from one clock."
                  retry_after_basis:
                    type: string
                    enum:
                      - ip_rate_window
                      - utc_day
                    description: "WHICH clock retry_after_seconds was computed from. ip_rate_window = this address’s own one-hour window; utc_day = the next 00:00 UTC."
        "500":
          description: "metadata_strip_failed — That photo could not be cleaned of location metadata, so it was neither diagnosed nor stored.  |  internal_error — Something went wrong on our side."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - metadata_strip_failed
                      - internal_error
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
        "502":
          description: "engine_error — The diagnostic engine did not return a usable answer.  |  contract_unrepairable — The diagnostic engine returned an answer that breaks the output contract in a way that could not be repaired safely, so nothing is returned."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - engine_error
                      - contract_unrepairable
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
                  contract_validation:
                    type: object
                    description: "Present on contract_unrepairable only: the full record of which CONTRACT-v1 semantic rules the engine output broke, what was attempted, and which violations survived repair (`residual_violations`). The response is refused rather than emitted with a note attached."
        "503":
          description: "budget_exhausted — The triage demo has used up its compute budget for today. This is a spending limit on our side, not a problem with your photo or your request. The budget resets at 00:00 UTC — please try again then."
          content:
            "application/json":
              schema:
                type: object
                required:
                  - error
                  - message
                properties:
                  error:
                    type: string
                    enum:
                      - budget_exhausted
                  message:
                    type: string
                    description: Plain English, for a human. Never a stack trace and never an internal code.
                  budget:
                    type: object
                    description: "Present on 503 budget_exhausted only: the day, the COMPUTED (never metered) spend, and the limit."
                    properties:
                      day:
                        type: string
                      computed_usd:
                        type: number
                      limit_usd:
                        type: number
                        const: 20
                      basis:
                        type: string
                        description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
                        const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
                  detail:
                    type: string
                  exif_strip_version:
                    type: string
                  retry_after_seconds:
                    type: integer
                    description: The same number as the Retry-After header, in the body.
                  resets_at:
                    type: string
                    format: date-time
                    description: The UTC instant the day bucket rolls over.
                  retry_after_basis:
                    type: string
                    const: utc_day
                    description: "The clock retry_after_seconds was computed from: the next 00:00 UTC, the instant the fuse message says the budget resets."
          headers:
            Retry-After:
              description: "Seconds until the budget actually resets, computed from the current time to the next 00:00 UTC. It agrees with the sentence in `message` by construction; it used to be a fixed 3600 regardless of the hour, so at 02:00 UTC the header and the message gave different instructions."
              schema:
                type: integer
                minimum: 1
                maximum: 86400
      security:
        - triageKey: []
    options:
      tags:
        - triage
      operationId: triagePreflight
      summary: CORS preflight
      responses:
        "204":
          description: No content.
components:
  schemas:
    TriageResponse:
      type: object
      required:
        - diagnosis_id
        - engine_version
        - rubric_version
        - config_id
        - config_source
        - agreement
        - callback_url
        - core
        - extended
        - provenance
        - stored
        - warnings
        - contract_validation
        - customer
        - data_use
        - schema_version
      properties:
        diagnosis_id:
          type: string
          format: uuid
          description: "uuid4, minted by the server BEFORE the engine runs, because it is the path segment in /c/<uuid> and /o/<uuid> and the join key into outcomes. Clients never supply it."
        engine_version:
          type: string
          description: Which engine build produced this. Recorded by the wrapper, never asked of the model. engine-v2-triage-door-1.0.0 is the two-pass path (ENGINE_V2_FAST unset); engine-v2-vote-1.0.0 is the k-of-n vote over the measured a1_rubric_v11 export (ENGINE_V2_FAST=1, BLOCK-149), whose config_id, engine_version and rubric_version are read from api/_engine-v2-candidates.json.
          enum:
            - engine-v2-triage-door-1.0.0
            - engine-v2-vote-1.0.0
        rubric_version:
          type: string
          const: v1.1-2026-09-11
          description: Which rubric assigned the tier. Without it a stored tier is uninterpretable the moment the rubric is bumped.
        config_id:
          type: string
          description: "The config snapshot behind the model ids, temperature and prompt hash. An id beginning \"fallback-\" means the candidates file did not answer and a hardcoded config did."
        config_source:
          type: object
          description: "Where the config came from, in full. `candidates_file` names the file the loader read for THIS request: qa/engine-v2/ENGINE-V2-CANDIDATES.json on the two-pass path, api/_engine-v2-candidates.json on the vote path. Read `fallback_used` before treating a response as a measured-candidate run."
          required:
            - candidates_file
            - candidates_file_present
            - requested
            - fallback_used
          properties:
            candidates_file:
              type: string
              enum:
                - qa/engine-v2/ENGINE-V2-CANDIDATES.json
                - api/_engine-v2-candidates.json
            candidates_file_present:
              type: boolean
              description: "Whether the file named in `candidates_file` was readable at request time."
            requested:
              type: string
            fallback_used:
              type: boolean
            fallback_reason:
              type:
                - string
                - "null"
              enum:
                - candidates_file_absent
                - candidates_file_unparseable
                - config_not_in_candidates_file
                - candidate_missing_model_ids
                - candidate_pin_mismatch
                - vote_module_unavailable
                - null
            warning:
              type:
                - string
                - "null"
              description: A sentence, present whenever a fallback was used, saying this response was not produced by a measured candidate and must not be reported as one.
            mismatch:
              type: array
              items:
                type: string
              description: "Vote path only, with fallback_reason candidate_pin_mismatch: which pins disagreed with the code (config_id, model, prompt_sha256, schema_sha256, rubric_version, engine_version, n, temperature — the file may not name a temperature other than the one api/_engine-v2-fast.json sends)."
            vote:
              type: boolean
              description: true when the vote loader (ENGINE_V2_FAST=1) answered this request, served or fallen back; absent on the two-pass path.
        agreement:
          type: object
          description: "How sure the engine is, as AGREEMENT between independent reads of the same photo — never the model's own number. n is the number of reads attempted, k the parsed reads that gave the majority yes/no fault verdict (null on a tie). decided is true only when k >= ceil(n/2) (BLOCK-149 T0.i): a read that failed or returned no verdict counts in n and never in k; a tie is never decided. On the vote path an undecided first round is re-voted ONCE (another n reads) and both rounds ride in `rounds`; on the two-pass path n is 1 (fast: a single read, nothing to agree) or 9 (deep) and there is no re-vote. The Triage Profile prints these as landlord sentences (\"two of three reads agreed\", \"undecided — read again\"). When neither round decides, `reported_round` names the round the counts and the delivered core come from."
          required:
            - mode
            - "n"
            - "k"
            - decided
            - votes
            - applicable
          properties:
            mode:
              type: string
              enum:
                - fast
                - deep
              description: the ?config requested
            "n":
              type: integer
              minimum: 1
              description: reads attempted in the round this response reports
            k:
              type:
                - integer
                - "null"
              description: parsed reads for the majority verdict; null on a tie or a single read
            decided:
              type:
                - boolean
                - "null"
              description: "k >= ceil(n/2) and not a tie; null on a single read"
            votes:
              type:
                - object
                - "null"
              properties:
                fault:
                  type: integer
                no_fault:
                  type: integer
                no_verdict:
                  type: integer
              description: how the reads fell; no_verdict = reads that failed or returned no boolean verdict
            vote_rule:
              type: string
              description: the decided rule in words, stamped wherever a vote is read
            re_voted:
              type: boolean
              description: true when the first round was undecided and a second round of n reads was taken (vote path only; always false on the two-pass path)
            rounds:
              type: array
              items:
                type: object
              description: "vote path only: every round attempted (or skipped for lack of time budget), with its counts and reason"
            majority_verdict:
              type:
                - boolean
                - "null"
              description: true = fault, false = no fault, null when undecided or single read
            delivered_matches_majority:
              type:
                - boolean
                - "null"
              description: whether the delivered core still carries the majority verdict after contract repair; false makes the Triage Profile refuse to print the count
            applicable:
              type: boolean
              description: "false when the photo is not a building asset — agreement is only counted on photos of something in the building"
            verdict_field:
              type: string
            reads_attempted_total:
              type: integer
            need:
              type: integer
              description: ceil(n/2)
            reported_round:
              type:
                - integer
                - "null"
              description: "Vote path only: which round this response reports (1 or 2). A decided round is always the last one attempted. When neither round decided, the round with the LARGER plurality is reported (then the one with more parsed reads, then the later one) — never a round that parsed nothing, and never a worse second round over a better first (BLOCK-149 fixer round 1). The Triage Profile says \"the round this Triage Profile shows\". Absent on the two-pass path."
        callback_url:
          type: string
          description: The /api/outcome path for this diagnosis_id.
        callback:
          type: object
          description: How to report the outcome. /api/outcome reads diagnosis_id from the JSON BODY on POST; the query string on callback_url identifies the diagnosis to a human and in logs, it is not what the endpoint reads.
          properties:
            method:
              type: string
              const: POST
            url:
              type: string
              const: "/api/outcome"
            note:
              type: string
            required_body:
              type: object
        core:
          "$ref": "#/components/schemas/TriageCore"
        extended:
          "$ref": "#/components/schemas/TriageExtended"
        provenance:
          "$ref": "#/components/schemas/TriageProvenance"
        stored:
          "$ref": "#/components/schemas/TriageStored"
        warnings:
          type: array
          items:
            type: string
          description: "Anything the caller should not miss: an open demo-key gate, a config fallback. Empty array when there is nothing to say."
        contract_validation:
          type: object
          description: "What CONTRACT-v1.json said about this core — ALL of it — and what was done about it. Always present on a 200. TWO HALVES. (1) rules.semantic: the eight rule SENTENCES are read from the contract file at module load, never transcribed, and each is bound to a predicate; a sentence with no predicate, or a predicate with no sentence, is reported as drift. (2) json_schema: every field the door emits is walked against the contract's own type, enum, pattern, bounds, string length, array shape, uniqueness and nested required keys — read from the JSON, so a contract change changes what is enforced with no code edit. The validator DECLARES which JSON-Schema keywords it can execute; a keyword the contract uses that it cannot is reported in rules_source.shape_drift and in `warnings` as UNENFORCED, because a constraint that stopped being checked must not read like one that is. Before this existed the handler enforced the eight sentences and nothing else: an engine reply carrying asset_category:'BANANA' came back HTTP 200 with that value intact. Policy is repair-and-record: a violation is repaired, named here, and echoed into `warnings`; a repair never invents a photo-grounded fact, a taxonomy class or prose, and where the engine gave no support the assertion is RETRACTED (preserved under `retracted`) rather than dressed up. A repair that moved severity says so in `repairs_changed_severity` and in an X-Triage-Contract-Repair response header. If any violation survives repair the response is refused with 502 contract_unrepairable instead."
          required:
            - contract_version
            - policy
            - conformant
            - violations_found
            - repairs
            - residual_violations
          properties:
            contract_version:
              type: string
              const: v1-DRAFT-2026-09-10
            policy:
              type: string
              const: repair-and-record
            policy_note:
              type: string
            rules_source:
              type: object
              description: "Where the rules came from and how completely they are enforced: the contract file, how many semantic sentences it supplied, how many predicates are implemented, `drift` between the two, how many json_schema field definitions were found, which JSON-Schema keywords this validator EXECUTES, which it deliberately ignores as annotations, and `shape_drift` — any keyword the contract uses that the validator cannot execute, i.e. any constraint that is currently UNENFORCED."
            scope_checked:
              type: object
              description: "Which clauses were actually evaluated, and on which fields. Clauses that live in `extended` cannot be checked when `extended` was not requested. `core_fields_not_governed_by_contract` names the five RUBRIC-v1.1 additions (tier, criterion_1_fired, tier_forced_by, visible_wiring, water_near_electrical) that CONTRACT-v1 predates and therefore cannot check. `contract_required_fields_this_door_does_not_emit` names the consumer-surface render keys in json_schema.required that this door is not a surface for — their absence is scope, not a violation."
            violations_found:
              type: array
              description: What was wrong, with the contract's own sentence attached.
              items:
                type: object
            repairs:
              type: array
              description: "Field-by-field: was, now, and why."
              items:
                type: object
            exemptions:
              type: array
              description: "A rule that legitimately does not apply — currently only RUBRIC-v1.1 §2.2's criterion-1 carve-out, the one legal P1 with fault_detected=false. Recorded, never silent."
              items:
                type: object
            retracted:
              type: object
              description: What an unsupported assertion said before it was withdrawn. Nothing is lost.
            engine_classes_rejected:
              type: array
              items:
                type: string
              description: fault_classes the engine offered that are not taxonomy ids. These used to be dropped silently.
            repairs_changed_severity:
              type: boolean
            severity_before_repair:
              type: string
            severity_after_repair:
              type: string
            conformant:
              type: boolean
              description: "The result of re-running every rule AFTER repair — the after half of a before/after pair, not a hope."
            residual_violations:
              type: array
              items:
                type: object
            shape_violations_found:
              type: array
              items:
                type: object
              description: "Field-shape violations found by walking json_schema: { path, keyword, detail, declared_conflict }. `keyword` is the contract construct that was broken (type, enum, minLength, maximum, uniqueItems, required, ...). `declared_conflict: true` marks the one carve-out described under contract_conflicts."
            contract_conflicts:
              type: array
              items:
                type: object
              description: "Places where CONTRACT-v1 and the shipped pipeline genuinely disagree and no repair exists that invents nothing. Currently exactly one: `core.confidence` is typed {number, 0..1} with no null, while clampConf() emits null whenever the engine gave no usable number. Such a case is NOT counted as a residual violation — but it is listed here and pushed onto `warnings` every time it occurs, so it can never read as conformance. It is a named open item, not a fix."
        channel:
          type: string
          enum:
            - page
            - embed
            - handyman
            - shortcut
            - email-in
            - mcp
            - api
          description: The door this read came through, as stored on the row (ai_result.channel).
        channel_source:
          type: string
          enum:
            - client
            - query
            - variant
            - origin
            - key
            - default
          description: "Which derivation rule answered: the request body's channel, the ?channel= query parameter, the door's 'source:<door>' variant, a first-party Origin, a key, or the default (api)."
        language:
          type: object
          description: "Which language the prose fields were asked for and served in (BLOCK-166). `requested` is what the caller sent (body, then ?lang=, then Accept-Language; null when nothing was sent), `served` is en or es, `source` names the rule that answered, and `hazard_text` is what hazard_detail actually reads as: hazard_detail, resident_do_not and every *_reason field are requested in English on every path, and on the es path the server reads the line back and reports en or not-en. A `note` appears when the two-pass fallback path answered (it is English)."
          properties:
            requested:
              type:
                - string
                - "null"
            served:
              type: string
              enum:
                - en
                - es
            source:
              type: string
              enum:
                - body
                - query
                - header
                - default
            hazard_text:
              type: string
              enum:
                - en
                - not-en
              description: "A READING of hazard_detail's language on the es path, never a promise: en when the line reads English (or is empty), not-en when the engine wrote it in another language despite being asked for English — the Triage Profile then withholds the line and says so. Always en on the en path."
            note:
              type: string
          required:
            - served
            - source
            - hazard_text
        customer:
          "$ref": "#/components/schemas/TriageCustomer"
        data_use:
          "$ref": "#/components/schemas/TriageDataUse"
        schema_version:
          type: string
          description: "The response schema this body answers under, e.g. \"triage-response-v1@1.1.0\". Same value as the X-Triage-Schema header. Schema: https://fixragent.com/schemas/triage-response-v1.json — additive only within v1."
    TriageCore:
      type: object
      description: "CONTRACT v1 CORE — exactly 20 fields, in this order. 15 are CONTRACT-v1.json fields under their contract names; 5 (tier, criterion_1_fired, tier_forced_by, visible_wiring, water_near_electrical) are RUBRIC v1.1-2026-09-11 additions that CONTRACT-v1 predates. Everything the engine produced beyond these rides in `extended`; nothing is dropped."
      required:
        - is_building_asset
        - asset_type
        - asset_category
        - photo_subject
        - report_photo_agreement
        - fault_detected
        - fault_visible_in_photo
        - fault_summary
        - fault_classes
        - severity
        - tier
        - criterion_1_fired
        - tier_forced_by
        - visible_wiring
        - water_near_electrical
        - safety_hazard
        - hazard_detail
        - trade_required
        - confidence
        - resident_explanation
      additionalProperties: false
      properties:
        is_building_asset:
          description: False when the photo is a person, pet, vehicle, food or document. False forces severity P4 before anything else is evaluated.
          type: boolean
        asset_type:
          description: "The specific asset, named precisely (\"Gas water heater\", not \"appliance\"). Null only when is_building_asset is false."
          type:
            - string
            - "null"
        asset_category:
          description: "HVAC | PLUMBING | ELECTRICAL | APPLIANCE | STRUCTURAL | OTHER | NOT_AN_ASSET."
          type: string
          enum:
            - HVAC
            - PLUMBING
            - ELECTRICAL
            - APPLIANCE
            - STRUCTURAL
            - OTHER
            - NOT_AN_ASSET
        photo_subject:
          description: What is actually in frame, named from the pixels alone and independent of anything the reporter said. This is the field that catches a fabricated diagnosis of an asset that is not in the photo.
          type: string
        report_photo_agreement:
          description: "\"true\" | \"false\" | \"uncertain\" — does the reported problem describe what the photo shows. Null ONLY when no problem_text accompanied the photo."
          type:
            - string
            - "null"
          enum:
            - "true"
            - "false"
            - uncertain
            - null
        fault_detected:
          description: "The scored verdict. Rides the photo: a report-only symptom is a hazard input, never a fault."
          type: boolean
        fault_visible_in_photo:
          description: Whether the named fault is actually visible. Null when the engine did not answer.
          type:
            - boolean
            - "null"
        fault_summary:
          description: One paragraph for the person dispatching. Empty string when no fault.
          type: string
        fault_classes:
          description: Taxonomy-v0 class ids, primary first. Empty array whenever fault_detected is false. Ids not in the taxonomy are dropped by the server rather than passed through.
          type: array
          items:
            type: string
            enum:
              - panel-fault
              - burnt-charred-wiring
              - damaged-outlet-switch
              - deteriorated-legacy-wiring
              - exposed-improper-wiring
              - electrical-fixture-fault
              - other-electrical
              - gas-leak-piping-fault
              - water-heater-gas-control-fault
              - water-heater-tank-leak-corrosion
              - tpr-valve-discharge-fault
              - active-plumbing-leak
              - frozen-pipe
              - sewage-drain-backup
              - toilet-leak-running
              - fixture-wear-fault
              - other-plumbing
              - combustion-venting-co-fault
              - heating-cooling-outage
              - condensate-leak-blockage
              - refrigerant-circuit-fault
              - condenser-coil-degradation
              - ductwork-fault
              - radon-mitigation-failure
              - other-hvac
              - gas-appliance-fault
              - appliance-electrical-burn
              - battery-swelling-hazard
              - appliance-water-leak
              - refrigerator-cooling-fault
              - refrigerator-water-system-fault
              - laundry-mechanical-fault
              - dryer-vent-lint-fault
              - cooking-appliance-fault
              - small-appliance-degradation
              - other-appliance
              - structural-sag-collapse
              - water-damaged-structure
              - foundation-crack-fault
              - active-water-intrusion
              - mold-growth
              - interior-surface-damage
              - door-window-hardware-fault
              - other-structural
              - roof-structure-rot
              - roof-active-leak
              - roof-membrane-degradation
              - siding-envelope-damage
              - other-roofing-exterior
              - equipment-tool-fault
              - site-grounds-fault
              - soiling-maintenance-buildup
              - cosmetic-surface-wear
              - other-other
        severity:
          description: "P1 | P2 | P3 | P4. ASSIGNED BY THE SERVER from the rubric axes. Never taken from the model — whatever the model proposed is kept in provenance.model_proposed_severity instead."
          type: string
          enum:
            - P1
            - P2
            - P3
            - P4
        tier:
          description: "EMERGENCY | TODAY | THIS WEEK | WHENEVER. A pure function of severity, assigned in code. The only urgency wording a Triage Profile is allowed to use."
          type: string
          enum:
            - EMERGENCY
            - TODAY
            - THIS WEEK
            - WHENEVER
        criterion_1_fired:
          description: "True when the RUBRIC v1.1 EMERGENCY floor forced P1 — visible wiring, or water near anything electrical. This is the one legal way a diagnosis reaches P1 with fault_detected false."
          type: boolean
        tier_forced_by:
          description: "Which rule overrode the decision table, when one did: \"criterion-1:visible_wiring\", \"criterion-1:water_near_electrical\", \"not-a-building-asset\", or \"no-fault-detected-clamp\"."
          type:
            - string
            - "null"
        visible_wiring:
          description: "Photo-borne fact: a current-carrying conductor, splice or terminal a person could touch without a tool. Low-voltage wiring is excluded by definition. Uncertain reads as false, on purpose."
          type: boolean
        water_near_electrical:
          description: "Photo-borne fact: water, wetting or wetting-corrosion in contact with, above, or beneath an electrical device. Uncertain reads as false, on purpose."
          type: boolean
        safety_hazard:
          description: "A hazard EXISTS. Additive — it never replaces the explanation, and it is true whenever the hazard axis is at least H1. Every P1 carries a hazard; not every hazard is a P1."
          type: boolean
        hazard_detail:
          description: The named hazard. Non-empty exactly when safety_hazard is true, null exactly when it is false.
          type:
            - string
            - "null"
        trade_required:
          description: The trade to dispatch. Null when there is no fault.
          type:
            - string
            - "null"
        confidence:
          description: Calibrated 0-1 probability that the fault_detected verdict is correct. Out-of-range values are REJECTED to null, never rescaled on a guess.
          type:
            - number
            - "null"
          minimum: 0
          maximum: 1
        resident_explanation:
          description: "Plain language, for the resident. Always filled, never \"contact maintenance\"."
          type: string
    TriageExtended:
      type:
        - object
        - "null"
      description: Present only when extended=1 is requested; null otherwise. The rest of the contract output.
      properties:
        isHighRisk:
          description: "boolean — a projection of the axes (safety_hazard OR severity P1), not a fifth opinion."
        hazard_class:
          description: "H0 | H1 | H2 — the hazard axis as the rubric recorded it (criterion 1 records H2)."
        active_loss:
          description: "L0 | L1 | L2 — the active-loss axis."
        habitability:
          description: "C0 | C1 | C2 — the habitability axis."
        deferral_cost:
          description: "D0 | D1 | D2 — what one week of doing nothing does."
        severity_reason_row:
          description: "integer|null — which row of the RUBRIC v1 decision table decided it (1..7). Null when an override decided instead; tier_forced_by then says which."
        make_model:
          description: "string|null — only when legible on a label."
        identification_reasoning:
          description: "string|null — the distinguishing features behind asset_type."
        observations:
          description: "array — up to 4 {label, box, confidence}. box is [ymin, xmin, ymax, xmax] as INTEGERS 0-1000 (the Clarity convention, not the 0-1 floats used elsewhere in the consumer app)."
        loto_required:
          description: "boolean — lock-out/tag-out before work."
        skill_level:
          description: "integer|null — 1 (handy resident) .. 5 (licensed specialist)."
        diy_appropriate:
          description: boolean
        estimated_duration_minutes:
          description: "integer — technician time on site; 0 when no fault."
        parts_manifest:
          description: "array of {description, part_number, quantity}. part_number is ALWAYS null and the server forces it so: procurement assigns SKUs, this engine does not."
        resident_why_it_matters:
          description: string
        resident_do_not:
          description: "string|null — the one thing the resident must not do."
        resident_what_happens_next:
          description: string
        root_cause:
          description: "string|null — 2-3 sentences on the physical mechanism, for a supervisor."
        failure_mechanism:
          description: "string|null — one sentence, the failure mode in trade language."
        differential:
          description: "array of {cause, tell} — up to 4 candidates, most likely first; tell is what a technician observes on site to confirm or rule it out."
        wiring_water_reason:
          description: "string|null — one sentence on what in the photo decided visible_wiring and water_near_electrical."
    TriageProvenance:
      type: object
      description: "What produced this answer. Recorded by the wrapper — a model is never asked to report its own config."
      properties:
        contract_version:
          type: string
          const: v1-DRAFT-2026-09-10
        models:
          type: object
          properties:
            identification:
              type: string
            reasoning:
              type: string
        temperature:
          type: number
        prompt_hash:
          type: string
          description: First 16 hex of SHA-256 over both prompts as actually sent, base plus addendum.
        prompt_base:
          type: string
          description: "The prompts are IMPORTED from api/clarity-diagnose.js, not copied: one engine, two doors."
        prompt_addendum:
          type: string
          description: The rubric addendum this endpoint appends, asking for the axis values and the two criterion-1 booleans.
        latency_ms:
          type: integer
        photo_sha256:
          type: string
          description: SHA-256 of the photo AFTER metadata stripping. The only thing kept about the image.
        exif_strip_version:
          type: string
        model_proposed_severity:
          type:
            - string
            - "null"
          description: What the model said. Kept for scoring, never published as the answer.
        severity_assigned_by:
          type: string
          description: Always the server plus the rubric version.
        severity_overwritten:
          type: boolean
          description: True when the rubric disagreed with the model.
        spend_today:
          type: object
          description: Today's COMPUTED spend against the fuse. Not metered, not a bill.
          properties:
            computed_usd:
              type: number
            limit_usd:
              type: number
              const: 20
            basis:
              type: string
              description: "How the figure was arrived at AND what it is the figure of. Carries two caveats and BOTH ride in the string itself, not only in this description: computed from token estimates x declared prices (not metered, not a bill), and scoped to ONE warm instance (the counter is in process memory, so cross-instance day spend is under-counted by up to the number of warm instances). This is the value api/_triage-fuse.js BASIS emits verbatim on every payload that carries a spend figure — the 200's provenance.spend_today.basis and the 503's budget.basis are the same string. qa/triage-loop/fuse-basis-redproof.js compares this const to the basis read off an ACTUAL response."
              const: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
            token_estimate:
              type: boolean
              const: true
              description: True because the token counts themselves are estimated from text length, not read from a provider usage block.
    TriageStored:
      type: object
      description: What happened to the database row in public.diy_guest_scans. A degraded write never reads the same as a clean one.
      required:
        - written
        - degraded
      properties:
        written:
          type: boolean
        degraded:
          type: boolean
          description: True when the row landed WITHOUT the provenance columns because the staged migration is not applied to this database. The same values are still inside ai_result; they are simply not queryable by column.
        reason:
          type: string
          enum:
            - supabase_not_configured
            - insert_failed
            - missing_provenance_columns
            - missing_provenance_columns_and_base_insert_failed
            - write_threw
        missing_migration:
          type: string
        provenance_columns_expected:
          type: array
          items:
            type: string
            enum:
              - diagnosis_id
              - engine_version
              - rubric_version
              - config_id
              - role
              - variant
        detail:
          type: string
        note:
          type: string
        table:
          type: string
          const: diy_guest_scans
        columns:
          type: array
          items:
            type: string
    TriageCustomer:
      type: object
      additionalProperties: true
      description: "What a partner shows its own customer, under its own brand. Every value is derived in code from `core` (never copied from model text), so none of these fields ever carries a brand name or an empty sentence."
      required:
        - action
        - emergency
        - trade
        - sentence
        - language
        - find_a_pro_url
      properties:
        action:
          type: string
          enum:
            - call_now
            - book
            - monitor
            - no_fault
          description: call_now = severity P1, a gas/CO fault class, a criterion-1 electrical fact, or a hazard the rubric scores H2 (life-safety now; a reported gas smell is H2); book = P2, or any other named hazard; monitor = P3, or a P4 with a named fault; no_fault = no fault, or not a building asset.
        emergency:
          type: boolean
          description: True exactly when action is call_now.
        trade:
          type:
            - string
            - "null"
          enum:
            - plumbing
            - electrical
            - hvac
            - appliance
            - roofing
            - structural
            - general
            - null
          description: Which trade this belongs to. Null only when the photo is not a building asset.
        sentence:
          type: string
          minLength: 1
          description: "One or two plain sentences for the end customer, in `language`. No brand, no model hedging. A gas/CO fault class says leave the building; a criterion-1 electrical fact says keep away and switch off the power; any other named hazard says there is a safety concern and, IF you smell gas, leave the building — never \"not urgent\"."
        language:
          type: string
          enum:
            - en
            - es
          description: "Language of `sentence`."
        find_a_pro_url:
          type:
            - string
            - "null"
          description: "OPTIONAL to use: a plain Google Maps \"<trade> near me\" search (no partner id, no affiliate tag) on call_now and book; null otherwise, and always null on a gas/CO emergency (the gas company comes first)."
    TriageDataUse:
      type: object
      additionalProperties: true
      description: "Whether this call may ever be used to train or tune a model. Off by default; on only for a key whose holder authorised it in writing. A request can opt out (body do_not_train:true or header x-triage-do-not-train: 1); nothing in a request can opt in. No pipeline reads this flag yet, because nothing trains on these rows today; it is the one field any future export must honour."
      required:
        - training_allowed
        - basis
        - request_opt_out
        - photo_bytes_stored
      properties:
        training_allowed:
          type: boolean
        basis:
          type: string
        request_opt_out:
          type: boolean
        photo_bytes_stored:
          type: boolean
          description: "Always false on this endpoint: only the SHA-256 of the EXIF-stripped photo is kept."
  securitySchemes:
    triageKey:
      type: apiKey
      in: header
      name: x-triage-key
      description: "The demo key, sent on every call; 60 requests per UTC day. It is required whenever TRIAGE_DEMO_KEY is set on the deployment, as it is on production. One exemption serves the site's own Triage Profile: a browser request whose Origin is exactly https://fixragent.com or https://www.fixragent.com and that sends no key is let through (a request that sends a key is judged on the key). Request a key at https://fixragent.com/docs#key."
x-fixr:
  generated_from: api/triage.js exported constants
  checked_by: qa/triage-loop/t6-openapi-check.js
  core_field_count: 20
  tiers:
    P1: EMERGENCY
    P2: TODAY
    P3: THIS WEEK
    P4: WHENEVER
  guards:
    ip_rate_limit:
      max: 20
      window_ms: 3600000
      scheme: same as api/diagnose.js
    demo_key:
      env: TRIAGE_DEMO_KEY
      header: x-triage-key
      daily_limit: 60
    daily_spend_fuse:
      limit_usd: 20
      module: api/_triage-fuse.js
      red_proof: qa/triage-loop/fuse-redproof.js
      basis: computed_from_usage_tokens_x_declared_prices_not_metered; scope=this_warm_instance_only (in-process counter; cross-instance day spend is under-counted by up to the number of warm instances)
      retry_after: "seconds to the next 00:00 UTC, computed — not a fixed 3600"
      basis_note: "VERBATIM, the value api/_triage-fuse.js BASIS emits in every payload. It used to read 'computed, not metered, and scoped to ONE warm instance' — a paraphrase, which is a second record of the same fact and free to drift from the first."
  error_codes:
    method_not_allowed: 405
    rate_limited: 429
    demo_key_required: 401
    demo_key_quota: 429
    budget_exhausted: 503
    image_required: 400
    image_invalid: 400
    image_too_small: 400
    image_too_large: 413
    image_unsupported: 415
    metadata_strip_failed: 500
    engine_error: 502
    contract_unrepairable: 502
    internal_error: 500
  provenance_columns:
    - diagnosis_id
    - engine_version
    - rubric_version
    - config_id
    - role
    - variant
  not_deployed: This branch is not deployed. Sentry events from this handler are tagged environment=preview.
  photo_limits:
    max_photo_bytes: 3145728
    platform_request_body_limit:
      bytes: 4500000
      stated_as: "4.5 MB"
      source: "https://vercel.com/docs/functions/limitations"
      source_last_updated: August 24, 2026
      fetched: "2026-09-11"
      evidence: "cited from vendor documentation fetched on this date — NOT measured against a deployed function (nothing deploys from this branch)"
    note: "The photo ceiling is set BY the platform ceiling. The platform figure is cited from vendor documentation fetched 2026-09-11, not measured against a deployed function — nothing deploys from this branch."
  contract_validation:
    rules_file: qa/engine-v2/CONTRACT-v1.json rules.semantic
    rules_enforced: 8
    policy: repair-and-record; refuse (502 contract_unrepairable) if a violation survives repair
    red_proof: qa/triage-loop/contract-semantic-redproof.js
    key_set_red_proof: qa/triage-loop/core-keyset-redproof.js
    severity_path_red_proof: qa/triage-loop/severity-paths-redproof.js
