{
  "openapi": "3.1.0",
  "info": {
    "title": "bevel",
    "version": "2026-08-25",
    "description": "Public plane only -- the agent plane (/agent/v1/*) versions independently and is never third-party-issuable. Authenticate with `Authorization: Bearer <public API key>`. Every error response is application/problem+json; see /problems/ for the closed slug catalogue. Deprecation (RFC 9745) and Sunset (RFC 8594) headers are reserved as the retirement mechanism for a future breaking change; none is in effect today, and no Shapecast-Version request header exists yet.\n\nA request with no `User-Agent` header is rejected by Cloudflare (error 1010) before it ever reaches this gateway -- send a real one (SCGW-81, BANNER-515: this cost a confusing debug session with no gateway-side error to point at).\n\nEvery API key also carries a scope list (e.g. `jobs:read`, `jobs:write`, `jobs:read:all`, `webhooks:write`, `assets:purge`, `forgehub:write`) beyond bearer authentication. An authenticated request whose key lacks the scope a route requires gets a real, reachable `403` with problem type `insufficient-scope` -- not a 401, and not silently allowed. Each operation below that enforces a scope says so in its description; a route with no such note requires only a valid key. `assets:purge` (SCGW-90) is never bundled into `jobs:write` -- it gates only the hard-delete path of DELETE /v1/assets/{id}?purge=true, the first genuinely irreversible-data-loss capability this API exposes."
  },
  "servers": [{ "url": "https://bevel.foo" }],
  "security": [{ "bearerAuth": [] }],
  "components": {
    "securitySchemes": {
      "bearerAuth": { "type": "http", "scheme": "bearer" }
    },
    "responses": {
      "Problem": {
        "description": "application/problem+json.",
        "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } }
      },
      "Unauthorized": {
        "description": "unauthorized -- missing or malformed Authorization header, or the bearer token doesn't match any known key. Real and reachable on every operation below that requires the global bearerAuth security requirement (i.e. every operation except GET /v1/health and GET /v1/openapi.json).",
        "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } }
      }
    },
    "schemas": {
      "Problem": {
        "type": "object",
        "description": "RFC 9457. `type` always resolves to a human-readable page under /problems/.",
        "properties": {
          "type": { "type": "string", "format": "uri" },
          "title": { "type": "string", "description": "The problem slug itself." },
          "status": { "type": "integer" },
          "detail": { "type": "string" },
          "request_id": { "type": "string" },
          "job_id": { "type": "string", "format": "uuid" },
          "retry_after": { "type": "integer", "description": "Seconds. Also present as the standard Retry-After HTTP header on every 429." }
        },
        "required": ["type", "title", "status"]
      },
      "ForgeHubPush": {
        "type": "object",
        "description": "Where a ForgeHub push landed.",
        "properties": {
          "commit": { "type": "string", "description": "The commit sha (the branch's tip when nothing changed)." },
          "no_change": { "type": "boolean", "description": "true when the bytes were already there: no commit and no pull request were made." },
          "path": { "type": "string", "description": "The folder (piece route) or file (specs route) written, relative to the repo root." }
        },
        "required": ["commit", "no_change", "path"]
      },
      "JobCreate": {
        "type": "object",
        "required": ["prompt"],
        "properties": {
          "prompt": { "type": "string", "description": "1-2000 bytes (UTF-8 encoded), not 2000 characters -- a multi-byte prompt (emoji, CJK, Cyrillic, accented Latin, ...) has a correspondingly lower character ceiling." },
          "kind": { "type": "string", "description": "CLOSED vocabulary (prop, character, creature); determines which pose/style values are valid. See GET /v1/styles for the current set. Optional -- defaults to \"prop\" when omitted or empty, never rejected." },
          "style": { "type": ["string", "null"] },
          "pose": { "type": ["string", "null"], "description": "CLOSED per-kind vocabulary: creature takes biped/quadruped/winged/aquatic/other (aquatic: SCGW-386, checked against the 0.3.12 engine for creature only); character takes t-pose/a-pose/natural; prop takes no pose at all. Found live (SCGW-81, BANNER-515): an earlier version of this description named biped as character-only, inverted from the real, tested vocabulary -- biped is creature-only; character + biped is a 400. The engine also lists a `posed` value, but no kind is known to take it, so it is rejected for every kind." },
          "target_tris": { "type": ["integer", "null"], "description": "500-200000, hard-rejected with 400 outside that range (500 and 200000 themselves are accepted; 499 and 200001 are 400). Matches the Shapecast 0.3.12 engine's own range. SCGW-385: the earlier 40000 ceiling came from a sweep on an older engine; a real 0.3.12 cast at 80000 returned exactly 80000 triangles on the final mesh. Values above 80000 were not cast individually, and larger budgets take longer to build. null or omitted uses the engine's own default (also 40000) and is NOT range-checked; the response's warnings[] will note this when it applies." },
          "seed": { "type": ["integer", "null"], "description": "Passed through verbatim to the Shapecast engine's own params.seed when set, so a given seed genuinely reproduces the same geometry (and is what row_key's idempotency hash is keyed on). null or omitted lets the engine pick its own random seed each time." },
          "texture_quality": { "type": ["string", "null"], "enum": ["standard", "4k", "8k", null], "description": "SCGW-383: Shapecast 0.3.12 texture resolution, forwarded to the engine's JobCreate.texture_quality. null or omitted uses the engine default (standard). Any other value is a 400 naming the field. Cannot be combined with geometry_only:true unless it is \"standard\" -- a geometry-only job has no texture stage, so geometry_only with 4k or 8k is a 400 naming geometry_only. Whether a given worker honours 4k/8k depends on its hardware tier and is not checked here (the engine's own refusal would surface on the job)." },
          "detail": { "type": ["string", "null"], "enum": ["standard", "high", null], "description": "SCGW-383: Shapecast 0.3.12 detail level, forwarded to the engine's JobCreate.detail. null or omitted uses the engine default (standard). Any other value is a 400 naming the field." },
          "geometry_only": { "type": "boolean", "default": false, "description": "SCGW-383: true builds the mesh only -- no texturing stage runs and the job completes with untextured artifacts (no texture files). Forwarded to the engine's JobCreate.geometry_only. Combining it with texture_quality \"4k\" or \"8k\" is a 400." },
          "side_views": { "type": ["string", "null"], "enum": ["auto", "always", "never", null], "description": "SCGW-384: Shapecast 0.3.12 side-view generation for a text job, forwarded to the engine's JobCreate.side_views. auto lets the engine decide, always forces the extra side views, never skips them. null or omitted leaves the engine default. Any other value is a 400 naming the field. Applies to text jobs only (from-image jobs do not take it). concept_auto, the engine's other text-job flag, is deliberately not exposed: the agent sets it itself (true for a single-variant job) so a single-variant job does not stop at awaiting_selection for a reference approval nobody asked for." },
          "prompt_mode": { "type": ["string", "null"], "enum": ["framed", "literal", null], "description": "SCGW-384: Shapecast 0.3.12 prompt handling for a text job, forwarded to the engine's JobCreate.prompt_mode. framed lets the engine rewrite the prompt into its clean product-shot framing; literal builds from the prompt exactly as submitted. null or omitted leaves the engine default. Any other value is a 400 naming the field. Applies to text jobs only." },
          "scale_m": { "type": ["number", "null"], "description": "Metres, longest axis; must be positive. Purely gateway-side metadata: recorded on the job and used to compute each artifact's scale_factor (scale_m / the engine's native bbox) at read time -- never sent to the engine itself, which always generates in its own normalized +-1 space." },
          "export_preset": { "type": ["string", "null"], "enum": ["unity", "unreal", "godot", "blender", "printing", "all", null], "description": "SCGW-77: validated against Shapecast's own ExportRequest.preset vocabulary (api-reference.md 5.8) and stored on the job so it reaches the leased job's request JSON shapecast-agent parses. Delivered: once the job finishes, shapecast-agent calls Shapecast's own export endpoint against the completed asset and uploads the files as additional artifacts of kind `export` (one per file) and `export_manifest`, listed by GET /v1/jobs/{id}/artifacts. Honored on POST /v1/jobs, POST /v1/batches and POST /v1/jobs/from-image (SCGW-393); an already-built asset is exported with POST /v1/assets/{id}/export. At least one of export_preset/export_formats must be non-empty if either is set at all." },
          "export_formats": { "type": ["array", "null"], "items": { "type": "string", "enum": ["glb", "fbx", "obj", "usdz", "stl", "ply", "3mf"] }, "description": "SCGW-77: validated against Shapecast's own ExportRequest.formats vocabulary (api-reference.md 5.8) and stored the same way export_preset is. Delivered the same way export_preset is -- see its description." },
          "print_scale_mm": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "SCGW-393: millimetres, forwarded to Shapecast's ExportRequest.print_scale_mm, which scales the print formats (stl, 3mf). The engine's documentation says target height, but measured against the real engine on 2026-10-14 the STL's longest side came out at exactly the requested value (a wide asset came out 60 mm wide and 57.3 mm tall for 60), so it is the longest side. Must be positive, and 400 unless the request exports a print format: export_formats containing stl or 3mf, or export_preset printing or all. Honored on POST /v1/jobs, POST /v1/batches, POST /v1/jobs/from-image and POST /v1/assets/{id}/export (as `print_scale_mm`)." },
          "variants": { "type": ["integer", "null"], "minimum": 1, "maximum": 4, "description": "SCGW-92: how many reference images Shapecast generates before pausing at state \"awaiting_selection\" for you to pick one (matches Shapecast's own JobCreate.variants -- api-reference.md 4.2). null or omitted resolves to 1 -- the job runs straight through with no gate. This is a gateway-level default, not Shapecast's own: Shapecast's own default_variants is 2, which WOULD gate, and this API has no built-in UI to ever answer that gate, so a caller opts into it explicitly by asking for 2-4, never by omission (found live 2026-09-03: an omitted-variants job sat parked at awaiting_selection for over a day with no way to resolve it). See GET /v1/jobs/{id}/variants and POST /v1/jobs/{id}/select-variant." },
          "rig": { "type": "boolean", "description": "SCGW-92: after postprocessing, run Shapecast's own rig stage and pause at state \"awaiting_rig_review\" for you to approve (optionally with bone edits/deletes) before the Idle/Walk/Run clips are built. false (the default) skips rigging entirely -- the job goes straight to complete. See POST /v1/jobs/{id}/approve-rig -- SCGW-99: bone_edits/bone_deletes are keyed on joint name, but the rig preview available at the gate names joints positionally only, so in practice `{}` (accept as-is) is the only reliably authorable response today." },
          "tags": { "type": "array", "items": { "type": "string" } },
          "metadata": { "type": "object" },
          "name": { "type": "string", "maxLength": 200, "description": "Caller-supplied client_ref, echoed back on the resource, at most 200 characters (a longer one is a 400). SCGW-395: also sent to the engine as the asset's name (its JobCreate.name), so the library folder carries it, and returned as the asset's `name` until PATCH /v1/assets/{id} renames it. Omitted: the asset is named from the prompt." }
        },
        "examples": [
          {
            "prompt": "a weathered brass compass with an engraved lid",
            "kind": "prop",
            "style": null,
            "pose": null,
            "target_tris": 20000,
            "seed": null,
            "scale_m": 0.12,
            "variants": null,
            "rig": false,
            "tags": ["catalog-import"],
            "metadata": { "order_id": "ord_9f2a" },
            "name": "brass-compass-01"
          }
        ]
      },
      "Job": {
        "type": "object",
        "description": "GET /v1/jobs/{id}'s full resource. `terminal` is the field to loop on -- see the forward-compatibility note below. `state` is CLOSED (queued, leased, running, canceling, awaiting_selection, awaiting_rig_review, succeeded, succeeded_degraded, failed, canceled, expired) but a client MUST NOT match on it to decide whether to keep polling; a future gateway release may add a new non-terminal state, and `terminal` reflects that with zero client-side changes required. Once terminal, artifacts are guaranteed listable and byte-retrievable -- the agent uploads, then completion flips state and writes the outbox row atomically, so a terminal job never has a race window where GET .../artifacts can return empty for a job that actually produced output.\n\nSCGW-92: `state` is also how a caller discovers a job needs a human decision -- awaiting_selection means GET /v1/jobs/{id}/variants has something to show and POST .../select-variant is waiting; awaiting_rig_review means POST .../approve-rig is waiting (the rig preview is the `rigged_glb` artifact on GET .../artifacts). Both are reused DIRECTLY as Shapecast's own state names (api-reference.md 3.3), not a gateway-invented vocabulary. A job left on either state indefinitely holds no lease and no worker slot -- see the two routes' own descriptions for what unblocks it. There is currently NO timeout that auto-resolves an abandoned gated job; it waits until a caller decides, or forever.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "client_ref": { "type": "string" },
          "state": { "type": "string" },
          "terminal": { "type": "boolean" },
          "outcome": { "type": ["string", "null"], "description": "Present only once terminal." },
          "prompt_final": { "type": ["string", "null"], "description": "Always null on this resource today -- found live (SCGW-81, BANNER-515): this field is not dead, it was simply never wired here. Shapecast's own engine-resolved final prompt genuinely exists and is populated on the job's provenance artifact instead (see the Artifact schema's own kind description) -- read it from there, not from this field, until/unless it's surfaced here too." },
          "attempt": { "type": "integer" },
          "max_attempts": { "type": "integer" },
          "degraded": { "type": "boolean", "description": "Computed by shapecast-agent's own verdict check, not merely passed through from the engine's status. A job can be degraded even when the engine reports status:complete with no error -- e.g. the agent's bbox-shape heuristic flags a mesh whose bbox is unusually flat on one axis relative to the other two (full XZ span, near-zero Y), which is the signature of a floor/ground-plane quad welded into the delivered mesh geometry by Shapecast's generation engine. That generation defect is engine-side and outside this API's control; this field only reports the agent's best-effort detection of it, not a guarantee the mesh is clean when false, nor a fix when true." },
          "warnings": { "type": "array", "items": { "type": "string" } },
          "checks": { "type": "array", "items": { "$ref": "#/components/schemas/Check" }, "description": "T-07: submit-time prompt checks in structured form. Optional and additive -- absent when no check ran. Each entry's message is also in warnings[]. T-08: GET /v1/jobs/{id} returns the checks stored on the job when it was created; completion never changes them, unlike warnings[], which the worker's report replaces." },
          "flags": { "type": "array", "items": { "$ref": "#/components/schemas/Flag" }, "description": "T-08: advisory flags raised on this job after it was created (shape mismatch, rig with no clips, policy review, held for review). A flag never changes the job's state. Always present on GET /v1/jobs/{id}, [] when there are none; optional elsewhere." },
          "stage": { "type": ["string", "null"] },
          "progress": { "type": ["number", "null"] },
          "queue_position": { "type": ["integer", "null"] },
          "blocked": { "type": "boolean", "description": "true when the worker is unhealthy or offline. Absent semantics: always present once terminal (always false)." },
          "blocked_reason": { "type": ["string", "null"] },
          "last_progress_at": { "type": ["string", "null"], "format": "date-time" },
          "stage_started_at": { "type": ["string", "null"], "format": "date-time" },
          "estimated_completion_at": { "type": "null", "description": "Deferred: no estimation model exists yet. Always null." },
          "usage": {
            "type": "object",
            "description": "gpu_seconds is wall-clock job duration (finished_at minus started_at), a lower bound on true GPU-seconds -- not true GPU-seconds itself. Shapecast's local API has no GPU/compute-time metric anywhere (its JobRead/JobDetail responses only ever carry started_at/finished_at); the gateway computes this proxy itself once a job reaches a terminal state, so it is null while running.",
            "properties": {
              "gpu_seconds": { "type": ["number", "null"], "description": "Wall-clock proxy, not true GPU-seconds. See usage.description." },
              "gpu_seconds_source": { "type": "string", "enum": ["wall_clock_proxy"], "description": "Always \"wall_clock_proxy\" today -- Shapecast reports no real GPU-time metric to source a truer value from." }
            }
          },
          "estimated_gpu_seconds": { "type": ["number", "null"], "description": "Deferred: no pre-run cost estimation model exists yet. Always null." },
          "created_at": { "type": "string", "format": "date-time" },
          "started_at": { "type": ["string", "null"], "format": "date-time" },
          "finished_at": { "type": ["string", "null"], "format": "date-time" },
          "cancel_requested_at": { "type": ["string", "null"], "format": "date-time" },
          "scale_m": { "type": ["number", "null"] },
          "scale_factor": { "type": "null", "description": "Deferred: needs the pre-scale native bbox, not stored separately. Always null." },
          "error": { "type": ["object", "null"], "description": "Present on a failed job: reason, shapecast_error and shapecast_error_code, plus problem_code and failed_rules when the agent reported them. problem_code is an open string a client may branch on; today the only value is rig_quality (SCGW-419): the rig failed the rig audit, failed_rules lists each failed rule as {rule, detail}, and the job is terminal failed with no clips, no retry and no substitute rig. problem_code here is a job error code, not an RFC 9457 problem type: it is never the `type` of an HTTP response and has no /problems/{slug} page." },
          "rig_audit": { "type": ["object", "null"], "description": "SCGW-419: the rig audit the agent reported when it parked the job at awaiting_rig_review, so the reviewer sees the same facts as the audit: passed, joint_count and one entry per rule {name, passed, detail}. null until the job has reached that gate with a report.", "properties": { "passed": { "type": "boolean" }, "joint_count": { "type": "integer" }, "rules": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "passed": { "type": "boolean" }, "detail": { "type": "string" } } } } } },
          "bbox_native": { "type": ["object", "null"], "description": "min/max union of the delivered final.glb's POSITION accessor bounds, as measured by shapecast-agent (never re-derived server-side). A near-zero extent on one axis against a full span on the other two indicates a ground-plane quad baked into the mesh by the generation engine -- see the `degraded`/`warnings` fields, which the agent sets when this shape is detected." },
          "operation_kind": { "type": "string", "enum": ["generate", "fix_face", "repaint", "repaint_view", "rig", "retarget", "generate_clip", "delete_clip", "retopo", "sharpen", "export_asset", "fix_hand", "touchup_undo", "rename"], "description": "SCGW-91: \"generate\" for every ordinary prompt-driven job (POST /v1/jobs, POST /v1/jobs/from-image) -- the default every job unambiguously gets. fix_face/repaint/repaint_view identify a job created against an EXISTING asset (POST /v1/assets/{id}/fix-face, POST /v1/assets/{id}/repaint, GET /v1/assets/{id}/repaint-view) -- see target_asset_id. rig, retarget, generate_clip and delete_clip (SCGW-94) retopo and sharpen (SCGW-388, SCGW-389) and export_asset (SCGW-394, POST /v1/assets/{id}/export), fix_hand (SCGW-390, POST /v1/assets/{id}/fix-hand) touchup_undo (SCGW-392, POST /v1/assets/{id}/touchup-undo) and rename (SCGW-395, PATCH /v1/assets/{id}) are the other operations on an existing asset." },
          "target_asset_id": { "type": ["string", "null"], "description": "SCGW-91: the asset id an operation_kind other than \"generate\" job acts on. Always null for a generate job; always a real asset id (the same one the caller supplied in the request path) for the other three." },
          "asset_id": { "type": ["string", "null"], "description": "The asset this job produced, null until it has one. Use it as the id on /v1/assets/{asset_id}/... . Operation jobs carry the id of the asset they act on from creation." },
          "urls": {
            "type": "object",
            "properties": {
              "self": { "type": "string" },
              "events": { "type": "string" },
              "artifacts": { "type": "string" },
              "cancel": { "type": "string" }
            }
          }
        },
        "required": ["id", "state", "terminal"],
        "examples": [
          {
            "id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
            "client_ref": "brass-compass-01",
            "state": "succeeded",
            "terminal": true,
            "outcome": "succeeded",
            "attempt": 1,
            "max_attempts": 3,
            "degraded": false,
            "warnings": [],
            "stage": null,
            "progress": null,
            "queue_position": null,
            "blocked": false,
            "blocked_reason": null,
            "last_progress_at": "2026-08-27T06:18:20.5Z",
            "stage_started_at": null,
            "estimated_completion_at": null,
            "usage": { "gpu_seconds": 483.2 },
            "estimated_gpu_seconds": null,
            "created_at": "2026-08-27T06:10:21.699Z",
            "started_at": "2026-08-27T06:10:22.1Z",
            "finished_at": "2026-08-27T06:18:27.21Z",
            "cancel_requested_at": null,
            "scale_m": 0.12,
            "scale_factor": null,
            "error": null,
            "bbox_native": {
              "min": [-0.9997259974479675, -0.07583899796009064, -0.9999741315841675],
              "max": [0.9938958287239075, 0.07364699989557266, 0.9947947263717651]
            },
            "operation_kind": "generate",
            "target_asset_id": null,
            "urls": {
              "self": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a",
              "events": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/events",
              "artifacts": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/artifacts",
              "cancel": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a/cancel"
            }
          }
        ]
      },
      "Artifact": {
        "type": "object",
        "description": "GET /v1/jobs/{id}/artifacts' per-item shape. Deliberately has no bbox_native field (F35): the agent-side producer never sets a per-artifact bbox, only the job-level one, so the column is always null on every artifact in production -- the real value is the parent Job resource's own `bbox_native`.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "kind": { "type": "string", "description": "Non-exhaustive vocabulary on GET /v1/jobs/{id}/artifacts: manifest, reference_png, reference_view_png, provenance, final_glb, animated_glb, retopo_glb (declared by the Shapecast engine's file vocabulary but NOT produced by the engine retopo operation, which rewrites the asset's final_glb and thumbnail in place and uploads those two as the retopo job's artifacts; do not wait for this kind), export and export_manifest (the files an export_preset/export_formats request produces: one `export` artifact per file, one `export_manifest`), rigged_glb (SCGW-92's awaiting_rig_review preview, visible here as soon as the job reaches that state; see POST /v1/jobs/{id}/approve-rig), and other asset files the Shapecast engine may add. `ref_variant` (the awaiting_selection gate's own review images) is deliberately NOT part of this vocabulary here -- found live (SCGW-26's own end-to-end proof run) that it used to leak into this general listing permanently once a job passed through that gate, contradicting this endpoint's own documented final-delivered-set contract; fixed to exclude it. `ref_variant` has its own dedicated, correctly-scoped home: GET /v1/jobs/{id}/variants (see that route's own description), which returns exactly and only the current gate's review images, never mixed into this general listing. Neither this gateway nor shapecast-agent parses or rewrites artifact bytes before upload (the agent downloads every file Shapecast's manifest lists and re-uploads it byte-for-byte), so the following content-level limitations are inherited unmodified from the closed-source Shapecast engine and cannot be fixed at this layer: (1) `final_glb` materials are always a bare pbrMetallicRoughness -- no `KHR_materials_transmission`, `alphaMode`, or any related extension is ever emitted (and neither `extensionsUsed` nor `extensionsRequired` is set), so glass, gems, water, and other transmissive materials always export opaque; no parameter exists anywhere in this API, or in Shapecast's own local API, to request otherwise. (2) `final_glb`'s textured LOD meshes are named with Shapecast's literal internal Blender export convention (`textured.obj.001`/`.002`/`.003`) regardless of the asset, not anything asset-specific -- only the sibling `asset_LODn` node names are meaningful. (3) `provenance`'s JSON body's schema is not guaranteed stable across jobs -- optional-looking keys (e.g. a seed field, `target_tris`, `prompt_final`) may or may not appear depending on job history; treat it as informational only, not as an accepted input echoed back. `prompt_final` in particular is NOT dead or unused (found live, SCGW-81/BANNER-515: it's null on every job resource -- Job.prompt_final always is, see that field's own note -- but present in `provenance`'s own JSON body; a caller reading it off the job resource and concluding it's unpopulated is reading the wrong object). SCGW-92: `params.variants` used to always read `1` here regardless of what (if anything) the caller requested, back when `variants` was rejected as unrecognised on job submission -- `variants` is now a real, accepted `POST /v1/jobs` field (see JobCreate.variants) and this value reflects it. Found live (SCGW-86, measured against 71 real production jobs on 2026-09-01, exact and consistent since 2026-08-27): the delivered artifact SET itself varies by the job's own request kind, not just by gate/rig state -- a rig-less cast delivers 7 artifacts for JobCreate.kind \"prop\", and 8 for \"character\"/\"creature\". The extra file is `reference_view_png` (filename `reference_right.png`), a second reference view that only \"character\"/\"creature\" jobs receive; this had gone entirely undocumented, including from this very vocabulary list." },
          "format": { "type": "string" },
          "lod_level": { "type": ["integer", "null"] },
          "filename": { "type": "string" },
          "bytes": { "type": ["integer", "null"] },
          "content_type": { "type": ["string", "null"] },
          "tri_count": { "type": ["integer", "null"], "description": "The WHOLE FILE's triangle count, not just the mesh a caller typically renders. final_glb ships three root nodes (asset_LOD0/1/2); tri_count is their sum, not LOD0 alone. Found live (SCGW-81, BANNER-515): undocumented, which reads as target_tris being unhonoured when it is honoured exactly (LOD0 alone matches the requested target_tris precisely; the LOD1/LOD2 decimated copies are what account for the rest). A caller budgeting a render against this number should expect it to overstate the LOD0-only mesh cost by roughly 50%." },
          "tri_count_source": { "type": ["string", "null"], "description": "\"glb\" for the final_glb artifact specifically -- shapecast-agent independently parses the downloaded GLB's own indices accessor (internal/glb.Parse) rather than trusting Shapecast's manifest, and reports that verified count. Every other artifact (lod_fbx, thumbnail, provenance, ...) still carries \"manifest\" (agent-reported, unverified) since GLB-side counting only applies to a GLB file." },
          "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "Set at job-completion time to 90 days out -- the artifact's retention window. Past this time, GET /v1/artifacts/{id}/content answers 410 instead of minting a download URL (the row itself remains listable here)." },
          "scale_factor": { "type": ["number", "null"], "description": "Computed from the job's current scale_m at read time, not stored." },
          "url": { "type": "string" },
          "clips": { "type": "array", "items": { "type": "string" }, "description": "SCGW-97: present ONLY on the animated_glb row of a rig:true job that reported real clips at completion time -- omitted entirely (not null, not []) on every other artifact and every rig:false job, so its mere presence is itself the signal that real animation clips came out, without a caller having to download and parse the GLB to find out." }
        },
        "required": ["id", "kind", "format", "filename", "url"],
        "examples": [
          {
            "id": "8d2f9a3e-1b4c-4e2a-9f3a-2c1d4e5f6a7b",
            "kind": "final_glb",
            "format": "glb",
            "lod_level": null,
            "filename": "final.glb",
            "bytes": 7271620,
            "content_type": "model/gltf-binary",
            "tri_count": 39482,
            "tri_count_source": "manifest",
            "expires_at": "2026-09-26T06:18:27.21Z",
            "scale_factor": 0.06,
            "url": "/v1/artifacts/8d2f9a3e-1b4c-4e2a-9f3a-2c1d4e5f6a7b/content"
          }
        ]
      },
      "Asset": {
        "type": "object",
        "description": "GET /v1/assets' per-item shape, and the base of GET /v1/assets/{id}'s full detail (AssetDetail). SCGW-90: an asset IS the job that produced it -- there is no separate assets table on the gateway side; `id` is Shapecast's own asset uuid (job.shapecast_asset_id), `job_id` links back to the full job resource (its own events/artifacts/cancel routes). `tags` is the gateway's OWN bookkeeping copy of an asset's tags (full-replace via PUT .../tags) -- NOT a live mirror of Shapecast's own provenance.json tag store. The gateway has no synchronous path to Shapecast's local API (its own agent plane is exclusively agent-initiated, with no reverse channel) to keep one; this is a deliberate, documented architecture decision, not an oversight. `kind` defaults to \"prop\" when the producing job's submission omitted it, matching JobCreate's own documented default.",
        "properties": {
          "id": { "type": "string", "format": "uuid", "description": "Shapecast's own asset id." },
          "job_id": { "type": "string", "format": "uuid", "description": "The gateway job that produced this asset -- GET /v1/jobs/{job_id} for its full lifecycle detail." },
          "name": { "type": "string", "description": "The name set by PATCH /v1/assets/{id}, else the name the producing job was submitted with, else its prompt -- mirrors Shapecast's own AssetRead.name (api-reference.md 5.1: \"usually prompt_raw\"). GET /v1/assets?q= matches on this." },
          "kind": { "type": "string", "description": "prop | character | creature. Defaults to \"prop\" when the producing job omitted kind at submission." },
          "folder": { "type": "string", "description": "Shapecast's own library folder slug (job.shapecast_folder)." },
          "tags": { "type": "array", "items": { "type": "string" }, "description": "The gateway's own bookkeeping tags -- see this schema's top-level description." },
          "created_at": { "type": "string", "format": "date-time", "description": "The producing job's finished_at -- an asset does not exist before its job completes." },
          "thumbnail_artifact_id": { "type": "string", "description": "The producing job's own most recently uploaded \"thumbnail\" artifact id (\"\" if it has none) -- fetch its real image via GET /v1/artifacts/{id}/content." },
          "final_glb_artifact_id": { "type": "string", "description": "The producing job's own most recently uploaded \"final_glb\" artifact id (\"\" if it has none) -- fetch the real model via GET /v1/artifacts/{id}/content." },
          "tri_count": { "type": ["integer", "null"], "description": "That same final_glb artifact's own real tri_count, null if there is no final_glb artifact." },
          "rig": { "type": "boolean", "description": "The producing job's own submitted `rig` flag (false if the submission never set it)." },
          "pose": { "type": "string", "description": "The producing job's own submitted `pose` (\"\" if the submission never set one -- most prop-kind assets never do)." },
          "degraded": { "type": "boolean", "description": "The producing job's own real `degraded` column." },
          "rename_job_id": { "type": "string", "format": "uuid", "description": "Only on the PATCH /v1/assets/{id} response: the queued operation job that renames the asset in the engine -- GET /v1/jobs/{rename_job_id}." },
          "flags": { "type": "array", "items": { "$ref": "#/components/schemas/Flag" }, "description": "T-08: the producing job's advisory flags -- the same list GET /v1/jobs/{job_id} carries. Always present, [] when there are none." },
          "urls": {
            "type": "object",
            "properties": {
              "self": { "type": "string" },
              "tags": { "type": "string" },
              "job": { "type": "string" }
            }
          }
        },
        "required": ["id", "job_id", "name", "kind", "tags", "created_at", "thumbnail_artifact_id", "final_glb_artifact_id", "rig", "pose", "degraded"],
        "examples": [
          {
            "id": "a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
            "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
            "name": "a weathered brass compass with an engraved lid",
            "kind": "prop",
            "folder": "a-weathered-brass-compass-b9d5cdf7",
            "tags": ["catalog-import", "brass"],
            "created_at": "2026-08-27T06:18:27.21Z",
            "thumbnail_artifact_id": "b1a2c3d4-0003-4a5b-9c6d-7e8f9a0b1c2d",
            "final_glb_artifact_id": "b1a2c3d4-0004-4a5b-9c6d-7e8f9a0b1c2d",
            "tri_count": 20000,
            "rig": false,
            "pose": "",
            "degraded": false,
            "urls": {
              "self": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
              "tags": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e/tags",
              "job": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
            }
          }
        ]
      },
      "AssetDetail": {
        "type": "object",
        "description": "GET /v1/assets/{id}'s response: everything on Asset, plus files and clips. `files` reuses the EXACT shape GET /v1/jobs/{id}/artifacts already returns (see the Artifact schema) -- an asset's files ARE its producing job's artifacts, the same underlying shapecast.artifact rows. `clips` (SCGW-97): the real animation clip names, when the producing job was a rig:true job that completed -- Shapecast reads them live off the animated_glb file on its own host (api-reference.md 5.4: \"read live out of the animated_glb -- the authoritative clip list\"), and shapecast-agent forwards that value once, at completion time, since the gateway itself still has no synchronous path to ask Shapecast directly. `[]` for a rig:false job.",
        "allOf": [
          { "$ref": "#/components/schemas/Asset" },
          {
            "type": "object",
            "properties": {
              "clips": { "type": "array", "items": { "type": "string" }, "description": "See this schema's own top-level description." },
              "files": { "type": "array", "items": { "$ref": "#/components/schemas/Artifact" } }
            },
            "required": ["clips", "files"]
          }
        ],
        "examples": [
          {
            "id": "a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
            "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a",
            "name": "a weathered brass compass with an engraved lid",
            "kind": "prop",
            "folder": "a-weathered-brass-compass-b9d5cdf7",
            "tags": ["catalog-import", "brass"],
            "created_at": "2026-08-27T06:18:27.21Z",
            "clips": [],
            "files": [
              {
                "id": "8d2f9a3e-1b4c-4e2a-9f3a-2c1d4e5f6a7b",
                "kind": "final_glb",
                "format": "glb",
                "lod_level": null,
                "filename": "final.glb",
                "bytes": 7271620,
                "content_type": "model/gltf-binary",
                "tri_count": 39482,
                "tri_count_source": "manifest",
                "expires_at": "2026-09-26T06:18:27.21Z",
                "scale_factor": 0.06,
                "url": "/v1/artifacts/8d2f9a3e-1b4c-4e2a-9f3a-2c1d4e5f6a7b/content"
              }
            ],
            "urls": {
              "self": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
              "tags": "/v1/assets/a3f9c21b-9d4f-4a1b-8c2e-5f6a7b8c9d0e/tags",
              "job": "/v1/jobs/1164d478-da11-4d9a-aabc-b411e3cc658a"
            }
          }
        ]
      },
      "AssetListResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/Asset" } },
          "has_more": { "type": "boolean" },
          "next_cursor": { "type": ["string", "null"] }
        },
        "required": ["data", "has_more"]
      },
      "AssetTagsResponse": {
        "type": "object",
        "description": "GET /v1/assets/tags' response. Matches Shapecast's own {\"tags\":[{\"name\":...,\"count\":...}]} shape (api-reference.md 5.2), sorted by lower-cased name -- but aggregated from the gateway's own bookkeeping tags (see the Asset schema's own description), not a live read of Shapecast's tag store.",
        "properties": {
          "tags": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": { "name": { "type": "string" }, "count": { "type": "integer" } },
              "required": ["name", "count"]
            }
          }
        },
        "required": ["tags"],
        "examples": [{ "tags": [{ "name": "brass", "count": 4 }, { "name": "catalog-import", "count": 12 }] }]
      },
      "AssetRename": {
        "type": "object",
        "description": "PATCH /v1/assets/{id}'s body -- mirrors Shapecast 0.3.12's AssetRename (extra: forbid: an unrecognised field is rejected with 400). The name is trimmed and must be 1-200 characters; the 200 limit is the engine's JobCreate.name limit, its AssetRename schema states none.",
        "properties": {
          "name": { "type": "string", "minLength": 1, "maxLength": 200 }
        },
        "required": ["name"],
        "examples": [ { "name": "Barrel v2" } ]
      },
      "AssetTagsUpdate": {
        "type": "object",
        "description": "PUT /v1/assets/{id}/tags' request body. Matches Shapecast's own TagsUpdate validation exactly (api-reference.md 5.3): each tag is whitespace-collapsed and lower-cased, duplicates removed, at most 40 characters, at most 24 distinct tags -- a full replacement, never a merge.",
        "properties": {
          "tags": { "type": "array", "items": { "type": "string" }, "description": "Required -- even an intentional \"clear all tags\" must send tags: []." }
        },
        "required": ["tags"],
        "examples": [{ "tags": ["brass", "catalog-import"] }]
      },
      "Batch": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "state": { "type": "string", "enum": ["running", "partially_failed", "completed", "canceled"], "description": "partially_failed means at least one child genuinely failed or expired -- a real, system-attributed problem. canceled (F34) means every non-succeeded terminal child was an intentional cancellation and none failed or expired; it is not an error condition and should not page on its own. This is a backward-incompatible addition: integrations matching on the previous 3-value enum (in particular `state == \"partially_failed\"` alerting) will stop seeing that value for an all-canceled batch and must add a case for `canceled` explicitly." },
          "item_count": { "type": "integer" },
          "terminal_count": { "type": "integer" },
          "queued": { "type": "integer" },
          "leased": { "type": "integer" },
          "running": { "type": "integer" },
          "canceling": { "type": "integer" },
          "succeeded": { "type": "integer" },
          "succeeded_degraded": { "type": "integer" },
          "failed": { "type": "integer" },
          "canceled": { "type": "integer" },
          "expired": { "type": "integer" }
        },
        "required": ["id", "state", "item_count", "terminal_count"]
      },
      "Usage": {
        "type": "object",
        "properties": {
          "gpu_seconds_used": { "type": "number", "description": "Sum of gpu_seconds across this key's jobs finished in the current UTC day. Wall-clock job duration (finished_at minus started_at) summed per job, a lower bound on true GPU-seconds -- not true GPU-seconds itself. Shapecast's local API has no GPU/compute-time metric anywhere to source a truer number from; see gpu_seconds_source." },
          "gpu_seconds_source": { "type": "string", "enum": ["wall_clock_proxy"], "description": "Always \"wall_clock_proxy\" today -- Shapecast reports no real GPU-time metric to source a truer value from." },
          "gpu_seconds_limit": { "type": ["integer", "null"], "description": "Reported, not yet enforced -- no cost-based admission debit exists yet." },
          "window_reset_at": { "type": "string", "format": "date-time" },
          "jobs_submitted": { "type": "integer" },
          "queued_jobs": { "type": "integer" },
          "max_queued_jobs": { "type": "integer", "description": "The effective limit actually enforced -- this key's own value, or the system default." },
          "max_batch_items": { "type": "integer", "description": "SCGW-83: max_queued_jobs' sibling quota for POST /v1/batches -- this key's own value, or the system default (100). Reported here so a caller can size a wave from the API instead of a hardcoded constant." }
        },
        "required": ["gpu_seconds_used", "gpu_seconds_source", "window_reset_at", "jobs_submitted", "queued_jobs", "max_queued_jobs", "max_batch_items"]
      },
      "Queue": {
        "type": "object",
        "description": "GET /v1/queue's response. See the operation for what each scope can see.",
        "properties": {
          "scope": { "type": "string", "enum": ["all", "own"], "description": "`all` for an owner key, `own` for any other key." },
          "running": { "oneOf": [{ "$ref": "#/components/schemas/QueueRunning" }, { "type": "null" }] },
          "queued": { "type": "array", "items": { "$ref": "#/components/schemas/QueuedJob" }, "description": "In claim order. A non-owner key lists only its own jobs, each with its position in the whole line." }
        },
        "required": ["scope", "running", "queued"],
        "examples": [{ "scope": "own", "running": null, "queued": [{ "position": 2, "id": "8f1c2f3a-0f6b-4a64-9b53-2f0b2f6d1c11", "state": "queued", "kind": "prop", "lane": "api", "priority": 0, "effective_priority": 0, "created_at": "2026-10-11T11:35:00Z", "not_yet_due": false }] }]
      },
      "QueueRunning": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "state": { "type": "string", "description": "leased, running or canceling." },
          "kind": { "type": "string", "description": "The asset operation (fix_face, repaint, ...) for an operation job, otherwise the request's kind." },
          "started_at": { "type": ["string", "null"], "format": "date-time" }
        },
        "required": ["id", "state", "kind", "started_at"]
      },
      "QueuedJob": {
        "type": "object",
        "properties": {
          "position": { "type": "integer", "minimum": 1, "description": "1-based place in claim order across the whole queue." },
          "id": { "type": "string", "format": "uuid" },
          "state": { "type": "string", "enum": ["queued"] },
          "kind": { "type": "string" },
          "lane": { "type": "string", "enum": ["web", "api"], "description": "`web` for jobs from the web app and the front page, `api` for jobs submitted with a key. An api job older than the promotion window is lifted to the web level (see effective_priority)." },
          "priority": { "type": "integer", "description": "The stored priority." },
          "effective_priority": { "type": "integer", "description": "What the worker ranks on (priority, or the web level for an aged api job)." },
          "created_at": { "type": "string", "format": "date-time" },
          "not_yet_due": { "type": "boolean", "description": "True for a job waiting out a retry backoff; it is listed after every claimable job." }
        },
        "required": ["position", "id", "state", "kind", "lane", "priority", "effective_priority", "created_at", "not_yet_due"]
      },
      "QueueMoved": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "state": { "type": "string", "enum": ["queued"] },
          "position": { "type": "integer", "minimum": 1, "description": "The job's place in claim order after the move." }
        },
        "required": ["id", "state", "position"],
        "examples": [{ "id": "8f1c2f3a-0f6b-4a64-9b53-2f0b2f6d1c11", "state": "queued", "position": 1 }]
      },
      "Webhook": {
        "type": "object",
        "description": "GET /v1/webhooks and GET /v1/webhooks/{id}'s resource shape. Never includes the plaintext secret -- POST /v1/webhooks returns that exactly once, at registration, and it cannot be read back afterward.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "url": { "type": "string" },
          "events": { "type": "array", "items": { "type": "string" } },
          "disabled_at": { "type": ["string", "null"], "format": "date-time", "description": "Set automatically after 5 consecutive days of failed deliveries; a disabled endpoint is skipped by the dispatcher until re-registered." },
          "consecutive_failures": { "type": "integer" },
          "created_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "url", "events", "consecutive_failures", "created_at"]
      },
      "Health": {
        "type": "object",
        "description": "GET /v1/health's public response shape. An authenticated owner request (Cloudflare Access identity) also receives reconnaissance-sensitive fields (shapecast_version, resolved_tier, fake_stages, free_disk_gb, agent_version) not documented here, since this document covers the public plane only. agent_version (SCGW-96) is the agent binary's own content hash, shown alongside the public source_commit field so an owner can eyeball whether the two builds actually match after a deploy -- they're hashes of two different repos and never automatically comparable, so this is a human-readable pairing, not a computed staleness flag.",
        "properties": {
          "degraded": { "type": "boolean" },
          "worker_online": { "type": "boolean" },
          "worker_reason": { "type": "string", "description": "Present only when something is wrong." },
          "queue_depth": { "type": "integer" },
          "source_commit": { "type": "string", "description": "The deployed build's SOURCE_COMMIT (Coolify-injected container env var), so a caller can assert the pinned gateway build is actually running. Empty or omitted if unset, e.g. local dev -- a missing build id never fails the health check." }
        },
        "required": ["degraded", "worker_online", "queue_depth"]
      },
      "WebhookCreated": {
        "type": "object",
        "description": "POST /v1/webhooks' 201 body: the same shape as Webhook, plus the plaintext secret -- present here exactly once and never returned by any other operation.",
        "allOf": [
          { "$ref": "#/components/schemas/Webhook" },
          {
            "type": "object",
            "properties": { "secret": { "type": "string", "description": "whsec_-prefixed. Store this now -- it cannot be read back afterward." } },
            "required": ["secret"]
          }
        ],
        "examples": [
          {
            "id": "3e7b1a2c-9d4f-4a1b-8c2e-5f6a7b8c9d0e",
            "url": "https://example.com/hooks/shapecast",
            "events": ["job.succeeded", "job.failed"],
            "disabled_at": null,
            "consecutive_failures": 0,
            "created_at": "2026-08-27T06:00:00Z",
            "secret": "whsec_3f9a2b7c1e8d4f5a6b7c8d9e0f1a2b3c"
          }
        ]
      },
      "WebhookTestResult": {
        "type": "object",
        "description": "POST /v1/webhooks/{id}/test's response. A synchronous delivery attempt, not a queued one -- the caller learns the outcome in the response itself.",
        "properties": {
          "delivered": { "type": "boolean" },
          "error": { "type": "string", "description": "Present only when delivered is false." }
        },
        "required": ["delivered"],
        "examples": [{ "delivered": true }]
      },
      "JobListItem": {
        "type": "object",
        "description": "GET /v1/jobs' per-item shape -- a summary, not the full Job resource, but carries the same real attempt/max_attempts/gpu_seconds/warnings/stage/progress_pct fields the full resource does, plus a real artifact_count; fetch GET /v1/jobs/{id} for the fields that genuinely are detail-only (error, bbox_native, timestamps, the original request).",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "client_ref": { "type": "string" },
          "state": { "type": "string" },
          "terminal": { "type": "boolean" },
          "created_at": { "type": "string", "format": "date-time" },
          "tags": { "type": "array", "items": { "type": "string" } },
          "metadata": { "type": ["object", "null"] },
          "attempt": { "type": "integer" },
          "max_attempts": { "type": "integer" },
          "warnings": { "type": "array", "items": { "type": "string" } },
          "gpu_seconds": { "type": ["number", "null"] },
          "stage": { "type": ["string", "null"], "description": "The real, raw Shapecast stage name off the job's own active lease -- null when nothing currently holds a lease on this job (queued, gated, or already terminal)." },
          "progress_pct": { "type": ["integer", "null"], "description": "Null under the same condition as stage -- there is no lease to report progress from." },
          "artifact_count": { "type": "integer", "description": "The real, current count of this job's own shapecast.artifact rows -- 0 before anything has been delivered." },
          "thumbnail_artifact_id": { "type": "string", "description": "The same real thumbnail artifact id the Asset Library surfaces (kind='thumbnail') -- \"\" before this job has produced one." }
        },
        "required": ["id", "state", "terminal", "created_at"]
      },
      "JobListResponse": {
        "type": "object",
        "properties": {
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/JobListItem" } },
          "has_more": { "type": "boolean" },
          "next_cursor": { "type": ["string", "null"] },
          "total_count": { "type": "integer", "description": "The real count of every job the same WHERE clause matches -- this key's own jobs, or every key's under jobs:read:all, respecting ?status= -- not just this page's own limit. Computed via a single count(*) OVER() window function alongside the page query itself." }
        },
        "required": ["data", "has_more", "total_count"]
      },
      "JobEvent": {
        "type": "object",
        "description": "One row of a job's durable event log (GET /v1/jobs/{id}/events).",
        "properties": {
          "id": { "type": "integer", "description": "Monotonic, but scoped to this gateway instance's whole event stream, not per-job -- do not assume contiguity within one job's own event list." },
          "source": { "type": "string" },
          "from_status": { "type": ["string", "null"] },
          "to_status": { "type": ["string", "null"] },
          "stage": { "type": ["string", "null"] },
          "progress_pct": { "type": ["integer", "null"] },
          "message": { "type": ["string", "null"] },
          "received_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "source", "received_at"]
      },
      "JobEventListResponse": {
        "type": "object",
        "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/JobEvent" } } },
        "required": ["data"]
      },
      "JobDelivery": {
        "type": "object",
        "description": "One real webhook delivery attempt (GET /v1/jobs/{id}/deliveries), read directly from the durable shapecast.outbox table. A job's events are delivered only to endpoints registered by the same API key that owns the job, and only for the event types each endpoint subscribed to (SCGW-133). Rows recorded before that fix may still belong to an endpoint a different API key registered, since delivery used to be a global broadcast.",
        "properties": {
          "endpoint_url": { "type": "string", "description": "The webhook endpoint's own registered URL." },
          "event_type": { "type": "string" },
          "attempt": { "type": "integer" },
          "status": { "type": "string", "enum": ["delivered", "dead", "pending"], "description": "delivered: published_at is set. dead: dead_at is set, the endpoint's own retry ceiling was reached. pending: neither yet -- still due, or mid-retry." },
          "last_error": { "type": ["string", "null"] },
          "created_at": { "type": "string", "format": "date-time" }
        },
        "required": ["endpoint_url", "event_type", "attempt", "status", "created_at"]
      },
      "JobDeliveryListResponse": {
        "type": "object",
        "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/JobDelivery" } } },
        "required": ["data"]
      },
      "Style": {
        "type": "object",
        "description": "GET /v1/styles' per-item shape: the authoritative live source for every open vocabulary in the API (kind/pose/style/export -- see x-notes.closed_vocabularies). Never-drop semantics: a style Shapecast has ever reported stays listed even if temporarily unavailable, marked accordingly.",
        "properties": {
          "id": { "type": "string" },
          "label": { "type": "string" },
          "blurb": { "type": ["string", "null"] },
          "available": { "type": "boolean" },
          "first_seen_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "label", "available", "first_seen_at"]
      },
      "StyleListResponse": {
        "type": "object",
        "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Style" } } },
        "required": ["data"]
      },
      "BatchSubmitItemResult": {
        "type": "object",
        "properties": {
          "index": { "type": "integer" },
          "name": { "type": "string" },
          "job_id": { "type": "string", "format": "uuid" },
          "idempotency_key": { "type": "string" },
          "replayed": { "type": "boolean", "description": "true when this item matched an existing row_key and no new job was created." }
        },
        "required": ["index", "job_id", "replayed"]
      },
      "BatchSubmitResponse": {
        "type": "object",
        "description": "POST /v1/batches' 202 body. One entry per submitted item, in submission order.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/BatchSubmitItemResult" } },
          "checks": { "type": "array", "items": { "$ref": "#/components/schemas/Check" }, "description": "T-07: every item's prompt checks, each carrying its item index. Optional and additive -- absent when no check ran." }
        },
        "required": ["id", "items"]
      },
      "Check": {
        "type": "object",
        "description": "T-07: one submit-time prompt check. Advisory: a check never blocks a job (a policy refusal is a 422 prompt-rejected problem instead). The same message also appears in the response's warnings[] string array, prefixed \"item N: \" for a batch item, so a client that reads only warnings[] loses nothing. `code` is a CLOSED set a client may branch on: thin_sparse (thin or twiggy parts that tend not to rebuild), extra_surroundings (a scene or background, not one object), orientation_trait (a pose or camera angle that fights the reference view), vague_prompt (too little detail), photo_or_render (photo or render wording), kind_defaulted (kind was omitted and defaulted to prop), checks_unavailable (the prompt could not be checked this time; the job goes ahead unchecked), held_for_review (the job is held for the owner to review before it runs).",
        "properties": {
          "code": { "type": "string", "enum": ["thin_sparse", "extra_surroundings", "orientation_trait", "vague_prompt", "photo_or_render", "kind_defaulted", "checks_unavailable", "held_for_review"] },
          "message": { "type": "string", "description": "Plain-English advice for a human." },
          "severity": { "type": "string", "enum": ["info", "warn"] },
          "source": { "type": "string", "enum": ["jev", "code"], "description": "jev: TypeSafe's Jev model. code: the gateway's own code." },
          "item": { "type": "integer", "description": "The batch item index this check applies to. Absent on a single-job response." }
        },
        "required": ["code", "message", "severity", "source"],
        "examples": [
          { "code": "thin_sparse", "message": "Thin branches often fail to rebuild; try a fuller, leafy canopy.", "severity": "warn", "source": "jev" }
        ]
      },
      "Flag": {
        "type": "object",
        "description": "T-08: one advisory flag on a job (and so on the asset it produced). A flag asks for a human look; it never changes the job's state and is never a failure. `code` is a CLOSED set a client may branch on: shape_mismatch (the finished model's measured shape does not match the shape its prompt asked for), rig_no_clips (a rig was requested but no animation clips came back), policy_review (a policy check sent the prompt to the owner for review), held_for_review (the job was held for the owner before it ran). A code is raised at most once while open; once acknowledged it may be raised again, and the acknowledged entry stays in the list as history.",
        "properties": {
          "code": { "type": "string", "enum": ["shape_mismatch", "rig_no_clips", "policy_review", "held_for_review"] },
          "source": { "type": "string", "enum": ["jev", "code"], "description": "jev: TypeSafe's Jev model. code: the gateway's own code." },
          "detail": { "type": "string", "description": "Plain-English detail written by the gateway." },
          "raised_at": { "type": "string", "format": "date-time" },
          "acknowledged_at": { "type": ["string", "null"], "format": "date-time", "description": "null while the flag is open." }
        },
        "required": ["code", "source", "detail", "raised_at", "acknowledged_at"],
        "examples": [
          { "code": "shape_mismatch", "source": "code", "detail": "The prompt asked for a tall, narrow shape; the model is close to a cube.", "raised_at": "2026-09-24T10:00:00Z", "acknowledged_at": null }
        ]
      },
      "DryRunResult": {
        "type": "object",
        "description": "The 200 body of a successful ?validate=true / Prefer: dry-run probe on a submit route. No job or batch was created.",
        "properties": {
          "valid": { "type": "boolean" },
          "warnings": { "type": "array", "items": { "type": "string" } },
          "checks": { "type": "array", "items": { "$ref": "#/components/schemas/Check" }, "description": "T-07: prompt checks in structured form. Optional and additive -- absent when no check ran." }
        },
        "required": ["valid", "warnings"]
      },
      "RepaintRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/repaint's body -- mirrors Shapecast's own RepaintRequest exactly (api-reference.md 5.11, extra: forbid: an unrecognised field is rejected with 400, not silently dropped). DESTRUCTIVE once the job actually runs: it overwrites the target asset's GLB and thumbnail in place -- see this operation's own x-destructive note below.",
        "required": ["prompt", "mask_png"],
        "properties": {
          "prompt": { "type": "string", "description": "1-300 characters after trimming whitespace." },
          "mask_png": { "type": "string", "description": "Base64-encoded PNG mask, at most 16,777,216 base64 characters (Shapecast's own MAX_MASK_B64_CHARS) and at most 4096px per side once decoded -- lines up with the canvas GET /v1/assets/{id}/repaint-view rendered. Bilinearly resized to 1024x1024 by Shapecast; malformed/oversized image content itself (not decodable as a PNG, wrong magic bytes, too many pixels) is caught engine-side and surfaces later on the job resource's `error` field, not synchronously from this route -- see this operation's description." },
          "strength": { "type": ["number", "null"], "description": "0.5-1.0 when supplied; null/omitted uses the engine's own default." },
          "view": { "type": ["string", "null"], "enum": ["front", "three_quarter", "back", null], "description": "Defaults to \"three_quarter\" when omitted -- matches whichever view GET /v1/assets/{id}/repaint-view rendered the caller's mask against." }
        },
        "examples": [
          { "prompt": "change the tunic from red to deep forest green", "mask_png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "strength": 0.75, "view": "front" }
        ]
      },
      "FixFaceRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/fix-face's optional body -- mirrors Shapecast 0.3.12's FixFaceRequest (extra: forbid: an unrecognised field is rejected with 400). cx and cy are a click on the asset's saved reference image, as fractions of its width and height (x from the left, y from the top). Both or neither.",
        "properties": {
          "cx": { "type": "number", "minimum": 0, "maximum": 1, "description": "Horizontal position of the face on the reference image, 0 (left) to 1 (right). Required with cy." },
          "cy": { "type": "number", "minimum": 0, "maximum": 1, "description": "Vertical position of the face on the reference image, 0 (top) to 1 (bottom). Required with cx." }
        },
        "examples": [ {}, { "cx": 0.5, "cy": 0.3 } ]
      },
      "FixHandRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/fix-hand's body -- mirrors Shapecast 0.3.12's FixHandRequest (extra: forbid). DESTRUCTIVE once the job runs: it overwrites the asset's GLB and thumbnail in place (one step of undo is kept).",
        "required": ["cx", "cy"],
        "properties": {
          "cx": { "type": "number", "minimum": 0, "maximum": 1, "description": "Horizontal position of the hand on the asset's saved reference image, 0 (left) to 1 (right)." },
          "cy": { "type": "number", "minimum": 0, "maximum": 1, "description": "Vertical position of the hand on the reference image, 0 (top) to 1 (bottom)." },
          "size_frac": { "type": "number", "minimum": 0.05, "maximum": 0.5, "default": 0.22, "description": "Width of the square crop around the click, as a fraction of the reference image. The 0.05-0.5 range is the engine's own check." }
        },
        "examples": [ { "cx": 0.5, "cy": 0.7 }, { "cx": 0.2, "cy": 0.55, "size_frac": 0.3 } ]
      },
      "ReviseRequest": {
        "type": "object",
        "description": "POST /v1/jobs/{id}/revise's body.",
        "required": ["note"],
        "properties": {
          "note": { "type": "string", "minLength": 1, "maxLength": 500, "description": "What should change in the reference images. The engine appends it to the job's prompt and generates the references again. Blank is refused; the engine's cap is 500 characters and this gateway refuses a longer note instead of letting the engine cut it." }
        },
        "examples": [ { "note": "make the armor dark green, no cape" } ]
      },
      "RetopoRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/retopo's body -- mirrors Shapecast 0.3.12's RetopoRequest (extra: forbid: an unrecognised field is rejected with 400).",
        "properties": {
          "faces": { "type": "integer", "minimum": 500, "maximum": 200000, "default": 12000, "description": "The engine's quad-dominant face count, NOT a triangle target: the triangle count of the result is roughly 1.8 times larger and not exact (live-verified: faces 2000 on a 3000-triangle prop gave 3608 triangles). Defaults to the engine's own 12000. The 500-200000 bounds are this gateway's (the engine's target_tris range); the engine documents none for faces." }
        }
      },
      "AssetExportRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/export's body -- mirrors Shapecast 0.3.12's ExportRequest (extra: forbid: an unrecognised field is rejected with 400). At least one of preset and formats must be non-empty.",
        "properties": {
          "preset": { "type": ["string", "null"], "enum": ["unity", "unreal", "godot", "blender", "printing", "all", null] },
          "formats": { "type": ["array", "null"], "items": { "type": "string", "enum": ["glb", "fbx", "obj", "usdz", "stl", "ply", "3mf"] } },
          "print_scale_mm": { "type": ["number", "null"], "exclusiveMinimum": 0, "description": "Millimetres; see JobCreate.print_scale_mm. 400 unless a print format (stl, 3mf, or the printing or all preset) is requested." }
        },
        "examples": [ { "preset": "printing", "print_scale_mm": 80 } ]
      },
      "SharpenRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/sharpen's body -- mirrors Shapecast 0.3.12's SharpenRequest (extra: forbid). DESTRUCTIVE once the job runs: it overwrites the asset's GLB and thumbnail in place.",
        "required": ["quality"],
        "properties": {
          "quality": { "type": "string", "enum": ["4k", "8k"], "description": "Target texture quality. The engine accepts only 4k or 8k here; \"standard\" is refused with 400 (SCGW-404)." }
        }
      },
      "GenerateClipRequest": {
        "type": "object",
        "description": "POST /v1/assets/{id}/generate-clip's body -- mirrors Shapecast's own GenerateClipRequest exactly (api-reference.md 5.14, extra: forbid). DESTRUCTIVE once the job actually runs: it appends the generated clip into the target asset's animated.glb in place.",
        "required": ["prompt"],
        "properties": {
          "prompt": { "type": "string", "description": "1-200 bytes after trimming whitespace, parsed into a motion family (idle, walk, run, jump, attack, death) plus continuous params by Shapecast's own parser -- this gateway does not re-derive or validate the motion vocabulary itself; an unrecognised motion surfaces later as 409 unsupported_motion on the job resource's own `error` field." },
          "name": { "type": ["string", "null"], "description": "Optional. Omitted/null lets Shapecast derive the clip's name from the prompt via its own slug_name convention. Must not collide with an existing clip on this asset (409 clip_exists, surfaced the same way)." }
        },
        "examples": [
          { "prompt": "a menacing stalking walk", "name": "menace_walk" }
        ]
      }
    }
  },
  "paths": {
    "/v1/health": {
      "get": {
        "summary": "Dependency health probe",
        "security": [],
        "responses": {
          "200": {
            "description": "Healthy or degraded; see body for per-dependency detail.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } }
          }
        }
      }
    },
    "/v1/openapi.json": {
      "get": {
        "summary": "This document",
        "security": [],
        "responses": { "200": { "description": "This OpenAPI 3.1 document." } }
      }
    },
    "/v1/jobs": {
      "post": {
        "summary": "Submit a job",
        "description": "?validate=true (or Prefer: dry-run) runs the exact same validation as a real submission -- including the rig/kind/pose/target_tris checks -- but never creates a job, consumes quota, or spends GPU time: a 400/422 on failure is identical to what a real submission would return, and a 200 {\"valid\":true,\"warnings\":[...]} on success.",
        "parameters": [
          { "name": "Idempotency-Key", "in": "header", "schema": { "type": "string" } },
          { "name": "Prefer", "in": "header", "schema": { "type": "string" }, "description": "wait=N (seconds, capped at 50): hold the response open and return 200 if the job reaches terminal within N seconds, else 202. dry-run: validate only, see validate=true below (equivalent to it; either works)." },
          { "name": "validate", "in": "query", "schema": { "type": "boolean" }, "description": "true: validate-only mode -- see the operation description. Never combine with Prefer: wait, since a validate-only request never creates a job to wait on." }
        ],
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobCreate" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "200": { "description": "Terminal within the requested wait window, OR a successful ?validate=true/Prefer:dry-run probe (body shape differs: {\"valid\":true,\"warnings\":[...]}, no job was created).", "content": { "application/json": { "schema": { "anyOf": [{ "$ref": "#/components/schemas/Job" }, { "$ref": "#/components/schemas/DryRunResult" }] } } } },
          "202": { "description": "Accepted, still non-terminal.", "headers": { "Location": { "schema": { "type": "string" } }, "Retry-After": { "schema": { "type": "integer" } }, "Preference-Applied": { "schema": { "type": "string" }, "description": "wait=<seconds actually honored, post-clamp>. Present only when the request sent a valid Prefer: wait=N (RFC 7240) -- absent otherwise, including when Prefer: wait was present but unparseable (that case is a 400, not a degraded 202)." } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "Also returned (F32) when Prefer carries an unparseable wait= value (non-numeric or negative) -- this is a real, deliberate validation error, not the old silent fallback to plain 202 behavior." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/Problem" },
          "429": { "$ref": "#/components/responses/Problem" }
        }
      },
      "get": {
        "summary": "List jobs owned by this key",
        "parameters": [
          { "name": "status", "in": "query", "schema": { "type": "string" }, "description": "One real state (exact match), or a comma-separated list of real states (an OR-of-states match) -- e.g. status=queued,leased,running,canceling. Every value must be one of the job lifecycle's own 11 real states." },
          { "name": "starting_after", "in": "query", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobListResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/jobs/from-image": {
      "post": {
        "summary": "Submit a job from an uploaded reference image",
        "description": "?validate=true (or Prefer: dry-run) validates the request (including that an image part was provided) without uploading it to storage or creating a job -- 200 {\"valid\":true,\"warnings\":[...]} on success, same 400/422 as a real submission on failure.\n\nForm fields (SCGW-382/383): `image` (the file part, required), `name`, `kind`, `rig`, `target_tris`, `pose`, plus the Shapecast 0.3.12 options `texture_quality` (standard, 4k, 8k), `detail` (standard, high) and `geometry_only` (true or false), with the same vocabularies and the same geometry_only-versus-4k/8k conflict as POST /v1/jobs. Export (SCGW-393): `export_preset`, `export_formats` (repeat the part once per format) and `print_scale_mm`, with the same vocabularies and rules as on POST /v1/jobs; the files are delivered as `export` and `export_manifest` artifacts once the job finishes. Any other part name (including a misspelt option) is refused with 422 listing it, never silently dropped. A bad option value is 422 naming the field.\n\nF37: only POST is registered on this exact path; GET/PUT/etc. correctly 404/405 rather than falling through to GET /v1/jobs/{id} with id=\"from-image\" (a previous route-collision bug -- see server.go).",
        "parameters": [
          { "name": "Prefer", "in": "header", "schema": { "type": "string" }, "description": "dry-run: validate only, equivalent to ?validate=true." },
          { "name": "validate", "in": "query", "schema": { "type": "boolean" }, "description": "true: validate-only mode -- see the operation description." }
        ],
        "requestBody": { "content": { "multipart/form-data": {} } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "200": { "description": "A successful ?validate=true/Prefer:dry-run probe -- {\"valid\":true,\"warnings\":[...]}, no job was created and no image was uploaded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DryRunResult" } } } },
          "202": { "description": "Accepted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "$ref": "#/components/responses/Problem", "description": "Also covers a missing/empty/oversized upload; see the 4 distinct messages (F37) -- \"missing required multipart field \\\"image\\\"\", \"the uploaded file was empty\", \"reference images must be under 20 MB\", or a field-level validation message, never a single collapsed generic string." }
        }
      }
    },
    "/v1/jobs/{id}": {
      "get": {
        "summary": "Read a job",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
          { "name": "wait", "in": "query", "schema": { "type": "integer" }, "description": "Long-poll up to N seconds for a state change." }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/jobs/{id}/events": {
      "get": {
        "summary": "Public event log for a job",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
          { "name": "after", "in": "query", "schema": { "type": "string" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer" } }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobEventListResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/jobs/{id}/deliveries": {
      "get": {
        "summary": "Real webhook delivery history for a job",
        "description": "Every shapecast.outbox row ever recorded for this job (aggregate_id match), oldest first -- not further filtered by endpoint owner. New deliveries only ever go to the job owner's own subscribed endpoints (SCGW-133); older rows from before that fix may include other keys' endpoints (see JobDelivery's own description).",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobDeliveryListResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/jobs/{id}/cancel": {
      "post": {
        "summary": "Request cancellation",
        "description": "No progress-level webhooks exist in v1 -- cancellation outcome is only ever observed via the job resource or job.canceled/job.failed/job.succeeded* webhooks, never a dedicated cancel-progress event.\n\nF37: cancelling a job already in queued/leased/running/canceling is idempotent (a repeat call while still canceling returns 202 again with the unchanged resource). A job paused at awaiting_selection or awaiting_rig_review (SCGW-92) cancels straight to canceled too, the same as queued -- it holds no lease, so no agent needs to confirm (fixed 2026-09-03: this used to 409 job-already-terminal, wrongly, since these two states didn't exist yet when the cancel filter was written; a caller had no way to abandon a stuck gated job at all). Once the job has actually reached a terminal state (succeeded, succeeded_degraded, failed, canceled, expired), a further cancel call is a real, deliberate 409 job-already-terminal, not a bug -- there is nothing left to cancel. A retry-safe client should treat 409 job-already-terminal on this operation as an expected, safe-to-ignore outcome (the job is done, one way or another) rather than an error to surface to a user.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "202": { "description": "Accepted -- cancellation requested (or, for an already-queued job, applied immediately). F37: this operation always responds 202, matching every other job-mutation route in this API (POST /v1/jobs, POST /v1/jobs/from-image) -- a prior version of this document incorrectly declared 200 here while the implementation always returned 202; the document was the one that was wrong and has been corrected.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "409": { "$ref": "#/components/responses/Problem", "description": "job-already-terminal -- the job already reached succeeded/succeeded_degraded/failed/canceled/expired before this call. See this operation's description: a retry-safe client should treat this as expected, not as a failure." }
        }
      }
    },
    "/v1/jobs/{id}/variants": {
      "get": {
        "summary": "Gate 1 review material: the generated reference images",
        "description": "SCGW-92. Only meaningful while state==\"awaiting_selection\" -- returns every ref_variant artifact Shapecast generated for this job (uploaded the moment the job first reported that state; see the Job.state description). Pick one and POST its `id` to /v1/jobs/{id}/select-variant. Calling this before the job reaches awaiting_selection, or after a caller has already resolved the gate (the job has since moved on to generating_shape or further), returns 409 -- the same \"job is <state>, not awaiting selection\" shape Shapecast's own engine uses (api-reference.md 4.7), not a 404: the job exists, it just isn't at this gate right now.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": {
            "description": "OK -- same per-item shape as GET /v1/jobs/{id}/artifacts, filtered to kind==\"ref_variant\".",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Artifact" } } } },
                "example": {
                  "data": [
                    { "id": "b1a2c3d4-0001-4a5b-9c6d-7e8f9a0b1c2d", "kind": "ref_variant", "format": "png", "filename": "ref_0.png", "bytes": 184320, "sha256": "9f2a...", "content_type": "image/png", "url": "/v1/artifacts/b1a2c3d4-0001-4a5b-9c6d-7e8f9a0b1c2d/content" },
                    { "id": "b1a2c3d4-0002-4a5b-9c6d-7e8f9a0b1c2d", "kind": "ref_variant", "format": "png", "filename": "ref_1.png", "bytes": 179904, "sha256": "7c3b...", "content_type": "image/png", "url": "/v1/artifacts/b1a2c3d4-0002-4a5b-9c6d-7e8f9a0b1c2d/content" }
                  ]
                }
              }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "409": { "$ref": "#/components/responses/Problem", "description": "job is <state>, not awaiting_selection." }
        }
      }
    },
    "/v1/jobs/{id}/select-variant": {
      "post": {
        "summary": "Resolve Gate 1: pick a reference image",
        "description": "SCGW-92. Requires jobs:write. Moves the job from awaiting_selection back to \"queued\" with an internal resume marker -- the NEXT worker to lease it (not necessarily the one that originally reported it blocked) continues the existing Shapecast cast from the chosen reference image, it does not start over. Poll GET /v1/jobs/{id} (or use this call's own 200 response) to see the resumed state progress from queued through generating_shape onward. There is currently NO timeout on how long a job may sit at awaiting_selection waiting for this call -- see Job.state's own description.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "type": "object", "required": ["artifact_id"], "properties": { "artifact_id": { "type": "string", "format": "uuid", "description": "One of the `id` values from GET /v1/jobs/{id}/variants -- a gateway artifact id, never a raw Shapecast file id." } } },
              "example": { "artifact_id": "b1a2c3d4-0001-4a5b-9c6d-7e8f9a0b1c2d" }
            }
          }
        },
        "responses": {
          "200": { "description": "OK -- the job resource, state now \"queued\" and resuming.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "artifact_id missing, or not a ref_variant artifact of this job." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "409": { "$ref": "#/components/responses/Problem", "description": "job is not awaiting_selection -- either it hasn't reached that gate yet, or a prior call (from this client or another) already resolved it." }
        }
      }
    },
    "/v1/jobs/{id}/revise": {
      "post": {
        "summary": "Revise the reference images at Gate 1",
        "description": "SCGW-391. Requires jobs:write. The other decision at awaiting_selection, beside `select-variant`: instead of choosing one of the reference images, say what should change. The engine (Shapecast 0.3.12's `POST /v1/jobs/{job_id}/revise`) deletes the current references, adds your `note` to the job's prompt and generates new ones. The job goes back to \"queued\", a worker continues the existing cast, and once the new references are ready the job parks at `awaiting_selection` again, with a fresh list from `GET /v1/jobs/{id}/variants`. The old variant ids no longer exist. A job can be revised any number of times.\n\nOnly a job at `awaiting_selection` can be revised. The note is a plain sentence appended to the prompt; the engine does not take other keys.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ReviseRequest" } } } },
        "responses": {
          "200": { "description": "OK -- the job resource, state now \"queued\" and regenerating its references.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "the body is not valid JSON or carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "409": { "$ref": "#/components/responses/Problem", "description": "job is not awaiting_selection -- either it hasn't reached that gate yet, or a prior call already resolved it." },
          "422": { "$ref": "#/components/responses/Problem", "description": "note is missing, blank, or longer than 500 characters." }
        }
      }
    },
    "/v1/jobs/{id}/approve-rig": {
      "post": {
        "summary": "Resolve Gate 2: approve (optionally edit) the predicted rig",
        "description": "SCGW-92. Requires jobs:write. Only meaningful while state==\"awaiting_rig_review\" (a rig:true job that has finished postprocessing and rigging). Mirrors Shapecast's own ApproveRig exactly (api-reference.md 4.8): send `{}` to accept the predicted skeleton as-is, or bone_edits/bone_deletes to adjust it first. The rig preview itself is the `rigged_glb` artifact, already visible via GET /v1/jobs/{id}/artifacts as soon as the job reaches this state -- there is no separate bone-list endpoint; Shapecast has none, so inspect the GLB's own skeleton in a viewer/tool that reads it. SCGW-99, found live (measured 2026-09-02, BANNER-556): inspecting that skeleton does NOT get you usable bone names today -- Shapecast's `rigged_glb` preview names every joint positionally (`bone_0`...`bone_N`, root `bone_0`); the real, semantic names (`Hips`, `LeftUpperLeg`, ...) only appear in the `animated_glb` delivered AFTER this call, once the animate stage has run. bone_edits/bone_deletes are keyed on joint name, so in practice they cannot be authored from what this endpoint hands you to review -- an engine-side limitation (Shapecast assigns names later in its own pipeline than this gate fires), not something fixable at this layer. A caller with no real edit to make should send `{}`; that path works correctly. Moves the job back to \"queued\" with a resume marker, the same mechanism select-variant uses -- the next worker to lease it applies these edits against the EXISTING Shapecast job and continues into the animate stage, it does not re-rig from scratch. There is currently NO timeout on how long a job may sit at awaiting_rig_review waiting for this call.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "bone_edits": { "type": ["object", "null"], "additionalProperties": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 }, "description": "Map of bone name -> [x, y, z] delta. At most 200 entries (Shapecast's own MAX_BONE_EDITS); each component's magnitude at most 10.0 (MAX_EDIT_DELTA). Validated at this edge before it ever reaches Shapecast. SCGW-99: keyed on joint name, but the rigged_glb preview you'd read names from is positional-only (bone_0, bone_1, ...) -- see this operation's own top-level description. In practice this field cannot be reliably authored from the preview today; omit it (or send `{}` overall) unless you already know the real joint names some other way." },
                  "bone_deletes": { "type": ["array", "null"], "items": { "type": "string" }, "description": "Bone names to remove. At most 200 entries (MAX_BONE_DELETES). Same SCGW-99 caveat as bone_edits above." }
                }
              },
              "example": {}
            }
          }
        },
        "responses": {
          "200": { "description": "OK -- the job resource, state now \"queued\" and resuming into the animate stage.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "409": { "$ref": "#/components/responses/Problem", "description": "job is not awaiting_rig_review." },
          "422": { "$ref": "#/components/responses/Problem", "description": "invalid-request -- too many bone edits/deletions, or an edit's magnitude exceeds 10.0 per axis. Rejected at this gateway's own edge (never reaches Shapecast, whose equivalent is its own 422 bad_edits, api-reference.md 4.8)." }
        }
      }
    },
    "/v1/jobs/{id}/artifacts": {
      "get": {
        "summary": "List a job's artifacts",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Artifact" } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/artifacts/{id}/content": {
      "get": {
        "summary": "Fetch (or redirect to) an artifact's bytes",
        "description": "Mints a fresh Supabase Storage signed URL and 302s to it -- the artifact's own object_key never appears in any response body. That signed URL itself carries no Shapecast auth: possession of the URL is sufficient to download, exactly like any presigned storage URL, for as long as it remains valid. Three things about that redirect that a caller should know:\n\n1. The signed URL is short-lived -- valid for 300 seconds (5 minutes) from the moment this endpoint mints it, not from when the caller first requested it. A URL saved/logged/cached past that window returns an expired-token error from storage, not from this gateway; re-GET this endpoint to mint a new one.\n2. The storage response the URL points at is served with `Access-Control-Allow-Origin: *`, so for that same 5-minute window the URL is fetchable from browser JS on any origin, not just the caller's own.\n3. The `Location` header (and so the redirect itself) discloses the storage host, the bucket name, and the owning job's UUID in the URL path. This is exposed only to a caller who already holds a valid Shapecast API key authorized for that artifact -- i.e. disclosure is scoped to an already-authorized caller, not to the public -- but it is real disclosure, so avoid forwarding this endpoint's raw Location header to an untrusted party.\n\nArtifacts also carry their own, separate retention window (see 410 below) -- distinct from the above per-download URL TTL.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "302": { "description": "Redirect to a Supabase Storage URL signed for 300 seconds (5 minutes) from mint time. See the operation description for what that URL discloses and how long it stays valid." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "410": { "$ref": "#/components/responses/Problem", "description": "gone -- the artifact's retention window has elapsed. Artifacts are retained for 90 days after the job completes (shapecast.artifact.expires_at, set at job-completion time); once that window passes the artifact row is still readable via GET /v1/jobs/{id}/artifacts but this content route answers 410 instead of minting a download URL." }
        }
      }
    },
    "/v1/batches": {
      "post": {
        "summary": "Submit a batch",
        "description": "SCGW-382: strict, like POST /v1/jobs. `defaults` and every item may carry only the fields of the JobCreate schema (including texture_quality, detail and geometry_only, SCGW-383, and side_views and prompt_mode, SCGW-384); any other key is a 400 naming the item (or `defaults`) and the key, and no job is created. Each item is validated exactly as POST /v1/jobs would validate it.\n\nAccepts either the canonical object form {name, defaults, items} or a legacy bare array of items; both produce identical children given the same effective idempotency identity. Per-item idempotency is keyed on (api_key_id, name-or-submit-level-Idempotency-Key, row_key) where row_key hashes {prompt,kind,style,pose,rig,variants,target_tris,seed,scale_m,export_preset,export_formats, and texture_quality/detail/geometry_only/side_views/prompt_mode when set} -- editing one item's prompt and resubmitting replays every unchanged sibling and only re-casts the edited one. A submission with neither a name nor an Idempotency-Key header is rejected with 400, since it would otherwise have no way to scope its children's idempotency identity apart from other callers on the same API key (this matters most for the bare-array form, which has no name field at all).\n\n?validate=true (or Prefer: dry-run) validates every item (after merging defaults) without creating the batch or any child job -- 200 {\"valid\":true,\"warnings\":[...]} once every item passes, or the same 400 the real submission would give naming the first invalid item, whichever comes first. SCGW-85: on that 200, warnings[] is the union of every item's own warnings (e.g. an omitted target_tris/variants, a style registered before but not currently offered), each entry prefixed with its item index the same way the 400 case already names the offending item -- e.g. \"item 3: target_tris was null or omitted; the engine's default of 40000 applies and is not range-checked\".",
        "parameters": [
          { "name": "Prefer", "in": "header", "schema": { "type": "string" }, "description": "dry-run: validate only, equivalent to ?validate=true." },
          { "name": "validate", "in": "query", "schema": { "type": "boolean" }, "description": "true: validate-only mode -- see the operation description." }
        ],
        "requestBody": { "content": { "application/json": {} } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "200": { "description": "A successful ?validate=true/Prefer:dry-run probe -- {\"valid\":true,\"warnings\":[...]}, no batch or child job was created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DryRunResult" } } } },
          "202": { "description": "Accepted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BatchSubmitResponse" } } } },
          "400": { "$ref": "#/components/responses/Problem" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/batches/{id}": {
      "get": {
        "summary": "Read a batch's full-state counts",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Batch" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/styles": {
      "get": {
        "summary": "Currently-available styles, with never-drop semantics",
        "description": "A style Shapecast has ever reported stays listed even if temporarily unavailable, marked accordingly -- callers building content pipelines never see a style silently vanish from the catalogue.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StyleListResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/usage": {
      "get": {
        "summary": "This key's current quota usage",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Usage" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/queue": {
      "get": {
        "summary": "The queue: the running job and the queued jobs in claim order",
        "description": "SCGW-396. An owner key sees every job (`scope: \"all\"`); any other key sees only the jobs it submitted (`scope: \"own\"`), each at its real place in the whole line. `position` is 1-based in claim order, the order the worker will take jobs; it can differ from a job's own `queue_position`, which is a 0-based count of the jobs ahead that ignores the per-key round robin. `running` is null when the slot is free or the running job belongs to another key. Pause and clear are not offered: pause already exists as worker draining, and a job is canceled with POST /v1/jobs/{id}/cancel.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Queue" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope: the key lacks jobs:read." },
          "500": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/queue/{id}/move-to-front": {
      "post": {
        "summary": "Move a queued job to the front of the line (owner keys only)",
        "description": "SCGW-396. Raises the job's priority just above every other queued job (never lowers it, and capped), so it is claimed next. Owner keys only: any other key gets 403 before anything about the job is looked at.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "A queued job's id." }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/QueueMoved" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope: the key is not an owner key, or lacks jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "No job with that id is queued (unknown, running or finished)." },
          "409": { "$ref": "#/components/responses/Problem", "description": "The job left the queue between the move and the read-back." },
          "500": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/assets": {
      "get": {
        "summary": "List/search this key's own assets",
        "description": "SCGW-90. An asset is the job that produced it -- see the Asset schema's own description. Ownership-scoped the same way GET /v1/jobs is: owner-only unless the key carries jobs:read:all. Only jobs that produced a real, still-live Shapecast asset (shapecast_asset_id set, not soft- or hard-deleted) are ever listed here.",
        "parameters": [
          { "name": "tag", "in": "query", "schema": { "type": "string" }, "description": "Exact match (case-insensitive) against one of the asset's own gateway-bookkeeping tags (see PUT .../tags)." },
          { "name": "kind", "in": "query", "schema": { "type": "string" }, "description": "Exact match against prop/character/creature; an asset whose producing job omitted kind at submission is treated as \"prop\" (JobCreate's own default)." },
          { "name": "q", "in": "query", "schema": { "type": "string" }, "description": "Case-insensitive substring match against the asset's name (the producing job's prompt) -- mirrors Shapecast's own `q` param (api-reference.md 5.1)." },
          { "name": "starting_after", "in": "query", "schema": { "type": "string" }, "description": "An asset id from a previous page's next_cursor." },
          { "name": "limit", "in": "query", "schema": { "type": "integer" }, "description": "Default 20, maximum 100 -- same ceiling as GET /v1/jobs." }
        ],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssetListResponse" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "An unrecognised query parameter." },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/assets/tags": {
      "get": {
        "summary": "Distinct tags in use across this key's own assets",
        "description": "SCGW-90. Aggregated from the gateway's own bookkeeping tags (see the Asset schema's own description) -- not a live read of Shapecast's own tag store, which the gateway has no synchronous path to reach.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssetTagsResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/v1/assets/{id}": {
      "patch": {
        "summary": "Rename an asset",
        "description": "SCGW-395. Requires jobs:write. Ownership-scoped: unknown, deleted and not-yours all 404.\n\nTwo things happen together, in one transaction. The new name is stored on the asset at once, so GET /v1/assets/{id}, the asset list and `GET /v1/assets?q=` use it as soon as this returns, and the 200 body is the renamed asset. And a job with `operation_kind: \"rename\"` is queued, which the agent runs against Shapecast 0.3.12's `PATCH /v1/assets/{asset_id}` so the engine's own library entry carries the name too. The 200 body carries that job's id as `rename_job_id`; poll GET /v1/jobs/{id} to see whether the engine side finished. If the engine refuses the name the job fails with the engine's error and the gateway keeps the new name.\n\nThe worker must be online, like every operation on an existing asset: a worker the gateway knows is offline is a 503 and nothing is stored, so the two names cannot drift apart because nobody was there to rename the engine's.\n\nBody: `{\"name\": \"...\"}`, trimmed, 1-200 characters, `extra: forbid`.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Shapecast's own asset id (job.shapecast_asset_id)." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssetRename" } } } },
        "responses": {
          "200": { "description": "OK -- the renamed asset, plus `rename_job_id`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Asset" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "name is missing, blank or over 200 characters, the body is not valid JSON, or it carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown, deleted or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." },
          "503": { "$ref": "#/components/responses/Problem", "description": "worker-offline -- see this operation's description. `Retry-After` is 60." }
        }
      },
      "get": {
        "summary": "Read an asset's full detail",
        "description": "SCGW-90. Ownership-scoped exactly like GET /v1/jobs/{id}: unknown and not-yours both 404, with the same jobs:read:all bypass.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Shapecast's own asset id (job.shapecast_asset_id)." }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssetDetail" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      },
      "delete": {
        "summary": "Delete an asset (soft by default; hard with ?purge=true)",
        "description": "SCGW-90. Requires jobs:write (matching cancel and every other job mutation -- never jobs:read:all, which is a read-only bypass). Ownership-scoped: unknown and not-yours both 404. `purge=true` additionally requires the assets:purge scope -- a deliberately separate, non-default grant, since it is the first genuinely irreversible-data-loss capability this API exposes; a key with only jobs:write gets 403 and the asset is provably untouched by the rejected attempt.\n\nDefault (soft delete): idempotent -- sets a deleted-at marker excluding this asset from GET /v1/assets and GET /v1/assets/{id}, matching Shapecast's own soft-delete semantics (api-reference.md 5.5: \"files stay on disk, the row is hidden from GET /v1/assets\"). The producing job resource itself (GET /v1/jobs/{id}) is completely untouched -- the asset-library view and the job-resource view are separate surfaces over the same row by design.\n\n`?purge=true`: soft-deletes (if not already) AND additionally, once, hard-deletes this job's own artifact rows -- the gateway's real, irreversible downloadable copies. This does NOT reach Shapecast's own host to delete the library folder the way Shapecast's own purge does (api-reference.md 5.5: an unrecoverable rmtree) -- the gateway has no synchronous path to Shapecast's local API to do that; this is a known, documented architecture gap, not a silent no-op dressed up as a real delete.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
          { "name": "purge", "in": "query", "schema": { "type": "boolean" }, "description": "true: hard-delete (see operation description). Requires the assets:purge scope. Any other value, or omitted, is treated as false." }
        ],
        "responses": {
          "204": { "description": "Deleted (soft, or hard with ?purge=true)." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- either the key lacks jobs:write, or ?purge=true was requested without the assets:purge scope." },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/assets/{id}/tags": {
      "put": {
        "summary": "Overwrite an asset's tag set (full replace, not merge)",
        "description": "SCGW-90. Requires jobs:write. Ownership-scoped: unknown and not-yours both 404. Matches Shapecast's own TagsUpdate semantics exactly (api-reference.md 5.3): each tag is whitespace-collapsed and lower-cased, duplicates removed, at most 40 characters each, at most 24 distinct tags -- but writes the gateway's OWN bookkeeping copy of this asset's tags (see the Asset schema's own description for why this is not a live write to Shapecast's provenance.json).",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssetTagsUpdate" } } } },
        "responses": {
          "200": { "description": "OK -- the asset's new tag set.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Asset" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "Malformed body, or `tags` omitted entirely (it is required -- send tags: [] to clear)." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/Problem" },
          "422": { "$ref": "#/components/responses/Problem", "description": "tag_too_long (\"Tags are 40 characters at most.\") or too_many_tags (\"24 tags per asset at most.\") -- the exact messages Shapecast's own API uses for the same limits (api-reference.md 5.3)." }
        }
      }
    },
    "/v1/assets/{id}/fix-face": {
      "post": {
        "summary": "Repair a known face-texture defect on an existing asset",
        "x-destructive": true,
        "description": "SCGW-91. **DESTRUCTIVE**: once the created job actually runs, it re-bakes the face region from the asset's saved reference image and overwrites `final.glb` and `thumbnail.png` in place (api-reference.md 5.9). The previous model is kept for one step back: `POST /v1/assets/{id}/touchup-undo` restores it (SCGW-392). The request body is optional (SCGW-390): with none, or `{}`, the engine finds the face itself; with `cx` and `cy` it aims the bake at that click on the asset's saved reference image. Requires `jobs:write`.\n\nResource model: this does NOT run synchronously. It creates a job resource -- the exact same shape POST /v1/jobs returns, with `operation_kind: \"fix_face\"` and `target_asset_id` set to `id` -- which the caller polls via GET /v1/jobs/{id} exactly like a generation job, then reads the updated GLB/thumbnail back via GET /v1/jobs/{id}/artifacts once terminal.\n\nOwnership: `id` must be an asset a prior job under THIS key produced (or already operated on) -- an asset id no job of this key's has ever reported, and one owned by a different key, both 404 identically (gw-tenancy: never leak that an asset exists to a non-owner).\n\nEvery Shapecast-side outcome (409 export_busy -- the engine's global export/fix-face/repaint lock is held by another long operation, retried automatically by the agent before it ever gives up; 409 no_source/no_reference; 500 facefix_failed; 403 license_required/setup_required) happens inside the agent's own call to Shapecast, well after this endpoint has already returned 202 -- it surfaces on the created job's own `error` field (GET /v1/jobs/{id}), never as a synchronous response from this route.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id (a prior job's shapecast_asset_id)." }],
        "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FixFaceRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "only one of cx and cy was given, either is outside 0-1, the body is not valid JSON, or it carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable (see this operation's description)." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key, the same admission check every job-creating route applies." }
        }
      }
    },
    "/v1/assets/{id}/repaint": {
      "post": {
        "summary": "Repaint (rebake) part of an existing asset's texture from a masked prompt",
        "x-destructive": true,
        "description": "SCGW-91. **DESTRUCTIVE**: once the created job actually runs, it renders the requested view, runs the image-host repaint model, bakes the result back onto the mesh, re-renders the thumbnail, and overwrites the asset's GLB and thumbnail in place (api-reference.md 5.11) -- there is no undo. Requires `jobs:write`.\n\nSame async resource model as POST .../fix-face: this creates a job (`operation_kind: \"repaint\"`, `target_asset_id` set to `id`) rather than running synchronously -- poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal for the updated GLB/thumbnail. `mask_png` should line up with whatever GET /v1/assets/{id}/repaint-view rendered for the same `view`.\n\nOwnership: same unknown-and-not-yours-both-404 rule as fix-face.\n\nField errors (missing/blank prompt, missing mask_png, an oversized mask, strength or view outside the documented range/enum, or an unrecognised top-level field -- this body is `extra: forbid`, matching Shapecast's own RepaintRequest) are caught here and return 400 before any job is created. Everything Shapecast itself can still reject once the job actually runs (422 bad_mask/mask_too_large/bad_prompt/bad_strength/bad_view -- the engine's own deeper image-content checks this gateway does not duplicate; 409 export_busy/image_not_available/export_unavailable; 500 repaint_failed; 403 license_required/setup_required) surfaces on the created job's own `error` field, never as a synchronous response from this route.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RepaintRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "A field failed the checks this gateway can run without asking Shapecast -- see this operation's description." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/repaint-view": {
      "get": {
        "summary": "Render (or re-fetch) the reference view a repaint mask must line up with",
        "description": "SCGW-91. Not destructive -- but unlike a normal GET, this route creates a job resource, exactly like the two POST operations above (`operation_kind: \"repaint_view\"`, `target_asset_id` set to `id`): this gateway has no synchronous path to the remote, poll-driven Shapecast agent (see assetops.go's own package doc comment), so a GET here cannot simply block and hand back PNG bytes the way Shapecast's own local route does. Poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal to fetch the rendered PNG (1024x1024, api-reference.md 5.10) via the normal artifact-content redirect. Requires `jobs:write` for the same reason as repaint-view's POST siblings: it creates a job. Shapecast itself caches this render per (asset, view) on its own side (\"cached-on-hit\"), so a repeat call for the same view after the first is fast on the agent's end even though this gateway still creates a fresh job row for it.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." },
          { "name": "view", "in": "query", "schema": { "type": "string", "enum": ["front", "three_quarter", "back"] }, "description": "Defaults to three_quarter when omitted." }
        ],
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "view is not one of front, three_quarter, back." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/fix-hand": {
      "post": {
        "summary": "Re-cast one hand of an existing asset (fix-hand)",
        "x-destructive": true,
        "description": "SCGW-390. **DESTRUCTIVE**: once the created job actually runs, it re-casts one hand from a crop of the asset's saved reference image (Shapecast 0.3.12's `POST /v1/assets/{asset_id}/fix-hand`) and overwrites `final.glb` and the thumbnail in place. The previous model is kept for one step back: `POST /v1/assets/{id}/touchup-undo` restores it. Requires `jobs:write`.\n\n`cx` and `cy` are where the hand is on the saved reference image, as fractions of its width and height. The engine runs its shape and texture steps on the crop, so the job takes a few minutes and needs the GPU. It fails with the engine's own error when the asset has no saved reference image (`no_reference`, assets made before fix-face existed) or the engine is not set up.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FixHandRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`." },
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "cx or cy is missing or outside 0-1, size_frac is outside 0.05-0.5, the body is not valid JSON, or it carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/touchup-undo": {
      "post": {
        "summary": "Undo the last touch-up on an existing asset",
        "x-destructive": true,
        "description": "SCGW-392. **DESTRUCTIVE**: once the created job actually runs, it restores the asset's model and thumbnail from just before the last `fix-face`, `fix-hand` or `repaint` (Shapecast 0.3.12's `POST /v1/assets/{asset_id}/touchup-undo`). Only one step back is kept per asset. Requires `jobs:write`. No request body.\n\nWhether there is anything to undo is only known to the engine, so this call is accepted regardless and the job fails with the engine's own message (`nothing_to_undo`: \"There is no touch-up to undo for this asset.\") when there is not. The restored `final_glb` and `thumbnail` are delivered as the job's artifacts, like any other touch-up.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`." },
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/retopo": {
      "post": {
        "summary": "Re-topologise an existing asset (retopo)",
        "x-destructive": true,
        "description": "SCGW-388. Re-topologises an existing asset with Shapecast 0.3.12's retopo operation (`POST /v1/assets/{asset_id}/retopo`). Requires `jobs:write`. **DESTRUCTIVE** once the created job runs: the engine rewrites the asset's current `final.glb` and thumbnail IN PLACE, replacing the previous mesh, and writes no separate retopo file (live-verified on 0.3.12). The job's artifacts are the new `final_glb` and `thumbnail`; the engine's `retopo_glb` kind is not produced by this operation. Download the asset's current files first if you want to keep the old mesh. Other asset files (LOD files, animated.glb) are not regenerated and may be stale afterward.\n\nResource model: this does NOT run synchronously. It creates a job resource -- the exact same shape POST /v1/jobs returns, with `operation_kind: \"retopo\"` and `target_asset_id` set to `id` -- on the shared GPU queue, which the caller polls via GET /v1/jobs/{id}, then reads the result back via GET /v1/jobs/{id}/artifacts once terminal.\n\nBody: `{\"faces\": <integer>}`. `faces` is optional and defaults to 12000, the engine's own default; the gateway stores the resolved value on the job. `faces` is the engine's quad-dominant face count, not a triangle target: the triangle count of the result is roughly 1.8 times larger and not exact (live-verified: faces 2000 on a 3000-triangle prop gave 3608 triangles). The engine's schema gives `faces` no bounds, so this gateway accepts 500-200000 (the range the engine documents for `target_tris`) and the engine stays the authority: a value it still refuses surfaces on the job's own `error` field. The body is `extra: forbid`.\n\nOwnership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.\n\nEvery engine-side outcome (409 export_busy/engine_busy while the engine's global lock or a cast holds the GPU, 403 license_required/setup_required, and any engine failure) happens inside the agent's own call to Shapecast, well after this endpoint has already returned 202, and surfaces on the created job's own `error` field.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RetopoRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "faces is not an integer in 500-200000, the body is not valid JSON, or it carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/export": {
      "post": {
        "summary": "Export an existing asset (export)",
        "x-destructive": false,
        "description": "SCGW-394. Exports an already-built asset with Shapecast 0.3.12's export operation (`POST /v1/assets/{asset_id}/export`), so an owner who did not ask for stl or any other format at submit time does not have to recast. Requires `jobs:write`. Not destructive: the engine writes into the asset folder's exports/ subfolder.\n\nResource model: this does NOT run synchronously. It creates a job resource -- the exact same shape POST /v1/jobs returns, with `operation_kind: \"export_asset\"` and `target_asset_id` set to `id` -- on the shared GPU queue, which the caller polls via GET /v1/jobs/{id}, then reads the files from GET /v1/jobs/{id}/artifacts once terminal: one `export` artifact per file and an `export_manifest`.\n\nBody: `{\"preset\": ..., \"formats\": [...], \"print_scale_mm\": ...}`, validated like the submit-time export fields. At least one of `preset` and `formats` is required (an empty body is 400, mirroring the engine's empty_export). The body is `extra: forbid`.\n\nOwnership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.\n\nEvery engine-side outcome (409 export_busy while another export runs, 409 no_source, 403 license_required/setup_required, and any engine failure) surfaces on the created job's own `error` field, never as a synchronous response from this route.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssetExportRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "Neither preset nor formats is given, a value is outside the vocabulary, print_scale_mm is not positive or no print format is requested, the body is not valid JSON, or it carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/sharpen": {
      "post": {
        "summary": "Upscale an existing asset's texture (sharpen)",
        "x-destructive": true,
        "description": "SCGW-389. **DESTRUCTIVE**: once the created job actually runs, it upscales the asset's texture with Shapecast 0.3.12's sharpen operation (`POST /v1/assets/{asset_id}/sharpen`) and overwrites `final.glb` and the thumbnail in place -- there is no undo. Requires `jobs:write`.\n\nResource model: same as retopo and fix-face -- this creates a job (`operation_kind: \"sharpen\"`, `target_asset_id` set to `id`) on the shared GPU queue rather than running synchronously. Poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal for the updated `final_glb` and thumbnail.\n\nBody: `{\"quality\": \"4k\"|\"8k\"}`, required. The engine accepts only 4k or 8k for sharpen, so this gateway rejects anything else, `standard` included, with 400 naming the `quality` field and listing the valid values (SCGW-404). The engine stays the authority for everything else: a failure it reports surfaces on the job's own `error` field. The body is `extra: forbid`.\n\nOwnership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.\n\nEvery engine-side outcome (409 export_busy/engine_busy, 403 license_required/setup_required, and any engine failure) surfaces on the created job's own `error` field, never as a synchronous response from this route.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SharpenRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "quality is missing or not one of standard, 4k, 8k (the message lists them), the body is not valid JSON, or it carries an unrecognised field." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/rig": {
      "post": {
        "summary": "Rig an already-finished asset",
        "x-destructive": true,
        "description": "SCGW-94. **DESTRUCTIVE**: reopens the asset's job row and enqueues a real rig work item through Shapecast's OWN job queue (api-reference.md 5.12) -- there is no undo. No request body. Requires `jobs:write`.\n\nUnlike every other operation on this page, this is genuinely async through Shapecast's own queue rather than one blocking call: the created job (`operation_kind: \"rig\"`, `target_asset_id` set to `id`) is gate-eligible exactly like a fresh `rig:true` generation -- it can park on `awaiting_rig_review` (POST /v1/jobs/{id}/approve-rig resolves it, same as any other rig gate) before eventually reaching a terminal state. Poll GET /v1/jobs/{id}, then GET /v1/jobs/{id}/artifacts once terminal for the updated GLB.\n\nOwnership: same unknown-and-not-yours-both-404 rule as fix-face/repaint.\n\nErrors Shapecast itself can still report once the job actually runs (409 not_riggable -- only characters/creatures can be rigged; 409 no_source/no_job; 409 job_active -- this asset's own job is still running, retry once it finishes) surface on the created job's own `error` field, never as a synchronous response from this route.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/retarget": {
      "post": {
        "summary": "Fit an uploaded animation clip onto an existing rigged asset",
        "x-destructive": true,
        "description": "SCGW-94. **DESTRUCTIVE**: once the created job actually runs, it retargets the uploaded clip onto the asset's skeleton and overwrites `animated.glb` in place (api-reference.md 5.13) -- there is no undo. `multipart/form-data`, a single required `clip` file part (`.fbx` or `.bvh`, non-empty, at most 25 MB -- Shapecast's own MAX_CLIP_UPLOAD_BYTES). Requires `jobs:write`.\n\nSame async resource model as fix-face/repaint: this creates a job (`operation_kind: \"retarget\"`, `target_asset_id` set to `id`) rather than running synchronously. The uploaded clip is stored and relayed to the agent out of band -- it never travels through the job's own request/response bodies.\n\nField errors this gateway can check without asking Shapecast (missing `clip` part, empty file, over 25 MB, or an unsupported extension) return 422 before any job is created -- the same status Shapecast's own equivalent checks (`bad_format`/`empty_clip`/`clip_too_large`) would report for the identical mistake. Everything Shapecast can still reject once the job runs (409 clip_unsupported -- only two-legged characters accept animation files; 409 no_animations/clip_exists/export_busy; 500 clip_failed; 403 license_required/setup_required) surfaces on the created job's own `error` field.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": { "type": "object", "required": ["clip"], "properties": { "clip": { "type": "string", "format": "binary", "description": "The animation clip file -- .fbx or .bvh, at most 25 MB." } } }
            }
          }
        },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "422": { "$ref": "#/components/responses/Problem", "description": "missing/empty/oversized clip, or an unsupported extension -- see this operation's description." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/generate-clip": {
      "post": {
        "summary": "Generate a new animation clip for an existing rigged asset from a text prompt",
        "x-destructive": true,
        "description": "SCGW-94. **DESTRUCTIVE**: once the created job actually runs, it parses the prompt into a motion family and appends the generated clip into `animated.glb` in place (api-reference.md 5.14) -- there is no undo. Requires `jobs:write`.\n\nSame async resource model as retarget/fix-face/repaint: this creates a job (`operation_kind: \"generate_clip\"`, `target_asset_id` set to `id`).\n\nField errors this gateway can check without asking Shapecast (blank prompt, or over 200 bytes after trimming) return 400 before any job is created. The motion-family vocabulary itself (idle/walk/run/jump/attack/death) and skeleton-capability gate are entirely Shapecast's own -- an unsupported motion (409 unsupported_motion) or an incompatible skeleton (409 clip_unsupported) surface on the created job's own `error` field, along with 409 no_animations/clip_exists/export_busy, 500 clip_failed, and 403 license_required/setup_required.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GenerateClipRequest" } } } },
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "400": { "$ref": "#/components/responses/Problem", "description": "A field failed the checks this gateway can run without asking Shapecast -- see this operation's description." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/assets/{id}/clips/{clip_name}": {
      "delete": {
        "summary": "Remove an animation clip from an existing rigged asset",
        "x-destructive": true,
        "description": "SCGW-94. **DESTRUCTIVE**: once the created job actually runs, it rewrites `animated.glb` with the named clip stripped out and removes it from the asset's own clip list (api-reference.md 5.15) -- there is no undo. Requires `jobs:write`.\n\nSame async resource model as the operations above: this creates a job (`operation_kind: \"delete_clip\"`, `target_asset_id` set to `id`). `clip_name` is matched Shapecast-side after `strip().lower()`.\n\nErrors Shapecast itself can still report once the job runs (409 no_animations/clip_not_found/export_busy; 500 glb_invalid/clip_failed) surface on the created job's own `error` field, never as a synchronous response from this route.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The target asset's id." },
          { "name": "clip_name", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The clip's name, matched after strip().lower() on Shapecast's own side." }
        ],
        "responses": {
          "503": {"$ref": "#/components/responses/Problem", "description": "worker-offline -- the gateway already knows the worker is offline (anything but healthy or `draining`), so new work would only wait in the queue (SCGW-297). The body's `worker_reason` is /v1/health's own `worker_reason`; `Retry-After` is 60. Also returned when the worker's status can't be read, with `worker_reason: \"worker status unavailable\"`."},
          "202": { "description": "Accepted -- a job was created for this operation.", "headers": { "Location": { "schema": { "type": "string" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Job" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry jobs:write." },
          "404": { "$ref": "#/components/responses/Problem", "description": "no such asset -- unknown or not owned by this key, indistinguishable." },
          "429": { "$ref": "#/components/responses/Problem", "description": "quota-exhausted -- max_queued_jobs reached for this key." }
        }
      }
    },
    "/v1/webhooks": {
      "post": {
        "summary": "Register a webhook endpoint",
        "description": "Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. An endpoint receives only the event types listed in `events`, and only for jobs and batches owned by the same API key that registered it (SCGW-133). `events` must be non-empty and every entry must be one of the event names under `webhooks` in this document.",
        "responses": {
          "201": { "description": "Created. Body includes the plaintext secret exactly once -- it cannot be read back afterward.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookCreated" } } } },
          "400": { "$ref": "#/components/responses/Problem" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry webhooks:write." }
        }
      },
      "get": {
        "summary": "List webhooks owned by this key",
        "description": "Requires the `webhooks:write` scope (there is no separate webhooks:read scope in this system); a key without it gets 403 insufficient-scope. F9: added alongside GET/DELETE .../{id} -- previously the only way to interact with a registered webhook was POST, with no way to list, read, or remove one afterward.",
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/Webhook" } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry webhooks:write." }
        }
      }
    },
    "/v1/webhooks/{id}": {
      "get": {
        "summary": "Read a webhook endpoint",
        "description": "Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. Ownership-scoped: unknown and not-yours both 404.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Webhook" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry webhooks:write." },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      },
      "delete": {
        "summary": "Delete a webhook endpoint",
        "description": "Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. Ownership-scoped: unknown and not-yours both 404. A real delete, including any recorded delivery history for the endpoint -- not a disable.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "204": { "description": "Deleted." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry webhooks:write." },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/webhooks/{id}/test": {
      "post": {
        "summary": "Send a synthetic test delivery to a registered endpoint",
        "description": "Requires the `webhooks:write` scope; a key without it gets 403 insufficient-scope. Ownership-scoped: unknown and not-yours both 404.",
        "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
        "responses": {
          "200": { "description": "Delivery attempted synchronously; body reports the outcome.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WebhookTestResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry webhooks:write." },
          "404": { "$ref": "#/components/responses/Problem" }
        }
      }
    },
    "/v1/forgehub/packs/{pack}/pieces/{piece}/package": {
      "put": {
        "summary": "Publish a packaged piece to ForgeHub",
        "description": "SCGW-328. Requires the `forgehub:write` scope (never bundled into `jobs:write`); a key without it gets 403 insufficient-scope. The gateway commits the piece's packaged GLB and its piece.json into the key's ForgeHub repo, under `<pack>/<piece>/package/`, beside the generation of the same piece (a name like `cave_bear` becomes the folder `cave-bear`). Only `package/model.glb` and `package/piece.json` are ever written; the client names no repo, path or file name, and the filenames on the multipart parts are ignored.\n\nThe first package commit of a piece lands straight on main. A later change goes on a `pkg/<piece>-<first 6 of plan_key>` branch with a pull request that is merged, so ForgeHub shows a diff of `package/model.glb`. Identical bytes make no commit and no pull request (`no_change: true`). The commit date is `built_at`, so a retried push is the same commit.\n\n`multipart/form-data`: file part `meta` (the piece.json, JSON, at most 256 KB), file part `model` (a binary glTF, at most 8 MB -- ForgeHub's glTF diff skips larger files), and fields `built_at` (required, RFC 3339), `model_job_id`, `packager` and `plan_key` (recorded as commit trailers). The whole request is at most 16 MB. `pack` and `piece` must match `^[a-z][a-z0-9_]{0,63}$`.\n\nPushes and the ForgeHub sync share one lock. If it stays busy for 60 seconds, or ForgeHub is failing in a way a retry can fix, the answer is 503 forgehub-unavailable with Retry-After. A ForgeHub 4xx (revoked token, refused merge) is 502 forgehub-rejected. A gateway with no ForgeHub connection answers 503 forgehub-disabled.",
        "parameters": [
          { "name": "pack", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" } },
          { "name": "piece", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" } }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object", "required": ["meta", "model", "built_at"],
                "properties": {
                  "meta": { "type": "string", "format": "binary", "description": "The piece's piece.json (JSON, at most 256 KB)." },
                  "model": { "type": "string", "format": "binary", "description": "The packaged binary glTF (.glb), at most 8 MB." },
                  "built_at": { "type": "string", "format": "date-time", "description": "When the piece was packaged; becomes the commit date." },
                  "model_job_id": { "type": "string", "description": "Recorded as the Bevel-Job trailer." },
                  "packager": { "type": "string", "description": "Recorded as the Bevel-Packager trailer." },
                  "plan_key": { "type": "string", "description": "Recorded as the Bevel-Package-Key trailer; its first 6 characters name the pkg/ branch." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Pushed, or already there.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgeHubPush" }, "example": { "commit": "9f2c1ab", "no_change": false, "path": "prehistoric/cave-bear" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry forgehub:write." },
          "413": { "$ref": "#/components/responses/Problem", "description": "body-too-large -- the model is over 8 MB or the request over 16 MB." },
          "415": { "$ref": "#/components/responses/Problem", "description": "unsupported-media-type -- not multipart/form-data." },
          "422": { "$ref": "#/components/responses/Problem", "description": "invalid-request -- a bad pack or piece name, a missing part, meta that is not JSON, a model that is not a binary glTF, or a missing or malformed built_at." },
          "502": { "$ref": "#/components/responses/Problem", "description": "forgehub-rejected -- ForgeHub refused the push (a 4xx that retrying will not fix)." },
          "503": { "$ref": "#/components/responses/Problem", "description": "forgehub-disabled (no ForgeHub connection configured), or forgehub-unavailable (lock busy for 60 s, or ForgeHub failing) with a Retry-After header." }
        }
      }
    },
    "/v1/forgehub/packs/{pack}/specs": {
      "put": {
        "summary": "Publish a pack's SPECS.csv to ForgeHub",
        "description": "SCGW-328. Requires the `forgehub:write` scope. The body is the pack's SPECS.csv as `text/csv` (non-empty, at most 1 MB), committed straight to main as `<pack>/SPECS.csv` in the key's ForgeHub repo; identical bytes are `no_change: true`. Optional query `built_at` (RFC 3339) sets the commit date, default now. Error statuses are the same as for the piece route.",
        "parameters": [
          { "name": "pack", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" } },
          { "name": "built_at", "in": "query", "required": false, "schema": { "type": "string", "format": "date-time" } }
        ],
        "requestBody": { "required": true, "content": { "text/csv": { "schema": { "type": "string" } } } },
        "responses": {
          "200": { "description": "Pushed, or already there.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgeHubPush" }, "example": { "commit": "4be0a77", "no_change": false, "path": "prehistoric/SPECS.csv" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Problem", "description": "insufficient-scope -- the key does not carry forgehub:write." },
          "413": { "$ref": "#/components/responses/Problem", "description": "body-too-large -- over 1 MB." },
          "415": { "$ref": "#/components/responses/Problem", "description": "unsupported-media-type -- not text/csv." },
          "422": { "$ref": "#/components/responses/Problem", "description": "invalid-request -- a bad pack name, an empty body or a malformed built_at." },
          "502": { "$ref": "#/components/responses/Problem", "description": "forgehub-rejected." },
          "503": { "$ref": "#/components/responses/Problem", "description": "forgehub-disabled or forgehub-unavailable (with Retry-After)." }
        }
      }
    }
  },
  "webhooks": {
    "job.succeeded": {
      "post": {
        "summary": "A job finished successfully with a clean verdict.",
        "description": "Delivered with headers webhook-id (the event id), webhook-timestamp, and webhook-signature (HMAC over id+timestamp+body, verifiable against the secret(s) returned once at registration) -- see internal/outbox and internal/webhook.",
        "requestBody": {
          "content": {
            "application/json": {
              "example": { "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a", "asset_id": "a3f9c21b-brass-compass", "degraded": false }
            }
          }
        }
      }
    },
    "job.succeeded_degraded": { "post": { "summary": "A job finished but the verdict was degraded.", "requestBody": { "content": { "application/json": { "example": { "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a", "asset_id": "a3f9c21b-brass-compass", "degraded": true } } } } } },
    "job.failed": { "post": { "summary": "A job failed terminally (non-retryable, or retries exhausted). The payload carries job_id and reason, plus problem_code and failed_rules ([{rule, detail}]) when the agent reported them; a rig that fails the rig audit arrives with problem_code rig_quality (SCGW-419).", "requestBody": { "content": { "application/json": { "example": { "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a", "reason": "rig failed the rig audit", "problem_code": "rig_quality", "failed_rules": [{ "rule": "joint_count", "detail": "1 joint, expected at least 8" }] } } } } } },
    "job.canceled": { "post": { "summary": "A queued job was canceled before any GPU work started.", "requestBody": { "content": { "application/json": {} } } } },
    "job.expired": { "post": { "summary": "A job left queued past queued_expires_at, or a lease expired at max_attempts.", "requestBody": { "content": { "application/json": {} } } } },
    "job.awaiting_selection": { "post": { "summary": "SCGW-92. A job parked at Gate 1 -- GET /v1/jobs/{id}/variants now has reference images to review, POST .../select-variant is waiting. NOT terminal -- unlike every other event in this list, the job's own `terminal` field is still false when this fires.", "requestBody": { "content": { "application/json": { "example": { "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a", "gate": "awaiting_selection" } } } } } },
    "job.awaiting_rig_review": { "post": { "summary": "SCGW-92. A job parked at Gate 2 -- the rigged_glb preview is on GET /v1/jobs/{id}/artifacts, POST .../approve-rig is waiting. NOT terminal, same as job.awaiting_selection above. The payload also carries rig_audit ({passed, joint_count, rules}) when the agent reported one (SCGW-419).", "requestBody": { "content": { "application/json": { "example": { "job_id": "1164d478-da11-4d9a-aabc-b411e3cc658a", "gate": "awaiting_rig_review", "rig_audit": { "passed": true, "joint_count": 24, "rules": [{ "name": "joint_count", "passed": true, "detail": "24 joints" }] } } } } } } },
    "batch.completed": { "post": { "summary": "Every child job in a batch reached a terminal state. Fires once, regardless of the batch's final state (completed, partially_failed, or canceled) -- there is no separate event per final state; check GET /v1/batches/{id}'s state field to distinguish them.", "requestBody": { "content": { "application/json": {} } } } }
  },
  "x-notes": {
    "no_progress_webhooks_in_v1": "The only webhook events in v1 are the eight listed under `webhooks` above. Six are terminal (job or batch level); job.awaiting_selection/job.awaiting_rig_review (SCGW-92) are the two exceptions -- a job that fires either is still non-terminal (`terminal`: false), parked on a human decision point, not done. There is still no progress-level webhook (stage changes, percent complete) -- poll GET /v1/jobs/{id} for that.",
    "deprecation_mechanism": "Deprecation (RFC 9745) and Sunset (RFC 8594) response headers are reserved as the retirement mechanism for the first genuine breaking change. Neither is emitted today. No version-transform layer exists or is planned until that first breaking change actually happens.",
    "closed_vocabularies": "job.state, problem `title` (the slug), and webhook event_type are all closed sets a client may branch on. Every other enum-like field (kind, pose, style, export) is validated server-side per validate.JobCreate against Shapecast's own live-reported vocabulary via GET /v1/styles, and is NOT hardcoded in this document."
  }
}
