{"openapi":"3.0.3","info":{"title":"Capigo Public API","description":"Public API documentation for Capigo Developer Platform. Integrate your external tools with Capigo tasks, boards, and members. See `apps/platform/docs/api-v1-changelog.md` for version history. A key is pinned to one workspace, lists several workspaces, or reaches every workspace of its owner. A pinned key needs no `X-Tenant-Code`. A key that lists several workspaces, or reaches all of them, names the workspace per call in `X-Tenant-Code`: the workspace must be one the key reaches and its owner still belongs to, or the call is `403` `AUTH_TENANT_MISMATCH`. A key that lists its workspaces must send the header on every route except `GET /health` and `GET /tenants`, or the call is `400` `AUTH_TENANT_REQUIRED`.","version":"2.5.0","contact":{"name":"Capigo Developer Support"}},"servers":[{"url":"http://localhost:3999/api/v1","description":"Local Development Server"},{"url":"https://platform.capigo.app/api/v1","description":"Development API Server"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"CSK","description":"Enter your Capigo Secret Key (starts with `csk_`)"}},"schemas":{"DeviceCodeRequest":{"type":"object","required":["client_id","client_secret"],"properties":{"client_id":{"type":"string","description":"The assistant's registered client id."},"client_secret":{"type":"string","description":"The assistant's registered secret."}}},"DeviceCodeResponse":{"type":"object","properties":{"device_code":{"type":"string","description":"Secret held by the assistant and sent back when polling."},"user_code":{"type":"string","example":"BCDF-GHJK","description":"Eight letters in two groups of four, from `BCDFGHJKLMNPQRSTVWXZ`."},"verification_uri":{"type":"string","format":"uri"},"verification_uri_complete":{"type":"string","format":"uri","description":"`verification_uri` with the user code, to send to the person."},"expires_in":{"type":"integer","example":600,"description":"Seconds the request lives."},"interval":{"type":"integer","example":5,"description":"Minimum seconds between polls."}}},"DeviceTokenRequest":{"type":"object","required":["client_id","client_secret","device_code"],"properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"device_code":{"type":"string"}}},"DeviceTokenResponse":{"type":"object","properties":{"api_key":{"type":"string","description":"The `csk_` key. It is returned once and never shown again."},"expires_at":{"type":"string","format":"date-time","description":"90 days after the key was created."},"scope":{"type":"object","properties":{"kind":{"type":"string","enum":["tenant","allowlist","global"]},"tenant_codes":{"type":"array","items":{"type":"string"},"description":"The workspaces the person chose. Empty for a global key."}}}}},"DeviceFlowError":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_client","invalid_request","authorization_pending","slow_down","access_denied","expired_token","server_error"]}}},"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"INVALID_QUERY_PARAMS"},"message":{"type":"string","example":"Invalid query parameters"}}}}},"MetaPagination":{"type":"object","description":"Pagination envelope for list endpoints. A `page` past the last one is not an error: the response is 200 with an empty `data`, the real `total` and `has_more: false`.","properties":{"page":{"type":"integer","example":1},"limit":{"type":"integer","example":20},"total":{"type":"integer","example":100},"has_more":{"type":"boolean","example":true}}},"InviteMemberResponse":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","status","expires_at","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","description":"Current status of the invitation: `pending`, or `cancelled` on a replay after a cancel."},"expires_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"},"token":{"type":"string","description":"Accept-link secret. Present only in the `201` that created the invitation."}}}}},"PublicTaskCommentResponse":{"type":"object","required":["id","author","kind","content","ui_data","attachments","parent_id","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"author":{"type":"object","required":["id","name","type"],"properties":{"id":{"type":"string"},"name":{"type":"string","description":"display_name → agent name → 'System'. Never the member email."},"type":{"type":"string","enum":["user","agent"]}}},"kind":{"type":"string","enum":["comment","activity","card","artifact"]},"content":{"type":"string","nullable":true},"ui_data":{"type":"object","nullable":true,"additionalProperties":true,"description":"Raw structured event data for activity messages; not pre-rendered."},"attachments":{"type":"array","items":{"type":"object","required":["id","file_name","mime_type","size_bytes"],"properties":{"id":{"type":"string"},"file_name":{"type":"string"},"mime_type":{"type":"string"},"size_bytes":{"type":"integer"}}}},"parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"PublicTaskStatusHistoryEntry":{"type":"object","description":"One recorded status change on a task — the row `mission.task_status_histories` holds, not the activity message the same change also posts.","required":["id","task_id","from_status","to_status","changed_by","changed_at"],"properties":{"id":{"type":"string","format":"uuid"},"task_id":{"type":"string","format":"uuid"},"from_status":{"type":"string","nullable":true,"description":"The status left behind. `null` on the entry a task's creation wrote — there was no status to leave.","example":"To-Do"},"to_status":{"type":"string","description":"The status entered.","example":"Doing"},"changed_by":{"type":"object","nullable":true,"description":"Who made the change, in the same person-ref shape a task's `owner` and `assignee` use. `null` when the row records no actor — a change made by a removed member or by a system path. The email address itself is never exposed; a member with no display name falls back to the address's local part, the same rule `owner` and `assignee` follow.","properties":{"id":{"type":"string"},"display_name":{"type":"string"},"member_code":{"type":"string","nullable":true}}},"changed_at":{"type":"string","format":"date-time"}}},"PublicTaskStatusHistoryListResponse":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskStatusHistoryEntry"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}},"PostTaskCommentRequest":{"type":"object","description":"Request body for creating a task comment. At least one of content or attachments must be provided.","properties":{"content":{"type":"string","description":"Comment text. Can be empty if attachments are provided."},"attachments":{"type":"object","description":"Pre-uploaded attachment references, keyed by the lowercase attachment UUID returned at upload. Each key must name an attachment uploaded in this workspace and not deleted, or the request fails with E9748. The stored comment shows the uploaded file's name, type and size, whatever values are sent here.","additionalProperties":{"type":"object","required":["file_name","mime_type","size_bytes"],"properties":{"file_name":{"type":"string"},"mime_type":{"type":"string"},"size_bytes":{"type":"integer"}}}}},"anyOf":[{"required":["content"]},{"required":["attachments"]}]},"PublicBrandResponse":{"type":"object","required":["id","name","logo_url"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"logo_url":{"type":"string","format":"uri","nullable":true}}},"PublicCategoryResponse":{"type":"object","required":["id","name","parent_id"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"parent_id":{"type":"string","format":"uuid","nullable":true}}},"PublicProductTypeResponse":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true}}},"HealthCheckResponse":{"type":"object","required":["ok","timestamp"],"properties":{"ok":{"type":"boolean","example":true},"timestamp":{"type":"string","format":"date-time"}}},"PublicPropertyDefinitionResponse":{"type":"object","required":["id","key","label"],"properties":{"id":{"type":"string","format":"uuid"},"key":{"type":"string"},"label":{"type":"string"}}},"PublicUnitResponse":{"type":"object","required":["id","name","abbreviation"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"abbreviation":{"type":"string"}}},"PublicTenantResponse":{"type":"object","required":["tenant_code","name","role","joined_at"],"properties":{"tenant_code":{"type":"string","example":"demo"},"name":{"type":"string","example":"Demo Tenant"},"role":{"type":"string","enum":["owner","member"],"example":"owner"},"joined_at":{"type":"string","format":"date-time"}}},"PublicMemberResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"123e4567-e89b-12d3-a456-426614174000"},"display_name":{"type":"string","example":"John Doe"},"email":{"type":"string","format":"email","example":"john@example.com"},"member_code":{"type":"string","nullable":true,"description":"Employee code, unique within the tenant; null when none is set","example":"NV001"},"role":{"type":"string","enum":["owner","member"],"example":"member"},"avatar_url":{"type":"string","nullable":true,"example":"https://example.com/avatar.jpg"},"title":{"type":"string","nullable":true,"description":"Job title / chức danh (from extra_data.job_title)","example":"Software Engineer"},"department":{"type":"string","nullable":true,"description":"Department (from extra_data.department)","example":"Engineering"},"birthday":{"type":"string","nullable":true,"description":"Birthday / ngày sinh, ISO date string (from extra_data.birth_date)","example":"1990-01-01"}}},"PublicBoardResponse":{"type":"object","required":["code"],"properties":{"id":{"type":"string","format":"uuid","example":"550e8400-e29b-41d4-a716-446655440000"},"code":{"type":"string","description":"Board code, `{PREFIX}-{N}`. Drawn from the same per-tenant counter as task codes, so a code names exactly one task or board. Set by the server and immutable; never accepted in requests.","example":"ACMEC-457"},"name":{"type":"string","example":"Product Roadmap"},"description":{"type":"string","nullable":true,"example":"Q1 2024 Roadmap"},"is_public":{"type":"boolean","example":true},"created_at":{"type":"string","format":"date-time"}}},"PublicBoardListResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"To Do"},"position":{"type":"integer","example":0}}},"PublicBoardDetailResponse":{"allOf":[{"$ref":"#/components/schemas/PublicBoardResponse"},{"type":"object","properties":{"lists":{"type":"array","items":{"$ref":"#/components/schemas/PublicBoardListResponse"}}}}]},"PublicBoardMemberResponse":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","example":"123e4567-e89b-12d3-a456-426614174000"},"email":{"type":"string","format":"email","example":"john@example.com"},"display_name":{"type":"string","example":"John Doe"},"role":{"type":"string","enum":["owner","member"],"example":"member"},"avatar_url":{"type":"string","nullable":true,"example":"https://example.com/avatar.jpg"}}},"PublicTaskResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"TASK-123"},"title":{"type":"string","example":"Fix login bug"},"description":{"type":"string","nullable":true},"status":{"type":"string","enum":["Pending","To-Do","Doing","Done","Cancelled"],"example":"To-Do"},"priority":{"type":"string","nullable":true,"enum":["Low","Normal","High","Urgent"],"example":"High"},"assignee":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"display_name":{"type":"string"},"member_code":{"type":"string","nullable":true}}},"owner":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"display_name":{"type":"string"},"member_code":{"type":"string","nullable":true}}},"responsible_type":{"type":"string","enum":["human","agent"],"description":"Who is responsible: `human` (a person in `assignee`) or `agent` (the key in `assigned_agent_key`). The two are exclusive — a task has one or the other.","example":"agent"},"assigned_agent_key":{"type":"string","nullable":true,"description":"Agent key when `responsible_type` is `agent`; null for a person-owned task. Move it with POST /mission/tasks/{id}/actions/assign-agent.","example":"task-pilot"},"board_id":{"type":"string","format":"uuid","nullable":true},"board_list_id":{"type":"string","format":"uuid","nullable":true},"due_date":{"type":"string","format":"date-time","nullable":true},"parent":{"nullable":true,"description":"Parent task reference. Populated for subtasks returned via GET /mission/tasks/{id}/subtasks or code/{code}/subtasks; null elsewhere.","allOf":[{"$ref":"#/components/schemas/PublicTaskRef"}]},"has_subtasks":{"type":"boolean","description":"True if this task has one or more non-archived subtasks. Fetch them via GET /v1/mission/tasks?parent_task_id={thisTaskId}."},"subtask_progress":{"type":"object","nullable":true,"description":"Subtask progress, so a client does not have to list the subtasks to compute 7/10. Null for a subtask (a subtask has no children), and null when the count could not be resolved — a failed count is never reported as a number. Otherwise done / total over active (non-archived) subtasks, done 0 / total 0 for a top-level task with none. List endpoints resolve them in one batched call per tenant, never one query per task.","properties":{"done":{"type":"integer","description":"How many active subtasks are done."},"total":{"type":"integer","description":"How many active subtasks the task has."}},"required":["done","total"]},"attachments":{"type":"array","description":"Flat metadata for this task's own attachments (not comment attachments — see the comments endpoint for those). No download URL is included; fetch one via GET /mission/tasks/{id}/attachments/{attachmentId}/download.","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"file_name":{"type":"string"},"mime_type":{"type":"string"},"size_bytes":{"type":"integer"}}}},"meta_data":{"type":"object","description":"Auto-computed metadata. Server-authoritative — callers cannot override.","properties":{"url":{"type":"string","format":"uri","description":"Canonical task URL built from tenant code and task code.","example":"https://acme-corp.capigo.app/mission/tasks/TASK-123"}}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"followers":{"type":"array","description":"Followers list — user IDs resolved to display refs. Empty when the task has no followers or resolution failed; partiality is signalled via meta.followers_partial.","items":{"type":"object","properties":{"id":{"type":"string"},"display_name":{"type":"string"},"member_code":{"type":"string","nullable":true}}}},"labels":{"type":"array","description":"The task's labels, each with its name and colour. Empty when the task has no labels.","items":{"$ref":"#/components/schemas/PublicTaskLabel"}}}},"LabelNames":{"type":"array","maxItems":20,"items":{"type":"string"},"description":"Label names, at most 20 per list (more is 400). A name is 1–30 letters, digits, spaces, hyphens or underscores; any other is 400 `E9117` and nothing is written."},"PublicTaskLabel":{"type":"object","description":"A task label. `color` is the palette key the app draws it with, one of `sky`, `orange`, `green`, `rose`, `teal`, `amber`, `violet`, `indigo`, `fuchsia` or `lime`.","properties":{"name":{"type":"string"},"color":{"type":"string"}}},"PublicCatalogTag":{"type":"object","description":"A catalog tag of the tenant. `name` keeps the spelling it was first created with; names that differ only in letter case or surrounding spaces are the same tag. `color` is the palette key the system assigned when the tag was created, one of `sky`, `orange`, `green`, `rose`, `teal`, `amber`, `violet`, `indigo`, `fuchsia` or `lime`. Catalog tags and task labels are separate sets.","required":["name","color"],"properties":{"name":{"type":"string","example":"Hàng Tết"},"color":{"type":"string","example":"sky"}}},"PublicTaskRef":{"type":"object","required":["id","code","title"],"properties":{"id":{"type":"string","format":"uuid","example":"550e8400-e29b-41d4-a716-446655440000"},"code":{"type":"string","example":"TASK-123"},"title":{"type":"string","example":"Alert: System Monitor"}}},"PublicMediaEntry":{"type":"object","description":"A single product/variant image. `url` is a signed, time-limited Supabase Storage URL (valid until `expiresAt`) — re-fetch the parent product/variant to get a fresh link after it expires.","required":["id","url","position","isDefault","altText","expiresAt"],"properties":{"id":{"type":"string","format":"uuid","description":"Underlying product_media row id."},"url":{"type":"string","format":"uri","description":"Signed storage URL to the image, valid until `expiresAt`.","example":"https://xxxxx.supabase.co/storage/v1/object/sign/product-media/acme/3fa85f64-5717-4562-b3fc-2c963f66afa6.jpg?token=..."},"position":{"type":"integer","description":"Gallery ordering, ascending."},"isDefault":{"type":"boolean","description":"Whether this image is the default/cover image."},"altText":{"type":"string","nullable":true,"description":"Accessibility alt text, if set by the uploader."},"expiresAt":{"type":"string","format":"date-time","description":"When `url` stops working. Re-fetch the parent product/variant for a fresh link."}}},"PublicProductNote":{"type":"object","description":"An internal staff note on a product. Read-only via the Public API — no POST/PATCH/PUT/DELETE operation accepts or mutates notes.","properties":{"id":{"type":"string","format":"uuid"},"product_id":{"type":"string","format":"uuid"},"content":{"type":"string","maxLength":2000},"tag":{"type":"object","properties":{"type":{"type":"string","enum":["sales","warehouse","internal","custom"]},"label":{"type":"string","description":"Present only when `type` is `\"custom\"`."}}},"author":{"type":"object","description":"The note's author — a full snapshot captured at write time (id, name, job_title, department), not a live lookup. Stays attributable even after the author leaves the tenant or changes role; job_title/department reflect the author's role at the time the note was written, not necessarily their current one.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"Snapshot of the author's display name captured at write time."},"job_title":{"type":"string","nullable":true,"description":"Snapshot of the author's job title at write time. `null` if none was set."},"department":{"type":"string","nullable":true,"description":"Snapshot of the author's department at write time. `null` if none was set."}}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PublicProductVariantResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Default"},"sku":{"type":"string","nullable":true,"example":"SKU-001"},"barcode":{"type":"string","nullable":true},"price":{"type":"number","nullable":true,"example":99000},"compare_at_price":{"type":"number","nullable":true},"currency":{"type":"string","example":"VND"},"weight":{"type":"number","nullable":true},"dimensions":{"type":"object","nullable":true,"description":"Physical dimensions of the variant. Keys use shorthand notation: l (length), w (width), h (height).","properties":{"l":{"type":"number","nullable":true,"description":"Length"},"w":{"type":"number","nullable":true,"description":"Width"},"h":{"type":"number","nullable":true,"description":"Height"}}},"option1":{"type":"string","nullable":true},"option2":{"type":"string","nullable":true},"option3":{"type":"string","nullable":true},"variant_type":{"type":"string","example":"simple"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"manufacturer_code":{"type":"string","nullable":true,"description":"Manufacturer-assigned code for this variant."},"legacy_code":{"type":"string","nullable":true,"description":"Code carried over from a legacy system (e.g. ERP migration)."},"status":{"type":"string","enum":["active","inactive"],"description":"SKU lifecycle status. Defaults to 'active'."},"extra_data":{"type":"object","nullable":true,"additionalProperties":true,"description":"Arbitrary key-value metadata for this variant."},"own_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"},"description":"The variant's own tags. A tag the variant also inherits from its product is listed in `inherited_tags` only. `[]` when none."},"inherited_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"},"description":"The tags the variant inherits from its product: the product's tags at read time, added and removed on the product. `[]` when none."},"product":{"description":"Denormalized parent product reference. Null when the variant is looked up via SKU and the parent product cannot be resolved (should not occur under normal operation).","oneOf":[{"$ref":"#/components/schemas/PublicVariantProductRef"}],"nullable":true},"media":{"type":"array","items":{"$ref":"#/components/schemas/PublicMediaEntry"},"description":"The variant's own assigned images (position ASC), falling back to the parent product's single default image when the variant has none of its own. `[]` when neither exists."}}},"PublicVariantProductRef":{"type":"object","description":"Minimal parent product reference embedded in variant responses.","properties":{"id":{"type":"string","format":"uuid","example":"550e8400-e29b-41d4-a716-446655440000"},"name":{"type":"string","example":"Áo thun nam"},"slug":{"type":"string","example":"ao-thun-nam-001"},"aliases":{"type":"array","items":{"type":"string"},"example":["ao-thun","tee"]},"tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"},"description":"The parent product's tags, as names and colours."}}},"PublicProductMedia":{"type":"object","description":"One media item of a product, as the media endpoints answer. `url` is a signed, time-limited link valid until `expires_at`; no storage path is ever returned. `position` is the 1-based place among the product's live items.","required":["id","product_id","type","filename","file_size","width","height","content_version","position","is_default","alt_text","variant_ids","url","expires_at","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"product_id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["image","video"]},"filename":{"type":"string","description":"Trimmed and lower-cased, the form the duplicate check compares."},"file_size":{"type":"integer","description":"Bytes."},"width":{"type":"integer","nullable":true,"description":"Null for a video."},"height":{"type":"integer","nullable":true,"description":"Null for a video."},"content_version":{"type":"integer","description":"Starts at 1 and rises each time the content is replaced."},"position":{"type":"integer","minimum":1},"is_default":{"type":"boolean"},"alt_text":{"type":"string","nullable":true},"variant_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Live variants this item is linked to."},"url":{"type":"string","description":"Signed link, valid until `expires_at`."},"expires_at":{"type":"string","format":"date-time"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"ProductPropertyEntry":{"type":"object","required":["label","value","position","definition_id"],"properties":{"label":{"type":"string","minLength":1,"maxLength":50,"example":"Origin"},"value":{"type":"string","minLength":1,"maxLength":1000,"example":"Vietnam"},"position":{"type":"integer","description":"Place in the product's order. Unique within the product."},"definition_id":{"type":"string","format":"uuid","nullable":true,"description":"The live property definition this property follows, or `null` for an ad-hoc property."}}},"ProductPropertyInput":{"type":"object","maxProperties":100,"description":"A map from property key (`^[a-z0-9][a-z0-9_]{0,49}$`) to its entry, at most 100 keys. A key that matches a live property definition of the tenant follows that definition and takes its label; any label sent for it is ignored. Any other key is ad hoc to this product and needs a `label` (1 to 50 characters). A sent `definition_id` is ignored. `value` is 1 to 1000 characters. `position` is an integer, unique within the map, and gives the entry its place in the product's order; the map's key order carries no meaning. Omit `properties` on update to keep the stored set; send `{}` to clear it. Omit it on create for none. A refusal writes nothing: a malformed map is `400` `E9499`, more than 100 keys `E9497`, a bad key `E9494`, a bad label `E9495`, a bad value `E9496`, a bad or repeated position `E9498`.","additionalProperties":{"type":"object","required":["value","position"],"properties":{"label":{"type":"string","minLength":1,"maxLength":50,"description":"Required for a key that matches no live definition. Ignored for a key that does: the definition's label is stored."},"value":{"type":"string","minLength":1,"maxLength":1000},"position":{"type":"integer","description":"Unique within the map."}}}},"PublicProductResponse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Áo thun nam"},"slug":{"type":"string","example":"ao-thun-nam-001"},"description":{"type":"string","nullable":true},"status":{"type":"string","example":"ACTIVE","enum":["DRAFT","ACTIVE","ARCHIVED"]},"currency":{"type":"string","example":"VND"},"aliases":{"type":"array","items":{"type":"string"},"example":[]},"tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"},"description":"The product's catalog tags, as names and colours. Change them one at a time through `POST` and `DELETE /pcms/products/{id}/tags/{name}`."},"is_deleted":{"type":"boolean","example":false},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"brand":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"category":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"parent_id":{"type":"string","format":"uuid","nullable":true},"parent":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}}}},"product_type":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"unit":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"options":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"position":{"type":"integer"},"values":{"type":"array","items":{"type":"string"}}}}},"variants":{"type":"array","items":{"$ref":"#/components/schemas/PublicProductVariantResponse"}},"media":{"type":"array","items":{"$ref":"#/components/schemas/PublicMediaEntry"},"description":"Every active image for this product, ordered by gallery position. `[]` when the product has no images."},"notes":{"type":"array","items":{"$ref":"#/components/schemas/PublicProductNote"},"description":"Internal staff notes on this product, for every tag. Read-only — no write operation accepts or mutates this field. `[]` when the product has no notes."},"properties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/ProductPropertyEntry"},"description":"The product's properties, keyed by property key. `{}` when it has none. Each entry carries its label, value, position (its place in the product's order; the map's key order carries no meaning) and `definition_id`: the property definition it follows, or `null` for an ad-hoc property of this product only. A relabel of a definition, or a delete (the entry becomes ad hoc, keeping its key, label and value), reaches every product carrying it and counts as a product change for `updated_since` and the `catalog:product:updated` event."}}},"CreateBoardListItem":{"type":"object","required":["name"],"properties":{"name":{"type":"string","maxLength":200},"limit":{"type":"integer","minimum":0,"nullable":true,"description":"Optional WIP limit for the list."}}},"CreateBoardListRequest":{"type":"object","required":["tenant_code","name"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code where the list will be created."},"name":{"type":"string","maxLength":200},"limit":{"type":"integer","minimum":0,"nullable":true,"description":"Optional WIP limit for the list."}}},"UpdateBoardListRequest":{"type":"object","required":["tenant_code"],"description":"A field update, or a reorder, never both. At least one of `name`, `limit` or `is_archived` is required for an update; a reorder names exactly one of `after_list_id` / `before_list_id` and no field, because the two directions are two different services and one request is one write.","properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code."},"name":{"type":"string","maxLength":200},"limit":{"type":"integer","minimum":0,"nullable":true,"description":"Optional WIP limit for the list. Pass null to clear it."},"is_archived":{"type":"boolean","description":"Archive (true) or unarchive (false) the list."},"after_list_id":{"type":"string","format":"uuid","description":"Move the list to sit directly behind this list on the same board. Mutually exclusive with `before_list_id` and with every field above. The anchor must be one of the board's own visible lists: an anchor on another board, or an archived one, answers 404 — a list no read reports is not a place to sit behind."},"before_list_id":{"type":"string","format":"uuid","description":"Move the list to sit directly in front of this list on the same board. Mutually exclusive with `after_list_id` and with every field above, and the same anchor rule applies."}}},"UpdateBoardListItem":{"type":"object","description":"A list operation within an update-board request. Omit `id` to create a new list; provide `id` to update an existing list.","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","maxLength":200},"limit":{"type":"integer","minimum":0,"nullable":true},"is_archived":{"type":"boolean"}}},"UpdateBoardRequest":{"type":"object","required":["tenant_code"],"properties":{"tenant_code":{"type":"string"},"name":{"type":"string","maxLength":200},"description":{"type":"string","maxLength":10000,"nullable":true},"is_public":{"type":"boolean"},"lists":{"type":"array","maxItems":50,"description":"Optional list operations. Entries without `id` create new lists; entries with `id` update existing lists.","items":{"$ref":"#/components/schemas/UpdateBoardListItem"}}}},"CreateBoardRequest":{"type":"object","required":["tenant_code","name"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code where the board will be created."},"name":{"type":"string","maxLength":200},"description":{"type":"string","maxLength":10000,"nullable":true},"is_public":{"type":"boolean","description":"Visibility flag. Defaults to true when omitted."},"lists":{"type":"array","maxItems":50,"description":"Optional lists to create under the board (up to 50).","items":{"$ref":"#/components/schemas/CreateBoardListItem"}}}},"AddBoardMemberRequest":{"type":"object","required":["tenant_code","user_ids"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code where the board lives."},"user_ids":{"type":"array","minItems":1,"maxItems":50,"items":{"type":"string","format":"uuid"},"description":"Auth user ids of the tenant members to add — the id `GET /mission/boards/{id}/members` reports and the per-member routes address. 1 to 50 in one request. A uuid names one member whatever case it is written in, and the answer reports the lowercase form. A member the board already holds is skipped, never an error."},"role":{"type":"string","enum":["owner","member"],"default":"member","description":"Board role for the whole request. Defaults to `member`."}}},"UpdateBoardMemberRequest":{"type":"object","required":["tenant_code","role"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code where the board lives."},"role":{"type":"string","enum":["owner","member"],"description":"The new board role."}}},"CreateTaskRequest":{"type":"object","required":["tenant_code","title"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code where the task will be created."},"title":{"type":"string","maxLength":500},"description":{"type":"string","maxLength":10000,"nullable":true},"assignee_id":{"type":"string","format":"uuid","nullable":true},"board_id":{"type":"string","format":"uuid","nullable":true},"board_list_id":{"type":"string","format":"uuid","nullable":true},"after_task_id":{"type":"string","format":"uuid","description":"Place the new card directly behind this task in `board_list_id`. Send at most one of `after_task_id` and `position`, and never without `board_list_id`. `null` is refused — send `position: \"top\"` to place the card first. Omit both to append."},"position":{"type":"string","enum":["top"],"description":"Place the new card first in `board_list_id`. Requires `board_list_id`, and is mutually exclusive with `after_task_id`."},"follower_ids":{"type":"array","items":{"type":"string","format":"uuid"},"nullable":true},"label_names":{"allOf":[{"$ref":"#/components/schemas/LabelNames"}],"description":"Labels to put on the new task; a name no label has yet creates one."},"priority":{"type":"string","enum":["Low","Normal","High","Urgent"],"nullable":true},"status":{"type":"string","enum":["Pending","To-Do","Doing","Done","Cancelled"],"nullable":true},"due_date":{"type":"string","format":"date-time","nullable":true}}},"CreateSubtasksRequest":{"type":"object","required":["tenant_code","subtasks"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code."},"subtasks":{"type":"array","minItems":1,"maxItems":25,"description":"List of subtask items to create (1-25 items).","items":{"$ref":"#/components/schemas/SubtaskItem"}}}},"SubtaskItem":{"type":"object","required":["title"],"properties":{"title":{"type":"string","maxLength":500},"description":{"type":"string","maxLength":10000,"nullable":true},"assignee_id":{"type":"string","format":"uuid","nullable":true},"due_date":{"type":"string","format":"date","nullable":true},"priority":{"type":"string","enum":["Low","Normal","High","Urgent"],"nullable":true},"status":{"type":"string","enum":["Pending","To-Do","Doing","Done","Cancelled"],"nullable":true}}},"PublicAttachmentDownloadResponse":{"type":"object","required":["url","file_name","mime_type","size_bytes","expires_at"],"properties":{"url":{"type":"string","format":"uri","description":"Short-lived signed download URL. Forces Content-Disposition: attachment with the original file name."},"file_name":{"type":"string"},"mime_type":{"type":"string"},"size_bytes":{"type":"integer"},"expires_at":{"type":"string","format":"date-time","description":"When the signed URL expires (5 minutes from response time)."}}},"PublicTaskAttachment":{"type":"object","description":"Flat metadata for one task attachment. No download URL — fetch one via GET /mission/tasks/{id}/attachments/{attachmentId}/download. This is the same shape a task read returns in its `attachments` array, and the body of a successful upload.","required":["id","file_name","mime_type","size_bytes"],"properties":{"id":{"type":"string","format":"uuid","description":"Attachment id. Keep it: it is what the download and remove endpoints address."},"file_name":{"type":"string"},"mime_type":{"type":"string"},"size_bytes":{"type":"integer"}}},"PublicWmsWarehouseReference":{"type":"object","required":["id","code","name"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","description":"Unique warehouse code (e.g. WH-001)"},"name":{"type":"string","description":"Warehouse display name"}}},"PublicWmsProductVariantReference":{"type":"object","description":"A line's product variant. `id` is always the line's own variant id. When the variant was deleted or is not visible, `sku`, `barcode`, `variant_name`, `product_id` and `product_name` are null.","required":["id"],"properties":{"id":{"type":"string","format":"uuid"},"sku":{"type":"string","nullable":true},"barcode":{"type":"string","nullable":true},"variant_name":{"type":"string","nullable":true},"product_id":{"type":"string","format":"uuid","nullable":true},"product_name":{"type":"string","nullable":true}}},"PublicWmsLocationReference":{"type":"object","required":["id","code","name"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"name":{"type":"string"}}},"PublicWmsStatusHistoryEntry":{"type":"object","required":["status","created_at","created_by_name"],"properties":{"status":{"type":"string","example":"DRAFT"},"created_at":{"type":"string","format":"date-time"},"created_by_name":{"type":"string","description":"Display name of the user who performed the status change"}}},"PublicWmsLocationNode":{"type":"object","required":["id","code","name","category","type","is_active"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true,"enum":["ZONE","AISLE","RACK","SHELF","BIN","SHELF_BIN",null]},"type":{"type":"string"},"is_active":{"type":"boolean"},"parent_id":{"type":"string","format":"uuid","nullable":true},"children":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsLocationNode"}}}},"PublicWmsWarehouseResponse":{"type":"object","required":["id","code","name","type","currency","location_strategy","is_active","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"WH-001"},"name":{"type":"string","example":"Main Warehouse"},"type":{"type":"string","example":"user"},"address":{"type":"object","nullable":true,"additionalProperties":true},"currency":{"type":"string","example":"VND"},"location_strategy":{"type":"string","example":"ZONE_BIN"},"is_active":{"type":"boolean"},"settings":{"type":"object","nullable":true,"additionalProperties":true},"extra_data":{"type":"object","nullable":true,"additionalProperties":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"PublicWmsWarehouseDetailResponse":{"type":"object","required":["id","code","name","type","currency","location_strategy","is_active","created_at","updated_at","location_count","location_hierarchy"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"name":{"type":"string"},"type":{"type":"string"},"address":{"type":"object","nullable":true,"additionalProperties":true},"currency":{"type":"string"},"location_strategy":{"type":"string"},"is_active":{"type":"boolean"},"settings":{"type":"object","nullable":true,"additionalProperties":true},"extra_data":{"type":"object","nullable":true,"additionalProperties":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"location_count":{"type":"integer","description":"Total number of locations in this warehouse"},"location_hierarchy":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsLocationNode"}}}},"WmsAvailabilityCheckLineInput":{"type":"object","description":"Exactly one of variant_id, sku or barcode is required.","required":["demand"],"oneOf":[{"required":["variant_id"]},{"required":["sku"]},{"required":["barcode"]}],"properties":{"variant_id":{"type":"string"},"sku":{"type":"string"},"barcode":{"type":"string"},"demand":{"type":"number","exclusiveMinimum":true,"minimum":0,"description":"Quantity requested, in the variant's base unit. Must be greater than 0."}}},"WmsAvailabilityCheckRequest":{"type":"object","required":["warehouses","lines"],"properties":{"warehouses":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string"},"description":"Warehouse codes to check. Every code must resolve to an active warehouse of the tenant."},"lines":{"type":"array","minItems":1,"maxItems":100,"items":{"$ref":"#/components/schemas/WmsAvailabilityCheckLineInput"}}}},"PublicWmsAvailabilityWarehouseResult":{"type":"object","required":["code","name","score","promisable_qty"],"properties":{"code":{"type":"string"},"name":{"type":"string"},"score":{"type":"integer","minimum":0,"maximum":100},"promisable_qty":{"type":"number","nullable":true,"description":"min(on-hand, cap); null when the warehouse covers the demand."}}},"PublicWmsAvailabilityCheckLine":{"type":"object","required":["line_no","found","warehouses"],"properties":{"line_no":{"type":"integer","description":"1-based position of the line in the request."},"found":{"type":"boolean"},"warehouses":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsAvailabilityWarehouseResult"},"description":"Empty when found is false."}}},"PublicWmsInboundReceiptLine":{"type":"object","required":["id","product_variant","qty_expected","qty_received","unit_cost_denied"],"properties":{"id":{"type":"string","format":"uuid"},"product_variant":{"$ref":"#/components/schemas/PublicWmsProductVariantReference"},"qty_expected":{"type":"number"},"qty_received":{"type":"number"},"unit_cost":{"type":"number","nullable":true,"description":"null when no cost was recorded, or when unit_cost_denied is true (scope.financial_view required)."},"unit_cost_denied":{"type":"boolean","description":"true when unit_cost is null because the caller lacks scope.financial_view, never because the receipt has no recorded cost."},"variance_notes":{"type":"string","nullable":true},"variance_type":{"type":"string","nullable":true}}},"PublicWmsInboundReceiptResponse":{"type":"object","required":["id","code","status","line_count","total_qty_expected","total_qty_received","total_value","total_value_denied","created_at","updated_at","warehouse"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"IR-001"},"status":{"type":"string","description":"Receipt status. `VERIFIED` is the terminal, posted state: the receipt is posted to the stock ledger. A cancelled receipt ends in `CANCELLED`.","enum":["DRAFT","PLANNED","RECEIVING","VERIFIED","CANCELLED"]},"rel_type":{"type":"string","nullable":true},"external_ref":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"line_count":{"type":"integer"},"total_qty_expected":{"type":"number"},"total_qty_received":{"type":"number"},"total_value":{"type":"number","nullable":true,"description":"null when total_value_denied is true (scope.financial_view required), or when the value could not be determined. Never 0 for a denied caller."},"total_value_denied":{"type":"boolean","description":"true when total_value is null because the caller lacks scope.financial_view."},"verified_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"}}},"PublicWmsWriteEchoUnavailable":{"type":"object","description":"Stand-in for a committed write whose document could not be read back. The write succeeded, so the status stays 200/201: do not retry it, GET the path in `retrieve` for the full document. It is never returned for a cost denial — a masked cost is reported by total_value_denied / unit_cost_denied on the document itself.","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"echo":{"type":"string","enum":["unavailable"]},"retrieve":{"type":"object","properties":{"method":{"type":"string","enum":["GET"]},"path":{"type":"string"}},"required":["method","path"]}},"required":["id","code","echo","retrieve"]},"PublicWmsInboundReceiptDetailResponse":{"type":"object","required":["id","code","status","line_count","total_qty_expected","total_qty_received","total_value","total_value_denied","created_at","updated_at","warehouse","lines","status_history","owner","owner_connector","cancel_locked","sender"],"properties":{"sender":{"type":"object","nullable":true,"readOnly":true,"additionalProperties":true,"description":"The sender the order system sent with its request, returned exactly as it was stored, or null when none was sent or no order system requested the receipt. It may be an empty object or lack `name`. Read-only: no request writes it here. Always present.","properties":{"name":{"type":"string","description":"The sender's name as the order system sent it."}}},"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"status":{"type":"string","description":"Receipt status. `VERIFIED` is the terminal, posted state: the receipt is posted to the stock ledger. A cancelled receipt ends in `CANCELLED`."},"rel_type":{"type":"string","nullable":true},"external_ref":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"line_count":{"type":"integer"},"total_qty_expected":{"type":"number"},"total_qty_received":{"type":"number"},"total_value":{"type":"number","nullable":true,"description":"null when total_value_denied is true (scope.financial_view required), or when the value could not be determined. Never 0 for a denied caller."},"total_value_denied":{"type":"boolean","description":"true when total_value is null because the caller lacks scope.financial_view."},"verified_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},"owner":{"type":"object","nullable":true,"description":"The member that created the document, or null when none is recorded. Always present.","required":["id"],"properties":{"id":{"type":"string","format":"uuid"}}},"owner_connector":{"type":"object","nullable":true,"description":"The Integration Hub connection whose order-system request created the receipt, or null when no external system owns it. Never set together with `owner`. Set once at create; never changes. Always present.","required":["id"],"properties":{"id":{"type":"string","format":"uuid"}}},"cancel_locked":{"type":"boolean","description":"True when only the owner can cancel the document: the member in `owner`, or, for a receipt an order system requested, that system through the Hub or through the member its connection names. Set once at create; never changes."},"parent":{"type":"object","nullable":true,"description":"The warehouse transfer this document belongs to, or null for a free-standing document. A document with a parent cancels only through that transfer.","required":["type","id","code"],"properties":{"type":{"type":"string","enum":["warehouse_transfer"]},"id":{"type":"string","format":"uuid"},"code":{"type":"string"}}},"lines":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsInboundReceiptLine"}},"status_history":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsStatusHistoryEntry"}}}},"PublicWmsOutboundShipmentLine":{"type":"object","required":["id","product_variant","qty_ordered","qty_shipped","unit_cost","unit_cost_denied"],"properties":{"id":{"type":"string","format":"uuid"},"product_variant":{"allOf":[{"$ref":"#/components/schemas/PublicWmsProductVariantReference"}],"description":"The line's variant. `id` is always the line's variant id. In write echoes (create, update, actions) the other fields are always null. On GET they are null only when the variant was deleted or is not visible."},"qty_ordered":{"type":"number"},"qty_shipped":{"type":"number"},"qty_can_send":{"type":"number","nullable":true,"description":"The quantity warehouse staff reported they can send, always below `qty_ordered`. null when the line carries no shortage report."},"shortage_note":{"type":"string","nullable":true,"description":"Free-text note of the shortage report. null when none."},"shortage_reported_by":{"type":"string","format":"uuid","nullable":true,"description":"User id of the member who reported the shortage. null when the line carries no report."},"shortage_reported_at":{"type":"string","format":"date-time","nullable":true,"description":"When the shortage was reported. null when the line carries no report."},"unit_cost":{"type":"number","nullable":true,"description":"Cost of goods shipped per unit for this line, gated on `scope.financial_view` at the shipment's warehouse. Measured by `cost_method` (FIFO by default) from the stock-ledger entries recorded for this line when the shipment posted. null when unit_cost_denied is true, when the shipment has not posted, when the line shipped nothing, or for shipments posted before the release that added these fields (their ledger entries name no line). Write endpoints (create, update, actions) always return null here, with unit_cost_denied false: they never return a posted shipment."},"unit_cost_denied":{"type":"boolean","description":"true when unit_cost is null because the caller lacks scope.financial_view, never because the shipment has no recorded cost."}}},"PublicWmsOutboundShipmentResponse":{"type":"object","required":["id","code","status","line_count","total_qty_ordered","total_qty_shipped","created_at","updated_at","warehouse","waybill"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"SO-001"},"status":{"type":"string","enum":["DRAFT","PLANNED","AWAITING_EXPORT","EXPORTED","CANCELLED"]},"rel_type":{"type":"string","nullable":true},"external_ref":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"line_count":{"type":"integer"},"total_qty_ordered":{"type":"number"},"total_qty_shipped":{"type":"number"},"exported_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},"waybill":{"allOf":[{"$ref":"#/components/schemas/PublicWmsOutboundShipmentWaybill"}],"nullable":true,"description":"The shipment's waybill, or null when it has none. Always present."}}},"PublicWmsOutboundShipmentWaybill":{"type":"object","description":"The waybill a carrier collects the shipment under. Every field is optional. A cancelled shipment still returns the waybill it held.","required":["carrier_code","carrier_name","tracking_number"],"properties":{"carrier_code":{"type":"string","nullable":true,"enum":["GHN","GHTK","VIETTEL_POST","VNPOST","JT_EXPRESS","SPX","NINJA_VAN","BEST_EXPRESS","AHAMOVE","GRAB_EXPRESS","LALAMOVE","SELI_EXPRESS","OTHER",null],"description":"`OTHER` is any carrier not listed, named by `carrier_name`."},"carrier_name":{"type":"string","nullable":true},"tracking_number":{"type":"string","nullable":true}}},"WmsOutboundShipmentWaybillInput":{"type":"object","description":"A waybill to set. It replaces the whole waybill: an omitted field is cleared, and a key not listed here is ignored. At least one of `carrier_code` or `tracking_number` is required. Writable while the shipment is DRAFT, PLANNED or AWAITING_EXPORT. A refusal is 409: E9683 the shipment is EXPORTED or CANCELLED, E9684 the tracking number is on another shipment of the same carrier (no carrier counts as one carrier; letter case ignored; cancelled shipments excluded), E9685 the waybill is empty or a field is not valid.","properties":{"carrier_code":{"type":"string","nullable":true,"enum":["GHN","GHTK","VIETTEL_POST","VNPOST","JT_EXPRESS","SPX","NINJA_VAN","BEST_EXPRESS","AHAMOVE","GRAB_EXPRESS","LALAMOVE","SELI_EXPRESS","OTHER",null]},"carrier_name":{"type":"string","nullable":true,"minLength":1,"maxLength":100},"tracking_number":{"type":"string","nullable":true,"minLength":1,"maxLength":64,"description":"Trimmed."}}},"PublicWmsOutboundShipmentDetailResponse":{"type":"object","required":["id","code","status","line_count","total_qty_ordered","total_qty_shipped","total_value","total_value_denied","created_at","updated_at","warehouse","lines","status_history","owner","owner_connector","cancel_locked","waybill","destination"],"properties":{"destination":{"type":"object","nullable":true,"readOnly":true,"additionalProperties":true,"description":"The destination the order system sent with its request, returned exactly as it was stored, or null when none was sent or no order system requested the shipment. Free-form: its keys are whatever the order system sent. Read-only: no request writes it here. Always present."},"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"status":{"type":"string"},"rel_type":{"type":"string","nullable":true},"external_ref":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"line_count":{"type":"integer"},"total_qty_ordered":{"type":"number"},"total_qty_shipped":{"type":"number"},"total_value":{"type":"number","nullable":true,"description":"Cost of goods shipped for the whole shipment: the sum of the line values (shipped quantity x cost, each rounded to 6 decimal places), gated on `scope.financial_view` at the shipment's warehouse and measured by `cost_method`. A posted line that shipped nothing has unit_cost null but counts as 0 here. null when total_value_denied is true, or when a line's value is unknown (not posted, or posted before the release that added these fields). Never 0 for a denied caller. Write endpoints (create, update, actions) always return null here, with total_value_denied false."},"total_value_denied":{"type":"boolean","description":"true when total_value is null because the caller lacks scope.financial_view."},"exported_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},"owner":{"type":"object","nullable":true,"description":"The member that created the document, or null when none is recorded. Always present.","required":["id"],"properties":{"id":{"type":"string","format":"uuid"}}},"owner_connector":{"type":"object","nullable":true,"description":"The Integration Hub connection whose order-system request created the shipment, or null when no external system owns it. Never set together with `owner`. Set once at create; never changes. Always present.","required":["id"],"properties":{"id":{"type":"string","format":"uuid"}}},"cancel_locked":{"type":"boolean","description":"True when only the owner can cancel the document: the member in `owner`, or, for a shipment an order system requested, that system through the Hub or through the member its connection names. Set once at create; never changes."},"waybill":{"allOf":[{"$ref":"#/components/schemas/PublicWmsOutboundShipmentWaybill"}],"nullable":true,"description":"The shipment's waybill, or null when it has none. Always present."},"parent":{"type":"object","nullable":true,"description":"The warehouse transfer this document belongs to, or null for a free-standing document. A document with a parent cancels only through that transfer.","required":["type","id","code"],"properties":{"type":{"type":"string","enum":["warehouse_transfer"]},"id":{"type":"string","format":"uuid"},"code":{"type":"string"}}},"lines":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentLine"}},"status_history":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsStatusHistoryEntry"}}}},"PublicWmsInternalTransferLine":{"type":"object","required":["id","product_variant","qty","planned_qty","from_location","to_location"],"properties":{"id":{"type":"string","format":"uuid"},"product_variant":{"$ref":"#/components/schemas/PublicWmsProductVariantReference"},"qty":{"type":"number","nullable":true,"description":"The quantity that actually moved. null while the transfer is a DRAFT: a draft line holds only a plan, in `planned_qty`, and has no actual yet. It is set from that plan at the DRAFT -> MOVING lock."},"planned_qty":{"type":"number","nullable":true,"description":"The plan the line was given, and the only quantity a DRAFT line holds: the quantity that moved is empty until the transfer starts, when it takes this value. Frozen from then on, so the plan can be compared with what actually moved. null when no plan was recorded."},"from_location":{"$ref":"#/components/schemas/PublicWmsLocationReference"},"to_location":{"$ref":"#/components/schemas/PublicWmsLocationReference"}}},"PublicWmsInternalTransferResponse":{"type":"object","required":["id","code","status","line_count","total_qty","created_at","updated_at","warehouse"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"IT-001"},"status":{"type":"string","enum":["DRAFT","MOVING","MOVED","CANCELLED"]},"line_count":{"type":"integer"},"total_qty":{"type":"number"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"}}},"PublicWmsInternalTransferDetailResponse":{"type":"object","required":["id","code","status","line_count","total_qty","created_at","updated_at","warehouse","lines","status_history"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"status":{"type":"string","enum":["DRAFT","MOVING","MOVED","CANCELLED"]},"line_count":{"type":"integer"},"total_qty":{"type":"number"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},"lines":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsInternalTransferLine"}},"status_history":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsStatusHistoryEntry"}}}},"CreateTaskWithSubtasksTask":{"type":"object","required":["title"],"properties":{"title":{"type":"string","maxLength":500},"description":{"type":"string","maxLength":10000,"nullable":true},"assignee_id":{"type":"string","format":"uuid","nullable":true},"assigned_agent_key":{"type":"string","nullable":true,"description":"AI agent key. Cannot be provided together with assignee_id."},"due_date":{"type":"string","format":"date","nullable":true},"board_id":{"type":"string","format":"uuid","nullable":true},"board_list_id":{"type":"string","format":"uuid","nullable":true},"follower_ids":{"type":"array","items":{"type":"string","format":"uuid"}},"priority":{"type":"string","enum":["Low","Normal","High","Urgent"],"nullable":true},"status":{"type":"string","enum":["Pending","To-Do","Doing","Done","Cancelled"]}}},"CreateTaskWithSubtasksRequest":{"type":"object","required":["tenant_code","task","subtasks"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code."},"task":{"$ref":"#/components/schemas/CreateTaskWithSubtasksTask"},"subtasks":{"type":"array","minItems":1,"maxItems":25,"description":"List of subtask items to create (1-25 items).","items":{"$ref":"#/components/schemas/SubtaskItem"}}}},"PublicWmsWarehouseTransferTimelineEntry":{"type":"object","required":["source","status","created_at","created_by_name"],"properties":{"source":{"type":"string","enum":["coordination","outbound","inbound"]},"status":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by_name":{"type":"string"},"notes":{"type":"string","nullable":true}}},"PublicWmsWarehouseTransferLine":{"type":"object","required":["id","product_variant","qty_requested","is_unplanned"],"properties":{"id":{"type":"string","format":"uuid"},"product_variant":{"$ref":"#/components/schemas/PublicWmsProductVariantReference"},"qty_requested":{"type":"number","description":"Quantity the transfer requested. 0 on an unplanned line. Unchanged when the shipment's ordered quantity is later reduced."},"qty_exported":{"type":"number","nullable":true,"description":"Quantity the source shipped, summed over the shipment's lines for this variant (0 when it has none). null until the shipment is EXPORTED, and on a cancelled transfer; never a false 0."},"qty_received":{"type":"number","nullable":true,"description":"Quantity the destination received, summed over every receipt line of this variant, split lines included (0 when it has none). null until the receipt is VERIFIED, and on a cancelled transfer."},"is_unplanned":{"type":"boolean","description":"true for a variant on the verified receipt that the transfer never requested. Such a line appears only once the receipt is VERIFIED, with qty_requested 0 and qty_exported 0."}}},"PublicWmsWarehouseTransferResponse":{"type":"object","required":["id","code","status","source_warehouse","destination_warehouse","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"WT-SRC-00001"},"status":{"type":"string","enum":["DRAFT","PENDING_APPROVAL","APPROVED","IN_TRANSIT","COMPLETED","REJECTED","CANCELLED"]},"source_warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},"destination_warehouse":{"oneOf":[{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},{"type":"null"}]},"created_at":{"type":"string","format":"date-time"},"exported_at":{"type":"string","format":"date-time","nullable":true},"destination_receipt_code":{"type":"string","nullable":true},"hours_in_transit":{"type":"number","nullable":true},"is_abnormal":{"type":"boolean","nullable":true},"abnormal_resolved":{"type":"boolean","nullable":true}}},"PublicWmsWarehouseTransferDetailResponse":{"type":"object","description":"GET /wms/warehouse-transfers/{code} and the create/PATCH/action responses. Deliberately its own schema rather than extending PublicWmsWarehouseTransferResponse (the list item): the detail response replaces the list item's flat destination_receipt_code with the structured generated_inbound_receipt/generated_outbound_shipment references below, so it does not carry destination_receipt_code.","required":["id","code","status","direction","source_warehouse","destination_warehouse","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","example":"WT-SRC-00001"},"status":{"type":"string","enum":["DRAFT","PENDING_APPROVAL","APPROVED","IN_TRANSIT","COMPLETED","REJECTED","CANCELLED"]},"direction":{"type":"string","enum":["PUSH","PULL"]},"transfer_ref":{"type":"string"},"external_ref":{"type":"string","nullable":true},"notes":{"type":"string","nullable":true},"source_warehouse":{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},"destination_warehouse":{"oneOf":[{"$ref":"#/components/schemas/PublicWmsWarehouseReference"},{"type":"null"}]},"created_at":{"type":"string","format":"date-time"},"submitted_at":{"type":"string","format":"date-time","nullable":true},"approved_at":{"type":"string","format":"date-time","nullable":true},"completed_at":{"type":"string","format":"date-time","nullable":true},"rejected_at":{"type":"string","format":"date-time","nullable":true},"rejection_reason":{"type":"string","nullable":true},"cancelled_at":{"type":"string","format":"date-time","nullable":true},"cancellation_reason":{"type":"string","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true},"exported_at":{"type":"string","format":"date-time","nullable":true},"hours_in_transit":{"type":"number","nullable":true},"is_abnormal":{"type":"boolean","nullable":true},"abnormal_resolved":{"type":"boolean","nullable":true},"generated_outbound_shipment":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"}}},"generated_inbound_receipt":{"type":"object","nullable":true,"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"}}},"lines":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferLine"}},"timeline":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferTimelineEntry"}}}},"WmsInboundReceiptLineInput":{"type":"object","description":"Inbound receipt line. Supply `product_variant_id` or `sku` (one is required; both must resolve to the same variant).","required":["qty_expected"],"properties":{"product_variant_id":{"type":"string","format":"uuid","description":"Canonical variant UUID.","nullable":true},"sku":{"type":"string","description":"Tenant-scoped SKU alias resolved via PCMS.","nullable":true},"qty_expected":{"type":"number","minimum":0,"description":"Expected quantity."},"unit_cost":{"type":"number","minimum":0,"description":"Cost per unit of this line, in the warehouse's currency (the `currency` of GET /wms/warehouses/{code}); the receipt has no currency of its own and nothing is converted. Include any fees you allocate to the line. When the receipt is VERIFIED, the received quantity at this cost enters the variant's weighted-average cost in that warehouse, exactly as a cost entered in the WMS screen does. Posting happens once: no line's cost can be changed after VERIFIED. A planned line (`qty_expected` above 0) needs a cost from PLANNED onwards (0 is valid). An unplanned line (`qty_expected` 0, an extra product the warehouse counted) always enters stock at cost 0, whatever this field holds. To give such a product a cost, add it as a planned line with its cost before the warehouse counts it; once it has been counted as unplanned, PATCH that line by its `id` while the receipt is RECEIVING with `qty_expected` above 0 and a `unit_cost` (its count is kept, its recorded variance is cleared, so the warehouse confirms it again).","nullable":true}}},"WmsInboundReceiptLinePatchInput":{"type":"object","description":"Inbound receipt line on PATCH. Supply `product_variant_id` or `sku` (one is required; both must resolve to the same variant). `id` is honored only while the receipt is RECEIVING, and must name a line of this receipt of the same variant; a repeated `id` is rejected (400 DUPLICATE_LINE_ID) and a non-UUID `id` is rejected (400 INVALID_LINE_ID).","required":["qty_expected"],"properties":{"id":{"type":"string","format":"uuid","description":"Existing line id (from the receipt detail). Omit to append a new line.","nullable":true},"product_variant_id":{"type":"string","format":"uuid","description":"Canonical variant UUID.","nullable":true},"sku":{"type":"string","description":"Tenant-scoped SKU alias resolved via PCMS.","nullable":true},"qty_expected":{"type":"number","minimum":0,"description":"Expected quantity."},"unit_cost":{"type":"number","minimum":0,"description":"Cost per unit of this line, in the warehouse's currency (the `currency` of GET /wms/warehouses/{code}); the receipt has no currency of its own and nothing is converted. Include any fees you allocate to the line. When the receipt is VERIFIED, the received quantity at this cost enters the variant's weighted-average cost in that warehouse, exactly as a cost entered in the WMS screen does. Posting happens once: no line's cost can be changed after VERIFIED. A planned line (`qty_expected` above 0) needs a cost from PLANNED onwards (0 is valid). An unplanned line (`qty_expected` 0, an extra product the warehouse counted) always enters stock at cost 0, whatever this field holds. To give such a product a cost, add it as a planned line with its cost before the warehouse counts it; once it has been counted as unplanned, PATCH that line by its `id` while the receipt is RECEIVING with `qty_expected` above 0 and a `unit_cost` (its count is kept, its recorded variance is cleared, so the warehouse confirms it again). On a line named by `id` while RECEIVING, omitting `unit_cost` keeps the stored cost. Anywhere else (DRAFT or PLANNED, which replace the whole list, or a line appended without `id`) an omitted or null `unit_cost` leaves the line without cost: allowed on a DRAFT or an unplanned line, refused with 422 `E9649` on a planned line from PLANNED onwards.","nullable":true}}},"WmsOutboundShipmentLineInput":{"type":"object","description":"Outbound shipment line. Supply `product_variant_id` or `sku` (one is required; both must resolve to the same variant).","properties":{"product_variant_id":{"type":"string","format":"uuid","description":"Canonical variant UUID.","nullable":true},"sku":{"type":"string","description":"Tenant-scoped SKU alias resolved via PCMS.","nullable":true},"qty_ordered":{"type":"number","minimum":0,"description":"Ordered quantity."},"qty_reduction_reason":{"type":"string","description":"Why the ordered quantity is lowered. Required when a PATCH on a PLANNED shipment lowers a line's `qty_ordered`; ignored otherwise.","nullable":true}}},"WmsWarehouseTransferLineInput":{"type":"object","description":"Warehouse-transfer (W2W) line. Supply `product_variant_id` or `sku` (one is required; both must resolve to the same variant).","properties":{"product_variant_id":{"type":"string","format":"uuid","description":"Canonical variant UUID.","nullable":true},"sku":{"type":"string","description":"Tenant-scoped SKU alias resolved via PCMS.","nullable":true},"qty_requested":{"type":"number","minimum":0,"description":"Requested transfer quantity."}}},"WmsInternalTransferLineInput":{"type":"object","description":"Internal location-transfer line. Supply `product_variant_id` or `sku` (one is required; both must resolve to the same variant).","properties":{"product_variant_id":{"type":"string","format":"uuid","description":"Canonical variant UUID.","nullable":true},"sku":{"type":"string","description":"Tenant-scoped SKU alias resolved via PCMS.","nullable":true},"qty":{"type":"number","minimum":0,"description":"Quantity to move."},"from_location_code":{"type":"string","description":"Source location code inside the acting warehouse."},"to_location_code":{"type":"string","description":"Destination location code inside the acting warehouse."}}},"CreateInboundReceiptRequest":{"type":"object","description":"Create an inbound receipt draft. Header + lines are persisted atomically.","required":["lines"],"properties":{"warehouse_code":{"type":"string","description":"Acting warehouse (alternative to ?warehouse_code= / X-Warehouse-Code; must match the resolved acting warehouse if supplied).","nullable":true},"external_ref":{"type":"string","description":"Non-unique external business reference.","nullable":true},"notes":{"type":"string","description":"Free-text notes.","nullable":true},"extra_data":{"type":"object","description":"Free-form JSON metadata.","nullable":true},"rel_type":{"type":"string","description":"Opaque caller reference type. `oms_request` is reserved for documents an order system requests through the Integration Hub: sending it is refused with 400.","nullable":true},"rel_id":{"type":"string","description":"Opaque caller reference id.","nullable":true},"cancel_locked":{"type":"boolean","description":"Lock the cancel to the member that creates the document (the API key's member): only that member can cancel it, through its integration. Immutable after create; default false.","nullable":false},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsInboundReceiptLineInput"}}}},"CreateOutboundShipmentRequest":{"type":"object","description":"Create an outbound shipment draft. Header + lines are persisted atomically.","required":["lines"],"properties":{"warehouse_code":{"type":"string","description":"Acting warehouse (alternative to ?warehouse_code= / X-Warehouse-Code; must match the resolved acting warehouse if supplied).","nullable":true},"external_ref":{"type":"string","description":"Non-unique external business reference.","nullable":true},"notes":{"type":"string","description":"Free-text notes.","nullable":true},"extra_data":{"type":"object","description":"Free-form JSON metadata.","nullable":true},"rel_type":{"type":"string","description":"Opaque caller reference type. `oms_request` is reserved for documents an order system requests through the Integration Hub: sending it is refused with 400.","nullable":true},"rel_id":{"type":"string","description":"Opaque caller reference id.","nullable":true},"cancel_locked":{"type":"boolean","description":"Lock the cancel to the member that creates the document (the API key's member): only that member can cancel it, through its integration. Immutable after create; default false.","nullable":false},"waybill":{"allOf":[{"$ref":"#/components/schemas/WmsOutboundShipmentWaybillInput"}],"nullable":true,"description":"Set with the shipment. Checked before the shipment is created; a waybill refused after it (a tracking number in use) answers 409 and leaves the shipment created without it, to be set with PATCH. On an Idempotency-Key replay it is written only if the shipment still has no waybill, and refused the same way."},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsOutboundShipmentLineInput"}}}},"CreateWarehouseTransferRequest":{"type":"object","description":"Create a warehouse-to-warehouse transfer request, PUSH (default) or PULL. The acting warehouse (?warehouse_code= / X-Warehouse-Code) is always the initiator: the source on a PUSH, the destination on a PULL. On PUSH, `destination_warehouse_code` is required and names the counterparty. On PULL, `source_warehouse_code` is required and names the counterparty instead; a supplied `destination_warehouse_code` must equal the acting warehouse. Header + lines are persisted atomically.","required":["lines"],"properties":{"direction":{"type":"string","enum":["PUSH","PULL"],"default":"PUSH","description":"Transfer direction. Omitted means PUSH."},"warehouse_code":{"type":"string","description":"Acting (initiating) warehouse (alternative to ?warehouse_code= / X-Warehouse-Code; must match the resolved acting warehouse if supplied).","nullable":true},"destination_warehouse_code":{"type":"string","description":"Required on PUSH: the destination warehouse code in the same tenant (must differ from the acting warehouse). On PULL, optional and must equal the acting warehouse if supplied.","nullable":true},"source_warehouse_code":{"type":"string","description":"Required on PULL: the source warehouse code in the same tenant to request stock from (must differ from the acting warehouse). Not used on PUSH.","nullable":true},"external_ref":{"type":"string","description":"Non-unique external business reference.","nullable":true},"notes":{"type":"string","description":"Free-text notes.","nullable":true},"extra_data":{"type":"object","description":"Free-form JSON metadata.","nullable":true},"rel_type":{"type":"string","description":"Opaque caller reference type.","nullable":true},"rel_id":{"type":"string","description":"Opaque caller reference id.","nullable":true},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsWarehouseTransferLineInput"}}}},"CreateInternalTransferRequest":{"type":"object","description":"Create an internal location-transfer draft. Header + lines are persisted atomically.","required":["lines"],"properties":{"warehouse_code":{"type":"string","description":"Acting warehouse (alternative to ?warehouse_code= / X-Warehouse-Code; must match the resolved acting warehouse if supplied).","nullable":true},"external_ref":{"type":"string","description":"Non-unique external business reference.","nullable":true},"notes":{"type":"string","description":"Free-text notes.","nullable":true},"extra_data":{"type":"object","description":"Free-form JSON metadata.","nullable":true},"rel_type":{"type":"string","description":"Opaque caller reference type.","nullable":true},"rel_id":{"type":"string","description":"Opaque caller reference id.","nullable":true},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsInternalTransferLineInput"}}}},"UpdateInboundReceiptRequest":{"type":"object","description":"JSON Merge Patch (RFC 7386) update of an editable inbound receipt (DRAFT/PLANNED/RECEIVING). Omitted fields are unchanged. A present `lines` array replaces the full line list while the receipt is DRAFT or PLANNED. While it is RECEIVING, `lines` is merged by line `id`: a line with `id` updates that line's `qty_expected` and `unit_cost` in place and keeps its counted quantity and evidence (a changed `qty_expected` clears the line's recorded variance); a line without `id` is appended; an omitted line is removed only if nothing has been counted on it, otherwise the request is rejected with 409 `E9639`. Fields outside the editable whitelist are rejected (400). Lines, `unit_cost` included, can be written on any receipt in DRAFT, PLANNED or RECEIVING, also one the warehouse created in the WMS screen: there is no check on who created it. A VERIFIED or CANCELLED receipt, and a warehouse-transfer receipt in any status, is refused with 409 `E9522`.","properties":{"external_ref":{"type":"string","description":"Clear with null.","nullable":true},"notes":{"type":"string","description":"Clear with null.","nullable":true},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsInboundReceiptLinePatchInput"}}}},"UpdateOutboundShipmentRequest":{"type":"object","description":"JSON Merge Patch (RFC 7386) update of an editable outbound shipment (DRAFT/PLANNED; `waybill` alone also AWAITING_EXPORT). Omitted fields are unchanged; a present `lines` array replaces the full line list. Fields outside the editable whitelist are rejected (400).","properties":{"external_ref":{"type":"string","description":"Clear with null.","nullable":true},"notes":{"type":"string","description":"Clear with null.","nullable":true},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsOutboundShipmentLineInput"}},"waybill":{"allOf":[{"$ref":"#/components/schemas/WmsOutboundShipmentWaybillInput"}],"nullable":true,"description":"Omitted leaves the waybill unchanged; null clears it; an object replaces it whole. Writable through AWAITING_EXPORT, so a waybill-only PATCH is accepted where a header or line change is not. With a header or line change, the waybill is written after it under the same If-Match."}}},"UpdateWarehouseTransferRequest":{"type":"object","description":"JSON Merge Patch (RFC 7386) update of a DRAFT warehouse-transfer request, source-owned. Omitted fields are unchanged; a present `lines` array replaces the full line list. `direction`, source/destination warehouses, and `status` are not settable via PATCH.","properties":{"external_ref":{"type":"string","description":"Clear with null.","nullable":true},"notes":{"type":"string","description":"Clear with null.","nullable":true},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsWarehouseTransferLineInput"}}}},"UpdateInternalTransferRequest":{"type":"object","description":"JSON Merge Patch (RFC 7386) update of a DRAFT internal location transfer. Omitted fields are unchanged; a present `lines` array replaces the full line list.","properties":{"external_ref":{"type":"string","description":"Clear with null.","nullable":true},"notes":{"type":"string","description":"Clear with null.","nullable":true},"lines":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WmsInternalTransferLineInput"}}}},"WmsDecisionReasonCancelActionRequest":{"type":"object","description":"`cancel` action body. `submit` takes no body.","required":["reason"],"properties":{"reason":{"type":"string","minLength":1,"maxLength":255,"description":"Cancellation reason (non-empty after trim, at most 255 characters)."}}},"WmsCancelActionRequest":{"type":"object","description":"`cancel` action body. `submit` takes no body.","required":["reason"],"properties":{"reason":{"type":"string","minLength":1,"description":"Cancellation reason (non-empty after trim)."}}},"WmsInboundValidateOkResponse":{"type":"object","properties":{"request_id":{"type":"string","format":"uuid","description":"Stable request reference for agent follow-up."},"valid":{"type":"boolean","const":true},"data":{"type":"object","properties":{"line_count":{"type":"integer"},"total_qty_expected":{"type":"number"}}}}},"WmsInboundPreviewOkResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"line_count":{"type":"integer"},"total_qty_expected":{"type":"number"}}}}},"WmsInternalValidateOkResponse":{"type":"object","properties":{"request_id":{"type":"string","format":"uuid","description":"Stable request reference for agent follow-up."},"valid":{"type":"boolean","const":true},"data":{"type":"object","properties":{"line_count":{"type":"integer"},"total_qty":{"type":"number"}}}}},"WmsInternalPreviewOkResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"line_count":{"type":"integer"},"total_qty":{"type":"number"}}}}},"WmsOutboundValidateOkResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"valid":{"type":"boolean","const":true}}}}},"WmsOutboundPreviewOkResponse":{"type":"object","properties":{"data":{"type":"object","properties":{"lines":{"type":"array","items":{"type":"object","properties":{"product_variant_id":{"type":"string","format":"uuid","nullable":true},"qty_ordered":{"type":"number"}}}}}}}},"WmsWarehouseTransferCheck":{"type":"object","properties":{"check":{"type":"string"},"ok":{"type":"boolean"},"detail":{"type":"string"}}},"WmsWarehouseTransferValidateOkResponse":{"type":"object","properties":{"kind":{"type":"string","const":"validate"},"valid":{"type":"boolean","const":true},"checks":{"type":"array","items":{"$ref":"#/components/schemas/WmsWarehouseTransferCheck"}}}},"WmsWarehouseTransferPreviewOkResponse":{"type":"object","properties":{"kind":{"type":"string","const":"preview"},"valid":{"type":"boolean","const":true},"checks":{"type":"array","items":{"$ref":"#/components/schemas/WmsWarehouseTransferCheck"}},"preview":{"type":"object","properties":{"direction":{"type":"string","enum":["PUSH","PULL"]},"source_warehouse_code":{"type":"string"},"destination_warehouse_code":{"type":"string"},"status_after_create":{"type":"string","const":"DRAFT"},"line_count":{"type":"integer"},"lines":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer"},"product_variant_id":{"type":"string","format":"uuid","nullable":true},"sku":{"type":"string","nullable":true},"qty_requested":{"type":"number"}}}}}}}},"PublicWmsLocationSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"type":{"type":"string"},"is_active":{"type":"boolean"},"is_private":{"type":"boolean"},"parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"}}},"PublicWmsLocationNestedReference":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"type":{"type":"string"},"is_private":{"type":"boolean"}}},"PublicWmsLocationDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"name":{"type":"string"},"category":{"type":"string"},"type":{"type":"string"},"is_active":{"type":"boolean"},"is_private":{"type":"boolean"},"parent_id":{"type":"string","format":"uuid","nullable":true},"capacity_units":{"type":"number","nullable":true},"extra_data":{"type":"object","nullable":true},"parent":{"$ref":"#/components/schemas/PublicWmsLocationNestedReference"},"children":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsLocationNestedReference"}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}}},"security":[{"bearerAuth":[]}],"paths":{"/health":{"get":{"summary":"Health Check","tags":["Health"],"description":"Returns API health status after proxy authentication. Used to verify API keys and connectivity. A key that lists its workspaces needs no `X-Tenant-Code` here.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"API is healthy and authentication succeeded","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"timestamp":{"type":"string","format":"date-time"}}}}}},"401":{"description":"Unauthorized — missing or invalid API key"}}}},"/auth/device/code":{"post":{"summary":"Start a sign-in link","tags":["Assistant sign-in"],"description":"An assistant asks for a link a person can approve. It authenticates with the client id and secret it was registered with, not with a `csk_` key, so this is one of only two endpoints that need no bearer token. The response carries a secret `device_code` that the assistant keeps, and a `user_code` carried by `verification_uri_complete`, which the assistant sends to the person in a direct message. A request lives 10 minutes and is used once. Poll `POST /auth/device/token` with the device code. An unknown, wrong or inactive client is `401` `invalid_client`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceCodeRequest"}}}},"responses":{"200":{"description":"A link request was started","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceCodeResponse"}}}},"400":{"description":"The body is not a JSON object","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceFlowError"}}}},"401":{"description":"The client id or secret is wrong, or the assistant is not registered or not active","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceFlowError"}}}}}}},"/auth/device/token":{"post":{"summary":"Collect the key once a person has approved","tags":["Assistant sign-in"],"description":"The assistant polls with its `device_code` every `interval` seconds. While the person has not decided the answer is `400` `authorization_pending`; polling faster than `interval` answers `slow_down` and the assistant adds 5 seconds to its interval. When the person has approved, the poll creates an ordinary `csk_` key owned by that person, naming the assistant, valid 90 days, and returns it once. `access_denied` means the person refused, or no longer belongs to a workspace they chose. An unknown, already collected or expired `device_code` is `expired_token`; the assistant then starts a new link. A wrong, unknown or inactive client is `401` `invalid_client`.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceTokenRequest"}}}},"responses":{"200":{"description":"The key, shown once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceTokenResponse"}}}},"400":{"description":"Not ready or not available: `authorization_pending`, `slow_down`, `access_denied` or `expired_token`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceFlowError"}}}},"401":{"description":"The client id or secret is wrong, or the assistant is not registered or not active","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeviceFlowError"}}}}}}},"/members":{"get":{"summary":"List Members","tags":["Members"],"description":"Retrieve a list of members in the tenant(s).","parameters":[{"name":"X-Tenant-Code","in":"header","description":"Code of the tenant to scope the request to. If omitted, returns members from all accessible tenants.","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search query for display name, email or member code","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicMemberResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}}}}},"/members/invitations":{"get":{"summary":"List Invitations","tags":["Members"],"description":"List the invitations of the tenant named by `X-Tenant-Code`, newest first. Only an **active tenant owner** may call it. A caller who lost the `201` that created an invitation (the only answer carrying the `token`) recovers by listing, cancelling the pending invitation and inviting again with a new `Idempotency-Key`. Each item shows the invitee's `invitee_email` and `invitee_mobile` (a stored mobile has no leading `+`), the contact data an owner already sees on the members screen. The `token` is in no list. Each item's `status` is the stored value: `pending`, `approved` (accepted) or `cancelled` (cancelled by the owner or refused by the invitee, which cannot be told apart). An invitation still stored `pending` after its `expires_at` has lapsed: its `expired` is `true`, and `?status=expired` lists exactly those. `?status=pending` lists only the stored `pending` invitations that have not lapsed. `approved` and `cancelled` filter by stored status whatever the date, and their `expired` is `false`. `custom_message` is the message the inviter sent, or `null`. `total` counts the filtered set. Of two invitations created at the same instant the order is stable, so paging shows each once. This list and the join request list are the only answers that carry contact data. A list read page by page is consistent only while nothing changes between the requests.","parameters":[{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant that owns the invitations. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"`pending` (stored `pending` and not past `expires_at`), `approved`, `cancelled` (stored status), or `expired` (stored `pending` and past `expires_at`). Anything else is `400`.","schema":{"type":"string","enum":["pending","approved","cancelled","expired"]}},{"name":"email","in":"query","required":false,"description":"Return only the invitations sent to this address. Trimmed and lowercased, then compared exactly (no partial match), so `a@x.com` does not find `a@x.com.vn`; an invitation sent to a mobile number only never matches. Combines with `status`. Blank, or longer than 254 characters, is `400 INVALID_QUERY_PARAMS`.","schema":{"type":"string","maxLength":254}},{"name":"page","in":"query","required":false,"description":"Page number, from 1. A page past the last one answers `200` with an empty `data`.","schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"description":"Items per page.","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}}],"responses":{"200":{"description":"A page of invitations.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","invitee_email","invitee_mobile","expires_at","expired","custom_message","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending","approved","cancelled"]},"invitee_email":{"type":"string","nullable":true},"invitee_mobile":{"type":"string","nullable":true},"expires_at":{"type":"string","format":"date-time","nullable":true},"expired":{"type":"boolean","description":"True when the invitation is stored `pending` and past `expires_at`."},"custom_message":{"type":"string","nullable":true,"description":"The message the inviter sent, shown to the invitee."},"created_at":{"type":"string","format":"date-time"}}}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"A query parameter is invalid (`INVALID_QUERY_PARAMS`: unknown `status`, `page` under 1, `limit` over 50), or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), or the key cannot reach the tenant"}}},"post":{"summary":"Invite Member","tags":["Members"],"description":"Invite a person to the tenant named by `X-Tenant-Code`, by email, mobile or both. Only an **active tenant owner** may call it. The platform sends no email or SMS: the `201` that creates the invitation returns the accept-link `token` **once** — it appears in no replay, error or later read, so store it. Build the link as `{web origin}/invite?token={token}`; the web origin is your workspace's app origin, not this API's. The invitee must sign in with an account whose email or mobile matches the invitation to accept it. The invitation expires 30 days after creation. This answer carries no contact data; only the invitation list shows it. An optional `Idempotency-Key` header makes a retry safe: the same key with an identical body returns `200` with the same invitation (its current status, **without** `token`) and writes nothing; the same key with a different body returns `409` (E0601). A caller who loses the `201` loses the token: list the invitations (`GET /members/invitations`), cancel the pending one (`POST /members/invitations/{id}/actions/cancel`) and invite again with a new `Idempotency-Key`. A cancelled invitation keeps its key: replaying the creating call answers `200` with status `cancelled` and no `token`.","parameters":[{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant to invite into. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key, scoped to the tenant. A replay of the same key with an identical body returns 200 with the stored invitation and no `token`; the same key with a different body returns 409 (E0601).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"description":"At least one of `invitee_email` and `invitee_mobile` is required.","properties":{"invitee_email":{"type":"string","format":"email","description":"Stored lowercase."},"invitee_mobile":{"type":"string","description":"Any common phone format; stored normalized to E.164 without the leading `+`."},"custom_message":{"type":"string","nullable":true,"maxLength":500,"description":"Optional message the invitee sees on the invite page, shown as text. Trimmed; blank or `null` means none; over 500 characters is `400`. It is part of the `Idempotency-Key` body comparison and is not echoed in the answer."}}}}}},"responses":{"200":{"description":"Replay: the same Idempotency-Key was applied with an identical body. Returns the stored invitation with its current status and no `token`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteMemberResponse"}}}},"201":{"description":"The invitation was created. `data.token` is returned only here.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InviteMemberResponse"}}}},"400":{"description":"The body is not valid JSON or fails validation, or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), or the key cannot reach the tenant"},"409":{"description":"The invitee is already a member (E2302), already has a live pending invitation (E2301), or the Idempotency-Key was already used with a different body (E0601)"}}}},"/join-requests":{"get":{"summary":"List Join Requests","tags":["Members"],"description":"List the join requests of the tenant named by `X-Tenant-Code`, newest first, so the `id` that approve and reject need can be found. Only an **active tenant owner** may call it. Each item shows who asked and how to reach them (`contact_email`, `contact_mobile`, `bio`), the contact data an owner already sees on the members screen; each is `null` when the request holds no text for it. The requester's account id is never shown. `status` is the stored value: `pending`, `approved` or `rejected`. `total` counts the filtered set. Of two requests created at the same instant the order is stable, so paging shows each once. This list and the invitation list are the only answers that carry contact data. A list read page by page is consistent only while nothing changes between the requests.","parameters":[{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant that owns the requests. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"description":"Only records with this stored status. Anything else is `400`.","schema":{"type":"string","enum":["pending","approved","rejected"]}},{"name":"email","in":"query","required":false,"description":"Return only the requests whose `contact_email` is this address. Trimmed and lowercased, then compared exactly (no partial match). A request that left no email never matches. Combines with `status`. Blank, or longer than 254 characters, is `400 INVALID_QUERY_PARAMS`.","schema":{"type":"string","maxLength":254}},{"name":"page","in":"query","required":false,"description":"Page number, from 1. A page past the last one answers `200` with an empty `data`.","schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"description":"Items per page.","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}}],"responses":{"200":{"description":"A page of join requests.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data","meta"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","status","contact_email","contact_mobile","bio","created_at","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending","approved","rejected"]},"contact_email":{"type":"string","nullable":true},"contact_mobile":{"type":"string","nullable":true,"description":"Stored without a leading `+`."},"bio":{"type":"string","nullable":true},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"A query parameter is invalid (`INVALID_QUERY_PARAMS`: unknown `status`, `page` under 1, `limit` over 50), or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), or the key cannot reach the tenant"}}}},"/join-requests/{id}/actions/approve":{"post":{"summary":"Approve Join Request","tags":["Members"],"description":"Approve a pending join request (find its id with `GET /join-requests`): the requester becomes a `member` of the tenant named by `X-Tenant-Code`. Only an **active tenant owner** may call it. `display_name` is required; `member_code`, `job_title` and `department` are optional. Of two concurrent decisions on one request exactly one wins and the other answers `409` (E2403). A requester whose membership exists but is not `active` (`inactive`, `banned`) is refused with `409` (E2302) and the request stays pending; a requester who is already an active member is marked approved without a second member and the answer has no `member_id`. If creating the member fails the request is restored to `pending`. No response carries the requester's email or mobile.","parameters":[{"name":"id","in":"path","required":true,"description":"Join request id (UUID).","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant that owns the request. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["display_name"],"properties":{"display_name":{"type":"string","description":"Name shown for the new member. Must not be blank."},"member_code":{"type":"string","nullable":true,"description":"Optional; generated when omitted. A code already used in the tenant answers `409` (E4103)."},"job_title":{"type":"string","nullable":true},"department":{"type":"string","nullable":true}}}}}},"responses":{"200":{"description":"The request is approved.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"},"member_id":{"type":"string","format":"uuid","description":"The new member's id; absent when the requester was already an active member."}}}}}}}},"400":{"description":"The id is not a UUID, the body is not valid JSON or fails validation, or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), or the key cannot reach the tenant"},"404":{"description":"No such join request in the tenant. Another tenant's request answers exactly like an unknown id."},"409":{"description":"The request has already been decided, or a concurrent decision won (E2403)"}}}},"/join-requests/{id}/actions/reject":{"post":{"summary":"Reject Join Request","tags":["Members"],"description":"Reject a pending join request (find its id with `GET /join-requests`) of the tenant named by `X-Tenant-Code`. Only an **active tenant owner** may call it. The body must be empty: the service collects no reason, so a field is refused with `400`. No member is created. Of two concurrent decisions on one request exactly one wins and the other answers `409` (E2403). No response carries the requester's email or mobile.","parameters":[{"name":"id","in":"path","required":true,"description":"Join request id (UUID).","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant that owns the request. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}}],"responses":{"200":{"description":"The request is rejected.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"}}}}}}}},"400":{"description":"The id is not a UUID, the body is not valid JSON or fails validation, or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), or the key cannot reach the tenant"},"404":{"description":"No such join request in the tenant. Another tenant's request answers exactly like an unknown id."},"409":{"description":"The request has already been decided, or a concurrent decision won (E2403)"}}}},"/members/invitations/{id}/actions/cancel":{"post":{"summary":"Cancel Invitation","tags":["Members"],"description":"Cancel a pending invitation of the tenant named by `X-Tenant-Code`; its link no longer accepts. Only an **active tenant owner** may call it. The body must be empty: a field is refused with `400`. Only a `pending` invitation can be cancelled, even one past its `expires_at`; an invitation that was accepted, refused or already cancelled answers `409` (`E2308`) and nothing changes. Another tenant's invitation answers exactly like an unknown id. Cancelling keeps the invitation's `Idempotency-Key` bound to it: replaying the creating call afterwards answers `200` with status `cancelled` and no `token`, so a new invitation needs a new key. Find the id with `GET /members/invitations`. A cancel retried after a lost answer gets `409` (`E2308`); a client that already saw the invitation `cancelled` should treat that `409` as done. The answer carries no contact data.","parameters":[{"name":"id","in":"path","required":true,"description":"Invitation id (UUID).","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant that owns the invitation. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}}],"responses":{"200":{"description":"The invitation is cancelled.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["cancelled"]}}}}}}}},"400":{"description":"The id is not a UUID, the body is not valid JSON or has a field, or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), or the key cannot reach the tenant"},"404":{"description":"No such invitation in the tenant. Another tenant's invitation answers exactly like an unknown id."},"409":{"description":"The invitation is no longer pending: accepted, refused or already cancelled (`E2308`)"}}}},"/members/{id}":{"get":{"summary":"Get Member by ID","tags":["Members"],"description":"Fetch a single tenant member by their user id, scoped to the tenants the caller belongs to. Sensitive PII is excluded. Returns 404 if the member is not in any accessible tenant.","parameters":[{"name":"id","in":"path","required":true,"description":"The member's user id.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","description":"Scope to a tenant. If omitted, resolved across all tenants the caller belongs to.","schema":{"type":"string"}}],"responses":{"200":{"description":"Member detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicMemberResponse"}}}}}},"404":{"description":"Member not found"}}},"patch":{"summary":"Update Member","tags":["Members"],"description":"Update a member of the tenant named by `X-Tenant-Code`. `{id}` is the member's user id, as in `GET /members/{id}`. Only an **active tenant owner** may call it. The body takes any of the profile fields the member edit page changes (`display_name`, `email`, `mobile`, `member_code`, `job_title`, `department`, `bio`) plus `role`, `status`, `positions` and `permissions`; a field left out is not changed, and `avatar_url` and `birth_date` are not offered. The writes are separate transactions applied in a fixed order: profile, `status: active`, `role`, `permissions`, `positions`, then `status: inactive|banned`. There is **no rollback**: if a step fails after earlier ones applied, the answer is that step's error and its message names the groups already applied; send the same body again to complete it. A field that already holds its value is a no-op that emits no event, so the verb is idempotent by value and takes no `Idempotency-Key`. A caller who is not an active owner gets `403` before anything else. A `role` equal to the member's stored role is ignored, so a finished call can be repeated. Refused before any write: the caller's own `role` or `status` (`403`, E9206), `permissions` for a member who is or becomes an owner (`409`), a `role` on a member who is not active and is not reactivated by the same call (`409`), and a member code or email another member already uses (`409`, E4103, E4104). Demoting or deactivating the last active owner answers `409` (E9205). A removed member, an unknown id and another tenant's member answer the same `404`. The answer is the stored state and never carries the member's email, mobile or other contact data. There is no member-create call: a member joins through an invitation or an approved join request.","parameters":[{"name":"id","in":"path","required":true,"description":"The member's user id.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":false,"description":"Code of the tenant the member belongs to. Required unless the API key is pinned to one tenant.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"description":"At least one field. A field left out is not changed.","properties":{"display_name":{"type":"string","maxLength":200,"description":"Must not be blank; the name cannot be cleared."},"email":{"type":"string","format":"email","maxLength":254,"nullable":true,"description":"`null` or blank clears it. An address another non-removed member of the tenant already uses, in any letter case, answers `409` (E4104)."},"mobile":{"type":"string","nullable":true,"description":"Normalized like the invitation mobile; `null` or blank clears it."},"member_code":{"type":"string","maxLength":50,"nullable":true,"description":"Trimmed and upper-cased; `null` or blank clears it. A code another member of the tenant uses answers `409` (E4103)."},"job_title":{"type":"string","nullable":true,"maxLength":100},"department":{"type":"string","nullable":true,"maxLength":100},"bio":{"type":"string","nullable":true,"maxLength":2000},"role":{"type":"string","enum":["owner","member"],"description":"The caller's own role cannot change (`403`, E9206). The member must be active, or reactivated by `status: active` in the same call."},"status":{"type":"string","enum":["active","inactive","banned"],"description":"The caller's own status cannot change (`403`, E9206)."},"positions":{"type":"array","maxItems":200,"items":{"type":"string","format":"uuid"},"description":"Replaces all of the member's positions; `[]` clears them. A position outside the tenant answers `400`."},"permissions":{"type":"array","maxItems":200,"items":{"type":"string"},"description":"Replaces all granular permission keys; `[]` clears them. Refused with `409` for a member who is or becomes an owner."}}}}}},"responses":{"200":{"description":"The member after the update.","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"object","required":["id","role","status","display_name","member_code","job_title","department","bio","permissions","positions"],"properties":{"id":{"type":"string","format":"uuid","description":"The member's user id."},"role":{"type":"string","enum":["owner","member"]},"status":{"type":"string","enum":["active","inactive","banned"]},"display_name":{"type":"string","nullable":true},"member_code":{"type":"string","nullable":true},"job_title":{"type":"string","nullable":true},"department":{"type":"string","nullable":true},"bio":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"positions":{"type":"array","items":{"type":"string","format":"uuid"}}}}}}}}},"400":{"description":"The id is not a UUID, the body is not valid JSON or fails validation (no field, an unknown field, a bad value, an unknown permission key, a non-UUID or unknown position), or no tenant could be resolved (`TENANT_CODE_REQUIRED`)"},"401":{"description":"Authentication required"},"403":{"description":"The caller is not an active owner of the tenant (`FORBIDDEN`), the key cannot reach the tenant, or the body changes the caller's own role or status (E9206)"},"404":{"description":"The member is unknown, removed or belongs to another tenant"},"409":{"description":"Refused. `code` is one of `E4103` (member code already used), `E4104` (email already used), `E9205` (the last active owner would be lost), `PERMISSIONS_NOT_APPLICABLE` (permissions for a member who is or becomes an owner) or `MEMBER_NOT_ACTIVE` (a role change on a member who is not active and is not reactivated by the same call). After earlier steps applied, the message names them"},"500":{"description":"Internal server error. After earlier steps applied, the message names them"}}}},"/mission/labels":{"get":{"summary":"List Labels","tags":["Tasks"],"description":"Retrieve the tenant's task labels, ordered by name, including a label no task carries any more.","parameters":[{"name":"X-Tenant-Code","in":"header","description":"Code of the tenant to scope the request to. Required when the key reaches more than one tenant.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskLabel"}}}}}}},"400":{"description":"`INVALID_TENANT_SCOPE`: the key reaches more than one tenant and no `X-Tenant-Code` was sent"},"401":{"description":"Missing or invalid API key"},"403":{"description":"The key has no access to the tenant"},"500":{"description":"Internal server error"}}}},"/mission/boards":{"get":{"summary":"List Boards","tags":["Boards"],"description":"Retrieve a list of public boards.","parameters":[{"name":"X-Tenant-Code","in":"header","description":"Code of the tenant to scope the request to. If omitted, returns boards from all accessible tenants (where user is a member).","schema":{"type":"string"}},{"name":"q","in":"query","description":"Case-insensitive partial search against board name. Values shorter than 2 characters are ignored. Characters `%` and `_` are treated as literals, not wildcards. Maximum 500 characters.","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicBoardResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}}}},"post":{"summary":"Create Board","tags":["Boards"],"description":"Create a new board, optionally together with its lists, in a single request. When `lists` is provided, each list is created under the board and returned in the response.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBoardRequest"}}}},"responses":{"201":{"description":"Board created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardDetailResponse"},"meta":{"type":"object","properties":{"list_count":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"}}}},"/mission/boards/{id}":{"get":{"summary":"Get Board Detail","tags":["Boards"],"description":"Retrieve details of a specific board, including its lists.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardDetailResponse"},"meta":{"type":"object","properties":{"list_count":{"type":"integer"}}}}}}}},"404":{"description":"Board not found"}}},"patch":{"summary":"Update Board","tags":["Boards"],"description":"Update a board together with its lists in a single request. Lists without an `id` are created; lists with an `id` are updated.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBoardRequest"}}}},"responses":{"200":{"description":"Board updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardDetailResponse"},"meta":{"type":"object","properties":{"list_count":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board not found"}}}},"/mission/boards/code/{code}":{"get":{"summary":"Get Board Detail (by Code)","operationId":"getBoardByCode","tags":["Boards"],"description":"Resolve a board by its code (e.g. `ACMEC-457`) and return its details, including its lists. Same response as Get Board Detail.","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Board code (e.g. ACMEC-457)","example":"ACMEC-457"},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant that owns the board. Board codes are unique per tenant only.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardDetailResponse"},"meta":{"type":"object","properties":{"list_count":{"type":"integer"}}}}}}}},"400":{"description":"`X-Tenant-Code` header missing (`TENANT_CODE_REQUIRED`): a code lookup needs the tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"tenantCodeRequired":{"summary":"Missing X-Tenant-Code header","value":{"error":{"code":"TENANT_CODE_REQUIRED","message":"X-Tenant-Code header is required for code-based board lookup"}}}}}}},"404":{"description":"Board not found"}}}},"/mission/boards/{id}/lists":{"post":{"summary":"Add List to Board","tags":["Boards"],"description":"Create a single list under an existing public board.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateBoardListRequest"}}}},"responses":{"201":{"description":"List created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardListResponse"}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board not found"}}}},"/mission/boards/{id}/lists/{listId}":{"patch":{"summary":"Update or Reorder List in Board","tags":["Boards"],"description":"Apply a partial update to a list under an existing public board (`name`, `limit`, `is_archived`), or move it to a position relative to another list on the same board (`after_list_id` or `before_list_id`). The two are never asked for at once: at least one field is required for an update, and a reorder names exactly one anchor and no field. A reorder answers the list with the `position` the server computed between the anchor's neighbours; a list already where it is asked to go is a success whose order is unchanged, and its position may still be recomputed between the same neighbours.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"listId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBoardListRequest"}}}},"responses":{"200":{"description":"List updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardListResponse"}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board or list not found — or, on a reorder, the anchor is unknown, archived, or on another board"}}}},"/mission/boards/{id}/members":{"get":{"summary":"List Board Members","tags":["Boards"],"description":"Retrieve the members of a public board together with their board roles. Requires board membership (any role) or tenant ownership.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":50}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicBoardMemberResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board not found"}}},"post":{"summary":"Add Board Members","tags":["Boards"],"description":"Add tenant members to a public board in one request. Requires board-edit permission (board owner or tenant owner). A member the board already holds is skipped rather than refused, which is also what makes the call safe to retry: the same request again writes nothing and reports every member as `skipped`.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddBoardMemberRequest"}}}},"responses":{"201":{"description":"One entry per requested member, in request order. `added` means this request wrote the membership; `skipped` means the board already held it (reason `already_member`), including a user id sent twice in one request.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","required":["user_id","status"],"properties":{"user_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["added","skipped"]},"reason":{"type":"string","enum":["already_member"]}}}},"added_count":{"type":"integer"},"skipped_count":{"type":"integer"}}}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board not found, or a user is not an active tenant member of the tenant"}}}},"/mission/boards/{id}/members/{userId}":{"patch":{"summary":"Change Board Member Role","tags":["Boards"],"description":"Change a member's role on a public board (`owner` or `member`). Requires board-edit permission (board owner or tenant owner).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateBoardMemberRequest"}}}},"responses":{"200":{"description":"Member role updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBoardMemberResponse"}}}}}},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board or member not found"},"409":{"description":"Cannot remove or demote the last board owner"}}},"delete":{"summary":"Remove Board Member","tags":["Boards"],"description":"Remove a member from a public board. Requires board-edit permission (board owner or tenant owner).","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tenant_code"],"properties":{"tenant_code":{"type":"string","description":"The tenant/workspace code where the board lives."}}}}}},"responses":{"204":{"description":"Member removed successfully"},"400":{"$ref":"#/components/schemas/Error"},"403":{"$ref":"#/components/schemas/Error"},"404":{"description":"Board or member not found"},"409":{"description":"Cannot remove or demote the last board owner"}}}},"/mission/tasks":{"get":{"summary":"List Tasks","tags":["Tasks"],"description":"Search and filter tasks. Archived tasks are excluded unless `include_archived=true`. The `filters[...]` grammar joins every rule with AND, so three questions it cannot ask are answered by their own parameter, each backed by the database function the corresponding screen already uses: `scope=mine` (the caller's own tasks — owner OR assignee OR follower), `unassigned`/`date_field`, and `archived=true`. Each of the three reads one tenant at a time and they are mutually exclusive with each other.","parameters":[{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search by task text, case-insensitive, and escaped on every list: a literal `%` or `_` in the term stays literal. On the default and archived lists it matches `title`, `description` and `code`. On `scope=mine` it matches `title` and `code` — the personal read queries `mission.v_tasks`, which has no description column. On the board list (`unassigned`/date filters) it matches `title` only: `mission.search_board_tasks` takes one text parameter and reads its board rows by title, with no code or description search.","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20}},{"name":"filters","in":"query","style":"deepObject","explode":true,"description":"Filter by fields, e.g. filters[status][$eq]=Done. Rules are joined with AND, and the columns are `status`, `priority`, `assignee_id`, `owner_id`, `board_id`, `board_list_id`, `due_date`, `created_at` and `updated_at`. `filters[labels][$all]=a,b` keeps the tasks that carry every listed label, in any letter case; it is the only operator `labels` takes, and only the default, archived and `scope=mine` lists accept it. A name that is no label of the tenant gives an empty page. `filters[assignee_id]` compares against a value, so \"no assignee\" is the `unassigned` parameter rather than a filter.","schema":{"type":"object"}},{"name":"sort","in":"query","required":false,"description":"Order the page, as `column:direction` (the direction defaults to ascending). `created_at`, `updated_at`, `due_date` and `priority` are accepted; `created_at` is the default. When `unassigned` or a date range selects the board task search, only `created_at`, `updated_at`, `due_date` and `status` are accepted and anything else is HTTP 400 `INVALID_SORT_COLUMN`.","schema":{"type":"string"},"examples":{"mostRecentlyChanged":{"value":"updated_at:desc","summary":"Most recently updated first"}}},{"name":"parent_task_id","in":"query","required":false,"description":"Subtask filter. (a) Omit to include subtasks alongside top-level tasks (default since 2026-04-16). (b) Pass the literal string `null` to return only top-level tasks. (c) Pass a task UUID to return only the subtasks of that parent. (d) Any other value returns HTTP 400.","schema":{"type":"string"},"examples":{"topLevelOnly":{"value":"null","summary":"Return only top-level tasks"},"subtasksOfParent":{"value":"550e8400-e29b-41d4-a716-446655440000","summary":"Return only subtasks of the given parent"}}},{"name":"include_archived","in":"query","required":false,"description":"Include archived tasks in the answer. Omitted or `false` leaves them out — the default on every task read. `true` widens this one request, and archived rows come back alongside live ones. Any other value returns HTTP 400 with `INVALID_INCLUDE_ARCHIVED`.","schema":{"type":"boolean","default":false}},{"name":"scope","in":"query","required":false,"description":"Whose tasks to answer with. Omitted or `all` is the tenant-wide read this endpoint has always served. `mine` answers with the caller's own tasks — those they own, are assigned to, or follow — which is the one shape the AND-only `filters[...]` grammar cannot express. `scope=mine` reads a single tenant at a time, so it needs a resolvable tenant (a tenant-scoped key, or the `X-Tenant-Code` header); otherwise HTTP 400 `INVALID_TENANT_SCOPE`. It is mutually exclusive with the `unassigned`/date filters and with `archived=true`, and any other value returns HTTP 400 `INVALID_SCOPE`.","schema":{"type":"string","enum":["all","mine"],"default":"all"}},{"name":"unassigned","in":"query","required":false,"description":"Answer with tasks that have no assignee — the other question the AND-only grammar cannot ask, since `filters[assignee_id]` can only compare against a value. This filter is served by the board task search (`mission.search_board_tasks`), so the rows are board tasks the caller can view, and archived rows are never among them. Combining it with a filter other than `filters[board_id]` or `filters[status]`, with `parent_task_id`, with `include_archived=true`, or with a sort column outside `created_at`/`updated_at`/`due_date`/`status` returns HTTP 400 `INVALID_QUERY_PARAMS` (or `INVALID_SORT_COLUMN`) rather than answering without that part. Any value other than `true`/`false` returns HTTP 400 `INVALID_UNASSIGNED`.","schema":{"type":"boolean","default":false}},{"name":"date_field","in":"query","required":false,"description":"Which date column `date_from`/`date_to` bound. Required when either bound is set — a bound with no field would silently filter nothing — and refused on its own when neither bound is set. Served by the same board task search as `unassigned`, so the same row scope and the same 400s apply. Any other value returns HTTP 400 `INVALID_DATE_FIELD`.","schema":{"type":"string","enum":["created_at","due_date","updated_at"]}},{"name":"date_from","in":"query","required":false,"description":"Inclusive lower bound on `date_field`. ISO 8601 date-time **with a timezone offset** — percent-encode the `+`, as in `2026-09-01T00:00:00%2B07:00`. An offset-less value such as `2026-09-01` is refused (HTTP 400 `INVALID_DATE_RANGE`) rather than read as UTC midnight and silently shifting the window by the caller's offset. For overdue tasks, use `date_field=due_date` with `date_to` set to now and no `date_from`.","schema":{"type":"string","format":"date-time"}},{"name":"date_to","in":"query","required":false,"description":"Inclusive upper bound on `date_field`. Same format and same refusals as `date_from`; a `date_from` after its `date_to` returns HTTP 400 `INVALID_DATE_RANGE` rather than an empty page that looks like a real answer.","schema":{"type":"string","format":"date-time"}},{"name":"archived","in":"query","required":false,"description":"Answer with the archived tasks the caller owns or is assigned to — the archived screen's own list, newest archive first. This is not `include_archived`: that widens the live list, this replaces it with the retired one, and a task matching neither test (board membership included) is not returned. Reads a single tenant at a time, so it needs a resolvable tenant (HTTP 400 `INVALID_TENANT_SCOPE` otherwise), and it is mutually exclusive with `scope=mine`, the `unassigned`/date filters, and `include_archived=true`. Any value other than `true`/`false` returns HTTP 400 `INVALID_ARCHIVED`.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Invalid query parameter (e.g. malformed `parent_task_id`, `include_archived`, `scope`, `unassigned`, `archived` or date range, or a mode combined with something it cannot express)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalidParentTaskId":{"summary":"Malformed parent_task_id value","value":{"error":{"code":"INVALID_PARENT_TASK_ID","message":"invalid parent_task_id filter"}}},"invalidIncludeArchived":{"summary":"Malformed include_archived value","value":{"error":{"code":"INVALID_INCLUDE_ARCHIVED","message":"include_archived must be true or false"}}},"invalidScope":{"summary":"A scope that names no backend","value":{"error":{"code":"INVALID_SCOPE","message":"Invalid scope: team"}}},"tenantScopeRequired":{"summary":"A single-tenant mode asked for while the key spans several","value":{"error":{"code":"INVALID_TENANT_SCOPE","message":"scope=mine read one tenant at a time; send X-Tenant-Code to name it"}}},"invalidDateRange":{"summary":"A bound with no timezone offset","value":{"error":{"code":"INVALID_DATE_RANGE","message":"date_to is not a valid ISO 8601 date-time with a timezone offset"}}}}}}}}},"post":{"summary":"Create Task","tags":["Tasks"],"description":"Create a new task. A body that names `board_list_id` creates the card through the board's own card-creation path: it requires membership of that board (or a tenant owner), seeds the board owners as followers, and accepts an optional placement (`after_task_id`, or `position: \"top\"`) — omit both to append. A body with no list keeps the plain create. An optional `Idempotency-Key` header makes a retry safe: the first application returns 201, a replay of the same key with an identical canonical body returns 200 with the existing task, and the same key with a different body returns 409 (E0601). A replay answers 200 only when the row records that its placement ran: a keyed request that stopped between the write and the move answers 409 `PLACEMENT_INCOMPLETE`, naming the card in `data.id` and calling the operation unproven rather than failed — read it with `GET /v1/mission/tasks/{id}` before moving it, since it may have been placed and moved on since.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key, scoped to the tenant. First application returns 201; a replay of the same key with an identical canonical body returns 200 with the existing task (a replay whose row records no completed placement answers 409 `PLACEMENT_INCOMPLETE` naming the card); the same key with a different body — or with a task archived since — returns 409 (E0601).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTaskRequest"}}}},"responses":{"200":{"description":"Replay: the same Idempotency-Key was applied with an identical canonical body — returns that task and writes nothing.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"201":{"description":"Task created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Invalid body, or `E9117` for an invalid name in `label_names` (no task is created)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Access to this tenant is denied, or the caller is neither a member of the board named by `board_list_id` nor a tenant owner (the latter answers `BOARD_FORBIDDEN`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"tenantDenied":{"summary":"The key or the X-Tenant-Code names another tenant","value":{"error":{"code":"AUTH_TENANT_MISMATCH","message":"Access to this tenant is denied"}}},"boardDenied":{"summary":"A tenant member who is not on that board","value":{"error":{"code":"BOARD_FORBIDDEN","message":"You must be a member of the board, or a tenant owner, to create a task in its list"}}}}}}},"404":{"description":"The `board_list_id` no longer resolves to a live list in this tenant","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"The Idempotency-Key was already used with a different request body (E0601); or the task was created and the requested position could not be applied (PLACEMENT_FAILED); or a replay of this key found no record that its placement ran (PLACEMENT_INCOMPLETE). The last two name the created task in `data.id`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"idempotencyConflict":{"summary":"Same key, different body","value":{"error":{"code":"E0601","message":"Idempotency-Key was already used with a different request body"}}},"placementFailed":{"summary":"The card exists at the end of the list; the placement did not happen","value":{"error":{"code":"PLACEMENT_FAILED","message":"The task was created at the end of the list, but the requested position could not be applied. Place it with POST /v1/mission/tasks/{id}/actions/move"},"data":{"id":"the created task id"}}}}}}}}}},"/mission/tasks/{id}":{"get":{"summary":"Get Task Detail","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}},{"name":"include_archived","in":"query","required":false,"description":"Read an archived task. Omitted or `false` keeps today's answer — 404 for an archived task. `true` resolves the archived row as well, through the same tenant gate. Any other value returns HTTP 400 with `INVALID_INCLUDE_ARCHIVED`.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Malformed `include_archived` value","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalidIncludeArchived":{"summary":"Malformed include_archived value","value":{"error":{"code":"INVALID_INCLUDE_ARCHIVED","message":"include_archived must be true or false"}}}}}}},"404":{"description":"Task not found"}},"description":"Retrieve a single task by UUID. An archived task answers 404 unless the request carries `include_archived=true`."},"patch":{"summary":"Update Task","description":"Partial update of a task. Any subset of fields may be provided; at least one is required. Each changed field emits its corresponding domain event. `PATCH` is partial-update (cf. there is no full-replace `PUT` for tasks). Editing a subtask, reassigning it (`assignee_id`) included, asks one rule: the caller must be the subtask's owner, its assignee, a tenant owner, the parent task's owner or assignee, or an active member of the parent task's board — the same check the GUI applies. A top-level task asks its owner, its assignee, a tenant owner or an active member of its board.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"additionalProperties":false,"properties":{"status":{"type":"string","enum":["Pending","To-Do","Doing","Done","Cancelled"]},"assignee_id":{"type":"string","format":"uuid","nullable":true,"description":"Set to null to unassign."},"board_id":{"type":"string","format":"uuid","nullable":true,"description":"Must be provided together with board_list_id. Both null removes the task from its board."},"board_list_id":{"type":"string","format":"uuid","nullable":true,"description":"Must be provided together with board_id."},"title":{"type":"string","minLength":1,"maxLength":500},"description":{"type":"string","maxLength":10000,"nullable":true},"due_date":{"type":"string","format":"date-time","nullable":true,"description":"Set to null to clear the due date. Must be today or in the future."},"follower_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Users to add as followers. Additive and idempotent: a listed user who already follows the task is left alone."},"follower_remove_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Users to remove from the followers. Removing a user who does not follow the task is a no-op. Must not repeat an id from follower_ids (400)."},"label_names":{"allOf":[{"$ref":"#/components/schemas/LabelNames"}],"description":"Labels to add. Additive and idempotent: a label the task already carries is left alone, and a name no label has yet creates one."},"label_remove_names":{"allOf":[{"$ref":"#/components/schemas/LabelNames"}],"description":"Labels to remove. A name the task does not carry is a no-op. Applied after label_names, so a name in both lists ends removed."}}}}}},"responses":{"200":{"description":"Updated task","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error (empty body, invalid status, invalid assignee/followers, board mismatch), or `E9117` for an invalid name in `label_names` or `label_remove_names` (nothing is written)"},"403":{"description":"Insufficient permission to perform the update"},"404":{"description":"Task not found"}}},"delete":{"summary":"Delete Task","description":"Retire a task: soft-delete it, exactly as the archive action does. A **top-level task** is retired together with its active subtasks, which the database's family cascade retires in the same write; a subtask named here is retired alone, and its dedicated address is `DELETE /mission/tasks/{id}/subtasks/{subtaskId}`. The caller must be the task's **owner**, its **assignee**, the **active owner of its current board**, or a **tenant owner of the task's tenant**; ordinary board membership alone does not qualify. The body carries no fields: the task is named by its address and the delete records no reason. The answer names the task by id rather than returning the task, because every task read answers `404` once the delete has run — a repeat answers `404` too, which is also why no `Idempotency-Key` is accepted: a replay is a read-side fact, not a duplicate write.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task was retired; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"A body was sent (delete takes no fields), or the body is not valid JSON"},"403":{"description":"The caller is not the task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant"},"404":{"description":"Task not found — including a task that is already deleted, and another tenant's task"}}}},"/mission/tasks/{id}/actions/transfer-ownership":{"post":{"summary":"Transfer Task Ownership","description":"Hand a task to another active member of its tenant. The task's **current owner**, the **active owner of its current board** (parent's board for a subtask), or a **tenant owner of the task's own tenant** may call this (ADR-097) — the change runs through `public.mission_transfer_task_ownership`, whose trigger accepts the same authorities, so a tenant owner of some other tenant gets the same 403. `owner_id` must be an active member of the task's tenant, and the task must be live: an archived task answers 404 like every other task endpoint. The write changes `owner_id` alone — assignee, followers, board placement and status stay as they are — and writes the `task:ownership:transferred` event in the same transaction, carrying `actor_authority` (`owner` or `tenant_owner`) and naming the former owner as a recipient. Naming the current owner is a successful no-op, so no `Idempotency-Key` is needed or accepted.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["owner_id"],"additionalProperties":false,"properties":{"owner_id":{"type":"string","format":"uuid","description":"User id of the new owner — an active member of the task's tenant."}}}}}},"responses":{"200":{"description":"The task after the transfer","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Missing owner_id, or INVALID_OWNER: the id does not name an active member of the task's tenant"},"403":{"description":"The caller is not the task's current owner, active current-board owner or tenant owner"},"404":{"description":"Task not found"},"409":{"description":"TASK_NOT_TRANSFERABLE: another ownership change won the race, or the database refused the transfer — re-read the task and retry"}}}},"/mission/tasks/{id}/actions/archive":{"post":{"summary":"Archive Task","description":"Retire a task: set `archived_at` and take the task out of every task read. The caller must be the task's **owner**, its **assignee**, the **active owner of its current board**, or a **tenant owner of the task's tenant** — ordinary board membership alone does not qualify, and a subtask is stricter: archiving it asks the *parent's* owner, assignee or active current-board owner, so a subtask's own assignee is refused. Archiving a subtask retires that subtask alone — its parent and siblings are untouched — while archiving a **parent** retires its active subtasks with it and marks them as taken by that archive, which is what its own restore later releases (`docs/adr/adr-078-parent-archive-sweeps-and-records.md`). The body carries no fields: the task is named by its address and the archive records no reason. The answer names the task by id rather than returning the task, because every task read answers `404` once the archive has run — a repeat answers `404` too, which is also why no `Idempotency-Key` is accepted: a replay is a read-side fact, not a duplicate write.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task was archived; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"A body was sent (archive takes no fields), or the body is not valid JSON"},"403":{"description":"The caller is not the task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant — for a subtask, not the parent's owner, assignee or active current-board owner"},"404":{"description":"Task not found — including a task that is already archived, and another tenant's task"}}}},"/mission/tasks/{id}/actions/unarchive":{"post":{"summary":"Unarchive Task","description":"Restore an archived task: clear `archived_at` and bring it back into every task read. The caller must be the task's **owner**, its **assignee**, the **active owner of its current board**, or a **tenant owner of the task's tenant** — ordinary board membership alone does not qualify. A subtask answers to its parent's coordinators here too, so a subtask's own assignee is refused. A subtask whose parent is still archived cannot be restored at all (`400 PARENT_TASK_ARCHIVED`): restoring that parent is what releases the subtasks its archive took, and it leaves a subtask that was archived on its own archived (`docs/adr/adr-078-parent-archive-sweeps-and-records.md`). The body carries no fields: the task is named by its address and the restore records no reason. Restoring a task that is already live is a successful no-op — the answer is that task and nothing is written. The answer is the task in the `GET /mission/tasks/{id}` shape, and one `task:unarchived` event is recorded unless the task was already live. The database's own triggers ride along: an archived list holding the task is unarchived with it, and a subtask deleted on its own has `is_deleted` cleared.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task, restored","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}},"required":["data"]}}}},"400":{"description":"A body was sent (unarchive takes no fields), or the body is not valid JSON"},"403":{"description":"The caller is not the task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant"},"404":{"description":"Task not found — including another tenant's task, a deactivated caller, and a task that stopped being archived before the write"},"409":{"description":"TASK_NOT_RESTORABLE: another call restored the task first, or the database refused the change — re-read the task"}}}},"/mission/tasks/{id}/actions/move":{"post":{"summary":"Move Task","description":"Place a top-level task in a board list: at the top of it, or behind a task already there. The caller must be the task's **owner**, its **assignee**, a **tenant owner of the task's tenant**, or an **active member of the task's board** — the task-edit rule, which the service checks before it writes, so a caller who may read the task but not move it gets the same 404 a missing task gets. A move that changes the board needs destination membership unless the caller owns the task, is assigned to it, actively owns its current source board, or owns the tenant; destination visibility remains unchanged. Exactly one placement per request: `after_task_id` (a task in the target list) or `position` = `\"top\"`. A subtask answers 400 — it has no board placement — and an archived task, or a target list that is archived, deleted or in another tenant, answers 404. An `after_task_id` that was archived between the read and the call places the task at the top instead of failing: the fallback the board's own drag relies on. An `after_task_id` naming the task being moved is 400 before anything is read: a card cannot be placed behind itself. The position is computed by one atomic, row-locking RPC, so no `Idempotency-Key` is needed or accepted — a retry lands the card in the same place.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["board_list_id"],"additionalProperties":false,"properties":{"board_list_id":{"type":"string","format":"uuid","description":"Destination board list."},"after_task_id":{"type":"string","format":"uuid","description":"Place the task immediately after this task, which must be in the destination list."},"position":{"type":"string","enum":["top"],"description":"Place the task first in the destination list."}},"oneOf":[{"required":["after_task_id"]},{"required":["position"]}]}}}},"responses":{"200":{"description":"The task after the move","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error: both placements or neither, `position` other than `top`, a malformed list or task id, an unknown field, or SUBTASK_BOARD_FORBIDDEN for a subtask"},"404":{"description":"Task not found, not movable by this caller, or the destination list is not readable for them"}}}},"/mission/tasks/{id}/actions/promote":{"post":{"summary":"Promote Subtask to Task","description":"Turn a subtask into a top-level task on the same row: it keeps its code, status, assignee, due date, followers, comments, files and history, and leaves its parent. The caller must be the subtask's **owner**, its **assignee**, the **active owner of its parent's current board**, or a **tenant owner of its tenant** — the rule the screen applies, enforced by the same database function. A caller who may not promote gets the same 404 a missing task gets, as the other task actions answer. `board_list_id` names a live list of any live board of the tenant; the board is read from the list, and the task is placed at the end of it. The task's owner and assignee become members of that board. `board_list_id: null`, `{}` or an empty body promote it to no board. An archived or deleted subtask answers `409 SUBTASK_NOT_PROMOTABLE`. A list that is deleted, archived, on a deleted board or not in this tenant answers `400 DESTINATION_UNAVAILABLE`. A task that is already top-level answers 200 unchanged and records nothing, so a retry needs no `Idempotency-Key`: the call ignores `board_list_id` and returns the task as it stands, so compare its `board_list_id` with the one sent. On success one `task:subtask:promoted` event is recorded.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"board_list_id":{"type":"string","nullable":true,"format":"uuid","description":"Destination list; its board is read from it. Null or absent promotes the subtask to no board."}}}}}},"responses":{"200":{"description":"The task after the promotion, in the shape `GET /mission/tasks/{id}` returns","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error (a malformed `board_list_id`, an unknown field, a body that is not JSON), or DESTINATION_UNAVAILABLE: the list is not a live list of a live board of this tenant"},"401":{"description":"Missing or invalid API key"},"404":{"description":"Task not found, in another tenant, or not promotable by this caller"},"409":{"description":"SUBTASK_NOT_PROMOTABLE: the subtask is archived or deleted"}}}},"/mission/tasks/{id}/actions/claim":{"post":{"summary":"Claim Task (assign it to the caller)","description":"Assign an unassigned task to the caller. Any **active member of the task's tenant** may claim it — no owner or manager role is needed — and the assignee is always the authenticated caller, taken from the key: the body carries no fields, so a request can never assign the task to somebody else. Only an unassigned, live task can be claimed. An archived task answers `404` like every other task endpoint. A task that already has an assignee answers `409 TASK_ALREADY_ASSIGNED`, which is the same answer whether another member claimed it first or the caller already holds it — that is why no `Idempotency-Key` is accepted, and why the answer tells the caller to re-read the task. The write sets `assignee_id` alone, so ownership, followers, status and board placement stay as they were, and one `task:assigned` event is recorded. Addressed by task UUID.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task with the caller as its assignee","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"A body was sent (claim takes no fields), or the body is not valid JSON"},"403":{"description":"The caller's membership of the task's tenant changed between the gate and the write. A caller who is simply not a member is answered 404 by the tenant gate before the service runs"},"404":{"description":"Task not found"},"409":{"description":"TASK_ALREADY_ASSIGNED: the task is not unassigned — another member claimed it, or the caller already did; re-read the task to see its assignee"}}}},"/mission/tasks/{id}/actions/assign-agent":{"post":{"summary":"Assign Task To Agent","description":"Move an agent-responsible task to another agent. The task decides what is legal: it must already be assigned to an agent (`responsible_type = 'agent'` or an `assigned_agent_key`), and its agent task must still be `pending`. A task assigned to a person has no agent task to reassign, so it returns 400 — the two are exclusive; use `PATCH /mission/tasks/{id}` with `assignee_id` for a person. The caller must be able to edit the task; for a subtask that includes the parent task's owner and assignee. `agent_key` must name a published agent of this tenant or a system-scoped one. The reassignment updates `mission.tasks` and `ai.agent_tasks` in one transaction and emits `task:agent:reassigned`. No `Idempotency-Key`: naming the agent the task already has is a successful no-op, so a retry cannot duplicate anything.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["agent_key"],"additionalProperties":false,"properties":{"agent_key":{"type":"string","minLength":1,"description":"Key of the agent to move the task to, e.g. task-pilot."}}}}}},"responses":{"200":{"description":"The task after the reassignment","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Missing agent_key, or INVALID_AGENT: the task is not agent-responsible, its agent task is not pending, or the key names no published agent in this tenant"},"403":{"description":"Insufficient permission to reassign the task"},"404":{"description":"Task not found"}}}},"/mission/tasks/{id}/attachments/{attachmentId}/download":{"get":{"summary":"Download Task Attachment","description":"Get a short-lived signed download URL for a task-level attachment by task UUID.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Task UUID","schema":{"type":"string","format":"uuid"}},{"name":"attachmentId","in":"path","required":true,"description":"Attachment ID.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Signed download URL and file metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAttachmentDownloadResponse"}}}},"404":{"description":"Task or attachment not found"},"500":{"description":"Signed URL generation failed"}}}},"/mission/tasks/{id}/attachments":{"post":{"summary":"Upload Task Attachment","description":"Attach a file to a task in one request. The file is the `file` part of a `multipart/form-data` body; the server stores the bytes and records the metadata, so there is no presigned-URL round trip to orchestrate. Limits are the web UI's own: the MIME type must be one of the allowed types (`image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`, Word/Excel/PowerPoint, `text/plain`, `text/markdown`, `text/csv`, `application/zip`, `application/x-zip-compressed`) and the file must be larger than 0 bytes and no larger than 50 MB (52428800 bytes). The caller must be an active member of the task's tenant and the task's owner, its assignee, a tenant owner, or a member of its board; for a subtask, the subtask's owner or assignee, a tenant owner, the parent task's owner or assignee, or a member of the parent task's board. An optional `Idempotency-Key` header makes a retry safe: the first application returns 201, a replay of the same key with the same file returns 200 with the attachment already stored, and the same key with a different file (name, type or size) returns 409 (E0601).","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Task UUID","schema":{"type":"string","format":"uuid"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key, scoped to the tenant and the task. First application returns 201; a replay of the same key with the same file returns 200 with the attachment already stored; the same key with a different file returns 409 (E0601).","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The file. Its part header supplies the file name and the Content-Type the allow-list is checked against; send no separate fields."}}}}}},"responses":{"200":{"description":"Replay: the same Idempotency-Key was applied with the same file — the attachment stored by the earlier attempt, and nothing written.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskAttachment"}}}}}},"201":{"description":"The file is stored and recorded on the task.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskAttachment"}}}}}},"400":{"description":"The request was refused before anything was stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"notMultipart":{"summary":"Body is not multipart/form-data","value":{"error":{"code":"INVALID_CONTENT_TYPE","message":"Request body must be multipart/form-data carrying the file in a part named `file`"}}},"noFilePart":{"summary":"No part named `file`","value":{"error":{"code":"MISSING_FILE_PART","message":"The request must carry the file in a part named `file`"}}},"badType":{"summary":"MIME type not on the allow-list","value":{"error":{"code":"INVALID_FILE_TYPE","message":"File type \"application/x-msdownload\" is not allowed"}}},"badSize":{"summary":"Empty file, or larger than 50 MB","value":{"error":{"code":"INVALID_FILE_SIZE","message":"File size must be greater than 0 and at most 52428800 bytes"}}}}}}},"403":{"description":"The caller may not edit this task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Task not found (also the answer for another tenant's task — no existence leak)"},"409":{"description":"The Idempotency-Key was already used to attach a different file to this task (E0601)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"idempotencyConflict":{"summary":"Same key, different file","value":{"error":{"code":"E0601","message":"Idempotency-Key was already used to attach a different file to this task"}}}}}}},"500":{"description":"The storage write or the metadata write failed — nothing is left behind"}}}},"/mission/tasks/{id}/attachments/{attachmentId}":{"delete":{"summary":"Remove Task Attachment","description":"Retire a file from a task: the metadata leaves the task's attachment list and the stored object is deleted with it, in one request. The caller must be an active member of the task's tenant and the task's owner, its assignee, a tenant owner, or a member of its board; for a subtask, the subtask's owner or assignee, a tenant owner, the parent task's owner or assignee, or a member of the parent task's board. The answer names the attachment that was removed — the file name, type and size — which is a fact the caller cannot read back afterwards. No `Idempotency-Key` is accepted: a repeat is a read-side fact, not a duplicate write, so a second `DELETE` of the same attachment answers `404` (the same reasoning the archive action follows). A task description that embedded the file as markdown is not edited: the web UI strips that text itself (`apps/web/src/actions/task/task-attachment-actions.ts`), and this endpoint does not touch the description.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Task UUID","schema":{"type":"string","format":"uuid"}},{"name":"attachmentId","in":"path","required":true,"description":"Attachment id, from a task read's `attachments[].id` or from the upload response.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The attachment was removed, and this is what it was.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskAttachment"}}}}}},"403":{"description":"The caller may not edit this task","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Task not found (also the answer for another tenant's task — no existence leak), or the task holds no attachment with that id (which is also what a repeated DELETE answers), or the attachment id is not a UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"alreadyRemoved":{"summary":"The attachment is not on this task any more","value":{"error":{"code":"E0307","message":"Attachment not found"}}}}}}},"500":{"description":"The metadata write failed — the attachment is still on the task. A storage delete that fails after the metadata is gone is logged, not raised: the request still answers 200"}}}},"/mission/tasks/{id}/comments":{"get":{"summary":"List Task Comments","description":"Retrieve the chronological timeline of comments and activity for a task by UUID.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"type","in":"query","required":false,"description":"Filter the timeline. Omit to return both.","schema":{"type":"string","enum":["comment","activity"]}},{"name":"sort","in":"query","required":false,"description":"Order by created_at. Default desc (newest first).","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"description":"Page size. Values > 50 are rejected with 400 (not clamped).","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Task comment / activity timeline","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskCommentResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Invalid query parameter (type, sort, page, or limit)"},"404":{"description":"Task not found"}}},"post":{"summary":"Create Task Comment","description":"Add a comment to a task. Two bodies are accepted on the same endpoint. A `multipart/form-data` body carries the comment and its files together: a `content` text field and one `file` part per attachment (at most 10 files, each larger than 0 bytes and at most 50 MB, each of a type the web UI accepts — images, PDF, Word/Excel/PowerPoint, text/plain, text/markdown, text/csv, zip). The server stores each file and records the attachment, so a caller never orchestrates a presigned URL. An `application/json` body is unchanged: it takes `content` and/or `attachments`, an object of pre-uploaded attachment references keyed by attachment id. Either way, at least text or one file/attachment is required. The caller must be an active member of the task's tenant. On the multipart body an optional `Idempotency-Key` header makes a retry safe: the first application returns 201, a replay of the same key with the same comment returns 200 with the comment already written, and the same key with a different comment returns 409 (E0601).","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key, scoped to the tenant, the task and the caller. Read on a `multipart/form-data` body: the first application returns 201, a replay of the same key with the same comment returns 200 with the comment already written, and the same key with a different comment returns 409 (E0601). Two requests carrying the same key at the same time are decided by the unique index on the message, and the one that loses answers with the winner's result — 200 with its comment, or 409 when the key landed as a different comment — deleting nothing the winner may hold; when the read that decides cannot complete, the loser is answered as the failure it hit rather than guessed at. A JSON body does not carry a key.","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"content":{"type":"string","description":"Comment text. May be omitted when at least one `file` part is sent."},"file":{"type":"string","format":"binary","description":"A file to attach. Repeat the part for more than one file (at most 10). Each part's header supplies the file name and the Content-Type the allow-list is checked against."}}}},"application/json":{"schema":{"$ref":"#/components/schemas/PostTaskCommentRequest"}}}},"responses":{"200":{"description":"Replay: the same Idempotency-Key was applied with the same comment — the comment the earlier attempt wrote, and nothing stored a second time. Multipart bodies only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTaskCommentResponse"}}}},"201":{"description":"Comment created","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTaskCommentResponse"}}}},"400":{"description":"Invalid request body: neither text nor a file, an unreadable multipart body, more than 10 files (`TOO_MANY_FILES`), a file type outside the allow-list (`INVALID_FILE_TYPE`), or an empty or oversized file (`INVALID_FILE_SIZE`). For a JSON body: missing content and attachments, or non-JSON.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"nothingToSay":{"summary":"A multipart body with no `content` and no file","value":{"error":{"code":"VALIDATION_ERROR","message":"A comment must carry text (a `content` field), a file, or both"}}},"tooManyFiles":{"summary":"More files than a comment carries","value":{"error":{"code":"TOO_MANY_FILES","message":"A comment carries at most 10 files"}}}}}}},"403":{"description":"Caller lacks task visibility"},"404":{"description":"Task not found"},"409":{"description":"The Idempotency-Key was already used to post a different comment to this task (E0601). Multipart bodies only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"idempotencyConflict":{"summary":"Same key, different comment","value":{"error":{"code":"E0601","message":"Idempotency-Key was already used to post a different comment to this task"}}}}}}}}}},"/mission/tasks/{id}/comments/attachments/{attachmentId}/download":{"get":{"summary":"Download Comment Attachment","description":"Get a short-lived signed download URL for a comment-level attachment by task UUID.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"description":"Task UUID","schema":{"type":"string","format":"uuid"}},{"name":"attachmentId","in":"path","required":true,"description":"Attachment ID.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Signed download URL and file metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAttachmentDownloadResponse"}}}},"404":{"description":"Task or attachment not found"},"500":{"description":"Signed URL generation failed"}}}},"/mission/tasks/{id}/history":{"get":{"summary":"List Task Status History","description":"Every status change a task has been through, newest first, with the member who made each one. This is the status log, not the activity timeline: the same change also posts an activity message on `/mission/tasks/{id}/comments?type=activity`, which reads as \"what happened to this card\", while this answers \"which status did it hold, from when, changed by whom\" — the shape an integrator syncs against. The order is fixed newest first, so **`sort` is refused with HTTP 400 `INVALID_QUERY_PARAMS`** rather than accepted and ignored: the sibling comments endpoint takes a sort because its service honours one, and this read has none to expose. The caller must be an active member of the task's tenant; another tenant's task, and an archived one, answer `404` like every other task read.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"description":"Page size. Values > 50 are rejected with 400 (not clamped).","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Task status history, newest first","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTaskStatusHistoryListResponse"}}}},"400":{"description":"An invalid page or limit, or a `sort` — this read is always newest first and has no sort to expose","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sortRefused":{"summary":"A sort on a read whose order is fixed","value":{"error":{"code":"INVALID_QUERY_PARAMS","message":"this read is always newest first and takes no sort parameter"}}}}}}},"404":{"description":"Task not found"}}}},"/mission/tasks/{id}/subtasks":{"post":{"summary":"Create Subtasks","tags":["Tasks"],"description":"Create batch subtasks (up to 25) under a parent task by UUID. All-or-nothing validation.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubtasksRequest"}}}},"responses":{"201":{"description":"Subtasks created successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"parent_task":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"title":{"type":"string"}}},"subtasks":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}}}}},"400":{"description":"Validation error (all-or-nothing) — nothing was created. Possible codes: SUBTASK_VALIDATION_ERROR, SUBTASK_INVALID, INVALID_ASSIGNEE. PARENT_TASK_NOT_FOUND and PARENT_TASK_ARCHIVED exist in the service layer but are not reachable here: the parent lookup answers 404 first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Permission denied — not parent task owner or tenant owner, or not a tenant member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Parent task not found — unknown, archived, or in another tenant. The Public API treats an archived task as absent, so the parent lookup answers before the PARENT_TASK_ARCHIVED branch can run.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Tasks"],"summary":"List Subtasks","description":"List subtasks of a parent task by UUID.","operationId":"listSubtasks","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Task UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated subtask list","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}},"examples":{"twoSubtasks":{"summary":"Parent task with 2 subtasks","value":{"data":[{"id":"550e8400-e29b-41d4-a716-446655440001","code":"TASK-124","title":"Check server logs","description":null,"status":"To-Do","priority":"High","assignee":{"id":"123e4567-e89b-12d3-a456-426614174000","display_name":"Alice","member_code":"MEM-ALICE","email":"alice@example.com"},"owner":{"id":"123e4567-e89b-12d3-a456-426614174001","display_name":"Bob","member_code":"MEM-BOB","email":"bob@example.com"},"board_id":null,"board_list_id":null,"due_date":"2026-07-15T00:00:00.000Z","parent":{"id":"550e8400-e29b-41d4-a716-446655440000","code":"TASK-123","title":"Alert: System Monitor"},"has_subtasks":false,"attachments":[]},{"id":"550e8400-e29b-41d4-a716-446655440002","code":"TASK-125","title":"Notify on-call team","description":"Send Slack notification","status":"Doing","priority":"Urgent","assignee":null,"owner":{"id":"123e4567-e89b-12d3-a456-426614174001","display_name":"Bob"},"board_id":null,"board_list_id":null,"due_date":null,"parent":{"id":"550e8400-e29b-41d4-a716-446655440000","code":"TASK-123","title":"Alert: System Monitor"},"has_subtasks":false,"attachments":[]}],"meta":{"page":1,"limit":2,"total":2,"has_more":false}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Parent task not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/mission/tasks/{id}/subtasks/{subtaskId}":{"delete":{"summary":"Delete a Subtask","description":"Retire one subtask, leaving its parent task and its sibling subtasks exactly as they are. The caller must be the **parent task's** owner, its assignee, or a **tenant owner of its tenant**; a subtask's own assignee is refused — deleting is a lifecycle operation, not a board edit — and ordinary board membership alone does not qualify either. The subtask must belong to the parent named in the address — a subtask under a different parent answers `404`. The body carries no fields: the subtask is named by its address and the delete records no reason. The answer names the subtask by id (`200 { \"data\": { \"id\": … } }`) rather than returning a row every read now answers `404` for. A repeat answers `404`, which is also why no `Idempotency-Key` is accepted: a replay is a read-side fact, not a duplicate write. Retiring a top-level task with its subtasks is `DELETE /mission/tasks/{id}`.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Parent task UUID"},{"name":"subtaskId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Subtask UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The subtask was retired; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"A body was sent (delete takes no fields), or the body is not valid JSON"},"403":{"description":"The caller is not the parent task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant — a subtask's own assignee is refused"},"404":{"description":"Parent task or subtask not found — unknown, archived, another tenant's, or a subtask that belongs to a different parent"}}},"patch":{"summary":"Reorder a Subtask","description":"Reorder one subtask among its siblings. The body carries a single field, `after_subtask_id`: the sibling to sit directly behind, or `null` to sit first among them — `serviceMoveSubtaskToAfter`'s own meaning for a null anchor, so there is one spelling per intent. The caller must be a member who may edit the subtask: its owner, its assignee, a **tenant owner of its tenant**, an active member of the **parent task's** board, or the parent task's owner or assignee. Any other member is refused. The subtask must belong to the parent named in the address — a subtask under a different parent answers `404`. The answer is the subtask in the same shape `GET /mission/tasks/{id}` returns, with its server-computed `position`: the placement is computed by one atomic, row-locking RPC, so a retry lands the subtask in the same place and no `Idempotency-Key` is needed or accepted. A subtask lives in no board list — moving a top-level card between lists is `POST /mission/tasks/{id}/actions/move`.","tags":["Tasks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Parent task UUID"},{"name":"subtaskId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Subtask UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["after_subtask_id"],"additionalProperties":false,"properties":{"after_subtask_id":{"type":"string","format":"uuid","nullable":true,"description":"Place the subtask immediately after this sibling; null places it first."}}}}}},"responses":{"200":{"description":"The subtask after the move, with its server-computed `position`","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error: a missing or unknown field, `after_subtask_id` that is not a UUID, or `after_subtask_id` naming the subtask itself"},"403":{"description":"Permission denied — the caller may not edit the subtask"},"404":{"description":"Parent task or subtask not found — unknown, archived, another tenant's, a subtask that belongs to a different parent, or an `after_subtask_id` that is a live sibling under another parent. An `after_subtask_id` that is archived, deleted, or in another tenant is not found either, but does not fail: it places the subtask first among its siblings, the same fall-back the card move makes."}}}},"/pcms/brands":{"post":{"summary":"Create Brand","tags":["PCMS"],"description":"Create a new brand in the tenant's catalog. A URL-safe slug is auto-generated from the name.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Nike"},"logo_url":{"type":"string","format":"uri","nullable":true,"example":"https://example.com/logo.png"}}}}}},"responses":{"201":{"description":"Brand created successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBrandResponse"}}}}}},"400":{"description":"Missing or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"409":{"description":"A brand with this name (slug) already exists in the tenant"}}},"get":{"summary":"List Brands","tags":["PCMS"],"description":"Paginated, name-sorted list of brands for a tenant. Use `?q=` for name-contains search to resolve brand names to UUIDs before creating products.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}},{"name":"q","in":"query","description":"Name contains filter (case-insensitive, max 200 chars). Empty or absent means no filter.","schema":{"type":"string","maxLength":200}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Paginated list of brands sorted by name ascending","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicBrandResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied"}}}},"/pcms/brands/{id}":{"get":{"summary":"Get Brand by ID","tags":["PCMS"],"description":"Fetch a single brand by id, tenant-scoped. Returns 404 if the brand does not exist or belongs to another tenant.","parameters":[{"name":"id","in":"path","required":true,"description":"Brand UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Brand detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBrandResponse"}}}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Brand not found or does not belong to this tenant"}}},"patch":{"summary":"Partial Update Brand","tags":["PCMS"],"description":"Partially update a brand (PATCH). All fields are optional; at least one must be provided. Fields not supplied are left unchanged. Use PUT to fully replace.","parameters":[{"name":"id","in":"path","required":true,"description":"Brand UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Nike"},"logo_url":{"type":"string","format":"uri","nullable":true}},"description":"Partial update — only the provided fields are changed; at least one field is required."}}}},"responses":{"200":{"description":"Brand updated successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBrandResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Brand not found or does not belong to this tenant"},"409":{"description":"A brand with this name (slug) already exists in the tenant"}}},"put":{"summary":"Update Brand","tags":["PCMS"],"description":"Update an existing brand. At least one field must be provided. Fields not provided are left unchanged. Updating `name` automatically recomputes the slug.","parameters":[{"name":"id","in":"path","required":true,"description":"Brand UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Nike"},"logo_url":{"type":"string","format":"uri","nullable":true}}}}}},"responses":{"200":{"description":"Brand updated successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicBrandResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Brand not found or does not belong to this tenant"},"409":{"description":"A brand with this name (slug) already exists in the tenant"}}},"delete":{"summary":"Delete Brand","tags":["PCMS"],"description":"Soft-delete a brand, as the PCMS editor does. The brand leaves every read, is never purged, and its slug is free for a new brand at once. Only the tenant owner or a member with the catalog admin permission may delete; the key acts as the member it belongs to. A brand a live product uses, whatever the product's status, is refused with `409` and nothing is deleted. Deleted products do not count. A repeated delete answers `404`: the first one succeeded and the brand is gone, so a client that lost the response and retries should treat `404` as done. The answer names the item by id, because every read answers `404` once the delete has run. Any request body and any query parameter are ignored; the tenant is the one the key and `X-Tenant-Code` name.","parameters":[{"name":"id","in":"path","required":true,"description":"Brand UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The brand was deleted; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"Missing X-Tenant-Code"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Brand not found: unknown, malformed id, already deleted, or another tenant's. Error code `E9401`"},"409":{"description":"The brand is used by a live product (`E9435`)"},"500":{"description":"The use check or the delete itself failed; nothing was deleted"}}}},"/pcms/categories":{"post":{"summary":"Create Category","tags":["PCMS"],"description":"Create a new product category. A slug is auto-generated from the name.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Electronics"},"parent_id":{"type":"string","format":"uuid","nullable":true,"description":"Parent category UUID. Omit or pass null for a root category."}}}}}},"responses":{"201":{"description":"Category created successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicCategoryResponse"}}}}}},"400":{"description":"Missing or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"409":{"description":"A category with this name (slug) already exists in the tenant"},"422":{"description":"Invalid parent_id (not found or cross-tenant)"}}},"get":{"summary":"List Categories","tags":["PCMS"],"description":"Paginated, name-sorted list of categories for a tenant; categories with the same name are ordered by `id`, so each appears once across the pages. Use `?q=` for name-contains search. Use `?parent_id=` to list the direct children of one category, or `?parent_id=null` for the root categories; the list stays flat, with the same item shape. A deeper level is read by repeating the call with a child's id.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string","maxLength":200}},{"name":"parent_id","in":"query","description":"A category UUID lists its direct children; the word `null` (lowercase) lists the root categories. Supplied once. A parent that does not exist, is deleted or belongs to another tenant gives an empty page, not `404`.","schema":{"type":"string","example":"null"}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Paginated list of categories sorted by name ascending","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicCategoryResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code or invalid query parameters, including a `parent_id` that is neither a UUID nor `null`, empty or repeated"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied"}}}},"/pcms/categories/{id}":{"get":{"summary":"Get Category by ID","tags":["PCMS"],"description":"Fetch a single category by id, tenant-scoped. Returns 404 if the category does not exist or belongs to another tenant.","parameters":[{"name":"id","in":"path","required":true,"description":"Category UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Category detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicCategoryResponse"}}}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Category not found or does not belong to this tenant"}}},"patch":{"summary":"Partial Update Category","tags":["PCMS"],"description":"Partially update a category (PATCH). All fields are optional; at least one must be provided. Fields not supplied are left unchanged. Use PUT to fully replace.","parameters":[{"name":"id","in":"path","required":true,"description":"Category UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500},"parent_id":{"type":"string","format":"uuid","nullable":true,"description":"Pass null to promote to root. Omit to leave unchanged."}},"description":"Partial update — only the provided fields are changed; at least one field is required."}}}},"responses":{"200":{"description":"Category updated successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicCategoryResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Category not found or does not belong to this tenant"},"409":{"description":"A category with this name (slug) already exists in the tenant"},"422":{"description":"Circular reference or invalid parent_id"}}},"put":{"summary":"Update Category","tags":["PCMS"],"description":"Update an existing category. Pass `parent_id: null` to promote to root. Updating `name` recomputes the slug.","parameters":[{"name":"id","in":"path","required":true,"description":"Category UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500},"parent_id":{"type":"string","format":"uuid","nullable":true,"description":"Pass null to promote to root. Omit to leave unchanged."}}}}}},"responses":{"200":{"description":"Category updated successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicCategoryResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Category not found or does not belong to this tenant"},"409":{"description":"A category with this name (slug) already exists in the tenant"},"422":{"description":"Circular reference or invalid parent_id"}}},"delete":{"summary":"Delete Category","tags":["PCMS"],"description":"Soft-delete a category, as the PCMS editor does. The category leaves every read, is never purged, and its slug is free for a new category at once. Only the tenant owner or a member with the catalog admin permission may delete; the key acts as the member it belongs to. A category with a live child category, or a live product of any status filed under it, is refused with `409` and nothing is deleted; a category with both answers the children code. Deleted children and deleted products do not count. A repeated delete answers `404`: the first one succeeded and the category is gone, so a client that lost the response and retries should treat `404` as done. The answer names the item by id, because every read answers `404` once the delete has run. Any request body and any query parameter are ignored; the tenant is the one the key and `X-Tenant-Code` name.","parameters":[{"name":"id","in":"path","required":true,"description":"Category UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The category was deleted; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"Missing X-Tenant-Code"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Category not found: unknown, malformed id, already deleted, or another tenant's. Error code `E9405`"},"409":{"description":"The category has a live child category (`E9439`) or a live product (`E9440`); a category with both answers `E9439`"},"500":{"description":"The use check or the delete itself failed; nothing was deleted"}}}},"/pcms/product-types":{"post":{"summary":"Create Product Type","tags":["PCMS"],"description":"Create a new product type in the tenant's catalog. Name must be unique within the tenant (case-insensitive).","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Smartphone"},"description":{"type":"string","maxLength":2000,"nullable":true}}}}}},"responses":{"201":{"description":"Product type created successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductTypeResponse"}}}}}},"400":{"description":"Missing or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"409":{"description":"A product type with this name already exists in the tenant"}}},"get":{"summary":"List Product Types","tags":["PCMS"],"description":"Paginated, name-sorted list of product types for a tenant. Use `?q=` for name-contains search.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string","maxLength":200}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Paginated list of product types sorted by name ascending","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicProductTypeResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied"}}}},"/pcms/product-types/{id}":{"get":{"summary":"Get Product Type by ID","tags":["PCMS"],"description":"Fetch a single product type by id, tenant-scoped. Returns 404 if the product type does not exist or belongs to another tenant.","parameters":[{"name":"id","in":"path","required":true,"description":"Product type UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Product Type detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductTypeResponse"}}}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Product type not found or does not belong to this tenant"}}},"patch":{"summary":"Partial Update Product Type","tags":["PCMS"],"description":"Partially update a product type (PATCH). All fields are optional; at least one must be provided. Fields not supplied are left unchanged. Use PUT to fully replace.","parameters":[{"name":"id","in":"path","required":true,"description":"Product type UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500},"description":{"type":"string","maxLength":2000,"nullable":true}},"description":"Partial update — only the provided fields are changed; at least one field is required."}}}},"responses":{"200":{"description":"Product type updated successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductTypeResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Product type not found or does not belong to this tenant"},"409":{"description":"A product type with this name already exists in the tenant"}}},"put":{"summary":"Update Product Type","tags":["PCMS"],"description":"Update an existing product type. At least one field must be provided. Fields not provided are left unchanged. Name must be unique (case-insensitive) within the tenant.","parameters":[{"name":"id","in":"path","required":true,"description":"Product type UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500},"description":{"type":"string","maxLength":2000,"nullable":true}}}}}},"responses":{"200":{"description":"Product type updated successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductTypeResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Product type not found or does not belong to this tenant"},"409":{"description":"A product type with this name already exists in the tenant"}}},"delete":{"summary":"Delete Product Type","tags":["PCMS"],"description":"Soft-delete a product type, as the PCMS editor does. The product type leaves every read, is never purged, and its name is free for a new product type at once. Only the tenant owner or a member with the catalog admin permission may delete; the key acts as the member it belongs to. A product type a live product uses, whatever the product's status, is refused with `409` and nothing is deleted. Deleted products do not count. A repeated delete answers `404`: the first one succeeded and the product type is gone, so a client that lost the response and retries should treat `404` as done. The answer names the item by id, because every read answers `404` once the delete has run. Any request body and any query parameter are ignored; the tenant is the one the key and `X-Tenant-Code` name.","parameters":[{"name":"id","in":"path","required":true,"description":"Product type UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The product type was deleted; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"Missing X-Tenant-Code"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Product type not found: unknown, malformed id, already deleted, or another tenant's. Error code `E9409`"},"409":{"description":"The product type is used by a live product (`E9437`)"},"500":{"description":"The use check or the delete itself failed; nothing was deleted"}}}},"/pcms/products":{"get":{"summary":"List Products (Catalog Sync)","tags":["PCMS"],"description":"Paginated product feed for external catalog synchronization.\n\nSupports three composable modes: full/delta sync via `updated_since` (ISO 8601 UTC) for incremental pulls, explicit fetch via `ids` (comma-separated UUIDs, max 50) for known subsets, and free-text search via `q` for substring matches against product name, variant name, SKU, or barcode. All three AND-compose with each other and with pagination; results remain ordered by `updated_at` ascending for stable cursor checkpointing.\n\nWhen `q` matches against a variant field, the full parent product is returned with all non-orphaned variants — parity with `updated_since` semantics. All modes are tenant-scoped (foreign IDs and foreign-tenant matches are silently filtered, never leaked).\n\nDeleted products are included with `is_deleted: true` so consumers can remove them from their local catalog. Each product's `variants` array contains only active variants.\n\nInternal fields (cost, audit metadata, tenancy identifiers) are omitted from the response.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant to scope the request to.","schema":{"type":"string"}},{"name":"updated_since","in":"query","description":"ISO 8601 UTC datetime. When provided, only products updated at or after this timestamp are returned — including products where an active variant was updated. Example: `2026-01-01T00:00:00Z`.","schema":{"type":"string","format":"date-time"}},{"name":"ids","in":"query","description":"Comma-separated list of product UUIDs to restrict the response to. Max 50 IDs per request. Foreign IDs (belonging to other tenants) are silently filtered. Returns `400 INVALID_IDS` for malformed input (non-UUID token, empty value, more than 50 IDs).","required":false,"schema":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"}},"style":"form","explode":false,"example":"11111111-1111-4111-8111-111111111111,22222222-2222-4222-8222-222222222222"},{"name":"brand_ids","in":"query","description":"Comma-separated list of brand UUIDs. When provided, only products whose `brand_id` matches one of the listed UUIDs are returned. AND-composes with all other filters. Returns `400 INVALID_BRAND_IDS` for malformed input (non-UUID token, empty value, more than 50 IDs).","required":false,"schema":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"}},"style":"form","explode":false},{"name":"category_ids","in":"query","description":"Comma-separated list of category UUIDs. When provided, only products whose `category_id` matches one of the listed UUIDs are returned. AND-composes with all other filters. Returns `400 INVALID_CATEGORY_IDS` for malformed input (non-UUID token, empty value, more than 50 IDs).","required":false,"schema":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"}},"style":"form","explode":false},{"name":"product_type_ids","in":"query","description":"Comma-separated list of product type UUIDs. When provided, only products whose `product_type_id` matches one of the listed UUIDs are returned. AND-composes with all other filters. Returns `400 INVALID_PRODUCT_TYPE_IDS` for malformed input (non-UUID token, empty value, more than 50 IDs).","required":false,"schema":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"}},"style":"form","explode":false},{"name":"unit_ids","in":"query","description":"Comma-separated list of unit UUIDs. When provided, only products whose `unit_id` matches one of the listed UUIDs are returned. AND-composes with all other filters. Returns `400 INVALID_UNIT_IDS` for malformed input (non-UUID token, empty value, more than 50 IDs).","required":false,"schema":{"type":"array","maxItems":50,"items":{"type":"string","format":"uuid"}},"style":"form","explode":false},{"name":"tags","in":"query","required":false,"description":"Comma-separated catalog tag names, any letter case; percent-encode non-ASCII names. Only products carrying every listed tag, themselves or through one variant that is neither deleted nor orphaned (its own and inherited tags), are returned. A deleted product carrying every listed tag is returned as a tombstone (`is_deleted: true`), as under `brand_ids`. A name that is no tag of the tenant matches nothing: `200` with an empty `data`. Empty names are ignored; absent or empty means no tag filter. AND-composes with all other filters. Send one `tags` parameter with at most 50 distinct names (letter case ignored); a repeated `tags` parameter or more than 50 distinct names is `400 INVALID_TAGS`.","schema":{"type":"string","example":"Hàng Tết,Bán chạy"}},{"name":"q","in":"query","description":"Free-text search fragment (2–500 characters). Matches case-insensitive substring (`ILIKE %q%`) against six columns for active (non-deleted) products: product name, product aliases, product tags, variant name, variant SKU, and variant barcode. When a variant matches, the full parent product is returned with all non-orphaned variants. For soft-deleted (tombstone) products, search is limited to product name, aliases, and tags only — variant fields (name, SKU, barcode) are not searchable because variant data for deleted products is not indexed. To retrieve all tombstones, omit `q` and use `updated_since` or a full sync instead. Composes with `ids` (which is independently capped at 50 UUIDs) and `updated_since` as a strict intersection — supplying all three narrows the result. Special characters `%` and `_` inside `q` are treated literally (escaped), not as wildcards. Control characters and Unicode-whitespace-only values are rejected with `INVALID_SEARCH`.","required":false,"schema":{"type":"string","minLength":2,"maxLength":500,"example":"iphone"}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp at query execution — use as `updated_since` for the next delta sync call.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicProductResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Invalid query parameters (e.g. malformed updated_since, malformed ids, out-of-range limit, or a repeated `tags` parameter or more than 50 tag names)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Create product","tags":["PCMS"],"description":"Create a new product within the tenant identified by `X-Tenant-Code`.\n\nSupports two creation modes:\n- **Simple mode**: omit `options` and `variants`. A single default variant is created from `sku`, `barcode`, and `price` on the product body.\n- **Variant mode**: supply 1–2 entries in `options` (e.g. Color, Size) plus a `variants` array. Each variant must populate `option1`/`option2` with the chosen value for the corresponding option (positions match `options[]` order). The backend does not auto-generate the cartesian product — clients are responsible for sending the exact list of variants to create.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant to scope the request to.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","unit_id"],"properties":{"properties":{"$ref":"#/components/schemas/ProductPropertyInput"},"name":{"type":"string","maxLength":500},"description":{"type":"string","maxLength":10000,"nullable":true},"brand_id":{"type":"string","format":"uuid","nullable":true},"category_id":{"type":"string","format":"uuid","nullable":true},"product_type_id":{"type":"string","format":"uuid","nullable":true},"unit_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["DRAFT","ACTIVE","ARCHIVED"],"default":"DRAFT"},"currency":{"type":"string","default":"VND"},"sku":{"type":"string","nullable":true,"description":"Simple mode only: SKU for the auto-created default variant. Ignored when `options`/`variants` are provided."},"barcode":{"type":"string","nullable":true,"description":"Simple mode only: barcode for the auto-created default variant."},"price":{"type":"number","nullable":true,"description":"Simple mode only: price for the auto-created default variant."},"manufacturer_code":{"type":"string","nullable":true,"description":"Simple mode only: manufacturer-assigned code for the auto-created default variant. Ignored when `options`/`variants` are provided."},"legacy_code":{"type":"string","nullable":true,"description":"Simple mode only: code carried over from a legacy system for the auto-created default variant."},"extra_data":{"type":"object","nullable":true,"additionalProperties":true,"description":"Simple mode only: arbitrary key-value metadata for the auto-created default variant."},"aliases":{"type":"array","items":{"type":"string"},"description":"Alternative names / search aliases for this product.","example":["áo thun","t-shirt"]},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":30},"description":"Tag names the product carries from creation. A name is 1–30 letters, digits, spaces, hyphens or underscores after surrounding spaces are trimmed. A name that matches one of the tenant's tags in another letter case or with other surrounding spaces carries that tag under its existing name; a new name creates the tag, with a colour the system assigns. One invalid name refuses the whole create with `400 E9473` and no product is written.","example":["Hàng Tết","Bán chạy"]},"options":{"type":"array","maxItems":2,"description":"Variant mode: defines the option axes (e.g. Color, Size). Up to 2 options; their order determines which `option1/2` slot each value maps to.","items":{"type":"object","required":["name","values"],"properties":{"name":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}}}},"variants":{"type":"array","description":"Variant mode: explicit list of variants to create. Each variant must set `option1`/`option2` matching the values declared in `options[]` (by position).","items":{"type":"object","required":["name"],"properties":{"name":{"type":"string"},"sku":{"type":"string","nullable":true},"barcode":{"type":"string","nullable":true},"price":{"type":"number","nullable":true},"option1":{"type":"string","nullable":true,"description":"Value for `options[0]`. Required when `options[0]` is defined."},"option2":{"type":"string","nullable":true,"description":"Value for `options[1]`."},"manufacturer_code":{"type":"string","nullable":true,"description":"Manufacturer-assigned code for this variant. Optional."},"legacy_code":{"type":"string","nullable":true,"description":"Code carried over from a legacy system (e.g. ERP migration). Optional."},"extra_data":{"type":"object","nullable":true,"additionalProperties":true,"description":"Arbitrary key-value metadata for this variant."},"tags":{"type":"array","items":{"type":"string"},"description":"The new variant's own tag names. A name that matches one of the tenant's tags in another letter case or with other surrounding spaces is that tag; a new name creates the tag. A name that breaks the tag name rule is `400` `E9473` and creates nothing. A name the product already carries is not stored as the variant's own; the variant inherits it."}}}}}}}}},"responses":{"201":{"description":"Product created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductResponse"}}}}}},"400":{"description":"Validation error or invalid product payload. `E9473`: a name in `tags` breaks the tag name rule; nothing is written.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"}}}},"/pcms/products/{id}":{"get":{"summary":"Get Product Detail","tags":["PCMS"],"description":"Fetch a single product by id (same shape as the catalog sync feed), tenant-scoped. Returns 404 if the product does not exist or belongs to another tenant.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","description":"Scope to a tenant. If omitted, the product is resolved across all tenants the caller belongs to.","schema":{"type":"string"}}],"responses":{"200":{"description":"Product detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductResponse"}}}}}},"404":{"description":"Product not found"}}},"put":{"summary":"Update product details","tags":["PCMS"],"description":"Update the core details of a product. At least one field must be provided. Fields not provided are left unchanged. Variant-level fields (`sku`, `barcode`, `price`, option values) are intentionally **not** editable through this endpoint — use `PUT /pcms/products/{id}/variants` to modify variants.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"description":"At least one field must be provided.","properties":{"properties":{"$ref":"#/components/schemas/ProductPropertyInput"},"name":{"type":"string","maxLength":500},"description":{"type":"string","nullable":true},"brand_id":{"type":"string","format":"uuid","nullable":true},"category_id":{"type":"string","format":"uuid","nullable":true},"product_type_id":{"type":"string","format":"uuid","nullable":true},"unit_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["DRAFT","ACTIVE","ARCHIVED"]},"currency":{"type":"string"},"aliases":{"type":"array","items":{"type":"string"}}}}}}},"responses":{"200":{"description":"Product updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductResponse"}}}}}},"400":{"description":"Validation error or invalid payload. `E9474`: the body carries `tags`, whatever the list holds; no field is changed. Tags change one at a time through `POST` and `DELETE /pcms/products/{id}/tags/{name}`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Product not found in the specified tenant"}}}},"/pcms/products/{id}/tags/{name}":{"post":{"summary":"Add a tag to a product","tags":["PCMS"],"description":"Add one tag to the product, by name. The key's member is the actor and needs the right to edit the product.\n\n- A name that matches one of the tenant's tags in another letter case or with other surrounding spaces adds that tag under its existing name.\n- A new name creates the tag, with a colour the system assigns.\n- Adding a tag the product already carries changes nothing and still answers `200`.\n\nEach tag added is recorded as its own change and sent to VTech POS. The answer is the product's tags after the call.","parameters":[{"name":"id","in":"path","required":true,"description":"Product UUID","schema":{"type":"string","format":"uuid"}},{"name":"name","in":"path","required":true,"description":"The tag name, percent-encoded (a space is `%20`).","schema":{"type":"string"},"example":"H%C3%A0ng%20T%E1%BA%BFt"},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}}],"responses":{"200":{"description":"The product's tags after the call.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}}}}}}}}},"400":{"description":"`E9473`: the name breaks the tag name rule; nothing changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"`E0102`: the key's member may not edit products of this tenant; nothing changes."},"404":{"description":"Product not found in the specified tenant"}}},"delete":{"summary":"Remove a tag from a product","tags":["PCMS"],"description":"Remove one tag from the product, by name, in any letter case. The key's member is the actor and needs the right to edit the product. Removing a tag the product does not carry changes nothing and still answers `200`. The tag stays in the tenant's tag list. Each tag removed is recorded as its own change and sent to VTech POS. The answer is the product's tags after the call.","parameters":[{"name":"id","in":"path","required":true,"description":"Product UUID","schema":{"type":"string","format":"uuid"}},{"name":"name","in":"path","required":true,"description":"The tag name, percent-encoded (a space is `%20`).","schema":{"type":"string"},"example":"H%C3%A0ng%20T%E1%BA%BFt"},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}}],"responses":{"200":{"description":"The product's tags after the call.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}}}}}}}}},"400":{"description":"`E9473`: the name breaks the tag name rule; nothing changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"`E0102`: the key's member may not edit products of this tenant; nothing changes."},"404":{"description":"Product not found in the specified tenant"}}}},"/pcms/products/{id}/media":{"post":{"summary":"Add product media","tags":["PCMS"],"description":"Add an image or video to a product's gallery. Send a `multipart/form-data` body with a `file` part, or a JSON body `{ \"source_url\": \"https://...\" }`, never both. Optional `variant_ids` links the item to those variants of this product, and `is_default: true` makes it the product default. The first item of a product is the product default without asking.\n\nOne call does what the PCMS editor does in several steps: it checks for a duplicate, stores the bytes in private storage, creates (or replaces) the item, links the variants and sets the default.\n\n**Limits.** A product holds at most 50 live media items (`E9454`). JPEG, PNG, WebP and GIF up to 10 MB (`E9452`), MP4 up to 50 MB, no empty file (`E9460`). The type is decided from the bytes, never from the `Content-Type` or the file name; anything else is `E9453`. The stored file's extension follows the detected type. The response never carries a storage path: the item carries a signed link in `url` and its expiry in `expires_at`.\n\n**File name.** An upload's name is the multipart file name. For `source_url` it is the last path segment, percent-decoded once, with the query and fragment dropped; `filename` in the JSON body overrides it. A name must be 1 to 200 characters with no path separator or control character. The duplicate check compares the name as given (trimmed, case-insensitive) with the file size.\n\n**Duplicates.** The same name and size as a live item, with the same width and height, answers `200` with the existing item and stores nothing. The same name and size with different dimensions replaces that item's content (`200`, `content_version` +1; its links, position and default are kept); a caller without content rights is refused with `403`.\n\n**`source_url`.** Fetched by the server: `https` only, port 443, no user or password in the address. Every address the host resolves to must be public (loopback, private, link-local including the cloud metadata address, shared, unspecified and reserved ranges are refused, at every redirect), the connection is made to the address that was checked, at most 3 redirects, 10 seconds in total, and the download is cut at the size limit whatever `Content-Length` says. A refused address or a redirect loop is `400 INVALID_SOURCE_URL`; a host that does not answer, times out or answers with an error is `502 SOURCE_FETCH_FAILED`.\n\n**Retry.** A repeat of the same file is safe without a key (the duplicate rule). `Idempotency-Key` adds the usual contract for a **new** item: the same key with the same request answers `200` with that item (and re-applies `variant_ids` and `is_default`), the same key with another request is `409` `E0601`. A request that resolves to an identical or replaced item stores no key, so reusing a key with another request is not detected there; the duplicate rule still makes that retry safe.\n\n**Not atomic.** The bytes are stored first, then the item, then the links and the default. If a step after the item exists fails (for example a variant that is not this product's, `404`), the item stays in place, the message names the step (`linking variants` or `setting the default`), and the item's id is in `data.id`; retry with the same key or use `PATCH`. A failure before the item exists leaves no stored file.\n\n**Request size.** The whole body is read in memory. The hosting platform may refuse a large request body before it reaches this endpoint (the limit has not been measured for this endpoint); if a large upload is refused by the host, send the file by `source_url` instead.","parameters":[{"name":"id","in":"path","required":true,"description":"Product UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional, at most 255 characters. Makes a retry of a new item safe.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The image or video. The part's file name is the item's name."},"variant_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Repeat the field for each variant id (a single JSON array string is also read)."},"is_default":{"type":"boolean","description":"Send `true` or `false` as text."}}}},"application/json":{"schema":{"type":"object","required":["source_url"],"additionalProperties":false,"properties":{"source_url":{"type":"string","maxLength":2048,"description":"Public `https` address of the file, port 443."},"filename":{"type":"string","maxLength":200,"description":"Overrides the name taken from the address."},"variant_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Variants of this product to link."},"is_default":{"type":"boolean","description":"Make this item the product default."}}}}}},"responses":{"200":{"description":"Nothing new was stored: an identical file, a content replace, or a replay of the same Idempotency-Key.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductMedia"}}}}},"headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}}},"201":{"description":"The item was created.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductMedia"}}}}},"headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Refused before anything was stored: invalid body or file name, `E9452` too large, `E9453` not an accepted type, `E9454` product holds 50 items, `E9460` empty file, `INVALID_SOURCE_URL`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The key's member lacks content rights (owner, catalog admin or catalog contributor).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Product not found (or another tenant's), or a variant in `variant_ids` is not this product's. In the second case the item exists and `data.id` names it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`E0601`: the Idempotency-Key was used for a different request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"`SOURCE_FETCH_FAILED`: the `source_url` host did not answer, timed out or answered with an error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/pcms/products/{id}/media/{mediaId}":{"patch":{"summary":"Change a product media item","tags":["PCMS"],"description":"Change one item's order, default, variant links or alt text. At least one field is required; unknown fields are refused.\n\n- `position`: 1-based place among the product's live items. The other items keep their relative order and the default does not change. A value below 1 or past the number of items is `400`; it is never clamped.\n- `is_default`: only `true`. The item becomes the product default; the order does not change.\n- `variant_ids`: the complete set of this product's variants the item is linked to. Missing links are added and the others removed; `[]` removes every link and the item stays. A variant that is not this product's is `404` and the links do not change.\n- `alt_text`: up to 500 characters, or `null` to clear.\n\nWhen several fields are sent they are applied in a fixed order: alt text, links, default, position. A later failure leaves the earlier steps in place. A reorder applies whole or not at all. The answer is the item as it now stands.","parameters":[{"name":"id","in":"path","required":true,"description":"Product UUID","schema":{"type":"string","format":"uuid"}},{"name":"mediaId","in":"path","required":true,"description":"Media item UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"minProperties":1,"properties":{"position":{"type":"integer","minimum":1},"is_default":{"type":"boolean","enum":[true]},"variant_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50},"alt_text":{"type":"string","nullable":true,"maxLength":500}}}}}},"responses":{"200":{"description":"The item after the change.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductMedia"}}}}},"headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Invalid body, or `position` outside 1 to the number of items. Nothing was written.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The key's member lacks content rights.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Product or item not found (an item of another product, another tenant or already deleted included), or a variant in `variant_ids` is not this product's.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"delete":{"summary":"Delete a product media item","tags":["PCMS"],"description":"Soft-deletes the item: it leaves every read at once and every variant link to it is removed. If it was the product default, the product has no default afterwards; no other item is promoted. The storage file is then removed on a best-effort basis, so a failed file removal never undoes the delete. There is no restore; add the file again if needed.","parameters":[{"name":"id","in":"path","required":true,"description":"Product UUID","schema":{"type":"string","format":"uuid"}},{"name":"mediaId","in":"path","required":true,"description":"Media item UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}}}}}}},"headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}}},"401":{"description":"Missing or invalid API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The key's member lacks content rights.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Product or item not found, another tenant's, or already deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/pcms/products/{id}/options":{"put":{"summary":"Save Product Options","tags":["PCMS"],"description":"Change a product's options and regenerate its variants in one database transaction: the save the PCMS editor runs. `strategy` is required and says what happens to the product's current `matrix` variants: **`replace_all`** orphans them (kept as history, no longer sellable) and generates the new matrix; **`keep_as_manual`** turns them `manual` and generates the new matrix; **`add_new`** only adds the combinations the new options introduce, and refuses (`400` `E9444`) any change that removes or renames a value. A product has at most two options; each needs a name and at least two distinct values (padding and repeats are normalized as the editor does); `replace_all` with no options is `400` `E9444`. A product that has only its default variant keeps it: `replace_all` orphans it and `keep_as_manual` turns it `manual`, and no price is copied to the generated variants, so set prices afterwards with `PUT /pcms/products/{id}/variants`. `overrides` set a SKU, barcode, weight or dimensions on a generated variant, matched by its name (for example `Red / S`); an override naming no generated variant is ignored, and an unknown field is `400`. A barcode that a deleted variant held is allowed and returned in `meta.code_warnings` (ADR-054). `overrides` apply only when the save writes: on a repeat that finds nothing to change they are ignored, and `meta` reports zero changes. A clashing live SKU or barcode is `400` (`E9445`, `E9464`) and the whole save is rolled back. **A repeat is safe:** when the options equal the product's current options and, for `replace_all` and `keep_as_manual`, the live matrix variants are exactly the combinations the save would generate, nothing is written and `meta` reports zero changes. That also covers a complete matrix with unchanged options. A missing, extra or inactive matrix variant makes the same request a real save. `add_new` with equal options never regenerates a combination whose variant was deleted; use `replace_all`, or create the variant with `PUT /pcms/products/{id}/variants`. Two identical requests sent at once: one applies and the other is refused `409` (`E0602`) when it read the product's variants before the first finished, or answers as a repeat when it read them after. `X-Tenant-Code` is required; a product of another tenant is `404`.","parameters":[{"name":"id","in":"path","required":true,"description":"Product UUID.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant of the product.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["options","strategy"],"additionalProperties":false,"properties":{"options":{"type":"array","maxItems":2,"description":"The product's complete option set after the save, in order.","items":{"type":"object","required":["name","values"],"additionalProperties":false,"properties":{"name":{"type":"string"},"values":{"type":"array","maxItems":10,"items":{"type":"string"}}}}},"strategy":{"type":"string","enum":["replace_all","add_new","keep_as_manual"]},"overrides":{"type":"array","maxItems":200,"items":{"type":"object","required":["name"],"additionalProperties":false,"properties":{"name":{"type":"string","description":"Name of the generated variant, for example `Red / S`."},"sku":{"type":"string","nullable":true},"barcode":{"type":"string","nullable":true},"weight":{"type":"number","nullable":true},"width":{"type":"number","nullable":true},"height":{"type":"number","nullable":true},"depth":{"type":"number","nullable":true}}}}}}}}},"responses":{"200":{"description":"The product with its variants, and what the save changed","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductResponse"},"meta":{"type":"object","properties":{"updated_variant_count":{"type":"integer","description":"Matrix variants orphaned or turned manual."},"new_variant_count":{"type":"integer","description":"Variants generated."},"code_warnings":{"type":"array","description":"Barcodes that a deleted or changed variant held before.","items":{"type":"object","properties":{"field":{"type":"string","enum":["barcode"]},"value":{"type":"string"},"previousVariantId":{"type":"string"},"previousVariantName":{"type":"string"},"isDeleted":{"type":"boolean"}}}}}}}}}}},"400":{"description":"Invalid body, strategy or options (`VALIDATION_ERROR`, `E9443`, `E9444`), a clashing SKU or barcode (`E9445`, `E9464`), or `X-Tenant-Code` missing"},"403":{"description":"The key's user may not change this tenant's catalog"},"404":{"description":"Product not found in the tenant"},"409":{"description":"Another request changed the product's variants at the same time (`E0602`), or created the same option combination (`E9446`)"}}}},"/pcms/products/{id}/variants":{"put":{"summary":"Upsert product variants","tags":["PCMS"],"description":"Upsert variants of a product. The request body is an array of items.\n\n- Items **with** `variant_id` (UUID) → update the existing variant.\n- Items **without** `variant_id` → create a new variant (`name` is required; `option1`/`option2` should match the product's option matrix).\n\nA single request may mix create and update items. Variants **not** included in the payload are left untouched. Unknown `variant_id` values return 404.\n\n**Deleting.** An item `{ \"variant_id\": \"<uuid>\", \"_delete\": true }` retires that variant, the same soft delete the PCMS editor does. A delete item carries no other field (`_delete` must be exactly `true`; anything else is `400`), and a `variant_id` may appear once in the request. The variant must belong to the product in the address: one of another product or tenant is `404` and nothing in the request is written. A product keeps at least one live variant: a request whose deletes would empty it is `400` `E9447` and deletes nothing. A deleted variant's SKU is never free again (ADR-054). **A request mixing writes is not atomic:** creates and updates run first, all deletes last in one all-or-nothing step, so a failed delete leaves the earlier creates and updates in place. Deleting a variant that stock or documents still reference is not checked here, as in the editor.\n\n**Tags.** A new item may carry `tags`, the new variant's own tag names. An existing item may not: an item with `variant_id` carrying `tags`, even `[]`, is `400` `E9474` and nothing in the request is applied.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the product.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"array","items":{"type":"object","description":"An update (`variant_id`), a create (`name`), or a delete (`variant_id` with `_delete: true` and nothing else). Either `variant_id` or `name` is required.","properties":{"variant_id":{"type":"string","format":"uuid","description":"Omit to create a new variant; include to update an existing one."},"_delete":{"type":"boolean","enum":[true],"description":"Exactly `true` retires the variant named by `variant_id`; the item may carry no other field."},"name":{"type":"string","description":"Variant display name. Required when creating (variant_id omitted). For matrix products use `\"<opt1> / <opt2>\"`."},"sku":{"type":"string","nullable":true,"description":"Can change only while the product has never been published; otherwise E9471."},"barcode":{"type":"string","nullable":true},"price":{"type":"number","nullable":true},"option1":{"type":"string","nullable":true,"description":"Used when creating a new variant on a matrix product."},"option2":{"type":"string","nullable":true},"manufacturer_code":{"type":"string","nullable":true,"description":"Manufacturer-assigned code for this variant. Optional."},"legacy_code":{"type":"string","nullable":true,"description":"Code carried over from a legacy system (e.g. ERP migration). Optional."},"status":{"type":"string","enum":["active","inactive"],"description":"SKU lifecycle status. Defaults to 'active' when omitted."},"extra_data":{"type":"object","nullable":true,"additionalProperties":true,"description":"Arbitrary key-value metadata for this variant."},"tags":{"type":"array","items":{"type":"string"},"description":"A new item only: the new variant's own tag names. An item with `variant_id` carrying `tags`, whatever its content, is `400` `E9474` and nothing in the request is applied; change an existing variant's tags one at a time through `/pcms/variants/{id}/tags/{name}`. A name that breaks the tag name rule is `400` `E9473`. A name the product already carries is not stored as the variant's own; the variant inherits it."}}}}}}},"responses":{"200":{"description":"Variants upserted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductResponse"}}}}}},"400":{"description":"Validation error or invalid variants payload. `E9474`: an existing item (`variant_id`) carries `tags`; nothing in the request is applied. `E9473`: a new item's tag name breaks the tag name rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Product or one of the supplied variants not found in the specified tenant"}}}},"/pcms/tags":{"get":{"summary":"List catalog tags","tags":["PCMS"],"description":"Retrieve the tenant's catalog tags, including a tag no product carries any more. Task labels are not among them.","parameters":[{"name":"X-Tenant-Code","in":"header","description":"Code of the tenant to scope the request to. Required when the key reaches more than one tenant.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}}}}}}},"400":{"description":"`INVALID_TENANT_SCOPE`: the key reaches more than one tenant and no `X-Tenant-Code` was sent"},"401":{"description":"Missing or invalid API key"},"403":{"description":"The key has no access to the tenant"},"500":{"description":"Internal server error"}}}},"/pcms/units":{"get":{"summary":"List Product Units","tags":["PCMS"],"description":"Paginated, name-sorted list of product units for a tenant. Use `?q=` for name-contains search.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"q","in":"query","schema":{"type":"string","maxLength":200}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":100}}],"responses":{"200":{"description":"Paginated list of product units sorted by name ascending","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicUnitResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied"}}},"post":{"summary":"Create Product Unit","tags":["PCMS"],"description":"Create a new product unit in the tenant's catalog. Both `name` and `abbreviation` are required. The `abbreviation` is normalized to lowercase automatically.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","abbreviation"],"properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Kilogram"},"abbreviation":{"type":"string","minLength":1,"maxLength":20,"example":"kg","description":"Normalized to lowercase automatically."}}}}}},"responses":{"201":{"description":"Unit created successfully","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicUnitResponse"}}}}}},"400":{"description":"Missing or invalid request body (both name and abbreviation are required)"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"409":{"description":"A unit with this abbreviation already exists in the tenant"}}}},"/pcms/units/{id}":{"get":{"summary":"Get Unit by ID","tags":["PCMS"],"description":"Fetch a single unit by id, tenant-scoped. Returns 404 if the unit does not exist or belongs to another tenant.","parameters":[{"name":"id","in":"path","required":true,"description":"Product unit UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"responses":{"200":{"description":"Unit detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicUnitResponse"}}}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Unit not found or does not belong to this tenant"}}},"patch":{"summary":"Partial Update Unit","tags":["PCMS"],"description":"Partially update a unit (PATCH). All fields are optional; at least one must be provided. Fields not supplied are left unchanged. Use PUT to fully replace.","parameters":[{"name":"id","in":"path","required":true,"description":"Product unit UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Kilogram"},"abbreviation":{"type":"string","minLength":1,"maxLength":20,"example":"kg","description":"Normalized to lowercase automatically."}},"description":"Partial update — only the provided fields are changed; at least one field is required."}}}},"responses":{"200":{"description":"Unit updated successfully","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicUnitResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Unit not found or does not belong to this tenant"},"409":{"description":"A unit with this abbreviation already exists in the tenant"}}},"put":{"summary":"Update Product Unit","tags":["PCMS"],"description":"Update an existing product unit. At least one field must be provided. Fields not provided are left unchanged. The `abbreviation` is normalized to lowercase automatically.","parameters":[{"name":"id","in":"path","required":true,"description":"Product unit UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":500,"example":"Kilogram"},"abbreviation":{"type":"string","minLength":1,"maxLength":20,"example":"kg","description":"Normalized to lowercase automatically."}}}}}},"responses":{"200":{"description":"Unit updated successfully","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicUnitResponse"}}}}}},"400":{"description":"Empty or invalid request body"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient permissions"},"404":{"description":"Unit not found or does not belong to this tenant"},"409":{"description":"A unit with this abbreviation already exists in the tenant"}}},"delete":{"summary":"Delete Unit","tags":["PCMS"],"description":"Soft-delete a unit, as the PCMS editor does. The unit leaves every read, is never purged, and its name is free for a new unit at once. Only the tenant owner or a member with the catalog admin permission may delete; the key acts as the member it belongs to. A unit a live product uses, whatever the product's status, is refused with `409` and nothing is deleted. Deleted products do not count. A repeated delete answers `404`: the first one succeeded and the unit is gone, so a client that lost the response and retries should treat `404` as done. The answer names the item by id, because every read answers `404` once the delete has run. Any request body and any query parameter are ignored; the tenant is the one the key and `X-Tenant-Code` name.","parameters":[{"name":"id","in":"path","required":true,"description":"Unit UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The unit was deleted; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"Missing X-Tenant-Code"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Unit not found: unknown, malformed id, already deleted, or another tenant's. Error code `E9413`"},"409":{"description":"The unit is used by a live product (`E9442`)"},"500":{"description":"The use check or the delete itself failed; nothing was deleted"}}}},"/pcms/property-definitions":{"get":{"summary":"List Property Definitions","tags":["PCMS"],"description":"The tenant's live property definitions in display order. Every member of the tenant may read. Deleted definitions are not listed. The list is not paged.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"responses":{"200":{"description":"Property definitions in display order","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicPropertyDefinitionResponse"}}},"required":["data"]}}}},"400":{"description":"Missing X-Tenant-Code"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces)"}}},"post":{"summary":"Create Property Definition","tags":["PCMS"],"description":"Add a property name to the tenant's shared list. The definition goes last. Send `key` to choose the key, or leave it out and the server generates one from `label` (accents removed, lowercase, other characters become `_`, `_2`, `_3` appended when taken, the base shortened so the key stays within 50 characters). A key is never reused in a tenant, deleted definitions included, and never changes. A product that already carries the key as an ad-hoc property links to the new definition and takes its label. Only the tenant owner or a member with the catalog admin permission may write.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label"],"additionalProperties":false,"properties":{"key":{"type":"string","pattern":"^[a-z0-9][a-z0-9_]{0,49}$","description":"Lowercase letter or digit first, then lowercase letters, digits or underscores, 50 characters at most."},"label":{"type":"string","minLength":1,"maxLength":50,"example":"Voltage"}}}}}},"responses":{"201":{"description":"Definition created","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicPropertyDefinitionResponse"}},"required":["data"]}}}},"400":{"description":"Invalid body: key off the format or no key can be generated from the label (`E9489`), label empty or over 50 characters (`E9491`)"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"409":{"description":"The key was used by a live or a deleted definition of the tenant (`E9490`)"}}}},"/pcms/property-definitions/{id}":{"get":{"summary":"Get Property Definition","tags":["PCMS"],"description":"Fetch one live definition, tenant-scoped. Every member may read.","parameters":[{"name":"id","in":"path","required":true,"description":"Property definition UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"responses":{"200":{"description":"Definition detail","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicPropertyDefinitionResponse"}},"required":["data"]}}}},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces)"},"404":{"description":"Definition not found: unknown, malformed id, deleted, or another tenant's (`E9488`)"}}},"patch":{"summary":"Relabel Property Definition","tags":["PCMS"],"description":"Change the label. The key never changes: a `key` in the body must equal the current one. Duplicate labels are allowed. Every product that carries the definition takes the new label. A label that is unchanged changes no product.","parameters":[{"name":"id","in":"path","required":true,"description":"Property definition UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label"],"additionalProperties":false,"properties":{"label":{"type":"string","minLength":1,"maxLength":50},"key":{"type":"string","pattern":"^[a-z0-9][a-z0-9_]{0,49}$","description":"Optional. Must equal the definition's current key; a different key is refused."}}}}}},"responses":{"200":{"description":"Definition relabeled; the key is unchanged","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicPropertyDefinitionResponse"}},"required":["data"]}}}},"400":{"description":"Invalid body: label empty or over 50 characters (`E9491`), or a `key` that differs from the definition's own (`E9492`)"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Definition not found: unknown, malformed id, deleted, or another tenant's (`E9488`)"}}},"put":{"summary":"Replace Property Definition","tags":["PCMS"],"description":"Same effect as PATCH: sets the label. A `key` in the body must equal the current one; the key never changes. Every product that carries the definition takes the new label. A label that is unchanged changes no product.","parameters":[{"name":"id","in":"path","required":true,"description":"Property definition UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label"],"additionalProperties":false,"properties":{"label":{"type":"string","minLength":1,"maxLength":50},"key":{"type":"string","pattern":"^[a-z0-9][a-z0-9_]{0,49}$","description":"Optional. Must equal the definition's current key; a different key is refused."}}}}}},"responses":{"200":{"description":"Definition relabeled; the key is unchanged","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicPropertyDefinitionResponse"}},"required":["data"]}}}},"400":{"description":"Invalid body: label empty or over 50 characters (`E9491`), or a `key` that differs from the definition's own (`E9492`)"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Definition not found: unknown, malformed id, deleted, or another tenant's (`E9488`)"}}},"delete":{"summary":"Delete Property Definition","tags":["PCMS"],"description":"Soft-delete a definition. It leaves every read and its key stays used. A delete is never refused for being in use. Every product that carries the definition keeps the property with its key, label and value, now ad-hoc. A repeated delete answers `404`: the first one succeeded. The answer names the item by id. Any request body and any query parameter are ignored.","parameters":[{"name":"id","in":"path","required":true,"description":"Property definition UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"responses":{"200":{"description":"The definition was deleted; `data.id` names it","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"Missing X-Tenant-Code"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Definition not found: unknown, malformed id, deleted, or another tenant's (`E9488`)"}}}},"/pcms/property-definitions/{id}/actions/move":{"post":{"summary":"Move Property Definition","tags":["PCMS"],"description":"Place a definition at the top of the list (`after_id` null) or right behind another live definition. Only the moved definition changes place; the others keep their order. `after_id` naming the definition itself, a deleted definition or another tenant's is refused. The position is computed by one atomic RPC, so a retry lands the definition in the same place.","parameters":[{"name":"id","in":"path","required":true,"description":"Property definition UUID","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["after_id"],"additionalProperties":false,"properties":{"after_id":{"type":"string","format":"uuid","nullable":true,"description":"The live definition to land behind, or null for the top."}}}}}},"responses":{"200":{"description":"The definition after the move","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicPropertyDefinitionResponse"}},"required":["data"]}}}},"400":{"description":"Malformed body, or `after_id` is the definition itself, not live or not in the tenant (`E9493`)"},"401":{"description":"Missing or invalid API key (`E0004` when the token cannot be used)"},"403":{"description":"The key's member is not in the tenant (`E2002`; `AUTH_TENANT_MISMATCH` from the proxy for a key that reaches several workspaces), or lacks the owner or catalog admin right (`E0102`)"},"404":{"description":"Definition not found: unknown, malformed id, deleted, or another tenant's (`E9488`)"}}}},"/pcms/variants":{"get":{"summary":"List Variants by Barcode Prefix","tags":["PCMS"],"description":"Paginated flat list of product variants. Accepts optional `product_id` and `status` filters (a malformed value is `400`), and an optional `barcode_prefix` to filter variants whose barcode starts with the given string (ilike prefix-match with `%` and `_` escaped). Useful for finding the highest barcode already assigned in a prefix namespace to auto-increment without collisions.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant to scope the request to.","schema":{"type":"string"}},{"name":"product_id","in":"query","required":false,"description":"Only the variants of this product (a UUID, given once). The tenant filter still applies, so another tenant's or an unknown product id lists nothing. Combines with the other filters.","schema":{"type":"string","format":"uuid"}},{"name":"status","in":"query","required":false,"description":"Only variants with this lifecycle status. Exactly `active` or `inactive`, lower case, given once. Combines with the other filters.","schema":{"type":"string","enum":["active","inactive"]}},{"name":"barcode_prefix","in":"query","required":false,"description":"Optional barcode prefix to filter variants. Only variants whose barcode starts with this value are returned. Special characters `%` and `_` are treated literally.","schema":{"type":"string","example":"620111"}},{"name":"tags","in":"query","required":false,"description":"Comma-separated catalog tag names, any letter case; percent-encode non-ASCII names. Only variants whose own and inherited (product) tags hold every listed tag are returned; deleted and orphaned variants are never returned under this filter. A name that is no tag of the tenant matches nothing: `200` with an empty `data`. Empty names are ignored; absent or empty means no tag filter. AND-composes with `product_id`, `status`, `barcode_prefix`, sort and paging. Send one `tags` parameter with at most 50 distinct names (letter case ignored); a repeated `tags` parameter or more than 50 distinct names is `400 INVALID_TAGS`.","schema":{"type":"string","example":"Màu đỏ,Size L"}},{"name":"sort","in":"query","required":false,"description":"Sort order. Use `-barcode` for descending barcode order (to find highest barcode in a prefix range) or `barcode` for ascending. Defaults to `created_at` descending when omitted.","schema":{"type":"string","enum":["barcode","-barcode"],"example":"-barcode"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"minimum":1,"maximum":100}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"minimum":1}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp at query execution.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"barcode":{"type":"string","nullable":true},"sku":{"type":"string","nullable":true},"name":{"type":"string"},"product_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["active","inactive"],"description":"SKU lifecycle status. Defaults to 'active'."},"own_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"},"description":"The variant's own tags. A tag the variant also inherits from its product is listed in `inherited_tags` only. `[]` when none."},"inherited_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"},"description":"The tags the variant inherits from its product: the product's tags at read time, added and removed on the product. `[]` when none."},"media":{"type":"array","items":{"$ref":"#/components/schemas/PublicMediaEntry"},"description":"The variant's own assigned images (position ASC), falling back to the parent product's single default image when the variant has none of its own. `[]` when neither exists."}}}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Invalid query parameters (e.g. invalid barcode_prefix, unsupported sort column, out-of-range limit, or a repeated `tags` parameter or more than 50 tag names)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/pcms/variants/{id}":{"get":{"summary":"Get Variant Detail","tags":["PCMS"],"description":"Fetch a single product variant by UUID. Tenant-scoped. Orphaned, soft-deleted, and cross-tenant variants return 404.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Variant UUID"},{"name":"X-Tenant-Code","in":"header","description":"Scope to a tenant. If omitted, the variant is resolved across all tenants the caller belongs to.","schema":{"type":"string"}}],"responses":{"200":{"description":"Variant detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductVariantResponse"}}}}}},"404":{"description":"Variant not found"}}}},"/pcms/variants/{id}/tags/{name}":{"post":{"summary":"Add a tag to a variant","tags":["PCMS"],"description":"Add one tag to the variant's own tags, by name. The key's member is the actor and needs the right to edit the variant's product.\n\n- A name that matches one of the tenant's tags in another letter case or with other surrounding spaces adds that tag under its existing name.\n- A new name creates the tag, with a colour the system assigns.\n- Adding a tag the variant already carries, as its own or inherited from its product, changes nothing and still answers `200`.\n\nEach tag added is recorded as its own change and sent to VTech POS. The answer is the variant's own and inherited tags after the call.","parameters":[{"name":"id","in":"path","required":true,"description":"Variant UUID","schema":{"type":"string","format":"uuid"}},{"name":"name","in":"path","required":true,"description":"The tag name, percent-encoded (a space is `%20`).","schema":{"type":"string"},"example":"M%C3%A0u%20%C4%90%E1%BB%8F"},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the variant.","schema":{"type":"string"}}],"responses":{"200":{"description":"The variant's own and inherited tags after the call.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"own_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}},"inherited_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}}}}}}}}},"400":{"description":"`E9473`: the name breaks the tag name rule; nothing changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"`E0102`: the key's member may not edit products of this tenant; nothing changes."},"404":{"description":"Variant not found in the specified tenant"}}},"delete":{"summary":"Remove a tag from a variant","tags":["PCMS"],"description":"Remove one tag from the variant's own tags, by name, in any letter case. The key's member is the actor and needs the right to edit the variant's product.\n\n- Removing a tag the variant carries as its own removes it, also when its product carries the tag too; the tag then stays inherited.\n- Removing a tag the variant only inherits from its product is refused with `400` `E9455`; remove it on the product.\n- Removing a tag the variant does not carry changes nothing and still answers `200`.\n\nThe tag stays in the tenant's tag list. Each tag removed is recorded as its own change and sent to VTech POS. The answer is the variant's own and inherited tags after the call.","parameters":[{"name":"id","in":"path","required":true,"description":"Variant UUID","schema":{"type":"string","format":"uuid"}},{"name":"name","in":"path","required":true,"description":"The tag name, percent-encoded (a space is `%20`).","schema":{"type":"string"},"example":"M%C3%A0u%20%C4%90%E1%BB%8F"},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Code of the tenant that owns the variant.","schema":{"type":"string"}}],"responses":{"200":{"description":"The variant's own and inherited tags after the call.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"own_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}},"inherited_tags":{"type":"array","items":{"$ref":"#/components/schemas/PublicCatalogTag"}}}}}}}}},"400":{"description":"`E9473`: the name breaks the tag name rule. `E9455`: the variant only inherits this tag from its product. Nothing changes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"`E0102`: the key's member may not edit products of this tenant; nothing changes."},"404":{"description":"Variant not found in the specified tenant"}}}},"/tenants":{"get":{"summary":"List Tenants","tags":["Tenants"],"description":"Retrieve the list of all tenants/workspaces that the authenticated user actively participates in. A key that lists its workspaces gets only the workspaces on its list that its owner still belongs to, and needs no `X-Tenant-Code`.","responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTenantResponse"}}}}}}},"401":{"description":"Invalid or missing Authorization"}}}},"/wms/inbound-receipts":{"get":{"summary":"List Inbound Receipts","tags":["WMS"],"description":"Returns paginated inbound receipts for a specific warehouse. Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search query for code or external reference (max 200 characters after trimming)","schema":{"type":"string","maxLength":200}},{"name":"filters[status][$eq]","in":"query","description":"Filter by exact status","schema":{"type":"string","description":"Receipt status to filter on. `VERIFIED` is the terminal, posted state — the receipt is posted to the stock ledger.","enum":["DRAFT","PLANNED","RECEIVING","VERIFIED","CANCELLED"]}},{"name":"filters[status][$in]","in":"query","description":"Filter by multiple statuses (comma-separated), each one of `DRAFT`, `PLANNED`, `RECEIVING`, `VERIFIED`, `CANCELLED`. `VERIFIED` is the terminal, posted state.","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}},{"name":"sort","in":"query","description":"Sort by column:asc or column:desc. Default: created_at:desc","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsInboundReceiptResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse not found"},"500":{"description":"Internal server error"}}},"post":{"summary":"Create Inbound Receipt","description":"Create an inbound receipt draft for the acting warehouse. Tenant context is proxy-injected (x-api-tenant-code); warehouse scope is client-supplied via ?warehouse_code= or X-Warehouse-Code. Authorization gate: `scope.inbound`. Create is atomic (header + lines in one transaction). Each line's `unit_cost` feeds the weighted-average cost when the receipt is VERIFIED (see WmsInboundReceiptLineInput). The API submits and cancels a receipt; receiving and verifying happen in the WMS screen.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key (create only). First application returns 201; a replay of the same key with an identical canonical body returns 200 with the existing document; the same key with a different body returns 409 (E0601).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInboundReceiptRequest"}}}},"responses":{"200":{"description":"Replay: same Idempotency-Key with an identical canonical body — returns the original document, no duplicate row/event.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"oneOf":[{"$ref":"#/components/schemas/PublicWmsInboundReceiptDetailResponse"},{"$ref":"#/components/schemas/PublicWmsWriteEchoUnavailable"}]}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"201":{"description":"Created inbound receipt. Returns the created document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"oneOf":[{"$ref":"#/components/schemas/PublicWmsInboundReceiptDetailResponse"},{"$ref":"#/components/schemas/PublicWmsWriteEchoUnavailable"}]}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Idempotency-key conflict (E0601) — same key, different canonical body."},"500":{"description":"Internal server error"}}}},"/wms/inbound-receipts/{code}":{"get":{"summary":"Get Inbound Receipt Detail","tags":["WMS"],"description":"Returns a single inbound receipt with its lines and status history. Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"code","in":"path","required":true,"description":"Receipt code (e.g. IR-001)","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsInboundReceiptDetailResponse"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse or receipt not found"},"500":{"description":"Internal server error"}}},"patch":{"summary":"Update Inbound Receipt","description":"JSON Merge Patch (RFC 7386) update of an editable inbound receipt. Requires an `If-Match` header carrying the current `updated_at` (concurrency token). Only editable header fields and an optional `lines` list are accepted (a full replacement while DRAFT/PLANNED, a merge by line `id` while RECEIVING; see UpdateInboundReceiptRequest); `status` and other internal fields are rejected (400). Authorization gate: `scope.inbound`; once the receipt is PLANNED or RECEIVING the caller also needs `scope.financial_view` (403 otherwise). From PLANNED onwards every planned line (`qty_expected` above 0) the request writes needs a `unit_cost`; 0 is valid, a missing one is refused with 422 `E9649`. A DRAFT may still be saved without cost. A line that names by `product_variant_id` a variant the receipt already holds is kept even if that variant was deleted, on at most as many lines as the receipt holds it on; any other deleted or unknown variant is refused.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInboundReceiptRequest"}}}},"responses":{"200":{"description":"Updated inbound receipt.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"oneOf":[{"$ref":"#/components/schemas/PublicWmsInboundReceiptDetailResponse"},{"$ref":"#/components/schemas/PublicWmsWriteEchoUnavailable"}]}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error (including DUPLICATE_LINE_ID, and LINE_VARIANT_MISMATCH when a line `id` names a line of a different variant)"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found, or a line `id` that does not belong to the receipt"},"409":{"description":"Stale-version conflict (E0602), the document is not in an editable status or is a warehouse-transfer receipt (E9522), or a RECEIVING update omitted a line that already has a counted quantity (E9639)."},"422":{"description":"A planned line the request writes has no unit cost (E9649). Cost 0 is valid; only a missing cost is refused."},"500":{"description":"Internal server error"}}}},"/wms/internal-transfers":{"get":{"summary":"List Internal Transfers","tags":["WMS"],"description":"Returns paginated internal transfers for a specific warehouse. Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search query for transfer code (max 200 characters after trimming)","schema":{"type":"string","maxLength":200}},{"name":"filters[status][$eq]","in":"query","description":"Filter by exact status","schema":{"type":"string","enum":["DRAFT","MOVING","MOVED","CANCELLED"]}},{"name":"filters[status][$in]","in":"query","description":"Filter by multiple statuses (comma-separated)","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}},{"name":"sort","in":"query","description":"Sort by column:asc or column:desc. Allowed: created_at, updated_at, code. Default: created_at:desc","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsInternalTransferResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse not found"},"500":{"description":"Internal server error"}}},"post":{"summary":"Create Internal Transfer","description":"Create a internal transfer draft for the acting warehouse. Tenant context is proxy-injected (x-api-tenant-code); warehouse scope is client-supplied via ?warehouse_code= or X-Warehouse-Code. Authorization gate: `scope.transfer`. Create is atomic (header + lines in one transaction).","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key (create only). First application returns 201; a replay of the same key with an identical canonical body returns 200 with the existing document; the same key with a different body returns 409 (E0601).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInternalTransferRequest"}}}},"responses":{"200":{"description":"Replay: same Idempotency-Key with an identical canonical body — returns the original document, no duplicate row/event.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsInternalTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"201":{"description":"Created internal transfer. Returns the created document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsInternalTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Idempotency-key conflict (E0601) — same key, different canonical body — or the request changes stock at an edge location outside a plan (E9656)."},"500":{"description":"Internal server error"}}}},"/wms/internal-transfers/{code}":{"get":{"summary":"Get Internal Transfer Detail","tags":["WMS"],"description":"Returns a single internal transfer with its lines (including from/to locations), and status history. Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"code","in":"path","required":true,"description":"Transfer code (e.g. IT-001)","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsInternalTransferDetailResponse"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse or transfer not found"},"500":{"description":"Internal server error"}}},"patch":{"summary":"Update Internal Transfer","description":"JSON Merge Patch (RFC 7386) update of an editable internal transfer. Requires an `If-Match` header carrying the current `updated_at` (concurrency token). Only editable header fields and an optional full `lines` replacement are accepted; `status` and other internal fields are rejected (400). Authorization gate: `scope.transfer`. A line that names by `product_variant_id` a variant the transfer already holds is kept even if that variant was deleted, on at most as many lines as the transfer holds it on; any other deleted or unknown variant is refused.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInternalTransferRequest"}}}},"responses":{"200":{"description":"Updated internal transfer.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsInternalTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Stale-version conflict (E0602), the transfer is not in an editable status (E9559), or the request changes stock at an edge location outside a plan (E9656)."},"500":{"description":"Internal server error"}}}},"/wms/internal-transfers/{code}/actions/{action}":{"post":{"summary":"Internal Transfer Lifecycle Action","description":"Apply a lifecycle action to an internal transfer: `cancel` (body `{\"reason\": string, non-empty, at most 255 characters}`) — DRAFT|MOVING → CANCELLED, under the same rules as the GUI. There is no `submit` action for this document type (no approval workflow; the only other transition, posting to MOVED, is not exposed on the wire) and every other action name returns 404. No `If-Match` header is used — cancel has no optimistic-concurrency input. Authorization gate: `scope.transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"action","in":"path","required":true,"description":"Lifecycle action: `cancel` only. Every other name — including the posting verb — returns 404.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsDecisionReasonCancelActionRequest"}}}},"responses":{"200":{"description":"Action applied. Returns the resulting document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsInternalTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, unknown body field, or an empty or over-long reason (E9645)."},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or action not found"},"409":{"description":"The document is not in a cancellable status (E9559)."},"500":{"description":"Internal server error"}}}},"/wms/outbound-shipments":{"get":{"summary":"List Outbound Shipments","tags":["WMS"],"description":"Returns paginated outbound shipments for a specific warehouse, each with its waybill (or null). Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search query for code or external reference (max 200 characters after trimming)","schema":{"type":"string","maxLength":200}},{"name":"filters[status][$eq]","in":"query","description":"Filter by exact status","schema":{"type":"string","enum":["DRAFT","PLANNED","AWAITING_EXPORT","EXPORTED","CANCELLED"]}},{"name":"filters[status][$in]","in":"query","description":"Filter by multiple statuses (comma-separated)","schema":{"type":"string"}},{"name":"filters[waybill][tracking_number][$eq]","in":"query","description":"Filter by exact tracking number on the shipment's waybill. Trimmed and matched ignoring letter case; never a partial match. A blank value is ignored. A number no shipment holds returns an empty page.","schema":{"type":"string"}},{"name":"filters[waybill][carrier_code][$eq]","in":"query","description":"Filter by exact carrier code on the shipment's waybill. Trimmed and upper-cased; it must match ^[A-Z][A-Z0-9_]{0,31}$ or the request is a 400. A blank value is ignored. Only the carrier's 300 newest shipments are searched, because the ids travel in one URL. That window is cut before filters[status] and q apply, so for a carrier with more than 300 shipments a status or q combined with it can miss older matches, and meta.total counts only inside the window. To reach an older one, add filters[waybill][tracking_number][$eq], which is not capped when combined with this filter. With both filters, a shipment must hold that tracking number under that carrier.","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}},{"name":"sort","in":"query","description":"Sort by column:asc or column:desc. Allowed: created_at, updated_at. Default: created_at:desc","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse not found"},"500":{"description":"Internal server error"}}},"post":{"summary":"Create Outbound Shipment","description":"Create a outbound shipment draft for the acting warehouse. Tenant context is proxy-injected (x-api-tenant-code); warehouse scope is client-supplied via ?warehouse_code= or X-Warehouse-Code. Authorization gate: `scope.outbound`. Create is atomic (header + lines in one transaction). The echoed document carries `unit_cost` and `total_value` as null with `unit_cost_denied` / `total_value_denied` false: a write never returns a posted shipment, so it has no cost yet. GET the shipment after it posts for its cost.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key (create only). First application returns 201; a replay of the same key with an identical canonical body returns 200 with the existing document; the same key with a different body returns 409 (E0601).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOutboundShipmentRequest"}}}},"responses":{"200":{"description":"Replay: same Idempotency-Key with an identical canonical body — returns the original document, no duplicate row/event.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"201":{"description":"Created outbound shipment. Returns the created document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Idempotency-key conflict (E0601) — same key, different canonical body — or the waybill is refused (E9683, E9684, E9685; see `WmsOutboundShipmentWaybillInput`)."},"500":{"description":"Internal server error"}}}},"/wms/outbound-shipments/{code}":{"get":{"summary":"Get Outbound Shipment Detail","tags":["WMS"],"description":"Returns a single outbound shipment with its lines and status history. Requires warehouse_code via query parameter or X-Warehouse-Code header. Authorization gate: `scope.view`. The cost of goods shipped (`unit_cost` per line, `total_value` for the shipment) is returned only to a caller holding `scope.financial_view` on the shipment's warehouse; any other caller gets both as null with `unit_cost_denied` / `total_value_denied` true, and the request still answers 200. An API key acts as its member, so grant the scope to that member. Costs are known once the shipment has posted; a shipment not yet posted, or posted before the release that added these fields, answers null.","parameters":[{"name":"code","in":"path","required":true,"description":"Shipment code (e.g. SO-001)","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"cost_method","in":"query","required":false,"description":"How `unit_cost` and `total_value` are measured. `fifo` (default): the cost of the stock layers each line consumed, first in, first out. `average`: the product's weighted-average cost in the warehouse when the shipment posted. Any other value is a 400 `INVALID_QUERY_PARAMS`.","schema":{"type":"string","enum":["fifo","average"],"default":"fifo"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentDetailResponse"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters (`INVALID_QUERY_PARAMS`, including a `cost_method` other than `fifo` or `average`)"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse or shipment not found"},"500":{"description":"Internal server error, including a failed cost read for a caller holding `scope.financial_view`"}}},"patch":{"summary":"Update Outbound Shipment","description":"JSON Merge Patch (RFC 7386) update of an editable outbound shipment. Requires an `If-Match` header carrying the current `updated_at` (concurrency token). Only editable header fields and an optional full `lines` replacement are accepted; `status` and other internal fields are rejected (400). On a DRAFT shipment `lines` replaces the whole list, except on a shipment an order system requested. From PLANNED on, and on an order system's DRAFT shipment, a full replacement is refused (409 `E9675`), and so is a change of `external_ref` on a shipment an order system requested; the only accepted `lines` is the current set with one or more `qty_ordered` lowered, each lowered line carrying a `qty_reduction_reason` (missing reason: 400 `E9674`). Authorization gate: `scope.outbound`. The echoed document carries `unit_cost` and `total_value` as null with `unit_cost_denied` / `total_value_denied` false: a write never returns a posted shipment, so it has no cost yet. GET the shipment after it posts for its cost. A line that names by `product_variant_id` a variant the shipment already holds is kept even if that variant was deleted, on at most as many lines as the shipment holds it on; any other deleted or unknown variant is refused.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOutboundShipmentRequest"}}}},"responses":{"200":{"description":"Updated outbound shipment.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, validation error, or a reduction without a reason (`E9674`)"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Stale-version conflict (E0602), the document is not in an editable status (E9558), the shipment is held by an owner or a transfer and the key's member is not its holder (`E9697`), its lines can no longer be replaced (`E9675`: a full replacement from PLANNED on or on an order system's DRAFT shipment, any line change from AWAITING_EXPORT on, or an `external_ref` change on a shipment an order system requested), or the waybill is refused (E9683, E9684, E9685; see `WmsOutboundShipmentWaybillInput`)."},"500":{"description":"Internal server error"}}}},"/wms/warehouses":{"get":{"summary":"List Warehouses","tags":["WMS"],"description":"Returns paginated list of warehouses the authenticated user has access to within the tenant. Owners and wms_admins see all warehouses; other members see only warehouses where they hold any warehouse scope.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search query for code or name (max 200 characters after trimming)","schema":{"type":"string","maxLength":200}},{"name":"filters[is_active][$eq]","in":"query","description":"Filter by active status (true or false)","schema":{"type":"string","enum":["true","false"]}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}},{"name":"sort","in":"query","description":"Sort by column:asc or column:desc. Allowed: created_at, updated_at, name, code.","schema":{"type":"string","default":"created_at:desc"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsWarehouseResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"}}}},"/wms/warehouses/{code}":{"get":{"summary":"Get Warehouse Detail","tags":["WMS"],"description":"Returns warehouse details including the full location hierarchy tree. Authorization via warehouse permission check (defense in depth with RLS).","parameters":[{"name":"code","in":"path","required":true,"description":"Warehouse code (e.g. WH-001)","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsWarehouseDetailResponse"}}}}}},"400":{"description":"Missing X-Tenant-Code or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse not found"},"500":{"description":"Internal server error"}}}},"/wms/availability-checks":{"post":{"summary":"Check Availability","tags":["WMS"],"description":"OH-ATP (ADR-088): a per-(line, warehouse) supply signal for a caller who cannot see stock, e.g. Seli quoting an order. Each line is named by exactly one of variant_id, sku or barcode plus a demand; the answer covers exactly the named warehouses and never carries on-hand, only a 0-100 score and, when short, a promisable quantity. Score and promisable quantity leave out stock promised to planned shipments. Read-only.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsAvailabilityCheckRequest"}}}},"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsAvailabilityCheckLine"}}}}}}},"400":{"description":"Possible codes: VALIDATION_ERROR (body fails schema — response carries `error.details`, an array of `{path, message}` per offending field), INVALID_JSON_BODY (unparsable body), TENANT_CODE_REQUIRED (missing X-Tenant-Code header), E9647 (a warehouse code is unknown, inactive, or belongs to another tenant), E9648 (a line has an invalid identifier or demand).","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/Error"},{"type":"object","properties":{"error":{"type":"object","properties":{"details":{"type":"array","description":"Present only for VALIDATION_ERROR.","items":{"type":"object","properties":{"path":{"type":"array","items":{"oneOf":[{"type":"string"},{"type":"integer"}]}},"message":{"type":"string"}}}}}}}}]}}}},"401":{"description":"Missing or invalid API key"},"403":{"description":"E0102 — access to this tenant's availability check is denied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error"}}}},"/wms/warehouse-transfers":{"get":{"summary":"List Warehouse Transfers","tags":["WMS"],"description":"Returns paginated warehouse-to-warehouse transfer requests visible from a specific warehouse. Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to scope the request to. Required.","schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"q","in":"query","description":"Search query for transfer code (max 200 characters after trimming)","schema":{"type":"string","maxLength":200}},{"name":"filters[status][$eq]","in":"query","description":"Filter by exact transfer status","schema":{"type":"string","enum":["DRAFT","PENDING_APPROVAL","APPROVED","IN_TRANSIT","COMPLETED","REJECTED","CANCELLED"]}},{"name":"filters[status][$in]","in":"query","description":"Filter by multiple statuses (comma-separated)","schema":{"type":"string"}},{"name":"page","in":"query","schema":{"type":"integer","default":1,"minimum":1}},{"name":"limit","in":"query","schema":{"type":"integer","default":20,"minimum":1,"maximum":50}},{"name":"sort","in":"query","description":"Sort by column:asc or column:desc. Allowed: created_at, code. Default: created_at:desc","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse not found"},"500":{"description":"Internal server error"}}},"post":{"summary":"Create Warehouse Transfer","description":"Create a warehouse transfer draft, PUSH (default) or PULL, for the acting warehouse. The acting warehouse is always the initiator: the source on a PUSH, the destination on a PULL — see `direction`, `destination_warehouse_code` and `source_warehouse_code` on the request schema for the per-direction rule. A PULL requester never learns the source warehouse's stock (no on-hand/availability field is returned for either direction). Tenant context is proxy-injected (x-api-tenant-code); warehouse scope is client-supplied via ?warehouse_code= or X-Warehouse-Code. Authorization gate: `scope.warehouse_transfer` at the acting (initiating) warehouse. Create is atomic (header + lines in one transaction).","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key (create only). First application returns 201; a replay of the same key with an identical canonical body returns 200 with the existing document; the same key with a different body returns 409 (E0601).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWarehouseTransferRequest"}}}},"responses":{"200":{"description":"Replay: same Idempotency-Key with an identical canonical body — returns the original document, no duplicate row/event.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"201":{"description":"Created warehouse transfer. Returns the created document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Idempotency-key conflict (E0601) — same key, different canonical body."},"500":{"description":"Internal server error"}}}},"/wms/warehouse-transfers/{code}":{"get":{"summary":"Get Warehouse Transfer Detail","tags":["WMS"],"description":"Returns a single warehouse-to-warehouse transfer with lines and unified lifecycle timeline. Requires warehouse_code via query parameter or X-Warehouse-Code header.","parameters":[{"name":"code","in":"path","required":true,"description":"Warehouse transfer code","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}},{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferDetailResponse"}}}}}},"400":{"description":"Missing X-Tenant-Code, missing warehouse_code, or invalid query parameters"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse or transfer not found"},"500":{"description":"Internal server error"}}},"patch":{"summary":"Update Warehouse Transfer","description":"JSON Merge Patch (RFC 7386) update of an editable warehouse transfer. Requires an `If-Match` header carrying the current `updated_at` (concurrency token). Only editable header fields and an optional full `lines` replacement are accepted; `status` and other internal fields are rejected (400). Authorization gate: `scope.warehouse_transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWarehouseTransferRequest"}}}},"responses":{"200":{"description":"Updated warehouse transfer.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Stale-version conflict (E0602) or the document is not in an editable status."},"500":{"description":"Internal server error"}}}},"/wms/inbound-receipts/validate":{"post":{"summary":"Validate Inbound Receipt","description":"Side-effect-free validate for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.inbound`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInboundReceiptRequest"}}}},"responses":{"200":{"description":"Valid validate request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsInboundValidateOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/inbound-receipts/preview":{"post":{"summary":"Preview Inbound Receipt","description":"Side-effect-free preview for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.inbound`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInboundReceiptRequest"}}}},"responses":{"200":{"description":"Valid preview request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsInboundPreviewOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/inbound-receipts/{code}/actions/{action}":{"post":{"summary":"Inbound Receipt Lifecycle Action","description":"Apply a non-posting lifecycle action to a inbound receipt: `submit` (no body) or `cancel` (body `{\"reason\": string, non-empty}`). A receipt whose cancel is locked cancels only for its owner: the member in `owner`, or, when `owner_connector` is set, the member that order-system connection names; anyone else gets 409 `E9680`. Other actions — including inventory-posting/execution verbs — return 404. Requires an `If-Match` concurrency token. Authorization gate: `scope.inbound`; `submit` also needs `scope.financial_view` (403 otherwise) and is refused with 422 `E9649` while any planned line (`qty_expected` above 0) has no unit cost. Cost 0 is valid.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"action","in":"path","required":true,"description":"Lifecycle action: `submit` or `cancel`. GUI-only posting verbs (receive/verify/reserve/pick/pack/export/move/approve/reject/ship/receive) return 404.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/WmsCancelActionRequest"},{"type":"object","description":"submit takes no body."}]}}}},"responses":{"200":{"description":"Action applied. Returns the resulting document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"oneOf":[{"$ref":"#/components/schemas/PublicWmsInboundReceiptDetailResponse"},{"$ref":"#/components/schemas/PublicWmsWriteEchoUnavailable"}]}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Stale-version conflict (E0602), invalid transition for the current status (E9522), or a `cancel` refused by ownership (E9680): a receipt that belongs to a warehouse transfer cancels only through the transfer, and a receipt locked to its owner (`cancel_locked`) only by its owner: the member in `owner`, or the member the connection in `owner_connector` names."},"422":{"description":"`submit` refused: a planned line has no unit cost (E9649). Cost 0 is valid; only a missing cost is refused."},"500":{"description":"Internal server error"}}}},"/wms/outbound-shipments/validate":{"post":{"summary":"Validate Outbound Shipment","description":"Side-effect-free validate for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.outbound`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOutboundShipmentRequest"}}}},"responses":{"200":{"description":"Valid validate request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsOutboundValidateOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/outbound-shipments/preview":{"post":{"summary":"Preview Outbound Shipment","description":"Side-effect-free preview for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.outbound`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOutboundShipmentRequest"}}}},"responses":{"200":{"description":"Valid preview request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsOutboundPreviewOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/outbound-shipments/{code}/actions/{action}":{"post":{"summary":"Outbound Shipment Lifecycle Action","description":"Apply a non-posting lifecycle action to a outbound shipment: `submit` (no body) or `cancel` (no body) — allowed from DRAFT, PLANNED and AWAITING_EXPORT; cancelling from AWAITING_EXPORT opens a put-back plan that returns the packed goods from OUTBOUND to storage; an EXPORTED shipment is refused with 409 `E9671`. A shipment whose cancel is locked cancels only for its owner: the member in `owner`, or, when `owner_connector` is set, the member that order-system connection names; anyone else gets 409 `E9680`. Other actions — including inventory-posting/execution verbs — return 404. Requires an `If-Match` concurrency token. Authorization gate: `scope.outbound`. The echoed document carries `unit_cost` and `total_value` as null with `unit_cost_denied` / `total_value_denied` false: a write never returns a posted shipment, so it has no cost yet. GET the shipment after it posts for its cost.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"action","in":"path","required":true,"description":"Lifecycle action: `submit` or `cancel`. GUI-only posting verbs (receive/verify/reserve/pick/pack/export/move/approve/reject/ship/receive) return 404.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/WmsCancelActionRequest"},{"type":"object","description":"submit takes no body."}]}}}},"responses":{"200":{"description":"Action applied. Returns the resulting document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsOutboundShipmentDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Stale-version conflict (E0602), invalid transition for the current status (E9558), cancel of an exported shipment (E9671), cancel refused by ownership (E9680: a shipment that belongs to a warehouse transfer cancels only through the transfer, and a shipment locked to its owner (`cancel_locked`) only by its owner: the member in `owner`, or the member the connection in `owner_connector` names), or submit of a shipment with no lines (E9677), or submit of a shipment whose lines free stock cannot cover (E9700: the body also lists `short_lines`; `qty_missing` is null with `qty_missing_denied` true when the caller holds neither the outbound nor the stock-view scope).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"},"short_lines":{"type":"array","items":{"type":"object","properties":{"product_variant_id":{"type":"string","format":"uuid"},"qty_requested":{"type":"number"},"qty_missing":{"type":"number","nullable":true},"qty_missing_denied":{"type":"boolean"}}}}}}}}}}},"500":{"description":"Internal server error"}}}},"/wms/warehouse-transfers/validate":{"post":{"summary":"Validate Warehouse Transfer","description":"Side-effect-free validate for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.warehouse_transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWarehouseTransferRequest"}}}},"responses":{"200":{"description":"Valid validate request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsWarehouseTransferValidateOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/warehouse-transfers/preview":{"post":{"summary":"Preview Warehouse Transfer","description":"Side-effect-free preview for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.warehouse_transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWarehouseTransferRequest"}}}},"responses":{"200":{"description":"Valid preview request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsWarehouseTransferPreviewOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/warehouse-transfers/{code}/actions/{action}":{"post":{"summary":"Warehouse Transfer Lifecycle Action","description":"Apply a non-posting lifecycle action to a warehouse transfer: `submit` (no body) or `cancel` (body `{\"reason\": string, non-empty, at most 255 characters}`). Other actions — including inventory-posting/execution verbs — return 404. Requires an `If-Match` concurrency token. Authorization gate: `scope.warehouse_transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}},{"name":"action","in":"path","required":true,"description":"Lifecycle action: `submit` or `cancel`. GUI-only posting verbs (receive/verify/reserve/pick/pack/export/move/approve/reject/ship/receive) return 404.","schema":{"type":"string"}},{"name":"If-Match","in":"header","required":true,"description":"Document's current `updated_at` (ISO 8601, microsecond precision) from the public detail. Optimistic concurrency guard — a stale token returns 409 (E0602).","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/WmsDecisionReasonCancelActionRequest"},{"type":"object","description":"submit takes no body."}]}}}},"responses":{"200":{"description":"Action applied. Returns the resulting document.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsWarehouseTransferDetailResponse"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"409":{"description":"Stale-version conflict (E0602) or an invalid transition for the current status (E9579)."},"500":{"description":"Internal server error"}}}},"/wms/internal-transfers/validate":{"post":{"summary":"Validate Internal Transfer","description":"Side-effect-free validate for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInternalTransferRequest"}}}},"responses":{"200":{"description":"Valid validate request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsInternalValidateOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/internal-transfers/preview":{"post":{"summary":"Preview Internal Transfer","description":"Side-effect-free preview for a create request: runs the same authorization and validation steps as a real create but performs NO writes and emits NO events. Authorization gate: `scope.transfer`.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInternalTransferRequest"}}}},"responses":{"200":{"description":"Valid preview request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WmsInternalPreviewOkResponse"}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/locations":{"get":{"summary":"List Locations","description":"Returns paginated warehouse locations scoped to the acting warehouse. Tenant context is proxy-injected; only locations inside the acting warehouse are returned — cross-warehouse/cross-tenant access resolves as not-found/denied. Search by code/name (q), active/inactive filter, pagination, sorting by created_at/updated_at/code/name.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"q","in":"query","required":false,"description":"Search query for location code or name (max 200 characters after trimming)","schema":{"type":"string","maxLength":200}},{"name":"filters[is_active][$eq]","in":"query","required":false,"description":"Filter by active state","schema":{"type":"boolean"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50,"default":20}},{"name":"sort","in":"query","required":false,"description":"Sort by column:asc or column:desc. Default: created_at:desc","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicWmsLocationSummary"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/wms/locations/{code}":{"get":{"summary":"Get Location Detail","description":"Returns a single warehouse location resolved by code inside the acting warehouse, including public parent/child references suitable for document line payloads. Wrong-tenant or wrong-warehouse access resolves as not-found with no existence leak.","tags":["WMS"],"parameters":[{"name":"warehouse_code","in":"query","required":true,"description":"Warehouse code (also accepted as X-Warehouse-Code header)","schema":{"type":"string"}},{"name":"X-Warehouse-Code","in":"header","required":false,"description":"Alternative to warehouse_code query parameter","schema":{"type":"string"}},{"name":"code","in":"path","required":true,"description":"Document code.","schema":{"type":"string"}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicWmsLocationDetail"}},"required":["data"]}}},"headers":{"X-Server-Time":{"description":"ISO 8601 server timestamp.","schema":{"type":"string","format":"date-time"}}}},"400":{"description":"Missing warehouse context, invalid JSON body, or validation error"},"401":{"description":"Missing or invalid API key"},"403":{"description":"Tenant access denied or insufficient warehouse permissions"},"404":{"description":"Warehouse, document, or variant not found"},"500":{"description":"Internal server error"}}}},"/mission/tasks/code/{code}":{"get":{"summary":"Get Task Detail (by Code)","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}},{"name":"include_archived","in":"query","required":false,"description":"Read an archived task by its code. Omitted or `false` keeps today's answer — 404 for an archived task. `true` resolves the archived row as well, through the same tenant gate. Any other value returns HTTP 400 with `INVALID_INCLUDE_ARCHIVED`.","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Malformed `include_archived` value","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"invalidIncludeArchived":{"summary":"Malformed include_archived value","value":{"error":{"code":"INVALID_INCLUDE_ARCHIVED","message":"include_archived must be true or false"}}}}}}},"404":{"description":"Task not found"}},"description":"Retrieve a single task by code. An archived task answers 404 unless the request carries `include_archived=true`."},"patch":{"summary":"Update Task (by Code)","description":"Partial update of a task by its natural code. Any subset of fields may be provided; at least one is required. Same contract as PATCH /mission/tasks/{id}.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"additionalProperties":false,"properties":{"status":{"type":"string","enum":["Pending","To-Do","Doing","Done","Cancelled"]},"assignee_id":{"type":"string","format":"uuid","nullable":true,"description":"Set to null to unassign."},"board_id":{"type":"string","format":"uuid","nullable":true,"description":"Must be provided together with board_list_id. Both null removes the task from its board."},"board_list_id":{"type":"string","format":"uuid","nullable":true,"description":"Must be provided together with board_id."},"title":{"type":"string","minLength":1,"maxLength":500},"due_date":{"type":"string","format":"date-time","nullable":true,"description":"Set to null to clear the due date. Must be today or in the future."},"description":{"type":"string","maxLength":10000,"nullable":true},"follower_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Users to add as followers. Additive and idempotent: a listed user who already follows the task is left alone."},"follower_remove_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Users to remove from the followers. Removing a user who does not follow the task is a no-op. Must not repeat an id from follower_ids (400)."},"label_names":{"allOf":[{"$ref":"#/components/schemas/LabelNames"}],"description":"Labels to add. Additive and idempotent: a label the task already carries is left alone, and a name no label has yet creates one."},"label_remove_names":{"allOf":[{"$ref":"#/components/schemas/LabelNames"}],"description":"Labels to remove. A name the task does not carry is a no-op. Applied after label_names, so a name in both lists ends removed."}}}}}},"responses":{"200":{"description":"Updated task","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error (empty body, invalid status, invalid assignee/followers, board mismatch), or `E9117` for an invalid name in `label_names` or `label_remove_names` (nothing is written)"},"403":{"description":"Insufficient permission to perform the update"},"404":{"description":"Task not found"}}},"delete":{"summary":"Delete Task (by Code)","description":"Same contract as `DELETE /mission/tasks/{id}`, with the task addressed by its natural code instead of its UUID. The code is resolved inside the tenant first, so an unknown or foreign code answers `404` before anything else runs.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42), resolved within the tenant"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task was retired; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"A body was sent (delete takes no fields), the body is not valid JSON, or X-Tenant-Code is missing (a code lookup needs the tenant)"},"403":{"description":"The caller is not the task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant"},"404":{"description":"Task not found — including a task that is already deleted, and another tenant's task"}}}},"/mission/tasks/code/{code}/comments":{"get":{"summary":"List Task Comments (by Code)","description":"Read-only chronological timeline of a task's conversation (human comments) and activity (status/assignment/etc. system messages). Returns 200 with an empty list when the task has no comments yet.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"type","in":"query","required":false,"description":"Filter the timeline. Omit to return both.","schema":{"type":"string","enum":["comment","activity"]}},{"name":"sort","in":"query","required":false,"description":"Order by created_at. Default desc (newest first).","schema":{"type":"string","enum":["asc","desc"],"default":"desc"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"description":"Page size. Values > 50 are rejected with 400 (not clamped).","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Task comment / activity timeline","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskCommentResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}}}}},"400":{"description":"Invalid query parameter (type, sort, page, or limit)"},"404":{"description":"Task not found"}}},"post":{"summary":"Create Task Comment (by Code)","description":"Add a comment to a task addressed by its taskCode. The same two bodies as POST /mission/tasks/{id}/comments: a `multipart/form-data` body of `content` plus one `file` part per attachment — stored and recorded by the server, no presigned URL — or the unchanged JSON body of `content` and/or pre-uploaded `attachments`. On the multipart body an optional `Idempotency-Key` makes a retry safe: 201 the first time, 200 when the same key replays the same comment, 409 (E0601) when the key meets a different one. Requires `X-Tenant-Code`.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g., ACME-42)"},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key, scoped to the tenant, the task and the caller. Read on a `multipart/form-data` body: the first application returns 201, a replay of the same key with the same comment returns 200 with the comment already written, and the same key with a different comment returns 409 (E0601). Two requests carrying the same key at the same time are decided by the unique index on the message, and the one that loses answers with the winner's result — 200 with its comment, or 409 when the key landed as a different comment — deleting nothing the winner may hold; when the read that decides cannot complete, the loser is answered as the failure it hit rather than guessed at. A JSON body does not carry a key.","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Required: a task code is unique within a tenant, not across them.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"content":{"type":"string","description":"Comment text. May be omitted when at least one `file` part is sent."},"file":{"type":"string","format":"binary","description":"A file to attach. Repeat the part for more than one file (at most 10)."}}}},"application/json":{"schema":{"$ref":"#/components/schemas/PostTaskCommentRequest"}}}},"responses":{"200":{"description":"Replay: the same Idempotency-Key was applied with the same comment — the comment the earlier attempt wrote, and nothing stored a second time. Multipart bodies only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTaskCommentResponse"}}}},"201":{"description":"Comment created","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTaskCommentResponse"}}}},"400":{"description":"Invalid request body: missing `X-Tenant-Code`, neither text nor a file, more than 10 files (`TOO_MANY_FILES`), a file type outside the allow-list (`INVALID_FILE_TYPE`), or an empty or oversized file (`INVALID_FILE_SIZE`)"},"403":{"description":"Caller lacks task visibility"},"404":{"description":"Task not found"},"409":{"description":"The Idempotency-Key was already used to post a different comment to this task (E0601). Multipart bodies only."}}}},"/mission/tasks/code/{code}/history":{"get":{"summary":"List Task Status History by Code","description":"Every status change a task has been through, newest first, with the member who made each one — the same read as `/mission/tasks/{id}/history`, addressed by the task's natural code instead of its UUID. `X-Tenant-Code` is required, because a code is only unique within its tenant. The order is fixed newest first, so **`sort` is refused with HTTP 400 `INVALID_QUERY_PARAMS`**, exactly as on the UUID address.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code, e.g. ACME-42"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1}},{"name":"limit","in":"query","required":false,"description":"Page size. Values > 50 are rejected with 400 (not clamped).","schema":{"type":"integer","minimum":1,"maximum":50,"default":20}},{"name":"X-Tenant-Code","in":"header","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Task status history, newest first","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicTaskStatusHistoryListResponse"}}}},"400":{"description":"Missing X-Tenant-Code, an invalid page or limit, or a `sort` — this read is always newest first and has no sort to expose","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"examples":{"sortRefused":{"summary":"A sort on a read whose order is fixed","value":{"error":{"code":"INVALID_QUERY_PARAMS","message":"this read is always newest first and takes no sort parameter"}}}}}}},"404":{"description":"Task not found"}}}},"/mission/tasks/code/{code}/subtasks":{"post":{"summary":"Create Subtasks (by Code)","tags":["Tasks"],"description":"Create batch subtasks (up to 25) under a parent task resolved by code. All-or-nothing validation.","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubtasksRequest"}}}},"responses":{"201":{"description":"Subtasks created successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"parent_task":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string"},"title":{"type":"string"}}},"subtasks":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}}}}},"400":{"description":"Validation error (all-or-nothing) — nothing was created. Possible codes: SUBTASK_VALIDATION_ERROR, SUBTASK_INVALID, INVALID_ASSIGNEE. PARENT_TASK_NOT_FOUND and PARENT_TASK_ARCHIVED exist in the service layer but are not reachable here: the parent lookup answers 404 first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Permission denied — not parent task owner or tenant owner, or not a tenant member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Parent task not found — unknown, archived, or in another tenant. The Public API treats an archived task as absent, so the parent lookup answers before the PARENT_TASK_ARCHIVED branch can run.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Tasks"],"summary":"List Subtasks (by Code)","description":"List subtasks of a parent task by code (e.g. ACME-42).","operationId":"listSubtasksByCode","parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated subtask list","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskResponse"}},"meta":{"$ref":"#/components/schemas/MetaPagination"}}},"examples":{"twoSubtasks":{"summary":"Parent task (ACME-42) with 2 subtasks","value":{"data":[{"id":"550e8400-e29b-41d4-a716-446655440001","code":"TASK-124","title":"Check server logs","description":null,"status":"To-Do","priority":"High","assignee":{"id":"123e4567-e89b-12d3-a456-426614174000","display_name":"Alice","member_code":"MEM-ALICE","email":"alice@example.com"},"owner":{"id":"123e4567-e89b-12d3-a456-426614174001","display_name":"Bob","member_code":"MEM-BOB","email":"bob@example.com"},"board_id":null,"board_list_id":null,"due_date":"2026-07-15T00:00:00.000Z","parent":{"id":"550e8400-e29b-41d4-a716-446655440000","code":"TASK-123","title":"Alert: System Monitor"},"has_subtasks":false,"attachments":[]},{"id":"550e8400-e29b-41d4-a716-446655440002","code":"TASK-125","title":"Notify on-call team","description":"Send Slack notification","status":"Doing","priority":"Urgent","assignee":null,"owner":{"id":"123e4567-e89b-12d3-a456-426614174001","display_name":"Bob"},"board_id":null,"board_list_id":null,"due_date":null,"parent":{"id":"550e8400-e29b-41d4-a716-446655440000","code":"TASK-123","title":"Alert: System Monitor"},"has_subtasks":false,"attachments":[]}],"meta":{"page":1,"limit":2,"total":2,"has_more":false}}}}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Parent task not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/mission/tasks/code/{code}/subtasks/{subtaskId}":{"delete":{"summary":"Delete a Subtask (by Code)","description":"Same contract as `DELETE /mission/tasks/{id}/subtasks/{subtaskId}`, with the parent task addressed by its natural code instead of its UUID.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Parent task code (e.g. ACME-42)"},{"name":"subtaskId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Subtask UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The subtask was retired; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"A body was sent (delete takes no fields), the body is not valid JSON, or X-Tenant-Code is missing (a code lookup needs the tenant)"},"403":{"description":"The caller is not the parent task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant — a subtask's own assignee is refused"},"404":{"description":"Parent task or subtask not found — unknown, archived, another tenant's, or a subtask that belongs to a different parent"}}},"patch":{"summary":"Reorder a Subtask (by Code)","description":"Same contract as `PATCH /mission/tasks/{id}/subtasks/{subtaskId}`, with the parent task addressed by its natural code instead of its UUID.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Parent task code (e.g. ACME-42)"},{"name":"subtaskId","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Subtask UUID"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["after_subtask_id"],"additionalProperties":false,"properties":{"after_subtask_id":{"type":"string","format":"uuid","nullable":true,"description":"Place the subtask immediately after this sibling; null places it first."}}}}}},"responses":{"200":{"description":"The subtask after the move, with its server-computed `position`","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error: a missing or unknown field, `after_subtask_id` that is not a UUID, or `after_subtask_id` naming the subtask itself, or X-Tenant-Code is missing (a code lookup needs the tenant)"},"403":{"description":"Permission denied — the caller may not edit the subtask"},"404":{"description":"Parent task or subtask not found — unknown, archived, another tenant's, a subtask that belongs to a different parent, or an `after_subtask_id` that is a live sibling under another parent. An `after_subtask_id` that is archived, deleted, or in another tenant is not found either, but does not fail: it places the subtask first among its siblings, the same fall-back the card move makes."}}}},"/mission/tasks/code/{code}/actions/transfer-ownership":{"post":{"summary":"Transfer Task Ownership (by Code)","description":"Same contract as `POST /mission/tasks/{id}/actions/transfer-ownership`, with the task addressed by its natural code. The task's current owner, active current-board owner (parent's board for a subtask), or a tenant owner of the task's own tenant may call it, `owner_id` must be an active member of the task's tenant, and the task must be live.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["owner_id"],"additionalProperties":false,"properties":{"owner_id":{"type":"string","format":"uuid","description":"User id of the new owner — an active member of the task's tenant."}}}}}},"responses":{"200":{"description":"The task after the transfer","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Missing owner_id, or INVALID_OWNER: the id does not name an active member of the task's tenant"},"403":{"description":"The caller is not the task's current owner, active current-board owner or tenant owner"},"404":{"description":"Task not found"},"409":{"description":"TASK_NOT_TRANSFERABLE: another ownership change won the race, or the database refused the transfer — re-read the task and retry"}}}},"/mission/tasks/code/{code}/actions/archive":{"post":{"summary":"Archive Task (by Code)","description":"Same contract as `POST /mission/tasks/{id}/actions/archive`, with the task addressed by its natural code instead of its UUID. The code is resolved inside the tenant first, so an unknown or foreign code answers `404` before anything else runs.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42), resolved within the tenant"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task was archived; `data.id` names it","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string","format":"uuid"}},"required":["id"]}},"required":["data"]}}}},"400":{"description":"A body was sent (archive takes no fields), the body is not valid JSON, or X-Tenant-Code is missing (a code lookup needs the tenant)"},"403":{"description":"The caller is not the task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant — for a subtask, not the parent's owner, assignee or active current-board owner"},"404":{"description":"Task not found — including a task that is already archived, and another tenant's task"}}}},"/mission/tasks/code/{code}/actions/unarchive":{"post":{"summary":"Unarchive Task (by Code)","description":"Same contract as `POST /mission/tasks/{id}/actions/unarchive`, with the task addressed by its natural code instead of its UUID. The code is resolved inside the tenant first, and archived rows are resolvable here — unlike a read, which answers `404` for them.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42), resolved within the tenant"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task, restored","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}},"required":["data"]}}}},"400":{"description":"A body was sent (unarchive takes no fields), the body is not valid JSON, or X-Tenant-Code is missing (a code lookup needs the tenant)"},"403":{"description":"The caller is not the task's owner, its assignee, its active current-board owner, or a tenant owner of its tenant"},"404":{"description":"Task not found — including another tenant's task, a deactivated caller, and a task that stopped being archived before the write"},"409":{"description":"TASK_NOT_RESTORABLE: another call restored the task first, or the database refused the change — re-read the task"}}}},"/mission/tasks/code/{code}/actions/move":{"post":{"summary":"Move Task (by Code)","description":"Place a top-level task in a board list: at the top of it, or behind a task already there. The caller must be the task's **owner**, its **assignee**, a **tenant owner of the task's tenant**, or an **active member of the task's board** — the task-edit rule, which the service checks before it writes, so a caller who may read the task but not move it gets the same 404 a missing task gets. A move that changes the board needs destination membership unless the caller owns the task, is assigned to it, actively owns its current source board, or owns the tenant; destination visibility remains unchanged. Exactly one placement per request: `after_task_id` (a task in the target list) or `position` = `\"top\"`. A subtask answers 400 — it has no board placement — and an archived task, or a target list that is archived, deleted or in another tenant, answers 404. An `after_task_id` that was archived between the read and the call places the task at the top instead of failing: the fallback the board's own drag relies on. An `after_task_id` naming the task being moved is 400 before anything is read: a card cannot be placed behind itself. The position is computed by one atomic, row-locking RPC, so no `Idempotency-Key` is needed or accepted — a retry lands the card in the same place.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["board_list_id"],"additionalProperties":false,"properties":{"board_list_id":{"type":"string","format":"uuid","description":"Destination board list."},"after_task_id":{"type":"string","format":"uuid","description":"Place the task immediately after this task, which must be in the destination list."},"position":{"type":"string","enum":["top"],"description":"Place the task first in the destination list."}},"oneOf":[{"required":["after_task_id"]},{"required":["position"]}]}}}},"responses":{"200":{"description":"The task after the move","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error: both placements or neither, `position` other than `top`, a malformed list or task id, an unknown field, or SUBTASK_BOARD_FORBIDDEN for a subtask"},"404":{"description":"Task not found, not movable by this caller, or the destination list is not readable for them"}}}},"/mission/tasks/code/{code}/actions/promote":{"post":{"summary":"Promote Subtask to Task (by Code)","description":"Turn a subtask into a top-level task on the same row: it keeps its code, status, assignee, due date, followers, comments, files and history, and leaves its parent. The caller must be the subtask's **owner**, its **assignee**, the **active owner of its parent's current board**, or a **tenant owner of its tenant** — the rule the screen applies, enforced by the same database function. A caller who may not promote gets the same 404 a missing task gets, as the other task actions answer. `board_list_id` names a live list of any live board of the tenant; the board is read from the list, and the task is placed at the end of it. The task's owner and assignee become members of that board. `board_list_id: null`, `{}` or an empty body promote it to no board. An archived or deleted subtask answers `409 SUBTASK_NOT_PROMOTABLE`. A list that is deleted, archived, on a deleted board or not in this tenant answers `400 DESTINATION_UNAVAILABLE`. A task that is already top-level answers 200 unchanged and records nothing, so a retry needs no `Idempotency-Key`: the call ignores `board_list_id` and returns the task as it stands, so compare its `board_list_id` with the one sent. On success one `task:subtask:promoted` event is recorded. A `code/{code}` sibling of this operation: the task is resolved by its natural code instead of its UUID, and every other rule is identical.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"board_list_id":{"type":"string","nullable":true,"format":"uuid","description":"Destination list; its board is read from it. Null or absent promotes the subtask to no board."}}}}}},"responses":{"200":{"description":"The task after the promotion, in the shape `GET /mission/tasks/{id}` returns","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Validation error (a malformed `board_list_id`, an unknown field, a body that is not JSON), X-Tenant-Code missing (`TENANT_CODE_REQUIRED`: a code lookup needs the tenant), or DESTINATION_UNAVAILABLE: the list is not a live list of a live board of this tenant"},"401":{"description":"Missing or invalid API key"},"404":{"description":"Task not found, in another tenant, or not promotable by this caller"},"409":{"description":"SUBTASK_NOT_PROMOTABLE: the subtask is archived or deleted"}}}},"/mission/tasks/code/{code}/actions/claim":{"post":{"summary":"Claim Task (by Code)","description":"Assign an unassigned task to the caller. Any **active member of the task's tenant** may claim it — no owner or manager role is needed — and the assignee is always the authenticated caller, taken from the key: the body carries no fields, so a request can never assign the task to somebody else. Only an unassigned, live task can be claimed. An archived task answers `404` like every other task endpoint. A task that already has an assignee answers `409 TASK_ALREADY_ASSIGNED`, which is the same answer whether another member claimed it first or the caller already holds it — that is why no `Idempotency-Key` is accepted, and why the answer tells the caller to re-read the task. The write sets `assignee_id` alone, so ownership, followers, status and board placement stay as they were, and one `task:assigned` event is recorded. A `code/{code}` sibling of this operation: the task is resolved by its natural code instead of its UUID, and every other rule is identical.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42), resolved within the tenant"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"The task with the caller as its assignee","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"A body was sent (claim takes no fields), the body is not valid JSON, or X-Tenant-Code is missing (a code lookup needs the tenant)"},"403":{"description":"The caller's membership of the task's tenant changed between the gate and the write. A caller who is simply not a member is answered 404 by the tenant gate before the service runs"},"404":{"description":"Task not found"},"409":{"description":"TASK_ALREADY_ASSIGNED: the task is not unassigned — another member claimed it, or the caller already did; re-read the task to see its assignee"}}}},"/mission/tasks/code/{code}/actions/assign-agent":{"post":{"summary":"Assign Task To Agent (by Code)","description":"Same contract as `POST /mission/tasks/{id}/actions/assign-agent`, with the task addressed by its natural code. The task must already be agent-responsible and its agent task must still be `pending`; a task assigned to a person is rejected with 400.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"},"description":"Task code (e.g. ACME-42)"},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["agent_key"],"additionalProperties":false,"properties":{"agent_key":{"type":"string","minLength":1,"description":"Key of the agent to move the task to, e.g. task-pilot."}}}}}},"responses":{"200":{"description":"The task after the reassignment","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}},"400":{"description":"Missing agent_key, or INVALID_AGENT: the task is not agent-responsible, its agent task is not pending, or the key names no published agent in this tenant"},"403":{"description":"Insufficient permission to reassign the task"},"404":{"description":"Task not found"}}}},"/mission/tasks/code/{code}/attachments/{attachmentId}/download":{"get":{"summary":"Download Task Attachment (by Code)","description":"Returns a short-lived (5 minute) signed URL for a task-level attachment as JSON data, never an HTTP redirect. Task-scoped: only members of the task's own tenant, for attachments that belong to that task, can download.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"description":"Task code (e.g. ACME-42)","schema":{"type":"string"}},{"name":"attachmentId","in":"path","required":true,"description":"Attachment ID.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Signed download URL and file metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAttachmentDownloadResponse"}}}},"404":{"description":"Task or attachment not found"},"500":{"description":"Signed URL generation failed"}}}},"/mission/tasks/code/{code}/attachments":{"post":{"summary":"Upload Task Attachment (by Code)","description":"The `code/{code}` sibling of `POST /mission/tasks/{id}/attachments`: the task is addressed by its natural code instead of its UUID, and every other rule is identical — a `multipart/form-data` body whose `file` part carries the bytes, the web UI's MIME allow-list and 50 MB size limit, the same permission set, and the same optional `Idempotency-Key`. Requires `X-Tenant-Code`.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"description":"Task code (e.g. ACME-42)","schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Optional idempotency key, scoped to the tenant and the task. First application returns 201; a replay of the same key with the same file returns 200 with the attachment already stored; the same key with a different file returns 409 (E0601).","schema":{"type":"string"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Required: a task code is unique within a tenant, not across them.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"The file. Its part header supplies the file name and the Content-Type the allow-list is checked against."}}}}}},"responses":{"200":{"description":"Replay: the same Idempotency-Key was applied with the same file — the attachment stored by the earlier attempt, and nothing written.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskAttachment"}}}}}},"201":{"description":"The file is stored and recorded on the task.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskAttachment"}}}}}},"400":{"description":"Missing `X-Tenant-Code`, or a request refused before anything was stored (`INVALID_CONTENT_TYPE`, `MISSING_FILE_PART`, `INVALID_FILE_TYPE`, `INVALID_FILE_SIZE`)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The caller may not edit this task"},"404":{"description":"Task not found — the answer for a malformed code, an unknown code, and another tenant's task alike (no existence leak)"},"409":{"description":"The Idempotency-Key was already used to attach a different file to this task (E0601)"},"500":{"description":"The storage write or the metadata write failed — nothing is left behind"}}}},"/mission/tasks/code/{code}/attachments/{attachmentId}":{"delete":{"summary":"Remove Task Attachment (by Code)","description":"The `code/{code}` sibling of `DELETE /mission/tasks/{id}/attachments/{attachmentId}`: the task is addressed by its natural code instead of its UUID, and every other rule is identical — the permission set, the answer naming the removed file, and the `404` a repeat answers. Requires `X-Tenant-Code`.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"description":"Task code (e.g. ACME-42)","schema":{"type":"string"}},{"name":"attachmentId","in":"path","required":true,"description":"Attachment id, from a task read's `attachments[].id` or from the upload response.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Required: a task code is unique within a tenant, not across them.","schema":{"type":"string"}}],"responses":{"200":{"description":"The attachment was removed, and this is what it was.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicTaskAttachment"}}}}}},"400":{"description":"Missing `X-Tenant-Code`","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"The caller may not edit this task"},"404":{"description":"Task not found — the answer for a malformed code, an unknown code, and another tenant's task alike (no existence leak) — or the task holds no attachment with that id, which is also what a repeated DELETE answers"},"500":{"description":"The metadata write failed — the attachment is still on the task"}}}},"/mission/tasks/code/{code}/comments/attachments/{attachmentId}/download":{"get":{"summary":"Download Comment Attachment (by Code)","description":"Returns a short-lived (5 minute) signed URL for a comment/message-level attachment as JSON data, never an HTTP redirect. Tenant-scoped, NOT task-scoped: any active member of this task's tenant can download this attachment, regardless of whether they have visibility into the specific task it was posted to. Cross-tenant access is not possible — this is a tenant-scope, not task-scope, guarantee.","tags":["Tasks"],"parameters":[{"name":"code","in":"path","required":true,"description":"Task code (e.g. ACME-42)","schema":{"type":"string"}},{"name":"attachmentId","in":"path","required":true,"description":"Attachment ID.","schema":{"type":"string","format":"uuid"}},{"name":"X-Tenant-Code","in":"header","schema":{"type":"string"}}],"responses":{"200":{"description":"Signed download URL and file metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicAttachmentDownloadResponse"}}}},"404":{"description":"Task or attachment not found"},"500":{"description":"Signed URL generation failed"}}}},"/pcms/variants/lookup":{"post":{"summary":"Look Up Variants by SKU and Barcode (batch)","tags":["PCMS"],"description":"Look up many variants at once, for an import or a POS sync. Send up to **100 codes in total** (`skus` and `barcodes` together, counted after trimming and removing repeats; each code at most 200 characters). A missing or null array counts as empty; a blank entry is dropped. Codes are trimmed and then matched **exactly, case included**. Answers the variants found, in the `GET /pcms/variants/{id}` shape, and the codes that matched nothing under `not_found`; a variant matched by both a SKU and a barcode appears once. Orphaned and deleted variants are never returned, and an `inactive` variant is returned with `status: \"inactive\"`. Items carry the product's `currency` and `media: []` (use the variant read for images). **Freshness:** this reads the catalog search index, which refreshes about once a minute, so a variant created or deleted in the last minute may be missing or still listed; `GET /pcms/variants/resolve` reads the live tables. This is a read: it takes no `Idempotency-Key`, and sending it again writes nothing.","parameters":[{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to look in. Required: a SKU, barcode or manufacturer code is unique per tenant, not globally.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"skus":{"type":"array","items":{"type":"string","maxLength":200},"description":"SKUs to look up."},"barcodes":{"type":"array","items":{"type":"string","maxLength":200},"description":"Barcodes to look up."}}}}}},"responses":{"200":{"description":"The variants found and the codes that matched nothing","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"variants":{"type":"array","items":{"$ref":"#/components/schemas/PublicProductVariantResponse"}},"not_found":{"type":"object","properties":{"skus":{"type":"array","items":{"type":"string"}},"barcodes":{"type":"array","items":{"type":"string"}}}}}}}}}}},"400":{"description":"The body is not JSON (`INVALID_JSON_BODY`), is not an object, holds no code, holds more than 100 codes, has a code over 200 characters or a non-string code (`VALIDATION_ERROR`), or `X-Tenant-Code` is missing"},"403":{"description":"The key's user is not an active member of the tenant"},"500":{"description":"The lookup failed; no partial answer is returned"}}}},"/pcms/variants/resolve":{"get":{"summary":"Resolve a Code to One Variant","tags":["PCMS"],"description":"Turn one code of any kind into one variant, the way the PCMS scan screen does. The code is trimmed and tried in this order, stopping at the first live variant: SKU, the SKU inside a QR link, barcode, manufacturer code. For each kind an exact match is tried first, then a case-insensitive one, so an exact-case row wins. `matched_by` is `sku`, `qr_url`, `barcode` or `manufacturer_code`. The answer is in the `GET /pcms/variants/{id}` shape and reads the live tables. **Encoding:** pass `code` percent-encoded; a QR link's own `?` and `&` and a `+` in a code (`%2B`) are otherwise cut or read as a space. `404` means no live, non-orphaned variant matched: an unknown code, an orphaned variant (even when a live variant would match the same code by barcode or manufacturer code), or a retired SKU, which answers with code `E9483` and names the old SKU in the message so an out-of-date label can be told from an unknown code. Under a database failure inside a lookup step the resolver reads the step as a miss, so a `404` is not proof the code is unknown; a failure the resolver itself reports is `500`. An `inactive` variant resolves with `status: \"inactive\"`.","parameters":[{"name":"code","in":"query","required":true,"description":"The code to resolve, once, at most 2048 characters. Percent-encode it.","schema":{"type":"string","maxLength":2048}},{"name":"X-Tenant-Code","in":"header","required":true,"description":"Tenant to look in. Required: a SKU, barcode or manufacturer code is unique per tenant, not globally.","schema":{"type":"string"}}],"responses":{"200":{"description":"The variant and which identifier matched","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"variant":{"$ref":"#/components/schemas/PublicProductVariantResponse"},"matched_by":{"type":"string","enum":["sku","qr_url","barcode","manufacturer_code"]}}}}}}}},"400":{"description":"`code` is missing, blank, repeated or over 2048 characters (`VALIDATION_ERROR`), or `X-Tenant-Code` is missing"},"403":{"description":"The key's user is not an active member of the tenant"},"404":{"description":"No live, non-orphaned variant matched; `E9483` marks a retired SKU"},"500":{"description":"The resolver reported an error"}}}},"/pcms/variants/sku/{sku}":{"get":{"summary":"Get Variant Detail (by SKU)","tags":["PCMS"],"description":"Fetch a single product variant by SKU (natural key). Tenant-scoped. Orphaned, soft-deleted, and cross-tenant variants return 404.","parameters":[{"name":"sku","in":"path","required":true,"schema":{"type":"string"},"description":"Variant SKU (e.g. SKU-001)"},{"name":"X-Tenant-Code","in":"header","description":"Scope to a tenant. If omitted, the variant is resolved across all tenants the caller belongs to.","schema":{"type":"string"}}],"responses":{"200":{"description":"Variant detail","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PublicProductVariantResponse"}}}}}},"404":{"description":"Variant not found"}}}},"/mission/tasks/with-subtasks":{"post":{"summary":"Create Task with Subtasks","tags":["Tasks"],"description":"Create a task together with its subtasks in a single atomic request. All-or-nothing validation: if any part fails, nothing is created. Max 25 subtasks per request.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTaskWithSubtasksRequest"}}}},"responses":{"201":{"description":"Task and subtasks created successfully","headers":{"X-Server-Time":{"schema":{"type":"string","format":"date-time"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"task":{"$ref":"#/components/schemas/PublicTaskResponse"},"subtasks":{"type":"array","items":{"$ref":"#/components/schemas/PublicTaskResponse"}}}}}}}}},"400":{"description":"Validation error (all-or-nothing) — nothing was created. Possible codes: VALIDATION_ERROR, MISSING_TENANT, INVALID_ASSIGNEE, INVALID_FOLLOWERS, BOARD_NOT_FOUND, SUBTASK_VALIDATION_ERROR.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Permission denied — not a tenant member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}}}