{
  "openapi": "3.1.0",
  "info": {
    "title": "Aperta Agent API",
    "version": "1.0.0",
    "summary": "Turn a flat black-on-white PNG into a laser-cut brass bracelet or ring, with a human approval step.",
    "description": "The Aperta Agent API lets an autonomous agent open a design job, submit a black-on-white PNG drawn by its own image model, receive machine-readable validation (errors it can fix, warnings about what the engine changed) or a manufacturable cutting file with a preview and a fixed price, and hand the person an approval link. Aperta never runs an AI model on the image and never receives the person's story.\n\nThe API is **capabilities-first**: every number an agent needs — products, sizes, the image contract, the two manufacturing floors (minimum opening, minimum metal), limits, price, the error catalogue — is published live by `GET /capabilities` and read from the same source the validator enforces. Nothing in this document is authoritative over that endpoint; the examples here are a snapshot at contract version 2026-09.\n\nAuthentication: `POST /jobs` returns a job secret once (`credentials.jobSecret`); every job route takes it as `Authorization: Bearer ajs_…` (the scheme is case-insensitive). An unknown job id and a wrong secret answer the same `404 NOT_FOUND`.\n\nError envelope (every non-2xx response the API itself sends, except the `422` of a submission, which carries the full `submission`): `{ \"error\": { \"code\", \"message\", \"agent_instruction\"?, \"retryable\"? } }`. Codes are UPPER_SNAKE and come only from the `ErrorCode` enum. `429`, `409 SUBMISSION_IN_PROGRESS`, `502`, `503` and `500` carry a `Retry-After` header in seconds, and `retryable: true` whenever they do. Every route answers `405 METHOD_NOT_ALLOWED` with an `Allow` header for an HTTP method it does not support (`OPTIONS` included; `HEAD` is supported wherever `GET` is).\n\nOne answer comes from the hosting platform, not from the API: a request the platform cuts off (for example at its CPU limit — Cloudflare error 1102) can end in a `5xx` that is **not JSON** — an HTML error page with no envelope, no code and no `Retry-After`. Treat it like a lost response: wait about 30 seconds, read `GET /jobs/{jobId}`, and if `latestSubmission` does not show the upload, resend the same bytes once.\n\nWhile the API is switched off, every path under this server (the two handoff routes included) and `/.well-known/api-catalog` answer exactly what an unknown path on aperta-designs.com answers: the site's own `404` page (`text/html`), for every method — no JSON envelope, no error code, no `Allow` header. The approval page shows the same 404 page. There is no error code for that state: a `404` page from `GET /capabilities` means the API is not open yet.\n\nA cancelled job stays readable by its secret, without its submissions and images (the cancel deleted them): `GET /jobs/{jobId}` answers `200` with `status: \"cancelled\"`, a second `DELETE` is `200`, and a new upload answers `409 JOB_CANCELLED`.\n\nThe approval page `/a/{token}` is a human web page in **Hebrew** (see `capabilities.approvalPage`), not part of this API: agents receive its URL in `approval.url` and pass it to the person, telling them the page is in Hebrew when that matters.",
    "termsOfService": "https://aperta-designs.com/terms",
    "contact": {
      "name": "Aperta for Agents",
      "url": "https://aperta-designs.com/agents"
    },
    "x-api-version": "v1",
    "x-contract-version": "2026-09",
    "x-agent-instruction": "Call GET /capabilities first and cache it for the session: the numbers there (minimum opening, minimum metal, canvas, aspect range, limits, price) are live and override any snapshot in this document or in the skill. Then POST /jobs once per design with a fresh Idempotency-Key (a UUID) and the width you want (widthMm is required: to follow your drawing, send blank length ÷ drawn ratio), draw at job.drawing.ratio, POST the PNG to the job's submissions, fix every validation.errors[].agent_instruction and resubmit to the same job (at most limits.submissionsPerJob times), and finally give the person approval.url. Never send the person's story, never order on their behalf, never resubmit an unchanged image after a 422 (resend the same bytes only when an error says retryable: true, after Retry-After, or for PROCESSING_INTERRUPTED)."
  },
  "servers": [
    {
      "url": "https://aperta-designs.com/api/agent/v1",
      "description": "Production. HTTPS only; `www.` and `http://` redirect (308). Browser navigation to /api/* without an Aperta session is redirected to the home page — use an HTTP client, not a browser."
    }
  ],
  "tags": [
    {
      "name": "Capabilities",
      "description": "What Aperta makes, the image contract, limits, price, versions, links. Read first."
    },
    {
      "name": "Jobs",
      "description": "A job is one design for one person: product, size, and the derived drawing hints. Sized once, submitted to up to 10 times."
    },
    {
      "name": "Submissions",
      "description": "One PNG upload each, processed synchronously inside a published window of `limits.syncTimeoutSec` (120 s) — a window, not a guaranteed ceiling. The outcome is stored: reads and replays never recompute."
    },
    {
      "name": "Handoff",
      "description": "The person's side of the flow. Called by the approval page, not by agents."
    }
  ],
  "x-agent-instruction": "Workflow: capabilities → create job → draw → submit → fix loop → hand off. Treat validation.errors as the to-do list (each carries agent_instruction and, when measurable, locations in millimetres on the framed strip), validation.warnings as \"the engine changed this — check preview.svg\", and error.retryable as \"wait Retry-After seconds, then do what its agent_instruction says\" (usually: resend the same request; for 409 SUBMISSION_IN_PROGRESS: read the running submission instead of uploading again). While the person decides, wait at least capabilities.limits.pollIntervalSec seconds between two reads of GET /jobs/{jobId} to learn whether they approved.",
  "paths": {
    "/capabilities": {
      "get": {
        "tags": [
          "Capabilities"
        ],
        "operationId": "getCapabilities",
        "summary": "Products, image contract, manufacturing floors, limits, price, links, error codes",
        "description": "No authentication, no rate limit, no database. Every value is read at call time from the same configuration the validator enforces (`fabrication.config`, `pricing`, `sizing`, agent config incl. environment overrides), so this is the source of truth for numbers. The `example` on the `Capabilities` schema equals the live output at contract version 2026-09 and is pinned by a drift test.",
        "security": [],
        "x-agent-instruction": "Fetch this once per session before creating a job. Use manufacturing.minOpeningMm and manufacturing.minMetalMm when you write your image prompt; use image.rules verbatim as drawing constraints; respect limits.submissionsPerJob.",
        "responses": {
          "200": {
            "description": "The live capabilities. The `Capabilities` schema's `example` is this response at contract version 2026-09.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Capabilities"
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs": {
      "post": {
        "tags": [
          "Jobs"
        ],
        "operationId": "createJob",
        "summary": "Open a design job — returns the job and, once, its secret",
        "description": "Sizes the piece from `size` (wrist circumference + fit, US ring size, or an explicit blank length), fixes the product, width, gap and thickness, and returns the drawing hints (`job.drawing.ratio` = length ÷ width, canvas, per-job minimum opening area). **`widthMm` is required**: the tracer measures the 0.2 mm floor at the millimetre scale of the declared width, so there is no \"by the drawing\" mode — an agent that wants the width to follow its drawing computes `widthMm = blank length ÷ drawn ratio` (start from `capabilities.products[*].defaultWidthMm`) and sends it. The width must also leave the drawing traceable: at least the blank length ÷ `capabilities.image.cropAspect.max` (the tracer cannot trace a piece thinner than that), else `400 WIDTH_OUT_OF_RANGE` naming the minimum for that length. The secret in `credentials.jobSecret` is returned **only** in the `201` response and is stored hashed; keep it.\n\nIdempotency: send an `Idempotency-Key` header (recommended; 8–128 characters, a UUID). Keys are scoped to the caller's IP address (stored as a keyed hash of address + key, kept for the job's lifetime), so keys chosen by other agents cannot collide with yours. The same key with the same body replays as `200` with a reduced `JobReplay` body — no `credentials`, no `approval`, no `latestSubmission`; the same key with a different body, or a key whose job was cancelled or has expired, is `409 IDEMPOTENCY_CONFLICT` (the message says which). Rate limits: 30 `POST /jobs` requests per hour per IP, 200 per UTC day globally — live in `capabilities.limits.jobsPerHourPerIp` and `capabilities.limits.globalJobsPerDay`. They count requests, not jobs: every request that gets past the switch takes a slot, including one refused afterwards with `400` and an idempotent replay.",
        "security": [],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "$ref": "#/components/requestBodies/JobCreate"
        },
        "x-agent-instruction": "Send exactly the documented fields; unknown or misspelled fields are rejected with 400 INVALID_REQUEST naming the path. widthMm is required. Use a fresh Idempotency-Key (a UUID) per design and reuse it only to replay the same body; a 200 for a key you never received a 201 for means the key collided inside your own network — use a fresh one. Store credentials.jobSecret immediately — it is never returned again. Draw at job.drawing.ratio (length ÷ width) on a landscape canvas; every opening must be wider than manufacturing.minOpeningMm and larger than job.drawing.minOpeningAreaMm2.",
        "responses": {
          "200": {
            "description": "Idempotent replay: the same `Idempotency-Key` (from the same IP address) with the same body. A reduced `JobReplay`: id, current `status` and `limits`, dims and drawing hints — no `credentials`, no `approval`, no `latestSubmission`. Read the job's state with `GET /jobs/{jobId}` and the secret.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobReplayResponse"
                },
                "examples": {
                  "replayed": {
                    "$ref": "#/components/examples/JobReplayed"
                  }
                }
              }
            }
          },
          "201": {
            "description": "Job created. `credentials.jobSecret` appears here and nowhere else.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobResponse"
                },
                "examples": {
                  "braceletCreated": {
                    "$ref": "#/components/examples/JobCreated"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`INVALID_REQUEST` (malformed JSON, unknown or wrong-typed field, missing `widthMm`, bad `Idempotency-Key`), `LENGTH_OUT_OF_RANGE` (the computed blank is outside the product's limits), `WIDTH_OUT_OF_RANGE` (`widthMm` outside the product's width range, or narrower than the blank length ÷ `capabilities.image.cropAspect.max` — the message names the minimum; its instruction says how to derive a width from the drawing).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalidRequest": {
                    "$ref": "#/components/examples/InvalidRequest"
                  },
                  "lengthOutOfRange": {
                    "$ref": "#/components/examples/LengthOutOfRange"
                  },
                  "widthOutOfRange": {
                    "$ref": "#/components/examples/WidthOutOfRange"
                  },
                  "missingWidth": {
                    "$ref": "#/components/examples/MissingWidth"
                  },
                  "invalidIdempotencyKey": {
                    "$ref": "#/components/examples/InvalidIdempotencyKey"
                  },
                  "widthTooNarrow": {
                    "$ref": "#/components/examples/WidthTooNarrow"
                  },
                  "invalidSizeKey": {
                    "$ref": "#/components/examples/InvalidSizeKey"
                  }
                }
              }
            }
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "409": {
            "description": "`IDEMPOTENCY_CONFLICT`: this `Idempotency-Key` was already used with a different request body, or its job has since been cancelled or has expired (use a fresh key). The two cases carry different messages.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "idempotencyConflict": {
                    "$ref": "#/components/examples/IdempotencyConflict"
                  },
                  "idempotencyConflictDeadJob": {
                    "$ref": "#/components/examples/IdempotencyConflictDeadJob"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{jobId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "get": {
        "tags": [
          "Jobs"
        ],
        "operationId": "getJob",
        "summary": "Job state: dims, drawing hints, submissions left, latest submission summary, approval status",
        "description": "The one call an agent needs after submitting: `approval.status` tells whether the person approved (`approved`), declined (`declined`) or has not decided yet (`pending`). `latestSubmission` summarises the newest upload; a `failed_processing` entry with `errorCodes: [\"PROCESSING_INTERRUPTED\"]` means the previous upload was cut off and the same bytes should be resent. A job past its `expiresAt` reads as `expired`; a job you cancelled reads as `cancelled` (its submissions were deleted, `approval` is `null`; an upload still processing when you cancelled finishes first and can show as `latestSubmission` until the next sweep deletes it, within about 10 minutes). No rate limit, but every read is a request: while the person decides, wait at least `capabilities.limits.pollIntervalSec` seconds (30) between two reads — a person takes minutes or hours.",
        "security": [
          {
            "jobSecret": []
          }
        ],
        "x-agent-instruction": "While the person decides, wait at least capabilities.limits.pollIntervalSec seconds (30) between two reads of this to learn approval.status. If latestSubmission.status is failed_processing, resubmit the same bytes to the same job; it was not counted.",
        "responses": {
          "200": {
            "description": "The job. Never includes `credentials`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobResponse"
                },
                "examples": {
                  "ready": {
                    "$ref": "#/components/examples/JobReady"
                  },
                  "approved": {
                    "$ref": "#/components/examples/JobApproved"
                  },
                  "declined": {
                    "$ref": "#/components/examples/JobDeclined"
                  },
                  "cancelled": {
                    "$ref": "#/components/examples/JobCancelledView"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "Jobs"
        ],
        "operationId": "cancelJob",
        "summary": "Cancel a job and delete its uploaded images",
        "description": "Marks the job `cancelled` (a compare-and-set, so it cannot undo an approval that landed the same second) and then removes every stored source image and submission of the job. An upload still being processed at that moment is finished first and deleted by the next sweep (within about 10 minutes); until then it can show as `latestSubmission`. Afterwards the job stays readable with its secret: `GET /jobs/{jobId}` answers `200` with `status: \"cancelled\"`, a new upload answers `409 JOB_CANCELLED`, and a second `DELETE` is `200` again — cancelling twice is idempotent. An `approved` job cannot be cancelled (`409 JOB_LOCKED`) — the design already lives in the person's account. An `expired` job answers `409 JOB_EXPIRED` (the sweep owns it). Both carry an instruction for a cancel, not an upload: there is nothing to do, and no new job to open unless the person wants a new design. The approval page answers the site's 404 for a cancelled job.",
        "security": [
          {
            "jobSecret": []
          }
        ],
        "x-agent-instruction": "Cancel when the person abandons the design before approving, so their images are deleted at once instead of 180 days after the job expires. Do not cancel after approval; it is refused.",
        "responses": {
          "200": {
            "description": "Cancelled (or already cancelled).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResponse"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "409": {
            "description": "`JOB_LOCKED` (approved) or `JOB_EXPIRED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "jobLocked": {
                    "$ref": "#/components/examples/JobLockedOnCancel"
                  },
                  "jobExpired": {
                    "$ref": "#/components/examples/JobExpiredOnCancel"
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{jobId}/submissions": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        }
      ],
      "post": {
        "tags": [
          "Submissions"
        ],
        "operationId": "createSubmission",
        "summary": "Submit a PNG — traced, framed, validated and previewed synchronously",
        "description": "Accepts the drawing as a raw `image/png` body or as `multipart/form-data` with a file field named `image` (its part typed `image/png`, or unlabelled — then the PNG signature decides). Any other body type, or a part that declares another type, is `415`. Before the bytes leave the worker: size ≤ `image.maxBytes` (`413` — checked on `Content-Length` before reading, and a raw body is read as a capped stream that stops at the limit), PNG signature + IHDR (short side ≥ `image.minShortSidePx`, long side ≤ `image.maxLongSidePx`, ≤ `image.maxPixels`, valid bit depth/colour type) — no decoding.\n\nIdempotent by content, checked **before** rate limits and quota: the same bytes already answered `ready`/`failed_validation` in this job return the stored outcome with its original status, without reprocessing, without consuming a submission and without counting against any rate limit — so a lost answer can be re-fetched while the job still takes submissions (`open`, `ready`, `declined`), even when its 10 submissions are used up. The job's state is checked first: on an approved, expired or cancelled job the same bytes answer that state's `409`. The read that always works is `GET /jobs/{jobId}` → `latestSubmission` → `GET /jobs/{jobId}/submissions/{no}`. The same bytes while an earlier identical upload is still running answer `409 SUBMISSION_IN_PROGRESS` (`Retry-After: 10`) naming that upload's number: read `GET /jobs/{jobId}/submissions/{no}` until its status leaves `processing` instead of uploading again.\n\nThen the pipeline runs, inside the `limits.syncTimeoutSec` window (120 s; a published window, not a guaranteed ceiling — a client that gives up first resends the same bytes and reads the stored outcome, see above): tracing on the vectorizer at the job's true millimetre scale; a strict pre-frame gate at the strip's effective dimensions that rejects only what framing would otherwise drop silently (V1 contract, V5 minimum opening, plus the proportion check); framing (edge skins absorbed, detached islands bridged or dropped, necks thickened, spurs shaved) followed by validation of the framed strip (V2 connectivity, V3 trapped islands, V4 minimum metal — failures the repairs could not fix); preview geometry. What framing changed comes back as `validation.warnings` (`ISLAND_BRIDGED`, `ISLAND_REMOVED`, `NECK_THICKENED`, `SPURS_SHAVED`). The outcome is stored and answered as `200` (`submission.status: \"ready\"`) or `422` (`\"failed_validation\"`) with the **same body shape**; `approval` and its `expiresAt` in the answer reflect the job as it is after this submission, and `approval` is set only when this submission is the one the job now points at. A `502` is a technical failure (`retryable: true`): it is not counted against the job and the same bytes may be resent.\n\nRate limits: 60 per hour per IP, 12 per minute and 600 per UTC day globally (`Retry-After` names the blocking window: 3600, 60, or the seconds left until midnight UTC — or 30 when the limiter itself was unavailable); 10 counted submissions per job, enforced under concurrent uploads as well.",
        "security": [
          {
            "jobSecret": []
          }
        ],
        "requestBody": {
          "$ref": "#/components/requestBodies/SubmissionImage"
        },
        "x-agent-instruction": "Send PNG only (raw image/png or multipart field `image`). On 422, read submission.validation.errors[]: each item has agent_instruction and, when measurable, locations in millimetres on the framed strip (x along the length from the left, y across the width from the top) — fix them in a new render and resubmit to the same job. On 200, review validation.warnings (what the engine changed; look at files.previewSvg), then give the person submission.approval.url. On 502 wait Retry-After seconds and resend the same bytes once; if it fails again re-export the PNG; do not loop. On 409 SUBMISSION_IN_PROGRESS do not upload again: wait Retry-After seconds and read GET /jobs/{jobId}/submissions/{no} for the number named in the message. Never resubmit an unchanged image: it replays the stored answer and teaches you nothing.",
        "responses": {
          "200": {
            "description": "`submission.status: \"ready\"` — manufacturable. `geometry` holds the framed dimensions, `files` the cutting SVG and the preview, `approval.url` the page for the person, `price` the fixed price. `validation.warnings` list what the engine changed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmissionResponse"
                },
                "examples": {
                  "ready": {
                    "$ref": "#/components/examples/SubmissionReady"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Rejected before processing: `INVALID_REQUEST` (empty body, unparsable multipart, missing `image` field), `INVALID_FILE` (not a PNG: bad signature or IHDR), `IMAGE_TOO_SMALL`, `IMAGE_TOO_LARGE` (IHDR dimensions of the canvas outside the limits). Not counted. The tracer's own size gate on the traced piece (a crop taller than 2.28× its width, or thinner than 46:1) is not a `400`: it is a `422` with `PROPORTION_MISMATCH` as an error, refused before anything large is allocated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalidFile": {
                    "$ref": "#/components/examples/InvalidFile"
                  },
                  "imageTooSmall": {
                    "$ref": "#/components/examples/ImageTooSmall"
                  },
                  "imageTooLarge": {
                    "$ref": "#/components/examples/ImageTooLarge"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "409": {
            "description": "The job cannot take this submission now: `JOB_LOCKED` (approved), `JOB_EXPIRED` (7 days after the job was created — submissions do not extend it — or 24 hours after a decline, whichever comes first; a passing submission after a decline returns it to creation + 7 days), `JOB_CANCELLED` (you cancelled it — open a new job), `TOO_MANY_SUBMISSIONS` (all 10 counted submissions used), or `SUBMISSION_IN_PROGRESS` (the same bytes are already being processed as the submission named in the message — `retryable: true`, `Retry-After: 10`; read that submission instead of uploading again).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter",
                "description": "Only with `SUBMISSION_IN_PROGRESS`: 10 seconds."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "jobLocked": {
                    "$ref": "#/components/examples/JobLocked"
                  },
                  "jobExpired": {
                    "$ref": "#/components/examples/JobExpired"
                  },
                  "jobCancelled": {
                    "$ref": "#/components/examples/JobCancelled"
                  },
                  "tooManySubmissions": {
                    "$ref": "#/components/examples/TooManySubmissions"
                  },
                  "submissionInProgress": {
                    "$ref": "#/components/examples/SubmissionInProgress"
                  }
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/PayloadTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/UnsupportedMediaType"
          },
          "422": {
            "description": "`submission.status: \"failed_validation\"` — the design cannot be made as drawn. Same shape as `200`: `validation.valid` is `false`, `validation.errors` is non-empty (each with `agent_instruction` and, when measurable, `locations` in mm), `validation.warnings` may list what the tracer noticed or changed before the design failed (for example `DESIGN_TOUCHES_EDGE`, `NOT_TWO_TONE`) — often the reason for the error, `geometry`/`files`/`approval` are `null`. Counts as one submission. **Not** the `Error` envelope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmissionResponse"
                },
                "examples": {
                  "openingTooSmall": {
                    "$ref": "#/components/examples/SubmissionFailedValidation"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "502": {
            "$ref": "#/components/responses/BadGateway"
          }
        }
      }
    },
    "/jobs/{jobId}/submissions/{no}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        },
        {
          "$ref": "#/components/parameters/SubmissionNo"
        }
      ],
      "get": {
        "tags": [
          "Submissions"
        ],
        "operationId": "getSubmission",
        "summary": "Full report of one submission",
        "description": "The stored outcome of submission `no`, in the same shape as the `POST` answer, always as HTTP `200` (it is a read): for `ready` and `failed_validation` what the `POST` returned, with `approval` as the job is now (and `null` once a newer passing submission replaced this one); for `failed_processing` one infrastructure finding in `validation.errors` with `retryable: true`; for a live `processing` row empty findings and `null`s.",
        "security": [
          {
            "jobSecret": []
          }
        ],
        "x-agent-instruction": "Use this to re-read a report you lost; it never recomputes. A status of failed_processing with PROCESSING_INTERRUPTED means resend the same bytes to the job.",
        "responses": {
          "200": {
            "description": "The submission in any status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SubmissionResponse"
                },
                "examples": {
                  "ready": {
                    "$ref": "#/components/examples/SubmissionReady"
                  },
                  "failedValidation": {
                    "$ref": "#/components/examples/SubmissionFailedValidation"
                  },
                  "interrupted": {
                    "$ref": "#/components/examples/SubmissionInterrupted"
                  },
                  "processing": {
                    "$ref": "#/components/examples/SubmissionProcessing"
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{jobId}/submissions/{no}/cutouts.svg": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        },
        {
          "$ref": "#/components/parameters/SubmissionNo"
        }
      ],
      "get": {
        "tags": [
          "Submissions"
        ],
        "operationId": "getSubmissionCutoutsSvg",
        "summary": "The canonical cutting SVG of a ready submission",
        "description": "`<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 L W\"><g id=\"cutouts\">…</g></svg>` in millimetres: the framed strip is `L × W`, black paths are the cut contours — every opening, plus the material cut away outside the piece's outline inside that frame (at shaped ends), only `M/L/Z` path commands, three decimals. Rewritten from sampled points — no script, style or foreign content can appear. Available only when the submission is `ready`; otherwise `404`. Served `Cache-Control: no-store`.",
        "security": [
          {
            "jobSecret": []
          }
        ],
        "x-agent-instruction": "Optional. Fetch it to inspect the geometry yourself (e.g. to overlay finding locations); the person's approval flow does not need it.",
        "responses": {
          "200": {
            "description": "The canonical SVG.",
            "content": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 55.95 5.99\"><g id=\"cutouts\"><path d=\"M0 0L2.58 0L2.487 0.062L1.841 0.26L1.276 0.569L0.791 0.993L0.389 1.536L0.199 1.968L0.078 2.436L0 2.576L0 0ZM0 3.398L0.062 3.492L0.26 4.136L0.57 4.701L0.994 5.185L1.539 5.587L1.971 5.776L2.44 5.897L2.58 5.99L0 5.99L0 3.398ZM10.786 2.871L10.987 2.357L11.148 2.144L11.377 1.971L11.75 1.816L12.258 1.825L12.697 2.022L13.027 2.371L13.21 2.84L13.21 3.181L13.029 3.661L12.667 4.019L12.278 4.19L11.718 4.19L11.314 4.004L10.959 3.627L10.786 3.135L10.786 2.871ZM18.074 2.898L18.162 2.633L18.322 2.401L18.526 2.235L18.821 2.111L23.173 2.111L23.468 2.235L23.739 2.485L23.888 2.824L23.903 3.107L23.827 3.375L23.672 3.608L23.452 3.786L23.157 3.911L18.837 3.911L18.588 3.817L18.324 3.631L18.091 3.166L18.074 2.898ZM26.576 2.84L26.667 2.503L26.84 2.188L27.229 1.816L27.726 1.614L28.239 1.614L28.803 1.858L29.043 2.062L29.218 2.312L29.405 2.855L29.405 3.15L29.315 3.49L29.141 3.817L28.737 4.205L28.208 4.407L27.695 4.392L27.282 4.23L26.951 3.958L26.713 3.601L26.576 3.181L26.576 2.84ZM32.062 2.898L32.149 2.633L32.309 2.401L32.513 2.235L32.809 2.111L37.16 2.111L37.455 2.235L37.726 2.485L37.875 2.824L37.89 3.107L37.814 3.375L37.659 3.608L37.44 3.786L37.145 3.911L32.824 3.911L32.575 3.817L32.311 3.631L32.078 3.166L32.062 2.898ZM42.774 3.19L42.801 2.679L43.05 2.219L43.354 1.966L43.719 1.816L44.223 1.821L44.668 2.02L45.004 2.373L45.18 2.84L45.188 3.122L45.126 3.358L44.869 3.833L44.636 4.019L44.263 4.19L43.688 4.19L43.33 4.035L42.955 3.67L42.774 3.19ZM53.355 0L55.95 0L55.95 5.99L53.355 5.99L53.448 5.912L54.222 5.665L54.909 5.23L55.389 4.7L55.717 4.066L55.857 3.538L55.934 3.398L55.934 2.576L55.872 2.483L55.81 2.188L55.56 1.573L55.189 1.024L54.823 0.674L54.417 0.415L53.494 0.078L53.355 0Z\" fill=\"black\"/></g></svg>"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/jobs/{jobId}/submissions/{no}/preview.svg": {
      "parameters": [
        {
          "$ref": "#/components/parameters/JobId"
        },
        {
          "$ref": "#/components/parameters/SubmissionNo"
        }
      ],
      "get": {
        "tags": [
          "Submissions"
        ],
        "operationId": "getSubmissionPreviewSvg",
        "summary": "A flat silhouette preview of a ready submission",
        "description": "A small self-contained SVG (`role=\"img\"`, dark fill, `fill-rule=\"evenodd\"`) of the metal as it will be cut, drawn from the stored preview geometry — what the gallery shows. Readable by any model that can look at an image. `404` unless the submission is `ready` and its preview geometry exists.",
        "security": [
          {
            "jobSecret": []
          }
        ],
        "x-agent-instruction": "Show this to the person (or look at it yourself) to judge warnings such as ISLAND_BRIDGED or NECK_THICKENED before handing over the approval link.",
        "responses": {
          "200": {
            "description": "The preview SVG.",
            "content": {
              "image/svg+xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-1 -1 57.95 8\" role=\"img\"><path d=\"M0,2.6L0.1,2.4L0.2,2L0.4,1.5L0.8,1L1.3,0.6L1.8,0.3L2.5,0.1L2.6,0L53.4,0L53.5,0.1L54.4,0.4L54.8,0.7L55.2,1L55.6,1.6L55.8,2.2L55.9,2.5L55.9,2.6L55.9,3.4L55.9,3.5L55.7,4.1L55.4,4.7L54.9,5.2L54.2,5.7L53.4,5.9L53.4,6L2.6,6L2.4,5.9L2,5.8L1.5,5.6L1,5.2L0.6,4.7L0.3,4.1L0.1,3.5L0,3.4L0,2.6Z M10.8,2.9L10.8,3.1L11,3.6L11.3,4L11.7,4.2L12.3,4.2L12.7,4L13,3.7L13.2,3.2L13.2,2.8L13,2.4L12.7,2L12.3,1.8L11.7,1.8L11.4,2L11.1,2.1L11,2.4L10.8,2.9Z M18.1,2.9L18.1,3.2L18.3,3.6L18.6,3.8L18.8,3.9L23.2,3.9L23.5,3.8L23.7,3.6L23.8,3.4L23.9,3.1L23.9,2.8L23.7,2.5L23.5,2.2L23.2,2.1L18.8,2.1L18.5,2.2L18.3,2.4L18.2,2.6L18.1,2.9Z M26.6,2.8L26.6,3.2L26.7,3.6L27,4L27.3,4.2L27.7,4.4L28.2,4.4L28.7,4.2L29.1,3.8L29.3,3.5L29.4,3.2L29.4,2.9L29.2,2.3L29,2.1L28.8,1.9L28.2,1.6L27.7,1.6L27.2,1.8L26.8,2.2L26.7,2.5L26.6,2.8Z M32.1,2.9L32.1,3.2L32.3,3.6L32.6,3.8L32.8,3.9L37.1,3.9L37.4,3.8L37.7,3.6L37.8,3.4L37.9,3.1L37.9,2.8L37.7,2.5L37.5,2.2L37.2,2.1L32.8,2.1L32.5,2.2L32.3,2.4L32.1,2.6L32.1,2.9Z M42.8,3.2L43,3.7L43.3,4L43.7,4.2L44.3,4.2L44.6,4L44.9,3.8L45.1,3.4L45.2,3.1L45.2,2.8L45,2.4L44.7,2L44.2,1.8L43.7,1.8L43.4,2L43.1,2.2L42.8,2.7L42.8,3.2Z\" fill-rule=\"evenodd\" fill=\"#202326\"/></svg>"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/handoff/{token}/adopt": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApprovalToken"
        }
      ],
      "post": {
        "tags": [
          "Handoff"
        ],
        "operationId": "adoptHandoff",
        "summary": "The person approves: copy the ready design into their Aperta account",
        "description": "Called by the approval page `/a/{token}` when the signed-in person clicks approve. Requires the person's Aperta session (browser cookie), **not** the job secret. The body names the submission the person saw (`submissionNo`, the number rendered on the page — after a sign-in round trip the page re-sends the number that was clicked, not the one it re-rendered); if the job's current ready submission is a different one — the agent resubmitted while the page was open — the answer is `409 SUBMISSION_SUPERSEDED` and the page reloads, so a person never approves a design they did not look at. Otherwise creates a new design owned by that person from that submission, re-validates it, and locks the job (`approved`) with a compare-and-set that also pins the submission: a passing resubmission that lands while the design is being built makes the lock miss, the created design is discarded and the answer is `409 SUBMISSION_SUPERSEDED`. On success returns the design id; the page continues to `/design?resume={designId}`. Idempotent for the same person (`200` with the same `designId`); a different account answers `409 ALREADY_APPROVED`. Rate limit: 20 per hour per account.",
        "security": [],
        "requestBody": {
          "$ref": "#/components/requestBodies/AdoptRequest"
        },
        "x-agent-instruction": "Do not call this. It is for the person, through the approval page. Send them approval.url and read the result in GET /jobs/{jobId} → approval.status.",
        "responses": {
          "200": {
            "description": "Already approved by this same account (double click, or the return from sign-in): the existing design id.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AdoptResponse"
                },
                "example": {
                  "designId": "c0ffee00-4b1d-4e5a-9c3b-7a2f1e0d9c8b"
                }
              }
            }
          },
          "201": {
            "description": "Design created in the person's account; the job is now `approved`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AdoptResponse"
                },
                "example": {
                  "designId": "c0ffee00-4b1d-4e5a-9c3b-7a2f1e0d9c8b"
                }
              }
            }
          },
          "400": {
            "description": "`INVALID_REQUEST` (missing or malformed `submissionNo`), or `LENGTH_OUT_OF_RANGE`: the job's dimensions no longer pass the buildability gate (belt and braces; dims were gated at creation).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalidRequest": {
                    "$ref": "#/components/examples/InvalidRequest"
                  },
                  "lengthOutOfRange": {
                    "$ref": "#/components/examples/LengthOutOfRange"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/AccountRequired"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "409": {
            "description": "`ALREADY_APPROVED`: another account approved this job first. `SUBMISSION_SUPERSEDED`: `submissionNo` is no longer the job's current ready submission — the page reloads and shows the current design; nothing was approved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "alreadyApproved": {
                    "$ref": "#/components/examples/AlreadyApproved"
                  },
                  "submissionSuperseded": {
                    "$ref": "#/components/examples/SubmissionSuperseded"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "$ref": "#/components/responses/AuthUnavailable"
          }
        }
      }
    },
    "/handoff/{token}/decline": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ApprovalToken"
        }
      ],
      "post": {
        "tags": [
          "Handoff"
        ],
        "operationId": "declineHandoff",
        "summary": "The person declines the design",
        "description": "Called by the approval page. Token only — no account, no body, no personal data. Moves a `ready` job to `declined` (its approval link re-arms if the agent later submits a design that passes). Idempotent: declining a declined job is `200`. An `approved` job answers `409 JOB_LOCKED`; anything else `404`.",
        "security": [],
        "x-agent-instruction": "Do not call this. When GET /jobs/{jobId} shows approval.status \"declined\", ask the person what to change, fix the design and submit again to the same job — the same approval link wakes up.",
        "responses": {
          "200": {
            "description": "Declined (or already declined).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeclineResponse"
                },
                "example": {
                  "ok": true,
                  "status": "declined"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          },
          "409": {
            "description": "`JOB_LOCKED`: the job is already approved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "jobLocked": {
                    "$ref": "#/components/examples/JobLocked"
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "jobSecret": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "ajs_<43 base64url characters>",
        "description": "The job secret from `POST /jobs` → `credentials.jobSecret`, sent as `Authorization: Bearer ajs_…` on every job route. Returned once, stored only as a SHA-256 hash, never logged. A wrong or missing secret and an unknown job id are indistinguishable (`404 NOT_FOUND`). If the secret is lost, open a new job."
      }
    },
    "parameters": {
      "JobId": {
        "name": "jobId",
        "in": "path",
        "required": true,
        "description": "The job id from `POST /jobs` → `job.id` (UUID).",
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "example": "6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31"
      },
      "SubmissionNo": {
        "name": "no",
        "in": "path",
        "required": true,
        "description": "The submission's running number within the job (`submission.no`, starting at 1).",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 9999
        },
        "example": 2
      },
      "ApprovalToken": {
        "name": "token",
        "in": "path",
        "required": true,
        "description": "The 12-character base58 approval token — the last path segment of `approval.url` (`https://aperta-designs.com/a/{token}`).",
        "schema": {
          "type": "string",
          "pattern": "^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{12}$"
        },
        "example": "7Hq2kPzR4mNx"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional but recommended: 8–128 printable ASCII characters chosen by the agent — use a UUID. Scoped to your IP address: Aperta stores a keyed hash of (address + key) for the job's lifetime, so keys chosen by other agents never collide with yours and never reveal your jobs. The same key with the same body replays the job (`200`, reduced `JobReplay` body, no `credentials`); the same key with a different body, or with a job that was cancelled or has expired, is `409 IDEMPOTENCY_CONFLICT`. A `200` for a key you never received a `201` for means a collision inside your own network — use a fresh key.",
        "schema": {
          "type": "string",
          "minLength": 8,
          "maxLength": 128,
          "pattern": "^[\\x21-\\x7e]{8,128}$"
        },
        "example": "5f1c9a2e-7b3d-4e8f-a1c2-0d9e8f7a6b5c"
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "Seconds to wait before retrying. After `429 RATE_LIMITED` it is the blocking bucket's window: 60 for the per-minute bucket, 3600 for an hourly one, the seconds left until midnight UTC for a daily one (daily buckets count from 00:00 UTC and are empty again at midnight, so waiting exactly this long is enough) — or 30 when the limiter's own store was unreachable (the message says so; no bucket was full). After `409 SUBMISSION_IN_PROGRESS`: 10. After `502`, `503` and `500`: 30. The `agent_instruction` quotes the same number.",
        "schema": {
          "type": "integer",
          "minimum": 1
        },
        "example": 60
      }
    },
    "requestBodies": {
      "JobCreate": {
        "required": true,
        "description": "Product, size and width (required). Exactly one size form: `{ wristMm, fit? }` (bracelet only), `{ usRingSize }` (ring only) or `{ lengthMm }` (either). Unknown fields are rejected.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/JobCreate"
            },
            "examples": {
              "braceletWrist": {
                "summary": "Bracelet from a wrist measurement, comfort fit, 18 mm wide",
                "value": {
                  "productType": "bracelet",
                  "size": {
                    "wristMm": 165,
                    "fit": "comfort"
                  },
                  "widthMm": 18,
                  "attribution": {
                    "agentPlatform": "openclaw",
                    "integration": "gift-concierge",
                    "skillVersion": "1.0.0",
                    "apiVersion": "v1"
                  }
                }
              },
              "ringUsSize": {
                "summary": "Ring from a US ring size, 6 mm wide",
                "value": {
                  "productType": "ring",
                  "size": {
                    "usRingSize": 7
                  },
                  "widthMm": 6
                }
              },
              "braceletExplicitLength": {
                "summary": "Bracelet with an explicit flat blank length",
                "value": {
                  "productType": "bracelet",
                  "size": {
                    "lengthMm": 160
                  },
                  "widthMm": 12
                }
              }
            }
          }
        }
      },
      "SubmissionImage": {
        "required": true,
        "description": "The PNG, as a raw body or as a multipart file field named `image`. PNG only: JPEG/WebP ringing corrupts the black/white boundary the tracer relies on. Size ≤ 6,000,000 bytes (`image.maxBytes`); a flat black-on-white 1536×1024 8-bit PNG is about 200 KB. A raw `image/png` body is read as a capped stream and refused with `413` the moment it passes the limit; a multipart body without `Content-Length` is buffered by the platform before the cap applies, so send `Content-Length` when you can.",
        "content": {
          "image/png": {
            "schema": {
              "type": "string",
              "format": "binary",
              "description": "The PNG bytes. Send `Content-Length` when you can; a chunked body is read as a capped stream and cut off at the limit with `413`."
            }
          },
          "multipart/form-data": {
            "schema": {
              "type": "object",
              "required": [
                "image"
              ],
              "properties": {
                "image": {
                  "type": "string",
                  "format": "binary",
                  "description": "The PNG file. Its part `Content-Type` may be `image/png`, or absent / `application/octet-stream` / `text/plain` (what many HTTP clients send for an unlabelled file part) — then the PNG signature decides, and a file that is not a PNG answers `400 INVALID_FILE`. Any other declared type (`image/jpeg`, `image/webp`, …) answers `415 UNSUPPORTED_FORMAT`."
                }
              },
              "additionalProperties": false
            },
            "encoding": {
              "image": {
                "contentType": "image/png"
              }
            }
          }
        }
      },
      "AdoptRequest": {
        "required": true,
        "description": "The submission the person is approving — the `submission.no` rendered on the approval page. Adopt refuses (`409 SUBMISSION_SUPERSEDED`) when it is no longer the job's current ready submission.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/AdoptRequest"
            },
            "example": {
              "submissionNo": 2
            }
          }
        }
      }
    },
    "responses": {
      "NotFound": {
        "description": "`NOT_FOUND`: unknown job id, or wrong or missing job secret — deliberately identical (`\"No such job.\"`, same instruction). A cancelled job is **not** a 404: it reads as `cancelled`. Once the secret has been verified, a missing submission number (`\"No such submission.\"`) or a file that does not exist for the submission's status (`\"No such file for this submission.\"`) is also `404 NOT_FOUND`, but with its own message and a specific `agent_instruction` (which submission exists, why the file is absent) so a good job is not abandoned. On the handoff routes: an unknown token, or a job with nothing to approve behind it. A path that is not an endpoint of this API (a typo) answers `404 NOT_FOUND` with the message `\"No such endpoint.\"` — the job, if any, is fine; do not open a new one.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "notFound": {
                "$ref": "#/components/examples/NotFound"
              },
              "submissionNotFound": {
                "$ref": "#/components/examples/SubmissionNotFound"
              },
              "fileNotFound": {
                "$ref": "#/components/examples/FileNotFound"
              },
              "unknownEndpoint": {
                "$ref": "#/components/examples/UnknownEndpoint"
              }
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "`METHOD_NOT_ALLOWED`: the HTTP method is not supported on this route (this includes `OPTIONS`). The `Allow` header lists the supported methods. Every route answers this while the API is on; while it is off, every method answers the site's 404.",
        "headers": {
          "Allow": {
            "description": "The methods this route supports (`HEAD` whenever `GET` is; `OPTIONS` is never listed — it answers 405 like any unsupported method), e.g. `GET, HEAD`.",
            "schema": {
              "type": "string"
            },
            "example": "GET, HEAD"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "methodNotAllowed": {
                "$ref": "#/components/examples/MethodNotAllowed"
              }
            }
          }
        }
      },
      "AccountRequired": {
        "description": "`ACCOUNT_REQUIRED`: no signed-in person. The approval page opens the sign-in gate and retries; agents never see this on their own routes.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "accountRequired": {
                "$ref": "#/components/examples/AccountRequired"
              }
            }
          }
        }
      },
      "PayloadTooLarge": {
        "description": "`FILE_TOO_LARGE`: the body exceeds `image.maxBytes` — checked on `Content-Length` before reading, on the capped stream while reading (the read stops at the limit), and on the multipart file before it is read. Not counted.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "fileTooLarge": {
                "$ref": "#/components/examples/FileTooLarge"
              }
            }
          }
        }
      },
      "UnsupportedMediaType": {
        "description": "`UNSUPPORTED_FORMAT`: the `Content-Type` is neither `image/png` nor `multipart/form-data`, or the multipart `image` part declares a type other than `image/png` (an unlabelled part — no type, `application/octet-stream` or `text/plain` — is judged by its PNG signature instead). Not counted.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "unsupportedFormat": {
                "$ref": "#/components/examples/UnsupportedFormat"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "`RATE_LIMITED`: a per-IP or global bucket is full — the message names the bucket and `Retry-After` is that bucket's window (60, 3600, or the seconds left until midnight UTC — a daily bucket counts per UTC day). When the limiter's own store is unreachable the API still fails closed with the same code, but the message says the limiter was unavailable and `Retry-After` is 30. The bucket that answered `429` did not count this request, but the buckets checked before it (per-IP first) already did; a `429` never counts against the job's submissions.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "rateLimited": {
                "$ref": "#/components/examples/RateLimited"
              },
              "rateLimiterUnavailable": {
                "$ref": "#/components/examples/RateLimiterUnavailable"
              }
            }
          }
        }
      },
      "BadGateway": {
        "description": "Technical failure while processing, always `retryable: true`. `VECTORIZER_UNAVAILABLE`: the tracing service is unreachable or timed out (90 s) — wait `Retry-After` and resend the same bytes to the same job. `PROCESSING_FAILED`: the tracer answered something that is not a result (a non-JSON body, a crash on bytes it could not decode) or tracing/framing failed on Aperta's side — resend the same bytes once; if it fails again, re-export the PNG; do not loop. The attempt was not counted; the submission is stored as `failed_processing`. The message never quotes the tracer.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "vectorizerUnavailable": {
                "$ref": "#/components/examples/VectorizerUnavailable"
              },
              "processingFailed": {
                "$ref": "#/components/examples/ProcessingFailed502"
              }
            }
          }
        }
      },
      "AuthUnavailable": {
        "description": "`AUTH_UNAVAILABLE`: the sign-in service could not verify the session. Retry after `Retry-After` seconds.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "authUnavailable": {
                "$ref": "#/components/examples/AuthUnavailable"
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "`PROCESSING_FAILED`: an unexpected failure on Aperta's side, on any route. The message never carries internal details and the instruction is generic (retry the same request once after `Retry-After` seconds; then stop) — the PNG-specific instruction belongs to the `502` of a submission only.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "processingFailed": {
                "$ref": "#/components/examples/ProcessingFailed500"
              }
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorCode": {
        "type": "string",
        "description": "Every code the API can emit — in `error.code` of the `Error` envelope and in `code` of a validation `Finding`. Stable, UPPER_SNAKE, documented with the same order and wording in `/skills/aperta-agent/references/errors.md`. Rough groups: file (`INVALID_FILE`…`IMAGE_TOO_LARGE`), tracing (`NO_DESIGN_FOUND`…`TRACE_REJECTED`), validation on the framed strip (`MATERIAL_DISCONNECTED`…`NECK_CHECK_SKIPPED`), what the engine changed (`PROPORTION_MISMATCH`…`NECK_THICKENED`, warnings), sizing (`LENGTH_OUT_OF_RANGE`, `WIDTH_OUT_OF_RANGE`), job state (`JOB_LOCKED`…`SUBMISSION_SUPERSEDED`, incl. `JOB_CANCELLED`), infrastructure (`VECTORIZER_UNAVAILABLE`…`PROCESSING_INTERRUPTED`, retryable), transport (`RATE_LIMITED`…`AUTH_UNAVAILABLE`, incl. `METHOD_NOT_ALLOWED`). `PROPORTION_MISMATCH` is a warning while the stretch stays under ×1.5 and an error at ×1.5 or beyond. There is no code for a switched-off API: that state is the site's own 404 page, the answer of any unknown path.",
        "enum": [
          "INVALID_FILE",
          "UNSUPPORTED_FORMAT",
          "FILE_TOO_LARGE",
          "IMAGE_TOO_SMALL",
          "IMAGE_TOO_LARGE",
          "NO_DESIGN_FOUND",
          "NOT_TWO_TONE",
          "DESIGN_TOUCHES_EDGE",
          "DISCONNECTED_REGIONS",
          "FEATURES_TOO_FINE",
          "TRACE_REJECTED",
          "MATERIAL_DISCONNECTED",
          "TRAPPED_ISLAND",
          "NECK_TOO_THIN",
          "OPENING_TOO_SMALL",
          "NECK_CHECK_SKIPPED",
          "PROPORTION_MISMATCH",
          "ISLAND_BRIDGED",
          "ISLAND_REMOVED",
          "OPENING_REMOVED",
          "SPURS_SHAVED",
          "NECK_THICKENED",
          "LENGTH_OUT_OF_RANGE",
          "WIDTH_OUT_OF_RANGE",
          "JOB_LOCKED",
          "JOB_EXPIRED",
          "JOB_CANCELLED",
          "TOO_MANY_SUBMISSIONS",
          "IDEMPOTENCY_CONFLICT",
          "ALREADY_APPROVED",
          "SUBMISSION_IN_PROGRESS",
          "SUBMISSION_SUPERSEDED",
          "VECTORIZER_UNAVAILABLE",
          "PROCESSING_FAILED",
          "PROCESSING_INTERRUPTED",
          "RATE_LIMITED",
          "INVALID_REQUEST",
          "METHOD_NOT_ALLOWED",
          "NOT_FOUND",
          "ACCOUNT_REQUIRED",
          "AUTH_UNAVAILABLE"
        ]
      },
      "Error": {
        "type": "object",
        "description": "The error envelope of every non-2xx response except a submission's `422` (which carries the `submission`). `agent_instruction` says what to do; `retryable: true` means wait `Retry-After` seconds and resend the same request. A `5xx` cut off by the hosting platform (an HTML error page, e.g. Cloudflare 1102) is not this envelope either — see the API description. While the API is switched off there is no envelope at all — the site's own 404 page (`text/html`), like any unknown path.",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string",
                "description": "One short sentence for a human reading a log. May be followed by a detail, e.g. the offending field path."
              },
              "agent_instruction": {
                "type": "string",
                "description": "What the agent should do, in the imperative, with the live numbers filled in."
              },
              "retryable": {
                "type": "boolean",
                "description": "`true` = wait `Retry-After` seconds and resend the same request (every technical `502`/`503`/`500`, `429`, and `409 SUBMISSION_IN_PROGRESS`). Absent on errors the agent must fix. This version never sends `false`; a future `false` would mean do not resend and would come without `Retry-After`."
              }
            }
          }
        }
      },
      "Location": {
        "type": "object",
        "description": "A spot on the strip, in millimetres. The strip is the bounding box of the black region in the submitted PNG, scaled to the job's `dims.lengthMm` × the strip's effective width — the declared `dims.widthMm`, unless your drawing's own width (at the job's length) is within 5 % of it, in which case the drawing's width is kept; on a `ready` submission that is exactly `geometry.lengthMm × geometry.widthMm`. `x` runs along the length from the left end, `y` across the width from the top edge, `r` is an approximate radius of the affected area. To overlay a location on your PNG, crop it to the bounding box of the black pixels and map that box onto the strip (the mapping is non-uniform when the drawn ratio differs from the job's).",
        "required": [
          "x",
          "y"
        ],
        "properties": {
          "x": {
            "type": "number"
          },
          "y": {
            "type": "number"
          },
          "r": {
            "type": "number"
          }
        }
      },
      "Finding": {
        "type": "object",
        "description": "One validation error or warning. Errors make the submission `failed_validation` and must be fixed; warnings describe what the engine noticed or changed — on a passing design, and on a `422` next to the errors (often the reason for them).",
        "required": [
          "code",
          "severity",
          "message",
          "agent_instruction"
        ],
        "properties": {
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "severity": {
            "type": "string",
            "enum": [
              "error",
              "warning"
            ]
          },
          "message": {
            "type": "string"
          },
          "agent_instruction": {
            "type": "string"
          },
          "locations": {
            "type": "array",
            "description": "Where, on the framed strip (mm). Present only when the check can point at spots.",
            "items": {
              "$ref": "#/components/schemas/Location"
            }
          },
          "details": {
            "type": "string",
            "description": "The technical detail in English: the validator's sentence, the tracer's warning, the bridge or spur list."
          },
          "retryable": {
            "type": "boolean",
            "description": "Only on infrastructure findings such as `PROCESSING_INTERRUPTED`."
          }
        }
      },
      "ProductType": {
        "type": "string",
        "enum": [
          "bracelet",
          "ring"
        ],
        "description": "An open cuff bracelet or an open ring, both cut flat from 1.5 mm brass and rolled."
      },
      "Fit": {
        "type": "string",
        "enum": [
          "snug",
          "comfort",
          "loose"
        ],
        "description": "Ease added to the wrist circumference: see `capabilities.sizing.bracelet.fitEaseMm`."
      },
      "SizeInput": {
        "description": "Exactly one of three forms. `wristMm` (+ optional `fit`, default `comfort`) sizes a bracelet from the wrist circumference in **millimetres**; `usRingSize` sizes a ring in whole or half US sizes; `lengthMm` is the flat blank length for either product. The computed or given blank must fall inside `capabilities.products[*].lengthLimitMm`, else `400 LENGTH_OUT_OF_RANGE`. `wristMm` with a ring, or `usRingSize` with a bracelet, is `400 INVALID_REQUEST`.",
        "oneOf": [
          {
            "title": "WristSize",
            "type": "object",
            "required": [
              "wristMm"
            ],
            "properties": {
              "wristMm": {
                "type": "number",
                "minimum": 100,
                "maximum": 260,
                "description": "Wrist circumference in millimetres (not centimetres)."
              },
              "fit": {
                "$ref": "#/components/schemas/Fit",
                "default": "comfort"
              }
            },
            "additionalProperties": false
          },
          {
            "title": "UsRingSize",
            "type": "object",
            "required": [
              "usRingSize"
            ],
            "properties": {
              "usRingSize": {
                "type": "number",
                "minimum": 3,
                "maximum": 15,
                "multipleOf": 0.5,
                "description": "US ring size in half steps."
              }
            },
            "additionalProperties": false
          },
          {
            "title": "BlankLength",
            "type": "object",
            "required": [
              "lengthMm"
            ],
            "properties": {
              "lengthMm": {
                "type": "number",
                "exclusiveMinimum": 0,
                "description": "Flat blank length in millimetres (the strip before rolling, not the circumference)."
              }
            },
            "additionalProperties": false
          }
        ]
      },
      "Attribution": {
        "type": "object",
        "description": "Who is calling — an integration name, never a person. All optional, each ≤ 64 characters. Send `skillVersion` and `apiVersion` from the skill you followed so submissions made under old instructions can be recognised.",
        "properties": {
          "agentPlatform": {
            "type": "string",
            "maxLength": 64
          },
          "integration": {
            "type": "string",
            "maxLength": 64
          },
          "skillVersion": {
            "type": "string",
            "maxLength": 64
          },
          "apiVersion": {
            "type": "string",
            "maxLength": 64
          }
        },
        "additionalProperties": false
      },
      "JobCreate": {
        "type": "object",
        "description": "The body of `POST /jobs`. Unknown keys are rejected (`400 INVALID_REQUEST` naming the key) — a misspelled field would otherwise silently produce a job nobody asked for. Cross-field rules: `size.wristMm` only with `productType: \"bracelet\"`; `size.usRingSize` only with `\"ring\"`; `widthMm` inside `capabilities.products[productType].widthRangeMm` (5–80 for a bracelet, 4–18 for a ring), else `400 WIDTH_OUT_OF_RANGE`; and `widthMm` at least the blank length ÷ `capabilities.image.cropAspect.max`, so that `drawing.ratio` stays traceable, else the same code naming the minimum.",
        "required": [
          "productType",
          "size",
          "widthMm"
        ],
        "properties": {
          "productType": {
            "$ref": "#/components/schemas/ProductType"
          },
          "size": {
            "$ref": "#/components/schemas/SizeInput"
          },
          "widthMm": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Strip width in millimetres — required. The tracer works at this scale, so it must be the real width. To let the width follow your drawing, compute it yourself: the blank length (`capabilities.products[*].defaultLengthMm` as a first estimate, or a previous job's `dims.lengthMm` for the same size) divided by your drawn length-to-width ratio."
          },
          "attribution": {
            "$ref": "#/components/schemas/Attribution"
          }
        },
        "additionalProperties": false
      },
      "Price": {
        "type": "object",
        "description": "Fixed per product, in ILS, VAT and shipping included. Referral codes and pickup are applied by the person at checkout.",
        "required": [
          "currency",
          "base",
          "shipping",
          "total",
          "vatIncluded",
          "note"
        ],
        "properties": {
          "currency": {
            "type": "string",
            "const": "ILS"
          },
          "base": {
            "type": "number"
          },
          "shipping": {
            "type": "number"
          },
          "total": {
            "type": "number"
          },
          "vatIncluded": {
            "type": "boolean",
            "const": true
          },
          "note": {
            "type": "string"
          }
        }
      },
      "DrawingHints": {
        "type": "object",
        "description": "What to draw for this job: the piece's length ÷ width ratio, the recommended canvas, orientation, and the per-job minimum opening area (0.04 % of length × width — openings smaller than this are filled before tracing).",
        "required": [
          "ratio",
          "recommendedPixels",
          "orientation",
          "minOpeningAreaMm2"
        ],
        "properties": {
          "ratio": {
            "type": "number",
            "description": "Length ÷ width of the piece (2 decimals). Draw the piece at this ratio; the canvas may be wider. Never above `capabilities.image.cropAspect.max`: `POST /jobs` refuses a width too narrow for its length."
          },
          "recommendedPixels": {
            "type": "string",
            "description": "e.g. `1536x1024`."
          },
          "orientation": {
            "type": "string",
            "const": "landscape"
          },
          "minOpeningAreaMm2": {
            "type": "number",
            "description": "`capabilities.manufacturing.despeckleAreaFraction × lengthMm × widthMm`, in mm² (3 decimals)."
          }
        }
      },
      "JobStatus": {
        "type": "string",
        "enum": [
          "open",
          "ready",
          "approved",
          "declined",
          "expired",
          "cancelled"
        ],
        "description": "`open` (no passing submission yet) → `ready` (a design awaits the person) → `approved` | `declined`. `declined` → `ready` on a new passing submission. Any non-approved job → `expired` 7 days after it was created (submissions do not extend this), or 24 hours after a decline if that comes first (a passing submission after a decline returns it to creation + 7 days); `cancelled` after `DELETE` — still readable with the secret, refuses uploads with `409 JOB_CANCELLED`."
      },
      "SubmissionStatus": {
        "type": "string",
        "enum": [
          "processing",
          "ready",
          "failed_validation",
          "failed_processing"
        ],
        "description": "`ready` and `failed_validation` are counted and replayable by content hash; `failed_processing` (technical) is neither."
      },
      "ApprovalStatus": {
        "type": "string",
        "enum": [
          "pending",
          "approved",
          "declined"
        ]
      },
      "Approval": {
        "type": "object",
        "description": "The person's side. `url` is the page to hand over — a human page in Hebrew, right-to-left (`capabilities.approvalPage`; tell the person when that matters); `status` follows the person's decision; `expiresAt` is when the link (and the job) expires.",
        "required": [
          "url",
          "status",
          "expiresAt"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "`https://aperta-designs.com/a/{token}` — a human web page, not an API route."
          },
          "status": {
            "$ref": "#/components/schemas/ApprovalStatus"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Geometry": {
        "type": "object",
        "description": "The framed strip as it will be cut. `drawnRatio` is what the tracer measured; `stretch` is how much the pattern was stretched along the length to keep the declared width: ≈ 1 when the drawing matches the job's ratio within 5 %; between 5 % and ×1.5 the submission passes with a `PROPORTION_MISMATCH` warning; at ×1.5 or beyond (or ×1/1.5 and below) it is rejected with `PROPORTION_MISMATCH` as an error.",
        "required": [
          "lengthMm",
          "widthMm",
          "drawnRatio",
          "stretch",
          "cuts",
          "openAreaPct",
          "estWeightGrams"
        ],
        "properties": {
          "lengthMm": {
            "type": "number"
          },
          "widthMm": {
            "type": "number",
            "description": "The framed width: equal to the declared `dims.widthMm` unless the drawing's own width (at the job's length) is within 5 % of it, in which case the drawing's width is kept."
          },
          "drawnRatio": {
            "type": "number"
          },
          "stretch": {
            "type": "number"
          },
          "cuts": {
            "type": "number",
            "description": "Number of cut contours in `cutouts.svg`: every opening, plus each region cut away outside the piece's outline at its ends — not a count of openings (the sample ring with five openings reports 8)."
          },
          "openAreaPct": {
            "type": "number"
          },
          "estWeightGrams": {
            "type": "number"
          }
        }
      },
      "SubmissionFiles": {
        "type": "object",
        "description": "Absolute URLs of the two files of a `ready` submission; both need the job secret.",
        "required": [
          "cutoutsSvg",
          "previewSvg"
        ],
        "properties": {
          "cutoutsSvg": {
            "type": "string",
            "format": "uri"
          },
          "previewSvg": {
            "type": "string",
            "format": "uri"
          }
        }
      },
      "Submission": {
        "type": "object",
        "description": "One upload and its stored outcome. The body of the `200` and the `422` of `POST …/submissions` and of `GET …/submissions/{no}` — identical shape; only `status` and the filled fields differ.",
        "required": [
          "no",
          "status",
          "validation",
          "geometry",
          "files",
          "approval",
          "price",
          "createdAt",
          "durationMs"
        ],
        "properties": {
          "no": {
            "type": "integer",
            "minimum": 1
          },
          "status": {
            "$ref": "#/components/schemas/SubmissionStatus"
          },
          "validation": {
            "type": "object",
            "required": [
              "valid",
              "errors",
              "warnings"
            ],
            "properties": {
              "valid": {
                "type": "boolean",
                "description": "`true` only for `ready`."
              },
              "errors": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Finding"
                }
              },
              "warnings": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Finding"
                }
              }
            }
          },
          "geometry": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Geometry"
              },
              {
                "type": "null"
              }
            ],
            "description": "`null` unless `ready`."
          },
          "files": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/SubmissionFiles"
              },
              {
                "type": "null"
              }
            ],
            "description": "`null` unless `ready`."
          },
          "approval": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Approval"
              },
              {
                "type": "null"
              }
            ],
            "description": "The job's approval — only on the submission the job currently points at: its newest `ready` one, the design the approval page shows. `null` for every other submission: `failed_validation`, `processing`, `failed_processing`, and a `ready` one that a newer passing submission replaced (or that finished after the person had already approved an earlier one). So `approval: null` on a `ready` submission means the link does not show it; read `GET /jobs/{jobId}` for the job's current approval."
          },
          "price": {
            "$ref": "#/components/schemas/Price"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "durationMs": {
            "type": [
              "number",
              "null"
            ],
            "description": "Processing time; `null` while `processing` or when interrupted."
          }
        }
      },
      "SubmissionResponse": {
        "type": "object",
        "required": [
          "submission"
        ],
        "properties": {
          "submission": {
            "$ref": "#/components/schemas/Submission"
          }
        }
      },
      "SubmissionSummary": {
        "type": "object",
        "description": "The newest submission, summarised inside a job: codes only, no messages.",
        "required": [
          "no",
          "status",
          "errorCodes",
          "warningCodes",
          "createdAt"
        ],
        "properties": {
          "no": {
            "type": "integer",
            "minimum": 1
          },
          "status": {
            "$ref": "#/components/schemas/SubmissionStatus"
          },
          "errorCodes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ErrorCode"
            }
          },
          "warningCodes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ErrorCode"
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Job": {
        "type": "object",
        "required": [
          "id",
          "status",
          "productType",
          "dims",
          "sizeInput",
          "drawing",
          "limits",
          "latestSubmission",
          "approval",
          "price",
          "attribution",
          "createdAt",
          "expiresAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "productType": {
            "$ref": "#/components/schemas/ProductType"
          },
          "dims": {
            "type": "object",
            "description": "The strip. `lengthMm` is the flat blank computed from `sizeInput` (2 decimals); `widthMm` is the declared width.",
            "required": [
              "lengthMm",
              "widthMm",
              "gapMm",
              "thicknessMm"
            ],
            "properties": {
              "lengthMm": {
                "type": "number"
              },
              "widthMm": {
                "type": "number"
              },
              "gapMm": {
                "type": "number",
                "description": "The opening between the two ends when worn (chord), product default."
              },
              "thicknessMm": {
                "type": "number"
              }
            }
          },
          "sizeInput": {
            "$ref": "#/components/schemas/SizeInput"
          },
          "drawing": {
            "$ref": "#/components/schemas/DrawingHints"
          },
          "limits": {
            "type": "object",
            "required": [
              "submissionsLeft"
            ],
            "properties": {
              "submissionsLeft": {
                "type": "integer",
                "minimum": 0,
                "description": "Counted submissions remaining (`ready`, `failed_validation` and live `processing` count; technical failures do not); `0` once the job no longer accepts submissions (approved, expired or cancelled), whatever was used."
              }
            }
          },
          "latestSubmission": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/SubmissionSummary"
              },
              {
                "type": "null"
              }
            ]
          },
          "approval": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Approval"
              },
              {
                "type": "null"
              }
            ],
            "description": "`null` until a submission passes."
          },
          "price": {
            "$ref": "#/components/schemas/Price"
          },
          "attribution": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Attribution"
              },
              {
                "type": "null"
              }
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time",
            "description": "Creation + 7 days, and no submission moves it later. A decline brings it forward to 24 hours after the decline (never past creation + 7 days); a new passing submission after a decline sets it back to creation + 7 days; approval sets it to 180 days after the approval. Once the job is `expired` it keeps its submissions and files, still readable with its secret, and `expiresAt` is when the whole job is deleted — 180 days after the expiry. Once it is `cancelled` its files are gone and `expiresAt` is when the bare job row is deleted — 30 days after the cancel."
          }
        }
      },
      "JobResponse": {
        "type": "object",
        "required": [
          "job"
        ],
        "properties": {
          "job": {
            "$ref": "#/components/schemas/Job"
          },
          "credentials": {
            "type": "object",
            "description": "Only in the `201` of `POST /jobs`. Never returned again.",
            "required": [
              "jobSecret"
            ],
            "properties": {
              "jobSecret": {
                "type": "string",
                "pattern": "^ajs_[A-Za-z0-9_-]{43}$",
                "description": "`ajs_` + 32 random bytes in unpadded base64url."
              }
            }
          }
        }
      },
      "JobReplay": {
        "type": "object",
        "description": "The `200` of `POST /jobs` on an idempotent replay: the job's identity, current `status` and `limits`, dims and drawing hints — and nothing a secret gates. No `credentials`, no `approval` (the approval link is a capability), no `latestSubmission`, no `sizeInput`, `price` or `attribution`. Read the full job with `GET /jobs/{jobId}`.",
        "required": [
          "id",
          "status",
          "productType",
          "dims",
          "drawing",
          "limits",
          "createdAt",
          "expiresAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "productType": {
            "$ref": "#/components/schemas/ProductType"
          },
          "dims": {
            "type": "object",
            "required": [
              "lengthMm",
              "widthMm",
              "gapMm",
              "thicknessMm"
            ],
            "properties": {
              "lengthMm": {
                "type": "number"
              },
              "widthMm": {
                "type": "number"
              },
              "gapMm": {
                "type": "number"
              },
              "thicknessMm": {
                "type": "number"
              }
            }
          },
          "drawing": {
            "$ref": "#/components/schemas/DrawingHints"
          },
          "limits": {
            "type": "object",
            "required": [
              "submissionsLeft"
            ],
            "properties": {
              "submissionsLeft": {
                "type": "integer",
                "minimum": 0
              }
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "JobReplayResponse": {
        "type": "object",
        "required": [
          "job"
        ],
        "properties": {
          "job": {
            "$ref": "#/components/schemas/JobReplay"
          }
        },
        "additionalProperties": false
      },
      "AdoptRequest": {
        "type": "object",
        "description": "The body of `POST /handoff/{token}/adopt`: the `submission.no` the approval page rendered.",
        "required": [
          "submissionNo"
        ],
        "properties": {
          "submissionNo": {
            "type": "integer",
            "minimum": 1,
            "maximum": 9999,
            "description": "1–9999, like `{no}` in the submission URLs. Out of range answers `400 INVALID_REQUEST`."
          }
        },
        "additionalProperties": false
      },
      "Capabilities": {
        "type": "object",
        "description": "The response of `GET /capabilities`. `errorCodes` equals the `ErrorCode` enum; `approvalPage` says the approval page is in Hebrew. The `example` below is the live output at contract version 2026-09 (pinned by a drift test against the code).",
        "required": [
          "apiVersion",
          "contractVersion",
          "products",
          "sizing",
          "image",
          "manufacturing",
          "limits",
          "approvalPage",
          "links",
          "errorCodes"
        ],
        "properties": {
          "apiVersion": {
            "type": "string"
          },
          "contractVersion": {
            "type": "string",
            "description": "Bumped whenever a published manufacturing number changes."
          },
          "products": {
            "type": "object",
            "required": [
              "bracelet",
              "ring"
            ],
            "properties": {
              "bracelet": {
                "$ref": "#/components/schemas/ProductCapabilities"
              },
              "ring": {
                "$ref": "#/components/schemas/ProductCapabilities"
              }
            }
          },
          "sizing": {
            "type": "object",
            "required": [
              "bracelet",
              "ring"
            ],
            "properties": {
              "bracelet": {
                "type": "object",
                "required": [
                  "input",
                  "fits",
                  "wristMm",
                  "fitEaseMm"
                ],
                "properties": {
                  "input": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "fits": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/Fit"
                    }
                  },
                  "wristMm": {
                    "$ref": "#/components/schemas/Range"
                  },
                  "fitEaseMm": {
                    "type": "object",
                    "required": [
                      "snug",
                      "comfort",
                      "loose"
                    ],
                    "properties": {
                      "snug": {
                        "type": "number"
                      },
                      "comfort": {
                        "type": "number"
                      },
                      "loose": {
                        "type": "number"
                      }
                    }
                  }
                }
              },
              "ring": {
                "type": "object",
                "required": [
                  "input",
                  "usRingSize",
                  "usRingSizeStep"
                ],
                "properties": {
                  "input": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "usRingSize": {
                    "$ref": "#/components/schemas/Range"
                  },
                  "usRingSizeStep": {
                    "type": "number"
                  }
                }
              }
            }
          },
          "image": {
            "type": "object",
            "required": [
              "format",
              "maxBytes",
              "minShortSidePx",
              "maxLongSidePx",
              "maxPixels",
              "recommendedPixels",
              "orientation",
              "background",
              "ink",
              "marginPct",
              "cropAspect",
              "rules"
            ],
            "properties": {
              "format": {
                "type": "string",
                "const": "image/png"
              },
              "maxBytes": {
                "type": "number"
              },
              "minShortSidePx": {
                "type": "number"
              },
              "maxLongSidePx": {
                "type": "number"
              },
              "maxPixels": {
                "type": "number"
              },
              "recommendedPixels": {
                "type": "string"
              },
              "orientation": {
                "type": "string",
                "const": "landscape"
              },
              "background": {
                "type": "string"
              },
              "ink": {
                "type": "string"
              },
              "marginPct": {
                "type": "number"
              },
              "cropAspect": {
                "type": "object",
                "description": "Allowed width ÷ height of the piece itself (not the canvas): outside this the tracer answers `PROPORTION_MISMATCH` as an error — and it refuses such a crop before allocating anything, so an extreme proportion is a fast `422`, never a timeout.",
                "required": [
                  "min",
                  "max"
                ],
                "properties": {
                  "min": {
                    "type": "number"
                  },
                  "max": {
                    "type": "number"
                  }
                }
              },
              "rules": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Drawing constraints in plain English with the live numbers filled in — use them verbatim in your image prompt."
              }
            }
          },
          "manufacturing": {
            "type": "object",
            "required": [
              "material",
              "thicknessMm",
              "minOpeningMm",
              "minMetalMm",
              "despeckleAreaFraction",
              "note"
            ],
            "properties": {
              "material": {
                "type": "string"
              },
              "thicknessMm": {
                "type": "number"
              },
              "minOpeningMm": {
                "type": "number",
                "description": "Every opening must be at least this wide at its narrowest point (enforced)."
              },
              "minMetalMm": {
                "type": "number",
                "description": "Every strip of metal, including bridges, must be at least this wide (enforced)."
              },
              "despeckleAreaFraction": {
                "type": "number",
                "description": "Openings smaller than this fraction of length × width are filled before tracing."
              },
              "note": {
                "type": "string"
              }
            }
          },
          "limits": {
            "type": "object",
            "required": [
              "submissionsPerJob",
              "jobsPerHourPerIp",
              "globalJobsPerDay",
              "submissionsPerHourPerIp",
              "globalSubmissionsPerMinute",
              "globalSubmissionsPerDay",
              "adoptsPerHourPerAccount",
              "jobTtlDays",
              "declinedTtlHours",
              "syncTimeoutSec",
              "pollIntervalSec"
            ],
            "properties": {
              "submissionsPerJob": {
                "type": "number"
              },
              "jobsPerHourPerIp": {
                "type": "number",
                "description": "`POST /jobs` requests per hour per IP address (a sliding hour). Every request that gets past the switch counts — also one refused afterwards with `400`, and an idempotent replay."
              },
              "globalJobsPerDay": {
                "type": "number",
                "description": "`POST /jobs` requests per UTC day across all agents, counted like `jobsPerHourPerIp` (from 00:00 UTC; the count starts again at midnight UTC)."
              },
              "submissionsPerHourPerIp": {
                "type": "number",
                "description": "Submissions per hour per IP address (a sliding hour)."
              },
              "globalSubmissionsPerMinute": {
                "type": "number",
                "description": "Submissions per minute across all agents (a sliding minute)."
              },
              "globalSubmissionsPerDay": {
                "type": "number",
                "description": "Submissions per UTC day across all agents (counted from 00:00 UTC; the count starts again at midnight UTC)."
              },
              "adoptsPerHourPerAccount": {
                "type": "number",
                "description": "Approvals per hour per Aperta account — the person's limit on the approval page, not the agent's."
              },
              "jobTtlDays": {
                "type": "number"
              },
              "declinedTtlHours": {
                "type": "number"
              },
              "syncTimeoutSec": {
                "type": "number"
              },
              "pollIntervalSec": {
                "type": "number",
                "description": "Seconds to wait between two reads of `GET /jobs/{jobId}` while the person decides on the approval page. A person takes minutes or hours; polling faster only spends requests."
              }
            }
          },
          "approvalPage": {
            "type": "object",
            "description": "The approval page `/a/{token}` is a human web page in Hebrew (right-to-left). Agents should tell the person before handing over the link.",
            "required": [
              "language",
              "note"
            ],
            "properties": {
              "language": {
                "type": "string",
                "const": "he"
              },
              "note": {
                "type": "string"
              }
            }
          },
          "links": {
            "type": "object",
            "description": "Site-relative paths under https://aperta-designs.com.",
            "required": [
              "agentsPage",
              "skill",
              "openapi",
              "errors",
              "imageContract",
              "duotoneSkill"
            ],
            "properties": {
              "agentsPage": {
                "type": "string"
              },
              "skill": {
                "type": "string"
              },
              "openapi": {
                "type": "string"
              },
              "errors": {
                "type": "string"
              },
              "imageContract": {
                "type": "string"
              },
              "duotoneSkill": {
                "type": "string"
              }
            }
          },
          "errorCodes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ErrorCode"
            }
          }
        },
        "example": {
          "apiVersion": "v1",
          "contractVersion": "2026-09",
          "products": {
            "bracelet": {
              "defaultLengthMm": 160,
              "lengthLimitMm": [
                70,
                280
              ],
              "defaultWidthMm": 18,
              "widthRangeMm": [
                5,
                80
              ],
              "defaultGapMm": 25.4,
              "thicknessMm": 1.5,
              "price": {
                "currency": "ILS",
                "base": 399,
                "shipping": 35,
                "total": 434,
                "vatIncluded": true,
                "note": "Referral codes and pickup are applied by the person at checkout."
              }
            },
            "ring": {
              "defaultLengthMm": 55.5,
              "lengthLimitMm": [
                25,
                130
              ],
              "defaultWidthMm": 6,
              "widthRangeMm": [
                4,
                18
              ],
              "defaultGapMm": 3,
              "thicknessMm": 1.5,
              "price": {
                "currency": "ILS",
                "base": 299,
                "shipping": 35,
                "total": 334,
                "vatIncluded": true,
                "note": "Referral codes and pickup are applied by the person at checkout."
              }
            }
          },
          "sizing": {
            "bracelet": {
              "input": [
                "wristMm+fit",
                "lengthMm"
              ],
              "fits": [
                "snug",
                "comfort",
                "loose"
              ],
              "wristMm": [
                100,
                260
              ],
              "fitEaseMm": {
                "snug": 8,
                "comfort": 15,
                "loose": 22
              }
            },
            "ring": {
              "input": [
                "usRingSize",
                "lengthMm"
              ],
              "usRingSize": [
                3,
                15
              ],
              "usRingSizeStep": 0.5
            }
          },
          "image": {
            "format": "image/png",
            "maxBytes": 6000000,
            "minShortSidePx": 256,
            "maxLongSidePx": 8192,
            "maxPixels": 24000000,
            "recommendedPixels": "1536x1024",
            "orientation": "landscape",
            "background": "#FFFFFF",
            "ink": "#000000",
            "marginPct": 5,
            "cropAspect": {
              "min": 0.439,
              "max": 46.15
            },
            "rules": [
              "Flat black ink (#000000) on a pure white background (#FFFFFF); no grey, gradients, shadows, texture or transparency.",
              "Exactly one piece: every black region must connect to the main body. Detached regions are dropped.",
              "Black is metal; white inside the piece is a cutout. The piece's outline is its silhouette.",
              "Draw the piece horizontally, longer than wide, at the job's length-to-width ratio, with at least 5% white margin on every side.",
              "Every cutout must be at least 0.2 mm wide at its narrowest point and larger than 0.04% of the piece's area.",
              "Every strip of metal, including bridges that hold islands, must be at least 0.6 mm wide.",
              "No text rendered by the image model, no gemstones, no colour, no closed rings.",
              "The piece is cut flat and then rolled; the two short ends stay open. Ends may be rounded, pointed or shaped."
            ]
          },
          "manufacturing": {
            "material": "C260 brass, annealed (O60)",
            "thicknessMm": 1.5,
            "minOpeningMm": 0.2,
            "minMetalMm": 0.6,
            "despeckleAreaFraction": 0.0004,
            "note": "Minimum opening and minimum metal are the two enforced floors. Openings smaller than 0.04% of the piece's area are filled before tracing; the per-job value is drawing.minOpeningAreaMm2."
          },
          "limits": {
            "submissionsPerJob": 10,
            "jobsPerHourPerIp": 30,
            "globalJobsPerDay": 200,
            "submissionsPerHourPerIp": 60,
            "globalSubmissionsPerMinute": 12,
            "globalSubmissionsPerDay": 600,
            "adoptsPerHourPerAccount": 20,
            "jobTtlDays": 7,
            "declinedTtlHours": 24,
            "syncTimeoutSec": 120,
            "pollIntervalSec": 30
          },
          "approvalPage": {
            "language": "he",
            "note": "The approval page (approval.url) is a human web page in Hebrew, right-to-left, like the rest of the site. Tell the person before handing over the link; the page shows the design, the price and two buttons (approve / decline). While you wait for their decision, read GET /api/agent/v1/jobs/{jobId} at most once every 30 seconds (limits.pollIntervalSec): approval.status changes from pending to approved or declined. A person takes minutes or hours, so polling faster only spends requests."
          },
          "links": {
            "agentsPage": "/agents",
            "skill": "/skills/aperta-agent/SKILL.md",
            "openapi": "/openapi/agent-v1.json",
            "errors": "/skills/aperta-agent/references/errors.md",
            "imageContract": "/skills/aperta-agent/references/image-contract.md",
            "duotoneSkill": "/skills/aperta-duotone/SKILL.md"
          },
          "errorCodes": [
            "INVALID_FILE",
            "UNSUPPORTED_FORMAT",
            "FILE_TOO_LARGE",
            "IMAGE_TOO_SMALL",
            "IMAGE_TOO_LARGE",
            "NO_DESIGN_FOUND",
            "NOT_TWO_TONE",
            "DESIGN_TOUCHES_EDGE",
            "DISCONNECTED_REGIONS",
            "FEATURES_TOO_FINE",
            "TRACE_REJECTED",
            "MATERIAL_DISCONNECTED",
            "TRAPPED_ISLAND",
            "NECK_TOO_THIN",
            "OPENING_TOO_SMALL",
            "NECK_CHECK_SKIPPED",
            "PROPORTION_MISMATCH",
            "ISLAND_BRIDGED",
            "ISLAND_REMOVED",
            "OPENING_REMOVED",
            "SPURS_SHAVED",
            "NECK_THICKENED",
            "LENGTH_OUT_OF_RANGE",
            "WIDTH_OUT_OF_RANGE",
            "JOB_LOCKED",
            "JOB_EXPIRED",
            "JOB_CANCELLED",
            "TOO_MANY_SUBMISSIONS",
            "IDEMPOTENCY_CONFLICT",
            "ALREADY_APPROVED",
            "SUBMISSION_IN_PROGRESS",
            "SUBMISSION_SUPERSEDED",
            "VECTORIZER_UNAVAILABLE",
            "PROCESSING_FAILED",
            "PROCESSING_INTERRUPTED",
            "RATE_LIMITED",
            "INVALID_REQUEST",
            "METHOD_NOT_ALLOWED",
            "NOT_FOUND",
            "ACCOUNT_REQUIRED",
            "AUTH_UNAVAILABLE"
          ]
        }
      },
      "ProductCapabilities": {
        "type": "object",
        "required": [
          "defaultLengthMm",
          "lengthLimitMm",
          "defaultWidthMm",
          "widthRangeMm",
          "defaultGapMm",
          "thicknessMm",
          "price"
        ],
        "properties": {
          "defaultLengthMm": {
            "type": "number"
          },
          "lengthLimitMm": {
            "$ref": "#/components/schemas/Range",
            "description": "The manufacturing limits of the flat blank; the real gate for `size`."
          },
          "defaultWidthMm": {
            "type": "number"
          },
          "widthRangeMm": {
            "$ref": "#/components/schemas/Range"
          },
          "defaultGapMm": {
            "type": "number"
          },
          "thicknessMm": {
            "type": "number"
          },
          "price": {
            "$ref": "#/components/schemas/Price"
          }
        }
      },
      "Range": {
        "type": "array",
        "description": "`[min, max]`.",
        "items": {
          "type": "number"
        },
        "minItems": 2,
        "maxItems": 2
      },
      "DeleteResponse": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "DeclineResponse": {
        "type": "object",
        "required": [
          "ok",
          "status"
        ],
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "status": {
            "type": "string",
            "const": "declined"
          }
        }
      },
      "AdoptResponse": {
        "type": "object",
        "required": [
          "designId"
        ],
        "properties": {
          "designId": {
            "type": "string",
            "format": "uuid",
            "description": "The new design in the person's account; the page continues to `/design?resume={designId}`."
          }
        }
      }
    },
    "examples": {
      "JobCreated": {
        "summary": "201 — bracelet, wrist 165 mm comfort, width 18 → blank 160.4 mm, ratio 8.91",
        "value": {
          "job": {
            "id": "6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31",
            "status": "open",
            "productType": "bracelet",
            "dims": {
              "lengthMm": 160.4,
              "widthMm": 18,
              "gapMm": 25.4,
              "thicknessMm": 1.5
            },
            "sizeInput": {
              "wristMm": 165,
              "fit": "comfort"
            },
            "drawing": {
              "ratio": 8.91,
              "recommendedPixels": "1536x1024",
              "orientation": "landscape",
              "minOpeningAreaMm2": 1.155
            },
            "limits": {
              "submissionsLeft": 10
            },
            "latestSubmission": null,
            "approval": null,
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "attribution": {
              "agentPlatform": "openclaw",
              "integration": "gift-concierge",
              "skillVersion": "1.0.0",
              "apiVersion": "v1"
            },
            "createdAt": "2026-09-24T12:00:00.000Z",
            "expiresAt": "2026-10-01T12:00:00.000Z"
          },
          "credentials": {
            "jobSecret": "ajs_Qm9ndXNCYXNlNjRVcmxTZWNyZXRGb3JEb2NzT25seTA"
          }
        }
      },
      "JobReplayed": {
        "summary": "200 — same Idempotency-Key and body from the same address: identity, current status and limits, dims and drawing hints; no credentials, no approval, no latestSubmission",
        "value": {
          "job": {
            "id": "6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31",
            "status": "ready",
            "productType": "bracelet",
            "dims": {
              "lengthMm": 160.4,
              "widthMm": 18,
              "gapMm": 25.4,
              "thicknessMm": 1.5
            },
            "drawing": {
              "ratio": 8.91,
              "recommendedPixels": "1536x1024",
              "orientation": "landscape",
              "minOpeningAreaMm2": 1.155
            },
            "limits": {
              "submissionsLeft": 8
            },
            "createdAt": "2026-09-24T12:00:00.000Z",
            "expiresAt": "2026-10-01T12:00:00.000Z"
          }
        }
      },
      "JobReady": {
        "summary": "200 — a design awaits the person; two submissions used, the newest passed with one warning",
        "value": {
          "job": {
            "id": "6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31",
            "status": "ready",
            "productType": "bracelet",
            "dims": {
              "lengthMm": 160.4,
              "widthMm": 18,
              "gapMm": 25.4,
              "thicknessMm": 1.5
            },
            "sizeInput": {
              "wristMm": 165,
              "fit": "comfort"
            },
            "drawing": {
              "ratio": 8.91,
              "recommendedPixels": "1536x1024",
              "orientation": "landscape",
              "minOpeningAreaMm2": 1.155
            },
            "limits": {
              "submissionsLeft": 8
            },
            "latestSubmission": {
              "no": 2,
              "status": "ready",
              "errorCodes": [],
              "warningCodes": [
                "ISLAND_BRIDGED"
              ],
              "createdAt": "2026-09-24T12:02:14.900Z"
            },
            "approval": {
              "url": "https://aperta-designs.com/a/7Hq2kPzR4mNx",
              "status": "pending",
              "expiresAt": "2026-10-01T12:00:00.000Z"
            },
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "attribution": {
              "agentPlatform": "openclaw",
              "integration": "gift-concierge",
              "skillVersion": "1.0.0",
              "apiVersion": "v1"
            },
            "createdAt": "2026-09-24T12:00:00.000Z",
            "expiresAt": "2026-10-01T12:00:00.000Z"
          }
        }
      },
      "JobApproved": {
        "summary": "200 — the person approved; the job is locked and the design lives in their account",
        "value": {
          "job": {
            "id": "6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31",
            "status": "approved",
            "productType": "bracelet",
            "dims": {
              "lengthMm": 160.4,
              "widthMm": 18,
              "gapMm": 25.4,
              "thicknessMm": 1.5
            },
            "sizeInput": {
              "wristMm": 165,
              "fit": "comfort"
            },
            "drawing": {
              "ratio": 8.91,
              "recommendedPixels": "1536x1024",
              "orientation": "landscape",
              "minOpeningAreaMm2": 1.155
            },
            "limits": {
              "submissionsLeft": 0
            },
            "latestSubmission": {
              "no": 2,
              "status": "ready",
              "errorCodes": [],
              "warningCodes": [
                "ISLAND_BRIDGED"
              ],
              "createdAt": "2026-09-24T12:02:14.900Z"
            },
            "approval": {
              "url": "https://aperta-designs.com/a/7Hq2kPzR4mNx",
              "status": "approved",
              "expiresAt": "2027-03-23T12:17:19.800Z"
            },
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "attribution": {
              "agentPlatform": "openclaw",
              "integration": "gift-concierge",
              "skillVersion": "1.0.0",
              "apiVersion": "v1"
            },
            "createdAt": "2026-09-24T12:00:00.000Z",
            "expiresAt": "2027-03-23T12:17:19.800Z"
          }
        }
      },
      "SubmissionReady": {
        "summary": "200 — manufacturable; the engine bridged one detached island to the body (warning)",
        "value": {
          "submission": {
            "no": 2,
            "status": "ready",
            "validation": {
              "valid": true,
              "errors": [],
              "warnings": [
                {
                  "code": "ISLAND_BRIDGED",
                  "severity": "warning",
                  "message": "The engine bridged 1 detached island(s) to the nearest metal.",
                  "agent_instruction": "Check the marked spots in preview.svg. If a bridge spoils the design, redraw with that island connected where you want the bridge to be.",
                  "locations": [
                    {
                      "x": 131.82875,
                      "y": 9.02,
                      "r": 4.008900000000001
                    }
                  ],
                  "details": "island 7.0175000000000125×8.017800000000001 mm at (131.82875, 9.02) bridged with 1.5 mm over 1 mm"
                }
              ]
            },
            "geometry": {
              "lengthMm": 160.4,
              "widthMm": 18.04,
              "drawnRatio": 8.89,
              "stretch": 1,
              "cuts": 13,
              "openAreaPct": 10.71,
              "estWeightGrams": 33.06
            },
            "files": {
              "cutoutsSvg": "https://aperta-designs.com/api/agent/v1/jobs/6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31/submissions/2/cutouts.svg",
              "previewSvg": "https://aperta-designs.com/api/agent/v1/jobs/6d1f3a2e-9c4b-4b7e-8a2d-1f0c5e7b9a31/submissions/2/preview.svg"
            },
            "approval": {
              "url": "https://aperta-designs.com/a/7Hq2kPzR4mNx",
              "status": "pending",
              "expiresAt": "2026-10-01T12:00:00.000Z"
            },
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "createdAt": "2026-09-24T12:02:14.900Z",
            "durationMs": 4900
          }
        }
      },
      "SubmissionFailedValidation": {
        "summary": "422 — one opening narrower than the 0.2 mm floor, with its location on the strip",
        "value": {
          "submission": {
            "no": 1,
            "status": "failed_validation",
            "validation": {
              "valid": false,
              "errors": [
                {
                  "code": "OPENING_TOO_SMALL",
                  "severity": "error",
                  "message": "1 opening(s) are narrower than 0.2 mm.",
                  "agent_instruction": "Widen each marked opening to at least 0.2 mm at its narrowest point, or remove it. The cutter cannot open anything finer.",
                  "locations": [
                    {
                      "x": 28.07000000000008,
                      "y": 9.020000000000078,
                      "r": 3.5067
                    }
                  ],
                  "details": "1 cutout(s) smaller than the minimum opening 0.2mm at (mm): (28.1, 9.0). Enlarge or remove them."
                }
              ],
              "warnings": []
            },
            "geometry": null,
            "files": null,
            "approval": null,
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "createdAt": "2026-09-24T12:00:40.000Z",
            "durationMs": 4900
          }
        }
      },
      "SubmissionInterrupted": {
        "summary": "GET — the upload's worker was cut off mid-run; after 3 minutes the row reads as interrupted: resend the same bytes (not counted)",
        "value": {
          "submission": {
            "no": 2,
            "status": "failed_processing",
            "validation": {
              "valid": false,
              "errors": [
                {
                  "code": "PROCESSING_INTERRUPTED",
                  "severity": "error",
                  "message": "The previous submission was interrupted before it finished.",
                  "agent_instruction": "Resubmit the same bytes to the same job; the interrupted attempt is not counted and the image is processed again from scratch. Only an image that already reached ready or failed_validation is answered from its stored result instead of being processed again.",
                  "retryable": true
                }
              ],
              "warnings": []
            },
            "geometry": null,
            "files": null,
            "approval": null,
            "price": {
              "currency": "ILS",
              "base": 299,
              "shipping": 35,
              "total": 334,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "createdAt": "2026-09-27T12:00:00.000Z",
            "durationMs": null
          }
        }
      },
      "RateLimited": {
        "summary": "429 — the per-IP hourly submissions bucket; Retry-After: 3600 (the minute bucket says 60, the daily one the seconds to midnight UTC)",
        "value": {
          "error": {
            "code": "RATE_LIMITED",
            "message": "Too many requests: the per-IP limit of 60 submissions per hour was reached.",
            "agent_instruction": "Wait 3600 seconds (the Retry-After header carries the same number), then retry. Space submissions out and never resubmit an unchanged image.",
            "retryable": true
          }
        }
      },
      "JobLocked": {
        "summary": "409 — the job is approved",
        "value": {
          "error": {
            "code": "JOB_LOCKED",
            "message": "The job is approved and locked.",
            "agent_instruction": "Do not resubmit to this job: the person already approved a design and it now lives in their account. Open a new job for a new design."
          }
        }
      },
      "JobExpired": {
        "summary": "409 — the job expired",
        "value": {
          "error": {
            "code": "JOB_EXPIRED",
            "message": "The job has expired.",
            "agent_instruction": "Open a new job and resubmit the image. Unapproved jobs expire 7 days after they were created (submissions do not extend this), or 24 hours after the person declined, whichever comes first."
          }
        }
      },
      "JobCancelled": {
        "summary": "409 — the agent cancelled this job; it takes no submissions, and what was submitted to it is gone",
        "value": {
          "error": {
            "code": "JOB_CANCELLED",
            "message": "The job was cancelled.",
            "agent_instruction": "Open a new job: this one was cancelled with DELETE and accepts no submissions. Nothing submitted to it can be recovered: the cancel deleted its submissions and images, and GET with its secret only shows status cancelled."
          }
        }
      },
      "JobLockedOnCancel": {
        "summary": "409 — DELETE on an approved job: nothing to cancel",
        "value": {
          "error": {
            "code": "JOB_LOCKED",
            "message": "The job is approved and locked.",
            "agent_instruction": "Nothing to cancel: the person already approved this design and it lives in their account. Do not retry the DELETE."
          }
        }
      },
      "JobExpiredOnCancel": {
        "summary": "409 — DELETE on an expired job: nothing to cancel, and no new job to open",
        "value": {
          "error": {
            "code": "JOB_EXPIRED",
            "message": "The job has expired.",
            "agent_instruction": "Nothing to cancel: the job already expired; Aperta keeps its images for 180 days and then deletes them. Do not open a new job unless the person wants a new design."
          }
        }
      },
      "TooManySubmissions": {
        "summary": "409 — all counted submissions used",
        "value": {
          "error": {
            "code": "TOO_MANY_SUBMISSIONS",
            "message": "This job used all 10 submissions.",
            "agent_instruction": "Open a new job. Before submitting again, fix every error from the last response instead of retrying the same image."
          }
        }
      },
      "IdempotencyConflict": {
        "summary": "409 — same Idempotency-Key, different body",
        "value": {
          "error": {
            "code": "IDEMPOTENCY_CONFLICT",
            "message": "This Idempotency-Key was already used with a different request body.",
            "agent_instruction": "Use a fresh Idempotency-Key for a new request, or resend exactly the same body to replay the original response."
          }
        }
      },
      "IdempotencyConflictDeadJob": {
        "summary": "409 — same Idempotency-Key and body, but the job it created was cancelled or has expired",
        "value": {
          "error": {
            "code": "IDEMPOTENCY_CONFLICT",
            "message": "This Idempotency-Key belongs to a job that is cancelled or expired.",
            "agent_instruction": "Use a fresh Idempotency-Key for a new job; this one stays bound to the dead job until it is purged."
          }
        }
      },
      "AlreadyApproved": {
        "summary": "409 — another account approved first",
        "value": {
          "error": {
            "code": "ALREADY_APPROVED",
            "message": "The design was already approved by another account.",
            "agent_instruction": "Nothing to do: the design belongs to the person who approved it. If this is the same person, ask them to sign in with the account they used the first time."
          }
        }
      },
      "SubmissionInProgress": {
        "summary": "409 — the same bytes are already being processed in this job as submission 3; Retry-After: 10",
        "value": {
          "error": {
            "code": "SUBMISSION_IN_PROGRESS",
            "message": "The same image is still being processed in this job. Submission 3 is processing these bytes.",
            "agent_instruction": "Do not resubmit these bytes: an earlier upload of the same image is still running as submission 3. Wait 10 seconds, then read GET /api/agent/v1/jobs/<jobId>/submissions/3 (the number in this message) until its status leaves processing; that response is this image's result. The running attempt counts once, not twice.",
            "retryable": true
          }
        }
      },
      "SubmissionSuperseded": {
        "summary": "409 — the approval page approved a submission that is no longer the job's current one (handoff only)",
        "value": {
          "error": {
            "code": "SUBMISSION_SUPERSEDED",
            "message": "The submission on the approval page is no longer the job's current design.",
            "agent_instruction": "This answer goes to the approval page, not to the agent: a newer passing submission replaced the design the person was looking at. Reload the page so the person sees and decides on the current design; nothing was approved."
          }
        }
      },
      "FileTooLarge": {
        "summary": "413 — over 6 MB",
        "value": {
          "error": {
            "code": "FILE_TOO_LARGE",
            "message": "The file exceeds 6 MB.",
            "agent_instruction": "Re-export the PNG under 6 MB. A flat black-on-white 1536x1024 8-bit PNG is about 200 KB: drop the alpha channel and 16-bit depth, and do not embed colour profiles."
          }
        }
      },
      "UnsupportedFormat": {
        "summary": "415 — not PNG",
        "value": {
          "error": {
            "code": "UNSUPPORTED_FORMAT",
            "message": "Only PNG is accepted.",
            "agent_instruction": "Send the image as image/png: either a raw body with Content-Type: image/png, or multipart/form-data with an `image` field. Do not send JPEG or WebP; lossy ringing corrupts the black/white boundary the tracer relies on."
          }
        }
      },
      "InvalidFile": {
        "summary": "400 — bytes are not a PNG",
        "value": {
          "error": {
            "code": "INVALID_FILE",
            "message": "The file is not a valid PNG. not a PNG: signature mismatch",
            "agent_instruction": "Send a well-formed PNG file: the 8-byte PNG signature followed by an IHDR chunk. Re-export the image from your image model or converter as PNG; do not rename a JPEG and do not send a data URL."
          }
        }
      },
      "ImageTooSmall": {
        "summary": "400 — short side under 256 px",
        "value": {
          "error": {
            "code": "IMAGE_TOO_SMALL",
            "message": "The image is too small to trace. short side is 200px; minimum 256px",
            "agent_instruction": "Export at least 256 px on the short side; 1536x1024 landscape is recommended."
          }
        }
      },
      "ImageTooLarge": {
        "summary": "400 — over 8192 px or 24 megapixels",
        "value": {
          "error": {
            "code": "IMAGE_TOO_LARGE",
            "message": "The image is too large. long side is 9000px; maximum 8192px",
            "agent_instruction": "Keep the long side at or below 8192 px and the total at or below 24 megapixels; 1536x1024 is plenty."
          }
        }
      },
      "InvalidRequest": {
        "summary": "400 — a misspelled field (`widthmm` for `widthMm`): the message names the missing key and the unknown one",
        "value": {
          "error": {
            "code": "INVALID_REQUEST",
            "message": "The request is malformed. widthMm: Required; body: Unrecognized key(s) in object: 'widthmm'",
            "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
          }
        }
      },
      "InvalidSizeKey": {
        "summary": "400 — a misspelled key inside `size`: the message names it and the three forms",
        "value": {
          "error": {
            "code": "INVALID_REQUEST",
            "message": "The request is malformed. size: expected { wristMm, fit? } (fit: snug | comfort | loose), { usRingSize } or { lengthMm }; received 'wrist_mm'",
            "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
          }
        }
      },
      "WidthOutOfRange": {
        "summary": "400 — widthMm outside the product's range",
        "value": {
          "error": {
            "code": "WIDTH_OUT_OF_RANGE",
            "message": "Width 81 mm is outside 5–80 mm for a bracelet.",
            "agent_instruction": "Send widthMm between 5 and 80 mm. widthMm is required: to let the width follow your drawing, compute it yourself as the blank length divided by the drawn length-to-width ratio (capabilities lists defaultWidthMm per product) and send that number."
          }
        }
      },
      "WidthTooNarrow": {
        "summary": "400 — a width too narrow for the blank length (280 mm at 5 mm would be drawn at 56:1, past the tracer's 46.15:1)",
        "value": {
          "error": {
            "code": "WIDTH_OUT_OF_RANGE",
            "message": "Width 5 mm is outside 6.1–80 mm for a bracelet. A 280 mm blank needs at least 6.1 mm: the tracer cannot trace a piece more than 46.15 times longer than wide.",
            "agent_instruction": "Send widthMm between 6.1 and 80 mm. widthMm is required: to let the width follow your drawing, compute it yourself as the blank length divided by the drawn length-to-width ratio (capabilities lists defaultWidthMm per product) and send that number."
          }
        }
      },
      "LengthOutOfRange": {
        "summary": "400 — the computed blank is outside the product's limits (the message ends with the buildability gate's own sentence)",
        "value": {
          "error": {
            "code": "LENGTH_OUT_OF_RANGE",
            "message": "Blank length 24 mm is outside 70–280 mm for a bracelet. Blank length 24mm is outside what a bracelet can be (70–280mm) — check the measurement that produced it.",
            "agent_instruction": "Check the measurement: wristMm is the wrist circumference in millimetres (not centimetres), usRingSize is a US ring size, lengthMm is the flat blank length. Send a value whose blank falls between 70 and 280 mm."
          }
        }
      },
      "NotFound": {
        "summary": "404 — unknown job id or wrong secret (identical by design)",
        "value": {
          "error": {
            "code": "NOT_FOUND",
            "message": "No such job.",
            "agent_instruction": "Check the job id and the Authorization: Bearer secret from the job's creation response. Unknown ids and wrong secrets are indistinguishable by design; if the secret is lost, open a new job."
          }
        }
      },
      "SubmissionNotFound": {
        "summary": "404 — the job and secret are fine, but there is no submission with that number",
        "value": {
          "error": {
            "code": "NOT_FOUND",
            "message": "No such submission.",
            "agent_instruction": "No submission 7 in this job; read GET /jobs/{id} → latestSubmission for the newest number."
          }
        }
      },
      "FileNotFound": {
        "summary": "404 — the submission exists but has no file in its current status",
        "value": {
          "error": {
            "code": "NOT_FOUND",
            "message": "No such file for this submission.",
            "agent_instruction": "Submission 1 has no cutouts.svg: its status is failed_validation; only ready submissions have files."
          }
        }
      },
      "MethodNotAllowed": {
        "summary": "405 — the method is not supported on this route; Allow lists the supported ones",
        "value": {
          "error": {
            "code": "METHOD_NOT_ALLOWED",
            "message": "The HTTP method is not allowed on this path; allowed: GET, HEAD.",
            "agent_instruction": "Use one of the methods in the Allow header (GET, HEAD). The OpenAPI document lists the method for every path; do not retry with the same method."
          }
        }
      },
      "RateLimiterUnavailable": {
        "summary": "429 — no bucket was full: the rate limiter's store was unreachable and the API fails closed; Retry-After: 30",
        "value": {
          "error": {
            "code": "RATE_LIMITED",
            "message": "The rate-limit check was temporarily unavailable, so the request was refused as a precaution; no limit was reached.",
            "agent_instruction": "The rate limiter itself was unavailable, not exhausted. Wait 30 seconds (the Retry-After header carries the same number) and retry the same request once; if it fails again, wait a few minutes before the next attempt.",
            "retryable": true
          }
        }
      },
      "AccountRequired": {
        "summary": "401 — the person is not signed in (approval page only)",
        "value": {
          "error": {
            "code": "ACCOUNT_REQUIRED",
            "message": "A signed-in person is required.",
            "agent_instruction": "This endpoint is for the person, not the agent: send them the approval URL and let them sign in on Aperta."
          }
        }
      },
      "AuthUnavailable": {
        "summary": "503 — sign-in service down; Retry-After: 30",
        "value": {
          "error": {
            "code": "AUTH_UNAVAILABLE",
            "message": "The sign-in service is temporarily unavailable.",
            "agent_instruction": "Wait 30 seconds and retry the same request.",
            "retryable": true
          }
        }
      },
      "VectorizerUnavailable": {
        "summary": "502 — the tracing service is unreachable or timed out; Retry-After: 30",
        "value": {
          "error": {
            "code": "VECTORIZER_UNAVAILABLE",
            "message": "The tracing service is unavailable.",
            "agent_instruction": "Wait 30 seconds and resubmit the same bytes to the same job. Attempts that fail this way do not count against the job's submissions.",
            "retryable": true
          }
        }
      },
      "ProcessingFailed502": {
        "summary": "502 — the tracer answered something that is not a result, or tracing/framing failed on our side; Retry-After: 30",
        "value": {
          "error": {
            "code": "PROCESSING_FAILED",
            "message": "Processing failed on our side.",
            "agent_instruction": "Retry once with the same bytes after 30 seconds. If it fails again, re-export the PNG (flat black on white, 8-bit, no alpha) and submit that; do not loop.",
            "retryable": true
          }
        }
      },
      "ProcessingFailed500": {
        "summary": "500 — unexpected failure on any route; no internal detail is exposed; Retry-After: 30",
        "value": {
          "error": {
            "code": "PROCESSING_FAILED",
            "message": "Processing failed on our side.",
            "agent_instruction": "Retry the same request once after 30 seconds; if it fails again, stop and report the response to the person. Do not loop.",
            "retryable": true
          }
        }
      },
      "MissingWidth": {
        "summary": "400 — widthMm is missing (it is required in v1)",
        "value": {
          "error": {
            "code": "INVALID_REQUEST",
            "message": "The request is malformed. widthMm: Required",
            "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
          }
        }
      },
      "InvalidIdempotencyKey": {
        "summary": "400 — the Idempotency-Key header is shorter than 8 or longer than 128 printable characters",
        "value": {
          "error": {
            "code": "INVALID_REQUEST",
            "message": "The request is malformed. Idempotency-Key must be 8–128 printable ASCII characters; a UUID is recommended.",
            "agent_instruction": "Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field."
          }
        }
      },
      "UnknownEndpoint": {
        "summary": "404 — the path is not an endpoint of this API (a typo, or a method on the wrong path); the job, if any, is fine",
        "value": {
          "error": {
            "code": "NOT_FOUND",
            "message": "No such endpoint.",
            "agent_instruction": "Check the method and the path. The agent API has /capabilities, /jobs, /jobs/{jobId}, /jobs/{jobId}/submissions, /jobs/{jobId}/submissions/{no} and its /cutouts.svg and /preview.svg, all under /api/agent/v1. Do not open a new job because of this error."
          }
        }
      },
      "SubmissionProcessing": {
        "summary": "GET — still running: empty findings, null geometry/files/approval, durationMs null",
        "value": {
          "submission": {
            "no": 2,
            "status": "processing",
            "validation": {
              "valid": false,
              "errors": [],
              "warnings": []
            },
            "geometry": null,
            "files": null,
            "approval": null,
            "price": {
              "currency": "ILS",
              "base": 299,
              "shipping": 35,
              "total": 334,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "createdAt": "2026-09-27T12:00:00.000Z",
            "durationMs": null
          }
        }
      },
      "JobDeclined": {
        "summary": "200 — the person declined; the link lives 24 hours (never past creation + 7 days) and wakes up when a new submission passes",
        "value": {
          "job": {
            "id": "1b4e7c2a-5d3f-4e6a-9b8c-2d1e0f3a4b5c",
            "status": "declined",
            "productType": "bracelet",
            "dims": {
              "lengthMm": 160.4,
              "widthMm": 18,
              "gapMm": 25.4,
              "thicknessMm": 1.5
            },
            "sizeInput": {
              "wristMm": 165,
              "fit": "comfort"
            },
            "drawing": {
              "ratio": 8.91,
              "recommendedPixels": "1536x1024",
              "orientation": "landscape",
              "minOpeningAreaMm2": 1.155
            },
            "limits": {
              "submissionsLeft": 9
            },
            "latestSubmission": {
              "no": 1,
              "status": "ready",
              "errorCodes": [],
              "warningCodes": [],
              "createdAt": "2026-09-25T12:00:25.000Z"
            },
            "approval": {
              "url": "https://aperta-designs.com/a/Rk3vW9pQ2sLm",
              "status": "declined",
              "expiresAt": "2026-09-26T13:00:00.000Z"
            },
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "attribution": {
              "agentPlatform": "openclaw",
              "integration": "gift-concierge",
              "skillVersion": "1.0.0",
              "apiVersion": "v1"
            },
            "createdAt": "2026-09-25T12:00:00.000Z",
            "expiresAt": "2026-09-26T13:00:00.000Z"
          }
        }
      },
      "JobCancelledView": {
        "summary": "200 — a job you cancelled stays readable by its secret; no submissions, no approval",
        "value": {
          "job": {
            "id": "8e2d4f6a-1c3b-4a5d-8e7f-9a0b1c2d3e4f",
            "status": "cancelled",
            "productType": "bracelet",
            "dims": {
              "lengthMm": 160.4,
              "widthMm": 18,
              "gapMm": 25.4,
              "thicknessMm": 1.5
            },
            "sizeInput": {
              "wristMm": 165,
              "fit": "comfort"
            },
            "drawing": {
              "ratio": 8.91,
              "recommendedPixels": "1536x1024",
              "orientation": "landscape",
              "minOpeningAreaMm2": 1.155
            },
            "limits": {
              "submissionsLeft": 0
            },
            "latestSubmission": null,
            "approval": null,
            "price": {
              "currency": "ILS",
              "base": 399,
              "shipping": 35,
              "total": 434,
              "vatIncluded": true,
              "note": "Referral codes and pickup are applied by the person at checkout."
            },
            "attribution": null,
            "createdAt": "2026-09-26T12:00:00.000Z",
            "expiresAt": "2026-10-27T12:04:04.570Z"
          }
        }
      }
    }
  }
}
