{
  "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": "ARLogic LLC",
      "url": "https://fixragent.com"
    }
  },
  "servers": [
    {
      "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 card) 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 card."
                  },
                  "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"
                }
              }
            },
            "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 demo 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
                }
              }
            }
          }
        }
      },
      "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"
        ],
        "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 card 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 card 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 card says \"the round this card 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 card then withholds the line and says so. Always en on the en path."
              },
              "note": {
                "type": "string"
              }
            },
            "required": [
              "served",
              "source",
              "hazard_text"
            ]
          }
        }
      },
      "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"
          },
          "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 card 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"
            }
          }
        }
      }
    }
  },
  "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"
    }
  }
}
